diff --git a/.github/build-config.json b/.github/build-config.json index 52a62f7c..3692a871 100644 --- a/.github/build-config.json +++ b/.github/build-config.json @@ -7,5 +7,5 @@ {"name": "gfx120x", "pytorch_whl": "gfx120X-all", "rocm_sdk": "gfx120x"} ], "default_gpu_target": "gfx1151", - "courses": ["CV", "DL", "LLM", "PhySim"] + "courses": ["CV", "DL", "LLM", "PhySim", "Finetuning", "LocalInference"] } diff --git a/.gitignore b/.gitignore index 9f55d1c8..da3e75c1 100644 --- a/.gitignore +++ b/.gitignore @@ -388,6 +388,7 @@ vite.config.ts.timestamp-* # Project specific dockerfiles/Courses/DL/data/FashionMNIST/raw/ *.vsix +projects/LocalInference/sweep_results/ # Local config overrides runtime/values.local.yaml diff --git a/auplc_installer/catalog.py b/auplc_installer/catalog.py index a115db10..1629926f 100644 --- a/auplc_installer/catalog.py +++ b/auplc_installer/catalog.py @@ -51,6 +51,27 @@ class Course: Course("Course-DL", "auplc-dl", True, "dl", "Deep Learning Course"), Course("Course-LLM", "auplc-llm", True, "llm", "Large Language Model Course"), Course("Course-PhySim", "auplc-physim", True, "physim", "Physical Simulation Course"), + Course( + "Course-Finetuning", + "auplc-finetuning", + True, + "finetuning", + "Fine-tuning on GPUs: from cloud to robot", + ), + Course( + "Course-LocalInference", + "auplc-localinference", + True, + "local-inference", + "Local inference of embodied AI", + ), + Course( + "Course-RLLearning", + "auplc-rl-learning", + True, + "rl-learning", + "Reinforcement learning for robotics", + ), ) COURSE_KEYS_ALL: tuple[str, ...] = tuple(c.key for c in COURSE_CATALOG) @@ -81,7 +102,16 @@ class Course: BASE_TEAM_MAPPING: dict[str, list[str]] = { "cpu": ["cpu", "code-cpu"], - "gpu": ["code-gpu", "Course-CV", "Course-DL", "Course-LLM", "Course-PhySim"], + "gpu": [ + "code-gpu", + "Course-CV", + "Course-DL", + "Course-LLM", + "Course-PhySim", + "Course-Finetuning", + "Course-LocalInference", + "Course-RLLearning", + ], "official": [ "cpu", "gpu", @@ -91,8 +121,19 @@ class Course: "Course-DL", "Course-LLM", "Course-PhySim", + "Course-Finetuning", + "Course-LocalInference", + "Course-RLLearning", + ], + "AUP": [ + "Course-CV", + "Course-DL", + "Course-LLM", + "Course-PhySim", + "Course-Finetuning", + "Course-LocalInference", + "Course-RLLearning", ], - "AUP": ["Course-CV", "Course-DL", "Course-LLM", "Course-PhySim"], "native-users": [ "code-cpu", "code-gpu", @@ -100,6 +141,9 @@ class Course: "Course-DL", "Course-LLM", "Course-PhySim", + "Course-Finetuning", + "Course-LocalInference", + "Course-RLLearning", "cpu", "gpu", ], @@ -112,6 +156,9 @@ class Course: "Course-DL", "Course-LLM", "Course-PhySim", + "Course-Finetuning", + "Course-LocalInference", + "Course-RLLearning", ], } diff --git a/auplc_installer/overlay.py b/auplc_installer/overlay.py index e86c0b88..749b73ac 100644 --- a/auplc_installer/overlay.py +++ b/auplc_installer/overlay.py @@ -36,6 +36,9 @@ "Course-DL": "auplc-dl", "Course-LLM": "auplc-llm", "Course-PhySim": "auplc-physim", + "Course-Finetuning": "auplc-finetuning", + "Course-LocalInference": "auplc-localinference", + "Course-RLLearning": "auplc-rl-learning", } diff --git a/dockerfiles/Courses/Finetuning/Dockerfile b/dockerfiles/Courses/Finetuning/Dockerfile new file mode 100644 index 00000000..4acb467a --- /dev/null +++ b/dockerfiles/Courses/Finetuning/Dockerfile @@ -0,0 +1,281 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +ARG BASE_IMAGE=ghcr.io/amdresearch/auplc-base:latest +# Selects the final stage: "without-assets" (default, code-only image for CI/registry) or +# "with-assets" (workshop image with the dataset/model baked in). build.sh sets this to +# with-assets and overrides the "assets" context below when ASSETS_SRC is provided. +ARG FINAL=without-assets + +# Optional pre-staged assets context. Empty by default; overridden at build time with +# `--build-context assets=`. Only read by the with-assets stage, so the +# default (no assets) build never touches it. +FROM scratch AS assets + +FROM ${BASE_IMAGE} AS base + +USER root +SHELL ["/bin/bash", "-c"] + +ARG MOLMOACT2_COMMIT=cdf4b7728e0545366bf3366639fd86477ef59d04 +ARG LEROBOT_COMMIT=a4f15bf347dee7eb8a8c5f4a70a37f476b091113 + +ENV DEBIAN_FRONTEND=noninteractive \ + HF_HUB_DISABLE_TELEMETRY=1 \ + HF_HUB_DISABLE_XET=1 \ + HF_HUB_ENABLE_HF_TRANSFER=0 \ + TOKENIZERS_PARALLELISM=false \ + TORCH_BLAS_PREFER_HIPBLASLT=0 \ + TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + git \ + libegl-dev \ + libgles-dev \ + libglib2.0-0t64 \ + libglew-dev \ + libosmesa6-dev \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone https://github.com/allenai/molmoact2 /opt/molmoact2 \ + && git -C /opt/molmoact2 checkout "${MOLMOACT2_COMMIT}" \ + && mkdir -p /opt/lerobot-allenai \ + && git -C /opt/lerobot-allenai init \ + && git -C /opt/lerobot-allenai remote add origin https://github.com/allenai/lerobot.git \ + && git -C /opt/lerobot-allenai fetch --depth 1 origin "${LEROBOT_COMMIT}" \ + && git -C /opt/lerobot-allenai checkout --detach FETCH_HEAD + +# Keep the ROCm torch supplied by auplc-base while installing the MolmoAct2 +# training and headless LIBERO stacks in the environment used by Jupyter. +RUN python3 -m venv --system-site-packages /opt/train-venv \ + && python3 -c "from importlib.metadata import version; \ +print('torch==' + version('torch')); \ +print('torchvision==' + version('torchvision')); \ +print('torchaudio==' + version('torchaudio'))" > /opt/torch-constraints.txt \ + && PIP_CONSTRAINT=/opt/torch-constraints.txt /opt/train-venv/bin/pip install --no-cache-dir \ + "/opt/lerobot-allenai[libero,molmoact2,training]" \ + "accelerate>=1.0" \ + "av>=12" \ + "einops>=0.7" \ + "fastapi>=0.116" \ + "hf-transfer>=0.1.8" \ + "huggingface-hub[cli]>=0.36" \ + "imageio[ffmpeg]>=2.34" \ + "json-numpy>=2.1.0" \ + "matplotlib>=3.8" \ + "pyarrow>=17" \ + "safetensors>=0.4" \ + "sentencepiece>=0.2" \ + "uvicorn[standard]>=0.35" \ + && /opt/train-venv/bin/pip install --no-cache-dir --ignore-installed --no-deps opencv-python \ + && /opt/train-venv/bin/python -c \ + "import torch, lerobot, transformers, robosuite, mujoco; assert torch.version.hip; assert lerobot.__version__ == '0.5.2'; print('MolmoAct2 training environment OK:', torch.__version__, transformers.__version__, lerobot.__version__)" \ + && rm -f /tmp/robosuite.log + +# Seed LIBERO non-interactively and bake its simulator assets outside HOME. +# JupyterHub mounts a persistent volume over /home/jovyan, so image content +# stored there would be hidden and the first eval would prompt or redownload. +RUN mkdir -p /opt/libero-config \ + && printf 'N\n' | LIBERO_CONFIG_PATH=/opt/libero-config \ + /opt/train-venv/bin/python -c "import libero.libero" \ + && LIBERO_CONFIG_PATH=/opt/libero-config \ + /opt/train-venv/bin/python -c \ + "from libero.libero import get_libero_path; from libero.libero.utils.download_utils import download_assets_from_huggingface as download; download(get_libero_path('assets'))" \ + && rm -f /tmp/robosuite.log + +# ============================================================================================ +# FastWAM (Wan2.2-TI2V-5B world-action model) inference layer -- powers notebook 3. +# Installed into an ISOLATED /opt/fastwam-venv so the pinned MolmoAct2 train-venv above is never +# disturbed. The FastWAM LIBERO path reuses the upstream `sim_libero` harness (vendored from +# ClockWorkKid/Ryzers@benchmark), which needs the numpy-1.26.4 LIBERO sim stack -- deliberately +# separate from the train-venv's numpy 2.x. FastWAM itself is cloned from its open-source upstream +# at build; only the CUDA torch pins are stripped so it runs on auplc-base's ROCm torch. Weights +# are NOT fetched here (baked from the local asset store in the with-assets stage / mounted at +# runtime). Placed before the notebook COPY so this heavy layer stays cached across notebook edits. +# The fastwam venv is created from the BASE interpreter (/usr/bin/python3, the auplc-base ROCm +# torch), NOT from train-venv, to keep a single, clean site-packages chain. +# ============================================================================================ +ARG FASTWAM_COMMIT=45d8e1458921d83f8ad6cf9ce993d371208dabd0 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + fonts-dejavu-core \ + libglu1-mesa \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone https://github.com/yuantianyuan01/FastWAM /repos/fastwam \ + && git -C /repos/fastwam checkout "${FASTWAM_COMMIT}" \ + && git clone --depth 1 https://github.com/Lifelong-Robot-Learning/LIBERO /opt/LIBERO + +# Vendored AMD ryzers glue (sim_libero harness + FastWAM LIBERO policy adapter + kv-cache patch +# + strip helper + upstream-pin provenance). Source only; no binaries. +COPY fastwam/ /opt/fastwam-src/ + +# Exact planning-path caches (bit-identical output, ~1.45x faster on gfx1151): memoize the +# UMT5-XXL text encode across a replan episode and cache the action cross-attention K/V. Shipped +# as a reviewable diff vs the pinned upstream; default-on at runtime (FASTWAM_TEXT_KV_CACHE=0 +# restores upstream behavior for parity checks). +RUN git -C /repos/fastwam apply --verbose /opt/fastwam-src/patches/fastwam_kv_cache.patch + +# Isolated venv: reuse auplc-base's ROCm torch (constraint-pinned so it is never replaced) while +# installing the LIBERO sim stack (numpy 1.26.4) + FastWAM. Mirrors the simulation/libero + +# wam/fastwam ryzer recipe so the direct port stays faithful. numpy 1.26.4 is installed first, +# then the constraint is frozen from it so no transitive dep downgrades numpy or swaps the torch. +RUN /usr/bin/python3 -m venv --system-site-packages /opt/fastwam-venv \ + && /opt/fastwam-venv/bin/pip install --no-cache-dir "numpy==1.26.4" \ + && /opt/fastwam-venv/bin/python /opt/fastwam-src/strip_cuda_torch.py /repos/fastwam/pyproject.toml \ + && /opt/fastwam-venv/bin/pip freeze | grep -iE "^(torch|torchvision|torchcodec|numpy)==" > /opt/fastwam-constraints.txt \ + && PIP_CONSTRAINT=/opt/fastwam-constraints.txt /opt/fastwam-venv/bin/pip install --no-cache-dir \ + "robosuite==1.4.0" "bddl==1.0.1" "mujoco==3.3.2" easydict thop \ + "future==1.0.0" "cloudpickle==3.1.2" "gym==0.25.2" termcolor \ + "imageio>=2.34" "imageio-ffmpeg>=0.5" "pillow>=10" \ + && PIP_CONSTRAINT=/opt/fastwam-constraints.txt /opt/fastwam-venv/bin/pip install --no-cache-dir --no-deps -e /opt/LIBERO \ + && PIP_CONSTRAINT=/opt/fastwam-constraints.txt /opt/fastwam-venv/bin/pip install --no-cache-dir -e /repos/fastwam \ + && PIP_CONSTRAINT=/opt/fastwam-constraints.txt /opt/fastwam-venv/bin/pip install --no-cache-dir \ + "hf-transfer>=0.1.8" "sentencepiece>=0.2" "pyarrow>=17" "av>=12" "matplotlib>=3.8" \ + && /opt/fastwam-venv/bin/pip install --no-cache-dir --ignore-installed --no-deps opencv-python-headless + +# Place the sim harness + adapter where the demos expect them; register the import paths + the +# torch.load compat shim in the fastwam venv only (self-contained .pth adds /opt/sim itself; the +# fastwam_paths.pth adds the upstream repo + adapters so experiments.libero + the policy resolve). +RUN mkdir -p /opt/sim /opt/fastwam-adapters \ + && cp -r /opt/fastwam-src/sim_libero /opt/sim/sim_libero \ + && cp /opt/fastwam-src/adapters/fastwam_libero_policy.py /opt/fastwam-adapters/ \ + && SP="$(/opt/fastwam-venv/bin/python -c 'import sysconfig;print(sysconfig.get_paths()["purelib"])')" \ + && cp /opt/fastwam-src/sim_libero_compat.pth "$SP/" \ + && printf '/opt/LIBERO\n/repos/fastwam\n/repos/fastwam/experiments/libero\n/opt/fastwam-adapters\n' > "$SP/fastwam_paths.pth" + +# Seed the upstream LIBERO config for the fastwam venv OUTSIDE HOME, on its OWN config path so it +# never clashes with the MolmoAct2 train-venv's LIBERO (which lives in a different site-packages). +# bddl + robosuite meshes ship in the repo (no dataset download for eval/interactive). Verify the +# whole fastwam stack imports on auplc-base's ROCm torch (no GPU needed at build). +RUN mkdir -p /opt/libero-config-fastwam \ + && printf 'N\n' | LIBERO_CONFIG_PATH=/opt/libero-config-fastwam \ + /opt/fastwam-venv/bin/python -c \ + "import mujoco, robosuite; from libero.libero import benchmark; from libero.libero.envs import OffScreenRenderEnv; import sim_libero; from sim_libero.policy import load_policy; print('sim_libero + LIBERO import OK, mujoco', mujoco.__version__)" \ + && LIBERO_CONFIG_PATH=/opt/libero-config-fastwam /opt/fastwam-venv/bin/python -c \ + "import torch; assert torch.version.hip, 'torch is not a ROCm build: '+torch.__version__; import fastwam; from fastwam.runtime import create_fastwam; print('FastWAM import OK on torch', torch.__version__, 'hip', torch.version.hip)" \ + && rm -f /tmp/robosuite.log + +RUN mkdir -p \ + /home/jovyan/.cache/huggingface \ + /home/jovyan/checkpoints \ + /home/jovyan/outputs \ + /ryzers/notebooks +# Proxy the interactive LIBERO sim server (notebook Section 6) through each JupyterHub +# user's existing notebook route, so no extra port must be exposed (same pattern the +# LocalInference course uses for its Streamlit UI). Installed into the system python that +# runs the single-user Jupyter server. +RUN /usr/bin/python3 -m pip install --no-cache-dir --break-system-packages \ + jupyter-server-proxy + +COPY ./course_data /ryzers/notebooks +# Make the shipped shell scripts executable and expose the env-check scripts at the +# /ryzers/test_*.sh paths the README documents (same convention as the other courses). +RUN chmod +x /ryzers/notebooks/tests/*.sh /ryzers/notebooks/scripts/*.sh \ + && ln -sf /ryzers/notebooks/tests/test_torch.sh /ryzers/test_torch.sh \ + && ln -sf /ryzers/notebooks/tests/test_molmoact2.sh /ryzers/test_molmoact2.sh \ + && ln -sf /ryzers/notebooks/tests/test_fastwam.sh /ryzers/test_fastwam.sh +RUN chown -R jovyan:100 \ + /home/jovyan/.cache \ + /home/jovyan/checkpoints \ + /home/jovyan/outputs \ + /ryzers + +ENV HOME=/home/jovyan \ + PATH="/opt/train-venv/bin:${PATH}" \ + HF_HOME=/home/jovyan/.cache/huggingface \ + HF_HUB_OFFLINE=1 \ + TRANSFORMERS_OFFLINE=1 \ + HF_DATASETS_OFFLINE=1 \ + LIBERO_CONFIG_PATH=/opt/libero-config \ + DROID_SERVER_DIR=/opt/molmoact2/examples/droid \ + OUT_DIR=/home/jovyan/outputs \ + CHECKPOINTS_DIR=/home/jovyan/checkpoints \ + REFERENCE_POLICY=/home/jovyan/checkpoints/reference/pretrained_model \ + MUJOCO_GL=egl \ + PYOPENGL_PLATFORM=egl + +# FastWAM (notebook 3) runtime knobs. The weight/data paths resolve to the baked assets in the +# with-assets image; in a code-only image they can be overridden or mounted at runtime. The fastwam +# venv uses its OWN LIBERO config path (never the train-venv's), and the exact planning-path caches +# are on by default. DIFFSYNTH_MODEL_BASE_PATH is where the upstream loader looks for the Wan2.2 +# base (T5 + VAE + tokenizer) instead of downloading it. +ENV FASTWAM_REPO=/repos/fastwam \ + FASTWAM_VENV=/opt/fastwam-venv \ + LIBERO_CONFIG_PATH_FASTWAM=/opt/libero-config-fastwam \ + DIFFSYNTH_MODEL_BASE_PATH=/opt/fastwam-assets/diffsynth \ + FASTWAM_RELEASE_DIR=/opt/fastwam-assets/fastwam_release \ + FASTWAM_DATA_DIR=/opt/fastwam-assets/data \ + FASTWAM_TEXT_KV_CACHE=1 + +USER jovyan +WORKDIR /ryzers/notebooks + +# --------------------------------------------------------------------------------------------- +# Distribution variants. The default final stage is the code-only image (no assets), which keeps +# CI/registry builds and non-workshop use working unchanged. When build.sh passes +# `--build-arg FINAL=with-assets` together with `--build-context assets=`, +# the build is FULLY SELF-CONTAINED: a staging stage unpacks the bundle and reconstructs the +# fine-tuned checkpoint in-build, and the final stage bakes the result in with the HF cache +# pointed at it (no host pre-stage, no shared mount). The cache lives under /opt (NOT /home/jovyan) +# because a persistent volume mounts over /home/jovyan at runtime and would hide anything under HOME. +# --------------------------------------------------------------------------------------------- +FROM base AS without-assets + +# Self-contained pre-stage: unpack the raw split-tar workshop bundle and rebuild the fine-tuned +# checkpoint entirely INSIDE the build, using the image's own train-venv. Only the bundle subdirs +# we need are copied (the optional ~33 GB libero_full_backup/ is skipped; with BuildKit its bytes +# are never transferred). This stage is discarded except for the staged /opt trees copied below. +# The reused scripts/fetch_assets.sh untars base/libero/tokenizer/droid into the HF cache and +# reconstructs the reference checkpoint from the BF16 base + delta. +FROM base AS assets-staging +USER root +COPY --from=assets base/ /staging/bundle/base/ +COPY --from=assets libero/ /staging/bundle/libero/ +COPY --from=assets tokenizer/ /staging/bundle/tokenizer/ +COPY --from=assets droid_dataset/ /staging/bundle/droid_dataset/ +COPY --from=assets ft_checkpoint/ /staging/bundle/ft_checkpoint/ +RUN set -euo pipefail; \ + HF_HOME=/opt/auplc-hf \ + HF_LEROBOT_HOME=/opt/auplc-hf/lerobot \ + REFERENCE_POLICY=/opt/auplc-ref/pretrained_model \ + ASSETS_SRC=/staging/bundle \ + bash /ryzers/notebooks/scripts/fetch_assets.sh; \ + test -f /opt/auplc-ref/pretrained_model/model.safetensors; \ + rm -rf /staging + +# FastWAM weight pre-stage: unpack the split-tar FastWAM bundle (bf16 release checkpoint + +# dataset stats, the bf16 Wan2.2 base = T5 / VAE / tokenizer, and the small LIBERO episode set +# used by the video-imagination cell) into /opt/fastwam-assets. Kept OUTSIDE /home/jovyan so it +# survives the runtime PVC mount, exactly like the MolmoAct2 caches above. The weights are already +# bf16 (production precision); the bundle is passed via the same BuildKit `assets` context and is +# never committed to git. +FROM base AS fastwam-staging +USER root +COPY --from=assets fastwam/ /staging/fastwam/ +RUN set -euo pipefail; \ + mkdir -p /opt/fastwam-assets; \ + cat /staging/fastwam/fastwam.tar.part-* | tar -C /opt/fastwam-assets -xf -; \ + test -f /opt/fastwam-assets/fastwam_release/libero_uncond_2cam224.pt; \ + test -f /opt/fastwam-assets/fastwam_release/libero_uncond_2cam224_dataset_stats.json; \ + test -d /opt/fastwam-assets/diffsynth; \ + rm -rf /staging + +FROM base AS with-assets +USER root +COPY --from=assets-staging /opt/auplc-hf /opt/auplc-hf +COPY --from=assets-staging /opt/auplc-ref /opt/auplc-ref +RUN chown -R jovyan:100 /opt/auplc-hf /opt/auplc-ref +# FastWAM weights are large (~25 GB) and read-only at runtime, so bake them root-owned (world +# readable) WITHOUT a chown -R -- a recursive chown would double the layer size in the overlay. +COPY --from=fastwam-staging /opt/fastwam-assets /opt/fastwam-assets +ENV HF_HOME=/opt/auplc-hf \ + HF_LEROBOT_HOME=/opt/auplc-hf/lerobot \ + REFERENCE_POLICY=/opt/auplc-ref/pretrained_model +USER jovyan +WORKDIR /ryzers/notebooks + +FROM ${FINAL} AS final diff --git a/dockerfiles/Courses/Finetuning/build.sh b/dockerfiles/Courses/Finetuning/build.sh new file mode 100755 index 00000000..5a0efa77 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/build.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +set -euo pipefail + +# Hardcoded workshop asset bundle location: drop mm2_workshop_assets.zip into projects/Finetuning/ +# (next to the notebooks) and it gets baked into the image. Nothing to configure. +PROJECT_DIR="../../../projects/Finetuning" +ASSETS_ZIP="${PROJECT_DIR}/mm2_workshop_assets.zip" + +WORK_ASSETS="" +cleanup() { + rm -rf course_data + [ -n "${WORK_ASSETS:-}" ] && rm -rf "${WORK_ASSETS}" + return 0 +} +trap cleanup EXIT + +# Stage the course notebooks + helper scripts into the build context, EXCLUDING the large asset +# bundle so it is never baked into /ryzers/notebooks. +rm -rf course_data +mkdir -p course_data +cp -r "$PROJECT_DIR"/. course_data/ +rm -rf course_data/mm2_workshop_assets course_data/mm2_workshop_assets.zip + +# Bake the workshop assets if the bundle is present; otherwise build a code-only image (CI/registry). +BUILD_EXTRA=() +if [ -f "${ASSETS_ZIP}" ]; then + echo "unpacking mm2_workshop_assets.zip (one-time; the bundle is large, this can take a while)..." + # Unpack OUTSIDE the docker build context (extracting here would bloat the context sent to the + # daemon). /var/tmp is disk-backed and typically large enough for the ~16 GB bundle. + WORK_ASSETS="$(mktemp -d "${TMPDIR:-/var/tmp}/auplc-ft-assets.XXXXXX")" + unzip -q "${ASSETS_ZIP}" -d "${WORK_ASSETS}" + echo "baking workshop assets into the image from: ${WORK_ASSETS}/mm2_workshop_assets" + BUILD_EXTRA+=(--build-context "assets=${WORK_ASSETS}/mm2_workshop_assets" --build-arg "FINAL=with-assets") +else + echo "no mm2_workshop_assets.zip in projects/Finetuning/ -> building code-only image (assets NOT baked in)." +fi + +DOCKER_BUILDKIT=1 docker build ${BASE_IMAGE:+--build-arg BASE_IMAGE="$BASE_IMAGE"} \ + ${BUILD_EXTRA[@]+"${BUILD_EXTRA[@]}"} \ + -t ghcr.io/amdresearch/auplc-finetuning:latest . diff --git a/dockerfiles/Courses/Finetuning/fastwam/README.md b/dockerfiles/Courses/Finetuning/fastwam/README.md new file mode 100644 index 00000000..35714d77 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/README.md @@ -0,0 +1,123 @@ +### FastWAM + +This package runs [FastWAM](https://github.com/yuantianyuan01/FastWAM) — a Wan2.2-TI2V-5B +world-action model (T5 text encoder + Wan VAE + video/action DiT) — on AMD Ryzen AI Max+ +395 (Strix Halo, `gfx1151`) under ROCm 7.2.2. Direct PyTorch port: upstream code runs on +the base image's ROCm torch; only the CUDA torch pins are stripped. + +It is a **slim policy/model layer that ships no simulator**, and the reference consumer of +the simulator packages' `Policy` seam. It composes on: + +- the plain ROCm base → non-sim demos (smoke / latency / open-loop / videogen); +- the `simulation/libero` base → closed-loop + interactive LIBERO; +- the `simulation/robotwin` base → closed-loop + interactive RoboTwin 2.0. + +The same policy layer composes on all three because the FastWAM install is pinned to the +base image's torch + numpy (so a plain base's numpy 2.x and a sim base's numpy 1.26.4 both +work). Weights/datasets are fetched by the scripts below; sim assets come from the sim base. + +### Build + +```sh +# Standalone (non-sim demos + model sign-of-life): +ryzers build fastwam --name fastwam +ryzers run --name fastwam # test.py: ROCm torch + GPU + deps sign-of-life + +# Chain on a simulator base for closed-loop / interactive rollouts: +ryzers build libero fastwam --name fastwam-libero +ryzers build robotwin fastwam --name fastwam-robotwin +``` + +Artifacts are written to `workspace/*/outputs`. For faster/gated HF downloads set +`HF_TOKEN`. The ~12 GB Wan2.2 base is fetched automatically on the first model run. + +```sh +ryzers run --name fastwam /ryzers/scripts/download_checkpoints.sh # LIBERO + RoboTwin ckpts +ryzers run --name fastwam /ryzers/scripts/download_datasets.sh # open-loop / video data +``` + +The chain drives a sim base's model-agnostic `Policy` seam via a runtime adapter +(`adapters/fastwam_{libero,robotwin}_policy.py`, selected by `POLICY_FACTORY`); these +adapters double as the worked reference for wiring any VLA/WAM into the sim bases (see each +`simulation/*` README). The RoboTwin closed-loop instead runs RoboTwin's own +`script/eval_policy.py` against `experiments/robotwin/fastwam_policy` +(`EVALUATION.robotwin_root=/opt/RoboTwin`). + +### Demos + +| Demo | Base | What it does | +|---|---|---| +| `demos/demo_smoke.sh` | plain | Load checkpoint, one `infer_action`; cold/steady latency + VRAM. | +| `demos/demo_latency.sh` | plain | Per-part latency (T5 / VAE / world prefill / plan) + SDPA backends. | +| `demos/demo_openloop.sh` | plain | Replay GT observations, overlay predicted vs GT action chunks + MAE. | +| `demos/demo_videogen.sh` | plain | Imagine future frames from the first observation; GT-vs-imagined clips. | +| `demos/demo_closedloop_libero.sh` | `libero` | Closed-loop LIBERO rollouts (MuJoCo/EGL) + success rate. | +| `demos/demo_interactive_libero.sh` / `_rt.sh` | `libero` | Interactive LIBERO over HTTP/MJPEG. | +| `demos/demo_closedloop_robotwin.sh` | `robotwin` | Closed-loop RoboTwin 2.0 rollouts (SAPIEN Vulkan RT) + success rate. | +| `demos/demo_interactive_robotwin.sh` / `_rt.sh` | `robotwin` | Interactive RoboTwin over HTTP/MJPEG. | + +```sh +ryzers run --name fastwam /ryzers/demos/demo_smoke.sh +ryzers run --name fastwam-libero /ryzers/demos/demo_closedloop_libero.sh +TASKS="click_bell lift_pot" NUM_EPISODES=10 \ + ryzers run --name fastwam-robotwin /ryzers/demos/demo_closedloop_robotwin.sh +ryzers run --name fastwam-robotwin /ryzers/demos/demo_interactive_robotwin.sh # http://localhost:8082 +``` + +### Open-loop replay + +Predicted action chunks track ground truth over 100 episodes: mean normalized MAE +**0.0222** (LIBERO) / **0.0208** (RoboTwin), action inference ~1.5 s. + +

+ open-loop per-dim MAE, LIBERO +
Per-dimension normalized MAE (LIBERO). +

+

+ open-loop GT-vs-pred overlay, LIBERO episode 0 +
GT (solid) vs predicted (dashed) action chunks, LIBERO episode 0. +

+ +### Video imagination + +Joint path imagines the future video + actions (GT left, imagined right). Steady-state +joint latency ~18.6 s (LIBERO) / ~21.9 s (RoboTwin) for a 33-frame clip at 20 denoise +steps (the first call pays a one-time ROCm warmup). + +

+ GT vs imagined, LIBERO +
Ground truth vs imagined future (LIBERO). +

+

+ GT vs imagined, RoboTwin +
Ground truth vs imagined future (RoboTwin). +

+ +### Closed-loop LIBERO + +`libero_object` suite, 10 tasks × 20 trials: **199/200 (99.5%)** success, rendered headless +via EGL. With `VISUALIZE_FUTURE=true` the slow path also renders the model's imagined future +alongside the real rollout (GT left, imagined right; PSNR ~27.3 dB). + +

+ closed-loop LIBERO rollout + closed-loop slow path, GT vs imagined +
Closed-loop rollout (left) and slow-path GT-vs-imagined (right). +

+ +### Useful knobs + +- Non-sim: `DATASET=libero|robotwin` (open-loop/videogen/latency), `NUM_STEPS`, `SEED`. +- Closed-loop LIBERO: `SUITE`, `NUM_TASKS`, `NUM_TRIALS`, `VISUALIZE_FUTURE`. +- Closed-loop RoboTwin: `TASKS`, `TASK_CONFIG`, `NUM_EPISODES`. +- Interactive: `PORT`, `CKPT`, `DATASET_STATS`, `REPLAN_STEPS`, `NUM_INFERENCE_STEPS`. +- `HF_TOKEN` for faster/gated downloads. + +### References + +- Upstream: https://github.com/yuantianyuan01/FastWAM (pinned in `docs/UPSTREAM_PIN.commit.txt`) +- Model: https://huggingface.co/yuanty/fastwam +- Datasets: https://huggingface.co/datasets/yuanty/LIBERO-fastwam · https://huggingface.co/datasets/yuanty/robotwin2.0-fastwam + +Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +SPDX-License-Identifier: MIT diff --git a/dockerfiles/Courses/Finetuning/fastwam/adapters/fastwam_libero_policy.py b/dockerfiles/Courses/Finetuning/fastwam/adapters/fastwam_libero_policy.py new file mode 100644 index 00000000..92bea6d6 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/adapters/fastwam_libero_policy.py @@ -0,0 +1,115 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""FastWAM LIBERO policy adapter for the simulation/libero harness. + +Implements the model-agnostic `sim_libero.Policy` seam by wrapping the FastWAM world-action +model. Reuses the *validated* eval machinery from experiments/libero/eval_libero_single.py +verbatim (config compose, model instantiate + checkpoint load, processor/normalizer, and +`_predict_action_chunk`) so interactive/closed-loop rollouts match the shipped numbers. + +Selected at runtime by the sim harness via + POLICY_FACTORY=fastwam_libero_policy:build_policy + +Env: CKPT, DATASET_STATS, MIXED_PRECISION (bf16), SUITE, NUM_INFERENCE_STEPS, +REPLAN_STEPS, NUM_STEPS_WAIT, FASTWAM_REPO (/repos/fastwam). +Requires /repos/fastwam and its experiments/libero dir on PYTHONPATH (the demo sets this). +""" +import os + +import numpy as np +from hydra import compose, initialize_config_dir +from hydra.core.global_hydra import GlobalHydra +from hydra.utils import instantiate + +import experiments.libero.eval_libero_single as E +from fastwam.datasets.lerobot.utils.normalizer import load_dataset_stats_from_json +from sim_libero.policy import Policy + +FASTWAM_REPO = os.environ.get("FASTWAM_REPO", "/repos/fastwam") +CONFIG_DIR = os.path.join(FASTWAM_REPO, "configs") +DEFAULT_CKPT = "/models/fastwam_release/libero_uncond_2cam224.pt" +DEFAULT_STATS = "/models/fastwam_release/libero_uncond_2cam224_dataset_stats.json" + + +class FastwamLiberoPolicy(Policy): + name = "fastwam" + + def __init__(self, model, processor, cfg, action_horizon, input_w, input_h, device): + self.model = model + self.processor = processor + self.cfg = cfg + self.action_horizon = action_horizon + self.input_w = input_w + self.input_h = input_h + self.device = device + self.replan_steps = int(cfg.EVALUATION.get("replan_steps", 5)) + self.num_steps_wait = int(cfg.EVALUATION.get("num_steps_wait", 5)) + + def predict_action_chunk(self, obs, instruction): + action, _imgs, _pred = E._predict_action_chunk( + obs=obs, + task_description=instruction, + model=self.model, + processor=self.processor, + cfg=self.cfg, + action_horizon=self.action_horizon, + input_w=self.input_w, + input_h=self.input_h, + model_device=self.device, + ) + return np.asarray(action, dtype=np.float32) + + +def build_policy(): + # ryzers passes optional knobs as empty strings; treat "" as unset. + ckpt = os.environ.get("CKPT") or DEFAULT_CKPT + stats = os.environ.get("DATASET_STATS") or DEFAULT_STATS + mixed = os.environ.get("MIXED_PRECISION") or "bf16" + suite = os.environ.get("SUITE") or "libero_object" + + overrides = [ + f"ckpt={ckpt}", + "gpu_id=0", + f"mixed_precision={mixed}", + f"EVALUATION.task_suite_name={suite}", + "EVALUATION.task_id=0", + "EVALUATION.num_trials=1", + f"EVALUATION.dataset_stats_path={stats}", + "EVALUATION.output_dir=/tmp/fastwam_interactive", + ] + if os.environ.get("NUM_INFERENCE_STEPS"): + overrides.append(f"EVALUATION.num_inference_steps={os.environ['NUM_INFERENCE_STEPS']}") + if os.environ.get("REPLAN_STEPS"): + overrides.append(f"EVALUATION.replan_steps={os.environ['REPLAN_STEPS']}") + if os.environ.get("NUM_STEPS_WAIT"): + overrides.append(f"EVALUATION.num_steps_wait={os.environ['NUM_STEPS_WAIT']}") + + if GlobalHydra.instance().is_initialized(): + GlobalHydra.instance().clear() + with initialize_config_dir(config_dir=CONFIG_DIR, version_base="1.3"): + cfg = compose(config_name="sim_libero", overrides=overrides) + + device = E._resolve_eval_device(cfg) + dtype = E._mixed_precision_to_model_dtype(mixed) + model = instantiate(cfg.model, model_dtype=dtype, device=device) + E._load_model_checkpoint(model, str(cfg.ckpt)) + model = model.to(device).eval() + + stats_path = E._resolve_dataset_stats_path(cfg) + dataset_stats = load_dataset_stats_from_json(str(stats_path)) + processor = instantiate(cfg.data.train.processor).eval() + processor.set_normalizer_from_stats(dataset_stats) + + action_horizon_cfg = cfg.EVALUATION.get("action_horizon", None) + if action_horizon_cfg is None: + action_horizon = int(cfg.data.train.num_frames) - 1 + else: + action_horizon = int(action_horizon_cfg) + + video_size = cfg.data.train.get("video_size", [224, 224]) + input_h = int(video_size[0]) + input_w = int(video_size[1]) + + print(f"[fastwam_libero_policy] model ready (ckpt={ckpt}, horizon={action_horizon}, " + f"input={input_w}x{input_h}, replan={cfg.EVALUATION.get('replan_steps', 5)})", flush=True) + return FastwamLiberoPolicy(model, processor, cfg, action_horizon, input_w, input_h, device) diff --git a/dockerfiles/Courses/Finetuning/fastwam/docs/UPSTREAM_PIN.commit.txt b/dockerfiles/Courses/Finetuning/fastwam/docs/UPSTREAM_PIN.commit.txt new file mode 100644 index 00000000..c697edd4 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/docs/UPSTREAM_PIN.commit.txt @@ -0,0 +1,2 @@ +yuantianyuan01/FastWAM +45d8e1458921d83f8ad6cf9ce993d371208dabd0 diff --git a/dockerfiles/Courses/Finetuning/fastwam/patches/fastwam_kv_cache.patch b/dockerfiles/Courses/Finetuning/fastwam/patches/fastwam_kv_cache.patch new file mode 100644 index 00000000..b0137905 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/patches/fastwam_kv_cache.patch @@ -0,0 +1,213 @@ +diff --git a/src/fastwam/models/wan22/action_dit.py b/src/fastwam/models/wan22/action_dit.py +index f6716e6..5838e0f 100644 +--- a/src/fastwam/models/wan22/action_dit.py ++++ b/src/fastwam/models/wan22/action_dit.py +@@ -229,6 +229,7 @@ class ActionDiT(nn.Module): + timestep: torch.Tensor, + context: torch.Tensor, + context_mask: Optional[torch.Tensor] = None, ++ precomputed_context_emb: Optional[torch.Tensor] = None, + ) -> Dict[str, Any]: + if action_tokens.ndim != 3: + raise ValueError( +@@ -281,7 +282,8 @@ class ActionDiT(nn.Module): + t_mod = self.time_projection(t).unflatten(1, (6, self.hidden_dim)) + + tokens = self.action_encoder(action_tokens) +- context_emb = self.text_embedding(context) ++ # `text_embedding(context)` is constant across a plan's diffusion steps; allow reuse. ++ context_emb = precomputed_context_emb if precomputed_context_emb is not None else self.text_embedding(context) + context_attn_mask = context_mask.unsqueeze(1).expand(-1, seq_len, -1) + freqs = self.freqs[:seq_len].view(seq_len, 1, -1).to(tokens.device) + +diff --git a/src/fastwam/models/wan22/fastwam.py b/src/fastwam/models/wan22/fastwam.py +index 106beb3..4157b39 100644 +--- a/src/fastwam/models/wan22/fastwam.py ++++ b/src/fastwam/models/wan22/fastwam.py +@@ -1,3 +1,4 @@ ++import os + from typing import Any, Optional, Sequence, Union + + import torch +@@ -85,6 +86,14 @@ class FastWAM(torch.nn.Module): + self.loss_lambda_video = float(loss_lambda_video) + self.loss_lambda_action = float(loss_lambda_action) + ++ # Exact inference-time caches (default on; disable with FASTWAM_TEXT_KV_CACHE=0). ++ # (1) encode_prompt memo: instruction is constant across a replan episode. ++ # (2) per-plan action cross-attn K/V + text-embedding cache: context is constant ++ # across a plan's diffusion steps. Both are numerically exact (bit-identical). ++ self._text_kv_cache_enabled = os.environ.get("FASTWAM_TEXT_KV_CACHE", "1") != "0" ++ self._prompt_cache: dict = {} ++ self._prompt_cache_cap = 16 ++ + self.to(self.device) + + @classmethod +@@ -205,6 +214,11 @@ class FastWAM(torch.nn.Module): + "Prompt encoding requires loaded text encoder/tokenizer. " + "Set `load_text_encoder=true` or provide precomputed `context/context_mask`." + ) ++ cache_key = prompt if (self._text_kv_cache_enabled and isinstance(prompt, str)) else None ++ if cache_key is not None: ++ hit = self._prompt_cache.get(cache_key) ++ if hit is not None: ++ return hit + ids, mask = self.tokenizer(prompt, return_mask=True, add_special_tokens=True) + ids = ids.to(self.device) + mask = mask.to(self.device, dtype=torch.bool) +@@ -214,7 +228,12 @@ class FastWAM(torch.nn.Module): + for i, v in enumerate(seq_lens): + prompt_emb[i, v:] = 0 + mask = torch.ones_like(mask) +- return prompt_emb.to(device=self.device), mask ++ result = (prompt_emb.to(device=self.device), mask) ++ if cache_key is not None: ++ if len(self._prompt_cache) >= self._prompt_cache_cap: ++ self._prompt_cache.clear() ++ self._prompt_cache[cache_key] = result ++ return result + + def _append_proprio_to_context( + self, +@@ -701,12 +720,15 @@ class FastWAM(torch.nn.Module): + video_kv_cache: list[dict[str, torch.Tensor]], + attention_mask: torch.Tensor, + video_seq_len: int, ++ precomputed_context_emb: Optional[torch.Tensor] = None, ++ action_cross_kv: Optional[list] = None, + ) -> torch.Tensor: + action_pre = self.action_expert.pre_dit( + action_tokens=latents_action, + timestep=timestep_action, + context=context, + context_mask=context_mask, ++ precomputed_context_emb=precomputed_context_emb, + ) + action_tokens = self.mot.forward_action_with_video_cache( + action_tokens=action_pre["tokens"], +@@ -719,6 +741,7 @@ class FastWAM(torch.nn.Module): + video_kv_cache=video_kv_cache, + attention_mask=attention_mask, + video_seq_len=video_seq_len, ++ action_cross_kv=action_cross_kv, + ) + return self.action_expert.post_dit(action_tokens, action_pre) + +@@ -1021,6 +1044,15 @@ class FastWAM(torch.nn.Module): + video_attention_mask=attention_mask[:video_seq_len, :video_seq_len], + ) + ++ # Precompute the (per-plan constant) action text-embedding + per-layer cross-attn K/V ++ # once, then reuse across all diffusion steps. Numerically exact; disable via ++ # FASTWAM_TEXT_KV_CACHE=0. ++ precomputed_context_emb = None ++ action_cross_kv = None ++ if self._text_kv_cache_enabled: ++ precomputed_context_emb = self.action_expert.text_embedding(context) ++ action_cross_kv = self.mot.prefill_action_cross_kv(precomputed_context_emb) ++ + infer_timesteps_action, infer_deltas_action = self.infer_action_scheduler.build_inference_schedule( + num_inference_steps=num_inference_steps, + device=self.device, +@@ -1038,6 +1070,8 @@ class FastWAM(torch.nn.Module): + video_kv_cache=video_kv_cache, + attention_mask=attention_mask, + video_seq_len=video_seq_len, ++ precomputed_context_emb=precomputed_context_emb, ++ action_cross_kv=action_cross_kv, + ) + pred_action = pred_action_posi + +diff --git a/src/fastwam/models/wan22/mot.py b/src/fastwam/models/wan22/mot.py +index 81bc6f8..3628ff5 100644 +--- a/src/fastwam/models/wan22/mot.py ++++ b/src/fastwam/models/wan22/mot.py +@@ -106,6 +106,7 @@ class MoT(nn.Module): + scale_mlp: torch.Tensor, + gate_mlp: torch.Tensor, + context_payload: Optional[dict], ++ cross_kv: Optional[tuple] = None, + ) -> torch.Tensor: + x = block.gate(residual_x, gate_msa, block.self_attn.o(mixed_attn_out)) + +@@ -115,7 +116,7 @@ class MoT(nn.Module): + context_mask = context_payload.get("mask") + if context_mask is not None and context_mask.dim() == 3: + context_mask = context_mask.unsqueeze(1) +- x = x + block.cross_attn(block.norm3(x), context, ctx_mask=context_mask) ++ x = x + block.cross_attn(block.norm3(x), context, ctx_mask=context_mask, kv=cross_kv) + + mlp_input = modulate(block.norm2(x), shift_mlp, scale_mlp) + x = block.gate(x, gate_mlp, block.ffn(mlp_input)) +@@ -194,6 +195,7 @@ class MoT(nn.Module): + use_gradient_checkpointing: bool, + mixed_slice: torch.Tensor, + context_payload: Optional[dict], ++ cross_kv: Optional[tuple] = None, + ) -> torch.Tensor: + """Apply post-attention computations, with optional checkpointing. + +@@ -232,6 +234,7 @@ class MoT(nn.Module): + scale_mlp=_scale_mlp, + gate_mlp=_gate_mlp, + context_payload=_context_payload, ++ cross_kv=cross_kv, + ) + + if use_gradient_checkpointing and self.training: +@@ -340,6 +343,12 @@ class MoT(nn.Module): + kv_cache.append({"k": k, "v": v}) + return kv_cache + ++ def prefill_action_cross_kv(self, context_emb: torch.Tensor) -> list[tuple]: ++ """Precompute per-layer action cross-attention K/V from the (per-plan constant) ++ text-embedded context. Reused across all diffusion steps of one plan (exact).""" ++ expert = self.mixtures["action"] ++ return [expert.blocks[i].cross_attn.compute_kv(context_emb) for i in range(self.num_layers)] ++ + def forward_action_with_video_cache( + self, + action_tokens: torch.Tensor, +@@ -349,6 +358,7 @@ class MoT(nn.Module): + video_kv_cache: list[dict[str, torch.Tensor]], + attention_mask: torch.Tensor, + video_seq_len: int, ++ action_cross_kv: Optional[list] = None, + ) -> torch.Tensor: + """Run action branch with cached video K/V instead of recomputing video tokens. + +@@ -441,6 +451,7 @@ class MoT(nn.Module): + use_gradient_checkpointing=use_gradient_checkpointing, + mixed_slice=mixed, + context_payload=action_context_payload, ++ cross_kv=None if action_cross_kv is None else action_cross_kv[layer_idx], + ) + return x + +diff --git a/src/fastwam/models/wan22/wan_video_dit.py b/src/fastwam/models/wan22/wan_video_dit.py +index 01dc2a0..1930e25 100644 +--- a/src/fastwam/models/wan22/wan_video_dit.py ++++ b/src/fastwam/models/wan22/wan_video_dit.py +@@ -212,10 +212,18 @@ class CrossAttention(nn.Module): + + # self.attn = AttentionModule(self.num_heads) + +- def forward(self, x: torch.Tensor, ctx: torch.Tensor, ctx_mask: Optional[torch.Tensor] = None): ++ def compute_kv(self, ctx: torch.Tensor): ++ # Cross-attn K/V depend only on the (per-plan constant) context; caching them across ++ # diffusion steps is numerically exact. See MoT.forward_action_with_video_cache. ++ return self.norm_k(self.k(ctx)), self.v(ctx) ++ ++ def forward(self, x: torch.Tensor, ctx: torch.Tensor, ctx_mask: Optional[torch.Tensor] = None, kv=None): + q = self.norm_q(self.q(x)) +- k = self.norm_k(self.k(ctx)) +- v = self.v(ctx) ++ if kv is None: ++ k = self.norm_k(self.k(ctx)) ++ v = self.v(ctx) ++ else: ++ k, v = kv + x = flash_attention(q=q, k=k, v=v, num_heads=self.num_heads, ctx_mask=ctx_mask) + return self.o(x) + diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/__init__.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/__init__.py new file mode 100644 index 00000000..1a14ea48 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/__init__.py @@ -0,0 +1,15 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Model-agnostic LIBERO simulator harness. + +Ships the LIBERO env glue, a `Policy` seam, generic closed-loop/interactive harnesses, +and a built-in RandomPolicy. Any policy/model (FastWAM, MolmoACT2, ...) drives the sim by +providing a `build_policy() -> Policy` factory selected via POLICY_FACTORY. +""" +from sim_libero._torch_compat import patch_torch_load + +patch_torch_load() + +from sim_libero.policy import Policy, load_policy # noqa: E402 + +__all__ = ["Policy", "load_policy"] diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/_torch_compat.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/_torch_compat.py new file mode 100644 index 00000000..e75a6d57 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/_torch_compat.py @@ -0,0 +1,29 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""torch.load compatibility shim for LIBERO's pickled assets. + +LIBERO stores per-task init-states as numpy-backed pickled tensors and loads them via +torch.load. PyTorch >= 2.6 flipped the torch.load default to weights_only=True, which +rejects those pickles. Restore the pre-2.6 default so the LIBERO assets baked into the +image load. Safe here: the files are trusted assets shipped in the simulator image. + +Applied globally via sitecustomize.py (so the upstream fastwam eval script, which does not +import sim_libero, is covered) and also from sim_libero.__init__ for direct library use. +""" + + +def patch_torch_load(): + try: + import torch + except ImportError: + return + if getattr(torch.load, "_sim_libero_patched", False): + return + _orig = torch.load + + def _load(*args, **kwargs): + kwargs.setdefault("weights_only", False) + return _orig(*args, **kwargs) + + _load._sim_libero_patched = True + torch.load = _load diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/envutil.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/envutil.py new file mode 100644 index 00000000..e69f3a1c --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/envutil.py @@ -0,0 +1,23 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Env-var readers that treat an empty string as unset. + +The ryzers run wrapper passes optional knobs as `-e VAR=${VAR:-}`, i.e. an empty string +when the host did not set them. Plain os.environ.get(key, default) would then return "" +(not the default) and break int()/float() parsing, so these helpers fall back to the +default whenever the value is missing or empty. +""" +import os + + +def env_str(key, default): + val = os.environ.get(key) + return val if val not in (None, "") else default + + +def env_int(key, default): + return int(env_str(key, str(default))) + + +def env_float(key, default): + return float(env_str(key, str(default))) diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/interactive_server.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/interactive_server.py new file mode 100644 index 00000000..32feeb8d --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/interactive_server.py @@ -0,0 +1,323 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Interactive LIBERO demo (model-agnostic, chunk-replay). + +Command-driven showcase: the sim sits IDLE on a scene until you send an instruction; +then it resets the env + policy and runs that one task to completion (or until Stop), +streaming the composed agentview|wrist view to the browser as MJPEG and saving a debug +MP4 (executed command banner on top). Use the environment dropdown to switch to any +shipped LIBERO task; the last run's video stays on screen with a download link. + +The policy is chosen at runtime via POLICY_FACTORY=module:function (default: the built-in +RandomPolicy shipped with this simulator). Any model that implements sim_libero.Policy can +drive this demo. Stdlib http.server only. + +Env: SUITE, TASK_ID, SEED, PORT (8080), OUT_DIR (/sim_outputs), VIEW_RES (720), +VIDEO_RES (720), RENDER_RES (512, HD sim render; policy still resizes to its own input), +MAX_STEPS (0=suite default), POLICY_FACTORY. View: ssh -L 8080:localhost:8080 . +""" +import json +import os +import threading +import time +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +from sim_libero.envutil import env_int, env_str +from sim_libero.libero_env import get_libero_image, list_envs +from sim_libero.policy import load_policy +from sim_libero.render import banner_frame, compose_view, encode_jpeg, save_mp4 +from sim_libero.scene import build_scene + +SUITE = env_str("SUITE", "libero_object") +TASK_ID = env_int("TASK_ID", 0) +SEED = env_int("SEED", 1000) +PORT = env_int("PORT", 8080) +VIEW_RES = env_int("VIEW_RES", 720) +VIDEO_RES = env_int("VIDEO_RES", 720) +RENDER_RES = env_int("RENDER_RES", 512) # sim camera size for the HD viewport +MAX_STEPS = env_int("MAX_STEPS", 0) # 0 -> per-suite default horizon +OUT_DIR = env_str("OUT_DIR", "/sim_outputs") + +STATE = { + "mode": "loading", "instruction": "", "scene_task": "", + "suite": SUITE, "task_id": TASK_ID, "objects": [], + "step": 0, "success": False, "status": "starting", "frame": None, "video_url": "", +} +LOCK = threading.Lock() +PENDING = {"action": None, "instruction": "", "max_steps": 0, "suite": SUITE, "task_id": TASK_ID} +EVENT = threading.Event() +STOP = {"flag": False} + +_ENVS_CACHE = None + + +def _envs_json(): + """Cached env-picker payload: [{value, label}] over every shipped LIBERO task.""" + global _ENVS_CACHE + if _ENVS_CACHE is None: + items = [] + for e in list_envs(): + desc = (e["description"] or "").strip() + label = f"{e['suite']} / task {e['task_id']}" + (f" - {desc[:44]}" if desc else "") + items.append({"value": f"suite={e['suite']}&task_id={e['task_id']}", "label": label}) + _ENVS_CACHE = items + return _ENVS_CACHE + + +def _set_frame(rgb): + with LOCK: + STATE["frame"] = encode_jpeg(rgb) + + +def engine_thread(): + os.makedirs(os.path.join(OUT_DIR, "interactive"), exist_ok=True) + with LOCK: + STATE["status"] = "loading policy ..." + policy = load_policy() + + def show_idle(sc, keep_video=False): + obs = sc.reset() + upd = dict(mode="idle", status="idle - send an instruction", step=0, + success=False, suite=sc.suite, task_id=sc.task_id, + scene_task=sc.description, objects=sc.objects, instruction="") + if not keep_video: + upd["video_url"] = "" + with LOCK: + STATE.update(upd) + if not keep_video: + STATE.pop("_video_path", None) + _set_frame(compose_view(get_libero_image(obs), height=VIEW_RES)) + + scene = build_scene(SUITE, TASK_ID, seed=SEED, resolution=RENDER_RES) + show_idle(scene) + + def run_command(sc, instruction, max_steps): + with LOCK: + STATE.update(mode="running", instruction=instruction, step=0, success=False, + status=f"running: {instruction}", video_url="") + STATE.pop("_video_path", None) + STOP["flag"] = False + frames = [] + + def on_frame(imgs, step, holding): + rgb = compose_view(imgs, height=VIEW_RES) + _set_frame(rgb) + frames.append(banner_frame(compose_view(imgs), instruction, VIDEO_RES)) + with LOCK: + STATE["step"] = step + + from sim_libero.rollout import run_episode + + limit = max_steps or MAX_STEPS or None + success, _ = run_episode(sc, policy, instruction, on_frame=on_frame, + should_stop=lambda: STOP["flag"], max_steps=limit) + + url = "" + if frames: + ts = datetime.now().strftime("%H%M%S") + name = f"interactive/{ts}_{sc.suite}_{sc.task_id}_{'ok' if success else 'run'}.mp4" + path = os.path.join(OUT_DIR, name) + try: + save_mp4(frames, path, fps=20) + url = "/video?ts=" + ts + with LOCK: + STATE["_video_path"] = path + except Exception as e: # noqa: BLE001 + print("video save failed:", e, flush=True) + + with LOCK: + STATE.update(mode="idle", success=success, video_url=url, + status=("success" if success else ("stopped" if STOP["flag"] else "done"))) + show_idle(sc, keep_video=True) + + while True: + EVENT.wait() + EVENT.clear() + with LOCK: + action = PENDING["action"] + instruction = PENDING["instruction"] + max_steps = int(PENDING.get("max_steps", 0) or 0) + sel_suite = PENDING.get("suite", SUITE) + sel_task = int(PENDING.get("task_id", 0) or 0) + PENDING["action"] = None + if action == "select": + STOP["flag"] = True + with LOCK: + STATE["status"] = f"loading scene {sel_suite}/{sel_task} ..." + try: + new_scene = build_scene(sel_suite, sel_task, seed=SEED, resolution=RENDER_RES) + except Exception as e: # noqa: BLE001 + with LOCK: + STATE["status"] = f"scene build failed: {e}" + continue + scene.close() + scene = new_scene + show_idle(scene) + elif action == "run": + run_command(scene, instruction, max_steps) + + +PAGE = b""" +FastWAM sim - LIBERO (live) +
+

FastWAM simulator - LIBERO (live)

+sim +
+ + + +
+
+ environment + + +
+
status: loading...
+
scene
+
+
+""" + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, code, ctype, body, extra=None): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = urlparse(self.path).path + if path == "/": + self._send(200, "text/html; charset=utf-8", PAGE) + elif path == "/envs": + self._send(200, "application/json", json.dumps(_envs_json()).encode()) + elif path == "/status": + with LOCK: + s = {k: STATE[k] for k in ("mode", "status", "instruction", "scene_task", + "suite", "task_id", "objects", "step", + "success", "video_url")} + self._send(200, "application/json", json.dumps(s).encode()) + elif path == "/video": + with LOCK: + p = STATE.get("_video_path") + if p and os.path.exists(p): + with open(p, "rb") as f: + self._send(200, "video/mp4", f.read(), + extra={"Content-Disposition": "attachment; filename=fastwam_libero.mp4"}) + else: + self._send(404, "text/plain", b"no video") + elif path == "/stream": + self.send_response(200) + self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") + self.end_headers() + try: + while True: + with LOCK: + frame = STATE["frame"] + if frame: + self.wfile.write(b"--frame\r\nContent-Type: image/jpeg\r\n") + self.wfile.write(f"Content-Length: {len(frame)}\r\n\r\n".encode()) + self.wfile.write(frame) + self.wfile.write(b"\r\n") + time.sleep(0.06) + except (BrokenPipeError, ConnectionResetError): + pass + else: + self._send(404, "text/plain", b"not found") + + def do_POST(self): + path = urlparse(self.path).path + if path == "/command": + n = int(self.headers.get("Content-Length", "0")) + q = parse_qs(self.rfile.read(n).decode()) + instr = q.get("instruction", [""])[0].strip() + ms = q.get("max_steps", ["0"])[0] + if instr: + STOP["flag"] = True + with LOCK: + PENDING.update(action="run", instruction=instr, max_steps=int(ms or 0)) + EVENT.set() + self.send_response(204) + self.end_headers() + elif path == "/select": + n = int(self.headers.get("Content-Length", "0")) + q = parse_qs(self.rfile.read(n).decode()) + suite = q.get("suite", [""])[0].strip() + tid = q.get("task_id", ["0"])[0] + if suite: + STOP["flag"] = True + with LOCK: + PENDING.update(action="select", suite=suite, task_id=int(tid or 0)) + EVENT.set() + self.send_response(204) + self.end_headers() + elif path == "/stop": + STOP["flag"] = True + self.send_response(204) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + + +def main(): + threading.Thread(target=engine_thread, daemon=True).start() + srv = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"interactive demo on http://0.0.0.0:{PORT} (ssh -L {PORT}:localhost:{PORT} )", flush=True) + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/interactive_server_rt.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/interactive_server_rt.py new file mode 100644 index 00000000..55d6dc86 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/interactive_server_rt.py @@ -0,0 +1,428 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Real-time (_RT) LIBERO demo (model-agnostic). + +The real-time sibling of interactive_server.py. The clean demo freezes the world during +each policy forward and fast-replays the chunk, hiding planner latency. Here planning is +decoupled from the simulator so you can SEE the latency: + + * a SIM thread owns the MuJoCo env and steps it at wall-clock RT_HZ (LIBERO's control + rate). Each tick it consumes one action from a shared buffer; if the buffer is empty + (the planner is still thinking) it applies a HOLD action so the robot stays put. + * a PLANNER thread runs policy.predict_action_chunk on a snapshot of the latest obs to + refill the buffer. It never touches the env (MuJoCo isn't thread-safe). + +HOLD is safe because LIBERO uses an OSC_POSE controller with control_delta=True: the 7-D +action is [dx,dy,dz, droll,dpitch,dyaw, gripper]. Zeroing the 6 delta dims => target == +current pose => the controller holds position. The gripper dim is absolute, so HOLD keeps +the LAST commanded gripper (don't drop what you're holding). Replaying the last motion +action would re-integrate the delta and drift, so HOLD must be zeros. + +The policy is chosen via POLICY_FACTORY=module:function (default RandomPolicy). Use the +environment dropdown to switch tasks; the last run's video stays on screen with a download +link. + +Env: SUITE, TASK_ID, SEED, PORT (8081), OUT_DIR (/sim_outputs), VIEW_RES (720), +VIDEO_RES (720), RENDER_RES (512, HD sim render), RT_HZ (20), RT_MAX_STEPS (1200), +MAX_STEPS (0=suite default), POLICY_FACTORY. +""" +import copy +import json +import os +import threading +import time +from collections import deque +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +import numpy as np + +from sim_libero.envutil import env_float, env_int, env_str +from sim_libero.libero_env import get_libero_dummy_action, get_libero_image, get_max_steps, list_envs +from sim_libero.policy import load_policy +from sim_libero.render import banner_frame, compose_view, encode_jpeg, save_mp4 +from sim_libero.scene import build_scene + +SUITE = env_str("SUITE", "libero_object") +TASK_ID = env_int("TASK_ID", 0) +SEED = env_int("SEED", 1000) +PORT = env_int("PORT", 8081) +VIEW_RES = env_int("VIEW_RES", 720) +VIDEO_RES = env_int("VIDEO_RES", 720) +RENDER_RES = env_int("RENDER_RES", 512) +RT_HZ = env_float("RT_HZ", 20) +RT_MAX_STEPS = env_int("RT_MAX_STEPS", 1200) +MAX_STEPS = env_int("MAX_STEPS", 0) # 0 -> per-suite default horizon +OUT_DIR = env_str("OUT_DIR", "/sim_outputs") +DT = 1.0 / RT_HZ + +STATE = { + "mode": "loading", "instruction": "", "scene_task": "", + "suite": SUITE, "task_id": TASK_ID, "objects": [], + "step": 0, "success": False, "holding": False, "buffer": 0, "hold_pct": 0.0, + "status": "starting", "frame": None, "video_url": "", +} +LOCK = threading.Lock() +PENDING = {"action": None, "instruction": "", "max_steps": 0, "suite": SUITE, "task_id": TASK_ID} +EVENT = threading.Event() +STOP = {"flag": False} + +_ENVS_CACHE = None + + +def _envs_json(): + global _ENVS_CACHE + if _ENVS_CACHE is None: + items = [] + for e in list_envs(): + desc = (e["description"] or "").strip() + label = f"{e['suite']} / task {e['task_id']}" + (f" - {desc[:44]}" if desc else "") + items.append({"value": f"suite={e['suite']}&task_id={e['task_id']}", "label": label}) + _ENVS_CACHE = items + return _ENVS_CACHE + + +def _set_frame(rgb): + with LOCK: + STATE["frame"] = encode_jpeg(rgb) + + +def engine_thread(): + os.makedirs(os.path.join(OUT_DIR, "interactive_rt"), exist_ok=True) + with LOCK: + STATE["status"] = "loading policy ..." + policy = load_policy() + + def show_idle(sc, keep_video=False): + obs = sc.reset() + upd = dict(mode="idle", status="idle - send an instruction", step=0, + success=False, suite=sc.suite, task_id=sc.task_id, + scene_task=sc.description, objects=sc.objects, instruction="", + holding=False, buffer=0, hold_pct=0.0) + if not keep_video: + upd["video_url"] = "" + with LOCK: + STATE.update(upd) + if not keep_video: + STATE.pop("_video_path", None) + _set_frame(compose_view(get_libero_image(obs), height=VIEW_RES)) + + scene = build_scene(SUITE, TASK_ID, seed=SEED, resolution=RENDER_RES) + + # Warm up the policy once so the first real episode isn't stalled by JIT/compile. + with LOCK: + STATE["status"] = "warming up policy ..." + try: + wobs = scene.reset() + policy.reset(scene.description) + policy.warmup(wobs, scene.description or "pick up the object") + except Exception as e: # noqa: BLE001 + print("warmup failed:", e, flush=True) + show_idle(scene) + + def run_command(sc, instruction, max_steps): + with LOCK: + STATE.update(mode="running", instruction=instruction, step=0, success=False, + status=f"running: {instruction}", video_url="", holding=False) + STATE.pop("_video_path", None) + STOP["flag"] = False + replan_steps = int(getattr(policy, "replan_steps", 5)) + num_steps_wait = int(getattr(policy, "num_steps_wait", 5)) + horizon = max_steps or get_max_steps(sc.suite) + max_steps_eff = horizon + num_steps_wait + if not max_steps: + max_steps_eff = min(max_steps_eff, RT_MAX_STEPS) + + obs = sc.reset() + policy.reset(instruction) + + buf = deque() + buflock = threading.Lock() + shared = {"obs": copy.deepcopy(obs), "last_gripper": -1.0, "done": False, + "hold": 0, "total": 0, "infer_ms": 0.0} + frames = [] + + def planner(): + while not STOP["flag"] and not shared["done"]: + with buflock: + have = len(buf) + if have > 0: + time.sleep(0.005) + continue + with LOCK: + snap = shared["obs"] + t0 = time.perf_counter() + try: + chunk = policy.predict_action_chunk(snap, instruction) + except Exception as e: # noqa: BLE001 + print("planner predict failed:", e, flush=True) + time.sleep(0.02) + continue + shared["infer_ms"] = round((time.perf_counter() - t0) * 1000.0, 0) + with buflock: + for a in chunk[:replan_steps]: + buf.append(np.asarray(a, dtype=np.float32)) + + pth = threading.Thread(target=planner, daemon=True) + pth.start() + + t = 0 + done = False + next_tick = time.perf_counter() + while t < max_steps_eff and not STOP["flag"]: + if t < num_steps_wait: + obs, _, done, _ = sc.env.step(get_libero_dummy_action()) + else: + with buflock: + a = buf.popleft() if buf else None + holding = a is None + if holding: + a = np.array([0, 0, 0, 0, 0, 0, shared["last_gripper"]], dtype=np.float32) + else: + shared["last_gripper"] = float(a[6]) + obs, _, done, _ = sc.env.step(list(a)) + shared["total"] += 1 + shared["hold"] += int(holding) + + with LOCK: + shared["obs"] = copy.deepcopy(obs) + imgs = get_libero_image(obs) + rgb = compose_view(imgs, height=VIEW_RES) + _set_frame(rgb) + tag = "THINKING" if (t >= num_steps_wait and holding) else "" + frames.append(banner_frame(compose_view(imgs), instruction, VIDEO_RES, tag=tag)) + with buflock: + bufn = len(buf) + with LOCK: + STATE.update(step=t, holding=bool(tag), buffer=bufn, + hold_pct=round(100.0 * shared["hold"] / max(1, shared["total"]), 0)) + t += 1 + if done: + shared["done"] = True + break + next_tick += DT + sleep = next_tick - time.perf_counter() + if sleep > 0: + time.sleep(sleep) + else: + next_tick = time.perf_counter() + + shared["done"] = True + success = bool(shared["done"] and done) + pth.join(timeout=2.0) + + url = "" + if frames: + ts = datetime.now().strftime("%H%M%S") + name = f"interactive_rt/{ts}_{sc.suite}_{sc.task_id}_{'ok' if success else 'run'}.mp4" + path = os.path.join(OUT_DIR, name) + try: + save_mp4(frames, path, fps=RT_HZ) + url = "/video?ts=" + ts + with LOCK: + STATE["_video_path"] = path + except Exception as e: # noqa: BLE001 + print("video save failed:", e, flush=True) + + hp = 100.0 * shared["hold"] / max(1, shared["total"]) + print(f"[rt] steps={shared['total']} hold%={hp:.0f} success={success}", flush=True) + with LOCK: + STATE.update(mode="idle", success=success, video_url=url, holding=False, + status=("success" if success else ("stopped" if STOP["flag"] else "done"))) + show_idle(sc, keep_video=True) + + while True: + EVENT.wait() + EVENT.clear() + with LOCK: + action = PENDING["action"] + instruction = PENDING["instruction"] + max_steps = int(PENDING.get("max_steps", 0) or 0) + sel_suite = PENDING.get("suite", SUITE) + sel_task = int(PENDING.get("task_id", 0) or 0) + PENDING["action"] = None + if action == "select": + STOP["flag"] = True + with LOCK: + STATE["status"] = f"loading scene {sel_suite}/{sel_task} ..." + try: + new_scene = build_scene(sel_suite, sel_task, seed=SEED, resolution=RENDER_RES) + except Exception as e: # noqa: BLE001 + with LOCK: + STATE["status"] = f"scene build failed: {e}" + continue + scene.close() + scene = new_scene + show_idle(scene) + elif action == "run": + run_command(scene, instruction, max_steps) + + +PAGE = b""" +FastWAM sim - LIBERO (REAL-TIME) +
+

FastWAM simulator - LIBERO (REAL-TIME)

+

The simulator runs at wall-clock speed; the robot pauses (THINKING) while the policy plans, then resumes when the action buffer refills.

+sim +
+ + + +
+
+ environment + + +
+
status: loading...
+
scene
+
+
+""" + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, code, ctype, body, extra=None): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = urlparse(self.path).path + if path == "/": + self._send(200, "text/html; charset=utf-8", PAGE) + elif path == "/envs": + self._send(200, "application/json", json.dumps(_envs_json()).encode()) + elif path == "/status": + with LOCK: + s = {k: STATE[k] for k in ("mode", "status", "instruction", "scene_task", + "suite", "task_id", "objects", "step", + "success", "holding", "buffer", "hold_pct", "video_url")} + self._send(200, "application/json", json.dumps(s).encode()) + elif path == "/video": + with LOCK: + p = STATE.get("_video_path") + if p and os.path.exists(p): + with open(p, "rb") as f: + self._send(200, "video/mp4", f.read(), + extra={"Content-Disposition": "attachment; filename=fastwam_libero_rt.mp4"}) + else: + self._send(404, "text/plain", b"no video") + elif path == "/stream": + self.send_response(200) + self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") + self.end_headers() + try: + while True: + with LOCK: + frame = STATE["frame"] + if frame: + self.wfile.write(b"--frame\r\nContent-Type: image/jpeg\r\n") + self.wfile.write(f"Content-Length: {len(frame)}\r\n\r\n".encode()) + self.wfile.write(frame) + self.wfile.write(b"\r\n") + time.sleep(0.04) + except (BrokenPipeError, ConnectionResetError): + pass + else: + self._send(404, "text/plain", b"not found") + + def do_POST(self): + path = urlparse(self.path).path + if path == "/command": + n = int(self.headers.get("Content-Length", "0")) + q = parse_qs(self.rfile.read(n).decode()) + instr = q.get("instruction", [""])[0].strip() + ms = q.get("max_steps", ["0"])[0] + if instr: + STOP["flag"] = True + with LOCK: + PENDING.update(action="run", instruction=instr, max_steps=int(ms or 0)) + EVENT.set() + self.send_response(204) + self.end_headers() + elif path == "/select": + n = int(self.headers.get("Content-Length", "0")) + q = parse_qs(self.rfile.read(n).decode()) + suite = q.get("suite", [""])[0].strip() + tid = q.get("task_id", ["0"])[0] + if suite: + STOP["flag"] = True + with LOCK: + PENDING.update(action="select", suite=suite, task_id=int(tid or 0)) + EVENT.set() + self.send_response(204) + self.end_headers() + elif path == "/stop": + STOP["flag"] = True + self.send_response(204) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + + +def main(): + threading.Thread(target=engine_thread, daemon=True).start() + srv = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"real-time demo on http://0.0.0.0:{PORT} (ssh -L {PORT}:localhost:{PORT} )", flush=True) + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/libero_env.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/libero_env.py new file mode 100644 index 00000000..c6171e31 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/libero_env.py @@ -0,0 +1,107 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Model-agnostic LIBERO environment glue for the simulation/libero package. + +Vendored from FastWAM's experiments/libero/libero_utils.py so this simulator package has +zero dependency on any policy/model repo. Provides just the pieces a closed-loop or +interactive harness needs: build an env for a benchmark task, pull the agentview+wrist +image, the no-op action, per-suite horizons, and the list of shipped suites. +""" +import contextlib +import logging +import os +import pathlib +import warnings + +# Quiet the noisy third-party import chatter (not errors). robosuite logs a "no private +# macro file" WARNING via its logger; gym prints its "unmaintained / NumPy 2.0" notice +# straight to stderr at import time (NOT through warnings, so a filter can't catch it). +# Suppress robosuite by raising its logger to ERROR, and gym by eagerly importing it once +# with stderr redirected -- later imports (by libero/robosuite) hit the module cache and +# stay quiet. Real errors still propagate (logger level is ERROR, not CRITICAL). +for _name in ("robosuite_logs", "robosuite"): + logging.getLogger(_name).setLevel(logging.ERROR) +with contextlib.redirect_stderr(open(os.devnull, "w")): + try: + import gym # noqa: F401 + except Exception: # noqa: BLE001 + pass +# Set the warnings filter AFTER importing gym: gym resets the warnings registry on import, +# which would otherwise wipe this filter and let robosuite's deprecated-.warn() notice leak. +warnings.filterwarnings("ignore", category=DeprecationWarning) + +import numpy as np +from libero.libero import benchmark as _benchmark +from libero.libero import get_libero_path +from libero.libero.envs import OffScreenRenderEnv + +LIBERO_ENV_RESOLUTION = 256 # resolution used to render training data + +SUITES = ["libero_object", "libero_goal", "libero_spatial", "libero_10", "libero_90"] + +_SUITE_MAX_STEPS = { + "libero_spatial": 400, + "libero_object": 400, + "libero_goal": 400, + "libero_10": 700, + "libero_90": 700, +} + + +def get_max_steps(task_suite_name): + if task_suite_name not in _SUITE_MAX_STEPS: + raise ValueError(f"Unknown task suite: {task_suite_name}") + return _SUITE_MAX_STEPS[task_suite_name] + + +def get_benchmark_dict(): + return _benchmark.get_benchmark_dict() + + +def list_envs(): + """Enumerate every shipped (suite, task_id, description) for the env-picker dropdown. + + Cheap: reads task metadata from the benchmark registry without building any sim env. + """ + out = [] + bench = get_benchmark_dict() + for suite in SUITES: + try: + task_suite = bench[suite]() + n_tasks = int(getattr(task_suite, "n_tasks", 0)) + for task_id in range(n_tasks): + out.append({ + "suite": suite, + "task_id": task_id, + "description": task_suite.get_task(task_id).language, + }) + except Exception: # noqa: BLE001 - skip a suite that fails to enumerate + continue + return out + + +def get_libero_env(task, resolution, seed): + """Initialize a single OffScreenRenderEnv for a task; returns (env, description).""" + task_description = task.language + task_bddl_file = ( + pathlib.Path(get_libero_path("bddl_files")) / task.problem_folder / task.bddl_file + ) + env = OffScreenRenderEnv( + bddl_file_name=task_bddl_file, + camera_heights=resolution, + camera_widths=resolution, + ) + env.seed(seed) # seed affects object positions even with a fixed initial state + return env, task_description + + +def get_libero_dummy_action(): + """No-op action (open gripper) used to settle the sim while the robot does nothing.""" + return [0, 0, 0, 0, 0, 0, -1] + + +def get_libero_image(obs): + """Extract + preprocess the agentview and wrist images (rotate 180 to match training).""" + img = np.ascontiguousarray(obs["agentview_image"][::-1, ::-1]) + wrist_img = np.ascontiguousarray(obs["robot0_eye_in_hand_image"][::-1, ::-1]) + return {"image": img, "wrist_image": wrist_img} diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/policy.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/policy.py new file mode 100644 index 00000000..b16afe0c --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/policy.py @@ -0,0 +1,53 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Model-agnostic policy interface for the LIBERO simulator harness. + +A policy plugs into the closed-loop / interactive harness by implementing this ABC. The +harness owns the env, rendering, streaming and the episode loop; the policy only turns an +observation + instruction into an action chunk. Any model (FastWAM, MolmoACT2, VLA-JEPA, +...) ships a factory `build_policy() -> Policy` and is selected at runtime via the +`POLICY_FACTORY=module:function` env var (default: the built-in RandomPolicy). +""" +import importlib +from abc import ABC, abstractmethod + +from sim_libero.envutil import env_str + + +class Policy(ABC): + """Turns (obs, instruction) into a [T, action_dim] chunk. Harness executes it.""" + + # LIBERO OSC_POSE control cadence knobs the harness reads (a model may override). + replan_steps = 5 # env steps executed per predicted chunk before replanning + num_steps_wait = 5 # no-op settle steps at episode start + name = "policy" + + def reset(self, instruction): + """Called once per episode before the first prediction (clear caches, etc.).""" + + @abstractmethod + def predict_action_chunk(self, obs, instruction): + """Return an ndarray of shape [T, 7] (dx,dy,dz,droll,dpitch,dyaw, gripper).""" + + def warmup(self, obs, instruction): + """Optional one-time forward so the first real episode isn't stalled.""" + try: + self.predict_action_chunk(obs, instruction) + except Exception: # noqa: BLE001 - warmup is best-effort + pass + + +def load_policy(): + """Instantiate the policy named by POLICY_FACTORY=module:function (default RandomPolicy).""" + spec = env_str("POLICY_FACTORY", "sim_libero.random_policy:build_policy") + if ":" not in spec: + raise ValueError(f"POLICY_FACTORY must be 'module:function', got {spec!r}") + module_name, fn_name = spec.split(":", 1) + factory = getattr(importlib.import_module(module_name), fn_name) + policy = factory() + if not isinstance(policy, Policy): + raise TypeError(f"{spec} did not return a sim_libero.Policy (got {type(policy)})") + return policy + + +__all__ = ["Policy", "load_policy"] diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/random_policy.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/random_policy.py new file mode 100644 index 00000000..7d85bc5a --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/random_policy.py @@ -0,0 +1,31 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Built-in no-model policy for simulator sanity checks. + +Emits small random end-effector deltas so the arm visibly moves, proving the LIBERO +env renders and steps without any learned model. This is the default policy when +POLICY_FACTORY is unset. +""" +import numpy as np + +from sim_libero.policy import Policy + + +class RandomPolicy(Policy): + name = "random" + + def __init__(self, scale=0.15, seed=0): + self.scale = scale + self.rng = np.random.default_rng(seed) + + def reset(self, instruction): + pass + + def predict_action_chunk(self, obs, instruction): + deltas = self.rng.uniform(-self.scale, self.scale, size=(self.replan_steps, 6)) + gripper = self.rng.choice([-1.0, 1.0], size=(self.replan_steps, 1)) + return np.concatenate([deltas, gripper], axis=1).astype(np.float32) + + +def build_policy(): + return RandomPolicy() diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/render.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/render.py new file mode 100644 index 00000000..a2204281 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/render.py @@ -0,0 +1,89 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Shared rendering helpers for the LIBERO interactive/sanity harness. + +Pure-stdlib + Pillow/imageio; no torch, no policy code. Handles MJPEG frame encoding, +the composed agentview|wrist viewport, the command banner, and even-dimension MP4 saving +(FFmpeg's yuv420p encoder rejects odd width/height). +""" +import io +import os + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + + +def font(size): + for p in ( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ): + if os.path.exists(p): + return ImageFont.truetype(p, size) + return ImageFont.load_default() + + +def encode_jpeg(rgb, quality=88): + buf = io.BytesIO() + Image.fromarray(np.ascontiguousarray(rgb)).save(buf, format="JPEG", quality=quality) + return buf.getvalue() + + +def compose_view(imgs, height=None): + """Stitch the {image, wrist_image} dict from get_libero_image side-by-side. + + Optionally upscale to `height` px tall for a crisper live viewport (aspect kept). + """ + parts = [] + for key in ("image", "wrist_image"): + if key in imgs: + arr = np.asarray(imgs[key]) + parts.append(arr) + view = np.concatenate(parts, axis=1) if len(parts) > 1 else parts[0] + if height is not None and view.shape[0] != height: + w = int(round(view.shape[1] * height / view.shape[0])) + view = np.asarray(Image.fromarray(view).resize((w, height), Image.BILINEAR)) + return np.ascontiguousarray(view) + + +def banner_frame(rgb, text, size, tag=""): + """Downscale the frame to `size` px wide and add a top banner with the command. + + Output width/height are forced even so the H.264 yuv420p encoder accepts them. + """ + img = Image.fromarray(np.ascontiguousarray(rgb)) + w = size + h = int(round(img.height * size / img.width)) + w += w % 2 + h += h % 2 + img = img.resize((w, h), Image.BILINEAR) + bh = max(40, h // 10) + bh += bh % 2 + canvas = Image.new("RGB", (w, h + bh), (15, 15, 18)) + canvas.paste(img, (0, bh)) + d = ImageDraw.Draw(canvas) + f = font(max(14, w // 40)) + cap = 44 if tag else 70 + msg = text if len(text) <= cap else text[: cap - 3] + "..." + d.text((10, bh // 2), msg, fill=(240, 240, 240), font=f, anchor="lm") + if tag: + color = (255, 180, 80) if tag == "THINKING" else (120, 210, 140) + d.text((w - 10, bh // 2), tag, fill=color, font=f, anchor="rm") + return np.asarray(canvas) + + +def save_mp4(frames, path, fps=20): + """Save RGB frames to an MP4 (H.264, yuv420p). Frames must share even dimensions. + + imageio-ffmpeg already injects `-pix_fmt yuv420p` for libx264, so we set it via the + writer's `pixelformat` (not output_params) to avoid ffmpeg's "Multiple -pix_fmt + options" warning from passing it twice. + """ + import imageio + + with imageio.get_writer( + path, fps=fps, codec="libx264", quality=8, + macro_block_size=1, pixelformat="yuv420p", + ) as w: + for fr in frames: + w.append_data(np.ascontiguousarray(fr)) diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/rollout.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/rollout.py new file mode 100644 index 00000000..46cddcf8 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/rollout.py @@ -0,0 +1,55 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Model-agnostic LIBERO episode loop (chunk-replay). + +Mirrors FastWAM's validated eval stepping exactly, but calls a generic Policy for the +action chunk instead of a hard-wired model: settle for `num_steps_wait` no-ops, then +repeatedly predict a chunk and execute its first `replan_steps` actions, rendering each +step through an `on_frame` callback. Used by both the sanity runner and the clean +interactive server. +""" +from sim_libero.libero_env import get_libero_dummy_action, get_libero_image, get_max_steps + + +def run_episode(scene, policy, instruction, on_frame=None, should_stop=None, max_steps=None): + """Run one episode; returns (success, num_model_steps). + + on_frame(view_dict, step_idx, holding) is called every executed step. + should_stop() -> True aborts early (interactive Stop button). + """ + replan_steps = int(getattr(policy, "replan_steps", 5)) + num_steps_wait = int(getattr(policy, "num_steps_wait", 5)) + if max_steps is None: + max_steps = get_max_steps(scene.suite) + + obs = scene.reset() + policy.reset(instruction) + + pending = [] + success = False + model_steps = 0 + t = 0 + while t < max_steps + num_steps_wait: + if should_stop is not None and should_stop(): + break + if t < num_steps_wait: + obs, _, done, _ = scene.env.step(get_libero_dummy_action()) + t += 1 + continue + + if not pending: + chunk = policy.predict_action_chunk(obs, instruction) + pending = [list(a) for a in chunk[:replan_steps]] + model_steps += 1 + + imgs = get_libero_image(obs) + if on_frame is not None: + on_frame(imgs, t, False) + + obs, _, done, _ = scene.env.step(pending.pop(0)) + t += 1 + if done: + success = True + break + + return success, model_steps diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/sanity.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/sanity.py new file mode 100644 index 00000000..04dd25ca --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/sanity.py @@ -0,0 +1,58 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Headless sanity rollout for the LIBERO simulator base image. + +Loads a policy (default: built-in RandomPolicy), rolls it through one scene for a +bounded number of steps, and saves an MP4 of the composed agentview|wrist view. Proves +the ROCm/EGL render + MuJoCo step + video encode path work end-to-end with no model. + +Env: SUITE, TASK_ID, SEED, STEPS, OUT_DIR, POLICY_FACTORY (module:function). +""" +import os +from datetime import datetime + +from sim_libero.envutil import env_int, env_str +from sim_libero.policy import load_policy +from sim_libero.render import banner_frame, compose_view, save_mp4 +from sim_libero.rollout import run_episode +from sim_libero.scene import build_scene + + +def main(): + suite = env_str("SUITE", "libero_object") + task_id = env_int("TASK_ID", 0) + seed = env_int("SEED", 1000) + steps = env_int("STEPS", 80) + out_dir = env_str("OUT_DIR", "/sim_outputs") + os.makedirs(out_dir, exist_ok=True) + + print(f"[sanity] building scene {suite}/{task_id} (seed={seed}) ...", flush=True) + scene = build_scene(suite, task_id, seed=seed) + print(f"[sanity] scene ready: \"{scene.description}\"", flush=True) + + policy = load_policy() + print(f"[sanity] policy: {getattr(policy, 'name', type(policy).__name__)}", flush=True) + + frames = [] + + def on_frame(imgs, step, holding): + frames.append(banner_frame(compose_view(imgs), f"{policy.name}: {scene.description}", 640)) + + success, model_steps = run_episode( + scene, policy, scene.description, on_frame=on_frame, max_steps=steps + ) + + ts = datetime.now().strftime("%H%M%S") + path = os.path.join(out_dir, f"sanity_{suite}_{task_id}_{ts}.mp4") + if frames: + save_mp4(frames, path, fps=20) + print(f"[sanity] OK: {len(frames)} frames, {model_steps} model calls, success={success}", flush=True) + print(f"[sanity] saved {path}", flush=True) + else: + raise RuntimeError("no frames rendered") + + scene.close() + + +if __name__ == "__main__": + main() diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero/scene.py b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/scene.py new file mode 100644 index 00000000..4bd5fade --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero/scene.py @@ -0,0 +1,104 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""LIBERO scene wrapper for the interactive/sanity harness (model-agnostic). + +Builds a single OffScreenRenderEnv for a (suite, task_id) via the vendored glue and +exposes the scene's native instruction, initial state, and object list. No policy or +model code here. +""" +import numpy as np + +from sim_libero._torch_compat import patch_torch_load +from sim_libero.libero_env import ( + LIBERO_ENV_RESOLUTION, + get_benchmark_dict, + get_libero_env, + get_libero_image, +) + + +class Scene: + def __init__(self, suite, task_id, seed=1000, resolution=LIBERO_ENV_RESOLUTION): + self.suite = suite + self.task_id = int(task_id) + self.seed = int(seed) + self.resolution = int(resolution) + + # Re-assert the torch.load shim at the point of use: LIBERO's get_task_init_states() loads + # numpy-pickled init states, which torch>=2.6 rejects under the new weights_only=True + # default. The package-level patch can be clobbered by heavy model-load imports that run + # before a scene is built (e.g. loading the FastWAM policy), so patch again right here. + patch_torch_load() + + benchmark_dict = get_benchmark_dict() + task_suite = benchmark_dict[suite]() + self.task = task_suite.get_task(self.task_id) + self.init_states = task_suite.get_task_init_states(self.task_id) + self.env, self.description = get_libero_env(self.task, self.resolution, self.seed) + self.objects = self._list_objects() + + def _list_objects(self): + """Best-effort manipulable-object names for the viewport panel. + + Tries the benchmark task's declared objects of interest, then falls back to parsing + the task's BDDL `(:objects ...)` block (instance names like `akita_black_bowl_1`, + normalised to `akita black bowl`). Cosmetic only, so any failure yields []. + """ + try: + names = list(getattr(self.task, "object_of_interest", []) or []) + if names: + return self._clean_names(names) + except Exception: + pass + try: + import pathlib + import re + + from sim_libero.libero_env import get_libero_path + + bddl = (pathlib.Path(get_libero_path("bddl_files")) + / self.task.problem_folder / self.task.bddl_file) + block = re.search(r"\(:objects(.*?)\)", bddl.read_text(), re.S) + names = [] + if block: + for line in block.group(1).splitlines(): + line = line.strip() + if not line or line.startswith(";"): + continue + names.extend(line.split(" - ")[0].split()) + if names: + return self._clean_names(names) + except Exception: + pass + return [] + + @staticmethod + def _clean_names(names): + import re + seen, out = set(), [] + for n in names: + base = re.sub(r"_\d+$", "", str(n)).replace("_", " ").strip() + if base and base not in seen: + seen.add(base) + out.append(base) + return out + + def reset(self): + """Reset to the task's first initial state; returns the raw obs dict.""" + self.env.reset() + idx = 0 if len(self.init_states) else None + obs = self.env.set_init_state(self.init_states[idx]) if idx is not None else self.env.reset() + return obs + + def view(self, obs): + return get_libero_image(obs) + + def close(self): + try: + self.env.close() + except Exception: + pass + + +def build_scene(suite, task_id, seed=1000, resolution=LIBERO_ENV_RESOLUTION): + return Scene(suite, task_id, seed=seed, resolution=resolution) diff --git a/dockerfiles/Courses/Finetuning/fastwam/sim_libero_compat.pth b/dockerfiles/Courses/Finetuning/fastwam/sim_libero_compat.pth new file mode 100644 index 00000000..759f14ad --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/sim_libero_compat.pth @@ -0,0 +1 @@ +import sys; exec("try:\n import os\n if os.path.isdir('/opt/sim') and '/opt/sim' not in sys.path: sys.path.insert(0, '/opt/sim')\n import sim_libero._torch_compat as _c; _c.patch_torch_load()\nexcept Exception: pass") diff --git a/dockerfiles/Courses/Finetuning/fastwam/strip_cuda_torch.py b/dockerfiles/Courses/Finetuning/fastwam/strip_cuda_torch.py new file mode 100644 index 00000000..b8f66354 --- /dev/null +++ b/dockerfiles/Courses/Finetuning/fastwam/strip_cuda_torch.py @@ -0,0 +1,35 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Strip the CUDA torch stack + numpy pins from FastWAM's pyproject so `pip install -e .` +cannot pull cu128 wheels over the base image's ROCm torch build, nor force a numpy +downgrade that conflicts with the base image's numpy. + +Removes any dependency line for torch / torchvision / torchcodec / numpy; the base image's +versions are then held via the PIP_CONSTRAINT pin in the Dockerfile (see the "Pin the base's +torch + numpy" note there). This lets the one FastWAM layer compose on a numpy-1.26 simulator +base and on a numpy-2.x plain ROCm base identically. Everything else (the exact upstream pins) +is preserved so the direct port stays faithful. +""" +import re +import sys +from pathlib import Path + +STRIP = ("torch", "torchvision", "torchcodec", "numpy") + + +def main() -> int: + path = Path(sys.argv[1] if len(sys.argv) > 1 else "pyproject.toml") + pat = re.compile(r'^\s*"(' + "|".join(STRIP) + r')\s*[=<>!~]') + kept, removed = [], [] + for line in path.read_text().splitlines(): + if pat.match(line): + removed.append(line.strip()) + else: + kept.append(line) + path.write_text("\n".join(kept) + "\n") + print("stripped CUDA torch pins:", removed or "(none found)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dockerfiles/Courses/LocalInference/.dockerignore b/dockerfiles/Courses/LocalInference/.dockerignore new file mode 100644 index 00000000..a153c0b0 --- /dev/null +++ b/dockerfiles/Courses/LocalInference/.dockerignore @@ -0,0 +1,23 @@ +# build.sh copies projects/LocalInference into ./course_data, so anything a +# local test run leaves behind in the source tree would otherwise be baked +# into /ryzers/notebooks. Keep generated artifacts out of the image. + +# Python bytecode and tool caches +**/__pycache__ +**/*.pyc +**/.pytest_cache +**/.ruff_cache +**/.mypy_cache + +# Notebook checkpoint snapshots +**/.ipynb_checkpoints + +# Local editor and OS metadata +**/.DS_Store +**/.vscode +**/.idea + +# Temporary files +**/*.tmp +**/*.swp +**/*.swo diff --git a/dockerfiles/Courses/LocalInference/Dockerfile b/dockerfiles/Courses/LocalInference/Dockerfile new file mode 100644 index 00000000..f6f8678c --- /dev/null +++ b/dockerfiles/Courses/LocalInference/Dockerfile @@ -0,0 +1,669 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +ARG BASE_IMAGE=ghcr.io/amdresearch/auplc-base:latest +FROM ${BASE_IMAGE} + +USER root +SHELL ["/bin/bash", "-c"] + +ARG ROS_DISTRO=jazzy +ARG RAI_COMMIT=e54f8ca + +ENV DEBIAN_FRONTEND=noninteractive \ + ROS_DISTRO=${ROS_DISTRO} \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 + +# ROS 2 Jazzy and development tools. +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + gnupg2 \ + locales \ + python3-venv \ + software-properties-common \ + && locale-gen en_US en_US.UTF-8 \ + && update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 \ + && add-apt-repository universe \ + && ROS_APT_SOURCE_VERSION="$(curl -fsSL https://api.github.com/repos/ros-infrastructure/ros-apt-source/releases/latest \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" \ + && curl -fsSL -o /tmp/ros2-apt-source.deb \ + "https://github.com/ros-infrastructure/ros-apt-source/releases/download/${ROS_APT_SOURCE_VERSION}/ros2-apt-source_${ROS_APT_SOURCE_VERSION}.$(. /etc/os-release && echo "${VERSION_CODENAME}")_all.deb" \ + && dpkg -i /tmp/ros2-apt-source.deb \ + && rm /tmp/ros2-apt-source.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + ros-${ROS_DISTRO}-desktop \ + ros-dev-tools \ + ros-${ROS_DISTRO}-cv-bridge \ + ros-${ROS_DISTRO}-image-transport \ + ros-${ROS_DISTRO}-v4l2-camera \ + ros-${ROS_DISTRO}-web-video-server \ + && rm -rf /var/lib/apt/lists/* + +# Vulkan and O3DE. +RUN curl -fsSL https://packages.lunarg.com/lunarg-signing-key-pub.asc \ + -o /etc/apt/trusted.gpg.d/lunarg.asc \ + && curl -fsSL https://packages.lunarg.com/vulkan/lunarg-vulkan-noble.list \ + -o /etc/apt/sources.list.d/lunarg-vulkan-noble.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends vulkan-sdk \ + && curl -fsSL https://o3debinaries.org/main/Latest/Linux/o3de_2510_0.deb \ + -o /tmp/o3de.deb \ + && apt-get install -y /tmp/o3de.deb \ + && rm /tmp/o3de.deb \ + && rm -rf /var/lib/apt/lists/* + +# RAI framework and ROS dependencies. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + git \ + libgl1-mesa-dri \ + libportaudio2 \ + libvulkan1 \ + mesa-vulkan-drivers \ + ros-${ROS_DISTRO}-ackermann-msgs \ + ros-${ROS_DISTRO}-depth-image-proc \ + ros-${ROS_DISTRO}-gazebo-msgs \ + ros-${ROS_DISTRO}-moveit \ + ros-${ROS_DISTRO}-moveit-resources-panda-moveit-config \ + ros-${ROS_DISTRO}-rai-interfaces \ + ros-${ROS_DISTRO}-sdformat-urdf \ + unzip \ + vulkan-tools \ + weston \ + xwayland \ + && rm -rf /var/lib/apt/lists/* + +# Proxy the RAI Streamlit UI and camera stream through each JupyterHub user's +# existing notebook-server route. +RUN /usr/bin/python3 -m pip install --no-cache-dir --break-system-packages \ + jupyter-server-proxy + +ENV CARGO_HOME=/opt/cargo \ + RUSTUP_HOME=/opt/rustup \ + PATH="/opt/cargo/bin:/opt/rai-venv/bin:${PATH}" + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain stable --no-modify-path \ + && echo "/opt/ros/${ROS_DISTRO}/opt/sdformat_vendor/lib" \ + > /etc/ld.so.conf.d/ros-sdformat.conf \ + && ldconfig + +# Ownership is set here rather than in a final `chown -R`: on the overlayfs +# snapshotter, chowning a file from an earlier layer copies the whole file into +# the new layer, so a trailing recursive chown would duplicate every large tree +# in the image. Each layer therefore hands off its own files already owned. +RUN mkdir -p /ryzers \ + && git clone https://github.com/RobotecAI/rai.git /ryzers/rai \ + && git -C /ryzers/rai checkout "${RAI_COMMIT}" \ + && cd /ryzers/rai \ + && vcs import < ros_deps.repos \ + && vcs import < demos.repos \ + && chown -R jovyan:100 /ryzers + +# Reuse the ROCm torch from auplc-base and prevent Python dependencies from +# replacing it with CUDA wheels. +RUN python3 -m venv --system-site-packages /opt/rai-venv \ + && python3 -c "from importlib.metadata import version; \ +print('torch==' + version('torch')); \ +print('torchvision==' + version('torchvision')); \ +print('torchaudio==' + version('torchaudio'))" > /opt/torch-constraints.txt \ + && PIP_CONSTRAINT=/opt/torch-constraints.txt /opt/rai-venv/bin/pip install --no-cache-dir \ + catkin_pkg \ + lark \ + "numpy<2" \ + opencv-python \ + "setuptools<82" \ + "empy<4" \ + && PIP_CONSTRAINT=/opt/torch-constraints.txt /opt/rai-venv/bin/pip install \ + --no-cache-dir --no-build-isolation openai-whisper==20250625 + +WORKDIR /ryzers/rai +RUN sed -i \ + 's/ get_grabbing_point_tool: "GetGrabbingPointTool"/ get_grabbing_point_tool: Any/' \ + src/rai_core/rai/tools/ros2/manipulation/custom.py \ + && PIP_CONSTRAINT=/opt/torch-constraints.txt pip install --no-cache-dir \ + src/rai_core src/rai_whoami \ + && (rosdep init || true) \ + && rosdep update \ + && rosdep install \ + --from-paths src/examples/rai-manipulation-demo/ros2_ws/src \ + --ignore-src -r -y \ + && . ./scripts/download_demo.sh manipulation \ + && . /opt/ros/${ROS_DISTRO}/setup.sh \ + && colcon build --symlink-install \ + && PIP_CONSTRAINT=/opt/torch-constraints.txt pip install --no-cache-dir \ + src/rai_bench src/rai_sim \ + && PIP_CONSTRAINT=/opt/torch-constraints.txt pip install --no-cache-dir \ + "rai-gsam2>=1.1.2,<2" \ + "rf-groundingdino>=0.2.0,<0.3" \ + "scikit-learn>=1.0,<1.4" \ + "transformers<5" \ + && PIP_CONSTRAINT=/opt/torch-constraints.txt pip install --no-cache-dir \ + --no-deps rai-perception==0.3.0 \ + && RAI_PERCEPTION_DIR="$(python3 -c \ + 'from pathlib import Path; import rai_perception; print(Path(rai_perception.__file__).parent)')" \ + && sed -i \ + 's|Path.home() / Path(".cache/rai/")|Path("/opt/rai-cache")|g' \ + "${RAI_PERCEPTION_DIR}/agents/base_vision_agent.py" \ + "${RAI_PERCEPTION_DIR}/services/base_vision_service.py" \ + && sed -i \ + 's|Path.home() / Path(".cache/rai")|Path("/opt/rai-cache")|g' \ + "${RAI_PERCEPTION_DIR}/services/segmentation_service.py" \ + && mkdir -p /opt/rai-cache \ + && download-perception-models \ + && python3 -c \ + "import torch; assert torch.version.hip, 'RAI dependencies replaced ROCm torch'; print('RAI ROCm environment OK:', torch.__version__)" \ + && chown -R jovyan:100 /ryzers/rai + +# Lemonade local OpenAI-compatible model server. +RUN add-apt-repository ppa:lemonade-team/stable \ + && apt-get update \ + && apt-get install -y --no-install-recommends lemonade-server \ + && rm -rf /var/lib/apt/lists/* + +# Keep Lemonade's daemon metadata and Hugging Face model cache outside +# /home/jovyan. JupyterHub mounts each user's PVC over that home directory, +# which would otherwise hide image-baked weights and make every workshop user +# download their own copy. +ENV LEMONADE_CACHE=/opt/lemonade-cache/lemonade \ + LEMONADE_HF_HOME=/opt/lemonade-cache/huggingface + +# Models exercised by the workshop notebooks and script-first demos. Download +# only the quantizations Lemonade's built-in aliases use; pulling each complete +# GGUF repository would include many redundant quantizations. +# Downloaded as jovyan so the weights are owned correctly on creation. A later +# `chown -R` over this tree would copy all ~29 GB into an extra layer. +RUN install -d -o jovyan -g 100 \ + /opt/lemonade-cache "${LEMONADE_CACHE}" "${LEMONADE_HF_HOME}" \ + && runuser -u jovyan -- env HF_HOME="${LEMONADE_HF_HOME}" \ + /opt/rai-venv/bin/python3 - <<'PY' +from huggingface_hub import snapshot_download + +models = { + "unsloth/gemma-4-E2B-it-GGUF": [ + "config.json", + "gemma-4-E2B-it-Q4_K_M.gguf", + "mmproj-F16.gguf", + ], + "unsloth/gemma-4-E4B-it-GGUF": [ + "config.json", + "gemma-4-E4B-it-Q4_K_M.gguf", + "mmproj-F16.gguf", + ], + "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF": [ + "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf", + ], +} +for repository, files in models.items(): + snapshot_download(repository, allow_patterns=files) +PY + +# Lemonade's built-in Qwen3-Coder alias currently selects a different +# quantization. Register the workshop-proven Q4_K_M file under an explicit, +# stable loader name. Lemonade loads +# user.Qwen3-Coder-30B-A3B-Instruct-Q4_K_M; its OpenAI endpoint exposes the +# corresponding OpenCode id without the user. registry prefix. +RUN set -euo pipefail; \ + export HF_HOME="${LEMONADE_HF_HOME}"; \ + export XDG_RUNTIME_DIR=/tmp/lemonade-build-runtime; \ + mkdir -p "${XDG_RUNTIME_DIR}"; \ + chmod 700 "${XDG_RUNTIME_DIR}"; \ + lemond "${LEMONADE_CACHE}" > /tmp/lemond-build.log 2>&1 & \ + LEMOND_PID=$!; \ + trap 'kill "${LEMOND_PID}" 2>/dev/null || true' EXIT; \ + for _ in $(seq 1 60); do \ + lemonade status >/dev/null 2>&1 && break; \ + sleep 1; \ + done; \ + lemonade status >/dev/null 2>&1 \ + || { printf '%s\n' "lemond failed during Qwen registration:" >&2; \ + tail -n 100 /tmp/lemond-build.log >&2; exit 1; }; \ + lemonade pull user.Qwen3-Coder-30B-A3B-Instruct-Q4_K_M \ + --checkpoint main \ + unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF:Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf \ + --recipe llamacpp \ + --label coding; \ + kill "${LEMOND_PID}"; \ + wait "${LEMOND_PID}" || true; \ + trap - EXIT; \ + rm -rf "${XDG_RUNTIME_DIR}" /tmp/lemond-build.log; \ + : "lemond runs as root and drops .lemonade_registry.json beside the weights" \ + "under LEMONADE_HF_HOME, so the sweep covers the whole cache rather than" \ + "just LEMONADE_CACHE. Only root-owned files are touched, and they were" \ + "written in this layer, so nothing is copied up."; \ + find /opt/lemonade-cache -user root -exec chown jovyan:100 {} + + +# --------------------------------------------------------------------------- +# CaP-X (Code-as-Policies eXtended) - the code-generating manipulation agent +# behind 3_code_as_policy.ipynb. Robosuite/MuJoCo rendered +# headless through EGL on the iGPU, perception and IK served on localhost. +# +# CaP-X pins dependencies that would fight the RAI stack (numpy, transformers, +# matplotlib, mujoco), so it gets its own venv plus its own Jupyter kernel +# ("CaP-X (ROCm)", selected by the notebook) instead of sharing /opt/rai-venv. +# --system-site-packages keeps the base ROCm torch rather than pulling CUDA +# wheels, exactly as the RAI venv does. +# --------------------------------------------------------------------------- + +# Pinned to a tested CaP-X commit. +ARG CAPX_REF=53e9966d7a8e2fa7494676772bccc35280f5c0ed + +ENV CAPX_ROOT=/ryzers/cap-x \ + CAPX_VENV=/opt/capx-venv \ + CAPX_CACHE=/opt/capx-cache + +# EGL/GL stack for headless MuJoCo, pyrender and open3d. MuJoCo renders through +# EGL/OpenGL, not through HIP, so this is what puts frames on the iGPU with no +# display attached. +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake \ + libegl1 \ + libegl-mesa0 \ + libgl1 \ + libgles2 \ + libglew-dev \ + libglfw3 \ + libglfw3-dev \ + libosmesa6 \ + libosmesa6-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Constraints: the base ROCm torch (already captured for RAI) plus CaP-X's uv +# override-dependencies and the Python 3.12 Robosuite resolution from its +# lockfile. Robosuite 1.5.1 still calls the pre-3.10 mj_fullM API, so letting +# pip take the current MuJoCo release breaks startup. +# FIX: CaP-X also pins PyOpenGL-accelerate==3.1.6, which has no cp312 wheel and +# does not compile on Python 3.12 - left out here. +RUN cp /opt/torch-constraints.txt /opt/capx-constraints.txt \ + && printf '%s\n' \ + 'numpy==1.26.4' \ + 'PyOpenGL==3.1.6' \ + 'matplotlib>=3.10,<3.11' \ + 'opencv-python-headless<4.13' \ + 'jax==0.4.29' \ + 'jaxlib==0.4.29' \ + 'mujoco==3.5.0' \ + 'mink==1.1.0' \ + 'networkx==3.6.1' \ + 'pyglet==2.1.13' \ + >> /opt/capx-constraints.txt + +# Clone WITHOUT --recurse-submodules, then init only the submodules the +# Robosuite eval path uses. SAM2 and OWLv2 come from Transformers, so the gated +# SAM3 submodule is deliberately excluded along with curobo, b1k, verl and +# LIBERO-PRO. +RUN /usr/bin/python3 -m venv --system-site-packages ${CAPX_VENV} \ + && git clone https://github.com/capgym/cap-x ${CAPX_ROOT} \ + && git -C ${CAPX_ROOT} checkout ${CAPX_REF} \ + && git -C ${CAPX_ROOT} submodule update --init \ + capx/third_party/contact_graspnet_pytorch \ + capx/third_party/robosuite \ + && chown -R jovyan:100 ${CAPX_ROOT} + +WORKDIR /ryzers/cap-x + +# Base runtime deps: the PyPI-resolvable subset of [project.dependencies], +# minus torch/torchvision (kept from the base image) and the CUDA-only entries. +# Setuptools 77+ is needed because Robosuite pulls in pynput -> evdev, whose +# pyproject.toml uses the PEP 639 SPDX license form. The explicit lower bound +# also forces a copy into the venv instead of reusing the base image's older +# system setuptools. +RUN PIP_CONSTRAINT=/opt/capx-constraints.txt ${CAPX_VENV}/bin/pip install --no-cache-dir \ + "gymnasium>=0.29" "tyro>=0.8.5" "pydantic>=2.7" "rich>=13.7" \ + "scipy>=1.12" "matplotlib>=3.10,<3.11" "imageio[ffmpeg]>=0.6.0" \ + requests tqdm h5py einops decord pycocotools pyyaml omegaconf \ + open3d "robot_descriptions>=1.16.0" "yourdfpy>=0.0.56" "viser>=0.2.0" \ + "trimesh>=4.4.0" msgpack_numpy "ray==2.48.0" "mediapy>=1.2.5" \ + uvicorn fastapi "transformers<5.0" openai \ + "opencv-python-headless<4.13" "pillow>=10" cloudpickle \ + "setuptools>=77" \ + "mujoco==3.5.0" "mink==1.1.0" "networkx==3.6.1" "pyglet==2.1.13" \ + "pyliblzfse==0.4.1" freetype-py six \ + && : "CaP-X overrides pyrender's stale PyOpenGL==3.1.0 metadata to 3.1.6." \ + "pip cannot express uv's override, so install the locked pyrender" \ + "once all of its runtime dependencies are already present." \ + && ${CAPX_VENV}/bin/pip install --no-cache-dir --no-deps "pyrender==0.1.45" + +# PyRoKi: JAX-based IK / trajopt, and the default motion layer here - it +# replaces cuRobo for the Robosuite bench. Deliberately CPU-only, which keeps +# the GPU for perception. +RUN PIP_CONSTRAINT=/opt/capx-constraints.txt ${CAPX_VENV}/bin/pip install --no-cache-dir \ + "jax==0.4.29" "jaxlib==0.4.29" \ + jaxlie jax_dataclasses jaxtyping loguru termcolor \ + && PIP_CONSTRAINT=/opt/capx-constraints.txt ${CAPX_VENV}/bin/pip install \ + --no-cache-dir --no-deps \ + "git+https://github.com/brentyi/jaxls.git@6fe7cf9d56223736b2c0272d080ec7346203c648" \ + "git+https://github.com/chungmin99/pyroki.git@95afccc22658c461ab1042a048ae4e9c24bc2a47" + +# Perception and simulator. The ungated SAM2 and OWLv2 implementations are +# already part of CaP-X and use Transformers. Editable submodule installs use +# --no-build-isolation because their setup.py imports numpy at build time. +RUN : "contact_graspnet_pytorch ships only a setup.py, so pip would take the" \ + "legacy 'setup.py develop' path. Setuptools >=77 implements that by" \ + "re-invoking pip in a subprocess WITHOUT --no-build-isolation, which" \ + "then cannot see numpy. --use-pep517 keeps the build in this env." \ + && PIP_CONSTRAINT=/opt/capx-constraints.txt ${CAPX_VENV}/bin/pip install --no-cache-dir \ + --use-pep517 --no-build-isolation -e capx/third_party/contact_graspnet_pytorch \ + && PIP_CONSTRAINT=/opt/capx-constraints.txt ${CAPX_VENV}/bin/pip install --no-cache-dir \ + --no-build-isolation -e capx/third_party/robosuite \ + && : "capx itself goes in without deps - they are all handled above - so" \ + "this only puts the package on the path, editable so that students" \ + "can change a docstring and see the agent's prompt change." \ + && ${CAPX_VENV}/bin/pip install --no-cache-dir --no-deps --no-build-isolation -e . + +# CaP-X ships both perception paths but registers SAM3 by default. Make the +# workshop's API and four live scenarios use OWLv2 for text grounding followed +# by box-prompted SAM2 segmentation. +RUN ${CAPX_VENV}/bin/python - <<'PY' +from pathlib import Path + +registry = Path("capx/integrations/__init__.py") +text = registry.read_text() +old = 'register_api("FrankaControlApi", lambda env: FrankaControlApi(env, use_sam3=True))' +new = 'register_api("FrankaControlApi", lambda env: FrankaControlApi(env, use_sam3=False))' +assert text.count(old) == 1 +text = text.replace(old, new) +old = ( + "lambda env: FrankaControlSpillWipeApi(" + "env, tcp_offset=[0.0, 0.0, -0.0158], use_sam3=True)," +) +new = ( + "lambda env: FrankaControlSpillWipeApi(" + "env, tcp_offset=[0.0, 0.0, -0.0158], use_sam3=False)," +) +assert text.count(old) == 1 +registry.write_text(text.replace(old, new)) + +# ROCm takes several minutes to transfer these checkpoints to the APU when +# their CPU tensors are float32. Stage them as float16, then restore float32 +# on-device; the GPU-side conversion is effectively instantaneous and retains +# FP32 execution. The workshop's held-out rollouts validate the staged weights. +owl_server = Path("capx/serving/launch_owlvit_server.py") +text = owl_server.read_text() +old = ' try:\n if is_v2:\n' +new = ( + ' load_dtype = torch.float16 if device.startswith("cuda") else torch.float32\n\n' + ' try:\n' + ' if is_v2:\n' +) +assert text.count(old) == 1 +text = text.replace(old, new) +for model_class in ("Owlv2ForObjectDetection", "OwlViTForObjectDetection"): + old = f"_MODEL = {model_class}.from_pretrained(model_name)" + new = f"_MODEL = {model_class}.from_pretrained(model_name, dtype=load_dtype)" + assert text.count(old) == 1 + text = text.replace(old, new) +old = " _MODEL = _MODEL.to(device)\n _MODEL.eval()\n" +new = ( + " _MODEL = _MODEL.to(device)\n" + ' if device.startswith("cuda"):\n' + " _MODEL = _MODEL.float()\n" + " _MODEL.eval()\n" +) +assert text.count(old) == 1 +owl_server.write_text(text.replace(old, new)) + +sam_server = Path("capx/serving/launch_sam2_server.py") +text = sam_server.read_text() +old = " # Initialize Pipeline\n" +new = ( + ' load_dtype = torch.float16 if device_arg.startswith("cuda") else torch.float32\n\n' + " # Initialize Pipeline\n" +) +assert text.count(old) == 1 +text = text.replace(old, new) +old = ' _GENERATOR = pipeline("mask-generation", model=model_name, device=device_arg)\n' +new = ( + ' _GENERATOR = pipeline(\n' + ' "mask-generation", model=model_name, device=device_arg, dtype=load_dtype\n' + " )\n" +) +assert text.count(old) == 1 +text = text.replace(old, new) +old = " _MODEL = Sam2Model.from_pretrained(model_name).to(device_arg)\n" +new = " _MODEL = Sam2Model.from_pretrained(model_name, dtype=load_dtype).to(device_arg)\n" +assert text.count(old) == 1 +text = text.replace(old, new) +old = " elif hasattr(_MODEL, \"to\"):\n _MODEL = _MODEL.to(device_arg)\n\n" +new = ( + ' elif hasattr(_MODEL, "to"):\n' + " _MODEL = _MODEL.to(device_arg)\n" + ' if device_arg.startswith("cuda"):\n' + " _MODEL = _MODEL.float()\n\n" +) +assert text.count(old) == 1 +sam_server.write_text(text.replace(old, new)) + +old_server = """ - _target_: capx.serving.launch_sam3_server.main + device: cuda + port: 8114 + host: 127.0.0.1 +""" +new_servers = """ - _target_: capx.serving.launch_owlvit_server.main + model_name: google/owlv2-large-patch14-ensemble + device: cuda + port: 8117 + host: 127.0.0.1 + + - _target_: capx.serving.launch_sam2_server.main + model_name: facebook/sam2.1-hiera-large + device: cuda + port: 8113 + host: 127.0.0.1 +""" +fast_servers = """ - _target_: capx.serving.launch_owlvit_server.main + model_name: google/owlv2-base-patch16-ensemble + device: cuda + port: 8117 + host: 127.0.0.1 + + - _target_: capx.serving.launch_sam2_server.main + model_name: facebook/sam2.1-hiera-small + device: cuda + port: 8113 + host: 127.0.0.1 +""" +configs = [ + "env_configs/cube_lifting/franka_robosuite_cube_lifting.yaml", + "env_configs/cube_restack/franka_robosuite_cube_restack.yaml", + "env_configs/cube_stack/franka_robosuite_cube_stack.yaml", + "env_configs/spill_wipe/franka_robosuite_spill_wipe.yaml", +] +for config_name in configs: + config = Path(config_name) + text = config.read_text() + assert text.count(old_server) == 1, config + config.write_text(text.replace(old_server, new_servers)) + fast_config = config.with_name(f"{config.stem}_fast{config.suffix}") + fast_config.write_text(text.replace(old_server, fast_servers)) +PY + +# Pre-fetch the default large and optional fast perception profiles. +# Excluding duplicate .pt/.bin files keeps both profiles reasonably compact. +RUN install -d -o jovyan -g 100 "${CAPX_CACHE}" \ + && runuser -u jovyan -- env HF_HOME="${CAPX_CACHE}" \ + ${CAPX_VENV}/bin/python - <<'PY' +from huggingface_hub import snapshot_download + +snapshot_download( + "facebook/sam2.1-hiera-small", + allow_patterns=[ + "config.json", + "model.safetensors", + "preprocessor_config.json", + "processor_config.json", + "*.yaml", + "video_preprocessor_config.json", + ], +) +snapshot_download( + "google/owlv2-base-patch16-ensemble", + allow_patterns=[ + "added_tokens.json", + "config.json", + "merges.txt", + "model.safetensors", + "preprocessor_config.json", + "special_tokens_map.json", + "tokenizer_config.json", + "vocab.json", + ], +) +snapshot_download( + "facebook/sam2.1-hiera-large", + allow_patterns=[ + "config.json", + "model.safetensors", + "preprocessor_config.json", + "processor_config.json", + "*.yaml", + "video_preprocessor_config.json", + ], +) +snapshot_download( + "google/owlv2-large-patch14-ensemble", + allow_patterns=[ + "added_tokens.json", + "config.json", + "merges.txt", + "model.safetensors", + "preprocessor_config.json", + "special_tokens_map.json", + "tokenizer_config.json", + "vocab.json", + ], +) +PY + +# Loading the perception models onto the GPU can take several minutes on a cold +# start. Upstream begins trials after 120 seconds even when those services are +# still unavailable, which fails the first trials of every run. +RUN sed -i \ + 's/api_servers: list | None, wait_timeout: float = 120.0/api_servers: list | None, wait_timeout: float = 900.0/' \ + capx/envs/runner.py \ + && grep -q "wait_timeout: float = 900.0" capx/envs/runner.py + +# The CaP-X kernel. Its `env` is why the notebook needs no setup cell: the +# perception servers and launch.py are children of the kernel, so they inherit +# the baked perception cache and the headless GL backend from here. +RUN mkdir -p /usr/local/share/jupyter/kernels/capx \ + && printf '%s\n' \ + '{' \ + ' "argv": ["/opt/capx-venv/bin/python", "-m", "ipykernel_launcher", "-f", "{connection_file}"],' \ + ' "display_name": "CaP-X (ROCm)",' \ + ' "language": "python",' \ + ' "env": {' \ + ' "HF_HOME": "/opt/capx-cache",' \ + ' "MUJOCO_GL": "egl",' \ + ' "PYOPENGL_PLATFORM": "egl",' \ + ' "CAPX_ROOT": "/ryzers/cap-x"' \ + ' }' \ + '}' \ + > /usr/local/share/jupyter/kernels/capx/kernel.json \ + && MUJOCO_GL=egl ${CAPX_VENV}/bin/python -c \ + "import torch, capx, robosuite, mujoco, pyroki; \ +assert torch.version.hip, 'CaP-X dependencies replaced ROCm torch'; \ +print('CaP-X ROCm environment OK:', torch.__version__, '| robosuite', robosuite.__version__)" \ + && : "The editable installs and config rewrites above ran as root. Only" \ + "those files are still root-owned, and they were already copied into" \ + "their own layers when written, so fixing them up here is free." \ + && find ${CAPX_ROOT} -user root -exec chown jovyan:100 {} + + +# The RAI kernel. langchain_core and rai_core live only in /opt/rai-venv, so the +# stock "python3" kernelspec cannot run the RAI notebooks: its argv is a bare +# "python", and jupyter_client rewrites that to its own sys.executable +# (/usr/bin/python3) rather than resolving it from PATH. Naming the interpreter +# explicitly, as the CaP-X kernel does, is what makes those imports resolve. +# ROS and the colcon overlay still arrive through the environment that +# start-local-inference.sh sources before the notebook server starts. +RUN mkdir -p /usr/local/share/jupyter/kernels/rai \ + && printf '%s\n' \ + '{' \ + ' "argv": ["/opt/rai-venv/bin/python", "-m", "ipykernel_launcher", "-f", "{connection_file}"],' \ + ' "display_name": "RAI (ROCm)",' \ + ' "language": "python"' \ + '}' \ + > /usr/local/share/jupyter/kernels/rai/kernel.json \ + && /opt/rai-venv/bin/python -c \ + "import ipykernel, langchain_core; print('RAI kernel environment OK')" + +# HELIX drives repository-level evolution; OpenCode is its local coding-agent +# backend. Keep this after the expensive CaP-X/weights layers so workshop +# harness changes do not rebuild the simulator and perception stack. +ARG HELIX_REF=29cfa6e5eae902f6bc6d2113e51499e92c6109ee +ARG OPENCODE_VERSION=1.18.18 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends nodejs npm \ + && rm -rf /var/lib/apt/lists/* \ + && git clone https://github.com/KE7/helix.git /ryzers/helix \ + && git -C /ryzers/helix checkout "${HELIX_REF}" \ + && ${CAPX_VENV}/bin/pip install --no-cache-dir -e /ryzers/helix \ + && npm install --global "opencode-ai@${OPENCODE_VERSION}" \ + && npm cache clean --force \ + && ${CAPX_VENV}/bin/helix --help >/dev/null \ + && opencode --version \ + && MUJOCO_GL=egl ${CAPX_VENV}/bin/python -c \ + "import capx, helix, torch; assert torch.version.hip; print('HELIX/OpenCode workshop stack OK')" \ + && chown -R jovyan:100 /ryzers/helix + +WORKDIR /ryzers + +RUN getent group audio >/dev/null || groupadd audio \ + && getent group kvm >/dev/null || groupadd kvm \ + && usermod -aG audio,kvm jovyan \ + && install -d -o jovyan -g 100 \ + /home/jovyan/.cache \ + /home/jovyan/.cache/huggingface \ + /home/jovyan/.cache/lemonade \ + /home/jovyan/.streamlit \ + /ryzers/notebooks \ + && install -d -o jovyan -g 100 -m 700 /tmp/runtime-jovyan \ + && printf 'pcm.!default = null;\n' > /home/jovyan/.asoundrc \ + && printf '[general]\nemail = ""\n' > /home/jovyan/.streamlit/credentials.toml \ + && chown jovyan:100 \ + /home/jovyan/.asoundrc \ + /home/jovyan/.streamlit/credentials.toml + +COPY --chown=jovyan:100 ./course_data /ryzers/notebooks + +RUN ln -s /ryzers/notebooks/tests/test_ros.sh /ryzers/test_ros.sh \ + && ln -s /ryzers/notebooks/tests/test_o3de.sh /ryzers/test_o3de.sh \ + && ln -s /ryzers/notebooks/tests/test_rai.sh /ryzers/test_rai.sh \ + && ln -s /ryzers/notebooks/tests/test_lemonade-sdk.sh /ryzers/test_lemonade-sdk.sh \ + && ln -s /ryzers/notebooks/tests/test_capx.sh /ryzers/test_capx.sh \ + && ln -s /ryzers/notebooks/tests/test_rho.sh /ryzers/test_rho.sh \ + && ln -s /ryzers/notebooks/tests/test_rho_multitask.sh /ryzers/test_rho_multitask.sh \ + && ln -s /ryzers/notebooks/tests/test_rai_toy_evolution.sh /ryzers/test_rai_toy_evolution.sh \ + && chmod +x \ + /ryzers/notebooks/scripts/*.sh \ + /ryzers/test_rho.sh \ + /ryzers/test_rho_multitask.sh \ + /ryzers/test_rai_toy_evolution.sh \ + && : "Only the symlinks created just above need an owner change. Every" \ + "large tree was already handed over by the layer that built it, so" \ + "there is deliberately no recursive chown here - one would copy the" \ + "model caches and /ryzers into this layer a second time." \ + && chown -h jovyan:100 /ryzers/test_*.sh \ + && printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'set -e' \ + 'source "/opt/ros/${ROS_DISTRO}/setup.bash"' \ + '[ ! -f /ryzers/rai/install/setup.bash ] || source /ryzers/rai/install/setup.bash' \ + 'exec /bin/bash /entrypoint.sh "$@"' \ + > /usr/local/bin/start-local-inference.sh \ + && chmod +x /usr/local/bin/start-local-inference.sh + +ENV HOME=/home/jovyan \ + HF_HOME=/home/jovyan/.cache/huggingface \ + LEMONADE_CACHE=/opt/lemonade-cache/lemonade \ + LEMONADE_HF_HOME=/opt/lemonade-cache/huggingface \ + XDG_RUNTIME_DIR=/tmp/runtime-jovyan + +USER jovyan +WORKDIR /ryzers/notebooks + +CMD ["/usr/local/bin/start-local-inference.sh"] diff --git a/dockerfiles/Courses/LocalInference/build.sh b/dockerfiles/Courses/LocalInference/build.sh new file mode 100755 index 00000000..e26c4544 --- /dev/null +++ b/dockerfiles/Courses/LocalInference/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +set -euo pipefail + +cp -r ../../../projects/LocalInference ./course_data +trap 'rm -rf course_data' EXIT + +docker build ${BASE_IMAGE:+--build-arg BASE_IMAGE="$BASE_IMAGE"} \ + -t ghcr.io/amdresearch/auplc-localinference:latest . diff --git a/dockerfiles/Courses/RLLearning/Dockerfile b/dockerfiles/Courses/RLLearning/Dockerfile new file mode 100644 index 00000000..27b6e438 --- /dev/null +++ b/dockerfiles/Courses/RLLearning/Dockerfile @@ -0,0 +1,62 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +ARG BASE_IMAGE=ghcr.io/amdresearch/auplc-base:latest +FROM ${BASE_IMAGE} + +ARG PLAYGROUND_COMMIT=cf88ae6e9c38654199d85c5c976795a7dc350571 + +USER root +SHELL ["/bin/bash", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + libegl-dev \ + libgl1-mesa-dri \ + libglew-dev \ + libgles-dev \ + libosmesa6-dev \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /ryzers/notebooks + +# The build script stages projects/RLLearning as course_data. +COPY ./course_data /ryzers/notebooks + +WORKDIR /ryzers/notebooks +RUN unzip -q PandaPickCube-20260817-150103.zip \ + && unzip -q PandaPickCube-20260807-131132.zip \ + && chown -R jovyan:100 /ryzers + +# Install course packages system-wide as root (see LLM Dockerfile for rationale). +RUN pip3 install --no-cache-dir \ + "mujoco==3.10.0" \ + "mujoco-mjx==3.10.0" \ + "jax==0.10.0" \ + "jaxlib==0.10.0" \ + "brax==0.14.2" \ + "flax" \ + "orbax-checkpoint>=0.11.22" \ + "etils" \ + "ml-collections" \ + "mediapy" \ + "imageio" \ + "imageio-ffmpeg" \ + "absl-py" \ + "lxml" \ + "tqdm" \ + && pip3 install --no-cache-dir --no-deps \ + "git+https://github.com/google-deepmind/mujoco_playground.git@${PLAYGROUND_COMMIT}" + +RUN python3 -c "import brax, jax, mujoco; from mujoco_playground import registry; print('PandaPickCube inference env OK')" + +# Pre-download MuJoCo Menagerie as root; runtime user cannot write under site-packages. +RUN python3 -c "\ +from mujoco_playground import registry; \ +env = registry.load('PandaPickCube', config_overrides={'impl': 'jax'}); \ +print('PandaPickCube env loaded, obs=', env.observation_size, 'act=', env.action_size)" + +USER jovyan +WORKDIR /ryzers/notebooks diff --git a/dockerfiles/Courses/RLLearning/build.sh b/dockerfiles/Courses/RLLearning/build.sh new file mode 100755 index 00000000..c912c598 --- /dev/null +++ b/dockerfiles/Courses/RLLearning/build.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +set -euo pipefail + +cp -r ../../../projects/RLLearning ./course_data +trap 'rm -rf course_data' EXIT + +docker build ${BASE_IMAGE:+--build-arg BASE_IMAGE="$BASE_IMAGE"} \ + -t ghcr.io/amdresearch/auplc-rl-learning:latest . diff --git a/dockerfiles/Makefile b/dockerfiles/Makefile index 5aae172c..80ce1ea8 100644 --- a/dockerfiles/Makefile +++ b/dockerfiles/Makefile @@ -27,6 +27,16 @@ ROCM_SDK_TARGET ?= $(GPU_TARGET) GPU_BASE_IMAGE ?= ghcr.io/amdresearch/auplc-base:latest CODE_GPU_BASE_IMAGE ?= ghcr.io/amdresearch/auplc-base:latest-$(GPU_TARGET) +# Finetuning course assets. ASSETS_SRC points at the RAW split-tar workshop bundle (base/ libero/ +# tokenizer/ droid_dataset/ ft_checkpoint/). `make finetuning ASSETS_SRC=` is FULLY +# self-contained: the Docker build itself unpacks the bundle and reconstructs the reference +# checkpoint (no host pre-stage), then bakes everything in so the image needs no shared mount. +# Set ASSETS_SRC= (empty) to build a code-only image for CI/registry. +ASSETS_ROOT ?= /opt/auplc-assets +ASSETS_BUNDLE ?= $(ASSETS_ROOT)/mm2_workshop_assets +ASSETS_SRC ?= $(ASSETS_BUNDLE) +FINETUNING_IMAGE ?= ghcr.io/amdresearch/auplc-finetuning:latest + # Build args for docker build (constructed from mirror settings) BUILD_ARGS := ifneq ($(MIRROR_PREFIX),) @@ -40,7 +50,7 @@ ifneq ($(MIRROR_NPM),) BUILD_ARGS += --build-arg NPM_REGISTRY=$(MIRROR_NPM) endif -.PHONY: all base base-cpu base-rocm base-gfx1151 hub code code-cpu code-gpu courses cv dl llm physim verify-resource-contracts +.PHONY: all base base-cpu base-rocm base-gfx1151 hub code code-cpu code-gpu courses cv dl llm physim finetuning local-inference verify-resource-contracts # Build all images all: base hub code courses @@ -118,7 +128,7 @@ code-gpu: $(MAKE) save-image IMAGE=ghcr.io/amdresearch/auplc-code-gpu:latest # --- Course Images --- -courses: cv dl llm physim +courses: cv dl llm physim finetuning local-inference rl-learning cv: @echo "-------------------------------------------"; \ @@ -156,6 +166,43 @@ physim: docker tag ghcr.io/amdresearch/auplc-physim:latest ghcr.io/amdresearch/auplc-physim:latest-$(GPU_TARGET) $(MAKE) save-image IMAGE=ghcr.io/amdresearch/auplc-physim:latest +finetuning: + @echo "-------------------------------------------"; \ + echo "Building ROSCon Finetuning Course Image (self-contained)..."; \ + echo " pass the RAW workshop bundle via ASSETS_SRC, e.g.:"; \ + echo " make -C dockerfiles finetuning GPU_TARGET=$(GPU_TARGET) ASSETS_SRC=/path/to/mm2_workshop_assets"; \ + echo " (current ASSETS_SRC=$(ASSETS_SRC))"; \ + echo "-------------------------------------------"; + + cd Courses/Finetuning && BASE_IMAGE=$(GPU_BASE_IMAGE) ASSETS_SRC="$(ASSETS_SRC)" bash ./build.sh + docker tag ghcr.io/amdresearch/auplc-finetuning:latest ghcr.io/amdresearch/auplc-finetuning:latest-$(GPU_TARGET) + $(MAKE) save-image IMAGE=ghcr.io/amdresearch/auplc-finetuning:latest + +# Back-compat alias. Pre-staging now happens INSIDE the Docker build, so this is equivalent to +# `make finetuning ASSETS_SRC=`. Kept so existing docs / muscle-memory keep working. +.PHONY: finetuning-baked +finetuning-baked: + @echo ">> 'finetuning-baked' now just runs 'finetuning' (bundle is pre-staged inside the build)." + $(MAKE) finetuning ASSETS_SRC="$(ASSETS_SRC)" + +local-inference: + @echo "-------------------------------------------"; \ + echo "Building ROSCon Local Inference Course Image..."; \ + echo "-------------------------------------------"; + + cd Courses/LocalInference && BASE_IMAGE=$(GPU_BASE_IMAGE) bash ./build.sh + docker tag ghcr.io/amdresearch/auplc-localinference:latest ghcr.io/amdresearch/auplc-localinference:latest-$(GPU_TARGET) + $(MAKE) save-image IMAGE=ghcr.io/amdresearch/auplc-localinference:latest + +rl-learning: + @echo "-------------------------------------------"; \ + echo "Building ROSCon RL Learning Course Image..."; \ + echo "-------------------------------------------"; + + cd Courses/RLLearning && BASE_IMAGE=$(GPU_BASE_IMAGE) bash ./build.sh + docker tag ghcr.io/amdresearch/auplc-rl-learning:latest ghcr.io/amdresearch/auplc-rl-learning:latest-$(GPU_TARGET) + $(MAKE) save-image IMAGE=ghcr.io/amdresearch/auplc-rl-learning:latest + # --- Export Images --- save-image: @if [ -n "$(SAVE_IMAGES)" ] && [ -n "$(K3S_IMAGES_DIR)" ]; then \ diff --git a/projects/Finetuning/README.md b/projects/Finetuning/README.md new file mode 100644 index 00000000..41eed28f --- /dev/null +++ b/projects/Finetuning/README.md @@ -0,0 +1,63 @@ + + +# ROSCon 2026: Fine-tuning a Robot Policy (MolmoAct2 + LIBERO) + +Fine-tune a real-robot vision-language-action model (**MolmoAct2**) on a new skill in the **LIBERO** +simulator with a short **LoRA** run, then drive the fine-tuned policy live in the simulator. It runs +in the browser on a single AMD **Strix Halo** machine as a JupyterHub course image. + +--- + +## Build the image + +Two steps: **copy the assets, then run the build.** + +### 1. Copy the workshop assets zip into this folder + +```bash +cp /path/to/mm2_workshop_assets.zip projects/Finetuning/ +``` + +### 2. Run the build + +From the repo root: + +```bash +make -C dockerfiles finetuning GPU_TARGET=gfx1151 +``` + +The build unpacks the assets, rebuilds the fine-tuned checkpoint, and bakes everything — plus the two +notebooks and helper scripts — into a self-contained image +`ghcr.io/amdresearch/auplc-finetuning:latest` (also tagged `:latest-gfx1151`). + +--- + +## Deploy and hand out to attendees + +Deploy the JupyterHub server with the image you just built: + +```bash +sudo ./auplc-installer install --gpu=strix-halo +``` + +--- + +## Developer check (optional): run a notebook headless against the built image + +Verify a build end-to-end with no network and no mounts, exactly what an attendee gets: + +```bash +docker run --rm --network=host --ipc=host --shm-size 16G \ + --device=/dev/kfd --device=/dev/dri --security-opt seccomp=unconfined \ + --group-add video --group-add render \ + --tmpfs /home/jovyan:mode=0777 \ + -e HF_HUB_OFFLINE=1 -e TRANSFORMERS_OFFLINE=1 \ + --entrypoint bash ghcr.io/amdresearch/auplc-finetuning:latest-gfx1151 -lc ' + mkdir -p /home/jovyan/outputs + /opt/train-venv/bin/python -m ipykernel install --user --name tv >/dev/null 2>&1 + jupyter nbconvert --to notebook --execute --ExecutePreprocessor.kernel_name=tv \ + --ExecutePreprocessor.timeout=-1 --output /home/jovyan/outputs/nb1.ipynb \ + finetune_molmoact2_libero.ipynb' +``` + +A healthy run shows the closed-loop LIBERO evaluation reporting success. diff --git a/projects/Finetuning/finetune_molmoact2_libero.ipynb b/projects/Finetuning/finetune_molmoact2_libero.ipynb new file mode 100644 index 00000000..3ea78a04 --- /dev/null +++ b/projects/Finetuning/finetune_molmoact2_libero.ipynb @@ -0,0 +1,1028 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# MolmoAct2 LoRA fine-tuning workshop (AMD, ROCm)\n", + "\n", + "This notebook takes a **pretrained MolmoAct2** vision-language-action model and **LoRA fine-tunes** it, then evaluates the fine-tuned policy in the **headless LIBERO simulator** - all on AMD hardware (a single Strix Halo iGPU, or a multi-GPU AMD Instinct node).\n", + "\n", + "**Real -> sim story.** We start from `allenai/MolmoAct2-DROID` (trained on the *real* Franka Panda arm, delta end-effector control) and LoRA-adapt it onto the `allenai/MolmoAct2-LIBERO-Dataset` (the LIBERO *simulator*). Same Panda embodiment and action space, so the base is well-initialized; the LoRA adapter learns the sim.\n", + "\n", + "**How LoRA works here.** The heavy VLM backbone (SigLIP2 vision tower + Qwen3-class LM) is *frozen*; small low-rank adapter matrices are trained on top. The flow-matching action expert stays fully trainable (upstream found this crucial). This keeps memory low enough to fine-tune on a single Strix Halo.\n", + "\n", + "Steps:\n", + "1. **Model import smoke-test** - ROCm/GPU sanity + import the MolmoAct2 stacks\n", + "2. **Download + load** the base checkpoint (one real forward proves the load)\n", + "3. **Open-loop rollout** on a few real DROID episodes (GT-vs-pred overlay)\n", + "4. **LoRA fine-tune** setup + a few steps (auto-scales single/multi-GPU)\n", + "5. **Load the LoRA checkpoint on the DROID base -> LIBERO policy** + closed-loop eval\n", + "\n", + "Everything is **self-contained in this notebook** - steps 1-3 (probe, load, open-loop) run directly *in the kernel* (no external scripts), and the fine-tune / eval / interactive-sim cells build their exact command inline. ROCm's noisy `HIPBLAS`/experimental-attention warnings are suppressed so the output stays readable.\n", + "\n", + "This runs on a single Strix Halo iGPU; the sibling `finetune_molmoact2_cluster.ipynb` retargets step 4 to a multi-GPU AMD Instinct node." + ], + "id": "057b9699" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import os\n", + "\n", + "# --- Quiet ROCm/torch on this iGPU *before* importing torch --------------------------------\n", + "# On gfx1151 every matmul otherwise logs \"HIPBLAS_STATUS_NOT_SUPPORTED ... will attempt to\n", + "# recover by calling cublas\" and \"experimental flash/mem-efficient attention\" UserWarnings,\n", + "# which flood the notebook. Preferring plain hipBLAS over hipBLASLt stops the fallback spam\n", + "# at the source; PYTHONWARNINGS + filterwarnings hide the rest (in-kernel and in children).\n", + "os.environ.setdefault(\"TORCH_BLAS_PREFER_HIPBLASLT\", \"0\")\n", + "os.environ.setdefault(\"PYTHONWARNINGS\", \"ignore\")\n", + "os.environ.setdefault(\"TOKENIZERS_PARALLELISM\", \"false\")\n", + "os.environ.setdefault(\"HF_HUB_DISABLE_XET\", \"1\")\n", + "# Silence the per-file download bars (file-003.parquet 100% ...) and the tqdm train/eval/load\n", + "# loops that otherwise redraw every fraction of a second and swamp the cell output. Progress is\n", + "# instead reported by the loaders' own periodic log lines (and the loss plot in Step 4).\n", + "os.environ.setdefault(\"HF_HUB_DISABLE_PROGRESS_BARS\", \"1\")\n", + "os.environ.setdefault(\"TQDM_DISABLE\", \"1\")\n", + "# Show a plain GB-progress heartbeat for the big HF pulls (env-independent, no tqdm).\n", + "os.environ.setdefault(\"VERBOSE_DOWNLOAD\", \"1\")\n", + "\n", + "import glob\n", + "import json\n", + "import logging\n", + "import re\n", + "import signal\n", + "import subprocess\n", + "import sys\n", + "import time\n", + "import warnings\n", + "\n", + "warnings.filterwarnings(\"ignore\")\n", + "# Silence per-request HTTP logs and the base loader's benign bf16-patch \"needle not found\"\n", + "# notices so the only thing printed is the workshop's own progress.\n", + "for _n in (\n", + " \"transformers\",\n", + " \"lerobot\",\n", + " \"accelerate\",\n", + " \"datasets\",\n", + " \"huggingface_hub\",\n", + " \"httpx\",\n", + " \"httpcore\",\n", + " \"urllib3\",\n", + " \"host_server_droid\",\n", + "):\n", + " logging.getLogger(_n).setLevel(logging.ERROR)\n", + "\n", + "import numpy as np\n", + "import torch\n", + "\n", + "# Paths inside the workshop image. Everything below runs in THIS kernel's trainable venv;\n", + "# the few genuine subprocesses left (fine-tune / eval / RT sim server) build their exact\n", + "# command inline in the cell, so there are no hidden wrapper scripts to go hunting for.\n", + "RYZERS = \"/ryzers\"\n", + "DROID_SRV = os.environ.get(\"DROID_SERVER_DIR\", \"/repos/molmoact2/examples/droid\")\n", + "TRAIN_PY = \"/opt/train-venv/bin/python\"\n", + "OUT_DIR = os.environ.get(\"OUT_DIR\", \"/outputs\")\n", + "os.makedirs(OUT_DIR, exist_ok=True)\n", + "if DROID_SRV not in sys.path:\n", + " sys.path.insert(0, DROID_SRV)\n", + "\n", + "\n", + "# Env for child processes: keep the warning-suppression flags and DROP the inherited\n", + "# matplotlib-inline backend (MPLBACKEND=module://matplotlib_inline...), which crashes\n", + "# headless children (the open-loop / eval crash we hit before).\n", + "def child_env(**extra):\n", + " e = dict(os.environ)\n", + " e.pop(\"MPLBACKEND\", None)\n", + " # Let the training/eval CHILDREN render their own tqdm progress bars (the kernel keeps tqdm\n", + " # disabled so in-notebook code stays quiet). run_cmd/run_train preserve carriage returns, so\n", + " # these bars redraw on ONE line instead of spamming a new line per tick.\n", + " e.pop(\"TQDM_DISABLE\", None)\n", + " e.update({\"TORCH_BLAS_PREFER_HIPBLASLT\": \"0\", \"PYTHONWARNINGS\": \"ignore\", \"PYTHONUNBUFFERED\": \"1\"})\n", + " scripts = os.path.join(RYZERS, \"notebooks\", \"scripts\")\n", + " e[\"PYTHONPATH\"] = scripts + (\":\" + e[\"PYTHONPATH\"] if e.get(\"PYTHONPATH\") else \"\")\n", + " e.update({k: str(v) for k, v in extra.items()})\n", + " return e\n", + "\n", + "\n", + "# Backstop line filter: even with the flags above, drop any stray ROCm/torch warning lines\n", + "# so the streamed subprocess output stays readable.\n", + "_NOISE = (\n", + " \"UserWarning\",\n", + " \"HIPBLAS_STATUS\",\n", + " \"hipblasLtMatmul\",\n", + " \"Triggered internally\",\n", + " \"scaled_dot_product_attention\",\n", + " \"run_backward\",\n", + " \"aotriton\",\n", + " \"AOTRITON\",\n", + ")\n", + "\n", + "\n", + "# LeRobot logs one metrics line per `log_freq` steps, e.g. \"step:10 ... loss:1.684 grdn:.. lr:..\".\n", + "# We scrape those (step, loss) points so Step 4 can draw a small loss curve. Handles the\n", + "# human-formatted step number (10, 1.5K, 2M) that LeRobot prints for large runs.\n", + "_STEP_RE = re.compile(r\"\\bstep:\\s*([0-9.]+)\\s*([KMB]?)\")\n", + "_LOSS_RE = re.compile(r\"\\bloss:\\s*([0-9.]+)\")\n", + "_SUF = {\"\": 1.0, \"K\": 1e3, \"M\": 1e6, \"B\": 1e9}\n", + "\n", + "\n", + "def _popen(cmd, env=None, cwd=None):\n", + " print(\"$ \" + (cmd if isinstance(cmd, str) else \" \".join(map(str, cmd))) + \"\\n\", flush=True)\n", + " # Binary pipe (no text=True): universal-newline mode would rewrite every '\\r' to '\\n' and turn\n", + " # a single self-updating tqdm bar into one new line per tick. We decode + split ourselves.\n", + " return subprocess.Popen(\n", + " cmd,\n", + " shell=isinstance(cmd, str),\n", + " env=env,\n", + " cwd=cwd,\n", + " stdout=subprocess.PIPE,\n", + " stderr=subprocess.STDOUT,\n", + " bufsize=0,\n", + " )\n", + "\n", + "\n", + "def _pump(p, quiet=True, scrape=None, drop=None):\n", + " \"\"\"Stream a child process into the notebook, PRESERVING carriage returns so tqdm/progress bars\n", + " redraw in place on ONE line (Jupyter honors '\\\\r'). `scrape(line)` collects data from complete\n", + " '\\\\n' lines; `drop(line)` hides a complete line from the display (it is still scraped).\"\"\"\n", + " import codecs\n", + "\n", + " dec = codecs.getincrementaldecoder(\"utf-8\")(\"replace\")\n", + " buf = \"\"\n", + " while True:\n", + " chunk = p.stdout.read(4096)\n", + " if not chunk:\n", + " break\n", + " buf += dec.decode(chunk)\n", + " while True:\n", + " i_n, i_r = buf.find(\"\\n\"), buf.find(\"\\r\")\n", + " idxs = [i for i in (i_n, i_r) if i != -1]\n", + " if not idxs:\n", + " break\n", + " cut = min(idxs) + 1\n", + " seg, buf = buf[:cut], buf[cut:]\n", + " if quiet and any(tok in seg for tok in _NOISE):\n", + " continue\n", + " full_line = seg.endswith(\"\\n\")\n", + " if full_line and scrape:\n", + " scrape(seg)\n", + " if not (full_line and drop and drop(seg)):\n", + " sys.stdout.write(seg)\n", + " sys.stdout.flush()\n", + " if buf and not (quiet and any(tok in buf for tok in _NOISE)):\n", + " if scrape:\n", + " scrape(buf)\n", + " if not (drop and drop(buf)):\n", + " sys.stdout.write(buf)\n", + " sys.stdout.flush()\n", + " p.wait()\n", + " if p.returncode != 0:\n", + " raise RuntimeError(f\"command failed (exit {p.returncode})\")\n", + "\n", + "\n", + "def run_cmd(cmd, env=None, cwd=None, quiet_warnings=True):\n", + " \"\"\"Stream a subprocess into the notebook; tqdm/progress bars stay on ONE self-updating line.\"\"\"\n", + " _pump(_popen(cmd, env=env, cwd=cwd), quiet=quiet_warnings)\n", + "\n", + "\n", + "def run_train(cmd, env=None, cwd=None):\n", + " \"\"\"Stream a training subprocess and return captured [(step, loss), ...]. The tqdm progress bar\n", + " stays on one line; the chatty per-step \"step:.. loss:..\" metric lines are hidden from the\n", + " display (still scraped, so the loss curve below is unaffected).\"\"\"\n", + " pts = []\n", + "\n", + " def _scrape(line):\n", + " ms, ml = _STEP_RE.search(line), _LOSS_RE.search(line)\n", + " if ms and ml:\n", + " pts.append((float(ms.group(1)) * _SUF[ms.group(2)], float(ml.group(1))))\n", + "\n", + " def _drop(line):\n", + " return bool(_STEP_RE.search(line) and _LOSS_RE.search(line))\n", + "\n", + " _pump(_popen(cmd, env=env, cwd=cwd), quiet=True, scrape=_scrape, drop=_drop)\n", + " return pts\n", + "\n", + "\n", + "# Flow-matching action-head call, tolerant of the inference_action_mode (new) vs action_mode\n", + "# (old) kwarg rename across MolmoAct2 checkpoints. Ported inline so Steps 2-3 need no scripts.\n", + "def predict_chunk(policy, norm_tag, images, task, state, num_steps):\n", + " kw = {\n", + " \"processor\": policy.processor,\n", + " \"images\": images,\n", + " \"task\": task,\n", + " \"state\": state,\n", + " \"norm_tag\": norm_tag,\n", + " \"enable_depth_reasoning\": False,\n", + " \"num_steps\": num_steps,\n", + " \"normalize_language\": True,\n", + " \"enable_cuda_graph\": False,\n", + " }\n", + " try:\n", + " out = policy.model.predict_action(inference_action_mode=\"continuous\", **kw)\n", + " except TypeError as e:\n", + " if \"action_mode\" not in str(e):\n", + " raise\n", + " out = policy.model.predict_action(action_mode=\"continuous\", **kw)\n", + " raw = out.actions if hasattr(out, \"actions\") else out\n", + " if torch.is_tensor(raw):\n", + " raw = raw.detach().to(dtype=torch.float32, device=\"cpu\").numpy()\n", + " a = np.asarray(raw, dtype=np.float32)\n", + " if a.ndim == 3 and a.shape[0] == 1:\n", + " a = a[0]\n", + " return a" + ], + "execution_count": null, + "outputs": [], + "id": "8f2015a5" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Model import smoke-test\n", + "\n", + "Confirms the trainable venv sees a ROCm device and imports the LeRobot MolmoAct2 training stack (torch+HIP, transformers, lerobot, robosuite/mujoco). The detected **GPU count** drives sensible defaults below (small batch/steps on a single Strix Halo, larger on a multi-GPU Instinct node). No weights are loaded here - this is a fast import/environment check." + ], + "id": "775f0630" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# In-kernel probe (no subprocess): confirm a ROCm device and that the trainable MolmoAct2\n", + "# + sim stack imports. The detected GPU count drives the batch/step defaults below.\n", + "assert torch.version.hip, \"torch is not a ROCm build\"\n", + "assert torch.cuda.is_available(), \"no ROCm device visible (check /dev/kfd, /dev/dri)\"\n", + "print(f\"torch : {torch.__version__} hip={torch.version.hip}\")\n", + "\n", + "N_GPUS, gpu_names, vram_gib = max(1, torch.cuda.device_count()), [], []\n", + "for i in range(torch.cuda.device_count()):\n", + " pr = torch.cuda.get_device_properties(i)\n", + " gpu_names.append(pr.name)\n", + " vram_gib.append(round(pr.total_memory / 1024**3, 1))\n", + " print(f\"device[{i}] : {pr.name} ({vram_gib[-1]} GiB)\")\n", + "\n", + "for mod in (\"lerobot\", \"transformers\", \"robosuite\", \"mujoco\", \"accelerate\"):\n", + " m = __import__(mod)\n", + " print(f\"{mod:16s} : {getattr(m, '__version__', '?')}\")\n", + "\n", + "print(f\"\\n==> detected {N_GPUS} GPU(s): {gpu_names} VRAM(GiB)={vram_gib}\")" + ], + "execution_count": null, + "outputs": [], + "id": "fbcbb639" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Download + load the base checkpoint\n", + "\n", + "Downloads the base checkpoint to adapt (`allenai/MolmoAct2-DROID`, real Franka) and the fine-tuning dataset (`allenai/MolmoAct2-LIBERO-Dataset`) into the persistent HF cache, then **loads the checkpoint and runs one real forward** to prove the whole flow-matching action path executes on ROCm (bf16). Nothing is re-hosted by us; everything pulls from Hugging Face at run time. Set `HF_TOKEN` for faster/gated downloads. First run pulls tens of GB - it is cached and reused afterwards.\n", + "\n", + "**One base, used twice (and how the assets are packaged).** This exact same `allenai/MolmoAct2-DROID` model is reused for *both* the open-loop check (Step 3, loaded here) *and* as the starting point of the LoRA fine-tune (Step 4 passes `--policy.checkpoint_path=allenai/MolmoAct2-DROID`) - it is loaded once and shared from the HF cache. We load it and train in **bf16**. Downloaded fresh from the Hub it arrives in **fp32** (~22 GB across 5 shards ≈ 4 bytes × 5.4B params); the pre-staged workshop assets instead ship it already in **bf16** (~11 GB), the precision we actually run in. LeRobot saves a fine-tuned policy as a single bf16 `model.safetensors` (~11.5 GB) plus tiny normalizer stats - the frozen base with the trained update on top. To avoid shipping the base twice, our ready-made reference checkpoint is packaged **split**: the bf16 base plus a small trained *delta* (the LoRA adapter + trained action-expert, ~2.4 GB), rebuilt into that same full checkpoint automatically when the assets are staged (`fetch_assets.sh`)." + ], + "id": "a0433813" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from huggingface_hub import snapshot_download\n", + "try:\n", + " from huggingface_hub import scan_cache_dir\n", + "except Exception:\n", + " scan_cache_dir = None\n", + "import subprocess as _sp\n", + "import threading\n", + "\n", + "BASE_CKPT = os.environ.get(\"BASE_CKPT\", \"allenai/MolmoAct2-DROID\")\n", + "DATASET_REPO = os.environ.get(\"DATASET_REPO\", \"allenai/MolmoAct2-LIBERO-Dataset\")\n", + "VERBOSE_DL = os.environ.get(\"VERBOSE_DOWNLOAD\", \"1\") == \"1\"\n", + "\n", + "_HUB = os.path.join(os.environ.get(\"HF_HOME\", os.path.expanduser(\"~/.cache/huggingface\")), \"hub\")\n", + "\n", + "\n", + "def _repo_dir(repo_id, repo_type):\n", + " pfx = \"datasets--\" if repo_type == \"dataset\" else \"models--\"\n", + " return os.path.join(_HUB, pfx + repo_id.replace(\"/\", \"--\"))\n", + "\n", + "\n", + "def _dir_gb(path):\n", + " try:\n", + " return int(_sp.check_output([\"du\", \"-sb\", path], stderr=_sp.DEVNULL).split()[0]) / 1e9\n", + " except Exception:\n", + " return 0.0\n", + "\n", + "\n", + "def _fully_cached(repo_id, repo_type):\n", + " # True only if every file is already present (no network) -> instant, no re-download.\n", + " try:\n", + " snapshot_download(repo_id=repo_id, repo_type=repo_type, local_files_only=True)\n", + " return True\n", + " except Exception:\n", + " return False\n", + "\n", + "\n", + "def _heartbeat(repo_id, repo_type, stop):\n", + " d, last = _repo_dir(repo_id, repo_type), _dir_gb(_repo_dir(repo_id, repo_type))\n", + " while not stop.wait(3):\n", + " cur = _dir_gb(d)\n", + " print(f\" ...{repo_id}: {cur:.2f} GB on disk (+{max(cur - last, 0):.2f} GB/3s)\", flush=True)\n", + " last = cur\n", + "\n", + "\n", + "def prefetch(repo_id, repo_type, tries=5):\n", + " if _fully_cached(repo_id, repo_type):\n", + " print(f\" CACHED [{repo_type}] {repo_id} ({_dir_gb(_repo_dir(repo_id, repo_type)):.2f} GB) - skipping\")\n", + " return\n", + " print(f\" FETCH [{repo_type}] {repo_id} - downloading into HF cache\", flush=True)\n", + " for n in range(1, tries + 1):\n", + " stop, th = threading.Event(), None\n", + " if VERBOSE_DL:\n", + " th = threading.Thread(target=_heartbeat, args=(repo_id, repo_type, stop), daemon=True)\n", + " th.start()\n", + " try:\n", + " t0 = time.time()\n", + " snapshot_download(repo_id=repo_id, repo_type=repo_type)\n", + " stop.set()\n", + " if th:\n", + " th.join(timeout=1)\n", + " print(f\" DONE [{repo_type}] {repo_id} ({_dir_gb(_repo_dir(repo_id, repo_type)):.2f} GB in {time.time() - t0:.0f}s)\")\n", + " return\n", + " except Exception as e:\n", + " stop.set()\n", + " if th:\n", + " th.join(timeout=1)\n", + " print(f\" (network hiccup on {repo_id}, retry {n}/{tries}: {str(e)[:80]}; resuming cached bytes)\")\n", + " if n == tries:\n", + " raise\n", + " time.sleep(5)\n", + "\n", + "\n", + "# --- Optional: stage large assets from a local resources dir (ASSETS_DIR) -----------------\n", + "# If the workshop hosts the base checkpoint + dataset + fine-tuned checkpoint on local storage,\n", + "# point ASSETS_DIR at that folder; they are copied into the HF cache + REFERENCE_POLICY here so\n", + "# prefetch() finds everything cached and downloads nothing. Idempotent (skips what exists).\n", + "ASSETS_DIR = os.environ.get(\"ASSETS_DIR\", \"\").strip()\n", + "\n", + "\n", + "def _stage_assets():\n", + " import shutil\n", + " if not ASSETS_DIR or not os.path.isdir(ASSETS_DIR):\n", + " return\n", + " hub_src = os.path.join(ASSETS_DIR, \"hf_hub\")\n", + " if os.path.isdir(hub_src):\n", + " os.makedirs(_HUB, exist_ok=True)\n", + " for _name in sorted(os.listdir(hub_src)):\n", + " _s, _d = os.path.join(hub_src, _name), os.path.join(_HUB, _name)\n", + " if os.path.isdir(_s) and not os.path.exists(_d):\n", + " print(f\" STAGE {_name} -> HF cache\", flush=True)\n", + " shutil.copytree(_s, _d, symlinks=True)\n", + " _ref_src = os.path.join(ASSETS_DIR, \"checkpoints\", \"reference\", \"pretrained_model\")\n", + " _ref_dst = os.environ.get(\"REFERENCE_POLICY\", os.path.expanduser(\"~/checkpoints/reference/pretrained_model\"))\n", + " if os.path.isdir(_ref_src) and not os.path.isdir(_ref_dst):\n", + " print(f\" STAGE reference checkpoint -> {_ref_dst}\", flush=True)\n", + " os.makedirs(os.path.dirname(_ref_dst), exist_ok=True)\n", + " shutil.copytree(_ref_src, _ref_dst, symlinks=True)\n", + "\n", + "\n", + "if ASSETS_DIR:\n", + " print(f\"== staging assets from ASSETS_DIR={ASSETS_DIR} (no Hub re-download) ==\")\n", + " _stage_assets()\n", + " print(\"== asset staging done ==\\n\")\n", + "else:\n", + " print(f\"== assets read from the image-baked cache: HF_HOME={os.environ.get('HF_HOME','')} (no staging needed) ==\\n\")\n", + "\n", + "\n", + "# --- Preflight: confirm every input is found BEFORE the long model load / training ---------\n", + "print(\"== preflight: inputs the notebook needs ==\")\n", + "_needed = [(BASE_CKPT, \"model\"), (DATASET_REPO, \"dataset\")]\n", + "_pol = os.environ.get(\"POLICY_PATH\") or os.environ.get(\"REFERENCE_POLICY\", \"\")\n", + "for _rid, _rt in _needed:\n", + " _d = _repo_dir(_rid, _rt)\n", + " if _fully_cached(_rid, _rt):\n", + " print(f\" [ok] {_rt:7s} {_rid} CACHED ({_dir_gb(_d):.2f} GB)\")\n", + " else:\n", + " print(f\" [missing] {_rt:7s} {_rid} will download\")\n", + "if _pol:\n", + " _ok = os.path.isdir(_pol) and os.path.exists(os.path.join(_pol, \"config.json\"))\n", + " print(f\" [{'ok' if _ok else 'n/a'}] policy {_pol} {'found' if _ok else '(not staged yet)'}\")\n", + "print(\"== end preflight ==\\n\")\n", + "\n", + "prefetch(BASE_CKPT, \"model\")\n", + "\n", + "# LIBERO dataset routing. DEFAULT: the small pre-staged SUBSET (offline, ~1 GB) so the workshop\n", + "# payload stays tiny and the short Step-4 fine-tune runs on it. The subset is a self-consistent\n", + "# LeRobot dataset (metadata rewritten to only the bundled episodes), so training \"just works\" on\n", + "# whatever episodes are present - no episode list is hard-coded here.\n", + "# EXTENDED ROUTE: set USE_FULL_LIBERO=1 to pull the COMPLETE dataset from the Hub (needs internet;\n", + "# ~33 GB) and train the full pipeline. Because the staged subset reuses each file's real content\n", + "# hash, going online only downloads the files that are not already present.\n", + "USE_FULL_LIBERO = os.environ.get(\"USE_FULL_LIBERO\", \"0\") == \"1\"\n", + "if USE_FULL_LIBERO:\n", + " import shutil\n", + " import huggingface_hub.constants as _hc\n", + "\n", + " print(\"USE_FULL_LIBERO=1 -> fetching the COMPLETE LIBERO dataset from the Hub (large; needs internet) ...\", flush=True)\n", + " _prev_off = _hc.HF_HUB_OFFLINE\n", + " _hc.HF_HUB_OFFLINE = False # this flag is captured at import time; flip it to actually reach the Hub\n", + " os.environ[\"HF_HUB_OFFLINE\"] = \"0\"\n", + " try:\n", + " _full = snapshot_download(repo_id=DATASET_REPO, repo_type=\"dataset\", local_files_only=False)\n", + " finally:\n", + " _hc.HF_HUB_OFFLINE = _prev_off\n", + " os.environ[\"HF_HUB_OFFLINE\"] = \"1\" if _prev_off else \"0\"\n", + " # Point the LeRobot dataset home at the full snapshot (replaces the staged-subset link) so Step 4\n", + " # trains on the complete dataset.\n", + " _lr_home = os.environ.get(\n", + " \"HF_LEROBOT_HOME\",\n", + " os.path.join(os.environ.get(\"HF_HOME\", os.path.expanduser(\"~/.cache/huggingface\")), \"lerobot\"),\n", + " )\n", + " _lr = os.path.join(_lr_home, *DATASET_REPO.split(\"/\"))\n", + " os.makedirs(os.path.dirname(_lr), exist_ok=True)\n", + " if os.path.islink(_lr) or os.path.isfile(_lr):\n", + " os.remove(_lr)\n", + " elif os.path.isdir(_lr):\n", + " shutil.rmtree(_lr)\n", + " os.symlink(_full, _lr)\n", + " print(f\" full LIBERO dataset ready ({_dir_gb(_full):.1f} GB) -> {_lr}\")\n", + "else:\n", + " prefetch(DATASET_REPO, \"dataset\")\n", + "print(\"PASS: base checkpoint + dataset cached\")" + ], + "execution_count": null, + "outputs": [], + "id": "5606c42f" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Load the base checkpoint in THIS kernel via the upstream DROID Policy loader (bf16 patches\n", + "# baked at /repos/molmoact2/examples/droid) and run ONE real forward. Proves the full 6B\n", + "# flow-matching action path executes on ROCm and that the just-downloaded weights load. First\n", + "# call may JIT-compile kernels (slow once). We keep `base_policy` around to reuse in Step 3.\n", + "sys.path.insert(0, \"scripts\")\n", + "from fast_to_device import install\n", + "install()\n", + "from host_server_droid import NORM_TAG, Policy\n", + "from PIL import Image\n", + "\n", + "DTYPE = {\"bfloat16\": torch.bfloat16, \"float16\": torch.float16, \"float32\": torch.float32}[\n", + " os.environ.get(\"DTYPE\", \"bfloat16\")\n", + "]\n", + "NUM_STEPS = int(os.environ.get(\"NUM_STEPS\", \"10\"))\n", + "\n", + "if globals().get(\"base_policy\") is None:\n", + " t0 = time.time()\n", + " base_policy = Policy(repo_id=BASE_CKPT, device=\"cuda:0\", dtype=DTYPE)\n", + " n_params = sum(p.numel() for p in base_policy.model.parameters())\n", + " print(f\"model loaded : {time.time() - t0:.1f}s params={n_params / 1e9:.2f}B norm_tag={NORM_TAG}\")\n", + "else:\n", + " print(\"base_policy already loaded in this kernel; reusing it (restart the kernel to force a reload)\")\n", + "\n", + "# One dummy DROID observation: 3 cams (ext1, ext2, wrist) + 8-DoF state.\n", + "imgs = [Image.fromarray(np.random.randint(0, 255, (180, 320, 3), dtype=np.uint8)) for _ in range(3)]\n", + "t1 = time.time()\n", + "acts = predict_chunk(base_policy, NORM_TAG, imgs, \"pick up the object\", np.zeros(8, np.float32), NUM_STEPS)\n", + "print(f\"predict_action : {acts.shape} in {(time.time() - t1) * 1000:.0f} ms\")\n", + "assert acts.ndim == 2 and acts.shape[-1] == 8 and np.isfinite(acts).all(), f\"bad actions {acts.shape}\"\n", + "print(\"PASS: MolmoAct2 full-model ROCm smoke OK\")" + ], + "execution_count": null, + "outputs": [], + "id": "b49d5df9" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Open-loop rollout on a few real DROID episodes\n", + "\n", + "Before fine-tuning, sanity-check the base policy on the **real** data it was trained on. This is a multi-episode port-fidelity check: we draw **`N_DROID_EPISODES` random episodes** (default `2`) from `allenai/MolmoAct2-DROID-Dataset` - each is a *different* real teleop episode with its own task string - and replay each one open-loop (receding-horizon: replan every `STRIDE` steps). For every episode we write, under `/outputs`:\n", + "\n", + "- `droid_ep.mp4` - the episode's exterior camera view (the scene the model predicts on),\n", + "- `droid_ep_actions.png` - the 8-DoF **GT (teleop) vs predicted** action trajectory, overlaid per dim on the same axes (GT solid, pred dashed).\n", + "\n", + "**About the downloads you see.** MolmoAct2 consumes **3 camera streams** (`exterior_1`, `exterior_2`, `wrist`), so each random episode pulls its own 3 camera `.mp4` clips (plus one small `.parquet` of states/actions) on demand from the Hub. That is why you see downloads continue per episode, and why - mid-run - the file count can be ahead of the finished-episode count (e.g. the 3rd clip of episode 2 is still streaming while only episode 1 has printed its metrics). It is **bounded**: total clips ≈ `3 × N_DROID_EPISODES`, and everything is cached for reruns. This is *not* the closed-loop task benchmark (that's Step 5); to make it lighter set `N_DROID_EPISODES=1`, or pin one episode with `EPISODE=`.\n", + "\n", + "The newest episode's plot + video are shown inline below." + ], + "id": "9d85ac05" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import random\n", + "\n", + "import matplotlib.pyplot as plt # inline backend -> plots render in the notebook, no Agg/subprocess\n", + "import pyarrow.compute as pc\n", + "import pyarrow.parquet as pq\n", + "from huggingface_hub import hf_hub_download\n", + "from IPython.display import Video, display\n", + "\n", + "EVAL_REPO = os.environ.get(\"EVAL_REPO\", \"allenai/MolmoAct2-DROID-Dataset\")\n", + "STRIDE = int(os.environ.get(\"STRIDE\", \"15\"))\n", + "N_DROID = int(os.environ.get(\"N_DROID_EPISODES\", \"2\"))\n", + "VIEW_CAM = \"observation.images.\" + os.environ.get(\"CAM\", \"exterior_1_left\")\n", + "CAMS = [\"observation.images.exterior_1_left\", \"observation.images.exterior_2_left\", \"observation.images.wrist_left\"]\n", + "DIM_NAMES = [\"joint_0\", \"joint_1\", \"joint_2\", \"joint_3\", \"joint_4\", \"joint_5\", \"joint_6\", \"gripper\"]\n", + "\n", + "\n", + "def _grab_frames(path, indices):\n", + " \"\"\"Decode the specific source frames we replan on (nearest-match fallback).\"\"\"\n", + " import av\n", + "\n", + " want = sorted({int(i) for i in indices})\n", + " if not want:\n", + " return {}\n", + " container = av.open(path)\n", + " stream = container.streams.video[0]\n", + " stream.thread_type = \"AUTO\"\n", + " rate, tb = float(stream.average_rate), stream.time_base\n", + " try:\n", + " container.seek(max(int((want[0] / rate) / tb), 0), stream=stream, backward=True, any_frame=False)\n", + " except Exception:\n", + " container.seek(0)\n", + " out, remaining = {}, set(want)\n", + " for frame in container.decode(stream):\n", + " if frame.pts is None:\n", + " continue\n", + " idx = int(round(float(frame.pts * tb) * rate))\n", + " if idx in remaining:\n", + " out[idx] = frame.to_ndarray(format=\"rgb24\")\n", + " remaining.discard(idx)\n", + " elif idx > want[-1]:\n", + " break\n", + " if not remaining:\n", + " break\n", + " container.close()\n", + " if remaining and out:\n", + " got = sorted(out)\n", + " for idx in list(remaining):\n", + " out[idx] = out[min(got, key=lambda g: abs(g - idx))]\n", + " return out\n", + "\n", + "\n", + "def _decode_clip(path, start_idx, n_frames):\n", + " \"\"\"Decode n contiguous frames from start_idx for the episode video.\"\"\"\n", + " import av\n", + "\n", + " container = av.open(path)\n", + " stream = container.streams.video[0]\n", + " stream.thread_type = \"AUTO\"\n", + " rate, tb = float(stream.average_rate), stream.time_base\n", + " try:\n", + " container.seek(max(int((start_idx / rate) / tb), 0), stream=stream, backward=True, any_frame=False)\n", + " except Exception:\n", + " container.seek(0)\n", + " frames = []\n", + " for frame in container.decode(stream):\n", + " if frame.pts is None:\n", + " continue\n", + " if int(round(float(frame.pts * tb) * rate)) < start_idx:\n", + " continue\n", + " frames.append(frame.to_ndarray(format=\"rgb24\"))\n", + " if len(frames) >= n_frames:\n", + " break\n", + " container.close()\n", + " return frames\n", + "\n", + "\n", + "def run_openloop_episode():\n", + " \"\"\"Replay one random DROID episode open-loop (replan every STRIDE) and show GT-vs-pred.\"\"\"\n", + " info_path = hf_hub_download(EVAL_REPO, \"meta/info.json\", repo_type=\"dataset\")\n", + " info = json.load(open(info_path))\n", + " fps, data_tmpl, video_tmpl = info[\"fps\"], info[\"data_path\"], info[\"video_path\"]\n", + " meta_ep = pq.read_table(\n", + " hf_hub_download(EVAL_REPO, \"meta/episodes/chunk-000/file-000.parquet\", repo_type=\"dataset\")\n", + " ).to_pydict()\n", + " episodes = list(meta_ep[\"episode_index\"])\n", + " # The workshop pre-stages only a subset of the DROID dataset (kept offline, zero-copy), so\n", + " # restrict the random pick to episodes whose data parquet AND all camera videos are present\n", + " # in the local cache. This avoids reaching for a file that was not bundled.\n", + " snap_root = os.path.dirname(os.path.dirname(info_path))\n", + " _present = lambda rel: os.path.exists(os.path.join(snap_root, rel))\n", + " available = [\n", + " ep\n", + " for r, ep in enumerate(episodes)\n", + " if _present(data_tmpl.format(chunk_index=meta_ep[\"data/chunk_index\"][r], file_index=meta_ep[\"data/file_index\"][r]))\n", + " and all(\n", + " _present(\n", + " video_tmpl.format(\n", + " video_key=c,\n", + " chunk_index=meta_ep[f\"videos/{c}/chunk_index\"][r],\n", + " file_index=meta_ep[f\"videos/{c}/file_index\"][r],\n", + " )\n", + " )\n", + " for c in CAMS\n", + " )\n", + " ]\n", + " if not available:\n", + " raise RuntimeError(\"No pre-staged DROID episodes found in the local cache; check the workshop assets.\")\n", + " ep_env = os.environ.get(\"EPISODE\", \"\")\n", + " episode = int(ep_env) if ep_env else random.choice(available)\n", + " mr = episodes.index(episode)\n", + " d_chunk, d_file = meta_ep[\"data/chunk_index\"][mr], meta_ep[\"data/file_index\"][mr]\n", + " length = meta_ep[\"length\"][mr]\n", + " task = meta_ep[\"tasks\"][mr]\n", + " task = task[0] if isinstance(task, (list, tuple)) else task\n", + " print(f\"episode {episode} task={task!r} length={length}\")\n", + "\n", + " # dataset_from/to_index are GLOBAL rows; filter the per-file table by episode_index.\n", + " dt = pq.read_table(\n", + " hf_hub_download(EVAL_REPO, data_tmpl.format(chunk_index=d_chunk, file_index=d_file), repo_type=\"dataset\")\n", + " )\n", + " ep_table = dt.filter(pc.equal(dt.column(\"episode_index\"), episode))\n", + " col = lambda name: ep_table.column(name).to_pylist()\n", + " states = np.asarray(col(\"observation.state\"), np.float32)\n", + " gt = np.asarray(col(\"action\"), np.float32)\n", + " timestamps = np.asarray(col(\"timestamp\"), np.float64)\n", + "\n", + " replan_pts = list(range(0, length, STRIDE))\n", + " cam_meta = {\n", + " c: {\n", + " \"chunk\": meta_ep[f\"videos/{c}/chunk_index\"][mr],\n", + " \"file\": meta_ep[f\"videos/{c}/file_index\"][mr],\n", + " \"from_ts\": meta_ep[f\"videos/{c}/from_timestamp\"][mr],\n", + " }\n", + " for c in CAMS\n", + " }\n", + " frames_by_cam = {}\n", + " for c in CAMS:\n", + " cm = cam_meta[c]\n", + " vp = hf_hub_download(\n", + " EVAL_REPO,\n", + " video_tmpl.format(video_key=c, chunk_index=cm[\"chunk\"], file_index=cm[\"file\"]),\n", + " repo_type=\"dataset\",\n", + " )\n", + " idxs = [int(round((cm[\"from_ts\"] + timestamps[t]) * fps)) for t in replan_pts]\n", + " grabbed = _grab_frames(vp, idxs)\n", + " frames_by_cam[c] = {t: grabbed[int(round((cm[\"from_ts\"] + timestamps[t]) * fps))] for t in replan_pts}\n", + "\n", + " pred = np.full_like(gt, np.nan)\n", + " lat = []\n", + " for t in replan_pts:\n", + " pics = [Image.fromarray(frames_by_cam[c][t]) for c in CAMS]\n", + " torch.cuda.synchronize()\n", + " ts = time.perf_counter()\n", + " chunk = predict_chunk(base_policy, NORM_TAG, pics, task, states[t], NUM_STEPS)\n", + " torch.cuda.synchronize()\n", + " lat.append((time.perf_counter() - ts) * 1000)\n", + " n = min(STRIDE, length - t, chunk.shape[0])\n", + " pred[t : t + n] = chunk[:n]\n", + "\n", + " valid = ~np.isnan(pred).any(axis=1)\n", + " l1 = float(np.abs(pred[valid] - gt[valid]).mean())\n", + " mse = float(((pred[valid] - gt[valid]) ** 2).mean())\n", + " print(f\"open-loop : L1={l1:.4f} MSE={mse:.4f} ({np.mean(lat):.0f} ms/infer)\")\n", + "\n", + " # Episode exterior view (the scene the model predicts on).\n", + " cm = cam_meta[VIEW_CAM]\n", + " vpath = hf_hub_download(\n", + " EVAL_REPO,\n", + " video_tmpl.format(video_key=VIEW_CAM, chunk_index=cm[\"chunk\"], file_index=cm[\"file\"]),\n", + " repo_type=\"dataset\",\n", + " )\n", + " frames = _decode_clip(vpath, int(round((cm[\"from_ts\"] + timestamps[0]) * fps)), length)\n", + " vid = os.path.join(OUT_DIR, f\"droid_ep{episode}.mp4\")\n", + " if frames:\n", + " import imageio\n", + "\n", + " imageio.mimsave(vid, frames, fps=int(round(fps)), codec=\"libx264\", quality=7)\n", + "\n", + " # GT (solid) vs predicted (dashed), overlaid per dim (workspace rule 2.a) - inline + saved.\n", + " fig, axes = plt.subplots(2, 4, figsize=(16, 7), squeeze=False)\n", + " x = np.arange(gt.shape[0])\n", + " for d in range(8):\n", + " ax = axes[d // 4][d % 4]\n", + " ax.plot(x, gt[:, d], color=\"tab:blue\", lw=1.4, label=\"GT (teleop)\")\n", + " ax.plot(x, pred[:, d], color=\"tab:red\", lw=1.2, ls=\"--\", label=\"MolmoAct2 (pred)\")\n", + " ax.set_title(DIM_NAMES[d], fontsize=10)\n", + " ax.tick_params(labelsize=7)\n", + " if d == 0:\n", + " ax.legend(fontsize=8, loc=\"best\")\n", + " fig.suptitle(\n", + " f\"MolmoAct2-DROID open-loop (ROCm), ep {episode}\\n{task}\\nL1={l1:.4f} MSE={mse:.4f} (GT solid, pred dashed)\",\n", + " fontsize=11,\n", + " )\n", + " fig.tight_layout(rect=[0, 0, 1, 0.9])\n", + " fig.savefig(os.path.join(OUT_DIR, f\"droid_ep{episode}_actions.png\"), dpi=110)\n", + " plt.show()\n", + " if frames:\n", + " display(Video(vid, embed=True, width=480))\n", + "\n", + "\n", + "for _ in range(N_DROID):\n", + " run_openloop_episode()\n", + "\n", + "# Release the base DROID policy so the fine-tune below has the iGPU to itself.\n", + "import gc\n", + "\n", + "del base_policy\n", + "gc.collect()\n", + "torch.cuda.empty_cache()" + ], + "execution_count": null, + "outputs": [], + "id": "2fe84d50" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. LoRA fine-tune (setup + a few steps)\n", + "\n", + "The same command runs single-GPU on Strix Halo and multi-GPU on an AMD Instinct node (`accelerate` uses `N_GPUS` processes automatically; the cluster notebook shows the multi-GPU path).\n", + "\n", + "**Fine-tune modes** (`FT_MODE`, maps to `--policy.train_mode_vlm`):\n", + "- `lora_vlm` *(default)* - LoRA adapters on the VLM; action expert fully trainable. ~20 GiB at batch 8.\n", + "- `action_expert_only` - freeze the VLM, train only the flow-matching action expert. ~16 GiB at batch 8.\n", + "- `full` - full fine-tune of VLM + action expert (~48 GiB at batch 8; prefer a multi-GPU Instinct node).\n", + "\n", + "For the workshop we run only a **few steps** to see the loop work end to end; a longer-trained *reference* checkpoint is provided for a compelling sim demo (see the last section)." + ], + "id": "b64258c7" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import shutil\n", + "\n", + "FT_MODE = os.environ.get(\"FT_MODE\", \"lora_vlm\")\n", + "# Workshop default: just 10 steps to prove the loop end to end. SAVE_FREQ defaults to STEPS,\n", + "# so a checkpoint reliably lands at the final step (this is what Step 5 loads). Raise STEPS\n", + "# (e.g. 10000) for real training.\n", + "STEPS = int(os.environ.get(\"STEPS\", \"10\"))\n", + "SAVE_FREQ = int(os.environ.get(\"SAVE_FREQ\", str(STEPS)))\n", + "# Log every step by default so the short workshop run yields a full loss curve (one point per\n", + "# step) to plot below; bump for long runs where per-step logging is too chatty.\n", + "LOG_FREQ = int(os.environ.get(\"LOG_FREQ\", \"1\"))\n", + "# Per-GPU batch size: keep small on a single Strix Halo, larger with more GPUs.\n", + "BATCH_SIZE = int(os.environ.get(\"BATCH_SIZE\", \"2\" if N_GPUS == 1 else \"8\"))\n", + "JOB_NAME = os.environ.get(\"JOB_NAME\", f\"mm2_{FT_MODE}_ws\")\n", + "CKPT_DIR = os.environ.get(\"CHECKPOINTS_DIR\", \"/checkpoints\")\n", + "OUTPUT_DIR = os.path.join(CKPT_DIR, JOB_NAME)\n", + "# The allenai LIBERO dataset ships without LeRobot codebase-version tags; pin a branch.\n", + "DATASET_REVISION = os.environ.get(\"DATASET_REVISION\", \"main\")\n", + "\n", + "# FT_MODE -> --policy.train_mode_vlm : lora (adapters on VLM + trainable action expert),\n", + "# freeze (VLM frozen, action expert only), fft (full fine-tune).\n", + "MODE_ARGS = {\n", + " \"lora_vlm\": [\"--policy.train_mode_vlm=lora\", \"--policy.action_mode=both\"],\n", + " \"action_expert_only\": [\"--policy.train_mode_vlm=freeze\", \"--policy.action_mode=continuous\"],\n", + " \"full\": [\"--policy.train_mode_vlm=fft\", \"--policy.action_mode=both\"],\n", + "}[FT_MODE]\n", + "\n", + "# LeRobot refuses to write into a non-empty output_dir; clean prior workshop reruns.\n", + "if os.path.isdir(OUTPUT_DIR) and os.environ.get(\"CLEAN_OUTPUT\", \"1\") == \"1\":\n", + " shutil.rmtree(OUTPUT_DIR)\n", + "os.makedirs(CKPT_DIR, exist_ok=True)\n", + "\n", + "print(\"=\" * 63)\n", + "print(f\" MolmoAct2 fine-tune | base={BASE_CKPT} mode={FT_MODE}\")\n", + "print(f\" GPUs(procs)={N_GPUS} batch/GPU={BATCH_SIZE} steps={STEPS} save_freq={SAVE_FREQ}\")\n", + "print(f\" output_dir={OUTPUT_DIR}\")\n", + "print(\"=\" * 63)\n", + "\n", + "# The exact accelerate + LeRobot training command, shown inline (no hidden wrapper). The SAME\n", + "# command runs single-GPU on Strix Halo and multi-GPU on an Instinct node (num_processes=N_GPUS).\n", + "cmd = [\n", + " TRAIN_PY,\n", + " \"-m\",\n", + " \"accelerate.commands.launch\",\n", + " f\"--num_processes={N_GPUS}\",\n", + " \"--mixed_precision=bf16\",\n", + " \"-m\",\n", + " \"lerobot.scripts.lerobot_train\",\n", + " f\"--dataset.repo_id={DATASET_REPO}\",\n", + " f\"--dataset.revision={DATASET_REVISION}\",\n", + " \"--dataset.video_backend=pyav\",\n", + " \"--dataset.image_transforms.enable=true\",\n", + " \"--policy.type=molmoact2\",\n", + " f\"--policy.checkpoint_path={BASE_CKPT}\",\n", + " \"--policy.device=cuda\",\n", + " *MODE_ARGS,\n", + " \"--policy.chunk_size=10\",\n", + " \"--policy.n_action_steps=10\",\n", + " \"--policy.setup_type=single franka robotic arm in libero\",\n", + " \"--policy.control_mode=delta end-effector pose\",\n", + " '--policy.image_keys=[\"observation.images.image\",\"observation.images.wrist_image\"]',\n", + " \"--policy.model_dtype=bfloat16\",\n", + " \"--policy.num_flow_timesteps=8\",\n", + " \"--policy.gradient_checkpointing=true\",\n", + " \"--policy.freeze_embedding=true\",\n", + " \"--policy.normalize_gripper=false\",\n", + " \"--policy.enable_knowledge_insulation=false\",\n", + " \"--policy.push_to_hub=false\",\n", + " f\"--wandb.enable={os.environ.get('WANDB_ENABLE', 'false')}\",\n", + " f\"--job_name={JOB_NAME}\",\n", + " f\"--output_dir={OUTPUT_DIR}\",\n", + " f\"--steps={STEPS}\",\n", + " f\"--batch_size={BATCH_SIZE}\",\n", + " f\"--num_workers={os.environ.get('NUM_WORKERS', '4')}\",\n", + " f\"--log_freq={LOG_FREQ}\",\n", + " f\"--eval_freq={STEPS + 1}\",\n", + " \"--save_checkpoint=true\",\n", + " f\"--save_freq={SAVE_FREQ}\",\n", + "]\n", + "loss_pts = run_train(cmd, env=child_env())\n", + "print(f\"PASS: fine-tune finished; checkpoints under {OUTPUT_DIR}/checkpoints/\")\n", + "\n", + "# Loss curve for the run we just did (LeRobot logs one point per --log_freq step). Even a short\n", + "# workshop run should trend downward; raise STEPS for a smoother, more convincing curve.\n", + "import matplotlib.pyplot as plt # inline backend -> renders in the notebook, no Agg/subprocess\n", + "\n", + "if len(loss_pts) >= 2:\n", + " steps, losses = zip(*loss_pts)\n", + " fig, ax = plt.subplots(figsize=(7, 4))\n", + " ax.plot(steps, losses, marker=\"o\", color=\"tab:red\", lw=1.6)\n", + " ax.set_xlabel(\"training step\")\n", + " ax.set_ylabel(\"loss\")\n", + " ax.set_title(f\"LoRA fine-tune loss ({FT_MODE}, {STEPS} steps, batch {BATCH_SIZE}×{N_GPUS})\")\n", + " ax.grid(True, alpha=0.3)\n", + " fig.tight_layout()\n", + " fig.savefig(os.path.join(OUT_DIR, \"finetune_loss.png\"), dpi=110)\n", + " plt.show()\n", + " print(f\"loss: {losses[0]:.4f} -> {losses[-1]:.4f} over {len(losses)} logged step(s)\")\n", + "else:\n", + " print(f\"(captured {len(loss_pts)} loss point(s) - need >=2 to plot; raise STEPS or lower LOG_FREQ)\")" + ], + "execution_count": null, + "outputs": [], + "id": "cab91785" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Which checkpoint do Step 5 (closed-loop eval) and Step 6 (interactive sim) load?\n", + "# DEFAULT: our fine-tuned LIBERO checkpoint, loaded DIRECTLY - so you get a strong policy\n", + "# without waiting on a long train. It lives at REFERENCE_POLICY (staged onto pod storage).\n", + "# Overrides: POLICY_PATH=/path/or/hub-id wins; PREFER_TRAINED=1 evaluates the checkpoint the\n", + "# short Step-4 run just produced instead.\n", + "REFERENCE = os.environ.get(\n", + " \"REFERENCE_POLICY\", os.path.join(CKPT_DIR, \"reference\", \"pretrained_model\")\n", + ")\n", + "_explicit = os.environ.get(\"POLICY_PATH\", \"\").strip()\n", + "_prefer_trained = os.environ.get(\"PREFER_TRAINED\", \"0\") == \"1\"\n", + "_trained = sorted(glob.glob(os.path.join(OUTPUT_DIR, \"checkpoints\", \"*\", \"pretrained_model\")))\n", + "_trained = [c for c in _trained if os.path.basename(os.path.dirname(c)) != \"last\"] or _trained\n", + "\n", + "\n", + "def _is_ckpt(p):\n", + " return bool(p) and os.path.isdir(p) and os.path.exists(os.path.join(p, \"config.json\"))\n", + "\n", + "\n", + "if _explicit:\n", + " POLICY_PATH = _explicit\n", + " print(\"POLICY_PATH (from env):\", POLICY_PATH)\n", + "elif _prefer_trained and _trained:\n", + " POLICY_PATH = _trained[-1]\n", + " print(\"PREFER_TRAINED=1 -> this run's Step-4 checkpoint:\", POLICY_PATH)\n", + "elif _is_ckpt(REFERENCE):\n", + " POLICY_PATH = REFERENCE\n", + " print(\"POLICY_PATH -> our fine-tuned checkpoint (default):\", POLICY_PATH)\n", + "elif _trained:\n", + " POLICY_PATH = _trained[-1]\n", + " print(f\"reference not staged at {REFERENCE}; using this run's Step-4 checkpoint:\", POLICY_PATH)\n", + "else:\n", + " raise FileNotFoundError(\n", + " \"No fine-tuned checkpoint found.\\n\"\n", + " f\" - expected our fine-tuned checkpoint at: {REFERENCE}\\n\"\n", + " f\" - or a Step-4 output under: {OUTPUT_DIR}/checkpoints/*/pretrained_model\\n\"\n", + " \"Stage the fine-tuned checkpoint at REFERENCE_POLICY, or set POLICY_PATH=/path \"\n", + " \"(or a Hub repo id), or re-run Step 4 with STEPS >= SAVE_FREQ.\"\n", + " )" + ], + "execution_count": null, + "outputs": [], + "id": "4a9ec56e" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Load the LoRA checkpoint on the DROID base -> LIBERO policy (closed-loop eval)\n", + "\n", + "The fine-tune saved a LeRobot-format checkpoint (`.../pretrained_model`): the DROID base config + the trained LoRA adapter weights + the LIBERO normalization/processor stats. Loading it with `--policy.path` reconstructs the runtime LIBERO policy (base + adapter merged at load), with no `norm_tag` needed. Here we run that policy in the LIBERO MuJoCo simulator (headless EGL on the AMD GPU) and report success.\n", + "\n", + "**This is intentionally tiny.** A LIBERO suite has ~10 tasks and the evaluator runs `N_EPISODES` rollouts *per task*, so a whole suite is `N_EPISODES × 10` MuJoCo rollouts - minutes of stepping. For the workshop we pin a **single task** (`--env.task_ids=[TASK_ID]`) and run just a few episodes, so this cell finishes quickly and is only a smoke-number. a very short LoRA run won't converge, so use the reference checkpoint (last section) for a strong number and full-suite eval offline." + ], + "id": "91a84470" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Closed-loop LIBERO eval of the checkpoint (headless EGL on the AMD GPU). The saved processor\n", + "# + normalization stats are restored from the checkpoint via --policy.path (no norm_tag). The\n", + "# exact lerobot-eval command is inline here; a very short LoRA run won't fully converge - use\n", + "# the reference checkpoint (last section) for a strong number.\n", + "SUITE = os.environ.get(\"SUITE\", \"libero_object\")\n", + "# A LIBERO suite has ~10 tasks and the evaluator runs `n_episodes` rollouts PER task, so the\n", + "# full suite is n_episodes x 10. This is only a quick sanity number (the interactive sim is the\n", + "# real demo), so we pin ONE task (TASK_ID) and run a handful of episodes -> ~2-3 rollouts total.\n", + "TASK_ID = os.environ.get(\"TASK_ID\", \"3\")\n", + "N_EPISODES = os.environ.get(\"N_EPISODES\", \"3\")\n", + "SEED = os.environ.get(\"SEED\", \"1000\")\n", + "RUN_DIR = os.path.join(OUT_DIR, f\"_ft_eval_{SUITE}_t{TASK_ID}_seed{SEED}\")\n", + "os.makedirs(RUN_DIR, exist_ok=True)\n", + "\n", + "# lerobot disables its tqdm progress bars when inside_slurm() is true (it only checks for the\n", + "# SLURM_JOB_ID env var). Setting it here turns OFF the per-step rollout / eval-batch bars at the\n", + "# source; we then print one tidy line per episode from eval_info.json after inference finishes.\n", + "eval_env = child_env(MUJOCO_GL=\"egl\", PYOPENGL_PLATFORM=\"egl\", OMP_NUM_THREADS=\"1\", MKL_NUM_THREADS=\"1\", SLURM_JOB_ID=\"1\")\n", + "cmd = [\n", + " \"/opt/train-venv/bin/lerobot-eval\",\n", + " f\"--policy.path={POLICY_PATH}\",\n", + " \"--policy.inference_action_mode=continuous\",\n", + " \"--policy.model_dtype=bfloat16\",\n", + " \"--policy.use_amp=true\",\n", + " \"--policy.enable_inference_cuda_graph=false\",\n", + " \"--policy.device=cuda\",\n", + " \"--policy.per_episode_seed=true\",\n", + " f\"--policy.eval_seed={SEED}\",\n", + " \"--env.type=libero\",\n", + " f\"--env.task={SUITE}\",\n", + " f\"--env.task_ids=[{TASK_ID}]\", # single task -> keep the workshop eval to a few rollouts\n", + " '--env.camera_name_mapping={\"agentview_image\":\"image\",\"robot0_eye_in_hand_image\":\"wrist_image\"}',\n", + " \"--eval.batch_size=1\",\n", + " f\"--eval.n_episodes={N_EPISODES}\",\n", + " f\"--seed={SEED}\",\n", + " f\"--output_dir={os.path.join(RUN_DIR, 'run')}\",\n", + "]\n", + "print(f\"quick closed-loop eval: suite={SUITE} task_id={TASK_ID} -> {N_EPISODES} rollout(s) total\\n\")\n", + "\n", + "# Per-step bars are already off (SLURM_JOB_ID above); also hide lerobot's final raw metric-dict\n", + "# dump. A clean per-episode summary is printed from eval_info.json below.\n", + "def _eval_drop(line):\n", + " return line.lstrip().startswith((\"{\", \"[{\", \"Overall Aggregated Metrics\", \"Aggregated Metrics for\"))\n", + "\n", + "_pump(_popen(cmd, env=eval_env), quiet=True, drop=_eval_drop)\n", + "\n", + "# One line per episode (success + reward), then the aggregate.\n", + "_info = json.load(open(os.path.join(RUN_DIR, \"run\", \"eval_info.json\")))\n", + "print(\"\\nclosed-loop results:\")\n", + "_n = 0\n", + "for _t in _info.get(\"per_task\", []):\n", + " _m = _t.get(\"metrics\", {})\n", + " _succ, _sr = _m.get(\"successes\", []), _m.get(\"sum_rewards\", [])\n", + " for _i, _ok in enumerate(_succ):\n", + " _rew = f\"{_sr[_i]:.2f}\" if _i < len(_sr) else \"n/a\"\n", + " print(f\" episode {_n + 1}: {'SUCCESS' if _ok else 'failure'} (sum_reward={_rew})\")\n", + " _n += 1\n", + "_ov = _info.get(\"overall\", {})\n", + "print(f\"\\nPASS: {_n} episode(s), success rate {_ov.get('pc_success', float('nan')):.1f}% \"\n", + " f\"(avg_sum_reward={_ov.get('avg_sum_reward', float('nan')):.2f}); artifacts in {RUN_DIR}\")" + ], + "execution_count": null, + "outputs": [], + "id": "844fc05e" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reference checkpoint (compelling demo without waiting for a long train)\n", + "\n", + "A short LoRA run demonstrates the *pipeline* but will not fully converge. For a strong sim demo, point `POLICY_PATH` at the provided longer-trained reference checkpoint (organizers supply the path / Hub repo; in this image it is placed under `~/checkpoints/reference/pretrained_model`), then re-run section 5:\n", + "\n", + "```python\n", + "POLICY_PATH = os.path.expanduser(\"~/checkpoints/reference/pretrained_model\") # or a Hub repo id\n", + "```\n", + "\n", + "Notes:\n", + "- Weights, images and large videos live only on the remote machine (workspace policy). Small eval artifacts land under `~/outputs`.\n", + "- To train for real, raise `STEPS` (e.g. 10000) and, on a multi-GPU Instinct node, `BATCH_SIZE`." + ], + "id": "9d5c7aaf" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/projects/Finetuning/inference_fastwam_libero.ipynb b/projects/Finetuning/inference_fastwam_libero.ipynb new file mode 100644 index 00000000..e352159c --- /dev/null +++ b/projects/Finetuning/inference_fastwam_libero.ipynb @@ -0,0 +1,294 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# FastWAM - LIBERO world-action model (interactive + imagination)\n", + "\n", + "Standalone demo of **FastWAM** - a *world-action* model built on **Wan2.2-TI2V-5B** (a T5 text\n", + "encoder, a Wan video VAE, and a video+action diffusion transformer) - driving the **LIBERO**\n", + "simulator, rendered **inline, right here in the notebook**. Nothing to train and nothing to\n", + "download: the model weights and a few ground-truth episodes are already baked into this image.\n", + "\n", + "Two things happen below, both on the **fast route** our AMD Strix Halo port ships:\n", + "\n", + "1. **Interactive simulator (fast action route).** Exactly like the MolmoAct2 interactive demo, but\n", + " driven by FastWAM. From the current camera view + robot state + your instruction, the model\n", + " *plans a short action chunk* (`infer_action`), the simulator executes it, then it re-plans - the\n", + " same receding-horizon closed loop as the shipped LIBERO eval. Type an instruction, press\n", + " **Send**, and watch the arm act; the status line shows the live per-plan latency.\n", + "\n", + "2. **Video imagination - what the model \\\"dreams\\\".** FastWAM is a *world* model, so on its joint\n", + " route (`infer_joint`) it can roll the future **video** forward as well as the actions. We imagine\n", + " **two full episodes from two different tasks** as receding-horizon rollouts and show **ground\n", + " truth on the left, FastWAM's imagined future on the right** so you can compare.\n", + "\n", + "Same self-contained style as the other notebooks: the sim's small web UI is served inside your\n", + "session and proxied through your JupyterHub route (`jupyter-server-proxy`), so there is **no extra\n", + "port to forward**. FastWAM runs in its own isolated environment (`/opt/fastwam-venv`), kept separate\n", + "from the MolmoAct2 training environment.\n", + "\n", + "**Baked assets:** BF16 checkpoint + Wan2.2 base under `/opt/fastwam-assets` (resolved automatically\n", + "below). **Knobs (env):** `SUITE=libero_object`, `TASK_ID=0`, `EPISODES=47,273` (the two imagined\n", + "episodes), `NUM_STEPS=20`, `RT_PORT=8080`." + ], + "id": "b8e61982" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import os\n", + "import subprocess\n", + "import json\n", + "import time\n", + "import urllib.request\n", + "\n", + "# Quiet ROCm/torch spam in any child process we spawn.\n", + "os.environ.setdefault(\"TORCH_BLAS_PREFER_HIPBLASLT\", \"0\")\n", + "os.environ.setdefault(\"PYTHONWARNINGS\", \"ignore\")\n", + "\n", + "OUT_DIR = os.environ.get(\"OUT_DIR\", \"/home/jovyan/outputs\")\n", + "os.makedirs(OUT_DIR, exist_ok=True)\n", + "\n", + "# FastWAM runs in its OWN isolated venv (the numpy-1.26.4 LIBERO sim stack + ROCm torch), separate\n", + "# from the MolmoAct2 train-venv, and on its OWN LIBERO config path so the two never clash.\n", + "FASTWAM_PY = os.path.join(os.environ.get(\"FASTWAM_VENV\", \"/opt/fastwam-venv\"), \"bin\", \"python\")\n", + "LIBERO_CONFIG_PATH_FASTWAM = os.environ.get(\"LIBERO_CONFIG_PATH_FASTWAM\", \"/opt/libero-config-fastwam\")\n", + "\n", + "# Baked weight/data locations (set in the image; overridable via env). The checkpoint and the\n", + "# Wan2.2 base are already BF16 - the production precision the fast route runs in.\n", + "RELEASE_DIR = os.environ.get(\"FASTWAM_RELEASE_DIR\", \"/opt/fastwam-assets/fastwam_release\")\n", + "DATA_DIR = os.environ.get(\"FASTWAM_DATA_DIR\", \"/opt/fastwam-assets/data\")\n", + "DIFFSYNTH = os.environ.get(\"DIFFSYNTH_MODEL_BASE_PATH\", \"/opt/fastwam-assets/diffsynth\")\n", + "CKPT = os.environ.get(\"CKPT\") or os.path.join(RELEASE_DIR, \"libero_uncond_2cam224.pt\")\n", + "DATASET_STATS = os.environ.get(\"DATASET_STATS\") or os.path.join(\n", + " RELEASE_DIR, \"libero_uncond_2cam224_dataset_stats.json\")\n", + "\n", + "\n", + "# Subprocesses (the sim server / videogen) get a clean env: drop the notebook's inline matplotlib\n", + "# backend (crashes headless children), force plain hipBLAS + unbuffered output, run against the\n", + "# fastwam venv's LIBERO config, and select the FastWAM policy for the model-agnostic sim harness.\n", + "def child_env(**extra):\n", + " e = dict(os.environ)\n", + " e.pop(\"MPLBACKEND\", None)\n", + " e.update({\n", + " \"TORCH_BLAS_PREFER_HIPBLASLT\": \"0\", \"PYTHONWARNINGS\": \"ignore\", \"PYTHONUNBUFFERED\": \"1\",\n", + " \"LIBERO_CONFIG_PATH\": LIBERO_CONFIG_PATH_FASTWAM,\n", + " \"POLICY_FACTORY\": \"fastwam_libero_policy:build_policy\",\n", + " \"CKPT\": CKPT, \"DATASET_STATS\": DATASET_STATS,\n", + " })\n", + " e.update({k: str(v) for k, v in extra.items()})\n", + " return e\n", + "\n", + "\n", + "# Fail early with a clear message if the assets aren't present (e.g. a code-only image without the\n", + "# baked weights). In the workshop image these are all baked in under /opt/fastwam-assets.\n", + "_missing = [p for p in (CKPT, DATASET_STATS, DIFFSYNTH) if not os.path.exists(p)]\n", + "if _missing:\n", + " raise FileNotFoundError(\n", + " \"FastWAM assets not found:\\n \" + \"\\n \".join(_missing) +\n", + " \"\\nThis notebook expects the with-assets workshop image (weights baked under \"\n", + " \"/opt/fastwam-assets). Organizers: build with the fastwam bundle (see ORGANIZER.md).\"\n", + " )\n", + "print(\"FastWAM assets OK\")\n", + "print(\" checkpoint :\", CKPT)\n", + "print(\" dataset stats :\", DATASET_STATS)\n", + "print(\" Wan2.2 base :\", DIFFSYNTH)\n", + "print(\" GT episodes :\", DATA_DIR)\n", + "print(\" interpreter :\", FASTWAM_PY)" + ], + "execution_count": null, + "outputs": [], + "id": "fe40c621" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Interactive LIBERO simulator (fast action route)\n", + "\n", + "Run the cell below, wait for the model to load (the first run JIT-compiles GPU kernels - it can take\n", + "a couple of minutes), then the live sim appears inline. Pick a task from the **environment**\n", + "dropdown, type an instruction, and press **Send**. The status line shows the step count and the\n", + "live **per-plan latency** (the fast `infer_action` time). This is the default, fastest route." + ], + "id": "b8190539" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from IPython.display import IFrame, display\n", + "\n", + "# Internal port inside your pod; reached only through the authenticated JupyterHub proxy (never\n", + "# exposed directly). The server binds 0.0.0.0:RT_PORT; jupyter-server-proxy forwards\n", + "# {JUPYTERHUB_SERVICE_PREFIX}/proxy/RT_PORT/ -> 127.0.0.1:RT_PORT.\n", + "RT_PORT = os.environ.get(\"RT_PORT\", \"8080\")\n", + "SIM_SERVER = \"/ryzers/notebooks/scripts/interactive_server_fastwam.py\"\n", + "\n", + "# Stop a server started by a previous run of this cell.\n", + "try:\n", + " if globals().get(\"_sim\") and _sim.poll() is None:\n", + " _sim.terminate()\n", + " _sim.wait(timeout=5)\n", + "except Exception:\n", + " pass\n", + "\n", + "# Serve the FastWAM policy on the fast action route. Stream server logs to a file so the notebook\n", + "# kernel never blocks on a full stdout pipe while the sim runs.\n", + "sim_env = child_env(\n", + " PORT=RT_PORT,\n", + " SUITE=os.environ.get(\"SUITE\", \"libero_object\"),\n", + " TASK_ID=os.environ.get(\"TASK_ID\", \"0\"),\n", + ")\n", + "_log_path = os.path.join(OUT_DIR, \"interactive_fastwam.log\")\n", + "_log = open(_log_path, \"w\")\n", + "_sim = subprocess.Popen([FASTWAM_PY, SIM_SERVER], env=sim_env, stdout=_log, stderr=subprocess.STDOUT)\n", + "print(f\"FastWAM sim server started (pid {_sim.pid}); loading T5 + VAE + DiT ...\")\n", + "print(\"(first run JIT-compiles ROCm kernels; this can take a few minutes)\")\n", + "\n", + "\n", + "def _sim_status():\n", + " try:\n", + " with urllib.request.urlopen(f\"http://127.0.0.1:{RT_PORT}/status\", timeout=3) as r:\n", + " return json.load(r)\n", + " except Exception:\n", + " return None\n", + "\n", + "\n", + "ready, deadline = False, time.time() + 1200\n", + "while time.time() < deadline:\n", + " if _sim.poll() is not None:\n", + " print(\"\\nserver exited early; tail of log:\")\n", + " print(\"\".join(open(_log_path).readlines()[-25:]))\n", + " break\n", + " s = _sim_status()\n", + " if s:\n", + " print(\" \" + str(s.get(\"status\", \"\"))[:96], end=\"\\r\")\n", + " if s.get(\"mode\") in (\"idle\", \"running\"):\n", + " ready = True\n", + " break\n", + " if s.get(\"mode\") == \"error\":\n", + " print(\"\\nserver error:\", s.get(\"status\"))\n", + " print(\"\".join(open(_log_path).readlines()[-25:]))\n", + " break\n", + " time.sleep(3)\n", + "\n", + "prefix = os.environ.get(\"JUPYTERHUB_SERVICE_PREFIX\", \"\").rstrip(\"/\")\n", + "sim_url = f\"{prefix}/proxy/{RT_PORT}/\" if prefix else f\"http://localhost:{RT_PORT}/\"\n", + "if ready:\n", + " print(f\"\\nready - interactive FastWAM sim embedded below (also open in a tab: {sim_url})\")\n", + " display(IFrame(sim_url, width=\"100%\", height=860))\n", + "else:\n", + " print(f\"\\nserver not ready yet; wait a moment and re-run this cell. URL: {sim_url}\")" + ], + "execution_count": null, + "outputs": [], + "id": "18060210" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Video imagination - what the model \"dreams\"\n", + "\n", + "FastWAM is a **world** model, so it can imagine the *future video*, not just the next actions. The\n", + "cell below imagines **two full episodes from two different LIBERO-Object tasks** - the *bbq sauce*\n", + "pick and the *milk* pick - and shows **ground truth (left) vs FastWAM's imagination (right)** side\n", + "by side, for the entire episode.\n", + "\n", + "To cover a whole episode (not just one 32-step chunk) it runs `infer_joint` as a **receding-horizon\n", + "rollout**: each call imagines the next horizon of video+actions from the real observation at that\n", + "step, and the segments are stitched into one full-length clip - the same receding-horizon loop the\n", + "shipped LIBERO eval uses.\n", + "\n", + "This is the heavier route (a full video diffusion per horizon), so the cell first **stops the live\n", + "sim above** to free the GPU, and the **first horizon JIT-compiles ROCm kernels** (a few minutes;\n", + "each subsequent horizon is far faster). Knobs: `EPISODES=\"47,273\"` picks the two episodes (any\n", + "episode indices; different tasks live at different indices), `NUM_STEPS` trades speed for quality,\n", + "`MAX_STEPS_PER_EPISODE` caps length if you want a quicker preview." + ], + "id": "98b1cd2b" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from IPython.display import Video, HTML, display\n", + "\n", + "# Free the GPU: stop the interactive sim (both would otherwise hold a full model copy in memory).\n", + "try:\n", + " if globals().get(\"_sim\") and _sim.poll() is None:\n", + " print(\"stopping the live sim to free the GPU for imagination ...\")\n", + " _sim.terminate()\n", + " _sim.wait(timeout=10)\n", + "except Exception:\n", + " pass\n", + "\n", + "VIDEOGEN = \"/ryzers/notebooks/scripts/fastwam_videogen.py\"\n", + "# Two DIFFERENT tasks, rendered as FULL episodes (receding-horizon rollouts, not a single chunk):\n", + "# ep47 = \"pick up the bbq sauce...\", ep273 = \"pick up the milk...\". Override via EPISODES=\"e0,e1\".\n", + "gen_env = child_env(\n", + " EPISODES=os.environ.get(\"EPISODES\", \"47,273\"),\n", + " NUM_STEPS=os.environ.get(\"NUM_STEPS\", \"20\"),\n", + " TAG=os.environ.get(\"SUITE\", \"libero_object\"),\n", + ")\n", + "print(\"imagining FULL episodes for two tasks (loads the model, then runs the joint video+action\")\n", + "print(\"route as a receding-horizon rollout over each whole episode)...\")\n", + "print(\"the first horizon JIT-warms ROCm kernels, so this takes a few minutes.\\n\")\n", + "\n", + "# Stream the backend's output live and pick up the machine-parseable lines it prints per clip.\n", + "proc = subprocess.Popen([FASTWAM_PY, VIDEOGEN], env=gen_env,\n", + " stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)\n", + "mp4s, summary = [], None\n", + "for line in proc.stdout:\n", + " line = line.rstrip()\n", + " if line.startswith(\"VIDEOGEN_MP4=\"):\n", + " mp4s.append(line.split(\"=\", 1)[1])\n", + " elif line.startswith(\"VIDEOGEN_SUMMARY=\"):\n", + " try:\n", + " summary = json.loads(line.split(\"=\", 1)[1])\n", + " except Exception:\n", + " pass\n", + " else:\n", + " print(line)\n", + "proc.wait()\n", + "\n", + "clip_task = {c[\"mp4\"]: c for c in (summary.get(\"clips\", []) if summary else [])}\n", + "if summary:\n", + " print(f\"\\njoint-path latency ({summary['num_inference_steps']} steps): first horizon \"\n", + " f\"{summary['joint_latency_s_first_warmup']:.1f}s (JIT warmup), then \"\n", + " f\"~{summary['joint_latency_s_mean_steady']:.1f}s per horizon\")\n", + "for i, mp4 in enumerate(mp4s):\n", + " if os.path.exists(mp4):\n", + " c = clip_task.get(mp4, {})\n", + " task = c.get(\"task\", f\"clip {i}\")\n", + " meta = f\" — {c['frames']} frames, {c['windows']} horizons\" if c else \"\"\n", + " display(HTML(f\"Full episode — {task}
\"\n", + " f\"ground truth (left) vs FastWAM imagined (right){meta}\"))\n", + " display(Video(mp4, embed=True, width=900))\n", + " else:\n", + " print(\"missing clip:\", mp4)\n", + "if not mp4s:\n", + " print(\"no clips produced - check the log above.\")" + ], + "execution_count": null, + "outputs": [], + "id": "f71fa59d" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/projects/Finetuning/interactive_sim_molmoact2_libero.ipynb b/projects/Finetuning/interactive_sim_molmoact2_libero.ipynb new file mode 100644 index 00000000..3d232055 --- /dev/null +++ b/projects/Finetuning/interactive_sim_molmoact2_libero.ipynb @@ -0,0 +1,205 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# MolmoAct2 - LIBERO interactive simulation (synchronous)\n", + "\n", + "Standalone demo: runs **our fine-tuned MolmoAct2 LIBERO policy** in the LIBERO simulator and renders it **inline, right here in the notebook** - no training or eval needed, it loads the fine-tuned checkpoint directly.\n", + "\n", + "This is the **synchronous** rollout: the policy plans an action chunk, the simulator executes it, then the policy re-plans - the same deterministic closed-loop (receding-horizon) control used by the Step-5 LIBERO eval. The arm pauses briefly to \"think\" between chunks, but the motion is precise and there is **no real-time blending** (blending can occasionally jostle a grasped object loose).\n", + "\n", + "There is **no extra port to forward**: the sim's small web UI is served inside your session and proxied through your JupyterHub route via `jupyter-server-proxy`, then embedded below with an `IFrame`. Type an instruction in the panel, press **Send**, and watch the policy act; the status line shows the executed command, step count, and live inference latency.\n", + "\n", + "**Default weight:** our fine-tuned checkpoint at `~/checkpoints/reference/pretrained_model` (`REFERENCE_POLICY`, staged onto pod storage; not baked into the image). Override with `POLICY_PATH=/path` or a Hub repo id.\n", + "\n", + "**Knobs (env):** `SUITE=libero_object`, `TASK_ID=3`, `RT_PORT=8080`." + ], + "id": "isim_md" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "import os\n", + "import subprocess\n", + "import json\n", + "import time\n", + "import urllib.request\n", + "\n", + "# Quiet ROCm/torch spam in any child process we spawn.\n", + "os.environ.setdefault(\"TORCH_BLAS_PREFER_HIPBLASLT\", \"0\")\n", + "os.environ.setdefault(\"PYTHONWARNINGS\", \"ignore\")\n", + "\n", + "OUT_DIR = os.environ.get(\"OUT_DIR\", \"/outputs\")\n", + "os.makedirs(OUT_DIR, exist_ok=True)\n", + "\n", + "\n", + "# Subprocesses (the sim server) inherit a clean env: drop the notebook's inline matplotlib\n", + "# backend (crashes headless children) and force plain hipBLAS + unbuffered output.\n", + "def child_env(**extra):\n", + " e = dict(os.environ)\n", + " e.pop(\"MPLBACKEND\", None)\n", + " e.update({\"TORCH_BLAS_PREFER_HIPBLASLT\": \"0\", \"PYTHONWARNINGS\": \"ignore\", \"PYTHONUNBUFFERED\": \"1\"})\n", + " e.update({k: str(v) for k, v in extra.items()})\n", + " return e\n", + "\n", + "\n", + "# Optional: stage assets from a local resources dir (ASSETS_DIR) so the sim finds everything\n", + "# without any download. The fine-tuned checkpoint reuses the base MolmoAct2-DROID weights, so we\n", + "# stage BOTH the HF cache (base + FAST tokenizer) and the reference checkpoint.\n", + "ASSETS_DIR = os.environ.get(\"ASSETS_DIR\", \"\").strip()\n", + "\n", + "\n", + "def _stage_assets():\n", + " import shutil\n", + " if not ASSETS_DIR or not os.path.isdir(ASSETS_DIR):\n", + " return\n", + " hub = os.path.join(os.environ.get(\"HF_HOME\", os.path.expanduser(\"~/.cache/huggingface\")), \"hub\")\n", + " hub_src = os.path.join(ASSETS_DIR, \"hf_hub\")\n", + " if os.path.isdir(hub_src):\n", + " os.makedirs(hub, exist_ok=True)\n", + " for _n in sorted(os.listdir(hub_src)):\n", + " _s, _d = os.path.join(hub_src, _n), os.path.join(hub, _n)\n", + " if os.path.isdir(_s) and not os.path.exists(_d):\n", + " print(f\"staging {_n} -> HF cache\")\n", + " shutil.copytree(_s, _d, symlinks=True)\n", + " _rs = os.path.join(ASSETS_DIR, \"checkpoints\", \"reference\", \"pretrained_model\")\n", + " _rd = os.environ.get(\"REFERENCE_POLICY\", os.path.expanduser(\"~/checkpoints/reference/pretrained_model\"))\n", + " if os.path.isdir(_rs) and not os.path.isdir(_rd):\n", + " print(f\"staging fine-tuned checkpoint -> {_rd}\")\n", + " os.makedirs(os.path.dirname(_rd), exist_ok=True)\n", + " shutil.copytree(_rs, _rd, symlinks=True)\n", + "\n", + "\n", + "if ASSETS_DIR:\n", + " print(f\"staging assets from {ASSETS_DIR} ...\")\n", + " _stage_assets()\n", + "\n", + "\n", + "# DEFAULT WEIGHT: our fine-tuned LIBERO checkpoint, loaded directly. POLICY_PATH overrides\n", + "# (local dir or a Hugging Face repo id).\n", + "REFERENCE = os.environ.get(\"REFERENCE_POLICY\", os.path.expanduser(\"~/checkpoints/reference/pretrained_model\"))\n", + "_explicit = os.environ.get(\"POLICY_PATH\", \"\").strip()\n", + "\n", + "\n", + "def _is_ckpt(p):\n", + " return bool(p) and os.path.isdir(p) and os.path.exists(os.path.join(p, \"config.json\"))\n", + "\n", + "\n", + "if _explicit:\n", + " POLICY_PATH = _explicit\n", + " print(\"interactive sim will load POLICY_PATH (from env):\", POLICY_PATH)\n", + "elif _is_ckpt(REFERENCE):\n", + " POLICY_PATH = REFERENCE\n", + " print(\"interactive sim will load our fine-tuned checkpoint (default):\", POLICY_PATH)\n", + "else:\n", + " raise FileNotFoundError(\n", + " f\"Fine-tuned checkpoint not found at {REFERENCE}.\\n\"\n", + " \"Stage our fine-tuned checkpoint there (organizers provide it; it is not baked into the \"\n", + " \"image), or set POLICY_PATH=/path (or a Hub repo id).\"\n", + " )" + ], + "id": "isim_setup" + }, + { + "cell_type": "code", + "metadata": {}, + "execution_count": null, + "outputs": [], + "source": [ + "from IPython.display import IFrame, display\n", + "\n", + "# Internal port inside your pod; reached only through the authenticated JupyterHub proxy\n", + "# (never exposed directly). The server binds 0.0.0.0:RT_PORT; jupyter-server-proxy forwards\n", + "# {JUPYTERHUB_SERVICE_PREFIX}/proxy/RT_PORT/ -> 127.0.0.1:RT_PORT.\n", + "RT_PORT = os.environ.get(\"RT_PORT\", \"8080\")\n", + "SIM_SERVER = \"/ryzers/notebooks/scripts/interactive_server_ft.py\"\n", + "\n", + "# Stop a server we started from a previous run of this cell.\n", + "try:\n", + " if globals().get(\"_sim\") and _sim.poll() is None:\n", + " _sim.terminate()\n", + " _sim.wait(timeout=5)\n", + "except Exception:\n", + " pass\n", + "\n", + "# Serve the fine-tuned policy (POLICY_PATH). Stream server logs to a file so the notebook\n", + "# kernel never blocks on a full stdout pipe while the sim runs.\n", + "sim_env = child_env(\n", + " POLICY_PATH=POLICY_PATH,\n", + " PORT=RT_PORT,\n", + " SUITE=os.environ.get(\"SUITE\", \"libero_object\"),\n", + " TASK_ID=os.environ.get(\"TASK_ID\", \"3\"),\n", + ")\n", + "_log_path = os.path.join(OUT_DIR, \"interactive_rt.log\")\n", + "_log = open(_log_path, \"w\")\n", + "_sim = subprocess.Popen(\n", + " [\"/opt/train-venv/bin/python\", SIM_SERVER],\n", + " env=sim_env, stdout=_log, stderr=subprocess.STDOUT,\n", + ")\n", + "print(f\"real-time sim server started (pid {_sim.pid}); loading policy: {POLICY_PATH}\")\n", + "print(\"(first run JIT-compiles kernels; this can take a few minutes)\")\n", + "\n", + "\n", + "def _sim_status():\n", + " try:\n", + " with urllib.request.urlopen(f\"http://127.0.0.1:{RT_PORT}/status\", timeout=3) as r:\n", + " return json.load(r)\n", + " except Exception:\n", + " return None\n", + "\n", + "\n", + "ready, deadline = False, time.time() + 900\n", + "while time.time() < deadline:\n", + " if _sim.poll() is not None:\n", + " print(\"\\nserver exited early; tail of log:\")\n", + " print(\"\".join(open(_log_path).readlines()[-20:]))\n", + " break\n", + " s = _sim_status()\n", + " if s:\n", + " print(\" \" + str(s.get(\"status\", \"\"))[:90], end=\"\\r\")\n", + " if s.get(\"mode\") in (\"idle\", \"running\"):\n", + " ready = True\n", + " break\n", + " if s.get(\"mode\") == \"error\":\n", + " print(\"\\nserver error:\", s.get(\"status\"))\n", + " break\n", + " time.sleep(3)\n", + "\n", + "prefix = os.environ.get(\"JUPYTERHUB_SERVICE_PREFIX\", \"\").rstrip(\"/\")\n", + "sim_url = f\"{prefix}/proxy/{RT_PORT}/\" if prefix else f\"http://localhost:{RT_PORT}/\"\n", + "if ready:\n", + " print(f\"\\nready - interactive sim embedded below (also open in a tab: {sim_url})\")\n", + " display(IFrame(sim_url, width=\"100%\", height=840))\n", + "else:\n", + " print(f\"\\nserver not ready yet; wait a moment and re-run this cell. URL: {sim_url}\")" + ], + "id": "isim_launch" + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/projects/Finetuning/scripts/fast_to_device.py b/projects/Finetuning/scripts/fast_to_device.py new file mode 100644 index 00000000..6af8a8f5 --- /dev/null +++ b/projects/Finetuning/scripts/fast_to_device.py @@ -0,0 +1,86 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""One HIP copy per dtype instead of one copy per nn.Parameter (MolmoAct2 has ~1300).""" +from collections import defaultdict + +_installed = False + + +def _same_device(a, b): + import torch + + a, b = torch.device(a), torch.device(b) + if a.type != b.type: + return False + if a.type in ("cuda", "hip"): + ai = a.index if a.index is not None else torch.cuda.current_device() + bi = b.index if b.index is not None else torch.cuda.current_device() + return ai == bi + return a == b + + +def bulk_to_device(module, device, dtype=None): + import torch + + device = torch.device(device) + unique, aliases = {}, defaultdict(list) + for t in list(module.parameters()) + list(module.buffers()): + if _same_device(t.device, device) and (dtype is None or t.dtype == dtype): + continue + if not t.is_floating_point() or t.numel() == 0: + t.data = t.data.to(device=device, dtype=dtype or t.dtype) + continue + ptr = t.data_ptr() + if ptr in unique: + aliases[ptr].append(t) + else: + unique[ptr] = t + + groups = defaultdict(list) + for t in unique.values(): + groups[dtype or t.dtype].append(t) + + for out_dtype, tensors in groups.items(): + flats = [t.detach().contiguous().to(out_dtype).reshape(-1) for t in tensors] + packed = torch.cat(flats).to(device) + off = 0 + for t in tensors: + n = t.numel() + # clone so tensors don't share one packed storage (safetensors save needs that) + owned = packed[off : off + n].view(t.shape).clone() + old = t.data_ptr() + t.data = owned + for alias in aliases.get(old, []): + alias.data = owned + off += n + del packed + print(f"fast_to_device: {len(tensors)} tensors {out_dtype} -> {device}", flush=True) + return module + + +def install(): + """Patch nn.Module.to so Policy(...).to(cuda) uses bulk_to_device.""" + global _installed + if _installed: + return + import torch + + orig = torch.nn.Module.to + + def _to(self, *args, **kwargs): + try: + device, cast_dtype, _, fmt = torch._C._nn._parse_to(*args, **kwargs) + except Exception: + return orig(self, *args, **kwargs) + if fmt is not None or device is None: + return orig(self, *args, **kwargs) + device = torch.device(device) + if device.type not in ("cuda", "hip"): + return orig(self, *args, **kwargs) + n = sum(1 for _ in self.parameters()) + if n < 32: + return orig(self, *args, **kwargs) + return bulk_to_device(self, device, dtype=cast_dtype) + + torch.nn.Module.to = _to + _installed = True diff --git a/projects/Finetuning/scripts/fastwam_smoke.py b/projects/Finetuning/scripts/fastwam_smoke.py new file mode 100644 index 00000000..30e0045a --- /dev/null +++ b/projects/Finetuning/scripts/fastwam_smoke.py @@ -0,0 +1,122 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Full-model GPU smoke for FastWAM (Wan2.2-TI2V-5B) on AMD Strix Halo (gfx1151). + +Builds the real FastWAM model via the upstream hydra config, loads the released bf16 LIBERO +checkpoint, and runs ONE fast-route `infer_action` on a synthetic observation to prove the whole +flow-matching action path executes end-to-end on ROCm. No simulator/dataset needed. Reports +first-call (one-time ROCm kernel-JIT + autotune warmup) and steady-state latency + peak VRAM. +Exits non-zero on any failure so the ORGANIZER offline check / CI catches a broken image. + +Env (baked-image defaults): FASTWAM_REPO (/repos/fastwam), CONFIG_NAME (sim_libero), +CKPT ($FASTWAM_RELEASE_DIR/libero_uncond_2cam224.pt), NUM_STEPS (20), PROMPT. +Run with the fastwam venv: /opt/fastwam-venv/bin/python scripts/fastwam_smoke.py +""" +import os +import sys +import time + +import numpy as np +import torch + +FASTWAM_REPO = os.environ.get("FASTWAM_REPO", "/repos/fastwam") +RELEASE_DIR = os.environ.get("FASTWAM_RELEASE_DIR", "/opt/fastwam-assets/fastwam_release") +CKPT = os.environ.get("CKPT") or os.path.join(RELEASE_DIR, "libero_uncond_2cam224.pt") +CONFIG_NAME = os.environ.get("CONFIG_NAME", "sim_libero") +NUM_STEPS = int(os.environ.get("NUM_STEPS") or "20") # upstream infer_action default +PROMPT = os.environ.get("PROMPT", "pick up the object and place it") + + +def _compose_cfg(): + from omegaconf import OmegaConf + from hydra import compose, initialize_config_dir + from hydra.core.global_hydra import GlobalHydra + for name, fn in (("eval", eval), ("max", lambda x: max(x)), + ("split", lambda s, idx: s.split("/")[int(idx)])): + try: + OmegaConf.register_new_resolver(name, fn, replace=True) + except Exception: + pass + GlobalHydra.instance().clear() + with initialize_config_dir(config_dir=os.path.join(FASTWAM_REPO, "configs"), version_base="1.3"): + return compose(config_name=CONFIG_NAME, overrides=[f"ckpt={CKPT}"]) + + +def main() -> int: + print(f"torch : {torch.__version__} hip={torch.version.hip}") + if not torch.version.hip: + print("FAIL: torch is not a ROCm build.", file=sys.stderr) + return 1 + if not torch.cuda.is_available(): + print("FAIL: no ROCm device visible (check /dev/kfd, /dev/dri).", file=sys.stderr) + return 1 + print(f"device[0] : {torch.cuda.get_device_name(0)}") + + if FASTWAM_REPO not in sys.path: + sys.path.insert(0, FASTWAM_REPO) + if not os.path.exists(CKPT): + print(f"FAIL: checkpoint not found: {CKPT}\n" + f" bake the FastWAM assets (with-assets image) or mount them.", file=sys.stderr) + return 1 + + from hydra.utils import instantiate + cfg = _compose_cfg() + + video_size = list(cfg.data.train.video_size) + height, width = int(video_size[0]), int(video_size[1]) + num_frames = int(cfg.data.train.num_frames) + action_horizon = num_frames - 1 + proprio_dim = int(cfg.data.train.processor.proprio_output_dim) + action_dim = int(cfg.data.train.processor.action_output_dim) + print(f"config : {CONFIG_NAME} HxW={height}x{width} " + f"action_horizon={action_horizon} proprio_dim={proprio_dim} action_dim={action_dim}") + + t0 = time.time() + model = instantiate(cfg.model, model_dtype=torch.bfloat16, device="cuda") + model.load_checkpoint(str(CKPT)) + model = model.to("cuda").eval() + n_params = sum(p.numel() for p in model.parameters()) + print(f"model loaded : {time.time() - t0:.1f}s params={n_params / 1e9:.2f}B") + + image = (torch.rand(1, 3, height, width) * 2.0 - 1.0) + proprio = torch.zeros(1, proprio_dim) + + def _run(): + with torch.no_grad(): + return model.infer_action( + prompt=PROMPT, input_image=image, action_horizon=action_horizon, + proprio=proprio, num_inference_steps=NUM_STEPS, seed=0, rand_device="cpu", + ) + + def _sync(): + if torch.cuda.is_available(): + torch.cuda.synchronize() + + _sync(); t0 = time.time(); _run(); _sync() + cold_ms = (time.time() - t0) * 1000.0 + if torch.cuda.is_available(): + torch.cuda.reset_peak_memory_stats() + _sync(); t1 = time.time(); out = _run(); _sync() + warm_ms = (time.time() - t1) * 1000.0 + + action = out["action"].detach().to(dtype=torch.float32, device="cpu").numpy() + if action.ndim == 3 and action.shape[0] == 1: + action = action[0] + peak_gb = (torch.cuda.max_memory_allocated() / 1e9) if torch.cuda.is_available() else 0.0 + print(f"infer_action : {action.shape} (steps={NUM_STEPS})") + print(f" first-call : {cold_ms:.0f} ms (incl. one-time ROCm warmup)") + print(f" steady-state : {warm_ms:.0f} ms peak={peak_gb:.1f} GB") + + if action.ndim != 2 or action.shape[-1] != action_dim: + print(f"FAIL: expected (T, {action_dim}) action chunk, got {action.shape}", file=sys.stderr) + return 1 + if not np.isfinite(action).all(): + print("FAIL: non-finite actions.", file=sys.stderr) + return 1 + + print("PASS: FastWAM full-model ROCm smoke OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/projects/Finetuning/scripts/fastwam_videogen.py b/projects/Finetuning/scripts/fastwam_videogen.py new file mode 100644 index 00000000..b47af2cc --- /dev/null +++ b/projects/Finetuning/scripts/fastwam_videogen.py @@ -0,0 +1,301 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""FastWAM video imagination (joint path) for notebook 3 - FULL EPISODES, two different tasks. + +Runs FastWAM's joint video+action denoising route (`FastWAM.infer_joint`) as a receding-horizon +rollout over the WHOLE episode (not a single 32-step chunk), for TWO genuinely different LIBERO +tasks, then writes one two-column MP4 per episode: ground-truth video (left) vs FastWAM imagined +(right) - the "what the model dreams" view (workshop rule 2.a: GT left, prediction right). + +How a full episode is imagined +------------------------------ +Each `infer_joint` call conditions on ONE start frame + ONE proprio vector and emits +`num_video_frames` video frames spanning one `action_horizon` (= num_frames-1) worth of actions, +sub-sampled by `action_video_freq_ratio` (so 33-frame / 32-action horizons -> 9 video frames). To +cover a full episode (~120-250 steps) we step the horizon window forward by `action_horizon` steps +and re-anchor each window on the real observation at that step - exactly the receding-horizon loop +the shipped LIBERO eval uses - concatenating the imagined segments into one full-length clip. This +re-grounds every horizon on ground truth (no unbounded drift) while still showing the model's +imagination across the entire task. + +Two different tasks +------------------- +The baked dataset is LIBERO-Object (10 "pick up the and place it in the basket" tasks). We +default to two visually/semantically distinct ones - the BBQ-sauce pick and the milk pick - selected +by their first episode index. Override with `EPISODES=,,...`. + +This is a subprocess backend for the notebook: it runs in the isolated /opt/fastwam-venv and prints +one `VIDEOGEN_MP4=` line per finished EPISODE plus a final `VIDEOGEN_SUMMARY=` line the +notebook parses to embed the clips inline. + +Env (all have baked-image defaults): + FASTWAM_REPO /repos/fastwam CONFIG_NAME sim_libero + CKPT $FASTWAM_RELEASE_DIR/libero_uncond_2cam224.pt + DATASET_STATS $FASTWAM_RELEASE_DIR/libero_uncond_2cam224_dataset_stats.json + DATASET_DIR $FASTWAM_DATA_DIR/libero_object_no_noops_lerobot + EPISODES "47,273" (bbq sauce, milk) NUM_STEPS 20 SEED 0 FPS 6 + MAX_STEPS_PER_EPISODE 0 (0 = full episode) OUT_DIR /outputs TAG libero_object +""" +import json +import os +import sys +import time + +import numpy as np +import torch +import imageio +from PIL import Image, ImageDraw + +FASTWAM_REPO = os.environ.get("FASTWAM_REPO", "/repos/fastwam") +CONFIG_NAME = os.environ.get("CONFIG_NAME", "sim_libero") +RELEASE_DIR = os.environ.get("FASTWAM_RELEASE_DIR", "/opt/fastwam-assets/fastwam_release") +DATA_DIR_BASE = os.environ.get("FASTWAM_DATA_DIR", "/opt/fastwam-assets/data") +CKPT = os.environ.get("CKPT") or os.path.join(RELEASE_DIR, "libero_uncond_2cam224.pt") +DATASET_STATS = os.environ.get("DATASET_STATS") or os.path.join( + RELEASE_DIR, "libero_uncond_2cam224_dataset_stats.json") +DATASET_DIR = os.environ.get("DATASET_DIR") or os.path.join( + DATA_DIR_BASE, "libero_object_no_noops_lerobot") +# Two DIFFERENT tasks by first-episode index: bbq sauce (ep47), milk (ep273). Override with EPISODES. +EPISODES = os.environ.get("EPISODES", "47,273") +NUM_STEPS = int(os.environ.get("NUM_STEPS") or "20") +SEED = int(os.environ.get("SEED") or "0") +FPS = int(os.environ.get("FPS") or "6") +MAX_STEPS_PER_EPISODE = int(os.environ.get("MAX_STEPS_PER_EPISODE") or "0") # 0 = full episode +OUT_DIR = os.environ.get("OUT_DIR", "/outputs") +TAG = os.environ.get("TAG", "libero_object") + + +def _compose_cfg(): + from omegaconf import OmegaConf + from hydra import compose, initialize_config_dir + from hydra.core.global_hydra import GlobalHydra + for name, fn in (("eval", eval), ("max", lambda x: max(x)), + ("split", lambda s, idx: s.split("/")[int(idx)])): + try: + OmegaConf.register_new_resolver(name, fn, replace=True) + except Exception: + pass + GlobalHydra.instance().clear() + with initialize_config_dir(config_dir=os.path.join(FASTWAM_REPO, "configs"), version_base="1.3"): + return compose(config_name=CONFIG_NAME, overrides=[f"ckpt={CKPT}"]) + + +def _build_dataset(cfg): + from hydra.utils import instantiate + import fastwam.datasets.lerobot.robot_video_dataset as rvd + from fastwam.utils import misc + + def _stub_text_context(self, prompt): + return torch.zeros(self.context_len, 8), torch.ones(self.context_len, dtype=torch.bool) + rvd.RobotVideoDataset._get_cached_text_context = _stub_text_context + try: + misc.get_work_dir = lambda *a, **k: "/tmp" + except Exception: + pass + + return instantiate( + cfg.data.train, + dataset_dirs=[DATASET_DIR], + is_training_set=False, + val_set_proportion=0.0, + pretrained_norm_stats=DATASET_STATS, + skip_padding_as_possible=False, + ) + + +def _episode_tasks(): + """episode_index -> task string, from the dataset's meta/episodes.jsonl (if present).""" + out = {} + p = os.path.join(DATASET_DIR, "meta", "episodes.jsonl") + if os.path.exists(p): + for line in open(p): + try: + d = json.loads(line) + except Exception: + continue + t = d.get("tasks") or d.get("task") + if isinstance(t, list): + t = t[0] if t else None + out[int(d["episode_index"])] = t + return out + + +def _slug(text, n=40): + keep = "".join(c if (c.isalnum() or c == " ") else " " for c in (text or "clip")) + return "_".join(keep.split())[:n] or "clip" + + +def _video_tensor_to_frames(video): + """[C, T, H, W] in [-1,1] -> list of uint8 HxWx3 numpy frames.""" + v = video.detach().float().clamp(-1, 1) + v = ((v + 1.0) * 127.5).to(torch.uint8).cpu().numpy() # [C, T, H, W] + return [np.ascontiguousarray(v[:, t].transpose(1, 2, 0)) for t in range(v.shape[1])] + + +def _to_rgb(frame): + if isinstance(frame, Image.Image): + return np.array(frame.convert("RGB")) + return np.asarray(frame)[..., :3] + + +def _label(img, text): + pil = Image.fromarray(img.astype(np.uint8)) + ImageDraw.Draw(pil).text((6, 6), text, fill=(255, 255, 0)) + return np.array(pil) + + +def _stitch(gt, gen): + if gt.shape[:2] != gen.shape[:2]: + gt = np.array(Image.fromarray(gt).resize((gen.shape[1], gen.shape[0]), Image.BILINEAR)) + left = _label(gt, "ground truth") + right = _label(gen, "FastWAM imagined") + return np.concatenate([left, right], axis=1) + + +def _rollout_episode(model, ds, f0, L, num_video_frames, action_horizon, ratio): + """Receding-horizon imagination over one full episode. + + Steps the horizon window forward by `action_horizon` actions, re-anchoring each window on the + ground-truth observation at that step. Returns (stitched_frames, action_mae, latencies, prompt). + """ + stitched, gt_all_a, pr_all_a, lats = [], [], [], [] + t = 0 + prompt = None + while t < L: + sample = ds[int(f0 + t)] + if prompt is None: + prompt = sample["prompt"] + gt_frames = _video_tensor_to_frames(sample["video"]) # NVF GT frames for this horizon + input_image = sample["video"][:, 0].unsqueeze(0).to("cuda", dtype=model.torch_dtype) + proprio = sample["proprio"][0:1].to("cuda", dtype=model.torch_dtype) + + t1 = time.time() + with torch.no_grad(): + out = model.infer_joint( + prompt=prompt, input_image=input_image, + num_video_frames=num_video_frames, action_horizon=action_horizon, + proprio=proprio, num_inference_steps=NUM_STEPS, seed=SEED, + rand_device="cpu", test_action_with_infer_action=False, + ) + lat = time.time() - t1 + lats.append(lat) + gen_frames = [_to_rgb(f) for f in out["video"]] + + is_last = (t + action_horizon) >= L + if is_last: + ka = L - t # actions remaining + kv = min(num_video_frames, (ka + ratio - 1) // ratio) # video frames covering them + else: + ka = action_horizon # non-overlapping stride + kv = num_video_frames - 1 # drop the shared boundary frame + + tt = min(kv, len(gt_frames), len(gen_frames)) + for i in range(tt): + stitched.append(_stitch(gt_frames[i], gen_frames[i])) + + gt_a = sample["action"].float().cpu().numpy() + pr_a = out["action"].float().cpu().numpy() + na = min(ka, gt_a.shape[0], pr_a.shape[0]) + gt_all_a.append(gt_a[:na]) + pr_all_a.append(pr_a[:na]) + + win = t // action_horizon + 1 + nwin = (L + action_horizon - 1) // action_horizon + print(f" [window {win}/{nwin}] steps {t:3d}-{min(t+action_horizon, L):3d} " + f"frames={tt} joint_latency={lat:.2f}s", flush=True) + t += action_horizon + + gt_cat = np.concatenate(gt_all_a, axis=0) if gt_all_a else np.zeros((1, 1)) + pr_cat = np.concatenate(pr_all_a, axis=0) if pr_all_a else np.zeros((1, 1)) + mae = float(np.abs(pr_cat - gt_cat).mean()) + return stitched, mae, lats, prompt + + +def main() -> int: + dev = torch.cuda.get_device_name(0) if torch.cuda.is_available() else "cpu" + print(f"torch {torch.__version__} hip={torch.version.hip} device={dev}", flush=True) + for p, what in ((CKPT, "checkpoint"), (DATASET_STATS, "dataset stats"), (DATASET_DIR, "GT dataset")): + if not os.path.exists(p): + print(f"FAIL: missing {what}: {p}", file=sys.stderr) + return 1 + + if FASTWAM_REPO not in sys.path: + sys.path.insert(0, FASTWAM_REPO) + from hydra.utils import instantiate + cfg = _compose_cfg() + num_frames = int(cfg.data.train.num_frames) + ratio = int(cfg.data.train.action_video_freq_ratio) + action_horizon = num_frames - 1 + num_video_frames = (num_frames - 1) // ratio + 1 # eval-aligned: 33/4 -> 9 video frames + print(f"num_frames={num_frames} action_horizon={action_horizon} " + f"num_video_frames={num_video_frames} ratio={ratio} steps={NUM_STEPS}", flush=True) + + t0 = time.time() + model = instantiate(cfg.model, model_dtype=torch.bfloat16, device="cuda") + model.load_checkpoint(str(CKPT)) + model = model.to("cuda").eval() + print(f"model loaded: {time.time()-t0:.1f}s proprio_dim={model.proprio_dim}", flush=True) + + ds = _build_dataset(cfg) + edi = ds.lerobot_dataset.episode_data_index + frm = edi["from"].tolist() + to = edi["to"].tolist() + tasks = _episode_tasks() + n_ep = len(frm) + + want = [int(x) for x in EPISODES.split(",") if x.strip() != ""] + want = [e for e in want if 0 <= e < n_ep] + if not want: + want = [0, min(273, n_ep - 1)] + + out_dir = os.path.join(OUT_DIR, f"videogen_{TAG}") + os.makedirs(out_dir, exist_ok=True) + print(f"episodes in dataset={n_ep} rendering FULL episodes for tasks: " + f"{[e for e in want]} -> {out_dir}", flush=True) + print("(joint route: first window JIT-warms ROCm kernels - can take several minutes)\n", flush=True) + + per = [] + all_lats = [] + for k, e in enumerate(want): + f0, f1 = int(frm[e]), int(to[e]) + L = f1 - f0 + if MAX_STEPS_PER_EPISODE > 0: + L = min(L, MAX_STEPS_PER_EPISODE) + task = tasks.get(e) or f"episode {e}" + print(f"[{k+1}/{len(want)}] episode {e} task={task!r} steps={L}", flush=True) + + stitched, mae, lats, prompt = _rollout_episode( + model, ds, f0, L, num_video_frames, action_horizon, ratio) + all_lats.extend(lats) + + mp4 = os.path.join(out_dir, f"episode{e:03d}_{_slug(task)}_gt_vs_imagined.mp4") + imageio.mimwrite(mp4, stitched, fps=FPS, quality=8, macro_block_size=1) + ep_lat = float(np.sum(lats)) + per.append({"episode": e, "task": task, "frames": len(stitched), + "windows": len(lats), "episode_latency_s": round(ep_lat, 2), + "action_norm_mae": round(mae, 4), "mp4": mp4}) + print(f" -> {len(stitched)} frames, {len(lats)} windows, " + f"episode joint-time={ep_lat:.1f}s, actMAE={mae:.4f}\n", flush=True) + print(f"VIDEOGEN_MP4={mp4}", flush=True) + + lat = np.array(all_lats) if all_lats else np.array([0.0]) + steady = lat[1:] if len(lat) > 1 else lat + summary = { + "tag": TAG, "config": CONFIG_NAME, "num_videos": len(per), + "num_inference_steps": NUM_STEPS, "num_video_frames": num_video_frames, "fps": FPS, + "joint_latency_s_first_warmup": round(float(lat[0]), 3), + "joint_latency_s_mean_steady": round(float(steady.mean()), 3), + "clips": per, + } + with open(os.path.join(out_dir, "video_latency.json"), "w") as f: + json.dump(summary, f, indent=2) + print(f"\n== {TAG} full-episode imagination ==", flush=True) + print(f"per-window joint latency ({NUM_STEPS} steps): warmup={lat[0]:.2f}s " + f"steady-mean={steady.mean():.2f}s", flush=True) + print("VIDEOGEN_SUMMARY=" + json.dumps(summary), flush=True) + print("PASS: FastWAM full-episode video imagination complete", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/projects/Finetuning/scripts/fetch_assets.sh b/projects/Finetuning/scripts/fetch_assets.sh new file mode 100755 index 00000000..2d70eb01 --- /dev/null +++ b/projects/Finetuning/scripts/fetch_assets.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# WORKSHOP USER script -- runs INSIDE your notebook server, needs NO sudo and NO kubectl. +# +# Makes the workshop's large inputs (BF16 base checkpoint + LIBERO dataset + fine-tuned checkpoint) +# available to the notebooks WITHOUT downloading anything. Your instructor pre-stages the assets once +# on the machine and mounts them read-only into your server; this wires them into the caches the +# notebooks read. It runs automatically when your server starts, and is safe to re-run by hand. +# +# It handles the two layouts the instructor may have staged: +# (1) UNPACKED read-only shared tree (recommended, ZERO-COPY) at $ASSETS_SRC: +# hf_hub// checkpoints/reference/pretrained_model/ +# -> we SYMLINK these into your cache. Nothing is copied; startup is instant. +# (2) the split-tar bundle (base/ libero/ tokenizer/ droid_dataset/ ft_checkpoint/): +# -> we extract it into your cache and rebuild the fine-tuned checkpoint from base + delta. +# +# Everything lands where the notebooks look: +# $HF_HOME/hub (models--*, datasets--*) +# $REFERENCE_POLICY (checkpoints/reference/pretrained_model) the fine-tuned policy +# +# Usage: +# scripts/fetch_assets.sh # auto-detects the mounted source +# ASSETS_SRC=/path/to/assets scripts/fetch_assets.sh +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PY="${PY:-/opt/train-venv/bin/python}" +HF_HOME="${HF_HOME:-$HOME/.cache/huggingface}" +HUB="$HF_HOME/hub" +REFERENCE_POLICY="${REFERENCE_POLICY:-$HOME/checkpoints/reference/pretrained_model}" +REF_PARENT="$(dirname "$REFERENCE_POLICY")" + +# Resolve the source. Explicit ASSETS_SRC wins; otherwise probe the usual mount points, preferring +# the UNPACKED read-only tree (zero-copy) over the split-tar bundle. +ASSETS_SRC="${ASSETS_SRC:-${1:-}}" +if [ -z "$ASSETS_SRC" ]; then + for cand in \ + /opt/auplc-assets/assets \ + /opt/auplc-assets/mm2_workshop_assets \ + /mnt/workshop-assets/assets \ + /mnt/workshop-assets/mm2_workshop_assets \ + /mnt/workshop-assets \ + "$HOME/mm2_workshop_assets" \ + "$HOME/mm2_asset_bundle"; do + if [ -d "$cand" ]; then ASSETS_SRC="$cand"; break; fi + done +fi + +if [ -z "$ASSETS_SRC" ] || [ ! -d "$ASSETS_SRC" ]; then + cat >&2 </dev/null || true)" ] +} + +_stage_ref_from() { # + local src="$1" + if [ -f "$REFERENCE_POLICY/model.safetensors" ] || { [ -L "$REFERENCE_POLICY" ] && [ -f "$REFERENCE_POLICY/model.safetensors" ]; }; then + echo " -> fine-tuned checkpoint: already present, skipping"; return 0 + fi + if [ -f "$src/model.safetensors" ]; then + # Full checkpoint already reconstructed on the shared store -> zero-copy symlink. + rm -rf "$REFERENCE_POLICY" 2>/dev/null || true + ln -sfn "$src" "$REFERENCE_POLICY" + echo " -> linked fine-tuned checkpoint (zero-copy)" + else + # Only a delta present -> copy locally (writable) and rebuild the full checkpoint. + echo " -> fine-tuned checkpoint is a delta; copying + rebuilding locally" + mkdir -p "$REFERENCE_POLICY"; cp -a "$src/." "$REFERENCE_POLICY/" + "$PY" "$HERE/reconstruct_reference.py" \ + --delta "$REFERENCE_POLICY" --out "$REFERENCE_POLICY" --hf-home "$HF_HOME" + fi +} + +# ------------------------------------------------- (1) unpacked read-only tree (zero-copy) ------- +if [ -d "$ASSETS_SRC/hf_hub" ]; then + echo " detected unpacked tree -- linking (zero-copy, nothing is copied)" + for d in "$ASSETS_SRC"/hf_hub/*/; do + [ -d "$d" ] || continue + name="$(basename "${d%/}")" + if _have "$HUB/$name"; then echo " -> $name: present, skipping"; else + ln -sfn "${d%/}" "$HUB/$name"; echo " -> linked $name" + fi + done + [ -d "$ASSETS_SRC/checkpoints/reference/pretrained_model" ] && \ + _stage_ref_from "$ASSETS_SRC/checkpoints/reference/pretrained_model" + +# ------------------------------------------------------------------- (2) split-tar bundle ------- +elif ls "$ASSETS_SRC"/base/base.tar.part-* >/dev/null 2>&1; then + echo " detected split-tar bundle -- extracting into your cache" + _untar_hub() { # + local name="$1"; shift + if _have "$HUB/$name"; then echo " -> $name: already present, skipping"; return 0; fi + echo " -> $name (into HF cache)"; cat "$@" | tar -C "$HUB" -xf - + } + _untar_hub models--allenai--MolmoAct2-DROID "$ASSETS_SRC"/base/base.tar.part-* + _untar_hub datasets--allenai--MolmoAct2-LIBERO-Dataset "$ASSETS_SRC"/libero/libero.tar.part-* + echo " -> tokenizer (into HF cache)"; tar -C "$HUB" -xf "$ASSETS_SRC"/tokenizer/tokenizer.tar + if ls "$ASSETS_SRC"/droid_dataset/droid_dataset.tar.part-* >/dev/null 2>&1; then + _untar_hub datasets--allenai--MolmoAct2-DROID-Dataset "$ASSETS_SRC"/droid_dataset/droid_dataset.tar.part-* + fi + if [ -f "$REFERENCE_POLICY/model.safetensors" ]; then + echo " -> fine-tuned checkpoint: already built, skipping" + else + echo " -> fine-tuned checkpoint delta (-> $REF_PARENT)" + cat "$ASSETS_SRC"/ft_checkpoint/ft.tar.part-* | tar -C "$REF_PARENT" -xf - + echo " -> rebuilding full fine-tuned checkpoint from BF16 base + delta" + "$PY" "$HERE/reconstruct_reference.py" \ + --delta "$REFERENCE_POLICY" --out "$REFERENCE_POLICY" --hf-home "$HF_HOME" + fi + +else + echo "ERROR: $ASSETS_SRC has neither hf_hub/ nor base/base.tar.part-* -- unknown layout." >&2 + exit 2 +fi + +# LeRobot (Step-4 fine-tune) resolves datasets under $HF_LEROBOT_HOME/{repo_id}, NOT the HF hub +# cache. Expose the hub-cached LIBERO dataset there via a symlink so offline training reads the same +# blobs instead of re-downloading 33 GB. +LEROBOT_HOME="${HF_LEROBOT_HOME:-$HF_HOME/lerobot}" +_link_lerobot_dataset() { # + local hubname="$1" repo="$2" snap + snap="$(ls -d "$HUB/$hubname"/snapshots/*/ 2>/dev/null | head -1)" + [ -n "$snap" ] || { echo " WARN: no snapshot for $hubname; LeRobot link skipped"; return 0; } + mkdir -p "$LEROBOT_HOME/$(dirname "$repo")" + ln -sfn "${snap%/}" "$LEROBOT_HOME/$repo" + echo " -> LeRobot dataset link: $LEROBOT_HOME/$repo" +} +_link_lerobot_dataset datasets--allenai--MolmoAct2-LIBERO-Dataset allenai/MolmoAct2-LIBERO-Dataset + +echo "=== verify ===" +ls -ld "$HUB"/models--allenai--MolmoAct2-DROID 2>/dev/null || true +ls -ld "$HUB"/datasets--allenai--MolmoAct2-LIBERO-Dataset 2>/dev/null || true +ls -ld "$REFERENCE_POLICY" 2>/dev/null || true +if [ -f "$REFERENCE_POLICY/config.json" ]; then + echo "fine-tuned checkpoint: config.json present" +else + echo "WARN: $REFERENCE_POLICY/config.json missing" +fi +echo "done -- open the notebooks and Run All; downloads are skipped (assets already staged)." diff --git a/projects/Finetuning/scripts/interactive_server_fastwam.py b/projects/Finetuning/scripts/interactive_server_fastwam.py new file mode 100644 index 00000000..522c80f0 --- /dev/null +++ b/projects/Finetuning/scripts/interactive_server_fastwam.py @@ -0,0 +1,389 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Interactive LIBERO demo driven by the FastWAM world-action model - notebook 3. + +Reuses the model-agnostic `sim_libero` harness shipped in the image (the SAME closed-loop / +chunk-replay control loop the MolmoAct2 interactive demo uses) but swaps in the FastWAM policy +via POLICY_FACTORY=fastwam_libero_policy:build_policy. FastWAM takes the DEFAULT "fast route" +(`infer_action`): from the current camera view + proprio + your instruction it plans a short +action chunk, the sim executes it, then it re-plans - the same receding-horizon loop as the +shipped closed-loop eval. + +Command-driven UX: the sim sits IDLE on a scene until you send an instruction; then it resets the +env + policy and runs that one task to completion (or until Stop), streaming the composed +agentview|wrist view. Pick any shipped LIBERO task from the environment dropdown. The last run's +debug video stays on screen with a download link. + +Proxy-hardened for JupyterHub: single-frame `/frame` polling (no multipart MJPEG that stalls +through jupyter-server-proxy) and RELATIVE URLs everywhere, so it embeds via +{JUPYTERHUB_SERVICE_PREFIX}/proxy/PORT/. The engine runs in a guarded thread so any load/eval +failure surfaces as mode=error in /status instead of hanging on "loading" forever. + +Env: SUITE (libero_object), TASK_ID (0), SEED (1000), PORT (8080), VIEW_RES (720), +VIDEO_RES (720), RENDER_RES (512), MAX_STEPS (0=suite default), OUT_DIR (/outputs), plus the +FastWAM policy knobs read by the adapter (CKPT, DATASET_STATS, MIXED_PRECISION, NUM_INFERENCE_STEPS, +REPLAN_STEPS, NUM_STEPS_WAIT, FASTWAM_REPO). Open http://localhost:PORT or embed via the proxy. +""" +import json +import os +import threading +import time +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +from sim_libero.envutil import env_int, env_str +from sim_libero.libero_env import get_libero_image, list_envs +from sim_libero.policy import load_policy +from sim_libero.render import banner_frame, compose_view, encode_jpeg, save_mp4 +from sim_libero.scene import build_scene + +SUITE = env_str("SUITE", "libero_object") +TASK_ID = env_int("TASK_ID", 0) +SEED = env_int("SEED", 1000) +PORT = env_int("PORT", 8080) +VIEW_RES = env_int("VIEW_RES", 720) +VIDEO_RES = env_int("VIDEO_RES", 720) +RENDER_RES = env_int("RENDER_RES", 512) # sim camera size for the HD viewport +MAX_STEPS = env_int("MAX_STEPS", 0) # 0 -> per-suite default horizon +OUT_DIR = env_str("OUT_DIR", "/outputs") + +STATE = { + "mode": "loading", "instruction": "", "scene_task": "", + "suite": SUITE, "task_id": TASK_ID, "objects": [], + "step": 0, "plans": 0, "infer_ms": 0.0, "success": False, + "status": "starting", "frame": None, "video_url": "", +} +LOCK = threading.Lock() +PENDING = {"action": None, "instruction": "", "max_steps": 0, "suite": SUITE, "task_id": TASK_ID} +EVENT = threading.Event() +STOP = {"flag": False} + +_ENVS_CACHE = None + + +def _envs_json(): + """Cached env-picker payload: [{value, label}] over every shipped LIBERO task.""" + global _ENVS_CACHE + if _ENVS_CACHE is None: + items = [] + for e in list_envs(): + desc = (e["description"] or "").strip() + label = f"{e['suite']} / task {e['task_id']}" + (f" - {desc[:44]}" if desc else "") + items.append({"value": f"suite={e['suite']}&task_id={e['task_id']}", "label": label}) + _ENVS_CACHE = items + return _ENVS_CACHE + + +def _set_frame(rgb): + with LOCK: + STATE["frame"] = encode_jpeg(rgb) + + +def _timed_policy(policy): + """Wrap predict_action_chunk so the UI can show live plan latency (the fast-route + infer_action time) and a running plan counter, without touching the harness loop.""" + _orig = policy.predict_action_chunk + + def _wrapped(obs, instruction): + t0 = time.perf_counter() + chunk = _orig(obs, instruction) + dt = (time.perf_counter() - t0) * 1000.0 + with LOCK: + STATE["infer_ms"] = round(dt, 0) + STATE["plans"] += 1 + return chunk + + policy.predict_action_chunk = _wrapped + return policy + + +def engine_thread(): + os.makedirs(os.path.join(OUT_DIR, "interactive_fastwam"), exist_ok=True) + with LOCK: + STATE["status"] = "loading FastWAM policy (T5 + VAE + DiT; first run JIT-compiles kernels) ..." + policy = _timed_policy(load_policy()) + + def show_idle(sc, keep_video=False): + obs = sc.reset() + upd = dict(mode="idle", status="idle - send an instruction", step=0, plans=0, + success=False, suite=sc.suite, task_id=sc.task_id, + scene_task=sc.description, objects=sc.objects, instruction="") + if not keep_video: + upd["video_url"] = "" + with LOCK: + STATE.update(upd) + if not keep_video: + STATE.pop("_video_path", None) + _set_frame(compose_view(get_libero_image(obs), height=VIEW_RES)) + return obs + + scene = build_scene(SUITE, TASK_ID, seed=SEED, resolution=RENDER_RES) + obs0 = show_idle(scene) + + # Warm up the fast route NOW: the first infer_action pays a one-time ROCm HIP kernel-JIT + + # attention autotune (tens of seconds). Do it before going idle so the first real command + # isn't a long silent stall. + with LOCK: + STATE["status"] = "warming up GPU kernels (one-time JIT) ..." + try: + policy.warmup(obs0, scene.description or "pick up the object") + except Exception as e: # noqa: BLE001 - warmup is best-effort + print("warmup failed (continuing):", e, flush=True) + with LOCK: + STATE["plans"] = 0 + STATE["infer_ms"] = 0.0 + STATE["status"] = "idle - send an instruction" + + def run_command(sc, instruction, max_steps): + with LOCK: + STATE.update(mode="running", instruction=instruction, step=0, plans=0, success=False, + status=f"running: {instruction}", video_url="") + STATE.pop("_video_path", None) + STOP["flag"] = False + frames = [] + + def on_frame(imgs, step, holding): + rgb = compose_view(imgs, height=VIEW_RES) + _set_frame(rgb) + frames.append(banner_frame(compose_view(imgs), instruction, VIDEO_RES)) + with LOCK: + STATE["step"] = step + + from sim_libero.rollout import run_episode + + limit = max_steps or MAX_STEPS or None + success, _ = run_episode(sc, policy, instruction, on_frame=on_frame, + should_stop=lambda: STOP["flag"], max_steps=limit) + + url = "" + if frames: + ts = datetime.now().strftime("%H%M%S") + name = f"interactive_fastwam/{ts}_{sc.suite}_{sc.task_id}_{'ok' if success else 'run'}.mp4" + path = os.path.join(OUT_DIR, name) + try: + save_mp4(frames, path, fps=20) + url = "video?ts=" + ts + with LOCK: + STATE["_video_path"] = path + except Exception as e: # noqa: BLE001 + print("video save failed:", e, flush=True) + + with LOCK: + STATE.update(mode="idle", success=success, video_url=url, + status=("success" if success else ("stopped" if STOP["flag"] else "done"))) + show_idle(sc, keep_video=True) + + while True: + EVENT.wait() + EVENT.clear() + with LOCK: + action = PENDING["action"] + instruction = PENDING["instruction"] + max_steps = int(PENDING.get("max_steps", 0) or 0) + sel_suite = PENDING.get("suite", SUITE) + sel_task = int(PENDING.get("task_id", 0) or 0) + PENDING["action"] = None + if action == "select": + STOP["flag"] = True + with LOCK: + STATE["status"] = f"loading scene {sel_suite}/{sel_task} ..." + try: + new_scene = build_scene(sel_suite, sel_task, seed=SEED, resolution=RENDER_RES) + except Exception as e: # noqa: BLE001 + with LOCK: + STATE["status"] = f"scene build failed: {e}" + continue + scene.close() + scene = new_scene + show_idle(scene) + elif action == "run": + run_command(scene, instruction, max_steps) + + +PAGE = b""" +FastWAM sim - LIBERO (live) +
+

FastWAM simulator - LIBERO (live)

+

Wan2.2-TI2V-5B world-action model on the fast route: from the current view it plans an action chunk (infer_action), the sim executes it, then it re-plans. Pick a task, type an instruction, press Send.

+sim +
+ + + +
+
+ environment + + +
+
status: loading...
+
scene
+
+
+""" + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, code, ctype, body, extra=None): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = urlparse(self.path).path + if path.endswith("/") or path == "": + self._send(200, "text/html; charset=utf-8", PAGE) + elif path.endswith("/envs"): + self._send(200, "application/json", json.dumps(_envs_json()).encode()) + elif path.endswith("/status"): + with LOCK: + s = {k: STATE[k] for k in ("mode", "status", "instruction", "scene_task", + "suite", "task_id", "objects", "step", "plans", + "infer_ms", "success", "video_url")} + self._send(200, "application/json", json.dumps(s).encode()) + elif path.endswith("/video"): + with LOCK: + p = STATE.get("_video_path") + if p and os.path.exists(p): + with open(p, "rb") as f: + self._send(200, "video/mp4", f.read(), + extra={"Content-Disposition": "attachment; filename=fastwam_libero.mp4"}) + else: + self._send(404, "text/plain", b"no video") + elif path.endswith("/frame"): + with LOCK: + frame = STATE.get("frame") + if frame: + self._send(200, "image/jpeg", frame) + else: + self._send(404, "text/plain", b"no frame") + elif path.endswith("/stream"): + self.send_response(200) + self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") + self.end_headers() + try: + while True: + with LOCK: + frame = STATE["frame"] + if frame: + self.wfile.write(b"--frame\r\nContent-Type: image/jpeg\r\n") + self.wfile.write(f"Content-Length: {len(frame)}\r\n\r\n".encode()) + self.wfile.write(frame) + self.wfile.write(b"\r\n") + time.sleep(0.06) + except (BrokenPipeError, ConnectionResetError): + pass + else: + self._send(404, "text/plain", b"not found") + + def do_POST(self): + path = urlparse(self.path).path + if path.endswith("/command"): + n = int(self.headers.get("Content-Length", "0")) + q = parse_qs(self.rfile.read(n).decode()) + instr = q.get("instruction", [""])[0].strip() + ms = q.get("max_steps", ["0"])[0] + if instr: + STOP["flag"] = True + with LOCK: + PENDING.update(action="run", instruction=instr, max_steps=int(ms or 0)) + EVENT.set() + self.send_response(204) + self.end_headers() + elif path.endswith("/select"): + n = int(self.headers.get("Content-Length", "0")) + q = parse_qs(self.rfile.read(n).decode()) + suite = q.get("suite", [""])[0].strip() + tid = q.get("task_id", ["0"])[0] + if suite: + STOP["flag"] = True + with LOCK: + PENDING.update(action="select", suite=suite, task_id=int(tid or 0)) + EVENT.set() + self.send_response(204) + self.end_headers() + elif path.endswith("/stop"): + STOP["flag"] = True + self.send_response(204) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + + +def _engine_guard(): + """Run the engine; surface any load/eval error in STATE so the UI shows 'error' instead of + hanging on 'loading' forever (important for the workshop).""" + try: + engine_thread() + except Exception as e: # noqa: BLE001 + import traceback + traceback.print_exc() + with LOCK: + STATE["mode"] = "error" + STATE["status"] = f"engine failed: {e}" + + +def main(): + threading.Thread(target=_engine_guard, daemon=True).start() + srv = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"FastWAM interactive demo on http://0.0.0.0:{PORT} " + f"(remote box: ssh -L {PORT}:localhost:{PORT} )", flush=True) + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/projects/Finetuning/scripts/interactive_server_ft.py b/projects/Finetuning/scripts/interactive_server_ft.py new file mode 100644 index 00000000..e6e43ec9 --- /dev/null +++ b/projects/Finetuning/scripts/interactive_server_ft.py @@ -0,0 +1,522 @@ +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Interactive MolmoAct2 x LIBERO demo for FINE-TUNED checkpoints - SYNCHRONOUS rollout. + +Synchronous (chunk-replay) interactive server. Unlike a real-time RTC engine (plan-ahead + +blend), it runs the exact same closed-loop receding-horizon control loop as the Step-5 LIBERO +eval: `lerobot_eval.rollout` plans an action chunk, executes it, then re-plans - the world is +effectively frozen during each model forward. This is the deterministic, best-behaved demo (no +blend artifacts), at the cost of hiding planner latency (the arm pauses briefly to think between +chunks). Use this when policy precision matters more than continuous motion (e.g. real-time +blending was dropping grasped objects). + +Policy loading matches the RT server and the Step-5 eval: a LeRobot-format fine-tuned checkpoint +via `--policy.path` (processor + normalization stats restored from the checkpoint, no norm_tag), +on the trainable allenai/lerobot@molmoact2-policy stack in /opt/train-venv. If POLICY_PATH is +unset it falls back to a released HF checkpoint via `--policy.checkpoint_path` + `--policy.norm_tag`. + +lerobot 0.5.2 note: `rollout` reads the task itself via `env.call("task_description")` (the old +`ev.add_envs_task` injection hook was removed), so we intercept `env.call` to feed the user's live +instruction into the loop. + +Command-driven UX identical to the RT demo: idle on a scene until you send an instruction; then it +resets the env + policy and runs that one task, streaming the camera (single-frame /frame polling, +proxy-friendly) and saving a debug video. Randomize swaps to a new random scene. + +Env: POLICY_PATH (LeRobot fine-tuned ckpt dir/Hub repo) OR CKPT (released HF repo), +NORM_TAG (libero, only for the CKPT fallback), SUITE, TASK_ID, SEED, PORT (8080), +VIEW_RES (720), VIDEO_RES (600), NUM_STEPS, OUT_DIR (/outputs). +Open http://localhost:PORT, or embed via jupyter-server-proxy ({PREFIX}/proxy/PORT/). +""" +import io +import json +import os +import random +import threading +import time +from datetime import datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +SUITE = os.environ.get("SUITE", "libero_object") +TASK_ID = int(os.environ.get("TASK_ID") or "3") +SEED = int(os.environ.get("SEED") or "1000") +POLICY_PATH = os.environ.get("POLICY_PATH", "").strip() +CKPT = os.environ.get("CKPT", "allenai/MolmoAct2-LIBERO").strip() # fallback (non-Think) +NORM_TAG = os.environ.get("NORM_TAG", "libero") +PORT = int(os.environ.get("PORT") or "8080") +VIEW_RES = int(os.environ.get("VIEW_RES") or "720") # live viewport (proxy-friendly single frames) +VIDEO_RES = int(os.environ.get("VIDEO_RES") or "600") # saved debug video (kept small) +OUT_DIR = os.environ.get("OUT_DIR", "/outputs") +SUITES = ["libero_object", "libero_goal", "libero_spatial", "libero_10"] + +# ---- shared state ------------------------------------------------------------ +STATE = { + "mode": "loading", # loading | idle | running | error + "instruction": "", # the instruction currently being executed + "scene_task": "", # the scene's native LIBERO instruction + "suite": SUITE, "task_id": TASK_ID, + "objects": [], # object names visible in the scene + "step": 0, "infer_ms": 0.0, "success": False, + "status": "starting", "frame": None, "video_url": "", +} +LOCK = threading.Lock() +PENDING = {"action": None, "instruction": ""} +EVENT = threading.Event() +STOP = {"flag": False} + + +class StopRollout(Exception): + pass + + +def _font(size): + for p in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"): + if os.path.exists(p): + return ImageFont.truetype(p, size) + return ImageFont.load_default() + + +def _encode_jpeg(rgb, quality=85): + buf = io.BytesIO() + Image.fromarray(np.ascontiguousarray(rgb)).save(buf, format="JPEG", quality=quality) + return buf.getvalue() + + +def _set_frame(rgb): + with LOCK: + STATE["frame"] = _encode_jpeg(rgb) + + +def _banner_frame(rgb, text, size): + """Downscale to `size` and add a top banner with the executed command.""" + img = Image.fromarray(np.ascontiguousarray(rgb)).resize((size, size), Image.BILINEAR) + bh = max(40, size // 12) + bh += bh % 2 + canvas = Image.new("RGB", (size, size + bh), (15, 15, 18)) + canvas.paste(img, (0, bh)) + d = ImageDraw.Draw(canvas) + f = _font(max(14, size // 28)) + msg = text if len(text) <= 70 else text[:67] + "..." + d.text((10, bh // 2), msg, fill=(240, 240, 240), font=f, anchor="lm") + return np.asarray(canvas) + + +class Scene: + """Holds a single (suite, task_id) vec env + its metadata. Built via make_env so + obs_type / processors match the lerobot eval path exactly.""" + + def __init__(self, suite, task_id, env, scene_task, objects): + self.suite, self.task_id = suite, task_id + self.env, self.scene_task, self.objects = env, scene_task, objects + + def hi_res(self, size): + try: + le = self.env.envs[0] + img = le._env.sim.render(width=size, height=size, camera_name="agentview") + return np.asarray(img)[::-1, ::-1] + except Exception: + return np.asarray(self.env.call("render")[0]) + + def idle_frame(self, size): + try: + self.env.reset(seed=SEED) + except Exception: + pass + return self.hi_res(size) + + +# Runtime overrides applied to the loaded checkpoint config when using --policy.path. +# (lerobot's path-field filter strips inline --policy.* overrides, so we set these on the +# config object after parsing instead.) +_PATH_OVERRIDES = { + "device": "cuda", + "inference_action_mode": "continuous", + "model_dtype": "bfloat16", + "use_amp": True, + "enable_inference_cuda_graph": False, +} + + +def _fallback_policy_cli(): + """Policy argv for the FALLBACK case: a released HF checkpoint via --policy.checkpoint_path + (draccus parses these natively). Used only when POLICY_PATH is unset.""" + args = [ + "--policy.type=molmoact2", + f"--policy.checkpoint_path={CKPT}", + f"--policy.norm_tag={NORM_TAG}", + "--policy.inference_action_mode=continuous", + "--policy.model_dtype=bfloat16", + "--policy.use_amp=true", + "--policy.enable_inference_cuda_graph=false", + "--policy.device=cuda", + ] + if os.environ.get("NUM_STEPS"): + args.append(f"--policy.num_inference_steps={os.environ['NUM_STEPS']}") + return args + + +def rollout_thread(): + import torch + from fast_to_device import install + install() + import draccus + from lerobot.configs import parser as lrparser + from lerobot.configs.eval import EvalPipelineConfig + from lerobot.envs.factory import make_env, make_env_pre_post_processors + from lerobot.policies.factory import make_policy, make_pre_post_processors + import lerobot.scripts.lerobot_eval as ev + + preprocess_observation = ev.preprocess_observation + + src = POLICY_PATH if POLICY_PATH else CKPT + common = [ + "--env.type=libero", + "--env.camera_name_mapping={\"agentview_image\":\"image\",\"robot0_eye_in_hand_image\":\"wrist_image\"}", + "--eval.batch_size=1", "--eval.n_episodes=1", + f"--seed={SEED}", "--output_dir=/tmp/interactive_ft_eval", + ] + + def parse_eval(extra): + """Build an EvalPipelineConfig. For a fine-tuned LeRobot checkpoint we register its + path in lerobot's path-field registry (what `@parser.wrap()` does for --policy.path) + so draccus loads the pretrained policy config, then apply runtime overrides on the + config object (the path-field filter would otherwise drop inline --policy.* flags). + For the released-HF fallback we pass --policy.type/--policy.checkpoint_path natively.""" + if POLICY_PATH: + lrparser._config_path_args["policy"] = POLICY_PATH # noqa: SLF001 + cfg = draccus.parse(EvalPipelineConfig, args=list(common) + list(extra)) + for k, v in _PATH_OVERRIDES.items(): + if hasattr(cfg.policy, k): + setattr(cfg.policy, k, v) + if os.environ.get("NUM_STEPS") and hasattr(cfg.policy, "num_inference_steps"): + cfg.policy.num_inference_steps = int(os.environ["NUM_STEPS"]) + else: + cfg = draccus.parse(EvalPipelineConfig, args=_fallback_policy_cli() + list(common) + list(extra)) + return cfg + + with LOCK: + STATE["status"] = f"loading policy from {src} (first run JIT-compiles kernels) ..." + base_cfg = parse_eval([f"--env.task={SUITE}", f"--env.task_ids=[{TASK_ID}]"]) + policy = make_policy(cfg=base_cfg.policy, env_cfg=base_cfg.env, rename_map=base_cfg.rename_map) + policy.eval() + preprocessor, postprocessor = make_pre_post_processors( + policy_cfg=base_cfg.policy, pretrained_path=base_cfg.policy.pretrained_path, + preprocessor_overrides={ + "device_processor": {"device": str(policy.config.device)}, + "rename_observations_processor": {"rename_map": base_cfg.rename_map}, + }, + ) + env_pre, env_post = make_env_pre_post_processors(env_cfg=base_cfg.env, policy_cfg=base_cfg.policy) + + def build_scene(suite, task_id): + cfg = parse_eval([f"--env.task={suite}", f"--env.task_ids=[{task_id}]"]) + envs = make_env(cfg.env, n_envs=1, use_async_envs=False, trust_remote_code=cfg.trust_remote_code) + env = envs[suite][task_id] + # lerobot 0.5.2 rollout pulls the task via env.call("task_description"); intercept it so + # we can inject the user's live instruction (the old ev.add_envs_task hook is gone). When + # holder["instr"] is None (idle scene) we defer to the env's native task description. + _true_call = env.call + holder = {"instr": None} + + def _call(name, *a, **k): + if holder["instr"] is not None and name in ("task_description", "task"): + return [holder["instr"]] * env.num_envs + return _true_call(name, *a, **k) + + env.call = _call + env._instr_holder = holder + env.reset(seed=SEED) + le = env.envs[0] + scene_task = getattr(le, "task_description", "") or "" + try: + objs = [getattr(o, "name", str(o)).replace("_1", "").replace("_", " ") + for o in le._env.env.objects] + except Exception: + objs = list(getattr(le._env, "obj_of_interest", [])) + return Scene(suite, task_id, env, scene_task, objs) + + def show_idle(sc): + sc.env._instr_holder["instr"] = None # idle scene uses its native task description + with LOCK: + STATE.update(mode="idle", status="idle - send an instruction", step=0, + success=False, suite=sc.suite, task_id=sc.task_id, + scene_task=sc.scene_task, objects=sc.objects, + instruction="", video_url="") + STATE.pop("_video_path", None) + _set_frame(sc.idle_frame(VIEW_RES)) + + os.makedirs(os.path.join(OUT_DIR, "interactive_ft"), exist_ok=True) + scene = build_scene(SUITE, TASK_ID) + + # Warm up flash/JIT kernels NOW (the first model forward compiles them and can take ~20s); + # otherwise that one-time stall would freeze the first command with no feedback. + with LOCK: + STATE["status"] = "warming up GPU kernels (one-time JIT) ..." + try: + wobs, _ = scene.env.reset(seed=SEED) + wproc = preprocess_observation(wobs) + wproc["task"] = [scene.scene_task or "pick up the object" for _ in range(scene.env.num_envs)] + wproc = env_pre(wproc) + wproc = preprocessor(wproc) + _t = time.perf_counter() + with torch.inference_mode(): + policy.select_action(wproc) + policy.reset() + print(f"[warmup] done in {time.perf_counter() - _t:.1f}s", flush=True) + except Exception as e: # noqa: BLE001 + import traceback; traceback.print_exc() + print("warmup failed:", e, flush=True) + + show_idle(scene) + + def run_command(sc, instruction): + sc.env._instr_holder["instr"] = instruction # rollout will read this via env.call + with LOCK: + STATE.update(mode="running", instruction=instruction, step=0, success=False, + status=f"running: {instruction}", video_url="") + STOP["flag"] = False + frames = [] + + def render_cb(vec_env): + if STOP["flag"]: + raise StopRollout + rgb = sc.hi_res(VIEW_RES) + _set_frame(rgb) + frames.append(_banner_frame(rgb, instruction, VIDEO_RES)) + v = getattr(policy, "_last_model_inference_s", 0.0) * 1000.0 + with LOCK: + STATE["step"] += 1 + if v > 0: + STATE["infer_ms"] = round(v, 0) + + success = False + try: + with torch.no_grad(): + out = ev.rollout(sc.env, policy, env_preprocessor=env_pre, env_postprocessor=env_post, + preprocessor=preprocessor, postprocessor=postprocessor, + seeds=[SEED], render_callback=render_cb) + try: + success = bool(np.asarray(out["success"]).any()) + except Exception: + success = False + except StopRollout: + with LOCK: + STATE["status"] = "stopped" + + url = "" + if frames: + ts = datetime.now().strftime("%H%M%S") + name = f"interactive_ft/{ts}_{sc.suite}_{sc.task_id}_{'ok' if success else 'run'}.mp4" + path = os.path.join(OUT_DIR, name) + try: + import imageio + with imageio.get_writer(path, fps=20, codec="libx264", quality=8, + macro_block_size=1, output_params=["-pix_fmt", "yuv420p"]) as w: + for fr in frames: + w.append_data(fr) + url = "video?ts=" + ts + with LOCK: + STATE["_video_path"] = path + except Exception as e: # noqa: BLE001 + print("video save failed:", e, flush=True) + + with LOCK: + STATE.update(mode="idle", success=success, video_url=url, + status=("success" if success else ("stopped" if STOP["flag"] else "done"))) + show_idle(sc) + + while True: + EVENT.wait() + EVENT.clear() + with LOCK: + action, instruction = PENDING["action"], PENDING["instruction"] + PENDING["action"] = None + if action == "randomize": + STOP["flag"] = True + suite = random.choice(SUITES) + tid = random.randint(0, 9) + with LOCK: + STATE["status"] = f"loading scene {suite}/{tid} ..." + try: + new_scene = build_scene(suite, tid) + except Exception as e: # noqa: BLE001 + with LOCK: + STATE["status"] = f"scene build failed: {e}" + continue + try: + scene.env.close() + except Exception: + pass + scene = new_scene + show_idle(scene) + elif action == "run": + run_command(scene, instruction) + + +# --------------------------------------------------------------------------- +PAGE = b""" +MolmoAct2 x LIBERO (fine-tuned, synchronous) +
+

MolmoAct2 x LIBERO - fine-tuned checkpoint (synchronous)

+

Deterministic closed-loop rollout: the policy plans an action chunk, the sim executes it, then it re-plans (the same receding-horizon loop as the Step-5 eval). The arm pauses briefly to think between chunks - no real-time blending.

+sim +
+ + + + +
+
status: loading...
+
scene
+
+
+""" + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, code, ctype, body): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + path = urlparse(self.path).path + if path == "/": + self._send(200, "text/html; charset=utf-8", PAGE) + elif path == "/status": + with LOCK: + s = {k: STATE[k] for k in ("mode", "status", "instruction", "scene_task", + "suite", "task_id", "objects", "step", "infer_ms", + "success", "video_url")} + self._send(200, "application/json", json.dumps(s).encode()) + elif path == "/video": + with LOCK: + p = STATE.get("_video_path") + if p and os.path.exists(p): + with open(p, "rb") as f: + self._send(200, "video/mp4", f.read()) + else: + self._send(404, "text/plain", b"no video") + elif path == "/frame": + with LOCK: + frame = STATE.get("frame") + if frame: + self._send(200, "image/jpeg", frame) + else: + self._send(404, "text/plain", b"no frame") + elif path == "/stream": + self.send_response(200) + self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame") + self.end_headers() + try: + while True: + with LOCK: + frame = STATE["frame"] + if frame: + self.wfile.write(b"--frame\r\nContent-Type: image/jpeg\r\n") + self.wfile.write(f"Content-Length: {len(frame)}\r\n\r\n".encode()) + self.wfile.write(frame) + self.wfile.write(b"\r\n") + time.sleep(0.06) + except (BrokenPipeError, ConnectionResetError): + pass + else: + self._send(404, "text/plain", b"not found") + + def do_POST(self): + path = urlparse(self.path).path + if path == "/command": + n = int(self.headers.get("Content-Length", "0")) + instr = parse_qs(self.rfile.read(n).decode()).get("instruction", [""])[0].strip() + if instr: + STOP["flag"] = True + with LOCK: + PENDING["action"], PENDING["instruction"] = "run", instr + EVENT.set() + self.send_response(204) + self.end_headers() + elif path == "/stop": + STOP["flag"] = True + self.send_response(204) + self.end_headers() + elif path == "/randomize": + STOP["flag"] = True + with LOCK: + PENDING["action"] = "randomize" + EVENT.set() + self.send_response(204) + self.end_headers() + else: + self.send_response(404) + self.end_headers() + + +def _engine_guard(): + """Run the rollout thread; on any unhandled error surface it in STATE so the UI shows + 'error' instead of hanging on 'loading' forever (important for the workshop).""" + try: + rollout_thread() + except Exception as e: # noqa: BLE001 + import traceback + traceback.print_exc() + with LOCK: + STATE["mode"] = "error" + STATE["status"] = f"engine failed: {e}" + + +def main(): + threading.Thread(target=_engine_guard, daemon=True).start() + srv = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) + print(f"interactive (fine-tuned, synchronous) demo on http://localhost:{PORT} " + f"(remote box: ssh -L {PORT}:localhost:{PORT} )", flush=True) + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/projects/Finetuning/scripts/make_fastwam_bundle.sh b/projects/Finetuning/scripts/make_fastwam_bundle.sh new file mode 100755 index 00000000..e13e9978 --- /dev/null +++ b/projects/Finetuning/scripts/make_fastwam_bundle.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# ORGANIZER helper: pack the FastWAM LIBERO weights into the split-tar bundle that the course +# image bakes (notebook 3). The FastWAM checkpoint + Wan2.2 base are already BF16 (the production +# precision the fast route runs in), so this only selects + tars them - no conversion needed. +# +# It writes /fastwam/fastwam.tar.part-* which the Dockerfile's fastwam-staging stage +# reassembles into /opt/fastwam-assets as: +# fastwam_release/libero_uncond_2cam224.pt (bf16 action checkpoint, ~12 GB) +# fastwam_release/libero_uncond_2cam224_dataset_stats.json +# diffsynth/... (bf16 Wan2.2 base: T5 + VAE + tokenizer, ~12 GB) +# data// (LIBERO GT episodes for the imagination cell, ~1.3 GB) +# +# Put the resulting fastwam/ subdir next to base/ libero/ tokenizer/ ... inside mm2_workshop_assets, +# then build exactly as before (ASSETS_SRC=/path/to/mm2_workshop_assets); the build bakes it in. +# +# Usage: +# scripts/make_fastwam_bundle.sh +# where FASTWAM_MODELS_DIR is the FastWAM `models/` tree (contains fastwam_release/, diffsynth/, +# data/) and ASSETS_DIR is the mm2_workshop_assets folder to add the fastwam/ subdir to. +# Optional: PART_SIZE (default 4000M), DATASET_NAME (default libero_object_no_noops_lerobot). +set -euo pipefail + +SRC="${1:?usage: make_fastwam_bundle.sh }" +ASSETS_DIR="${2:?usage: make_fastwam_bundle.sh }" +PART_SIZE="${PART_SIZE:-4000M}" +DATASET_NAME="${DATASET_NAME:-libero_object_no_noops_lerobot}" + +REL="$SRC/fastwam_release" +CKPT="$REL/libero_uncond_2cam224.pt" +STATS="$REL/libero_uncond_2cam224_dataset_stats.json" +DIFF="$SRC/diffsynth" +DATA_REL="data/$DATASET_NAME" + +echo "packing FastWAM bundle" +echo " source models = $SRC" +echo " -> assets = $ASSETS_DIR/fastwam" +for p in "$CKPT" "$STATS"; do + [ -f "$p" ] || { echo "ERROR: missing file: $p" >&2; exit 2; } +done +[ -d "$DIFF" ] || { echo "ERROR: missing dir: $DIFF" >&2; exit 2; } +[ -d "$SRC/$DATA_REL" ] || { echo "ERROR: missing dir: $SRC/$DATA_REL" >&2; exit 2; } + +DEST="$ASSETS_DIR/fastwam" +mkdir -p "$DEST" +rm -f "$DEST"/fastwam.tar.part-* 2>/dev/null || true + +# Stream a tar of ONLY the LIBERO pieces (no robotwin / rt_checkpoints) straight from SRC into +# ~PART_SIZE splits - no intermediate copy, so it needs no extra disk for a second full tree. +# Drop ModelScope/HF download bookkeeping (.msc/.mv/.locks/.cache), the empty scratch dirs +# (._____temp) and any *.incomplete leftovers: none are needed for direct-path loading, and the +# root-owned .msc lock is not even world-readable. This keeps the baked tree clean + reproducible. +tar -C "$SRC" \ + --exclude='*/._____temp' --exclude='*/.______temp' --exclude='*.incomplete' \ + --exclude='*/.msc' --exclude='*/.mv' --exclude='*/.locks' --exclude='*/.cache' \ + -cf - \ + "fastwam_release/libero_uncond_2cam224.pt" \ + "fastwam_release/libero_uncond_2cam224_dataset_stats.json" \ + "diffsynth" \ + "$DATA_REL" \ + | split -b "$PART_SIZE" -d -a 3 - "$DEST/fastwam.tar.part-" + +echo "done. parts:" +ls -lh "$DEST"/fastwam.tar.part-* +echo +echo "sanity: this reassembles to fastwam_release/ diffsynth/ data/ under /opt/fastwam-assets" +echo " cat $DEST/fastwam.tar.part-* | tar -tf - | head" diff --git a/projects/Finetuning/scripts/make_libero_subset.py b/projects/Finetuning/scripts/make_libero_subset.py new file mode 100755 index 00000000..4603bc18 --- /dev/null +++ b/projects/Finetuning/scripts/make_libero_subset.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Build a small, self-consistent subset of a LeRobot v3.0 dataset (e.g. the MolmoAct2 LIBERO +dataset) so the workshop payload is ~1 GB instead of ~33 GB. The subset is a drop-in replacement: +same repo id, same HF-cache layout (blobs/ + snapshots// + refs/main), so staging and the +notebook's offline prefetch keep working unchanged, and `lerobot-train` trains on exactly the +episodes present (no episode list is hard-coded in the notebook). + +Two selection modes: + + stratified (default) - round-robin across every task so the subset keeps a LARGE task + distribution (all/most tasks represented), not just the first few. Episodes are then + non-contiguous in the source, so the data parquet files are re-packed and the global + `index` / `episode_index` columns and per-episode metadata are re-numbered. `task_index` + values are preserved and `tasks.parquet` is kept whole, so no task re-mapping is needed. + + prefix - keep a contiguous prefix of data files (fast, reuses the original cache blobs, no + re-encode) but only covers the first tasks. Handy for a quick tiny build. + +The kept episode set is always derived from the ACTUAL `episode_index` values inside the data +parquet files, because this dataset's meta `data/file_index` column does NOT line up with the +parquet file numbering. + +Usage: + make_libero_subset.py --repo-cache /datasets--allenai--MolmoAct2-LIBERO-Dataset \ + --out [--target-gb 1.0] [--mode stratified|prefix] \ + [--episodes-per-task N] + + ends up containing datasets--/ ready to tar into the workshop bundle +(unpack_bundle.sh extracts it straight into ASSETS_DIR/hf_hub). +""" +import argparse +import hashlib +import io +import json +import os +import shutil +from collections import defaultdict + +import pyarrow as pa +import pyarrow.compute as pc +import pyarrow.parquet as pq + + +def _sha256_bytes(b): + return hashlib.sha256(b).hexdigest() + + +def _snapshot_dir(repo_cache): + sha = open(os.path.join(repo_cache, "refs", "main")).read().strip() + snap = os.path.join(repo_cache, "snapshots", sha) + if not os.path.isdir(snap): + raise SystemExit(f"snapshot dir not found for refs/main={sha}: {snap}") + return sha, snap + + +def _link_only(out_snap, rel, blob_abs): + link = os.path.join(out_snap, rel) + os.makedirs(os.path.dirname(link), exist_ok=True) + if os.path.lexists(link): + os.remove(link) + os.symlink(os.path.relpath(blob_abs, start=os.path.dirname(link)), link) + + +def _reuse_blob(src_snap, out_repo, out_snap, rel): + """Reuse the ORIGINAL cache blob for an unchanged file (copy blob + relative symlink).""" + blob = os.path.realpath(os.path.join(src_snap, rel)) + dst = os.path.join(out_repo, "blobs", os.path.basename(blob)) + if not os.path.exists(dst): + shutil.copy2(blob, dst) + _link_only(out_snap, rel, dst) + + +def _write_blob(out_repo, out_snap, rel, data_bytes): + """Write NEW content as a fresh blob + snapshot symlink.""" + h = _sha256_bytes(data_bytes) + dst = os.path.join(out_repo, "blobs", h) + if not os.path.exists(dst): + with open(dst, "wb") as f: + f.write(data_bytes) + _link_only(out_snap, rel, dst) + + +def _table_bytes(tbl): + buf = io.BytesIO() + pq.write_table(tbl, buf) + return buf.getvalue() + + +def _build_ep_to_file(snap, data_files): + """Map episode_index -> data filename by reading the ACTUAL episode_index column of each file + (meta data/file_index is unreliable for this dataset). Assumes an episode lives in one file.""" + ep2file = {} + for fn in data_files: + col = pq.read_table( + os.path.realpath(os.path.join(snap, "data", "chunk-000", fn)), columns=["episode_index"] + ).column("episode_index").to_pylist() + for e in set(col): + ep2file[int(e)] = fn + return ep2file + + +def _select_stratified(ep, target_frames, episodes_per_task): + """Round-robin across tasks (by task string set) until ~target_frames, capped per task.""" + n = len(ep["episode_index"]) + groups = defaultdict(list) + for i in range(n): + groups[tuple(ep["tasks"][i])].append(i) + task_keys = sorted(groups, key=lambda k: min(groups[k])) + for k in task_keys: + groups[k].sort() + pos = {k: 0 for k in task_keys} + taken = {k: 0 for k in task_keys} + selected, frames = [], 0 + progressed = True + while frames < target_frames and progressed: + progressed = False + for k in task_keys: + if pos[k] < len(groups[k]) and (episodes_per_task <= 0 or taken[k] < episodes_per_task): + e = groups[k][pos[k]] + pos[k] += 1 + taken[k] += 1 + selected.append(e) + frames += int(ep["length"][e]) + progressed = True + if frames >= target_frames: + break + return sorted(selected), len(task_keys) + + +def _select_prefix(snap, data_files, sizes, target_bytes): + cum, n_files = 0, 0 + for i, s in enumerate(sizes): + cum += s + n_files = i + 1 + if cum >= target_bytes: + break + kept_eps = set() + for i in range(n_files): + col = pq.read_table( + os.path.realpath(os.path.join(snap, "data", "chunk-000", data_files[i])), + columns=["episode_index"], + ).column("episode_index").to_pylist() + kept_eps.update(int(x) for x in col) + return sorted(kept_eps), n_files + + +def _repack(snap, out_repo, out_snap, ep_tbl, ep, selected, ep2file, data_files_size_mb): + """Re-pack the selected episodes' rows into new data parquet files with renumbered global + `index` (0..N-1) and `episode_index` (0..K-1); rebuild meta/episodes accordingly.""" + thresh = int(data_files_size_mb * 1e6) + buf, buf_bytes, fidx, g = [], 0, 0, 0 + new_ep_file = {} # new_ep -> data file index + written = [] # (fidx, table) + cached_fn, cached_tbl = None, None + + def flush(): + nonlocal buf, buf_bytes, fidx + if not buf: + return + tbl = pa.concat_tables(buf) + _write_blob(out_repo, out_snap, f"data/chunk-000/file-{fidx:03d}.parquet", _table_bytes(tbl)) + fidx += 1 + buf, buf_bytes = [], 0 + + lengths = [] + for new_ep, e in enumerate(selected): + fn = ep2file[e] + if fn != cached_fn: + cached_tbl = pq.read_table(os.path.realpath(os.path.join(snap, "data", "chunk-000", fn))) + cached_fn = fn + rows = cached_tbl.filter(pc.equal(cached_tbl.column("episode_index"), e)) + m = rows.num_rows + idx_arr = pa.array(list(range(g, g + m)), type=pa.int64()) + ep_arr = pa.array([new_ep] * m, type=pa.int64()) + rows = rows.set_column(rows.schema.get_field_index("index"), "index", idx_arr) + rows = rows.set_column(rows.schema.get_field_index("episode_index"), "episode_index", ep_arr) + new_ep_file[new_ep] = fidx + g += m + lengths.append(m) + buf.append(rows) + buf_bytes += rows.nbytes + if buf_bytes >= thresh: + flush() + flush() + total_frames = g + + # rebuild meta/episodes: take selected rows (in order), renumber the bookkeeping columns + sub = ep_tbl.take(pa.array(selected, type=pa.int64())) + K = len(selected) + froms, tos, acc = [], [], 0 + for L in lengths: + froms.append(acc) + tos.append(acc + L) + acc += L + + def setcol(t, name, arr): + return t.set_column(t.schema.get_field_index(name), name, pa.array(arr, type=pa.int64())) + + sub = setcol(sub, "episode_index", list(range(K))) + sub = setcol(sub, "dataset_from_index", froms) + sub = setcol(sub, "dataset_to_index", tos) + sub = setcol(sub, "data/chunk_index", [0] * K) + sub = setcol(sub, "data/file_index", [new_ep_file[i] for i in range(K)]) + if "meta/episodes/chunk_index" in sub.column_names: + sub = setcol(sub, "meta/episodes/chunk_index", [0] * K) + sub = setcol(sub, "meta/episodes/file_index", [0] * K) + # cross-check meta length column matches actual rows + meta_len = sub.column("length").to_pylist() + assert meta_len == lengths, "meta length column disagrees with packed rows" + _write_blob(out_repo, out_snap, "meta/episodes/chunk-000/file-000.parquet", _table_bytes(sub)) + return K, total_frames, fidx + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--repo-cache", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--target-gb", type=float, default=1.0) + ap.add_argument("--mode", choices=["stratified", "prefix"], default="stratified") + ap.add_argument("--episodes-per-task", type=int, default=0, + help="stratified: cap episodes taken per task (0 = no cap)") + args = ap.parse_args() + + repo_cache = os.path.abspath(args.repo_cache) + repo_name = os.path.basename(repo_cache.rstrip("/")) + sha, snap = _snapshot_dir(repo_cache) + + info = json.load(open(os.path.join(snap, "meta", "info.json"))) + ep_tbl = pq.read_table(os.path.join(snap, "meta", "episodes", "chunk-000", "file-000.parquet")) + ep = ep_tbl.to_pydict() + n_ep_total = len(ep["episode_index"]) + assert ep["episode_index"] == list(range(n_ep_total)), "meta episodes not 0..N contiguous" + + data_dir = os.path.join(snap, "data", "chunk-000") + data_files = sorted(f for f in os.listdir(data_dir) if f.endswith(".parquet")) + sizes = [os.path.getsize(os.path.realpath(os.path.join(data_dir, f))) for f in data_files] + total_bytes = sum(sizes) + bytes_per_frame = total_bytes / info["total_frames"] + + out_repo = os.path.join(os.path.abspath(args.out), repo_name) + out_snap = os.path.join(out_repo, "snapshots", sha) + os.makedirs(os.path.join(out_repo, "blobs"), exist_ok=True) + os.makedirs(out_snap, exist_ok=True) + os.makedirs(os.path.join(out_repo, "refs"), exist_ok=True) + open(os.path.join(out_repo, "refs", "main"), "w").write(sha) + + # unchanged files reused from the original cache + reuse = ["meta/stats.json", "meta/tasks.parquet"] + for opt in (".gitattributes", "README.md"): + if os.path.lexists(os.path.join(snap, opt)): + reuse.append(opt) + + print(f"source: {repo_name} sha={sha[:12]} episodes={n_ep_total} tasks={info['total_tasks']} " + f"frames={info['total_frames']} ({total_bytes/1e9:.1f} GB, {bytes_per_frame/1e3:.0f} KB/frame)") + + if args.mode == "prefix": + selected, n_files = _select_prefix(snap, data_files, sizes, args.target_gb * 1e9) + assert selected == list(range(len(selected))), "prefix episodes not contiguous 0..N" + K = len(selected) + total_frames = int(ep["dataset_to_index"][K - 1]) + # reuse the original data-file blobs 0..n_files-1 as-is + for i in range(n_files): + reuse.append(f"data/chunk-000/{data_files[i]}") + for rel in reuse: + _reuse_blob(snap, out_repo, out_snap, rel) + _write_blob(out_repo, out_snap, "meta/episodes/chunk-000/file-000.parquet", + _table_bytes(ep_tbl.slice(0, K))) + n_data = n_files + tasks = sorted({t for row in ep["tasks"][:K] for t in row}) + else: + target_frames = int(args.target_gb * 1e9 / bytes_per_frame) + selected, n_tasks = _select_stratified(ep, target_frames, args.episodes_per_task) + for rel in reuse: + _reuse_blob(snap, out_repo, out_snap, rel) + ep2file = _build_ep_to_file(snap, data_files) + K, total_frames, n_data = _repack( + snap, out_repo, out_snap, ep_tbl, ep, selected, ep2file, info["data_files_size_in_mb"] + ) + tasks = sorted({t for e in selected for t in ep["tasks"][e]}) + + info["total_episodes"] = K + info["total_frames"] = total_frames + info["splits"] = {"train": f"0:{K}"} + _write_blob(out_repo, out_snap, "meta/info.json", (json.dumps(info, indent=4) + "\n").encode()) + + kept_bytes = sum( + os.path.getsize(os.path.realpath(p)) + for p in [os.path.join(out_snap, "data", "chunk-000", f) + for f in os.listdir(os.path.join(out_snap, "data", "chunk-000"))] + ) + print(f"mode={args.mode} -> {K} episodes, {total_frames} frames, {n_data} data files, " + f"{len(tasks)}/{info['total_tasks']} tasks, {kept_bytes/1e9:.2f} GB") + print(f"wrote subset -> {out_repo}") + + +if __name__ == "__main__": + main() diff --git a/projects/Finetuning/scripts/prestage_shared.sh b/projects/Finetuning/scripts/prestage_shared.sh new file mode 100755 index 00000000..ac6cb67c --- /dev/null +++ b/projects/Finetuning/scripts/prestage_shared.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# ORGANIZER one-time pre-stage. Run ONCE, before the workshop. Unpacks the split OneDrive bundle +# into a single READ-ONLY shared tree and rebuilds the full fine-tuned checkpoint ONCE, so every +# attendee's server can LINK to it with zero copying (no per-attendee download, no sudo at runtime). +# +# Because it needs the training Python (torch/safetensors) and writes under /opt, run it INSIDE the +# course image as root, e.g.: +# sudo mkdir -p /opt/auplc-assets/assets +# docker run --rm --user 0:0 -v /opt/auplc-assets:/opt/auplc-assets \ +# --entrypoint bash ghcr.io/amdresearch/auplc-finetuning:latest \ +# /ryzers/notebooks/scripts/prestage_shared.sh \ +# /opt/auplc-assets/mm2_workshop_assets /opt/auplc-assets/assets +# +# Result layout (world-readable): +# /hf_hub// +# /checkpoints/reference/pretrained_model/ (FULL, reconstructed model.safetensors) +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PY="${PY:-/opt/train-venv/bin/python}" +command -v "$PY" >/dev/null 2>&1 || PY="python3" + +BUNDLE="${1:?usage: prestage_shared.sh [ASSETS_DIR]}" +ASSETS_DIR="${2:-$(dirname "$BUNDLE")/assets}" +[ -d "$BUNDLE" ] || { echo "ERROR: bundle dir not found: $BUNDLE" >&2; exit 2; } + +echo "pre-staging shared read-only assets" +echo " bundle = $BUNDLE" +echo " assets dir = $ASSETS_DIR" + +# 1) Unpack the split-tar bundle into hf_hub/ + checkpoints/reference/pretrained_model (delta). +"$HERE/unpack_bundle.sh" "$BUNDLE" "$ASSETS_DIR" + +# 2) Rebuild the FULL fine-tuned checkpoint ONCE from the BF16 base + delta, in place. +REF="$ASSETS_DIR/checkpoints/reference/pretrained_model" +if [ -f "$REF/model.safetensors" ]; then + echo " reference checkpoint already reconstructed, skipping" +else + echo " rebuilding full fine-tuned checkpoint (BF16 base + delta) ..." + "$PY" "$HERE/reconstruct_reference.py" --delta "$REF" --out "$REF" --hub "$ASSETS_DIR/hf_hub" +fi + +# 3) Make the whole tree world-readable so attendee pods (non-root) can read it. +chmod -R a+rX "$ASSETS_DIR" + +echo "done. Shared assets ready at: $ASSETS_DIR" +echo "The installer mounts $(dirname "$ASSETS_DIR") read-only into every server; attendees do nothing." diff --git a/projects/Finetuning/scripts/prestage_to_pod.sh b/projects/Finetuning/scripts/prestage_to_pod.sh new file mode 100755 index 00000000..fd0f6fbf --- /dev/null +++ b/projects/Finetuning/scripts/prestage_to_pod.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# Load the offline asset bundle (base checkpoint + LIBERO dataset + fine-tuned checkpoint) from a +# persistent host location DIRECTLY into a running JupyterHub single-user pod, placing everything +# at the paths the notebooks expect: +# - HF cache -> /home/jovyan/.cache/huggingface/hub (models--*, datasets--*) +# - fine-tuned policy -> /home/jovyan/checkpoints/reference/pretrained_model (= REFERENCE_POLICY) +# +# The bundle is the split-tar folder produced for OneDrive (base/ libero/ tokenizer/ droid_dataset/ +# ft_checkpoint/). Parts are streamed straight into the pod via `kubectl exec ... tar -x`, so files +# are written AS the pod user (correct ownership) and never re-downloaded from the Hub. Idempotent +# at the item level: tar overwrites, so re-running is safe. +# +# Usage: +# ./prestage_to_pod.sh +# Env overrides: +# NAMESPACE (default: jupyterhub) NB_USER (default: student) POD (default: jupyter-$NB_USER) +# CONTAINER (default: notebook) +set -euo pipefail + +BUNDLE="${1:-${BUNDLE_DIR:-}}" +NS="${NAMESPACE:-jupyterhub}" +NB_USER="${NB_USER:-student}" +POD="${POD:-jupyter-$NB_USER}" +CONTAINER="${CONTAINER:-notebook}" + +HUB="/home/jovyan/.cache/huggingface/hub" +REF_PARENT="/home/jovyan/checkpoints/reference" + +if [ -z "$BUNDLE" ] || [ ! -d "$BUNDLE" ]; then + echo "ERROR: bundle dir not found. Usage: $0 " >&2 + exit 2 +fi + +kx() { kubectl exec -i -n "$NS" "$POD" -c "$CONTAINER" -- "$@"; } + +echo "prestaging bundle: $BUNDLE" +echo " -> pod $NS/$POD ($CONTAINER)" +kubectl get pod -n "$NS" "$POD" >/dev/null # fail fast if the pod is not running +kx bash -lc "mkdir -p '$HUB' '$REF_PARENT'" + +extract() { # (concatenated then untarred inside the pod) + local dest="$1"; shift + echo " loading $(basename "$1" | sed 's/\..*//') -> $dest" + cat "$@" | kx tar -C "$dest" -xf - +} + +# HF cache items (models + datasets) -> hub +extract "$HUB" "$BUNDLE"/base/base.tar.part-* +extract "$HUB" "$BUNDLE"/libero/libero.tar.part-* +extract "$HUB" "$BUNDLE"/tokenizer/tokenizer.tar +if ls "$BUNDLE"/droid_dataset/droid_dataset.tar.part-* >/dev/null 2>&1; then + extract "$HUB" "$BUNDLE"/droid_dataset/droid_dataset.tar.part-* +fi +# fine-tuned checkpoint -> checkpoints/reference/pretrained_model +# It ships as a small delta (LoRA adapter + trained action-expert); rebuild the full checkpoint +# inside the pod from the BF16 base we just streamed into the HF cache. +extract "$REF_PARENT" "$BUNDLE"/ft_checkpoint/ft.tar.part-* +echo " rebuilding full fine-tuned checkpoint in pod (BF16 base + delta)" +kx bash -lc '/opt/train-venv/bin/python /ryzers/notebooks/scripts/reconstruct_reference.py \ + --delta /home/jovyan/checkpoints/reference/pretrained_model \ + --out /home/jovyan/checkpoints/reference/pretrained_model \ + --hf-home /home/jovyan/.cache/huggingface' + +# LeRobot (Step-4 fine-tune) resolves datasets under $HF_LEROBOT_HOME/{repo_id}, NOT the HF hub +# cache. Link the hub-cached LIBERO dataset there inside the pod so offline training reuses the same +# blobs rather than re-downloading 33 GB. +kx bash -lc ' + HF_HOME=/home/jovyan/.cache/huggingface + LEROBOT_HOME="${HF_LEROBOT_HOME:-$HF_HOME/lerobot}" + snap="$(ls -d "$HF_HOME"/hub/datasets--allenai--MolmoAct2-LIBERO-Dataset/snapshots/*/ 2>/dev/null | head -1)" + if [ -n "$snap" ]; then + mkdir -p "$LEROBOT_HOME/allenai" + ln -sfn "${snap%/}" "$LEROBOT_HOME/allenai/MolmoAct2-LIBERO-Dataset" + echo " LeRobot dataset link: $LEROBOT_HOME/allenai/MolmoAct2-LIBERO-Dataset" + fi +' + +echo "=== verify (sizes in pod) ===" +kx bash -lc " + du -sh '$HUB'/models--allenai--MolmoAct2-DROID 2>/dev/null + du -sh '$HUB'/datasets--allenai--MolmoAct2-LIBERO-Dataset 2>/dev/null + du -sh '$HUB'/datasets--allenai--MolmoAct2-DROID-Dataset 2>/dev/null + du -sh '$REF_PARENT'/pretrained_model 2>/dev/null + test -f '$REF_PARENT'/pretrained_model/config.json && echo 'reference checkpoint: config.json present' || echo 'WARN: reference config.json missing' +" +echo "prestage complete. The notebooks will now find everything cached (no Hub download)." diff --git a/projects/Finetuning/scripts/reconstruct_reference.py b/projects/Finetuning/scripts/reconstruct_reference.py new file mode 100644 index 00000000..93f32a8f --- /dev/null +++ b/projects/Finetuning/scripts/reconstruct_reference.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# Rebuild the full fine-tuned LeRobot checkpoint (model.safetensors) from the two SPLIT pieces the +# workshop bundle ships: +# * the BF16 DROID base (HF hub cache: models--allenai--MolmoAct2-DROID) +# * the small fine-tune "delta" (delta.safetensors + base_fill.json + config/processors) +# +# The delta stores only the tensors that actually changed during fine-tuning (the LoRA adapter and +# the trained action-expert); every frozen tensor was dropped and is restored here from the BF16 +# base (they are bit-identical to the base cast to BF16, which is verified at build time). The +# result is byte-for-tensor identical to the original merged checkpoint, so the existing loaders +# (`lerobot-eval --policy.path=...` and the interactive server) work unchanged. +# +# Staging scripts call this after extracting the bundle; it is idempotent (skips if already built). +import argparse, glob, json, os, shutil, sys + + +def _find_base_snapshot(hub, repo_dirname): + repo = os.path.join(hub, repo_dirname) + snaps = sorted(glob.glob(os.path.join(repo, "snapshots", "*", ""))) + if not snaps: + sys.exit(f"reconstruct: base checkpoint not found under {repo}/snapshots/*/ " + f"(did the base bundle extract into the HF cache?)") + return snaps[-1] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--delta", required=True, help="dir with delta.safetensors + base_fill.json + config/processors") + ap.add_argument("--out", required=True, help="output pretrained_model dir the loaders read") + ap.add_argument("--hf-home", default=os.environ.get("HF_HOME", os.path.expanduser("~/.cache/huggingface"))) + ap.add_argument("--hub", default=None, help="explicit HF hub dir (holds models--*/); defaults to /hub") + args = ap.parse_args() + + hub = args.hub or os.path.join(args.hf_home, "hub") + + out_model = os.path.join(args.out, "model.safetensors") + if os.path.exists(out_model) and os.path.getsize(out_model) > 1_000_000_000: + print(f"reconstruct: {out_model} already present -> skipping") + return + + import torch + from safetensors import safe_open + from safetensors.torch import save_file + + fill = json.load(open(os.path.join(args.delta, "base_fill.json"))) + base_snap = _find_base_snapshot(hub, fill.get("base_repo", "models--allenai--MolmoAct2-DROID")) + + # index the BF16 base shards: base_key -> shard file + base_key2file = {} + for f in sorted(glob.glob(os.path.join(base_snap, "*.safetensors"))): + with safe_open(f, framework="pt") as h: + for k in h.keys(): + base_key2file[k] = f + + os.makedirs(args.out, exist_ok=True) + print(f"reconstruct: base={base_snap}") + print(f"reconstruct: delta={args.delta} -> out={out_model}") + + state = {} + delta_path = os.path.join(args.delta, "delta.safetensors") + with safe_open(delta_path, framework="pt") as h: + meta = h.metadata() or {"format": "pt"} + for k in h.keys(): + state[k] = h.get_tensor(k) + kept = len(state) + + open_handles = {} + def _get(bk): + f = base_key2file[bk] + if f not in open_handles: + open_handles[f] = safe_open(f, framework="pt") + return open_handles[f].get_tensor(bk) + + for ft_key, base_key in fill["fill"].items(): + if base_key not in base_key2file: + sys.exit(f"reconstruct: base tensor '{base_key}' missing for '{ft_key}'") + state[ft_key] = _get(base_key).to(torch.bfloat16) + + print(f"reconstruct: {kept} delta tensors + {len(fill['fill'])} from base = {len(state)} total") + os.makedirs(args.out, exist_ok=True) + save_file(state, out_model, metadata=meta) + + # copy the small companion files (config, train_config, processors, normalizers) + for f in os.listdir(args.delta): + if f in ("delta.safetensors", "base_fill.json"): + continue + src = os.path.join(args.delta, f) + dst = os.path.join(args.out, f) + if os.path.isfile(src) and os.path.abspath(src) != os.path.abspath(dst): + shutil.copy2(src, dst) + # the delta pieces are no longer needed once model.safetensors exists -> drop them so the + # checkpoint dir matches a normal LeRobot checkpoint (saves ~2.5 GB on the pod) + for f in ("delta.safetensors", "base_fill.json"): + p = os.path.join(args.out, f) + if os.path.exists(p): + os.remove(p) + print(f"reconstruct: wrote {out_model} ({os.path.getsize(out_model)/1e9:.2f} GB) and companion files") + + +if __name__ == "__main__": + main() diff --git a/projects/Finetuning/scripts/sitecustomize.py b/projects/Finetuning/scripts/sitecustomize.py new file mode 100644 index 00000000..4eb67deb --- /dev/null +++ b/projects/Finetuning/scripts/sitecustomize.py @@ -0,0 +1,4 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +from fast_to_device import install +install() diff --git a/projects/Finetuning/scripts/stage_assets.sh b/projects/Finetuning/scripts/stage_assets.sh new file mode 100755 index 00000000..fe207650 --- /dev/null +++ b/projects/Finetuning/scripts/stage_assets.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# Stage workshop assets from a local resources directory into the Hugging Face cache and the +# fine-tuned-checkpoint location, so the notebooks do NOT re-download anything from the Hub. +# +# The workshop hosts three large items on local storage (e.g. copied from OneDrive): +# - base checkpoint allenai/MolmoAct2-DROID (HF model cache) +# - dataset allenai/MolmoAct2-LIBERO-Dataset (HF dataset cache) +# - fine-tuned policy our LoRA checkpoint (REFERENCE_POLICY) +# plus small helpers (the FAST tokenizer, and optionally the DROID-Dataset used by the Step-3 +# open-loop check). This script copies whatever is present; it is idempotent (skips items that +# already exist) and preserves the HF cache symlink layout. +# +# Expected ASSETS_DIR layout: +# /hf_hub// -> copied into $HF_HOME/hub/ +# /checkpoints/reference/pretrained_model -> copied into $REFERENCE_POLICY +# +# Usage: +# ASSETS_DIR=/path/to/assets ./stage_assets.sh +# ./stage_assets.sh /path/to/assets +# Optional overrides: HF_HOME, REFERENCE_POLICY. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PY="${PY:-/opt/train-venv/bin/python}" +ASSETS_DIR="${1:-${ASSETS_DIR:-}}" +HF_HOME="${HF_HOME:-$HOME/.cache/huggingface}" +REFERENCE_POLICY="${REFERENCE_POLICY:-$HOME/checkpoints/reference/pretrained_model}" + +if [ -z "$ASSETS_DIR" ]; then + echo "ERROR: ASSETS_DIR not set (pass as \$1 or env). Nothing to stage." >&2 + exit 2 +fi +if [ ! -d "$ASSETS_DIR" ]; then + echo "ERROR: ASSETS_DIR not found: $ASSETS_DIR" >&2 + exit 2 +fi + +echo "staging assets from: $ASSETS_DIR" +echo " HF_HOME = $HF_HOME" +echo " REFERENCE_POLICY = $REFERENCE_POLICY" + +_copy() { # src dst + local src="$1" dst="$2" + [ -e "$src" ] || return 0 + if [ -d "$dst" ] && [ -n "$(ls -A "$dst" 2>/dev/null || true)" ]; then + echo " skip (exists) $dst" + return 0 + fi + mkdir -p "$(dirname "$dst")" + if command -v rsync >/dev/null 2>&1; then + rsync -a "$src/" "$dst/" + else + cp -a "$src/." "$dst/" + fi + echo " staged $dst" +} + +# 1) Hugging Face hub cache (models + datasets) -> $HF_HOME/hub +if [ -d "$ASSETS_DIR/hf_hub" ]; then + mkdir -p "$HF_HOME/hub" + for d in "$ASSETS_DIR"/hf_hub/*/; do + [ -d "$d" ] || continue + _copy "${d%/}" "$HF_HOME/hub/$(basename "$d")" + done +else + echo " (no hf_hub/ in ASSETS_DIR - skipping HF cache staging)" +fi + +# 2) Fine-tuned reference checkpoint -> $REFERENCE_POLICY +# It ships as a small "delta" (LoRA adapter + trained action-expert) plus a map of the frozen +# tensors it dropped; rebuild the full checkpoint from the BF16 base staged in step 1. +if [ -d "$ASSETS_DIR/checkpoints/reference/pretrained_model" ]; then + _copy "$ASSETS_DIR/checkpoints/reference/pretrained_model" "$REFERENCE_POLICY" + if [ ! -f "$REFERENCE_POLICY/model.safetensors" ]; then + echo " rebuilding full fine-tuned checkpoint from BF16 base + delta" + "$PY" "$HERE/reconstruct_reference.py" \ + --delta "$REFERENCE_POLICY" --out "$REFERENCE_POLICY" --hf-home "$HF_HOME" + fi +else + echo " (no checkpoints/reference/pretrained_model in ASSETS_DIR - skipping)" +fi + +# 3) LeRobot (Step-4 fine-tune) resolves datasets under $HF_LEROBOT_HOME/{repo_id}, NOT the standard +# HF hub cache. Link the hub-cached LIBERO dataset there so offline training reuses the same blobs +# instead of re-downloading 33 GB. +LEROBOT_HOME="${HF_LEROBOT_HOME:-$HF_HOME/lerobot}" +_snap="$(ls -d "$HF_HOME"/hub/datasets--allenai--MolmoAct2-LIBERO-Dataset/snapshots/*/ 2>/dev/null | head -1)" +if [ -n "$_snap" ]; then + mkdir -p "$LEROBOT_HOME/allenai" + ln -sfn "${_snap%/}" "$LEROBOT_HOME/allenai/MolmoAct2-LIBERO-Dataset" + echo " LeRobot dataset link: $LEROBOT_HOME/allenai/MolmoAct2-LIBERO-Dataset" +fi + +echo "asset staging complete." diff --git a/projects/Finetuning/scripts/unpack_bundle.sh b/projects/Finetuning/scripts/unpack_bundle.sh new file mode 100755 index 00000000..7cfa3549 --- /dev/null +++ b/projects/Finetuning/scripts/unpack_bundle.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Copyright(C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# Reassemble the split OneDrive asset bundle (base/ libero/ tokenizer/ droid_dataset/ ft_checkpoint/) +# into a single ASSETS_DIR layout that stage_assets.sh (or the notebooks' ASSETS_DIR support) expect: +# +# /hf_hub// (base is the BF16 DROID checkpoint) +# /checkpoints/reference/pretrained_model/ (fine-tune DELTA: adapter + action-expert) +# +# The reference here is only the small fine-tune delta; stage_assets.sh rebuilds the full checkpoint +# from the BF16 base + delta when it stages into the pod. Use this when you want the assets on a host +# directory (e.g. to mount into the pod). To load straight into a running pod, use prestage_to_pod.sh. +# +# Usage: +# ./unpack_bundle.sh [ASSETS_DIR] # default ASSETS_DIR=/assets +set -euo pipefail + +BUNDLE="${1:?usage: unpack_bundle.sh [ASSETS_DIR]}" +ASSETS_DIR="${2:-$BUNDLE/assets}" +[ -d "$BUNDLE" ] || { echo "ERROR: bundle dir not found: $BUNDLE" >&2; exit 2; } + +mkdir -p "$ASSETS_DIR/hf_hub" "$ASSETS_DIR/checkpoints/reference" +echo "unpacking $BUNDLE -> $ASSETS_DIR" + +cat "$BUNDLE"/base/base.tar.part-* | tar -C "$ASSETS_DIR/hf_hub" -xf - +cat "$BUNDLE"/libero/libero.tar.part-* | tar -C "$ASSETS_DIR/hf_hub" -xf - +tar -C "$ASSETS_DIR/hf_hub" -xf "$BUNDLE"/tokenizer/tokenizer.tar +if ls "$BUNDLE"/droid_dataset/droid_dataset.tar.part-* >/dev/null 2>&1; then + cat "$BUNDLE"/droid_dataset/droid_dataset.tar.part-* | tar -C "$ASSETS_DIR/hf_hub" -xf - +fi +cat "$BUNDLE"/ft_checkpoint/ft.tar.part-* | tar -C "$ASSETS_DIR/checkpoints/reference" -xf - + +echo "done -> $ASSETS_DIR" +echo "next: ASSETS_DIR=$ASSETS_DIR /projects/Finetuning/scripts/stage_assets.sh" diff --git a/projects/Finetuning/tests/test_fastwam.sh b/projects/Finetuning/tests/test_fastwam.sh new file mode 100755 index 00000000..d58130ab --- /dev/null +++ b/projects/Finetuning/tests/test_fastwam.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +set -euo pipefail + +echo "Testing FastWAM world-action model + LIBERO sim stack..." + +# The FastWAM stack lives in its OWN isolated venv (numpy 1.26.4) with its OWN LIBERO config +# path, so this test never touches the MolmoAct2 train-venv. +FW_PY="${FASTWAM_VENV:-/opt/fastwam-venv}/bin/python" +export LIBERO_CONFIG_PATH="${LIBERO_CONFIG_PATH_FASTWAM:-/opt/libero-config-fastwam}" + +"$FW_PY" - <<'PY' +import os +import sys + +import numpy as np +import torch +import mujoco +import robosuite # noqa: F401 + +from libero.libero import benchmark # noqa: F401 +from libero.libero.envs import OffScreenRenderEnv # noqa: F401 + +import sim_libero # noqa: F401 +from sim_libero.libero_env import SUITES, get_benchmark_dict, get_max_steps +from sim_libero.policy import Policy, load_policy # noqa: F401 + +import fastwam # noqa: F401 +from fastwam.runtime import create_fastwam # noqa: F401 +from fastwam.datasets.lerobot.utils.normalizer import load_dataset_stats_from_json # noqa: F401 +import experiments.libero.eval_libero_single as E # noqa: F401 + +# The runtime policy factory (POLICY_FACTORY target for the interactive server). +sys.path.insert(0, "/opt/fastwam-adapters") +import fastwam_libero_policy # noqa: E402 +assert hasattr(fastwam_libero_policy, "build_policy"), "fastwam_libero_policy.build_policy missing" + +assert torch.version.hip, f"torch is not a ROCm build: {torch.__version__}" +assert np.__version__.startswith("1.26"), f"expected numpy 1.26.x in fastwam venv, got {np.__version__}" + +benchmark_dict = get_benchmark_dict() +for suite in ("libero_object", "libero_goal", "libero_spatial", "libero_10"): + assert suite in benchmark_dict, f"suite {suite} missing from LIBERO benchmark dict" + get_max_steps(suite) + +rel = os.environ.get("FASTWAM_RELEASE_DIR", "/opt/fastwam-assets/fastwam_release") +ckpt = os.path.join(rel, "libero_uncond_2cam224.pt") +stats = os.path.join(rel, "libero_uncond_2cam224_dataset_stats.json") +diffsynth = os.environ.get("DIFFSYNTH_MODEL_BASE_PATH", "/opt/fastwam-assets/diffsynth") + +print(f"python : {sys.executable}") +print(f"torch : {torch.__version__} hip={torch.version.hip}") +print(f"numpy : {np.__version__}") +print(f"mujoco/robosuite : {mujoco.__version__} / {robosuite.__version__}") +print(f"libero suites : {', '.join(SUITES)}") +print(f"baked ckpt : {ckpt} -> {'PRESENT' if os.path.exists(ckpt) else 'absent'}") +print(f"baked stats : {stats} -> {'PRESENT' if os.path.exists(stats) else 'absent'}") +print(f"Wan2.2 base : {diffsynth} -> {'PRESENT' if os.path.isdir(diffsynth) else 'absent'}") +print("PASS: FastWAM world-action + LIBERO sim imports OK") +PY + +echo "(env-check only; run scripts/fastwam_smoke.py on a GPU for a full infer_action smoke.)" diff --git a/projects/Finetuning/tests/test_molmoact2.sh b/projects/Finetuning/tests/test_molmoact2.sh new file mode 100755 index 00000000..ed1e1303 --- /dev/null +++ b/projects/Finetuning/tests/test_molmoact2.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +set -euo pipefail + +echo "Testing MolmoAct2 inference and training stack..." + +python3 - <<'PY' +import os +import subprocess +import sys +from pathlib import Path + +import accelerate +import einops # noqa: F401 +import fastapi # noqa: F401 +import huggingface_hub +import json_numpy # noqa: F401 +import lerobot +import mujoco +import robosuite +import safetensors # noqa: F401 +import sentencepiece # noqa: F401 +import torch +import transformers +from packaging.version import Version + +from lerobot.envs.libero import _get_suite +from lerobot.policies.molmoact2.configuration_molmoact2 import MolmoAct2Config +from lerobot.policies.molmoact2.modeling_molmoact2 import MolmoAct2Policy # noqa: F401 +from libero.libero import get_assets_path, get_libero_path + +sys.path.insert(0, os.environ["DROID_SERVER_DIR"]) +from host_server_droid import NORM_TAG, Policy # noqa: E402 + +assert torch.version.hip, f"torch is not a ROCm build: {torch.__version__}" +assert lerobot.__version__ == "0.5.2", lerobot.__version__ +assert Version("5.4") <= Version(transformers.__version__) < Version("5.6") +assert Policy.__name__ == "Policy" and NORM_TAG + +cfg = MolmoAct2Config( + checkpoint_path="allenai/MolmoAct2-DROID", + train_mode_vlm="lora", + action_mode="both", + chunk_size=10, + n_action_steps=10, + setup_type="single franka robotic arm in libero", + control_mode="delta end-effector pose", + image_keys=[ + "observation.images.image", + "observation.images.wrist_image", + ], + model_dtype="bfloat16", + num_flow_timesteps=8, + gradient_checkpointing=True, + freeze_embedding=True, + normalize_gripper=False, + enable_knowledge_insulation=False, + push_to_hub=False, +) +assert cfg.train_mode_vlm == "lora" +assert cfg.action_mode == "both" + +for key in ("benchmark_root", "bddl_files", "init_states"): + assert Path(get_libero_path(key)).exists(), (key, get_libero_path(key)) +assert Path(get_assets_path()).exists(), get_assets_path() +suite = _get_suite("libero_object") +assert len(suite.tasks) == 10 + +help_result = subprocess.run( + [ + sys.executable, + "-m", + "lerobot.scripts.lerobot_train", + "--policy.type=molmoact2", + "--help", + ], + check=True, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, +) +required_flags = { + "--policy.train_mode_vlm", + "--policy.setup_type", + "--policy.control_mode", + "--policy.image_keys", + "--policy.model_dtype", + "--policy.num_flow_timesteps", + "--policy.freeze_embedding", + "--policy.normalize_gripper", + "--policy.enable_knowledge_insulation", +} +missing = sorted(flag for flag in required_flags if flag not in help_result.stdout) +assert not missing, f"MolmoAct2 training CLI is missing: {missing}" + +print(f"python : {sys.executable}") +print(f"torch : {torch.__version__}") +print(f"lerobot : {lerobot.__version__}") +print(f"transformers : {transformers.__version__}") +print(f"accelerate : {accelerate.__version__}") +print(f"huggingface_hub : {huggingface_hub.__version__}") +print(f"robosuite/mujoco : {robosuite.__version__} / {mujoco.__version__}") +print(f"libero tasks : {len(suite.tasks)}") +print("PASS: MolmoAct2 inference, training CLI, and LIBERO metadata OK") +PY diff --git a/projects/Finetuning/tests/test_torch.sh b/projects/Finetuning/tests/test_torch.sh new file mode 100755 index 00000000..a8a83c8c --- /dev/null +++ b/projects/Finetuning/tests/test_torch.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +set -e + +echo "Testing ROCm torch environment..." + +python3 - <<'PY' +import sys +import torch + +print(f"torch : {torch.__version__}") +print(f"torch.version.hip: {torch.version.hip}") +if not torch.version.hip: + print("FAIL: torch is not a ROCm build.", file=sys.stderr) + sys.exit(1) +if not torch.cuda.is_available(): + print("FAIL: no ROCm device visible. Check --device=/dev/kfd, /dev/dri.", file=sys.stderr) + sys.exit(1) + +print(f"device[0] : {torch.cuda.get_device_name(0)}") +a = torch.randn(512, 512, device="cuda") +b = torch.randn(512, 512, device="cuda") +print(f"matmul ok : sum={(a @ b).sum().item():.3f}") +print("PASS: ROCm torch env OK") +PY diff --git a/projects/LocalInference/0_overview.ipynb b/projects/LocalInference/0_overview.ipynb new file mode 100644 index 00000000..01d9959f --- /dev/null +++ b/projects/LocalInference/0_overview.ipynb @@ -0,0 +1,25 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "overview-toc", + "metadata": {}, + "source": [ + "# Local Inference Workshop\n", + "\n", + "> Placeholder overview for the workshop notebook sequence.\n", + "\n", + "## Table of contents\n", + "\n", + "1. [Local inference](./1_local_inference.ipynb)\n", + "2. [Robot agents](./2_robot_agents.ipynb)\n", + "3. [Code as policy](./3_code_as_policy.ipynb)\n", + "4. [Robot harness optimization](./4_robot_harness_optimization.ipynb)\n", + "5. [Temporary: Evolving RAI](./temp_evolving_rai.ipynb)\n" + ] + } + ], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/projects/LocalInference/1_local_inference.ipynb b/projects/LocalInference/1_local_inference.ipynb new file mode 100644 index 00000000..01247a25 --- /dev/null +++ b/projects/LocalInference/1_local_inference.ipynb @@ -0,0 +1,543 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c707d534", + "metadata": {}, + "source": [ + "# AMD Lemonade\n", + "\n", + "

\n", + " \n", + "

\n", + "\n", + "Lemonade is a local inference server that serves a variety of models, including LLMs, VLMs, and speech models (speech-to-text and text-to-speech), on your own hardware through an OpenAI-compatible API. Any tool that supports that API can point to a model running on your machine instead of the cloud. With its ROCm backend, Lemonade offloads models onto the Radeon iGPU, keeping inference fast, private, and entirely on-device." + ] + }, + { + "cell_type": "markdown", + "id": "b3920e7a", + "metadata": {}, + "source": [ + "\n", + "## Goals\n", + "\n", + "* Run an LLM locally with the Lemonade framework, served on the Radeon iGPU instead of a cloud endpoint\n", + "* Drive a chat interaction with Lemonade's OpenAI-compatible API, so that any agent workflow that can point at your local model instead\n", + "* Describe an image with a vision language model (VLM), through that same local server" + ] + }, + { + "cell_type": "markdown", + "id": "66c55d22", + "metadata": {}, + "source": [ + "## Start the Lemonade Server\n", + "\n", + "The first step is to open a terminal session.\n", + "\n", + "![](images/new_terminal.png)\n", + "\n", + "\n", + "Before we can run inference, we need to start the Lemonade server and load a model. The server provides an OpenAI-compatible API endpoint that we'll communicate with.\n", + "\n", + "The workshop models are already baked into `/opt/lemonade-cache`, outside the per-user JupyterHub home volume. \n", + "\n", + "**In a separate terminal**, start the server against the shared cache and load the model:\n", + "\n", + "```bash\n", + "lemond &\n", + "lemonade load Gemma-4-E2B-it-GGUF\n", + "```\n", + "\n", + "`lemonade load` will prepare the model for an interactive chat. The model stays loaded until you load a different one. Leave it running and confirm the server is healthy from another terminal:\n", + "\n", + "```bash\n", + "curl http://localhost:13305/api/v1/health\n", + "```\n", + "\n", + "**What this does:**\n", + "- `lemond \"$LEMONADE_CACHE\" &` starts the Lemonade server on port `13305` using the image-baked metadata cache\n", + "- `HF_HOME=\"$LEMONADE_HF_HOME\"` points the server at the image-baked GGUF weights\n", + "- `lemonade load` loads the model into memory on the iGPU (via ROCm)\n", + "- No network download or Hugging Face token is needed for the workshop models; use `lemonade pull` only for an optional model not included in the image" + ] + }, + { + "cell_type": "markdown", + "id": "2e55156a", + "metadata": {}, + "source": [ + "#### (Optional) - Explore the CLI\n", + "List every model Lemonade can serve:\n", + "```\n", + "lemonade list\n", + "```\n", + "\n", + "Talk to the loaded model straight from the terminal, without going through this notebook:\n", + "\n", + "```\n", + "lemonade chat\n", + "```\n", + "\n", + "`lemonade chat` is just another client of the server you started above. Leave the chat with Ctrl+C; the server and the loaded model stay up." + ] + }, + { + "cell_type": "markdown", + "id": "77c7ebe2", + "metadata": {}, + "source": [ + "## Where the Model Lives\n", + "The model is loaded onto the Radeon iGPU, not into a Python process inside this notebook. rocm-smi is the ROCm system management tool, and it reports the GPU's memory use and utilization, so it is the direct way to confirm that.\n", + "\n", + "Run it now, with the model loaded but idle:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f313c8c8", + "metadata": {}, + "outputs": [], + "source": [ + "!rocm-smi" + ] + }, + { + "cell_type": "markdown", + "id": "49f4af1d", + "metadata": {}, + "source": [ + "`VRAM` total used memory should be about **13%** That is Gemma-4-E2B-it-GGUF's weights sitting in GPU memory, waiting for a request. The iGPU has no dedicated memory of its own, so what rocm-smi reports as VRAM is carved out of system RAM.\n", + "\n", + "At the moment, the GPU is not doing any work, since there was no prompt or tokens requested and the loaded model only costs memory. Run rocm-smi again while a query is in flight and GPU% should climb close to 100%: the weights are loaded once and stay resident, then every generated token is work done on the GPU." + ] + }, + { + "cell_type": "markdown", + "id": "060fc07c", + "metadata": {}, + "source": [ + "## Test OpenAI API Server Directly\n", + "\n", + "Let's verify the Lemonade server is working correctly by making a direct API call. This helps us understand the request/response format and confirm the model is responding.\n", + "\n", + "The OpenAI-compatible API uses the standard chat completions format." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4499fccf", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Making lemonade is incredibly easy and refreshing! Here is a classic recipe, followed by tips for customizing your perfect batch.\n", + "\n", + "---\n", + "\n", + "## 🍋 Classic Homemade Lemonade Recipe\n", + "\n", + "This recipe focuses on balancing the tartness of the lemon with the sweetness of sugar.\n", + "\n", + "### Ingredients\n", + "\n", + "* **Lemons:** 6–8 medium lemons (you will need about 1 to 1.5 cups of fresh juice)\n", + "* **Sugar:** 1 cup granulated sugar (adjust to taste)\n", + "* **Water:** 6 cups cold water\n", + "* **Optional:** Ice cubes, garnish (lemon slices, mint)\n", + "\n", + "### Equipment\n", + "\n", + "* Citrus juicer (or a knife and bowl)\n", + "* Large pitcher\n", + "* Measuring cups and spoons\n", + "\n", + "### Instructions\n", + "\n", + "#### Step 1: Prepare the Lemon Juice\n", + "1. Wash your lemons thoroughly.\n", + "2. Cut the lemons in half and squeeze them to extract the juice.\n", + "3. Measure out the fresh lemon juice and set it aside. (If you prefer a smoother lemonade, strain the juice to remove any seeds.)\n", + "\n", + "#### Step 2: Make the Simple Syrup (Optional, but recommended)\n", + "*This step ensures the sugar dissolves completely, preventing grainy lemonade.*\n", + "1. In a small saucepan, combine 1 cup of water and 1 cup of sugar.\n", + "2. Heat over medium heat, stirring constantly, until the sugar is completely dissolved. Do not let it boil.\n", + "3. Remove from heat and let the simple syrup cool completely.\n", + "\n", + "#### Step 3: Combine and Chill\n", + "1. In your large pitcher, combine the cooled simple syrup, the fresh lemon juice, and the remaining 5 cups of cold water.\n", + "2. Stir everything together well until the sugar is fully dissolved.\n", + "3. Taste the lemonade. If it’s too tart, add a little more sugar (or simple syrup). If it’s too sweet, add a little more water.\n", + "4. Chill the lemonade in the refrigerator for at least 30 minutes before serving.\n", + "\n", + "---\n", + "\n", + "## ✨ Tips for the Perfect Lemonade\n", + "\n", + "### 1. Adjusting the Flavor Balance\n", + "The secret to great lemonade is the ratio of tartness to sweetness.\n", + "\n", + "* **For a Tart Lemonade:** Use less sugar and more lemon juice.\n", + "* **For a Sweet Lemonade:** Use more sugar and slightly less lemon juice.\n", + "* **For a Richer Flavor:** Try replacing some of the granulated sugar with **honey** or **agave nectar**.\n", + "\n", + "### 2. Flavor Boosters\n", + "Want to elevate your lemonade? Try adding one of these ingredients:\n", + "\n", + "* **Mint:** Add a handful of fresh mint leaves to the pitcher for a refreshing aroma.\n", + "* **Ginger:** Add a small knob of fresh ginger, grated, to the simple syrup mixture for a spicy kick.\n", + "* **Cucumber:** Add thin slices of cucumber to the pitcher for a cooling, spa-like flavor.\n", + "* **Sparkling:** For a sophisticated twist, top your finished lemonade with sparkling water or club soda.\n", + "\n", + "### 3. Presentation Matters\n", + "Serve your lemonade over plenty of ice. Garnish each glass with a thin slice of lemon or a sprig of mint for a beautiful presentation!\n" + ] + } + ], + "source": [ + "import requests\n", + "\n", + "LEMONADE_SERVER_URL = \"http://localhost:13305/api/v1/chat/completions\"\n", + "MODEL = \"Gemma-4-E2B-it-GGUF\"\n", + "\n", + "def ask(question: str):\n", + " \"\"\"Query the local LLM using the OpenAI chat completions format\"\"\"\n", + " payload = {\n", + " \"model\": MODEL,\n", + " \"messages\": [\n", + " {\"role\": \"user\", \"content\": question}\n", + " ],\n", + " \"temperature\": 0.2,\n", + " \"max_tokens\": 3000\n", + " }\n", + "\n", + " r = requests.post(LEMONADE_SERVER_URL, json=payload, timeout=120)\n", + " r.raise_for_status()\n", + " resp = r.json()\n", + " return resp[\"choices\"][0][\"message\"][\"content\"]\n", + "\n", + "print(ask(\"How can I make a lemonade?\"))" + ] + }, + { + "cell_type": "markdown", + "id": "3dbf4030", + "metadata": {}, + "source": [ + "## Reading the Full Response\n", + "`ask()` finishes with `resp[\"choices\"][0][\"message\"][\"content\"]`, which is a long way to reach for a single string. That path is the shape of the OpenAI chat completions response, and the rest of the object is worth knowing, because it carries everything the server can tell you about a request other than the answer itself.\n", + "\n", + "The cell below makes the same call again, times it, and prints the whole envelope with the answer text elided so the structure fits on screen:\n", + "\n", + "* `id` uniquely identifies this completion, which is what you quote when tracing a single request through the server logs\n", + "* `object` is the response type, `chat.completion`\n", + "* `created` is the Unix timestamp of the completion\n", + "* `model` is the model that actually served the request, useful when the name you sent was an alias\n", + "* `choices` is a list, because one prompt can produce several alternative completions (the `n` field in the request). We never ask for more than one, which is why every call in this notebook indexes `[0]`. Each choice holds a `message`, the same `role` and `content` pair we send in, and a `finish_reason`: `stop` means the model ended on its own, `length` means it ran into our `max_tokens` and was cut off mid-sentence\n", + "* `usage` is the token accounting: `prompt_tokens` going in, `completion_tokens` coming out, and their `total_tokens`. Dividing `completion_tokens` by the wall-clock time gives `tokens/second`, the usual measure of how fast a model runs on a given piece of hardware" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f0d98895", + "metadata": {}, + "outputs": [], + "source": [ + "import copy\n", + "import json\n", + "import time\n", + "\n", + "payload = {\n", + " \"model\": MODEL,\n", + " \"messages\": [\n", + " {\"role\": \"user\", \"content\": \"How can I make a lemonade?\"}\n", + " ],\n", + " \"temperature\": 0.2,\n", + " \"max_tokens\": 3000\n", + "}\n", + "\n", + "start = time.perf_counter()\n", + "r = requests.post(LEMONADE_SERVER_URL, json=payload, timeout=120)\n", + "r.raise_for_status()\n", + "elapsed = time.perf_counter() - start\n", + "resp = r.json()\n", + "\n", + "# The same object, with the answer replaced by its length so the structure is readable\n", + "envelope = copy.deepcopy(resp)\n", + "for choice in envelope[\"choices\"]:\n", + " content = choice[\"message\"][\"content\"]\n", + " choice[\"message\"][\"content\"] = f\"<{len(content)} characters elided>\"\n", + "\n", + "print(json.dumps(envelope, indent=2))\n", + "\n", + "usage = resp[\"usage\"]\n", + "print(f\"\\n{usage['completion_tokens']} tokens in {elapsed:.2f} s \"\n", + " f\"= {usage['completion_tokens'] / elapsed:.1f} tokens/second\")" + ] + }, + { + "cell_type": "markdown", + "id": "862b426c", + "metadata": {}, + "source": [ + "### Prompting with an Image (VLM)\n", + "\n", + "Lemonade doesn't just serve text LLMs, Gemma-4-E2B-it-GGUF itself has a vision layer, so the same OpenAI-compatible endpoint and the model we already loaded can accept image (base64) and text queries together, with no separate model needed.\n", + "\n", + "The OpenAI-compatible API accepts images as base64-encoded data URLs inside the message content, alongside the text." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "9610eaf9", + "metadata": {}, + "outputs": [], + "source": [ + "import base64\n", + "from pathlib import Path\n", + "import cv2\n", + "import matplotlib.pyplot as plt\n", + "\n", + "def b64_image(image_path: str) -> str:\n", + " \"\"\"Convert image to base64 string\"\"\"\n", + " data = Path(image_path).read_bytes()\n", + " return base64.b64encode(data).decode(\"utf-8\")\n", + "\n", + "def ask_with_image(image_path: str, question: str):\n", + " \"\"\"Query the local VLM with an image and question using the OpenAI format\"\"\"\n", + " payload = {\n", + " \"model\": MODEL,\n", + " \"messages\": [\n", + " {\n", + " \"role\": \"user\",\n", + " \"content\": [\n", + " {\"type\": \"text\", \"text\": question},\n", + " {\"type\": \"image_url\", \"image_url\": {\"url\": f\"data:image/jpeg;base64,{b64_image(image_path)}\"}}\n", + " ]\n", + " }\n", + " ],\n", + " \"temperature\": 0.2,\n", + " \"max_tokens\": 3000\n", + " }\n", + "\n", + " r = requests.post(LEMONADE_SERVER_URL, json=payload, timeout=120)\n", + " r.raise_for_status()\n", + " resp = r.json()\n", + "\n", + " # Display image alongside the model's answer\n", + " img = cv2.imread(image_path)\n", + " fig, ax = plt.subplots(figsize=(4, 4))\n", + " ax.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))\n", + " ax.set_title(resp[\"choices\"][0][\"message\"][\"content\"])\n", + " ax.axis(\"off\")\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "12cb291b", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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P9927dpl+/bts8OHD2+ed84559gJJ5xgu3fvtuc85zn25je/2d73vvdZjNEe9rCHGWBPetKT7IILLrCXv/zl9tCHPtTe9773HXMf7nWve9mZZ55pr3jFK+yVr3yl7dq1y0455RTrum7zOp///OdtZWXF7nrXu9orXvEKu+CCC+zBD36wicgxz+fKK6+03bt32/bt2+0lL3mJvepVr7I73OEO9t3f/d0G2KWXXvpN79FHP/pR897bHe94R3vlK1+5+bu3b99+zGfn9/Je97qX/eiP/qi94Q1vsJ/+6Z82wJ7//Od/02uYmT3nOc+xRz/60fayl73M3vzmN9vTn/50c87Z4x//+G/52be+9a2bz/Hss8+23//937ef//mfN1W1Bz/4wZZz3jz3tNNOszPPPNO2b99uL3jBC+xNb3rT5jy5733va0996lPtNa95jb3uda+zRzziEQbYBRdccMz1buoaWltbs7vd7W7mnLNnPOMZ9sY3vtF+8zd/0+573/vaP/zDP5jZtXP1Xve6l9373ve217zmNfaSl7zExuOxnXXWWTf5t1/3WdzU8d2YpJTsAQ94gDnn7NnPfrZdcMEF9vCHP9zucY97GGBvfetbN88977zzrK5rO+OMM+y8886zN73pTfaOd7zDzMx++qd/2rz39oxnPMPe9KY32a/+6q/awsKC3fe+9z1mHr/jHe8wEbFHPepR9rrXvc5e8YpX2L59+2zbtm3H/KabupfcmLzzne+0s88+2+q6tne+8532zne+0y655JLN7wXs8Y9/vL3+9a+3pzzlKQbYueeee8x3nHPOOXbOOedsvp6v1evej9/5nd+xs88+237jN37D3vKWt9hzn/tcG41GdtZZZx0zB//+7//e6rq2ffv22W//9m/bS1/6UjvppJM27/F15abuO7dUbrYC+ru/+zsD7KMf/aiZmeWc7ZRTTrHnPve5N+nzp512mgH2N3/zN5vH9u/fb3Vd2y//8i9vHvuP//E/GmCf+MQnNo+tra3Z7W9/e9u3b5+llMzs2gV0+umn22QyOeZa84f7G7/xG8ccny+2ucwf5mg0sssvv3zz+Kc//WkD7Bd/8Rc3j307FNAP//APH3P8537u5wywz33uc2Zm9pWvfMWcc/bSl770mPP+6Z/+ybz3xxw/55xzDLA3velNx5z73/7bfzPAXv3qV99gHPPJ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9L7e2LO9sc9+o3O/+MUvsmvXrmO8n1sq//RP/8SXvvQl3v72t/OUpzxl8/hHP/rRm/U9F1100WZoGmB9fZ0rr7ySRz/60d/ysx/4wAdo25b3v//9x3gP/5owxxlnnHEDFOd3upx22ml8/OMfZzKZHOMF3RRE3lzOOOMM/vIv/5IHPvCB33QvOuOMMwDYs2fPt5zH3w457bTTyDlz0UUXbUZ2AK6++mqOHDlys/a4w4cP81d/9Vecf/75vOhFL9o8fv21t2fPHpqmudH7ef1jt8a+863kJueAptMp73nPe/jBH/xBHv/4x9/g79nPfjZra2s3CsO9JfLoRz+az3zmM3zqU5/aPLaxscFb3vIW9u3bx13vetdb5TrXlfe9732buQuAz3zmM3z605++QWz01pbrK/TXve51AJvX/dEf/VGcc5x//vk3sCDN7EZhlteXxz3ucRw4cIALLrjgBu/dXKt0z549POQhD+HNb34zV1555Q3ev+aaazb//9GPfjT/+3//bz7zmc8c8/5Nge6feOKJ3POe9+Ttb387R44c2Tz++c9/no985CM3aWO/KTK37q57H8yM3/u937tZ3/OWt7yFvu83X7/xjW8kxniT5s+NjeHo0aO89a1vvVljuK487nGP43Of+xzvfe97b/Dere2J3FryyEc+kr7v+YM/+IPNYznnGzV6v5E84QlPIKXEb/7mb97gvRjj5lx65CMfyfLyMi972cuOeW5zue48/nbIfP6+9rWvPeb4q1/9agAe85jH3OTvurH5c2Pf7Zzj+7//+3nf+97H17/+9c3jF198MR/60IeOOffm7DsHDhzgi1/84s2uObzJHtD73/9+1tbW+OEf/uEbff/+97//ZlHqE5/4xJs1iBuTF7zgBfzxH/8xP/ADP8Av/MIvsGPHDt7+9rdz6aWX8u53v/sGbvetIWeeeSYPetCDeNaznkXbtrz2ta9l586dPP/5z7/Vr3VdufTSS/nhH/5hHvWoR/GpT32KP/zDP+QnfuInuMc97gEUS+S3fuu3+E//6T/xla98hXPPPZelpSUuvfRS3vve9/IzP/MzPO95z/um13jKU57CO97xDn7pl36Jz3zmM5x99tlsbGzwl3/5l/zcz/0cP/IjP3Kzxvz617+eBz3oQdz97nfnGc94BqeffjpXX301n/rUp7j88sv53Oc+B8Dzn/983vnOd/KoRz2K5z73uSwsLPCWt7yF0047jX/8x3/8ltd51atexQ/8wA/wgAc8gKc//elMp1Ne97rXsbKywkte8pKbNeZvJHe+850544wzeN7znscVV1zB8vIy7373u292bL/rOh72sIfxhCc8gX/5l3/hDW94Aw960IO+4Zq5rjziEY+gqip+6Id+iGc+85msr6/zB3/wB+zZs+dGlfxNkV/5lV/hT//0T/mxH/sxnva0p3Hve9+bQ4cO8f73v583velNm/PrO0nOPfdczjrrLH75l3+Ziy++mDvf+c68//3v59ChQ8BNC1edc845PPOZz+TlL385n/3sZ3nEIx5BCIGLLrqIP/mTP+H3fu/3ePzjH8/y8jJvfOMbefKTn8z3fM/38KQnPYndu3fz1a9+lT//8z/ngQ984I0abLeW3OMe9+C8887jLW95C0eOHOGcc87hM5/5DG9/+9s599xzj/Gmv5UsLy/z4Ac/mFe+8pX0fc/JJ5/MRz7ykWNy9XN5yUtewkc+8hEe+MAH8qxnPYuUEhdccAF3u9vd+OxnP7t53s3Zdy644ALOP/98Pv7xjx/DWfet5CYroAsvvJCmaXj4wx9+o++rKo95zGO48MILOXjwIDt37rzJg7gx2bt3L5/85Cf51V/9VV73utcxm8347u/+bj7wgQ/cLMvg5shTnvIUVJXXvva17N+/n7POOosLLriAE0888dtyvbn89//+33nRi17EC17wArz3PPvZz+ZVr3rVMee84AUv4I53vCOvec1rOP/88wE49dRTecQjHnGTNjjnHB/84Ad56Utfyh/90R/x7ne/m507d24qkZsrd73rXfm7v/s7zj//fN72trdx8OBB9uzZw73uda9jQgAnnngiH//4x3nOc57Db//2b7Nz505+9md/lpNOOomnP/3p3/I63//938+HP/xhXvziF/OiF72IEALnnHMOr3jFK75hMv/mSgiBD3zgA/zCL/wCL3/5y2mahsc+9rE8+9nPvlmb9AUXXMCFF17Ii170Ivq+58d//Mf5/d///Zu0ad7pTnfiT//0T/m1X/s1nve853HCCSfwrGc9i927d/O0pz3tFv2uxcVFPvGJT/DiF7+Y9773vbz97W9nz549POxhD7tB4v47RZxz/Pmf/znPfe5zefvb346q8tjHPpYXv/jFPPCBD7zJLCRvetObuPe9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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ask_with_image(\"images/toucan.jpg\", \"What's in this image? Be concise.\")" + ] + }, + { + "cell_type": "markdown", + "id": "adbbc020", + "metadata": {}, + "source": [ + "### (Optional) Speech: Text-to-Speech and Transcription (Audio)\n", + "\n", + "Audio isn't attached to a chat message the way images are — Lemonade exposes it through two dedicated endpoints rather than the chat completions endpoint:\n", + "- `POST /api/v1/audio/speech` — text-to-speech (TTS), JSON body in, raw audio bytes out\n", + "- `POST /api/v1/audio/transcriptions` — speech-to-text (STT), multipart/form-data upload, WAV only\n", + "\n", + "**In a separate terminal**, pre-pull the models these endpoints use so that the first call doesn't stall on a cold-start download:\n", + "\n", + "```bash\n", + "lemonade pull kokoro-v1\n", + "lemonade pull Whisper-Base\n", + "```\n", + "\n", + "Let's do a round trip: synthesize a short WAV clip with the TTS endpoint, then feed it straight back into the transcription endpoint to get the text back." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "9ab2ee5c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Lemonade also supports speech-to-text and text-to-speech\n", + " models that can run entirely offline on your own AMD Ryzen\n", + " AI GPU.\n", + "\n" + ] + } + ], + "source": [ + "import io\n", + "\n", + "LEMONADE_SPEECH_URL = \"http://localhost:13305/api/v1/audio/speech\"\n", + "LEMONADE_TRANSCRIBE_URL = \"http://localhost:13305/api/v1/audio/transcriptions\"\n", + "\n", + "# Text-to-speech: synthesize a WAV clip\n", + "tts_payload = {\n", + " \"model\": \"kokoro-v1\",\n", + " \"input\": \"Lemonade also supports speech-to-text and text-to-speech models that can run entirely offline on your own AMD Ryzen AI GPU.\",\n", + " \"response_format\": \"wav\"\n", + "}\n", + "tts_resp = requests.post(LEMONADE_SPEECH_URL, json=tts_payload, timeout=120)\n", + "tts_resp.raise_for_status()\n", + "audio_bytes = tts_resp.content\n", + "\n", + "from IPython.display import Audio, display\n", + "\n", + "display(Audio(data=audio_bytes))\n", + "\n", + "# Speech-to-text: transcribe the WAV clip we just generated\n", + "files = {\"file\": (\"speech.wav\", io.BytesIO(audio_bytes), \"audio/wav\")}\n", + "data = {\"model\": \"Whisper-Base\"}\n", + "stt_resp = requests.post(LEMONADE_TRANSCRIBE_URL, files=files, data=data, timeout=120)\n", + "stt_resp.raise_for_status()\n", + "\n", + "print(stt_resp.json()[\"text\"])" + ] + }, + { + "cell_type": "markdown", + "id": "88161327", + "metadata": {}, + "source": [ + "## Hugging Face Compatibility\n", + "\n", + "(At home) Beyond all the models already available on the Lemonade website, https://lemonade-server.ai/models.html, you may find even more Lemonade-compatible models on Hugging Face. Try downloading and running a few from https://huggingface.co/models, be sure to select the GGUF format in the *Libraries* search box.\n", + "\n", + "

\n", + " \n", + " \n", + "

\n", + "\n", + "Hugging Face also gives you more flexibility, offering multiple quantization levels for the same model (e.g. `Q4_K_M`, `Q8_0`) that trade off speed, memory, and response quality. A higher quantization level can give noticeably better results for only a modest increase in memory usage.\n", + "\n", + "

\n", + "\n", + "

\n", + "\n", + "To pull a model with a specific quantization, add an optional `:QUANT` suffix (e.g. `unsloth/Qwen3-8B-GGUF:Q4_K_M`):\n", + "\n", + "```bash\n", + "lemonade pull unsloth/Qwen3-8B-GGUF:Q4_K_M\n", + "lemonade run unsloth/Qwen3-8B-GGUF:Q4_K_M\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "2640941f", + "metadata": {}, + "source": [ + "## Key Takeaways\n", + "\n", + "Now you know:\n", + "- How to start the Lemonade server and load a model with `lemond`, `lemonade pull`, and `lemonade run`\n", + "- How to call Lemonade's OpenAI-compatible API directly for text, image (VLM), and audio (TTS/STT) queries\n", + "- How to swap models with minor code changes while the server is running\n", + "- How to pull additional GGUF models at different quantization levels\n", + "\n", + "## What to Try Next\n", + "\n", + "- Swap in a different chat or vision model and compare response quality and speed\n", + "- Test the LLM in one of your own local projects and analyze the agentic automation\n", + "- Adjust `temperature` (0.2 → 1.0) to see how much the answers vary, and raise or lower `max_tokens` to see where responses get cut off\n", + "- Prompt the VLM with your own image\n", + "- Try a different TTS voice, or a larger Whisper model for more accurate transcription\n", + "- Pull a model at a different quantization level and compare memory usage and response quality" + ] + }, + { + "cell_type": "markdown", + "id": "4938ed83", + "metadata": {}, + "source": [ + "## References\n", + "\n", + "* [Lemonade](https://lemonade-server.ai/)\n", + "* [Hugging Face Models](https://huggingface.co/models)" + ] + }, + { + "cell_type": "markdown", + "id": "2706aadf", + "metadata": {}, + "source": [ + "**Continue to:** [2_robot_agents.ipynb](./2_robot_agents.ipynb)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "RAI (ROCm)", + "language": "python", + "name": "rai" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/projects/LocalInference/2_robot_agents.ipynb b/projects/LocalInference/2_robot_agents.ipynb new file mode 100644 index 00000000..0fdfc1cc --- /dev/null +++ b/projects/LocalInference/2_robot_agents.ipynb @@ -0,0 +1,596 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "71fcfc6e139f", + "metadata": {}, + "source": [ + "# Robot Agents with RAI\n", + "\n", + "

\n", + " \n", + "

\n", + "\n", + "RAI (Robot Agent Interface) is an open-source framework from Robotec.ai for building and deploying Embodied AI agents on robots. It connects a model to a robot's ROS 2 stack, letting it perceive the scene, reason on a natural-language instruction, and call the robot's tools to carry it out.\n", + "\n", + "In this notebook the robot is a Franka Panda arm in an O3DE simulation, and the model behind the agent is the same Lemonade server from the previous notebook. Reasoning therefore continues to run on your Radeon GPU, keeping everything private.\n", + "\n", + "## Goals\n", + "\n", + "* Learn the ROS 2 stack behind a real manipulation pipeline: the camera topics the robot perceives through, the perception services that turn an image into object positions, and MoveIt planning motion around them\n", + "* See how an embodied agent is wired onto that stack, with RAI binding ROS 2 tools to a model inside a LangGraph loop, and where the agent's system prompt actually comes from\n", + "* Command a simulated Franka Panda arm in natural language, then read the tool-call transcript to see how one sentence became a trajectory\n", + "* Run the whole pipeline against a locally served model, so that the reasoning never leaves your machine, keeping your data private" + ] + }, + { + "cell_type": "markdown", + "id": "bc9d7269b5f7", + "metadata": {}, + "source": [ + "## How the demo fits together\n", + "\n", + "Four pieces run side by side inside this container:\n", + "\n", + "| Piece | Role |\n", + "| --- | --- |\n", + "| **O3DE simulation** | Physics + rendering of the arm, the table and the objects; publishes camera images and accepts joint commands over ROS 2 |\n", + "| **ROS 2 stack** | MoveIt for motion planning, plus the GroundingDINO and SAM 2 perception services the agent uses to find objects |\n", + "| **RAI agent** | Turns your instruction into a sequence of tool calls (look at the camera, locate objects, move the arm) |\n", + "| **Lemonade** | Serves the model that does the reasoning |\n", + "\n", + "The agent never gets object coordinates for free: it takes a picture through the simulated camera, runs detection on it, and plans from what it sees, the same loop a real robot would run." + ] + }, + { + "cell_type": "markdown", + "id": "883c8d1a67a2", + "metadata": {}, + "source": [ + "## Start the demo\n", + "\n", + "Everything runs inside this notebook: the simulation, the ROS 2 stack, the agent and the chat. The cell below brings all of it up and renders the demo as its own output, so there is no second tab to open and no separate server to reach.\n", + "\n", + "Give it a few minutes the first time. The scene has to render on the iGPU, and MoveIt and the perception services have to come up before the arm can act on anything.\n", + "\n", + "#### (Optional) - Use a different local model\n", + "\n", + "`build_demo` takes a `model=` argument naming the Lemonade model the agent reasons with. It defaults to **Gemma-4-E2B-it-GGUF**, and `layout=` picks the starting scene.\n" + ] + }, + { + "cell_type": "markdown", + "id": "42fd2da9", + "metadata": {}, + "source": [ + "> **Note:** Notice that the detection tool cannot tell colors apart, so the agent has to ask for a category such as \"cube\" and then pick the red one out of the camera image itself." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c00f9c973d94", + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "\n", + "# Demo helpers live in the workshop scripts directory.\n", + "sys.path.insert(0, \"/ryzers/notebooks/scripts\")\n", + "\n", + "# Importing this initializes rclpy before torch gets loaded - the order matters;\n", + "# see the note at the top of scripts/notebook_demo.py.\n", + "from notebook_demo import build_demo\n", + "\n", + "demo = build_demo(layout=\"3 Red Cubes\")\n", + "demo.display()\n" + ] + }, + { + "cell_type": "markdown", + "id": "459ea3eacb67", + "metadata": {}, + "source": [ + "## Suggested Prompts\n", + "\n", + "Type into the box under the chat log to command the robot agent; each instruction is reasoned about and executed by your local Gemma model:\n", + "\n", + "- **Reason first:** \"What objects can you see on the table, and where are they?\"\n", + "\n", + "- **Relocate:** \"Move the red cube to the left side of the table.\"\n", + "\n", + "- **Stack objects:** \"Stack any cube on top of another.\"\n", + "\n", + "- **Sort by color:** \"Group the cubes by color.\"\n", + "\n", + "Watch the tool calls the agent prints as it works: it takes a camera image, calls `get_object_positions` to locate what it saw, and then issues arm movements. When the agent makes an error, such as a missed detection or a grasp the planner couldn't reach, it shows up in the logs in the chat panel.\n", + "\n", + "Use the controls on the left to load a different scene layout or to clear the conversation history before trying another instruction.\n" + ] + }, + { + "cell_type": "markdown", + "id": "fc79a52e", + "metadata": {}, + "source": [ + "## Behind the scenes\n", + "\n", + "![](images/rai_architecture.png)\n", + "\n", + "The system is built from four processes that collaborate in a single loop. O3DE publishes what the camera sees. RAI connects the VLM agent to that stream, feeding it the camera frames so that it can decide what to do next. MoveIt then turns that decision into a trajectory, and the controllers feed joint states back to the simulation. Lemonade sits off to the side as the piece serving the model that does the reasoning. All of the AI and system requests run locally on your machine.\n", + "\n", + "The subsections below take each piece in turn." + ] + }, + { + "cell_type": "markdown", + "id": "46e182b9", + "metadata": {}, + "source": [ + "### O3DE simulation\n", + "\n", + "O3DE is the simulation that creates the environment for the arm manipulation. It is the one block you could swap out for real hardware, leveraging the same system. It renders the scene on the iGPU and publishes what the camera sees on the topics `/color_image5`, `/depth_image5` and `/color_camera_info5`, along with the arm's joint states. The O3DE node also subscribes to the joint commands coming from MoveIt to follow the inverse kinematics for moving the arm. Everything else, perception and planning and the agent, is the stack a physical Panda arm would run unchanged." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "36c6893f", + "metadata": {}, + "outputs": [], + "source": [ + "!ros2 topic list --no-daemon --spin-time 3" + ] + }, + { + "cell_type": "markdown", + "id": "5001d2dc", + "metadata": {}, + "source": [ + "### Perception: GroundingDINO and SAM 2\n", + "\n", + "To find an object in the scene, two ROS 2 services are chained together: detection and segmentation. `/detection` runs GroundingDINO, an open-vocabulary detector: hand it an image and a text phrase such as \"cube\", and it returns boxes around the matching objects. GroundingDINO can identify the objects without a trained class list or any retraining, enabling the agent to name objects in plain language. `/segmentation` then runs SAM 2: given those boxes as prompts, it returns a per-pixel mask for each one. Combining the mask with the depth image and the camera intrinsics gives one 3D point per object, transformed out of the camera frame into `panda_link0`.\n", + "\n", + "That chain is what the agent's `get_object_positions` tool calls, and both models run locally on the same iGPU that serves Gemma." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63c2848a", + "metadata": {}, + "outputs": [], + "source": [ + "!ros2 service list --no-daemon --spin-time 3 | grep -E \"detection|segmentation\"" + ] + }, + { + "cell_type": "markdown", + "id": "e67753ea", + "metadata": {}, + "source": [ + "### Motion planning: MoveIt\n", + "\n", + "The agent asks for a pose, never for a trajectory. `move_group` closes that gap: it solves the inverse kinematics to turn the target pose into joint angles, searches the joint space for a collision-free path to them, and hands the resulting trajectory to the `ros2_control` controllers. The controllers will then execute it and feed these joint states back to the simulation. The `robotic_manipulation` node is the bridge that exposes all of that data back to the agent as a single tool.\n", + "\n", + "When a grasp fails because the planner could not reach it, this is the piece that reported it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d2861a8a", + "metadata": {}, + "outputs": [], + "source": [ + "!ros2 action list" + ] + }, + { + "cell_type": "markdown", + "id": "557cc826", + "metadata": {}, + "source": [ + "### The RAI agent\n", + "\n", + "The agent is the only piece in the diagram that reasons; everything above is a tool it can call. It draws itself:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b23118f9", + "metadata": {}, + "outputs": [], + "source": [ + "# The agent draws itself. It is a LangGraph state machine with three nodes:\n", + "# the model runs, and depending on whether it asked for a tool the graph either\n", + "# routes to `tools` and loops back, or stops.\n", + "demo.agent" + ] + }, + { + "cell_type": "markdown", + "id": "c0584a9f", + "metadata": {}, + "source": [ + "RAI is a LangChain application: `create_agent` builds a handful of LangChain tools, binds them to the model being served by Lemonade, and wraps them in the LangGraph loop drawn above. The model either answers or emits a tool call, the `tools` node runs it, and the result is appended to the conversation and fed back in.\n", + "\n", + "The tools themselves are generic, since `get_ros2_camera_image` reads whatever topic it is handed. The wiring to this particular robot is therefore configuration rather than code. When the agent is built, RAI remaps each tool onto the simulation's actual topic names, `camera_topic`: `/color_image5`, `depth_topic`: `/depth_image5`, `camera_info_topic`: `/color_camera_info5`, and transforms every detection out of the camera frame `RGBDCamera5` into `panda_link0`, the arm's base. That is what connects the tools the agent calls to the rest of the robotic system." + ] + }, + { + "cell_type": "markdown", + "id": "be7847e2", + "metadata": {}, + "source": [ + "### System prompt\n", + "\n", + "The agent's system prompt is not written in the code. It is generated from an embodiment file, `examples/embodiments/manipulation_embodiment.json`, which describes the robot in four parts: what it is, the rules it must obey, what it is physically capable of, and how it should behave. RAI renders those into the block below and prepends it to every conversation, so this is verbatim the context your Gemma model reads before it ever sees your instruction." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dae3b95a", + "metadata": {}, + "outputs": [], + "source": [ + "from rai_whoami.models import EmbodimentInfo\n", + "\n", + "EMBODIMENT = \"/ryzers/rai/examples/embodiments/manipulation_embodiment.json\"\n", + "\n", + "# to_langchain() builds the SystemMessage the agent prepends to every conversation.\n", + "# Its content is a list of multimodal parts, so printing it directly shows the\n", + "# dicts and escaped newlines; join the text parts instead to read it as prose.\n", + "system_message = EmbodimentInfo.from_file(EMBODIMENT).to_langchain()\n", + "\n", + "print(\"\".join(p[\"text\"] for p in system_message.content if p[\"type\"] == \"text\"))" + ] + }, + { + "cell_type": "markdown", + "id": "fa619c67", + "metadata": {}, + "source": [ + "> **Note:** Notice that cubes are declared to be 5 cm tall. The perception pipeline returns a single point for each object and reports its size as unknown, so that one line in the prompt is what lets the agent stack cubes at the right height." + ] + }, + { + "cell_type": "markdown", + "id": "71a81d899b4b", + "metadata": {}, + "source": [ + "### Peek at what the robot sees\n", + "\n", + "The agent's view of the world is the `/color_image5` camera topic. `web_video_server` serves it over HTTP, which is what the simulation panel displays, so we can grab a frame straight from this notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "63d739876d04", + "metadata": {}, + "outputs": [], + "source": [ + "import requests\n", + "import matplotlib.pyplot as plt\n", + "from PIL import Image\n", + "from io import BytesIO\n", + "\n", + "# web_video_server wants the topic unescaped, so keep it in the URL itself\n", + "SNAPSHOT_URL = \"http://localhost:8080/snapshot?topic=/color_image5&quality=80\"\n", + "\n", + "r = requests.get(SNAPSHOT_URL, timeout=30)\n", + "r.raise_for_status()\n", + "\n", + "plt.figure(figsize=(8, 4.5))\n", + "plt.imshow(Image.open(BytesIO(r.content)))\n", + "plt.axis(\"off\")\n", + "plt.title(\"Live view from the simulated camera\")\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "c1228f106bd2", + "metadata": {}, + "source": [ + "## Test OpenAI API Server Directly\n", + "\n", + "The chat panel above is only a front end. The agent behind it is a plain object you can build and call yourself, which is worth doing once because it makes the whole loop concrete.\n", + "\n", + "In the previous notebook we POSTed a message to Lemonade and got text back. Here we hand an instruction to the agent, and it makes that same call under the hood, except that the answer arrives as motion: the model decides which tools to call, and the arm moves. We therefore record the simulation camera while it works and play the result back as a video.\n", + "\n", + "Keep the demo from the previous section running, since this uses the simulation and the ROS 2 stack it started." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4d34ef09608f", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "\n", + "# rclpy.init() must happen before torch is loaded: torch's bundled C++\n", + "# runtime corrupts the heap for rcl's init, aborting the kernel with\n", + "# \"free(): invalid size\". RAI's connector skips its own init if rclpy is\n", + "# already up, and recommends initializing manually anyway.\n", + "import rclpy\n", + "\n", + "if not rclpy.ok():\n", + " rclpy.init()\n", + "\n", + "# RAI resolves its embodiment description and config.toml relative to the repo root\n", + "sys.path.insert(0, \"/ryzers/rai/examples\")\n", + "os.chdir(\"/ryzers/rai\")\n", + "os.environ.setdefault(\"OPENAI_API_KEY\", \"lemonade\") # dummy key, Lemonade ignores it\n", + "\n", + "from manipulation_common import create_agent\n", + "\n", + "# Builds the LLM client (configured by scripts/lemonade_env.sh) and the robot's\n", + "# tools: look through the camera, locate objects, move the arm, reset the arm\n", + "agent, camera_tool = create_agent(version=\"v2\")\n", + "\n", + "print(\"Agent ready\")" + ] + }, + { + "cell_type": "markdown", + "id": "5702cb0b43f4", + "metadata": {}, + "source": [ + "### Send an instruction and record the result\n", + "\n", + "`agent.invoke` blocks until the agent is done, which usually takes four to five minutes, since every step is a full round trip through the local model. Most of that time is the model thinking, so the cell prints a running log of what it is doing, a line per model turn and per tool call, plus a heartbeat from the recorder every 30 seconds. As long as those keep appearing, the run is alive and not wedged.\n", + "\n", + "While the agent works, a background thread pulls frames from the same MJPEG endpoint the simulation panel uses, and encodes them into an MP4 once the run finishes.\n", + "\n", + "Change `INSTRUCTION` to whatever you want the arm to do; keep it to objects that are actually in the current scene layout." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "94efd2f02722", + "metadata": {}, + "outputs": [], + "source": [ + "import subprocess\n", + "import threading\n", + "import time\n", + "\n", + "import requests # also imported by the snapshot cell above, which may not have run\n", + "\n", + "from IPython.display import Video, display\n", + "from langchain_core.callbacks.base import BaseCallbackHandler\n", + "from rai.messages import HumanMultimodalMessage\n", + "\n", + "RECORD_URL = (\n", + " \"http://localhost:8080/stream?topic=/color_image5&quality=70&width=960&height=540\"\n", + ")\n", + "VIDEO_PATH = \"/ryzers/notebooks/agent_run.mp4\"\n", + "\n", + "\n", + "class ProgressLog(BaseCallbackHandler):\n", + " \"\"\"Prints what the agent is doing, so a long run is visibly alive.\n", + "\n", + " A run is minutes of near-silence otherwise: one model turn can take tens of\n", + " seconds, and nothing reaches the notebook until the whole invoke returns.\n", + " \"\"\"\n", + "\n", + " def __init__(self):\n", + " self._started = {}\n", + " self._turn = 0\n", + " self._t0 = time.time()\n", + "\n", + " def _stamp(self):\n", + " return f\"[{time.time() - self._t0:5.1f}s]\"\n", + "\n", + " # Chat models fire on_chat_model_start; plain LLMs fire on_llm_start\n", + " def on_chat_model_start(self, serialized, messages, **kwargs):\n", + " self._turn += 1\n", + " print(f\"{self._stamp()} thinking (model turn {self._turn})...\", flush=True)\n", + "\n", + " on_llm_start = on_chat_model_start\n", + "\n", + " def on_tool_start(self, serialized, input_str, *, run_id=None, **kwargs):\n", + " name = (serialized or {}).get(\"name\", \"tool\")\n", + " self._started[run_id] = (name, time.time())\n", + " print(f\"{self._stamp()} -> {name}({input_str})\", flush=True)\n", + "\n", + " def on_tool_end(self, output, *, run_id=None, **kwargs):\n", + " name, started = self._started.pop(run_id, (\"tool\", time.time()))\n", + " text = str(output).replace(\"\\n\", \" \")\n", + " if len(text) > 120:\n", + " text = text[:120] + \"...\"\n", + " print(f\"{self._stamp()} <- {name} [{time.time() - started:.1f}s] {text}\", flush=True)\n", + "\n", + " def on_tool_error(self, error, *, run_id=None, **kwargs):\n", + " name, _ = self._started.pop(run_id, (\"tool\", 0))\n", + " print(f\"{self._stamp()} !! {name} failed: {error}\", flush=True)\n", + "\n", + "\n", + "class SimulationRecorder:\n", + " \"\"\"Capture the simulation camera into an MP4 while the agent works.\"\"\"\n", + "\n", + " def __init__(self, path=VIDEO_PATH, fps=4):\n", + " self.path, self.fps = path, fps\n", + " self.frames, self._stop = [], threading.Event()\n", + "\n", + " def _grab(self):\n", + " # One long-lived MJPEG connection, split into frames on the JPEG markers.\n", + " # (Polling /snapshot per frame instead would churn through subscribers\n", + " # and eventually hang web_video_server.)\n", + " try:\n", + " with requests.get(RECORD_URL, stream=True, timeout=30) as r:\n", + " buf, last = b\"\", 0.0\n", + " for chunk in r.iter_content(8192):\n", + " if self._stop.is_set():\n", + " return\n", + " buf += chunk\n", + " while True:\n", + " start, end = buf.find(b\"\\xff\\xd8\"), buf.find(b\"\\xff\\xd9\")\n", + " if start == -1 or end == -1 or end < start:\n", + " break\n", + " frame, buf = buf[start : end + 2], buf[end + 2 :]\n", + " now = time.time()\n", + " if now - last >= 1 / self.fps: # thin out to the target rate\n", + " self.frames.append(frame)\n", + " last = now\n", + " except requests.RequestException:\n", + " pass\n", + "\n", + " def _heartbeat(self):\n", + " # Own thread rather than a check inside _grab: the point is to keep\n", + " # reporting even when web_video_server stops delivering frames.\n", + " while not self._stop.wait(30):\n", + " print(\n", + " f\" ... {time.time() - self._started:.0f}s elapsed, \"\n", + " f\"{len(self.frames)} frames captured, agent still working\",\n", + " flush=True,\n", + " )\n", + "\n", + " def __enter__(self):\n", + " self._started = time.time()\n", + " self._thread = threading.Thread(target=self._grab, daemon=True)\n", + " self._thread.start()\n", + " self._pulse = threading.Thread(target=self._heartbeat, daemon=True)\n", + " self._pulse.start()\n", + " print(\"Recording the simulation. This usually takes four to five minutes.\", flush=True)\n", + " return self\n", + "\n", + " def __exit__(self, *exc):\n", + " self._stop.set()\n", + " self._thread.join(timeout=5)\n", + " self._pulse.join(timeout=1)\n", + " if not self.frames:\n", + " # The grab thread blocks in iter_content when web_video_server stops\n", + " # answering, so joining it times out and no frames ever arrive.\n", + " print(\n", + " \"No frames captured - web_video_server is not responding.\\n\"\n", + " \"Restart it from a terminal:\\n\"\n", + " \" pkill -9 -f web_video_server\\n\"\n", + " \" source /opt/ros/$ROS_DISTRO/setup.bash\\n\"\n", + " \" setsid ros2 run web_video_server web_video_server \"\n", + " \"--ros-args -p port:=8080 /tmp/wvs.log 2>&1 &\"\n", + " )\n", + " return\n", + " # Encode at the rate we actually captured, so playback runs at real speed\n", + " print(f\"Agent finished. Encoding {len(self.frames)} frames...\", flush=True)\n", + " rate = max(len(self.frames) / (time.time() - self._started), 1)\n", + " ffmpeg = subprocess.Popen(\n", + " [\"ffmpeg\", \"-y\", \"-loglevel\", \"error\",\n", + " \"-f\", \"image2pipe\", \"-vcodec\", \"mjpeg\", \"-framerate\", f\"{rate:.2f}\", \"-i\", \"-\",\n", + " \"-vcodec\", \"libx264\", \"-pix_fmt\", \"yuv420p\", \"-vf\", \"fps=15\", self.path],\n", + " stdin=subprocess.PIPE,\n", + " )\n", + " ffmpeg.communicate(b\"\".join(self.frames))\n", + " print(f\"Video ready: {self.path}\", flush=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b6695d522798", + "metadata": {}, + "outputs": [], + "source": [ + "INSTRUCTION = \"Pick up any cube and place it on top of another cube.\"\n", + "\n", + "# The agent starts from what the camera sees, exactly like the chat panel does\n", + "_, artifact = camera_tool._run()\n", + "message = HumanMultimodalMessage(content=INSTRUCTION, images=artifact.get(\"images\", []))\n", + "\n", + "start = time.time()\n", + "with SimulationRecorder():\n", + " result = agent.invoke(\n", + " {\"messages\": [message]},\n", + " config={\"recursion_limit\": 100, \"callbacks\": [ProgressLog()]},\n", + " )\n", + "\n", + "print(f\"\\n{result['messages'][-1].content}\\n\\n(took {time.time() - start:.0f}s)\")\n", + "display(Video(VIDEO_PATH, embed=True, width=640))" + ] + }, + { + "cell_type": "markdown", + "id": "2c294f812f35", + "metadata": {}, + "source": [ + "#### Optional: Agent Tools Called\n", + "\n", + "The full transcript of the run is kept in `result[\"messages\"]`: your instruction, every tool the agent decided to call, the arguments it passed, and what each tool returned. Printing it shows exactly how the agent got from a sentence to the motion you just watched." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0f804f5", + "metadata": {}, + "outputs": [], + "source": [ + "for m in result[\"messages\"]:\n", + " m.pretty_print()" + ] + }, + { + "cell_type": "markdown", + "id": "55c3138cadb2", + "metadata": {}, + "source": [ + "## Key Takeaways\n", + "\n", + "Now you know:\n", + "- How a robot agent is wired: simulation and ROS 2 tools on one side, a locally served LLM on the other\n", + "- What the manipulation pipeline is made of: GroundingDINO and SAM 2 turning a phrase into a 3D point, and MoveIt turning a pose into a trajectory\n", + "- How to point RAI at a Lemonade model with `scripts/lemonade_env.sh`, and swap that model out\n", + "- How to run a GPU-rendered simulation with no monitor, using a headless compositor plus Xwayland\n", + "- How to command a simulated manipulator in natural language, and read the agent's tool calls to see how it got there\n", + "\n", + "## What to Try Next\n", + "\n", + "- Ask for a task the arm can't reach or see, and watch how the agent recovers\n", + "- Swap in a different local model and compare how reliably each one calls the right tools\n", + "- Change the scene layout in the controls on the left and repeat the same instruction\n", + "- Explore RAI's benchmarks (`rai_bench`) to score models on tool calling and manipulation with your local model" + ] + }, + { + "cell_type": "markdown", + "id": "c6ccbaf8f07a", + "metadata": {}, + "source": [ + "## References\n", + "\n", + "* [RAI](https://github.com/RobotecAI/rai)\n", + "* [O3DE](https://o3de.org/)\n", + "* [Lemonade](https://lemonade-server.ai/)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "RAI (ROCm)", + "language": "python", + "name": "rai" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/projects/LocalInference/3_code_as_policy.ipynb b/projects/LocalInference/3_code_as_policy.ipynb new file mode 100644 index 00000000..6a7d2223 --- /dev/null +++ b/projects/LocalInference/3_code_as_policy.ipynb @@ -0,0 +1,523 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# A closer look at Code as Policies with CaP-X\n", + "\n", + "[CaP-X](https://github.com/capgym/cap-x) (*Code-as-Policies eXtended*) turns a natural-language manipulation task into an executable Python policy. A locally served model writes a short program that composes grounded perception and robot-control primitives; CaP-X runs it on a Franka Panda in Robosuite/MuJoCo and scores the resulting episode.\n", + "\n", + "## Goals\n", + "\n", + "* Contrast CaP-X's generated-program policy with the step-by-step tool loop used by RAI\n", + "* Follow a task from words, through perception and grasp planning, to joint motion and reward\n", + "* Inspect the system prompt, primitive API, generated code, and live `goto_pose` implementation\n", + "* Run the live model/service setup, one rollout with video, and a compact repeated-rollout benchmark\n", + "* Separate LLM sequencing and geometry errors from failures in grounded robot operations" + ], + "id": "6063d6b2" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## RAI vs CaP-X: tool calls or code as the policy\n", + "\n", + "Both approaches map language to robot motion, but the model has a different job.\n", + "\n", + "| | RAI | CaP-X |\n", + "| --- | --- | --- |\n", + "| Model output | one tool call at a time | one Python program up front |\n", + "| Model position | inside a step-by-step control loop | upstream of program execution |\n", + "| Runtime driver | agent graph | `env.step(program)` and CPython |\n", + "| Grounding | services exposed as tools | services exposed as Python primitives |\n", + "| Evaluation | inspect the interaction | reward and success over seeded rollouts |\n", + "| Typical failure | a bad next tool call | syntax/runtime error or incorrect geometry |\n", + "\n", + "The important boundary is the same in both systems: the language model does not implement a detector, segmenter, grasp network, IK solver, or simulator. In CaP-X it writes sequencing and geometry around an API whose primitive calls invoke those systems.\n", + "\n", + "## Words to motion\n", + "\n", + "1. A **task prompt** names the goal and exposes the available primitive signatures and docstrings.\n", + "2. The local **Lemonade LLM** generates executable Python that calls those primitives.\n", + "3. `env.step(program)` executes the policy with bound `FrankaControlApi` functions.\n", + "4. A perception call uses **OWLv2** to ground an object phrase into boxes, then **SAM2** to turn a selected box into a mask.\n", + "5. Masked depth supports a 3D point cloud and **oriented bounding box (OBB)** pose; for grasping, **Contact-GraspNet** proposes ranked 6-DoF grasp poses.\n", + "6. `goto_pose` sends the requested pose to **PyRoKi**, which solves inverse kinematics for robot joints.\n", + "7. **Robosuite/MuJoCo** executes the joint and gripper commands, after which CaP-X reports reward, completion, errors, and video.\n", + "\n", + "The generated program contains calls such as `sample_grasp_pose(...)` and `goto_pose(...)`; the perception and planning implementations remain services behind those calls, not code authored by the LLM." + ], + "id": "68b3028e" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Serve Gemma locally\n", + "\n", + "CaP-X only requires an OpenAI-compatible chat-completions endpoint. `ensure_lemonade` starts the local Lemonade daemon if needed and loads the image-cached Gemma E4B model. Setup time is recorded separately from generation and robot rollout time.\n", + "\n", + "The same model settings are used for the walkthrough and benchmark below: `Gemma-4-E4B-it-GGUF`, temperature `1.0`, and at most `4096` generated tokens." + ], + "id": "bab6fa2a" + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T09:15:40.974115Z", + "iopub.status.busy": "2026-08-23T09:15:40.973954Z", + "iopub.status.idle": "2026-08-23T09:15:45.549241Z", + "shell.execute_reply": "2026-08-23T09:15:45.548695Z" + } + }, + "source": [ + "import sys\n", + "from time import perf_counter\n", + "\n", + "sys.path.insert(0, \"/ryzers/notebooks/scripts\")\n", + "\n", + "from capx_demo import (\n", + " PRIMITIVES,\n", + " analyze_program,\n", + " benchmark_scenarios,\n", + " ensure_lemonade,\n", + " llama_metrics,\n", + " metric_delta,\n", + " quiet_output,\n", + " show_trial_grid,\n", + " show_video,\n", + " trial_introspection,\n", + ")\n", + "from capx_sweep import prepare_open_perception_configs\n", + "\n", + "MODEL = \"Gemma-4-E4B-it-GGUF\"\n", + "SERVER_URL = \"http://localhost:13305/api/v1/chat/completions\"\n", + "TEMPERATURE = 1.0\n", + "MAX_TOKENS = 4096\n", + "\n", + "OPEN_SCENARIOS = prepare_open_perception_configs({\n", + " \"cube stack\": \"env_configs/cube_stack/franka_robosuite_cube_stack.yaml\",\n", + " \"cube lift\": \"env_configs/cube_lifting/franka_robosuite_cube_lifting.yaml\",\n", + " \"spill wipe\": \"env_configs/spill_wipe/franka_robosuite_spill_wipe.yaml\",\n", + "})\n", + "CONFIG_PATH = OPEN_SCENARIOS[\"cube stack\"]\n", + "LEMONADE_SECONDS = ensure_lemonade(MODEL)" + ], + "execution_count": null, + "outputs": [], + "id": "e5910063" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## System prompt and primitive API\n", + "\n", + "Before generating anything, establish what the model can actually author. CaP-X uses a short system instruction plus a task-specific user message. The user message contains an `APIs:` section rendered from the live primitive signatures and docstrings.\n", + "\n", + "The five main primitives are:\n", + "\n", + "* `get_object_pose(description, return_bbox_extent=...)`: OWLv2 box grounding, SAM2 masking, and depth/OBB reconstruction return a 3D position, orientation, and optional full extents.\n", + "* `sample_grasp_pose(description)`: the same grounded perception path supplies Contact-GraspNet with mask and depth so it can return a candidate 6-DoF grasp.\n", + "* `goto_pose(position, quaternion_wxyz, z_approach=...)`: PyRoKi solves IK and the environment executes the resulting arm motion.\n", + "* `open_gripper()`: commands the gripper open.\n", + "* `close_gripper()`: commands the gripper closed.\n", + "\n", + "`home_pose()` is also available as a safe-motion helper. The LLM's API surface stays high level: it chooses object phrases, call order, offsets, and geometry, while the bound services do perception, grasp proposal, IK, and actuation.\n", + "\n", + "The next cell starts those services and constructs the same environment that the CaP-X launcher would create." + ], + "id": "b363aef1" + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T09:15:45.550533Z", + "iopub.status.busy": "2026-08-23T09:15:45.550412Z", + "iopub.status.idle": "2026-08-23T09:15:56.909571Z", + "shell.execute_reply": "2026-08-23T09:15:56.909246Z" + } + }, + "source": [ + "service_started = perf_counter()\n", + "with quiet_output() as service_log:\n", + " from capx.envs.configs.instantiate import instantiate\n", + " from capx.envs.launch import LaunchArgs\n", + " from capx.envs.runner import _start_api_servers\n", + " from capx.utils.launch_utils import _load_config\n", + "\n", + " args = LaunchArgs(\n", + " config_path=CONFIG_PATH,\n", + " model=MODEL,\n", + " server_url=SERVER_URL,\n", + " temperature=TEMPERATURE,\n", + " max_tokens=MAX_TOKENS,\n", + " )\n", + " env_factory, config, api_servers = _load_config(args)\n", + " servers = _start_api_servers(api_servers, 900.0)\n", + " env = instantiate(env_factory)\n", + " api = next(iter(env._apis.values()))\n", + " obs, _ = env.reset(options={\"trial\": 0}, seed=0)\n", + "\n", + "SERVICE_SECONDS = perf_counter() - service_started\n", + "print(\n", + " f\"Ready in {SERVICE_SECONDS:.1f}s: {type(env).__name__} + \"\n", + " f\"{type(api).__name__} (details: {service_log})\"\n", + ")" + ], + "execution_count": null, + "outputs": [], + "id": "185ef1d5" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Inspect the actual prompt before generation\n", + "\n", + "Now that the environment is configured, `obs[\"full_prompt\"]` contains the exact two-message conversation that will be sent to Lemonade. The system message asks for directly executable Python. The user message supplies the task, task-specific constraints, and the API documentation generated from the configured environment.\n", + "\n", + "Notice what is absent: there is no camera image, object coordinate, or joint state in the prompt. The model writes a scene-independent program; object facts enter later when that program calls grounded primitives at runtime." + ], + "id": "0e9da301" + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T09:15:56.911222Z", + "iopub.status.busy": "2026-08-23T09:15:56.911118Z", + "iopub.status.idle": "2026-08-23T09:16:11.097069Z", + "shell.execute_reply": "2026-08-23T09:16:11.096664Z" + } + }, + "source": [ + "system_message, user_message = obs[\"full_prompt\"]\n", + "\n", + "print(system_message[\"content\"])\n", + "print(user_message[\"content\"][0][\"text\"])" + ], + "execution_count": null, + "outputs": [], + "id": "e8414b9b" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Generate the Python policy\n", + "\n", + "`ModelQueryArgs` mirrors the model fields in `LaunchArgs`, and `query_model` makes one chat-completions request to the local server. CaP-X extracts the first Python block as `program`.\n", + "\n", + "Read the result as a policy, not as implementations of OWLv2, SAM2, Contact-GraspNet, or PyRoKi. The model should sequence primitive calls and perform task geometry; the bound primitives invoke those systems during execution." + ], + "id": "ef463893" + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T09:16:11.098360Z", + "iopub.status.busy": "2026-08-23T09:16:11.098254Z", + "iopub.status.idle": "2026-08-23T09:16:23.007040Z", + "shell.execute_reply": "2026-08-23T09:16:23.006805Z" + } + }, + "source": [ + "from capx.llm.client import ModelQueryArgs, query_model\n", + "from capx.utils.launch_utils import _extract_code\n", + "\n", + "query_args = ModelQueryArgs(\n", + " model=MODEL,\n", + " server_url=SERVER_URL,\n", + " temperature=TEMPERATURE,\n", + " max_tokens=MAX_TOKENS,\n", + ")\n", + "\n", + "metrics_before = llama_metrics()\n", + "query_started = perf_counter()\n", + "with quiet_output():\n", + " response = query_model(query_args, obs[\"full_prompt\"])\n", + "LLM_WALL_SECONDS = perf_counter() - query_started\n", + "LLM_METRICS = metric_delta(metrics_before, llama_metrics())\n", + "\n", + "blocks = _extract_code(response[\"content\"])\n", + "assert blocks, f\"no code in the reply - raise MAX_TOKENS?\\n{response['content'][-500:]}\"\n", + "program = blocks[0]\n", + "\n", + "print(\n", + " f\"LLM: {LLM_WALL_SECONDS:.1f}s wall · \"\n", + " f\"{LLM_METRICS.get('prompt_tokens_total', 0):.0f} prompt tokens · \"\n", + " f\"{LLM_METRICS.get('tokens_predicted_total', 0):.0f} generated tokens\"\n", + ")\n", + "print(program)" + ], + "execution_count": null, + "outputs": [], + "id": "79aa2e95" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Read the generated code, then run it\n", + "\n", + "A useful reading order is:\n", + "\n", + "1. Find `get_object_pose` and `sample_grasp_pose` calls: these are the program's grounded observations.\n", + "2. Check tuple unpacking and geometry: offsets, extents, coordinate axes, and quaternion reuse are authored by the LLM and are common sources of silent mistakes.\n", + "3. Follow each `goto_pose` waypoint and its `z_approach`; these become PyRoKi IK requests.\n", + "4. Check when the gripper opens and closes relative to those waypoints.\n", + "\n", + "`analyze_program(program)` parses the generated code without executing it. It reports primitive counts and flags for patterns that matter to manipulation, including bounding-box extents, approach offsets, suspicious nested pose indexing, and syntax errors. The next cell shows that static evidence; the cell after it executes the same string with `env.step(program)` and records a video.\n", + "\n", + "**The generated code is the policy.** A traceback indicates Python/execution failure, a grounding message points toward perception or the chosen noun phrase, and a clean low-reward run often points toward bad sequencing or geometry." + ], + "id": "df40a962" + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T09:16:23.007949Z", + "iopub.status.busy": "2026-08-23T09:16:23.007864Z", + "iopub.status.idle": "2026-08-23T09:18:44.849330Z", + "shell.execute_reply": "2026-08-23T09:18:44.849087Z" + } + }, + "source": [ + "program_analysis = analyze_program(program)\n", + "\n", + "print(\"Primitive calls\")\n", + "for primitive in PRIMITIVES:\n", + " print(f\" {primitive:<20} {program_analysis['primitive_calls'][primitive]}\")\n", + "\n", + "print(\"\\nProgram flags\")\n", + "for key in (\n", + " \"syntax_error\",\n", + " \"perception_calls\",\n", + " \"planner_calls\",\n", + " \"uses_bbox_extent\",\n", + " \"uses_approach_offset\",\n", + " \"nested_pose_indexing\",\n", + " \"line_count\",\n", + "):\n", + " print(f\" {key:<22} {program_analysis[key]}\")" + ], + "execution_count": null, + "outputs": [], + "id": "4156e34d" + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T09:18:44.850277Z", + "iopub.status.busy": "2026-08-23T09:18:44.850184Z", + "iopub.status.idle": "2026-08-23T09:18:44.852393Z", + "shell.execute_reply": "2026-08-23T09:18:44.852172Z" + } + }, + "source": [ + "env.enable_video_capture(True, clear=True)\n", + "\n", + "rollout_started = perf_counter()\n", + "with quiet_output():\n", + " _, reward, terminated, _, info = env.step(program)\n", + "ROLLOUT_SECONDS = perf_counter() - rollout_started\n", + "\n", + "print(\n", + " f\"Rollout: {ROLLOUT_SECONDS:.1f}s · reward {reward:.3f} · \"\n", + " f\"solved {info['task_completed']}\"\n", + ")\n", + "if info[\"sandbox_rc\"]:\n", + " print(\"\\nTraceback (tail):\\n\" + info[\"stderr\"][-1200:])\n", + "\n", + "show_video(env)" + ], + "execution_count": null, + "outputs": [], + "id": "547c9d6f" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Where the motion happens\n", + "\n", + "The rollout crosses the policy/service boundary at `goto_pose`. The generated program supplies a Cartesian gripper-tip pose and optional approach distance; the primitive converts that request into a robot configuration and blocks until Robosuite/MuJoCo has executed the motion. The LLM never emits joint commands or implements the IK solver." + ], + "id": "3b77e863" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### PyRoKi inside `goto_pose`\n", + "\n", + "The live source below makes the control path concrete. `goto_pose` applies the tool-center-point offset in the end-effector frame, optionally creates a standoff waypoint for `z_approach`, and calls `ik_solve_fn` for that waypoint and the final pose. The configured solver is the PyRoKi service. When available, the previous configuration is supplied to keep successive solutions connected; the resulting seven arm joints are passed to `move_to_joints_blocking` for simulation execution.\n", + "\n", + "This is also a useful failure boundary: a syntactically valid program can still request an awkward or unreachable Cartesian target. Static program flags explain what the LLM asked for, while the rollout shows whether the grounded pose and IK result produced useful motion." + ], + "id": "15a73de8" + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T09:18:45.041928Z", + "iopub.status.busy": "2026-08-23T09:18:45.041849Z", + "iopub.status.idle": "2026-08-23T09:18:45.044131Z", + "shell.execute_reply": "2026-08-23T09:18:45.043975Z" + } + }, + "source": [ + "import inspect\n", + "\n", + "goto_pose_source = inspect.getsource(type(api).goto_pose)\n", + "print(goto_pose_source)" + ], + "execution_count": null, + "outputs": [], + "id": "e534e525" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Six fresh rollouts: cube lift and spill wipe\n", + "\n", + "A single successful video is a demonstration, not a reliability estimate. The compact benchmark below asks Gemma E4B to generate a fresh policy for two scenarios that exercise different skills:\n", + "\n", + "* **cube lift**: ground one object, choose a grasp, close, and move upward;\n", + "* **spill wipe**: ground the spill/cleaning objects and coordinate a longer contact-rich sequence.\n", + "\n", + "`benchmark_scenarios` runs three seeded trials per scenario with `Gemma-4-E4B-it-GGUF`, temperature `1.0`, and `4096` maximum tokens. That is six complete model-generation-plus-simulation episodes and can take several minutes. Each trial retains its prompt, response, extracted program, diagnostics, reward, and video. No success claim is assumed in advance; rerun the cell to measure the current model and services." + ], + "id": "380163b8" + }, + { + "cell_type": "code", + "metadata": { + "execution": { + "iopub.execute_input": "2026-08-23T09:18:45.044752Z", + "iopub.status.busy": "2026-08-23T09:18:45.044690Z", + "iopub.status.idle": "2026-08-23T09:18:45.047860Z", + "shell.execute_reply": "2026-08-23T09:18:45.047710Z" + } + }, + "source": [ + "BENCHMARK_SCENARIOS = {\n", + " \"cube lift\": OPEN_SCENARIOS[\"cube lift\"],\n", + " \"spill wipe\": OPEN_SCENARIOS[\"spill wipe\"],\n", + "}\n", + "\n", + "benchmark_started = perf_counter()\n", + "benchmark_results = benchmark_scenarios(\n", + " model=\"Gemma-4-E4B-it-GGUF\",\n", + " server_url=SERVER_URL,\n", + " scenarios=BENCHMARK_SCENARIOS,\n", + " temperature=1.0,\n", + " max_tokens=4096,\n", + " trials=3,\n", + ")\n", + "BENCHMARK_SECONDS = perf_counter() - benchmark_started\n", + "print(f\"\\nSix complete trials: {BENCHMARK_SECONDS:.1f}s wall\")\n", + "\n", + "introspection_rows = []\n", + "for scenario in BENCHMARK_SCENARIOS:\n", + " scenario_trials = [\n", + " trial for trial in benchmark_results if trial[\"label\"] == scenario\n", + " ]\n", + " print(f\"\\n{scenario}: {len(scenario_trials)} rollout videos\")\n", + " show_trial_grid(scenario_trials)\n", + " for row in trial_introspection(scenario_trials):\n", + " introspection_rows.append({\"scenario\": scenario, **row})\n", + "\n", + "print(\"\\nGenerated-policy evidence\")\n", + "print(\n", + " f\"{'scenario':<12} {'seed':>4} {'outcome':<18} {'reward':>7} \"\n", + " f\"{'calls':>5} {'per':>3} {'plan':>4} {'bbox':>5} {'approach':>8} {'nested':>6}\"\n", + ")\n", + "for row in introspection_rows:\n", + " print(\n", + " f\"{row['scenario']:<12} {row['trial']:>4} {row['outcome']:<18} \"\n", + " f\"{row['reward']:>7.3f} {row['primitive_calls']:>5} \"\n", + " f\"{row['perception_calls']:>3} {row['planner_calls']:>4} \"\n", + " f\"{str(row['bbox_extent']):>5} {str(row['approach_offset']):>8} \"\n", + " f\"{str(row['nested_pose_indexing']):>6}\"\n", + " )\n", + " if row[\"syntax_error\"]:\n", + " print(f\" syntax error: {row['syntax_error']}\")" + ], + "execution_count": null, + "outputs": [], + "id": "c6b6a4b1" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Reading repeated rollouts\n", + "\n", + "Start with completion count and reward, then use the videos and `trial_introspection` rows to ask why trials differed. An **execution failure** points to invalid or failing generated Python. A clean run with **partial reward** means the policy made progress but did not complete the task. The call counts and flags reveal whether the generated program observed the scene, planned motion, used extents or approach offsets, or showed a known indexing hazard.\n", + "\n", + "Each trial samples a fresh generated program as well as a seeded layout. Variation across three attempts is therefore evidence about stochastic end-to-end reliability, not a fixed ranking from an earlier model sweep. Inspect each `row[\"program\"]` in `introspection_rows` when two videos differ and you need the exact policy behind each outcome." + ], + "id": "895b0342" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Key takeaways\n", + "\n", + "* **Code is the policy:** the LLM emits one executable program whose control flow, object phrases, waypoints, offsets, and geometry determine behavior.\n", + "* **Grounded systems provide operations:** OWLv2 finds boxes, SAM2 produces masks, depth/OBB reconstruction estimates 3D pose and size, Contact-GraspNet proposes grasps, and PyRoKi maps Cartesian requests to robot joints.\n", + "* **The LLM does sequencing and error-prone logic:** it decides which primitives to call and performs tuple unpacking, coordinate arithmetic, orientation reuse, and failure-sensitive ordering.\n", + "* **The simulator supplies evidence:** Robosuite/MuJoCo execution turns a plausible-looking program into reward, diagnostics, and video.\n", + "* **Repeated rollouts reveal stochastic reliability:** inspect success rate together with each generated program and video rather than generalizing from one run.\n", + "\n", + "## What to try next\n", + "\n", + "* Change only the natural-language task and compare the resulting primitive calls with `analyze_program`.\n", + "* Inspect `introspection_rows` to compare two policies that received different rewards on the same scenario.\n", + "* Run `benchmark_scenarios(..., oracle=True)` as a reference when separating generated-policy errors from environment or service issues.\n", + "* Point `SERVER_URL` at another OpenAI-compatible backend and keep the rest of the CaP-X pipeline unchanged.\n", + "\n", + "## References\n", + "\n", + "* [CaP-X](https://github.com/capgym/cap-x)\n", + "* [OWLv2 Large](https://huggingface.co/google/owlv2-large-patch14-ensemble) · [SAM2.1 Large](https://huggingface.co/facebook/sam2.1-hiera-large) · [Contact-GraspNet](https://github.com/NVlabs/contact_graspnet) · [PyRoKi](https://github.com/chungmin99/pyroki)\n", + "* [Robosuite](https://github.com/ARISE-Initiative/robosuite) · [MuJoCo](https://mujoco.org/)\n", + "* [Lemonade](https://lemonade-server.ai/)" + ], + "id": "6a9bbf7f" + } + ], + "metadata": { + "kernelspec": { + "display_name": "CaP-X (ROCm)", + "language": "python", + "name": "capx" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/projects/LocalInference/4_robot_harness_optimization.ipynb b/projects/LocalInference/4_robot_harness_optimization.ipynb new file mode 100644 index 00000000..186aa5d5 --- /dev/null +++ b/projects/LocalInference/4_robot_harness_optimization.ipynb @@ -0,0 +1,416 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Robotics Harness Optimization with RHO\n", + "\n", + "RHO improves robot policy **source code**, not model weights. The loop is intentionally simple:\n", + "\n", + "1. Evaluate a seed program.\n", + "2. Let a coding agent propose a source edit.\n", + "3. Keep the edit only if its training reward improves.\n", + "4. Check the accepted program on validation.\n", + "\n", + "This notebook has two short parts:\n", + "\n", + "- **Part A:** run one live cube-stack repair with Gemma E2B.\n", + "- **Part B:** inspect a recorded two-task Qwen evolution.\n", + "\n", + "Part B stops at the train and validation evidence already produced by HELIX; it adds no extra evaluation suite." + ], + "id": "34757d12" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from IPython.display import Markdown, display\n", + "\n", + "display(Markdown(r\"\"\"\n", + "## Part A — one live repair\n", + "\n", + "The seed is an authentic failed Gemma E4B cube-stack program. Gemma E2B gets one bounded attempt to edit `solver/`; the evaluator and HELIX configuration stay protected.\n", + "\n", + "We will inspect only the essentials: the seed, fixed budgets, training-gate improvement, validation check, accepted diff, and video paths.\n", + "\"\"\"))" + ], + "execution_count": null, + "outputs": [], + "id": "499c8d87" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import json\n", + "import sys\n", + "from pathlib import Path\n", + "from time import perf_counter\n", + "\n", + "import pandas as pd\n", + "from IPython.display import Markdown, Video, display\n", + "\n", + "sys.path.insert(0, \"/ryzers/notebooks/scripts\")\n", + "import rho_demo\n", + "\n", + "FAST_ROOT = Path(\"/tmp/rho_fast_notebook\")\n", + "FAST_TIMEOUT_SECONDS = 480\n", + "rho_demo.VIDEO_ROOT = FAST_ROOT / \"videos\"\n", + "\n", + "if rho_demo.MODEL != rho_demo.DEFAULT_RHO_MODEL:\n", + " raise RuntimeError(\"Part A requires Gemma E2B; unset RHO_MODEL and restart.\")\n", + "\n", + "print(\"Mutation model:\", rho_demo.DEFAULT_RHO_MODEL)\n", + "print(\"Budget: 1 generation · 1 proposal ·\", FAST_TIMEOUT_SECONDS, \"second timeout\")" + ], + "execution_count": null, + "outputs": [], + "id": "10b3ceec" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "display(Markdown(r\"\"\"\n", + "### What HELIX does here\n", + "\n", + "`seed diagnostics → one source edit → strict training gate → validation check`\n", + "\n", + "Only `solver/` is editable. A changed file is not automatically a success: the training reward must improve before the candidate is retained.\n", + "\"\"\"))" + ], + "execution_count": null, + "outputs": [], + "id": "5ac5fa4d" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "setup_started = perf_counter()\n", + "rho_demo.ensure_services(model=rho_demo.DEFAULT_RHO_MODEL)\n", + "FAST_REPO = rho_demo.prepare_workshop(FAST_ROOT / \"candidate\", generations=1)\n", + "FAST_SETUP_SECONDS = perf_counter() - setup_started\n", + "\n", + "print(f\"Setup: {FAST_SETUP_SECONDS:.1f}s\")\n", + "print(\"Seed repository:\", FAST_REPO)\n", + "print(\"Editable: solver/program.py and solver/policy.py\")\n", + "print(\"Protected evaluator:\", FAST_REPO / \"probe.py\")" + ], + "execution_count": null, + "outputs": [], + "id": "94fed4c9" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "print(\"===== failed seed =====\")\n", + "print((FAST_REPO / \"solver\" / \"program.py\").read_text())\n", + "\n", + "print(\"===== fixed HELIX settings =====\")\n", + "for line in (FAST_REPO / \"helix.toml\").read_text().splitlines():\n", + " if any(key in line for key in (\n", + " \"max_generations\", \"max_evaluations\", \"acceptance_criterion\",\n", + " \"num_parallel_proposals\", \"timeout_seconds\",\n", + " )):\n", + " print(line)" + ], + "execution_count": null, + "outputs": [], + "id": "2903c078" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "display(Markdown(r\"\"\"\n", + "### Before the mutation\n", + "\n", + "The training trial controls acceptance. The separate validation trial is a quick transfer check. Exceptions and timeouts force deployable reward to zero.\n", + "\"\"\"))" + ], + "execution_count": null, + "outputs": [], + "id": "d3a0b337" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "def result_row(label, result):\n", + " return {\n", + " \"phase\": label,\n", + " \"trial\": result.get(\"trial\"),\n", + " \"reward\": result.get(\"reward\"),\n", + " \"raw reward\": result.get(\"raw_reward\"),\n", + " \"completed\": result.get(\"task_completed\"),\n", + " \"timed out\": result.get(\"timed_out\"),\n", + " }\n", + "\n", + "\n", + "fast_baseline_started = perf_counter()\n", + "FAST_BEFORE_TRAIN = rho_demo.score_candidate(FAST_REPO, \"train\")\n", + "FAST_BEFORE_VAL = rho_demo.score_candidate(FAST_REPO, \"val\", capture=True)\n", + "FAST_BASELINE_SECONDS = perf_counter() - fast_baseline_started\n", + "\n", + "display(pd.DataFrame([\n", + " result_row(\"train\", FAST_BEFORE_TRAIN),\n", + " result_row(\"validation\", FAST_BEFORE_VAL),\n", + "]).set_index(\"phase\"))\n", + "print(FAST_BEFORE_TRAIN[\"feedback\"][-800:])" + ], + "execution_count": null, + "outputs": [], + "id": "b2472d0b" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "fast_evolution_started = perf_counter()\n", + "FAST_RUN = rho_demo.run_helix(\n", + " FAST_REPO,\n", + " generations=1,\n", + " timeout_seconds=FAST_TIMEOUT_SECONDS,\n", + ")\n", + "FAST_EVOLUTION_SECONDS = perf_counter() - fast_evolution_started\n", + "FAST_SUMMARY = rho_demo.summarize_run(FAST_REPO)\n", + "FAST_BEST = Path(FAST_SUMMARY[\"live_best\"])\n", + "FAST_AFTER_TRAIN = rho_demo.score_candidate(FAST_BEST, \"train\")\n", + "FAST_AFTER_VAL = rho_demo.score_candidate(FAST_BEST, \"val\", capture=True)\n", + "\n", + "print(\"Accepted:\", FAST_SUMMARY[\"accepted\"], \"· timed out:\", FAST_RUN.timed_out)\n", + "print(\"\\n===== training gate: mutation improvement =====\")\n", + "display(pd.DataFrame([\n", + " result_row(\"seed\", FAST_BEFORE_TRAIN),\n", + " result_row(\"mutated\", FAST_AFTER_TRAIN),\n", + "]).set_index(\"phase\"))\n", + "\n", + "print(\"===== separate validation check =====\")\n", + "display(pd.DataFrame([\n", + " result_row(\"seed\", FAST_BEFORE_VAL),\n", + " result_row(\"mutated\", FAST_AFTER_VAL),\n", + "]).set_index(\"phase\"))\n", + "\n", + "print(\"\\n===== accepted diff =====\")\n", + "print(rho_demo.source_diff(FAST_REPO, FAST_BEST) or \"No accepted source change.\")\n", + "\n", + "print(\"\\n===== validation rollouts =====\")\n", + "for label, result in ((\"Before mutation\", FAST_BEFORE_VAL), (\"After mutation\", FAST_AFTER_VAL)):\n", + " video_path = Path(result[\"video\"]) if result.get(\"video\") else None\n", + " display(Markdown(f\"**{label}**\"))\n", + " if video_path is not None and video_path.is_file():\n", + " display(Video(str(video_path), embed=True, width=420, html_attributes=\"controls loop\"))\n", + " else:\n", + " print(\"Video unavailable:\", video_path)" + ], + "execution_count": null, + "outputs": [], + "id": "3f026e77" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Part A takeaway\n", + "\n", + "The first table shows the improvement HELIX actually gated on. The second asks whether that repair transfers to validation. Keep both results visible: training improvement does not guarantee validation success.\n", + "\n", + "The disposable repository remains under `/tmp/rho_fast_notebook/` for inspection." + ], + "id": "2a655a20" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "display(Markdown(r\"\"\"\n", + "## Part B — recorded two-task evolution\n", + "\n", + "Qwen3-Coder evolved two policy files over two generations:\n", + "\n", + "- `solver/tasks/cube_stack.py`\n", + "- `solver/tasks/spill_wipe.py`\n", + "\n", + "We will show only the candidate validation scores and the selected source diff.\n", + "\"\"\"))" + ], + "execution_count": null, + "outputs": [], + "id": "9a9d8282" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### What changes in the multi-task case\n", + "\n", + "A candidate can improve stack, wipe, both, or neither. HELIX therefore keeps task-level validation scores instead of reducing everything to one opaque number. The recorded table below is enough to see which candidates helped." + ], + "id": "5acc6015" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "RECORDED_REPORT_PATH = Path(\n", + " \"/ryzers/notebooks/recorded_results/rho_multitask_report.json\"\n", + ")\n", + "if not RECORDED_REPORT_PATH.is_file():\n", + " raise FileNotFoundError(f\"Missing recorded report: {RECORDED_REPORT_PATH}\")\n", + "\n", + "LONG_REPORT = json.loads(RECORDED_REPORT_PATH.read_text())\n", + "assert LONG_REPORT[\"schema_version\"] == \"rho-multitask-helix-report/v2\"\n", + "\n", + "print(\"Mutation model:\", LONG_REPORT[\"mutation_model_loader_alias\"])\n", + "print(\"Generations:\", LONG_REPORT[\"generations\"])\n", + "print(\"Selected candidate:\", LONG_REPORT[\"selected_candidate\"])\n", + "print(\"Report:\", RECORDED_REPORT_PATH)" + ], + "execution_count": null, + "outputs": [], + "id": "42f042db" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Read the validation table\n", + "\n", + "`stack_val` and `wipe_val` are the two ordinary validation scenarios configured in HELIX. They explain candidate retention, but they are not an independent generalization test." + ], + "id": "d59b3429" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "LONG_FRONTIER = LONG_REPORT[\"frontier\"]\n", + "LONG_SELECTED = LONG_REPORT[\"selected_candidate\"]\n", + "\n", + "display(pd.DataFrame([\n", + " {\n", + " \"candidate\": candidate_id,\n", + " \"stack validation\": candidate[\"scores\"].get(\"stack_val\", 0.0),\n", + " \"wipe validation\": candidate[\"scores\"].get(\"wipe_val\", 0.0),\n", + " \"retained\": candidate[\"frontier\"],\n", + " }\n", + " for candidate_id, candidate in LONG_FRONTIER[\"candidates\"].items()\n", + "]).set_index(\"candidate\"))\n", + "\n", + "print(\"===== selected source diff =====\")\n", + "print(LONG_REPORT[\"selected_diff\"] or \"No selected source change.\")" + ], + "execution_count": null, + "outputs": [], + "id": "3af580f3" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "baseline_by_scenario = {\n", + " result[\"scenario_id\"]: result\n", + " for result in LONG_REPORT[\"baseline_validation\"]\n", + "}\n", + "selected_scores = LONG_FRONTIER[\"candidates\"][LONG_SELECTED][\"scores\"]\n", + "\n", + "comparison = []\n", + "for scenario_id, task in (\n", + " (\"stack_val\", \"cube_stack\"),\n", + " (\"wipe_val\", \"spill_wipe\"),\n", + "):\n", + " before = baseline_by_scenario[scenario_id]\n", + " comparison.append({\n", + " \"task\": task,\n", + " \"seed validation reward\": before[\"reward\"],\n", + " \"selected validation reward\": selected_scores[scenario_id],\n", + " })\n", + "\n", + "display(pd.DataFrame(comparison).set_index(\"task\"))\n", + "print(\"These are the ordinary validation scores already used by HELIX.\")" + ], + "execution_count": null, + "outputs": [], + "id": "faaf2d02" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "print(\"Part A live timing\")\n", + "print(f\" setup: {FAST_SETUP_SECONDS:7.1f}s\")\n", + "print(f\" baseline: {FAST_BASELINE_SECONDS:7.1f}s\")\n", + "print(f\" evolution: {FAST_EVOLUTION_SECONDS:7.1f}s\")\n", + "\n", + "print(\"\\nPart B recorded timing\")\n", + "for key in (\"setup_seconds\", \"baseline_seconds\", \"evolution_seconds\"):\n", + " print(f\" {key.replace('_', ' '):20s} {LONG_REPORT['timing'][key]:7.1f}s\")" + ], + "execution_count": null, + "outputs": [], + "id": "8a54abd5" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "rho_demo.stop_owned_services()\n", + "print(\"Notebook-owned model and robotics services stopped.\")" + ], + "execution_count": null, + "outputs": [], + "id": "ac6db116" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The whole idea\n", + "\n", + "RHO wraps a coding agent in an evaluator:\n", + "\n", + "**diagnostics → source mutation → strict training gate → validation evidence**\n", + "\n", + "Part A shows the loop live on one file. Part B shows the same idea when two policy files have separate validation scores. The useful evidence is the diff plus measured reward—not merely that an agent changed code." + ], + "id": "e472edf6" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## What not to claim\n", + "\n", + "The recorded Part B scores come from the same validation scenarios used by HELIX. They explain candidate selection, but they do not establish broad generalization.\n", + "\n", + "Report the exact source diff, deployable reward, completion flag, and rejected mutations. Do not substitute mock output when a live mutation fails." + ], + "id": "73ab5328" + } + ], + "metadata": { + "kernelspec": { + "display_name": "CaP-X (ROCm)", + "language": "python", + "name": "capx" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/projects/LocalInference/README.md b/projects/LocalInference/README.md new file mode 100644 index 00000000..4b5cf9e9 --- /dev/null +++ b/projects/LocalInference/README.md @@ -0,0 +1,99 @@ +# ROSCon 2026: Local Robot Inference + +Complete the notebooks in order: + +0. `0_overview.ipynb` +1. `1_local_inference.ipynb` +2. `2_robot_agents.ipynb` +3. `3_code_as_policy.ipynb` +4. `4_robot_harness_optimization.ipynb` +5. `temp_evolving_rai.ipynb` + +Notebook 4 begins with a workshop-sized, one-generation repair of an authentic +cube-stack program using Gemma E2B. It then loads measured results from a longer +two-generation HELIX/OpenCode evolution over equal cube-stack and spill-wipe +targets. Qwen3-Coder proposes repository mutations, HELIX retains per-task +winners on an instance frontier, and generation 2 may select or merge frontier +parents. The notebook stops at the recorded train/validation evidence; run +`scripts/rho_multitask_study.py` separately to repeat the longer experiment. + +## Recorded study evidence + +`recorded_results/overnight_study_summary.json` is the compact entry point for +the controlled overnight study. It links to: + +- `capx_matrix_analysis.json`: 60 same-image model rollouts, the 4-task oracle, + task metrics, latency, code signals, and failure taxonomy. +- `rho_study_analysis.json`: three single-policy mutation-agent preflights, + Qwen surface comparisons, the two-generation multi-task result, and the + bounded cube-restack depth study. +- `video_validation.json`: recording counts, report-path checks, container + validation, and ffprobe results. +- `capx_selected/provenance.json`: selected local Gemma programs and their + exact source trials. + +The raw per-trial CaP-X and RHO reports remain below `recorded_results/`. +Regenerate the compact analyses after adding new shards with: + +```bash +python scripts/capx_analyze.py \ + recorded_results/capx_primary_oracle/results.json \ + recorded_results/capx_primary_qwen/results.json \ + recorded_results/capx_local_gemma_shards/*/results.json \ + --output recorded_results/capx_matrix_analysis.json +python scripts/rho_analyze.py \ + --recorded-root recorded_results \ + --capx-analysis recorded_results/capx_matrix_analysis.json \ + --output recorded_results/rho_study_analysis.json +``` + +The temporary Evolving RAI notebook applies the same one-generation +repository-evolution pattern to a live RAI tool-calling agent. A deterministic +in-memory tabletop replaces the full O3DE benchmark so it fits the workshop: +HELIX edits both `prompt.py` and `tools.py`, the notebook displays the selected +files, and RAI reruns one held-out manipulation test. It does not replay or +claim to reproduce paper results. + +## Measured workshop runtime + +Measured on the workshop Strix Halo GPU: + +- CaP-X: 4.5 seconds for model setup, 11.4 seconds for perception/control + setup, 14.2 seconds for one LLM call, and 11.8 seconds for one rollout. +- RHO Part A: allow about 4–8 minutes for model/service setup, four simulator + evaluations, and one bounded OpenCode mutation. +- RHO Part B: the notebook loads compact pre-rendered evidence immediately. + It shows only the train/validation scores already produced by HELIX. The + longer two-generation experiment remains available as + `scripts/rho_multitask_study.py` rather than running additional evaluation + suites in the notebook. + +Environment checks: + +```bash +/ryzers/test_ros.sh +/ryzers/test_o3de.sh +/ryzers/test_rai.sh +/ryzers/test_lemonade-sdk.sh +/ryzers/test_capx.sh +/ryzers/test_rho.sh +/ryzers/test_rho_multitask.sh +/ryzers/test_rai_toy_evolution.sh +``` + +The toy RAI evolution test is static/mock by default. Inside the +LocalInference image, opt into a real RAI seed-versus-repaired agent check: + +```bash +RAI_TOY_RUN_LIVE=1 /ryzers/test_rai_toy_evolution.sh +``` + +The workshop's Gemma E2B, Gemma E4B, and Qwen3-Coder Q4_K_M GGUFs are baked +under `/opt/lemonade-cache`, outside the JupyterHub home-volume mount. Notebook +4 uses Gemma E2B for the fast live mutation and the 17.3 GB Qwen checkpoint for +the recorded multi-task evolution. SAM2.1 Large and OWLv2 Large are likewise +baked under `/opt/capx-cache`; neither runtime path needs a Hugging Face token +or a first-run model download. Their checkpoints are staged through FP16 before +an on-device FP32 conversion to avoid a multi-minute ROCm transfer while +retaining FP32 execution. Matching `*_fast.yaml` CaP-X configs retain SAM2.1 +Small and OWLv2 Base for comparisons. diff --git a/projects/LocalInference/fixtures/rho_multitask/cube_stack.py b/projects/LocalInference/fixtures/rho_multitask/cube_stack.py new file mode 100644 index 00000000..7c983949 --- /dev/null +++ b/projects/LocalInference/fixtures/rho_multitask/cube_stack.py @@ -0,0 +1,53 @@ +# Code block 0 +import numpy + +# --- 1. Get object poses and extents --- + +# Red cube data +red_pose, red_quat, red_extent = get_object_pose("red cube", return_bbox_extent=True) +# Green cube data +green_pose, _, green_extent = get_object_pose("green cube", return_bbox_extent=True) + +# --- 2. Sample grasp pose for red cube --- +red_grasp_position, red_grasp_quat = sample_grasp_pose("red cube") + +# --- 3. Approach and grasp the red cube --- +print("Approaching and grasping red cube...") +goto_pose(red_grasp_position, red_grasp_quat, z_approach=0.1) +close_gripper() + +# --- 4. Lift the red cube to a safe height --- +# Calculate lift position: original position + 0.2m in Z +lift_position = red_grasp_position.copy() +lift_position[2] += 0.2 +print("Lifting red cube to safe height...") +# Use z_approach=0.0 since we are actively moving the lifted object away from the initial grasp point +goto_pose(lift_position, red_grasp_quat, z_approach=0.0) + +# --- 5. Calculate the target placement pose on the green cube --- + +# Green cube center Z coordinate +green_center_z = green_pose[0][2] +# Half height of green cube +green_half_height = green_extent[2] / 2 +# Half height of red cube +red_half_height = red_extent[2] / 2 + +# Calculate stacking height +place_z = green_center_z + green_half_height + red_half_height + +# Target position (X, Y matches green cube center, Z is stacking height) +placement_position = numpy.array([green_pose[0][0], green_pose[0][1], place_z]) + +# --- 6. Approach and place the red cube --- +print("Moving to placement location on green cube...") +# Approach using z_approach=0.1 for controlled descent +goto_pose(placement_position, red_grasp_quat, z_approach=0.1) + +# Release the cube +print("Releasing red cube.") +open_gripper() + +# Optional: Move to a safe final pose if needed, but the task is complete. +# home_pose() +print("Task completed: Red cube stacked on green cube.") diff --git a/projects/LocalInference/fixtures/rho_multitask/provenance.json b/projects/LocalInference/fixtures/rho_multitask/provenance.json new file mode 100644 index 00000000..ad914671 --- /dev/null +++ b/projects/LocalInference/fixtures/rho_multitask/provenance.json @@ -0,0 +1,31 @@ +{ + "schema_version": "rho-multitask-fixtures/v2", + "seed_model": "Gemma-4-E4B-it-GGUF", + "policies": { + "cube_stack": { + "file": "cube_stack.py", + "source": "historical CaP-X Gemma E4B cube_stack trial 1 generated policy", + "source_policy_sha256": "1e832d66a724443636ef68cdc1d70c4357169317a44b2c65b80b42faa52dd17c", + "source_prompt": "Pick up the red cube and gently stack it on top of the green cube, then release it.", + "source_prompt_file": null, + "source_prompt_sha256": "fb532e91e6977c096e373861b1eb3b6c1e45e01e2a60a33c1d0f565d8d48bfed", + "source_git_commit": "53e9966", + "source_trial": 1, + "source_task_completed": false, + "relationship": "whitespace-normalized authentic generated policy" + }, + "spill_wipe": { + "file": "spill_wipe.py", + "source": "/results/live-20260821/full/capx/spill_wipe/attempt_03/artifacts/scenarios/Gemma-4-E4B-it-GGUF/spill_wipe/trial_01_sandboxrc_0_reward_0.980_taskcompleted_0/code.py", + "source_policy_sha256": "e31ccc884d4872ced181d6614d46b5dafc425548471206ceac32eff6a9dbac59", + "source_prompt": "Wipe the complete detected brown spill region with the Franka end effector.", + "source_prompt_file": null, + "source_prompt_sha256": null, + "prompt_note": "The source run retained the generated policy and task goal but not the fully expanded runtime API suffix.", + "source_git_commit": "7f55f31", + "source_trial": 1, + "source_task_completed": false, + "relationship": "normalized authentic generated raster policy retaining its overrun failure mode" + } + } +} diff --git a/projects/LocalInference/fixtures/rho_multitask/spill_wipe.py b/projects/LocalInference/fixtures/rho_multitask/spill_wipe.py new file mode 100644 index 00000000..86798833 --- /dev/null +++ b/projects/LocalInference/fixtures/rho_multitask/spill_wipe.py @@ -0,0 +1,28 @@ +# Code block 0 +import numpy + +# 1. Get the spill extents and center pose. +position, _, bbox_extent = get_object_pose("brown spill", return_bbox_extent=True) +length_x, length_y, _ = bbox_extent +center_x, center_y, _ = position +x_min = center_x - length_x / 2.0 +x_max = center_x + length_x / 2.0 +y_min = center_y - length_y / 2.0 +y_max = center_y + length_y / 2.0 + +# 2. Plan and execute a dense raster wipe. +wipe_z = 0.0 +wipe_quaternion = numpy.array([0.0, 0.0, 1.0, 0.0]) +step_size = 0.02 +print("Starting wiping motion...") + +x_steps = numpy.arange(x_min, x_max + step_size, step_size) +for x in x_steps: + y_steps_forward = numpy.arange(y_min, y_max + step_size, step_size) + y_steps_backward = numpy.arange(y_max, y_min - step_size, -step_size) + for y in y_steps_forward: + goto_pose(numpy.array([x, y, wipe_z]), wipe_quaternion) + for y in y_steps_backward: + goto_pose(numpy.array([x, y, wipe_z]), wipe_quaternion) + +print("Wiping complete.") diff --git a/projects/LocalInference/images/Lemonade_graphic.png b/projects/LocalInference/images/Lemonade_graphic.png new file mode 100644 index 00000000..56e71abc Binary files /dev/null and b/projects/LocalInference/images/Lemonade_graphic.png differ diff --git a/projects/LocalInference/images/different_quantizations.png b/projects/LocalInference/images/different_quantizations.png new file mode 100644 index 00000000..c344fb9e Binary files /dev/null and b/projects/LocalInference/images/different_quantizations.png differ diff --git a/projects/LocalInference/images/huggingface.png b/projects/LocalInference/images/huggingface.png new file mode 100644 index 00000000..169e348f Binary files /dev/null and b/projects/LocalInference/images/huggingface.png differ diff --git a/projects/LocalInference/images/lemonade_compatible_huggingface.png b/projects/LocalInference/images/lemonade_compatible_huggingface.png new file mode 100644 index 00000000..34557928 Binary files /dev/null and b/projects/LocalInference/images/lemonade_compatible_huggingface.png differ diff --git a/projects/LocalInference/images/new_terminal.png b/projects/LocalInference/images/new_terminal.png new file mode 100644 index 00000000..b7903fe7 Binary files /dev/null and b/projects/LocalInference/images/new_terminal.png differ diff --git a/projects/LocalInference/images/rai_architecture.png b/projects/LocalInference/images/rai_architecture.png new file mode 100644 index 00000000..54166fd6 Binary files /dev/null and b/projects/LocalInference/images/rai_architecture.png differ diff --git a/projects/LocalInference/images/rai_lemonade_arm_manipulation.png b/projects/LocalInference/images/rai_lemonade_arm_manipulation.png new file mode 100644 index 00000000..3bed95b7 Binary files /dev/null and b/projects/LocalInference/images/rai_lemonade_arm_manipulation.png differ diff --git a/projects/LocalInference/images/toucan.jpg b/projects/LocalInference/images/toucan.jpg new file mode 100644 index 00000000..31e3c529 Binary files /dev/null and b/projects/LocalInference/images/toucan.jpg differ diff --git a/projects/LocalInference/scripts/capx_analyze.py b/projects/LocalInference/scripts/capx_analyze.py new file mode 100644 index 00000000..f6c38af6 --- /dev/null +++ b/projects/LocalInference/scripts/capx_analyze.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Consolidate CaP-X sweep shards into a compact evidence report.""" + +from __future__ import annotations + +import argparse +import ast +import json +import re +import statistics +from collections import Counter +from pathlib import Path + + +PRIMITIVES = ( + "get_object_pose", + "sample_grasp_pose", + "goto_pose", + "open_gripper", + "close_gripper", + "home_pose", +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("shards", nargs="+", type=Path) + parser.add_argument("--output", type=Path, required=True) + return parser.parse_args() + + +def artifact_dir(shard: Path, rollout: dict) -> Path | None: + raw = rollout.get("dir") + if not raw: + return None + task = re.sub(r"[^0-9A-Za-z]+", "_", str(rollout["label"])) + return ( + shard.parent + / "artifacts" + / "scenarios" + / str(rollout["model"]) + / task + / Path(str(raw)).name + ) + + +def analyze_program(program: str) -> dict: + calls = Counter({name: 0 for name in PRIMITIVES}) + syntax_error = None + try: + tree = ast.parse(program) + except SyntaxError as exc: + tree = None + syntax_error = f"{exc.msg} (line {exc.lineno})" + if tree is not None: + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in calls + ): + calls[node.func.id] += 1 + compact = "".join(program.split()) + return { + "syntax_error": syntax_error, + "primitive_calls": dict(calls), + "uses_bbox_extent": "return_bbox_extent=True" in compact, + "uses_approach_offset": "z_approach=" in compact, + "nested_pose_indexing": bool( + re.search(r"[A-Za-z_][A-Za-z0-9_]*_pose\[0\]\[[012]\]", compact) + ), + "line_count": len(program.splitlines()), + } + + +def classify(rollout: dict, summary: str) -> str: + reward = float(rollout.get("reward") or 0.0) + if rollout.get("solved"): + return "completed" + if rollout.get("error"): + if "SyntaxError" in summary: + return "syntax failure" + if "IndexError" in summary: + return "indexing failure" + if "terminated episode" in summary.lower(): + return "post-termination action" + if "too many values to unpack" in summary: + return "API arity mismatch" + if "NameError" in summary: + return "missing name or import" + return "other execution failure" + if reward > 0.0: + return "partial execution" + return "no progress" + + +def enriched_rollout(shard: Path, rollout: dict) -> dict: + directory = artifact_dir(shard, rollout) + code_path = directory / "code.py" if directory else None + summary_path = directory / "summary.txt" if directory else None + program = ( + code_path.read_text(errors="replace") + if code_path is not None and code_path.is_file() + else "" + ) + summary = ( + summary_path.read_text(errors="replace") + if summary_path is not None and summary_path.is_file() + else "" + ) + return { + **{key: value for key, value in rollout.items() if key != "dir"}, + "outcome": classify(rollout, summary), + "program_analysis": analyze_program(program), + } + + +def aggregate_model_summaries(models: list[dict], rollouts: list[dict]) -> list[dict]: + grouped: dict[str, list[dict]] = {} + order: list[str] = [] + for model in models: + key = str(model["key"]) + if key not in grouped: + grouped[key] = [] + order.append(key) + grouped[key].append(model) + + aggregated = [] + dynamic_fields = { + "solved", + "rollouts", + "success_rate", + "sandbox_errors", + "mean_reward", + "mean_rollout_seconds", + "per_task", + "total_model_minutes", + } + for key in order: + source_models = grouped[key] + rows = [row for row in rollouts if row["model_key"] == key] + per_task = {} + for task in ("cube lift", "cube stack", "cube restack", "spill wipe"): + task_rows = [row for row in rows if row["label"] == task] + per_task[task] = { + "solved": sum(bool(row["solved"]) for row in task_rows), + "rollouts": len(task_rows), + "mean_reward": ( + statistics.fmean(float(row["reward"]) for row in task_rows) + if task_rows + else 0.0 + ), + } + solved = sum(bool(row["solved"]) for row in rows) + aggregated.append( + { + **{ + name: value + for name, value in source_models[0].items() + if name not in dynamic_fields + }, + "solved": solved, + "rollouts": len(rows), + "success_rate": solved / len(rows) if rows else 0.0, + "sandbox_errors": sum(bool(row["error"]) for row in rows), + "mean_reward": ( + statistics.fmean(float(row["reward"]) for row in rows) + if rows + else 0.0 + ), + "mean_rollout_seconds": ( + statistics.fmean( + float(row.get("elapsed_seconds", 0.0)) for row in rows + ) + if rows + else 0.0 + ), + "per_task": per_task, + "total_model_minutes": sum( + float(model.get("total_model_minutes", 0.0)) + for model in source_models + ), + } + ) + return aggregated + + +def main() -> int: + args = parse_args() + shard_payloads = [ + (path.expanduser().resolve(), json.loads(path.read_text())) + for path in args.shards + ] + metadata = [payload["metadata"] for _, payload in shard_payloads] + raw_summaries = [ + model + for _, payload in shard_payloads + for model in payload.get("models", []) + ] + shard_rollouts = [ + enriched_rollout(path, rollout) + for path, payload in shard_payloads + for rollout in payload.get("rollouts", []) + ] + rollouts = [] + seen_rollouts = set() + for rollout in shard_rollouts: + identity = ( + rollout.get("model_key"), + rollout.get("label"), + rollout.get("trial"), + ) + if identity not in seen_rollouts: + seen_rollouts.add(identity) + rollouts.append(rollout) + model_rollouts = [ + rollout for rollout in rollouts if rollout.get("model_key") != "oracle" + ] + oracle_rollouts = [ + rollout for rollout in rollouts if rollout.get("model_key") == "oracle" + ] + summaries = aggregate_model_summaries(raw_summaries, model_rollouts) + + taxonomy = {} + code_signals = {} + for model in summaries: + key = str(model["key"]) + rows = [row for row in model_rollouts if row["model_key"] == key] + taxonomy[key] = dict(Counter(str(row["outcome"]) for row in rows)) + code_signals[key] = { + "programs": len(rows), + "syntax_errors": sum( + bool(row["program_analysis"]["syntax_error"]) for row in rows + ), + "uses_bbox_extent": sum( + bool(row["program_analysis"]["uses_bbox_extent"]) for row in rows + ), + "uses_approach_offset": sum( + bool(row["program_analysis"]["uses_approach_offset"]) for row in rows + ), + "nested_pose_indexing": sum( + bool(row["program_analysis"]["nested_pose_indexing"]) for row in rows + ), + "median_program_lines": ( + statistics.median( + int(row["program_analysis"]["line_count"]) for row in rows + ) + if rows + else 0 + ), + } + + identities = { + (item.get("image_id"), item.get("source_revision"), item.get("perception")) + for item in metadata + } + restack_oracle_solved = any( + row["label"] == "cube restack" and row["solved"] + for row in oracle_rollouts + ) + report = { + "schema_version": "capx-overnight-analysis/v1", + "comparable_shards": len(identities) == 1, + "environment_identities": [ + { + "hostname": item.get("hostname"), + "image_id": item.get("image_id"), + "source_revision": item.get("source_revision"), + "perception": item.get("perception"), + "created_at": item.get("created_at"), + } + for item in metadata + ], + "oracle": { + "rollouts": len(oracle_rollouts), + "per_task": { + task: { + "solved": sum( + bool(row["solved"]) + for row in oracle_rollouts + if row["label"] == task + ), + "reward": max( + ( + float(row["reward"]) + for row in oracle_rollouts + if row["label"] == task + ), + default=None, + ), + } + for task in ("cube lift", "cube stack", "cube restack", "spill wipe") + }, + }, + "models": summaries, + "failure_taxonomy": taxonomy, + "code_signals": code_signals, + "rollouts": model_rollouts, + "cube_restack_viability": { + "oracle_solved": restack_oracle_solved, + "oracle_max_reward": max( + ( + float(row["reward"]) + for row in oracle_rollouts + if row["label"] == "cube restack" + ), + default=None, + ), + "mutation_depth_run": None, + "decision": ( + "eligible for staged mutation-depth experiments" + if restack_oracle_solved + else "stop before mutation because the open-perception oracle is unhealthy" + ), + }, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2) + "\n") + print(args.output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/projects/LocalInference/scripts/capx_demo.py b/projects/LocalInference/scripts/capx_demo.py new file mode 100644 index 00000000..3afa3550 --- /dev/null +++ b/projects/LocalInference/scripts/capx_demo.py @@ -0,0 +1,588 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Setup, profiling, and video helpers for the CaP-X notebook.""" + +import os + +# MuJoCo selects its headless renderer at import time. +os.environ.setdefault("MUJOCO_GL", "egl") +os.environ.setdefault("PYOPENGL_PLATFORM", "egl") + +import ast # noqa: E402 +import base64 # noqa: E402 +import re # noqa: E402 +import subprocess # noqa: E402 +import sys # noqa: E402 +import time # noqa: E402 +from collections.abc import Iterator # noqa: E402 +from contextlib import contextmanager, redirect_stderr, redirect_stdout # noqa: E402 +from pathlib import Path # noqa: E402 + +import numpy as np # noqa: E402 +import requests # noqa: E402 + +CAPX_ROOT = Path(os.environ.get("CAPX_ROOT", "/ryzers/cap-x")) + +# CaP-X resolves configs and assets relative to its repository. +if CAPX_ROOT.is_dir(): + os.chdir(CAPX_ROOT) + +try: + import capx # noqa: F401,E402 +except ModuleNotFoundError as exc: + raise RuntimeError( + "The CaP-X stack is not on this interpreter's path. In Jupyter, pick the " + "'CaP-X (ROCm)' kernel from the menu in the top right." + ) from exc + +LEMONADE_PORT = 13305 +DEFAULT_MODEL = "Gemma-4-E2B-it-GGUF" + +LEMONADE_ENV = Path( + os.environ.get( + "LEMONADE_ENV", + "/ryzers/notebooks/scripts/lemonade_env.sh", + ) +) + +LEMONADE_CACHE = os.environ.get("LEMONADE_CACHE", "/opt/lemonade-cache/lemonade") +LEMONADE_HF_HOME = os.environ.get("LEMONADE_HF_HOME", "/opt/lemonade-cache/huggingface") +LLAMA_METRICS_URL = os.environ.get("LLAMA_METRICS_URL", "http://127.0.0.1:8001/metrics") +SERVICE_LOG = Path(os.environ.get("CAPX_SERVICE_LOG", "/tmp/capx-services.log")) + +WORK = Path(os.environ.get("CAPX_WORK", "/tmp/capx_notebook")) + +TRIAL_DIR = re.compile(r"trial_(\d+)_sandboxrc_(\d+)_reward_([\d.]+)_taskcompleted_(\d)") + +PRIMITIVES = ( + "get_object_pose", + "sample_grasp_pose", + "goto_pose", + "open_gripper", + "close_gripper", + "home_pose", +) + +SCENARIOS = { + "cube stack": "env_configs/cube_stack/franka_robosuite_cube_stack.yaml", + "cube restack": "env_configs/cube_restack/franka_robosuite_cube_restack.yaml", + "cube lift": "env_configs/cube_lifting/franka_robosuite_cube_lifting.yaml", + "nut assembly": "env_configs/nut_assembly/franka_robosuite_nut_assembly.yaml", + "spill wipe": "env_configs/spill_wipe/franka_robosuite_spill_wipe.yaml", + "two arm handover": "env_configs/two_arm_handover/two_arm_handover.yaml", +} + + +def lemonade_alive(timeout: float = 2.0) -> bool: + try: + return requests.get(f"http://localhost:{LEMONADE_PORT}/api/v1/health", timeout=timeout).ok + except requests.RequestException: + return False + + +def ensure_lemonade(model: str = DEFAULT_MODEL, progress=print) -> float: + """Start Lemonade if needed and load one model.""" + started = time.monotonic() + action = "Loading" if lemonade_alive() else "Starting Lemonade and loading" + progress(f"{action} {model} from the image cache...") + + # setsid keeps the daemon alive across kernel restarts. + proc = subprocess.Popen( + ["setsid", "bash", str(LEMONADE_ENV), "--serve-only", model], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + env={ + **os.environ, + "HF_HOME": LEMONADE_HF_HOME, + "LEMONADE_CACHE": LEMONADE_CACHE, + "LEMONADE_HF_HOME": LEMONADE_HF_HOME, + }, + ) + load_error = None + for line in proc.stdout: + rendered = line.rstrip() + if "Error loading model:" in rendered: + load_error = rendered + elif "Downloading" in rendered or "Fetching" in rendered: + progress(f" {rendered}") + proc.wait() + + if load_error is not None: + raise RuntimeError(load_error) + if proc.returncode != 0 or not lemonade_alive(): + raise RuntimeError(f"{LEMONADE_ENV} --serve-only {model} failed (exit {proc.returncode}) - see /tmp/lemond.log") + elapsed = time.monotonic() - started + progress(f"Lemonade ready in {elapsed:.1f}s") + return elapsed + + +@contextmanager +def quiet_output(log_path: Path = SERVICE_LOG) -> Iterator[Path]: + """Send noisy native and child-process output to a log file.""" + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", buffering=1) as stream: + saved_stdout, saved_stderr = os.dup(1), os.dup(2) + try: + sys.stdout.flush() + sys.stderr.flush() + os.dup2(stream.fileno(), 1) + os.dup2(stream.fileno(), 2) + with redirect_stdout(stream), redirect_stderr(stream): + yield log_path + finally: + os.dup2(saved_stdout, 1) + os.dup2(saved_stderr, 2) + os.close(saved_stdout) + os.close(saved_stderr) + + +def llama_metrics(timeout: float = 2.0) -> dict[str, float]: + """Read cumulative prompt and generation counters from llama.cpp.""" + names = { + "prompt_tokens_total", + "prompt_seconds_total", + "tokens_predicted_total", + "tokens_predicted_seconds_total", + } + try: + text = requests.get(LLAMA_METRICS_URL, timeout=timeout).text + except requests.RequestException: + return {} + metrics: dict[str, float] = {} + for line in text.splitlines(): + if not line.startswith("llamacpp:"): + continue + key, _, raw_value = line.partition(" ") + name = key.removeprefix("llamacpp:") + if name in names: + metrics[name] = float(raw_value) + return metrics + + +def metric_delta(before: dict[str, float], after: dict[str, float]) -> dict[str, float]: + return {name: round(value - before.get(name, value), 3) for name, value in after.items()} + + +def show_video(env, name: str = "notebook_run", width: int = 640) -> str | None: + """Encode and display the frames captured during the last step.""" + from capx.utils.video_utils import _write_video + from IPython.display import Video, display + + frames = env.get_video_frames(clear=True) + if not frames: + print("no frames captured - was enable_video_capture called before the step?") + return None + + WORK.mkdir(parents=True, exist_ok=True) + _write_video(frames, str(WORK), suffix=name) + path = str(WORK / f"video_{name}.mp4") + display(Video(path, embed=True, width=width)) + return path + + +def _ffmpeg() -> str: + """The ffmpeg binary, preferring the one imageio ships with.""" + try: + import imageio_ffmpeg + + return imageio_ffmpeg.get_ffmpeg_exe() + except Exception: + return "ffmpeg" + + +def _scaled(video: Path, dest: Path, width: int) -> Path: + """Scale a clip for embedding, falling back to the original.""" + try: + done = subprocess.run( + [_ffmpeg(), "-y", "-loglevel", "error", "-i", str(video), "-vf", f"scale={width}:-2", "-an", str(dest)], + capture_output=True, + text=True, + ) + except OSError: + return video + return dest if done.returncode == 0 and dest.exists() else video + + +def _show_video_grid( + entries: list[tuple[str | int, float, bool, Path | None]], + width: int, + progress, +) -> None: + from IPython.display import HTML, display + + thumbs = WORK / "thumbs" + thumbs.mkdir(parents=True, exist_ok=True) + + figures = [] + embedded = 0 + for trial, reward, solved, video in entries: + caption = f"{trial} · reward {reward:.3f}" + caption += " · solved" if solved else "" + if video is None: + figures.append( + f'
' + f'
no video
{caption}
' + ) + continue + + safe_trial = re.sub(r"[^0-9A-Za-z_-]+", "_", str(trial)) + source = _scaled(video, thumbs / f"trial_{safe_trial}.mp4", width) + if source is video: + progress(f" could not scale {video.name}, embedding it as it is") + + data = base64.b64encode(source.read_bytes()).decode() + embedded += len(data) + figures.append( + f'
' + f'" + f"
{caption}
" + ) + + def row(items: list[str]) -> str: + return ( + '
' + "".join(items) + "
" + ) + + top = (len(figures) + 1) // 2 + display(HTML(row(figures[:top]) + (row(figures[top:]) if len(figures) > top else ""))) + progress(f"{len(figures)} rollout videos · {embedded / 1e6:.1f} MB embedded") + + +def show_trial_grid(trials: list[dict], width: int = 240, progress=print) -> None: + """Display CaP-X trial videos with rewards.""" + entries = [] + for trial in trials: + trial_dir = trial.get("dir") + video = ( + next(iter(sorted(trial_dir.glob("video_combined*.mp4"))), None) + if trial_dir is not None + else None + ) + entries.append((f"seed {trial['trial']}", trial["reward"], trial["solved"], video)) + _show_video_grid(entries, width, progress) + + +def show_rollout_grid(rollouts: list[dict], width: int = 240, progress=print) -> None: + """Display RHO rollout videos across seeds.""" + entries = [] + for rollout in rollouts: + raw_video = rollout.get("video") + video = Path(raw_video) if raw_video else None + if video is not None and not video.is_file(): + video = None + entries.append( + ( + f"{rollout.get('task', 'rollout')} seed {int(rollout['trial'])}", + float(rollout.get("reward") or 0.0), + bool(rollout.get("task_completed")), + video, + ) + ) + _show_video_grid(entries, width, progress) + + +def show_paired_rollout_grid( + before: list[dict], + after: list[dict], + width: int = 220, + progress=print, +) -> None: + """Display matched before/after rollout videos in adjacent pairs.""" + indexed_after = { + (str(item.get("task", "")), int(item["trial"])): item for item in after + } + entries = [] + for baseline in before: + key = (str(baseline.get("task", "")), int(baseline["trial"])) + evolved = indexed_after.get(key) + for phase, rollout in (("before", baseline), ("after", evolved)): + raw_video = rollout.get("video") if rollout else None + video = Path(raw_video) if raw_video else None + if video is not None and not video.is_file(): + video = None + entries.append( + ( + f"{key[0]} seed {key[1]} {phase}", + float(rollout.get("reward") or 0.0) if rollout else 0.0, + bool(rollout.get("task_completed")) if rollout else False, + video, + ) + ) + _show_video_grid(entries, width, progress) + + +def analyze_program(program: str) -> dict: + """Summarize how generated code uses CaP-X's grounded robot primitives.""" + calls = {name: 0 for name in PRIMITIVES} + syntax_error = None + try: + tree = ast.parse(program) + except SyntaxError as exc: + tree = None + syntax_error = f"{exc.msg} (line {exc.lineno})" + if tree is not None: + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + if node.func.id in calls: + calls[node.func.id] += 1 + + compact = "".join(program.split()) + return { + "syntax_error": syntax_error, + "primitive_calls": calls, + "perception_calls": calls["get_object_pose"] + calls["sample_grasp_pose"], + "planner_calls": calls["goto_pose"] + calls["home_pose"], + "uses_bbox_extent": "return_bbox_extent=True" in compact, + "uses_approach_offset": "z_approach=" in compact, + "nested_pose_indexing": bool( + re.search(r"[A-Za-z_][A-Za-z0-9_]*_pose\[0\]\[[012]\]", compact) + ), + "line_count": len(program.splitlines()), + } + + +def trial_program(trial: dict) -> str: + """Read the generated program retained in a CaP-X trial artifact.""" + trial_dir = trial.get("dir") + if trial_dir is None: + return "" + code_path = Path(trial_dir) / "code.py" + return code_path.read_text() if code_path.is_file() else "" + + +def trial_introspection(trials: list[dict]) -> list[dict]: + """Return compact code and failure evidence for a set of CaP-X trials.""" + rows = [] + for trial in trials: + program = trial_program(trial) + analysis = analyze_program(program) + reward = float(trial.get("reward") or 0.0) + if trial.get("solved"): + outcome = "completed" + elif trial.get("error"): + outcome = "execution failure" + elif reward > 0: + outcome = "partial reward" + else: + outcome = "no progress" + rows.append( + { + "trial": int(trial.get("trial", 0)), + "outcome": outcome, + "reward": reward, + "primitive_calls": sum(analysis["primitive_calls"].values()), + "perception_calls": analysis["perception_calls"], + "planner_calls": analysis["planner_calls"], + "bbox_extent": analysis["uses_bbox_extent"], + "approach_offset": analysis["uses_approach_offset"], + "nested_pose_indexing": analysis["nested_pose_indexing"], + "syntax_error": analysis["syntax_error"], + "program": program, + } + ) + return rows + + +def benchmark( + model: str, + server_url: str, + config_path: str, + temperature: float = 0.2, + max_tokens: int = 16384, + trials: int = 5, + oracle: bool = False, + verbose: bool = False, + progress=print, +) -> list[dict]: + """Run the CaP-X CLI serially over several layouts.""" + out_dir = WORK / "eval" + cmd = [ + sys.executable, + "capx/envs/launch.py", + "--config-path", + config_path, + "--model", + model, + "--server-url", + server_url, + "--temperature", + str(temperature), + "--max-tokens", + str(max_tokens), + "--total-trials", + str(trials), + "--num-workers", + "1", + "--output-dir", + str(out_dir), + ] + if oracle: + cmd.extend(["--use-oracle-code", "True"]) + progress(f"Running {trials} CaP-X trial{'s' if trials != 1 else ''}...") + + started = time.monotonic() + proc = subprocess.Popen( + cmd, + cwd=str(CAPX_ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + output: list[str] = [] + for line in proc.stdout: + output.append(line) + if verbose: + progress(line.rstrip()) + proc.wait() + elapsed = time.monotonic() - started + if proc.returncode != 0: + tail = "".join(output[-40:]).strip() + raise RuntimeError(f"CaP-X failed (exit {proc.returncode}):\n{tail}") + progress(f"CaP-X finished in {elapsed:.1f}s") + + return read_results(out_dir, model, verbose=verbose, progress=progress) + + +def read_results(out_dir: Path, model: str, *, verbose: bool = False, progress=print) -> list[dict]: + """Collect CaP-X trial artifacts.""" + root = out_dir.parent / model.replace("/", "_") / out_dir.name + if not root.is_dir(): + found = sorted(out_dir.parent.glob(f"*/{out_dir.name}"), key=lambda p: p.stat().st_mtime) + if not found: + progress(f"no results under {out_dir.parent}") + return [] + root = found[-1] + + summary = root / "summaries.txt" + if verbose and summary.exists(): + progress("\n" + summary.read_text()) + + by_trial = {} + for d in sorted(root.glob("trial_*")): + m = TRIAL_DIR.match(d.name) + if m: + entry = { + "trial": int(m[1]), + "error": m[2] != "0", + "reward": float(m[3]), + "solved": m[4] == "1", + "dir": d, + } + current = by_trial.get(entry["trial"]) + if current is None or d.stat().st_mtime > current["dir"].stat().st_mtime: + by_trial[entry["trial"]] = entry + trials = [by_trial[trial] for trial in sorted(by_trial)] + + progress(f"{'trial':>5} {'sandbox':>8} {'reward':>7} {'solved':>7}") + for t in trials: + progress(f"{t['trial']:>5} {'error' if t['error'] else 'ok':>8} {t['reward']:>7.3f} {str(t['solved']):>7}") + if trials: + solved = sum(t["solved"] for t in trials) + mean = np.mean([t["reward"] for t in trials]) + progress(f"\nsuccess rate: {solved}/{len(trials)} mean reward: {mean:.3f}") + return trials + + +def benchmark_scenarios( + model: str, + server_url: str, + scenarios: dict | None = None, + temperature: float = 0.2, + max_tokens: int = 16384, + trials: int = 1, + oracle: bool = False, + verbose: bool = False, + progress=print, +) -> list[dict]: + """Run one or more episodes of several different tasks, side by side.""" + if trials < 1: + raise ValueError("trials must be at least 1") + + scenarios = scenarios or SCENARIOS + results = [] + for label, config_path in scenarios.items(): + progress(f"\n===== {label}: {config_path} =====") + out_dir = WORK / "scenarios" / re.sub(r"[^0-9A-Za-z]+", "_", label) + cmd = [ + sys.executable, + "capx/envs/launch.py", + "--config-path", + config_path, + "--model", + model, + "--server-url", + server_url, + "--temperature", + str(temperature), + "--max-tokens", + str(max_tokens), + "--total-trials", + str(trials), + "--num-workers", + "1", + "--output-dir", + str(out_dir), + ] + if oracle: + cmd.extend(["--use-oracle-code", "True"]) + + started = time.monotonic() + proc = subprocess.Popen( + cmd, + cwd=str(CAPX_ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + output: list[str] = [] + for line in proc.stdout: + output.append(line) + if verbose: + progress(line.rstrip()) + proc.wait() + elapsed = time.monotonic() - started + if proc.returncode != 0: + tail = "".join(output[-40:]).strip() + progress(f"{label} failed (exit {proc.returncode}):\n{tail}") + + got = read_results( + out_dir, + model, + verbose=False, + progress=lambda *args, **kwargs: None, + ) + if not got: + got = [ + { + "trial": 0, + "reward": 0.0, + "solved": False, + "error": True, + "dir": None, + } + ] + per_trial_elapsed = elapsed / len(got) + for entry in got: + entry["label"] = label + entry["elapsed_seconds"] = per_trial_elapsed + results.append(entry) + + progress(f"\n{'scenario':<14}{'trial':>7}{'sandbox':>9}{'reward':>8}{'solved':>8}") + for result in results: + progress( + f"{result['label']:<14}{result.get('trial', 0):>7}" + f"{'error' if result.get('error') else 'ok':>9}" + f"{result['reward']:>8.3f}{str(result['solved']):>8}" + ) + solved = sum(result["solved"] for result in results) + progress(f"\nsolved {solved}/{len(results)} scenarios") + return results diff --git a/projects/LocalInference/scripts/capx_sweep.py b/projects/LocalInference/scripts/capx_sweep.py new file mode 100644 index 00000000..9716167d --- /dev/null +++ b/projects/LocalInference/scripts/capx_sweep.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Run a quick compact-model sweep over CaP-X's non-Molmo single-arm tasks.""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import socket +import statistics +import subprocess +import sys +import time +from dataclasses import asdict, dataclass +from pathlib import Path + + +SERVER_URL = "http://localhost:13305/api/v1/chat/completions" +CAPX_ROOT = Path(os.environ.get("CAPX_ROOT", "/ryzers/cap-x")) + +FAST_SCENARIOS = { + "cube lift": "env_configs/cube_lifting/franka_robosuite_cube_lifting.yaml", + "cube stack": "env_configs/cube_stack/franka_robosuite_cube_stack.yaml", + "cube restack": "env_configs/cube_restack/franka_robosuite_cube_restack.yaml", + "spill wipe": "env_configs/spill_wipe/franka_robosuite_spill_wipe.yaml", +} + + +def prepare_open_perception_configs(scenarios: dict[str, str]) -> dict[str, str]: + """Switch CaP-X from gated SAM3 to its ungated OWLv2 + SAM2 path.""" + import yaml + + integrations = CAPX_ROOT / "capx/integrations/__init__.py" + source = integrations.read_text(encoding="utf-8") + replacements = { + "FrankaControlApi(env, use_sam3=True)": ( + "FrankaControlApi(env, use_sam3=False)" + ), + ( + "FrankaControlSpillWipeApi(" + "env, tcp_offset=[0.0, 0.0, -0.0158], use_sam3=True)" + ): ( + "FrankaControlSpillWipeApi(" + "env, tcp_offset=[0.0, 0.0, -0.0158], use_sam3=False)" + ), + } + for old, new in replacements.items(): + if old not in source and new not in source: + raise RuntimeError(f"CaP-X perception registration changed: {old}") + source = source.replace(old, new) + integrations.write_text(source, encoding="utf-8") + + config_dir = Path("/tmp/capx-open-perception") + config_dir.mkdir(parents=True, exist_ok=True) + prepared = {} + for label, relative_path in scenarios.items(): + source_path = CAPX_ROOT / relative_path + config = yaml.safe_load(source_path.read_text(encoding="utf-8")) + servers = [] + replaced_sam3 = False + for server in config["api_servers"]: + if "launch_sam3_server" not in server.get("_target_", ""): + servers.append(server) + continue + replaced_sam3 = True + servers.extend( + [ + { + "_target_": "capx.serving.launch_owlvit_server.main", + "device": "cuda", + "port": 8117, + "host": "127.0.0.1", + "model_name": "google/owlv2-large-patch14-ensemble", + }, + { + "_target_": "capx.serving.launch_sam2_server.main", + "device": "cuda", + "port": 8113, + "host": "127.0.0.1", + "model_name": "facebook/sam2.1-hiera-large", + }, + ] + ) + if not replaced_sam3: + targets = {server.get("_target_", "") for server in servers} + already_open = ( + "capx.serving.launch_owlvit_server.main" in targets + and "capx.serving.launch_sam2_server.main" in targets + ) + if not already_open: + raise RuntimeError( + f"neither SAM3 nor OWLv2+SAM2 servers found in {source_path}" + ) + config["api_servers"] = servers + destination = config_dir / source_path.name + destination.write_text( + yaml.safe_dump(config, sort_keys=False), + encoding="utf-8", + ) + prepared[label] = str(destination) + return prepared + + +@dataclass(frozen=True) +class ModelSpec: + key: str + model: str + temperature: float + size_gb: float + temperature_source: str + checkpoint: str | None = None + + +MODELS = [ + ModelSpec( + "gemma-e2b", + "Gemma-4-E2B-it-GGUF", + 1.0, + 4.09, + "google/gemma-4-E2B-it generation_config.json", + ), + ModelSpec( + "gpt-oss-20b", + "gpt-oss-20b-mxfp4-GGUF", + 1.0, + 12.1, + "openai/gpt-oss README recommended sampling parameters", + ), + ModelSpec( + "gemma-e4b", + "Gemma-4-E4B-it-GGUF", + 1.0, + 5.97, + "google/gemma-4-E4B-it generation_config.json", + ), + ModelSpec( + "gemma-12b", + "Gemma-4-12B-it-GGUF", + 1.0, + 7.29, + "google/gemma-4-12B-it generation_config.json", + ), + ModelSpec( + "qwen3.5-9b", + "Qwen3.5-9B-GGUF", + 0.6, + 6.88, + "Qwen3.5 model card: thinking mode for precise coding", + ), + ModelSpec( + "qwen3-coder-30b-a3b", + "user.Qwen3-Coder-30B-A3B-Instruct-Q4_K_M", + 0.7, + 17.31, + "Qwen/Qwen3-Coder-30B-A3B-Instruct model card", + ( + "unsloth/Qwen3-Coder-30B-A3B-Instruct-GGUF:" + "Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf" + ), + ), + ModelSpec( + "ministral-8b", + "user.Ministral-3-8B-Instruct-2512-Q5_K_M", + 0.15, + 6.06, + "Ministral 3 Instruct model card example", + ( + "mistralai/Ministral-3-8B-Instruct-2512-GGUF:" + "Ministral-3-8B-Instruct-2512-Q5_K_M.gguf" + ), + ), + ModelSpec( + "ministral-14b", + "user.Ministral-3-14B-Instruct-2512-Q5_K_M", + 0.15, + 9.62, + "Ministral 3 Instruct model card example", + ( + "mistralai/Ministral-3-14B-Instruct-2512-GGUF:" + "Ministral-3-14B-Instruct-2512-Q5_K_M.gguf" + ), + ), + ModelSpec( + "qwen2.5-coder-7b", + "user.Qwen2.5-Coder-7B-Instruct-Q6_K", + 0.7, + 6.25, + "Qwen/Qwen2.5-Coder-7B-Instruct generation_config.json", + ( + "Qwen/Qwen2.5-Coder-7B-Instruct-GGUF:" + "qwen2.5-coder-7b-instruct-q6_k.gguf" + ), + ), + ModelSpec( + "qwen2.5-coder-14b", + "user.Qwen2.5-Coder-14B-Instruct-Q4_K_M", + 0.7, + 8.99, + "Qwen/Qwen2.5-Coder-14B-Instruct generation_config.json", + ( + "Qwen/Qwen2.5-Coder-14B-Instruct-GGUF:" + "qwen2.5-coder-14b-instruct-q4_k_m.gguf" + ), + ), + ModelSpec( + "granite-4.1-3b", + "user.granite-4.1-3b-Q8_0", + 0.0, + 3.62, + "No sampling recommendation in generation_config.json; greedy fallback", + "ibm-granite/granite-4.1-3b-GGUF:granite-4.1-3b-Q8_0.gguf", + ), +] + + +class Logger: + def __init__(self, path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + self._stream = path.open("a", encoding="utf-8", buffering=1) + + def __call__(self, message: object = "") -> None: + text = str(message) + print(text, flush=True) + self._stream.write(text + "\n") + + def close(self) -> None: + self._stream.close() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output-dir", + type=Path, + default=Path(os.environ.get("CAPX_SWEEP_OUTPUT", "/tmp/capx_sweep")), + ) + parser.add_argument("--trials", type=int, default=2) + parser.add_argument("--max-tokens", type=int, default=4096) + parser.add_argument( + "--models", + nargs="*", + metavar="KEY", + help="Model keys to run; defaults to all configured models", + ) + parser.add_argument( + "--tasks", + nargs="*", + metavar="LABEL", + choices=tuple(FAST_SCENARIOS), + help="Task labels to run; defaults to all established tasks", + ) + parser.add_argument("--skip-oracle", action="store_true") + parser.add_argument( + "--oracle-only", + action="store_true", + help="Run the controlled oracle and write its report without loading an LLM", + ) + parser.add_argument("--skip-pull", action="store_true") + parser.add_argument( + "--perception", + choices=("sam3", "open"), + default="sam3", + help="'open' uses ungated OWLv2 grounding with SAM2 segmentation", + ) + parser.add_argument("--verbose", action="store_true") + parser.add_argument("--list-models", action="store_true") + return parser.parse_args() + + +def select_models(keys: list[str] | None) -> list[ModelSpec]: + if not keys: + return MODELS + by_key = {spec.key: spec for spec in MODELS} + unknown = sorted(set(keys) - set(by_key)) + if unknown: + raise SystemExit( + f"unknown model key(s): {', '.join(unknown)}; " + f"choose from {', '.join(by_key)}" + ) + return [by_key[key] for key in keys] + + +def pull_custom_model(spec: ModelSpec, log: Logger) -> None: + if spec.checkpoint is None: + return + log(f"Registering/downloading {spec.model} ({spec.checkpoint})") + started = time.monotonic() + proc = subprocess.run( + [ + "lemonade", + "pull", + spec.model, + "--checkpoint", + "main", + spec.checkpoint, + "--recipe", + "llamacpp", + "--label", + "coding", + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + for line in proc.stdout.splitlines(): + log(f" {line}") + if proc.returncode: + raise RuntimeError(f"lemonade pull failed with exit code {proc.returncode}") + log(f"Model preparation took {(time.monotonic() - started) / 60:.1f} minutes") + + +def serializable(row: dict) -> dict: + return { + key: str(value) if isinstance(value, Path) else value + for key, value in row.items() + } + + +def model_summary(spec: ModelSpec, rows: list[dict]) -> dict: + solved = sum(bool(row["solved"]) for row in rows) + errors = sum(bool(row["error"]) for row in rows) + rewards = [float(row["reward"]) for row in rows] + elapsed = [float(row.get("elapsed_seconds", 0.0)) for row in rows] + per_task = {} + for label in FAST_SCENARIOS: + task_rows = [row for row in rows if row["label"] == label] + per_task[label] = { + "solved": sum(bool(row["solved"]) for row in task_rows), + "rollouts": len(task_rows), + "mean_reward": ( + statistics.fmean(float(row["reward"]) for row in task_rows) + if task_rows + else 0.0 + ), + } + return { + **asdict(spec), + "solved": solved, + "rollouts": len(rows), + "success_rate": solved / len(rows) if rows else 0.0, + "sandbox_errors": errors, + "mean_reward": statistics.fmean(rewards) if rewards else 0.0, + "mean_rollout_seconds": statistics.fmean(elapsed) if elapsed else 0.0, + "per_task": per_task, + } + + +def write_reports( + output_dir: Path, + metadata: dict, + summaries: list[dict], + rows: list[dict], +) -> None: + payload = { + "metadata": metadata, + "models": summaries, + "rollouts": [serializable(row) for row in rows], + } + (output_dir / "results.json").write_text( + json.dumps(payload, indent=2) + "\n", encoding="utf-8" + ) + with (output_dir / "rollouts.csv").open( + "w", newline="", encoding="utf-8" + ) as stream: + fieldnames = [ + "model_key", + "model", + "temperature", + "label", + "trial", + "solved", + "reward", + "error", + "elapsed_seconds", + "dir", + ] + writer = csv.DictWriter(stream, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow({key: serializable(row).get(key) for key in fieldnames}) + + +def stop_servers(servers: list[object], log: Logger) -> None: + for server in servers: + terminate = getattr(server, "terminate", None) + if terminate is not None: + terminate() + for server in servers: + wait = getattr(server, "wait", None) + if wait is not None: + try: + wait(timeout=10) + except subprocess.TimeoutExpired: + kill = getattr(server, "kill", None) + if kill is not None: + kill() + log("Perception services stopped") + + +def main() -> int: + args = parse_args() + if args.list_models: + for spec in MODELS: + print( + f"{spec.key:<22} {spec.model:<52} " + f"temp={spec.temperature:<4} {spec.size_gb:>5.2f} GB" + ) + return 0 + if args.trials < 1: + raise SystemExit("--trials must be at least 1") + if args.oracle_only and args.skip_oracle: + raise SystemExit("--oracle-only cannot be combined with --skip-oracle") + + selected = select_models(args.models) + scenarios_requested = ( + {label: FAST_SCENARIOS[label] for label in args.tasks} + if args.tasks + else FAST_SCENARIOS + ) + output_dir = args.output_dir.expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + os.environ["CAPX_WORK"] = str(output_dir / "artifacts") + scenarios = ( + prepare_open_perception_configs(scenarios_requested) + if args.perception == "open" + else scenarios_requested + ) + + # Imports are intentionally delayed until CAPX_WORK is fixed; capx_demo also + # moves into CAPX_ROOT so upstream relative config paths resolve correctly. + from capx.envs.launch import LaunchArgs + from capx.envs.runner import _start_api_servers + from capx.utils.launch_utils import _load_config + from capx_demo import ( + DEFAULT_MODEL, + benchmark_scenarios, + ensure_lemonade, + lemonade_alive, + quiet_output as quiet, + ) + + log = Logger(output_dir / "sweep.log") + metadata = { + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "hostname": socket.gethostname(), + "image_id": os.environ.get("EXPERIMENT_IMAGE_ID"), + "source_revision": os.environ.get("EXPERIMENT_SOURCE_REVISION"), + "trials_per_task": args.trials, + "max_tokens": args.max_tokens, + "server_url": SERVER_URL, + "perception": args.perception, + "scenarios": scenarios, + "models": [] if args.oracle_only else [asdict(spec) for spec in selected], + } + (output_dir / "metadata.json").write_text( + json.dumps(metadata, indent=2) + "\n", encoding="utf-8" + ) + + rows: list[dict] = [] + summaries: list[dict] = [] + servers: list[object] = [] + try: + perception_services = ( + "OWLv2, SAM2, Contact-GraspNet, and PyRoKi" + if args.perception == "open" + else "SAM3, Contact-GraspNet, and PyRoKi" + ) + log(f"Starting shared {perception_services} services") + setup_args = LaunchArgs( + config_path=next(iter(scenarios.values())), + model=selected[0].model, + server_url=SERVER_URL, + temperature=selected[0].temperature, + max_tokens=args.max_tokens, + ) + with quiet(output_dir / "services.log"): + _, _, api_servers = _load_config(setup_args) + servers = _start_api_servers(api_servers, 900.0) + log("Perception services are ready") + + if not args.skip_oracle: + log("\n===== oracle smoke test: one rollout per task =====") + oracle_rows = benchmark_scenarios( + model="oracle", + server_url=SERVER_URL, + scenarios=scenarios, + temperature=0.0, + max_tokens=args.max_tokens, + trials=1, + oracle=True, + verbose=args.verbose, + progress=log, + ) + for row in oracle_rows: + row["model_key"] = "oracle" + row["model"] = "oracle" + row["temperature"] = 0.0 + rows.extend(oracle_rows) + write_reports(output_dir, metadata, summaries, rows) + if args.oracle_only: + return 0 + + for index, spec in enumerate(selected, start=1): + log( + f"\n===== model {index}/{len(selected)}: {spec.key} " + f"({spec.model}, temperature={spec.temperature}) =====" + ) + model_started = time.monotonic() + try: + if spec.checkpoint is not None and not args.skip_pull: + if not lemonade_alive(): + log("Starting Lemonade before custom model registration") + ensure_lemonade(DEFAULT_MODEL, progress=log) + pull_custom_model(spec, log) + ensure_lemonade(spec.model, progress=log) + model_rows = benchmark_scenarios( + model=spec.model, + server_url=SERVER_URL, + scenarios=scenarios, + temperature=spec.temperature, + max_tokens=args.max_tokens, + trials=args.trials, + verbose=args.verbose, + progress=log, + ) + for row in model_rows: + row["model_key"] = spec.key + row["model"] = spec.model + row["temperature"] = spec.temperature + rows.extend(model_rows) + summary = model_summary(spec, model_rows) + summary["total_model_minutes"] = ( + time.monotonic() - model_started + ) / 60 + summaries.append(summary) + log( + f"{spec.key}: {summary['solved']}/{summary['rollouts']} solved, " + f"mean reward {summary['mean_reward']:.3f}, " + f"{summary['total_model_minutes']:.1f} minutes" + ) + except Exception as exc: + log(f"{spec.key} FAILED: {type(exc).__name__}: {exc}") + summaries.append( + { + **asdict(spec), + "failed": True, + "failure": f"{type(exc).__name__}: {exc}", + "total_model_minutes": ( + time.monotonic() - model_started + ) / 60, + } + ) + write_reports(output_dir, metadata, summaries, rows) + finally: + if servers: + stop_servers(servers, log) + subprocess.run( + ["lemonade", "unload"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + log.close() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/projects/LocalInference/scripts/lemonade_env.sh b/projects/LocalInference/scripts/lemonade_env.sh new file mode 100755 index 00000000..33a12ed8 --- /dev/null +++ b/projects/LocalInference/scripts/lemonade_env.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Source me, don't run me: source lemonade_env.sh [MODEL] +# Preps the container to run RAI benchmarks against a LOCAL Lemonade model, +# then leaves the benchmark command up to you. +# +# Serving the model is useful on its own, without any of the RAI or ROS setup +# that follows it, so that half can be run in a subshell instead: +# bash lemonade_env.sh --serve-only [MODEL] +# That is what notebook 3 does. It skips the config.toml rewrite and the ROS +# overlay below, which belong to RAI and would put the wrong cwd and the wrong +# Python packages on the CaP-X kernel. + +SERVE_ONLY=0 +if [ "${1:-}" = "--serve-only" ]; then + SERVE_ONLY=1 + shift +fi + +MODEL="${1:-Gemma-4-E2B-it-GGUF}" +LEMONADE_CACHE="${LEMONADE_CACHE:-/opt/lemonade-cache/lemonade}" +LEMONADE_HF_HOME="${LEMONADE_HF_HOME:-/opt/lemonade-cache/huggingface}" +export HF_HOME="${LEMONADE_HF_HOME}" + +# Start lemond if it isn't already up; log to /tmp/lemond.log +if ! lemonade status >/dev/null 2>&1; then + lemond "${LEMONADE_CACHE}" > /tmp/lemond.log 2>&1 & + # Bounded, so a server that never binds fails here instead of hanging the + # shell (or, under --serve-only, the caller waiting on this script) + for _ in $(seq 300); do + lemonade status >/dev/null 2>&1 && break + sleep 1 + done + if ! lemonade status >/dev/null 2>&1; then + echo "lemond did not come up - see /tmp/lemond.log" >&2 + return 1 2>/dev/null || exit 1 + fi +fi + +# Load the image-cached model (no-op if already loaded). +lemonade load "$MODEL" + +if [ "$SERVE_ONLY" -eq 0 ]; then + # Point RAI's [openai] base_url at Lemonade. Sets it regardless of current + # value, so this works no matter which backend you ran last. + sed -i 's|^base_url = .*|base_url = "http://localhost:13305/api/v0"|' /ryzers/rai/config.toml + export OPENAI_API_KEY="lemonade" # dummy; Lemonade ignores it + # Point the [openai] model name to do the same sourced in lemonade. + sed -i '/^\[openai\]/,/^\[/{s|^simple_model = .*|simple_model = "'"$MODEL"'"|; s|^complex_model = .*|complex_model = "'"$MODEL"'"|}' /ryzers/rai/config.toml + + # ROS env (runtime = interactive bash, so .bash) + cd /ryzers/rai + source /opt/ros/jazzy/setup.bash + source install/setup.bash +fi + +# Headless run only since this script is for the roscon26 conference +echo "Lemonade ready" diff --git a/projects/LocalInference/scripts/manipulation_demo_headless.sh b/projects/LocalInference/scripts/manipulation_demo_headless.sh new file mode 100755 index 00000000..52cf775e --- /dev/null +++ b/projects/LocalInference/scripts/manipulation_demo_headless.sh @@ -0,0 +1,123 @@ +#!/bin/bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# Runs the RAI manipulation demo without a monitor. +# +# O3DE renders through Vulkan, which needs a presentable surface: Xvfb can't +# provide one (no DRI3, so only the llvmpipe software device is presentable), +# but Xwayland on a headless Weston compositor can - it hands the X server the +# real /dev/dri render node, so the iGPU does the rendering. The simulation +# camera is then republished as MJPEG by web_video_server and shown next to the +# agent chat in the Streamlit page, so the whole demo lives in the browser. + +set -e + +# --infra-only brings up the headless display and the camera stream, then exits +# and leaves them running. The notebook builds the UI itself in that mode, so +# there is no Streamlit page and no second browser tab. +INFRA_ONLY=0 +[ "${1:-}" = "--infra-only" ] && INFRA_ONLY=1 + +RAI_DIR=/ryzers/rai +WESTON_LOG=/tmp/weston.log +VIDEO_PORT=8080 + +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/tmp/runtime-root}" +mkdir -p -m 700 "$XDG_RUNTIME_DIR" + +# Xwayland needs this to exist. The container runs with --network=host, which +# shares the abstract socket namespace, so the abstract @X0 name is taken and +# Xwayland falls back to a socket file in here - Weston crashes if it's missing. +mkdir -p -m 1777 /tmp/.X11-unix + +cleanup() { + [ -n "$WESTON_PID" ] && kill "$WESTON_PID" 2>/dev/null + [ -n "$WATCHDOG_PID" ] && kill "$WATCHDOG_PID" 2>/dev/null + # $VIDEO_PID is the "ros2 run" wrapper, not the server binary it forks, and a + # wedged server ignores SIGTERM (rclcpp's shutdown handler never gets to run). + # Match on the name and SIGKILL, or the orphan keeps :$VIDEO_PORT and the next + # launch dies with "Address already in use". + pkill -9 -f web_video_server 2>/dev/null + return 0 +} +trap cleanup EXIT + +# 1. Headless GPU display +if pgrep -f "weston --backend=headless" > /dev/null; then + echo "Reusing the Weston compositor that is already running" +else + echo "Starting headless Weston + Xwayland..." + weston --backend=headless --renderer=gl \ + --width=1920 --height=1080 \ + --xwayland --socket=wayland-headless > "$WESTON_LOG" 2>&1 & + WESTON_PID=$! +fi + +for _ in $(seq 30); do + DISPLAY=$(grep -oP 'xserver listening on display \K:[0-9]+' "$WESTON_LOG" | head -1) + [ -n "$DISPLAY" ] && break + sleep 1 +done +if [ -z "$DISPLAY" ]; then + echo "Xwayland did not come up - see $WESTON_LOG" >&2 + exit 1 +fi +export DISPLAY +echo "Headless display ready on $DISPLAY" + +# 2. ROS 2 + RAI environment (the demo resolves its assets relative to $RAI_DIR) +cd "$RAI_DIR" +source /opt/ros/${ROS_DISTRO}/setup.bash +source install/setup.bash + +# 3. Republish the simulation camera as MJPEG for the browser +echo "Starting web_video_server on port $VIDEO_PORT..." +start_video_server() { + ros2 run web_video_server web_video_server --ros-args -p port:=$VIDEO_PORT \ + >> /tmp/web_video_server.log 2>&1 & + VIDEO_PID=$! +} +# Clear anything left over from an earlier run before claiming the port +if pkill -9 -f web_video_server 2>/dev/null; then sleep 2; fi +start_video_server + +# web_video_server wedges when a stream client vanishes mid-frame - a crashed +# notebook kernel, a closed browser tab. It keeps the listen socket, spins at +# 100% CPU and stops answering, so the simulation panel and the notebook +# recorder both hang until someone restarts it by hand. Health-check the +# snapshot endpoint instead and respawn when it stops responding. +( + sleep 30 + while true; do + # Ask for the topic index, not the camera: the camera topic does not + # exist until the simulation is up, but a wedged server stops answering + # every request, this one included. + if ! curl -fsS --max-time 10 -o /dev/null "http://localhost:$VIDEO_PORT/"; then + echo "$(date -Is) web_video_server unresponsive - restarting" \ + >> /tmp/web_video_server.log + pkill -9 -f web_video_server 2>/dev/null || true + sleep 2 + start_video_server + sleep 20 # give it time to come up before the next check + fi + sleep 15 + done +# Redirect the whole subshell, not just the echo: it outlives this script, and +# anything reading our stdout through a pipe would block until it exits. +) >> /tmp/web_video_server.log 2>&1 & +WATCHDOG_PID=$! + +if [ "$INFRA_ONLY" = "1" ]; then + # Hand weston, web_video_server and the watchdog over to the notebook by + # dropping the trap - otherwise they die with this shell. + trap - EXIT + echo "DISPLAY=$DISPLAY" + echo "Infrastructure ready - build the UI from the notebook" + exit 0 +fi + +# 4. Streamlit page: simulation view + agent chat +echo "Starting the demo - open http://localhost:8501 once the scene has loaded" +streamlit run /ryzers/notebooks/scripts/manipulation_demo_streamlit.py \ + --server.headless true --server.address 0.0.0.0 diff --git a/projects/LocalInference/scripts/manipulation_demo_streamlit.py b/projects/LocalInference/scripts/manipulation_demo_streamlit.py new file mode 100644 index 00000000..36dc64d3 --- /dev/null +++ b/projects/LocalInference/scripts/manipulation_demo_streamlit.py @@ -0,0 +1,75 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# Headless front-end for RAI's manipulation demo: adds a live view of the +# simulation to the upstream Streamlit app so no local display is needed. +# The view is the MJPEG stream web_video_server publishes from the simulation +# camera topic - the same images the agent reasons about. + +import importlib.util +import os +import sys + +import streamlit as st +import streamlit.components.v1 as components + +RAI_EXAMPLES = "/ryzers/rai/examples" +DEMO_APP = f"{RAI_EXAMPLES}/manipulation-demo-streamlit.py" +O3DE_CONFIG = ( + "src/rai_bench/rai_bench/manipulation_o3de/predefined/configs/o3de_config.yaml" +) +CAMERA_TOPIC = "/color_image5" +JUPYTERHUB_SERVICE_PREFIX = os.environ.get("JUPYTERHUB_SERVICE_PREFIX") +STREAM_BASE_URL = ( + f"{JUPYTERHUB_SERVICE_PREFIX.rstrip('/')}/proxy/8080" + if JUPYTERHUB_SERVICE_PREFIX + else "http://localhost:8080" +) +STREAM_URL = ( + f"{STREAM_BASE_URL}/stream?topic={CAMERA_TOPIC}&quality=70&width=640&height=360" +) + +st.set_page_config(page_title="RAI Manipulation Demo", page_icon=":robot:") + +# Upstream calls set_page_config as well, and Streamlit allows only one call +# per page - drop the second one instead of patching the example itself. +st.set_page_config = lambda *args, **kwargs: None + +st.markdown( + "", + unsafe_allow_html=True, +) + +with st.sidebar: + st.header("Simulation") + # While O3DE is still loading, web_video_server accepts the connection but + # sends no frames - that never fires onerror, so reconnect on a timer until + # a frame actually arrives (naturalWidth turns non-zero). + components.html( + f""" + +

Waiting for the simulation...

+ + """, + height=310, + ) + +sys.path.insert(0, RAI_EXAMPLES) +spec = importlib.util.spec_from_file_location("rai_manipulation_demo", DEMO_APP) +demo = importlib.util.module_from_spec(spec) +spec.loader.exec_module(demo) + +demo.main(O3DE_CONFIG) diff --git a/projects/LocalInference/scripts/notebook_demo.py b/projects/LocalInference/scripts/notebook_demo.py new file mode 100644 index 00000000..3dba33a3 --- /dev/null +++ b/projects/LocalInference/scripts/notebook_demo.py @@ -0,0 +1,701 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# In-notebook front end for RAI's manipulation demo. +# +# The Streamlit version of this demo runs as a separate server the browser +# reaches over jupyter-server-proxy, so the demo lives in a second tab. Here the +# agent, the O3DE bridge and the chat all run in the notebook kernel and the UI +# is ipywidgets, so the whole demo is the output of one cell. Camera frames are +# fetched kernel-side from web_video_server and pushed into an Image widget over +# the kernel's existing websocket - the browser never opens the stream itself. + +# rclpy has to be initialized before torch is loaded: torch's bundled C++ +# runtime corrupts the heap for rcl's init, aborting the process with +# "free(): invalid size". Everything below pulls in torch through rai_perception, +# so this import must stay first. +import rclpy + +if not rclpy.ok(): + rclpy.init() + +import contextlib # noqa: E402 +import os # noqa: E402 +import re # noqa: E402 +import subprocess # noqa: E402 +import sys # noqa: E402 +import threading # noqa: E402 +import time # noqa: E402 +import traceback # noqa: E402 +import warnings # noqa: E402 +from pathlib import Path # noqa: E402 + +# matplotlib and timm both warn while the imports below load them, and neither +# is actionable here - they would otherwise be the first thing in the cell, +# before any progress message. Registered before the imports that trigger them. +warnings.filterwarnings("ignore", message="Unable to import Axes3D") +warnings.filterwarnings("ignore", message=".*timm\\.models\\.layers is deprecated") + +import ipywidgets as widgets # noqa: E402 +import requests # noqa: E402 +from IPython.display import display # noqa: E402 +from langchain_core.callbacks.base import BaseCallbackHandler # noqa: E402 +from langchain_core.messages import AIMessage # noqa: E402 +from langchain_core.runnables import RunnableConfig # noqa: E402 +from launch import LaunchDescription # noqa: E402 +from launch.actions import IncludeLaunchDescription # noqa: E402 +from launch.launch_description_sources import PythonLaunchDescriptionSource # noqa: E402 +from launch_ros.actions import Node # noqa: E402 +from launch_ros.substitutions import FindPackageShare # noqa: E402 +from rai.communication.ros2.connectors.ros2_connector import ROS2Connector # noqa: E402 +from rai.messages import HumanMultimodalMessage # noqa: E402 +from rai_bench.manipulation_o3de import get_scenarios # noqa: E402 +from rai_bench.manipulation_o3de.benchmark import Scenario # noqa: E402 +from rai_sim.o3de.o3de_bridge import ( # noqa: E402 + O3DEngineArmManipulationBridge, + O3DExROS2SimulationConfig, +) +from rai_sim.simulation_bridge import SceneConfig # noqa: E402 + +RAI_DIR = Path("/ryzers/rai") +DEMO_SCRIPT = "/ryzers/notebooks/scripts/manipulation_demo_headless.sh" +O3DE_CONFIG = "src/rai_bench/rai_bench/manipulation_o3de/predefined/configs/o3de_config.yaml" +CAMERA_TOPIC = "/color_image5" +VIDEO_PORT = 8080 +LEMONADE_URL = "http://localhost:13305" +DEFAULT_MODEL = "Gemma-4-E2B-it-GGUF" +LEMONADE_CACHE = os.environ.get( + "LEMONADE_CACHE", "/opt/lemonade-cache/lemonade" +) +LEMONADE_HF_HOME = os.environ.get( + "LEMONADE_HF_HOME", "/opt/lemonade-cache/huggingface" +) + +# Same layouts the Streamlit sidebar offers, keyed by scene config file stem +SCENARIO_NAMES = { + "3rc": "3 Red Cubes", + "4carrots": "4 Carrots", + "2rc_2a": "2 Red Cubes, 2 Apples", + "3rc_2a_1carrot": "3 Red Cubes, 2 Apples, 1 Carrot", + "3carrots_3a_2rc": "3 Carrots, 3 Apples, 2 Red Cubes", +} +GREETING = "Hi! I am a robotic arm. What can I do for you?" + + +LAUNCH_LOG = "/tmp/demo_launch.log" +_launch_log_handle = None + + +def _log_file(path: str = LAUNCH_LOG): + """One append handle for the whole session. + + Kept open deliberately: the logging handler installed below holds a + reference to it and keeps writing long after the launch call returns. + """ + global _launch_log_handle + if _launch_log_handle is None or _launch_log_handle.closed: + _launch_log_handle = open(path, "a", buffering=1) # noqa: SIM115 + return _launch_log_handle + + +def quiet_ros_logging(path: str = LAUNCH_LOG) -> None: + """Stop ROS 2 from writing to the notebook cell. + + Two separate sources, neither of which a file-descriptor swap can catch: + + The "[move_group-4] ..." lines are child process output that the launch + service reads off a pipe and re-emits through Python logging. launch builds + exactly one screen handler, lazily, bound to whatever sys.stdout was at the + time - in a kernel that is ipykernel's ZMQ stream. Replacing the handler + points all of it at a file instead, permanently, which matters because the + launch service keeps running in the background after build_demo returns. + + The unprefixed lines - tf2's TF_OLD_DATA, the connector's INFO - come from + rcl inside this process, so they take a severity bump instead. + """ + import launch.logging + + # launch's own StreamHandler subclass, not logging.StreamHandler: it carries + # setFormatterFor(), which get_output_loggers() calls on whatever it finds. + handler = launch.logging.handlers.StreamHandler(_log_file(path)) + formatter = getattr(launch.logging.launch_config, "screen_formatter", None) + if formatter is not None: + handler.setFormatter(formatter) + launch.logging.launch_config.screen_handler = handler + + rclpy.logging.set_logger_level("", rclpy.logging.LoggingSeverity.ERROR) + + +@contextlib.contextmanager +def quiet(log_path: str = LAUNCH_LOG): + """Divert this process's stdout/stderr to a log file. + + Has to be done at the file-descriptor level. The ROS 2 launch service, the + O3DE process and rcl's C++ logger all write to fd 1/2 directly, so + contextlib.redirect_stdout never sees them - and ipykernel captures those + fds, which is why their output lands in the cell at all. + + print() is unaffected: in a kernel sys.stdout is a ZMQ stream that does not + go through fd 1, so progress messages still reach the notebook. + """ + sys.stdout.flush() + sys.stderr.flush() + saved_out, saved_err = os.dup(1), os.dup(2) + log = open(log_path, "a") # noqa: SIM115 + try: + os.dup2(log.fileno(), 1) + os.dup2(log.fileno(), 2) + yield log_path + finally: + os.dup2(saved_out, 1) + os.dup2(saved_err, 2) + os.close(saved_out) + os.close(saved_err) + log.close() + + +# --------------------------------------------------------------------------- +# Environment: Lemonade, headless display, camera stream +# --------------------------------------------------------------------------- + + +def ensure_lemonade(model: str = DEFAULT_MODEL) -> None: + """Python equivalent of ``source lemonade_env.sh``. + + Starts the Lemonade server if it is down, loads the model, and points RAI's + [openai] section at it. Safe to call again - every step is a no-op once done. + """ + + def running() -> bool: + return subprocess.run(["lemonade", "status"], capture_output=True).returncode == 0 + + if not running(): + # The handle has to outlive this call - lemond keeps writing to it long + # after we return, so a context manager would close it out from under. + log = open("/tmp/lemond.log", "a") # noqa: SIM115 + subprocess.Popen( + ["lemond", LEMONADE_CACHE], + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + env={ + **os.environ, + "HF_HOME": LEMONADE_HF_HOME, + "LEMONADE_CACHE": LEMONADE_CACHE, + "LEMONADE_HF_HOME": LEMONADE_HF_HOME, + }, + ) + for _ in range(120): + if running(): + break + time.sleep(1) + else: + raise RuntimeError("Lemonade did not come up - see /tmp/lemond.log") + + subprocess.run(["lemonade", "load", model], check=True, capture_output=True) + + # RAI reads the backend out of config.toml, not the environment + config = RAI_DIR / "config.toml" + config.write_text(_point_openai_at(config.read_text(), model)) + os.environ.setdefault("OPENAI_API_KEY", "lemonade") + + +def _point_openai_at(text: str, model: str) -> str: + """Rewrite the [openai] section of RAI's config.toml to use Lemonade. + + Scoped to that one section on purpose. Every vendor section has + simple_model/complex_model keys, and in [vendor] they name the *vendor* + ("openai"), not a model - rewriting those globally makes RAI look up a + vendor named after the model and fail with AttributeError. + """ + out, in_openai = [], False + for line in text.splitlines(keepends=True): + stripped = line.strip() + if stripped.startswith("["): + in_openai = stripped == "[openai]" + elif in_openai: + body, newline = line.rstrip("\n"), line[len(line.rstrip("\n")) :] + body = re.sub( + r"^(simple_model|complex_model)\s*=.*$", + lambda m: f'{m.group(1)} = "{model}"', + body, + ) + body = re.sub(r"^base_url\s*=.*$", f'base_url = "{LEMONADE_URL}/api/v0"', body) + line = body + newline + out.append(line) + return "".join(out) + + +def camera_url(kind: str = "stream", **params) -> str: + query = "&".join(f"{k}={v}" for k, v in params.items()) + return f"http://localhost:{VIDEO_PORT}/{kind}?topic={CAMERA_TOPIC}&{query}" + + +def _camera_alive(timeout: int = 10) -> bool: + try: + r = requests.get(camera_url("snapshot", quality=20), timeout=timeout) + return r.ok and len(r.content) > 0 + except requests.RequestException: + return False + + +def _server_alive(timeout: int = 8) -> bool: + """Is web_video_server answering at all? + + Deliberately asks for the topic index rather than the camera: the camera + topic only exists once O3DE is up, which happens after this, while a wedged + server stops answering every request including this one. + """ + try: + return requests.get(f"http://localhost:{VIDEO_PORT}/", timeout=timeout).ok + except requests.RequestException: + return False + + +def _wait_for_server(deadline: float) -> bool: + """web_video_server takes a few seconds to bind after a restart, and + answers nothing at all until then.""" + while time.time() < deadline: + if _server_alive(): + return True + time.sleep(3) + return False + + +def start_infrastructure(timeout: int = 180) -> str: + """Bring up the headless display and the camera stream, and adopt $DISPLAY. + + O3DE is launched from this kernel later on, so the kernel itself needs the + DISPLAY that Xwayland picked - that is what the script reports back. + """ + proc = subprocess.run( + ["setsid", "bash", DEMO_SCRIPT, "--infra-only"], + cwd=RAI_DIR, + capture_output=True, + text=True, + timeout=timeout, + ) + if proc.returncode != 0: + raise RuntimeError(f"{DEMO_SCRIPT} --infra-only failed:\n{proc.stdout}\n{proc.stderr}") + + match = re.search(r"^DISPLAY=(\S+)$", proc.stdout, re.MULTILINE) + if not match: + raise RuntimeError(f"No display reported by the demo script:\n{proc.stdout}") + os.environ["DISPLAY"] = match.group(1) + + # Only that the server is up - the camera topic appears later, when the + # simulation starts publishing. The view waits for frames on its own. + if not _wait_for_server(time.time() + 60): + raise RuntimeError(f"web_video_server is not answering on :{VIDEO_PORT} - see /tmp/web_video_server.log") + return os.environ["DISPLAY"] + + +# --------------------------------------------------------------------------- +# Simulation + agent (ported from RAI's manipulation-demo-streamlit.py) +# --------------------------------------------------------------------------- + + +def launch_description() -> LaunchDescription: + launch_moveit = IncludeLaunchDescription( + PythonLaunchDescriptionSource( + [ + "src/examples/rai-manipulation-demo/Project/Examples/panda_moveit_config_demo.launch.py", + ] + ) + ) + launch_robotic_manipulation = Node( + package="robotic_manipulation", + executable="robotic_manipulation", + output="screen", + parameters=[{"use_sim_time": True}], + ) + launch_openset = IncludeLaunchDescription( + PythonLaunchDescriptionSource([FindPackageShare("rai_bringup"), "/launch/openset.launch.py"]), + # v1 tool names alongside v2's, so either agent version can run + launch_arguments={"enable_legacy_service_names": "true"}.items(), + ) + return LaunchDescription([launch_openset, launch_moveit, launch_robotic_manipulation]) + + +def _scenario_for(scenario_path: str) -> Scenario: + return Scenario( + task=None, + scene_config=SceneConfig.load_base_config(Path(scenario_path)), + scene_config_path=scenario_path, + ) + + +def initialize_o3de(scenario_path: str, agent_version: str = "v2"): + """Launch the simulation, the ROS 2 stack and the starting scene.""" + simulation_config = O3DExROS2SimulationConfig.load_config(config_path=Path(O3DE_CONFIG)) + if agent_version == "v2": + # v1 asks for the legacy perception service names, v2 for the new ones + renamed = { + "/grounding_dino_classify": "/detection", + "/grounded_sam_segment": "/segmentation", + } + services = simulation_config.required_robotic_ros2_interfaces["services"] + simulation_config.required_robotic_ros2_interfaces["services"] = [renamed.get(s, s) for s in services] + + scenario = _scenario_for(scenario_path) + o3de = O3DEngineArmManipulationBridge(ROS2Connector(executor_type="multi_threaded")) + o3de.init_simulation(simulation_config=simulation_config) + o3de.launch_robotic_stack( + required_robotic_ros2_interfaces=simulation_config.required_robotic_ros2_interfaces, + launch_description=launch_description(), + ) + o3de.setup_scene(scenario.scene_config) + return o3de, scenario + + +def setup_new_scene(o3de, scenario_path: str) -> Scenario: + scenario = _scenario_for(scenario_path) + o3de.setup_scene(scenario.scene_config) + return scenario + + +def available_layouts() -> dict: + """Layout label -> scene config path, for the layouts the demo ships with.""" + layouts = {} + for scenario in get_scenarios(levels=["medium", "hard", "very_hard"]): + stem = Path(scenario.scene_config_path).stem + if stem in SCENARIO_NAMES: + layouts[SCENARIO_NAMES[stem]] = scenario.scene_config_path + return layouts + + +# --------------------------------------------------------------------------- +# Widget UI +# --------------------------------------------------------------------------- + + +class _ToolCallLog(BaseCallbackHandler): + """Streams the agent's tool calls into an Output widget as they happen. + + Callbacks fire on the worker thread, so this writes with append_stdout - + "with output:" only captures on the thread that entered it. + """ + + def __init__(self, output: widgets.Output): + self.output = output + self._started = {} + + def on_tool_start(self, serialized, input_str, *, run_id=None, **kwargs): + name = (serialized or {}).get("name", "tool") + self._started[run_id] = (name, time.time()) + self.output.append_stdout(f" -> {name}({input_str})\n") + + def on_tool_end(self, output, *, run_id=None, **kwargs): + name, started = self._started.pop(run_id, ("tool", time.time())) + text = str(output).replace("\n", " ") + if len(text) > 200: + text = text[:200] + "..." + self.output.append_stdout(f" <- {name} [{time.time() - started:.1f}s] {text}\n") + + def on_tool_error(self, error, *, run_id=None, **kwargs): + name, _ = self._started.pop(run_id, ("tool", 0)) + self.output.append_stdout(f" !! {name} failed: {error}\n") + + +class ManipulationDemo: + """The Streamlit page's behaviour, as a widget tree in a notebook cell. + + Build it once, then call ``display()``. The camera view runs on its own + thread and each instruction runs on a worker thread, so the kernel stays + responsive while the arm moves. + """ + + def __init__(self, o3de, scenario, agent, camera_tool, fps: int = 6): + self.o3de = o3de + self.scenario = scenario + self.agent = agent + self.camera_tool = camera_tool + self.fps = fps + self.messages = [AIMessage(content=GREETING)] + self.layouts = available_layouts() + + self._stop = threading.Event() + self._camera_thread = None + self._worker = None + + self._build_widgets() + + # -- widgets ---------------------------------------------------------- + + def _build_widgets(self): + self.view = widgets.Image(format="jpeg", width=640, height=360) + self.status = widgets.HTML("Connecting to the simulation camera...") + + current = Path(self.scenario.scene_config_path).stem + self.layout_picker = widgets.Dropdown( + options=list(self.layouts), + value=SCENARIO_NAMES.get(current, next(iter(self.layouts), None)), + description="Layout:", + ) + self.layout_picker.observe(self._on_layout_change, names="value") + + self.reload_button = widgets.Button(description="Reload layout", tooltip="Rebuild the current scene") + self.reload_button.on_click(lambda _: self._change_layout(self.layout_picker.value)) + + self.clear_button = widgets.Button( + description="Clear history", + tooltip="Forget previous tool results so the agent re-checks the scene", + ) + self.clear_button.on_click(self._on_clear) + + # "overflow", not "overflow_y": ipywidgets 8 removed the per-axis traits, + # so overflow_y was silently dropped and the log never clipped. It kept a + # 360px box for layout while its content ran past it, painting over the + # input row underneath. With overflow set the log scrolls inside its box + # and the row below stays put. + self.log = widgets.Output( + layout=widgets.Layout( + height="360px", + overflow="auto", + border="1px solid #ddd", + padding="6px", + flex="0 0 auto", + ) + ) + self.entry = widgets.Text( + placeholder="Tell the arm what to do, then press Enter", + # flex rather than width=100%: at 100% the entry and the button add up + # to more than the row, which pushes Send out and scrolls the panel + layout=widgets.Layout(width="auto", flex="1 1 auto"), + ) + # on_submit is the only hook for the Enter key; ipywidgets 8 deprecates + # it without offering a replacement, so just keep the notice out of the + # cell output. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + self.entry.on_submit(self._on_submit) + + self.send_button = widgets.Button( + description="Send", + button_style="primary", + layout=widgets.Layout(width="90px", flex="0 0 auto", margin="0 0 0 6px"), + ) + self.send_button.on_click(lambda _: self._on_submit(self.entry)) + + self.log.append_stdout(f"assistant: {GREETING}\n\n") + + left = widgets.VBox( + [ + self.view, + self.status, + self.layout_picker, + widgets.HBox([self.reload_button, self.clear_button]), + ] + ) + # The margin is the gap the log must never close on: the log is a fixed + # 360px block and this row a fixed-height one after it, so the entry sits + # at the bottom of the panel no matter how much the agent logs. + self.input_row = widgets.HBox( + [self.entry, self.send_button], + layout=widgets.Layout( + width="100%", + margin="10px 0 0 0", + flex="0 0 auto", + align_items="center", + ), + ) + right = widgets.VBox( + [self.log, self.input_row], + layout=widgets.Layout(width="100%", overflow="hidden"), + ) + self.ui = widgets.HBox([left, right]) + + def display(self): + """Start the camera and show the UI. + + Returns nothing on purpose - returning self would make Jupyter echo the + repr under the widget. + """ + self._start_camera() + display(self.ui) + + # -- camera ----------------------------------------------------------- + + def _start_camera(self): + if self._camera_thread and self._camera_thread.is_alive(): + return + self._stop.clear() + self._camera_thread = threading.Thread(target=self._pump_frames, daemon=True) + self._camera_thread.start() + + def _pump_frames(self): + """One long-lived MJPEG connection, split on the JPEG markers. + + Polling /snapshot per frame instead would churn through subscribers and + eventually hang web_video_server. + """ + url = camera_url("stream", quality=70, width=640, height=360) + while not self._stop.is_set(): + try: + with requests.get(url, stream=True, timeout=30) as r: + buf, last = b"", 0.0 + for chunk in r.iter_content(8192): + if self._stop.is_set(): + return + buf += chunk + while True: + start, end = buf.find(b"\xff\xd8"), buf.find(b"\xff\xd9") + if start == -1 or end == -1 or end < start: + break + frame, buf = buf[start : end + 2], buf[end + 2 :] + now = time.time() + if now - last >= 1 / self.fps: + self.view.value = frame + if self.status.value: + self.status.value = "" + last = now + except requests.RequestException: + # The stream is empty until O3DE renders its first frame, and + # drops if web_video_server is restarted - reconnect either way. + self.status.value = "Waiting for the simulation camera..." + time.sleep(5) + + def stop(self): + """Stop the camera thread. The simulation keeps running.""" + self._stop.set() + + # -- chat ------------------------------------------------------------- + + def _busy(self) -> bool: + return self._worker is not None and self._worker.is_alive() + + def _set_enabled(self, enabled: bool): + for w in (self.entry, self.send_button, self.layout_picker, self.reload_button, self.clear_button): + w.disabled = not enabled + + def _on_submit(self, sender): + instruction = sender.value.strip() + if not instruction or self._busy(): + return + sender.value = "" + self._set_enabled(False) + self.log.append_stdout(f"you: {instruction}\n") + self._worker = threading.Thread(target=self._run_agent, args=(instruction,), daemon=True) + self._worker.start() + + def _run_agent(self, instruction: str): + started = time.time() + try: + # The agent starts from what the camera sees, like the chat panel does + _, artifact = self.camera_tool._run() + message = HumanMultimodalMessage(content=instruction, images=artifact.get("images", [])) + self.messages.append(message) + result = self.agent.invoke( + {"messages": self.messages}, + config=RunnableConfig( + { + "callbacks": [_ToolCallLog(self.log)], + "recursion_limit": 100, + } + ), + ) + self.messages = result["messages"] + reply = result["messages"][-1].content + self.log.append_stdout(f"assistant: {reply}\n({time.time() - started:.0f}s)\n\n") + except Exception: + self.log.append_stdout(f"error:\n{traceback.format_exc()}\n") + finally: + self._set_enabled(True) + + def _reset_history(self): + # Drops cached tool results, so the agent looks at the scene again + # instead of trusting an observation from a previous task + self.messages = [AIMessage(content=GREETING)] + self.log.outputs = () + self.log.append_stdout(f"assistant: {GREETING}\n\n") + + def _on_clear(self, _): + # Guard only the button: the scene worker calls _reset_history directly, + # since it is itself the thread _busy() would report. + if self._busy(): + return + self._reset_history() + + # -- scene ------------------------------------------------------------ + + def _on_layout_change(self, change): + self._change_layout(change["new"]) + + def _change_layout(self, label: str): + if self._busy(): + return + path = self.layouts.get(label) + if path is None: + return + self._set_enabled(False) + self.log.append_stdout(f"[scene] loading {label}...\n") + + def work(): + try: + self.o3de.clear_scene() + except Exception as exc: + self.log.append_stdout(f"[scene] could not clear: {exc}\n") + try: + self.scenario = setup_new_scene(self.o3de, path) + self._reset_history() + self.log.append_stdout(f"[scene] {label} ready\n\n") + except Exception: + self.log.append_stdout(f"[scene] failed:\n{traceback.format_exc()}\n") + finally: + self._set_enabled(True) + + self._worker = threading.Thread(target=work, daemon=True) + self._worker.start() + + +def build_demo( + layout: str = "3 Red Cubes", + agent_version: str = "v2", + model: str = DEFAULT_MODEL, + progress=print, + verbose: bool = False, +) -> ManipulationDemo: + """Everything the Streamlit page did at startup, in one call. + + Takes a few minutes: the scene has to render and the ROS 2 stack has to come + up before the agent can see anything. + + The launch is loud - MoveIt, O3DE and the perception services between them + print hundreds of lines. That goes to LAUNCH_LOG unless verbose is set, so + the cell shows the progress messages and then the demo. + """ + os.chdir(RAI_DIR) + hush = contextlib.nullcontext if verbose else quiet + + progress("Starting Lemonade and pointing RAI at it...") + ensure_lemonade(model) + + progress("Starting the headless display and camera stream...") + progress(f" display {start_infrastructure()}") + + layouts = available_layouts() + if layout not in layouts: + raise ValueError(f"Unknown layout {layout!r} - pick one of {list(layouts)}") + + progress("Launching the simulation and the ROS 2 stack (a few minutes)...") + if not verbose: + # Must happen before the launch: launch caches its screen handler the + # first time a process logs, and O3DE inherits fd 1/2 when it is spawned + quiet_ros_logging() + with hush(): + o3de, scenario = initialize_o3de(layouts[layout], agent_version=agent_version) + + progress("Building the agent...") + with hush(): + # Imported here rather than at module scope: manipulation_common reads + # RAI's config.toml, which ensure_lemonade has only just pointed at + # Lemonade. + sys.path.insert(0, str(RAI_DIR / "examples")) + from manipulation_common import create_agent + + agent, camera_tool = create_agent(version=agent_version) + + if not verbose: + progress(f" (launch output in {LAUNCH_LOG})") + progress("Ready.") + return ManipulationDemo(o3de, scenario, agent, camera_tool) diff --git a/projects/LocalInference/scripts/rai_toy_demo.py b/projects/LocalInference/scripts/rai_toy_demo.py new file mode 100644 index 00000000..823becce --- /dev/null +++ b/projects/LocalInference/scripts/rai_toy_demo.py @@ -0,0 +1,787 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Fast repository-evolution demo using a real RAI tool-calling agent. + +The candidate repository contains a system prompt and two LangChain tools. +HELIX/OpenCode may edit only those files. Evaluation runs a RAI conversational +agent against a tiny in-memory world, so one generation takes minutes rather +than the hours required by the O3DE benchmark. + +``RAI_TOY_MOCK=1`` is a static plumbing check and is always labeled as such. +It must not be presented as a live RAI result. +""" + +from __future__ import annotations + +import ast +import difflib +import json +import os +import shutil +import signal +import subprocess +import sys +import time +import types +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +SUPPORT_DIR = Path(__file__).resolve().parent +HELIX = Path(os.environ.get("HELIX_BIN", "/opt/capx-venv/bin/helix")) +RAI_PYTHON = Path(os.environ.get("RAI_PYTHON", "/opt/rai-venv/bin/python")) +DEFAULT_MODEL = os.environ.get("RAI_TOY_MODEL", "Gemma-4-E2B-it-GGUF") +DEFAULT_ENDPOINT = os.environ.get("RAI_TOY_MODEL_ENDPOINT", "http://127.0.0.1:13305/api/v1") +DEFAULT_ROOT = Path("/tmp/rai_toy_evolution/candidate") +DEFAULT_TIMEOUT = 600.0 +MOCK_LABEL = "MOCK_STATIC_CONTRACT_NOT_LIVE_RAI" + +SCENARIOS = { + "train-red-cube": { + "object": "red cube", + "initial_y": 0.35, + "target_y": -0.40, + "instruction": ( + "Move the red cube into the left target centered at y=-0.40. " + "Inspect the world first, then use the movement tool exactly once." + ), + }, + "val-blue-cylinder": { + "object": "blue cylinder", + "initial_y": 0.25, + "target_y": -0.30, + "instruction": ( + "Move the blue cylinder into the left target centered at y=-0.30. " + "Inspect the world first, then use the movement tool exactly once." + ), + }, + "test-green-cube": { + "object": "green cube", + "initial_y": 0.45, + "target_y": -0.50, + "instruction": ( + "Move the green cube into the left target centered at y=-0.50. " + "Inspect the world first, then use the movement tool exactly once." + ), + }, +} + +SEED_PROMPT = '''\ +"""System prompt for the toy RAI manipulation agent.""" + +SYSTEM_PROMPT = """ +You are a robot agent controlling objects on a tabletop. +Always inspect the world before moving anything. +Coordinate convention: positive y is LEFT and negative y is RIGHT. +Call move_object with the requested object's name and target y coordinate. +When the tool reports success, briefly report completion. +""" +''' + +SEED_TOOLS = '''\ +"""Mutable tools for the toy RAI manipulation agent.""" + +from typing import Any, List, Type + +from langchain_core.tools import BaseTool +from pydantic import BaseModel, Field + + +class ObserveWorldTool(BaseTool): + name: str = "observe_world" + description: str = "Return object positions and the tabletop coordinate convention." + world: Any = Field(exclude=True) + + def _run(self) -> str: + return self.world.observe() + + +class MoveObjectInput(BaseModel): + object_name: str = Field(description="Object name from observe_world") + target_y: float = Field(description="Requested destination y coordinate") + + +class MoveObjectTool(BaseTool): + name: str = "move_object" + description: str = "Move one named object to an exact target y coordinate." + args_schema: Type[MoveObjectInput] = MoveObjectInput + world: Any = Field(exclude=True) + + def _run(self, object_name: str, target_y: float) -> str: + # The seed wrapper mistakenly folds negative coordinates to positive. + normalized_y = abs(float(target_y)) + return self.world.move(object_name, normalized_y) + + +REQUIRED_TOOL_NAMES = {"observe_world", "move_object"} + + +def build_tools(world: Any) -> List[BaseTool]: + return [ObserveWorldTool(world=world), MoveObjectTool(world=world)] +''' + +CONTRACT = """\ +# Toy RAI evolution contract + +The repository controls a real RAI conversational agent in a deterministic, +in-memory tabletop world. Only `solver/prompt.py` and `solver/tools.py` may be +edited. + +The world uses the convention **negative y is left**. The seed has two related +defects: + +1. `solver/prompt.py` states the opposite coordinate convention. +2. `solver/tools.py` applies `abs()` to the requested target, making every + negative/left destination positive/right. + +Preserve: + +- a literal non-empty `SYSTEM_PROMPT` string; +- `build_tools(world) -> list[BaseTool]`; +- tool names `observe_world` and `move_object`; +- an observation before movement. + +Training and validation use different objects and coordinates. The final test +uses a third object that is never exposed to HELIX. +""" + +DEFAULT_OBJECTIVE = """\ +Repair the toy RAI policy so the agent moves objects to negative-y left targets. +Edit BOTH solver/prompt.py and solver/tools.py: state that negative y is LEFT, +and preserve the signed target coordinate instead of applying abs(). Keep the +two-tool contract and make the fix general across object names and coordinates.""" + +DEFAULT_BACKGROUND = """\ +This is a bounded workshop mutation of a real RAI tool-calling agent. Read +CONTRACT.md and evaluator feedback. Your first actions must edit both mutable +files: in solver/prompt.py correct the coordinate convention to say negative y +is LEFT and positive y is RIGHT; in solver/tools.py replace +normalized_y = abs(float(target_y)) with normalized_y = float(target_y). +Do not add shortcuts for scenario names. Do not edit protected files. Run +/opt/rai-venv/bin/python -m py_compile solver/*.py and +/opt/rai-venv/bin/python probe.py before finishing, then inspect git diff.""" + +PROBE_SOURCE = """\ +import os +import sys + +sys.path.insert( + 0, os.environ.get("RAI_TOY_SUPPORT_DIR", "/ryzers/notebooks/scripts") +) +from rai_toy_demo import evaluate_cli + +raise SystemExit(evaluate_cli()) +""" + + +@dataclass +class BoundedRun: + returncode: int + timed_out: bool + stdout: str + elapsed_seconds: float + + +class ToyWorld: + """Minimal stateful world injected into candidate tools.""" + + def __init__(self, scenario: Mapping[str, Any]) -> None: + self.object_name = str(scenario["object"]) + self.position_y = float(scenario["initial_y"]) + self.target_y = float(scenario["target_y"]) + self.events: list[dict[str, Any]] = [] + + @staticmethod + def _normalize_name(value: str) -> str: + return " ".join(value.lower().replace("_", " ").split()) + + def observe(self) -> str: + event = { + "tool": "observe_world", + "object": self.object_name, + "y": self.position_y, + } + self.events.append(event) + return ( + f"Detected {self.object_name} at y={self.position_y:+.2f}. " + "World convention: negative y is LEFT; positive y is RIGHT." + ) + + def move(self, object_name: str, target_y: float) -> str: + requested = float(target_y) + event = { + "tool": "move_object", + "object_name": object_name, + "target_y": requested, + } + self.events.append(event) + if self._normalize_name(object_name) != self._normalize_name(self.object_name): + event["accepted"] = False + return f"Unknown object {object_name!r}; no movement occurred." + if not -0.60 <= requested <= 0.60: + event["accepted"] = False + return f"Target y={requested:+.2f} is outside the workspace." + self.position_y = requested + event["accepted"] = True + return f"Moved {self.object_name} to y={requested:+.2f}." + + @property + def passed(self) -> bool: + return abs(self.position_y - self.target_y) <= 0.01 + + +def _mock_enabled() -> bool: + return os.environ.get("RAI_TOY_MOCK", "") == "1" + + +def _safe_reset(path: Path) -> None: + resolved = path.resolve() + if resolved == Path("/tmp") or Path("/tmp") not in resolved.parents: + raise ValueError(f"refusing to reset non-workshop path: {resolved}") + shutil.rmtree(resolved, ignore_errors=True) + + +def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "git", + "-c", + "user.name=RAI Toy Workshop", + "-c", + "user.email=rai-toy@localhost", + *args, + ], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + + +def _opencode_config(model: str, endpoint: str) -> dict[str, Any]: + return { + "$schema": "https://opencode.ai/config.json", + "model": f"lemonade/{model}", + "small_model": f"lemonade/{model}", + "agent": {"build": {"temperature": 0.0, "steps": 8}}, + "experimental": {"primary_tools": ["read", "edit", "bash"]}, + "provider": { + "lemonade": { + "npm": "@ai-sdk/openai-compatible", + "name": "Local Lemonade", + "options": {"baseURL": endpoint, "apiKey": "lemonade"}, + "models": { + model: { + "name": model, + "limit": {"context": 32768, "output": 4096}, + } + }, + } + }, + "permission": { + "*": "allow", + "edit": { + "*": "deny", + "solver/**": "allow", + "**/solver/**": "allow", + }, + "external_directory": "deny", + "webfetch": "deny", + "websearch": "deny", + "task": "deny", + "skill": "deny", + "todowrite": "deny", + "bash": { + "*": "deny", + "/opt/rai-venv/bin/python probe.py*": "allow", + "/opt/rai-venv/bin/python -m py_compile solver/*.py": "allow", + "git diff*": "allow", + "git status*": "allow", + }, + }, + } + + +def helix_config( + *, + model: str = DEFAULT_MODEL, + endpoint: str = DEFAULT_ENDPOINT, + generations: int = 1, + objective: str = DEFAULT_OBJECTIVE, + background: str = DEFAULT_BACKGROUND, +) -> str: + if generations not in {1, 2}: + raise ValueError("workshop generations must be 1 or 2") + if '"""' in objective or '"""' in background: + raise ValueError("HELIX text cannot contain TOML triple quotes") + return f'''\ +objective = """{objective}""" +seed = "." +rng_seed = 23 +passthrough_env = [ + "RAI_TOY_MOCK", + "RAI_TOY_MODEL", + "RAI_TOY_MODEL_ENDPOINT", + "RAI_TOY_SUPPORT_DIR", + "OPENAI_API_KEY", +] + +[env] +RAI_TOY_MODEL = "{model}" +RAI_TOY_MODEL_ENDPOINT = "{endpoint}" +RAI_TOY_SUPPORT_DIR = "/ryzers/notebooks/scripts" + +[evaluator] +command = "/opt/rai-venv/bin/python probe.py" +protected_files = [ + "probe.py", + "helix.toml", + "opencode.json", + "CONTRACT.md", + "scenarios.json", +] + +[dataset] +train_size = 1 +val_size = 1 + +[evolution] +max_generations = {generations} +max_evaluations = 8 +minibatch_size = 1 +max_workers = 1 +num_parallel_proposals = 1 +mutations_per_parent = 1 +merge_enabled = false +cache_evaluation = true +acceptance_criterion = "strict_improvement" +frontier_type = "instance" +perfect_score_threshold = 1.0 + +[agent] +backend = "opencode" +model = "lemonade/{model}" +max_turns = 8 +background = """{background}""" + +[sandbox] +enabled = false + +[worktree] +base_dir = ".helix/worktrees" +''' + + +def prepare_workshop( + root: Path | str = DEFAULT_ROOT, + *, + model: str = DEFAULT_MODEL, + endpoint: str = DEFAULT_ENDPOINT, + generations: int = 1, + reset: bool = True, +) -> Path: + """Create the disposable prompt+tools repository.""" + root = Path(root).expanduser().resolve() + if reset: + _safe_reset(root) + root.mkdir(parents=True, exist_ok=True) + solver = root / "solver" + solver.mkdir() + (solver / "__init__.py").write_text("") + (solver / "prompt.py").write_text(SEED_PROMPT) + (solver / "tools.py").write_text(SEED_TOOLS) + (root / "CONTRACT.md").write_text(CONTRACT) + (root / "scenarios.json").write_text( + json.dumps( + { + "splits": { + "train": ["train-red-cube"], + "val": ["val-blue-cylinder"], + }, + "scenarios": {task_id: SCENARIOS[task_id] for task_id in ("train-red-cube", "val-blue-cylinder")}, + "test_exposed_to_evolution": False, + }, + indent=2, + sort_keys=True, + ) + + "\n" + ) + (root / "probe.py").write_text(PROBE_SOURCE) + (root / "opencode.json").write_text(json.dumps(_opencode_config(model, endpoint), indent=2) + "\n") + (root / "helix.toml").write_text( + helix_config( + model=model, + endpoint=endpoint, + generations=generations, + ) + ) + (root / ".gitignore").write_text( + ".helix/\n.helix_artifacts/\n.helix_opencode_state/\n__pycache__/\n*.pyc\nhelix_batch.json\n" + ) + _git(root, "init", "-b", "main") + _git(root, "add", ".") + _git(root, "commit", "-m", "Seed toy RAI prompt and tools") + return root + + +def ensure_model(model: str = DEFAULT_MODEL, progress: Callable[[str], None] = print) -> None: + """Start Lemonade and load the one model used by RAI and OpenCode.""" + if _mock_enabled(): + progress(f"{MOCK_LABEL}: model startup skipped") + return + from capx_demo import ensure_lemonade + + ensure_lemonade(model, progress=progress) + + +def _literal_prompt(source: str) -> str: + tree = ast.parse(source, filename="solver/prompt.py") + for node in tree.body: + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + if any(isinstance(target, ast.Name) and target.id == "SYSTEM_PROMPT" for target in targets): + value = ast.literal_eval(node.value) + if isinstance(value, str) and value.strip(): + return value + raise ValueError("solver/prompt.py must define a literal non-empty SYSTEM_PROMPT") + + +def _mock_evaluate(task_id: str, prompt_source: str, tools_source: str) -> dict[str, Any]: + prompt = _literal_prompt(prompt_source).lower() + tree = ast.parse(tools_source, filename="solver/tools.py") + functions = {node.name for node in tree.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))} + signed_target = "abs(float(target_y))" not in tools_source + correct_prompt = "negative y is left" in prompt + markers = { + "literal_prompt": True, + "correct_coordinate_prompt": correct_prompt, + "signed_target_preserved": signed_target, + "build_tools": "build_tools" in functions, + "required_names": ("observe_world" in tools_source and "move_object" in tools_source), + } + weights = { + "literal_prompt": 0.10, + "correct_coordinate_prompt": 0.25, + "signed_target_preserved": 0.45, + "build_tools": 0.10, + "required_names": 0.10, + } + score = round(sum(weights[name] for name, passed in markers.items() if passed), 6) + return { + "score": score, + "passed": bool(correct_prompt and signed_target), + "side_info": { + "task_id": task_id, + "benchmark_kind": MOCK_LABEL, + "is_live_rai": False, + "mock": True, + "markers": markers, + "scores": {task_id: score}, + "tool_trace": [], + "error": None, + }, + } + + +def _live_evaluate(task_id: str, prompt_source: str, tools_source: str) -> dict[str, Any]: + scenario = SCENARIOS[task_id] + world = ToyWorld(scenario) + error: str | None = None + response = "" + started = time.monotonic() + try: + prompt = _literal_prompt(prompt_source) + prompt_correct = "negative y is left" in prompt.lower() + code = compile(tools_source, "solver/tools.py", "exec") + module_name = "rai_toy_candidate_tools" + module = types.ModuleType(module_name) + sys.modules[module_name] = module + exec(code, module.__dict__) + build_tools = getattr(module, "build_tools", None) + if not callable(build_tools): + raise ValueError("solver/tools.py must define build_tools(world)") + tools = build_tools(world) + names = {getattr(tool, "name", None) for tool in tools} + if names != {"observe_world", "move_object"}: + raise ValueError( + "build_tools must return exactly observe_world and move_object; " + f"got {sorted(str(name) for name in names)}" + ) + + from langchain_core.messages import HumanMessage + from langchain_core.runnables import RunnableConfig + from langchain_openai import ChatOpenAI + from rai.agents.langchain.core import create_conversational_agent + + llm = ChatOpenAI( + model=os.environ.get("RAI_TOY_MODEL", DEFAULT_MODEL), + base_url=os.environ.get("RAI_TOY_MODEL_ENDPOINT", DEFAULT_ENDPOINT), + api_key=os.environ.get("OPENAI_API_KEY", "lemonade"), + temperature=0.0, + max_retries=0, + timeout=90, + ) + agent = create_conversational_agent(llm, tools, prompt) + result = agent.invoke( + {"messages": [HumanMessage(content=str(scenario["instruction"]))]}, + config=RunnableConfig({"recursion_limit": 20}), + ) + messages = result.get("messages", []) + if messages: + response = str(getattr(messages[-1], "content", "")) + except Exception as exc: + error = f"{type(exc).__name__}: {exc}" + prompt_correct = False + + observed = any(event["tool"] == "observe_world" for event in world.events) + moved = any(event["tool"] == "move_object" for event in world.events) + negative_move = any(event["tool"] == "move_object" and float(event["target_y"]) < 0 for event in world.events) + task_passed = world.passed and error is None + passed = task_passed and prompt_correct + score = 1.0 if passed else (0.10 + 0.15 * observed + 0.15 * moved + 0.30 * negative_move + 0.20 * prompt_correct) + score = round(min(float(score), 1.0), 6) + return { + "score": score, + "passed": passed, + "side_info": { + "task_id": task_id, + "instruction": scenario["instruction"], + "target_y": scenario["target_y"], + "final_y": world.position_y, + "tool_trace": world.events, + "agent_response": response, + "error": error, + "wall_seconds": round(time.monotonic() - started, 3), + "benchmark_kind": "rai_toy_in_memory_manipulation", + "is_live_rai": True, + "mock": False, + "scores": { + "task_success": 1.0 if task_passed else 0.0, + "prompt_contract": 1.0 if prompt_correct else 0.0, + "observed": 1.0 if observed else 0.0, + "moved": 1.0 if moved else 0.0, + "negative_move": 1.0 if negative_move else 0.0, + }, + }, + } + + +def evaluate_task(root: Path, task_id: str) -> dict[str, Any]: + if task_id not in SCENARIOS: + raise ValueError(f"unknown task ID: {task_id}") + prompt_source = (root / "solver" / "prompt.py").read_text() + tools_source = (root / "solver" / "tools.py").read_text() + compile(prompt_source, "solver/prompt.py", "exec") + compile(tools_source, "solver/tools.py", "exec") + if _mock_enabled(): + return _mock_evaluate(task_id, prompt_source, tools_source) + return _live_evaluate(task_id, prompt_source, tools_source) + + +def _resolve_batch_ids(root: Path) -> list[str]: + manifest = json.loads((root / "scenarios.json").read_text()) + split = os.environ.get("HELIX_SPLIT", "train") + split_ids = list(manifest["splits"][split]) + batch_path = root / "helix_batch.json" + raw = json.loads(batch_path.read_text()) if batch_path.exists() else ["0"] + resolved = [] + for item in raw: + text = str(item) + if text in SCENARIOS: + resolved.append(text) + else: + resolved.append(split_ids[int(text)]) + return resolved + + +def evaluate_cli(argv: Sequence[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + root = Path.cwd().resolve() + if len(argv) == 2 and argv[0] == "--task": + result = evaluate_task(root, argv[1]) + print("RAI_TOY_RESULT=" + json.dumps(result, separators=(",", ":"))) + return 0 + payload = [] + for task_id in _resolve_batch_ids(root): + result = evaluate_task(root, task_id) + side_info = dict(result["side_info"]) + side_info["passed"] = result["passed"] + payload.append([float(result["score"]), side_info]) + print("HELIX_RESULT=" + json.dumps(payload, separators=(",", ":"))) + return 0 + + +def run_bounded( + command: Sequence[str], + *, + cwd: Path, + timeout_seconds: float, + env: Mapping[str, str] | None = None, + progress: Callable[[str], None] | None = None, +) -> BoundedRun: + started = time.monotonic() + process = subprocess.Popen( + list(command), + cwd=cwd, + env=dict(env or os.environ), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + chunks: list[str] = [] + timed_out = False + try: + output, _ = process.communicate(timeout=timeout_seconds) + chunks.append(output or "") + except subprocess.TimeoutExpired: + timed_out = True + os.killpg(process.pid, signal.SIGTERM) + try: + output, _ = process.communicate(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + output, _ = process.communicate() + chunks.append(output or "") + stdout = "".join(chunks) + if progress is not None and stdout: + for line in stdout.splitlines(): + progress(line) + return BoundedRun( + returncode=124 if timed_out else int(process.returncode), + timed_out=timed_out, + stdout=stdout, + elapsed_seconds=time.monotonic() - started, + ) + + +def _evaluator_python() -> str: + if _mock_enabled() or not RAI_PYTHON.exists(): + return sys.executable + return str(RAI_PYTHON) + + +def score_candidate( + root: Path | str, + task_id: str = "test-green-cube", + *, + timeout_seconds: float = 120.0, +) -> dict[str, Any]: + """Evaluate one explicit toy scenario in a bounded subprocess.""" + root = Path(root).resolve() + completed = run_bounded( + [_evaluator_python(), "probe.py", "--task", task_id], + cwd=root, + timeout_seconds=timeout_seconds, + env={ + **os.environ, + "RAI_TOY_SUPPORT_DIR": str(SUPPORT_DIR), + }, + ) + if completed.timed_out: + raise TimeoutError(f"RAI toy evaluation exceeded {timeout_seconds}s") + if completed.returncode != 0: + raise RuntimeError(completed.stdout) + marker = "RAI_TOY_RESULT=" + lines = [line for line in completed.stdout.splitlines() if line.startswith(marker)] + if len(lines) != 1: + raise RuntimeError(f"missing {marker} in evaluator output:\n{completed.stdout}") + return json.loads(lines[0][len(marker) :]) + + +def run_helix( + root: Path | str = DEFAULT_ROOT, + *, + generations: int = 1, + timeout_seconds: float = DEFAULT_TIMEOUT, + progress: Callable[[str], None] = print, +) -> BoundedRun: + root = Path(root).resolve() + command = [ + str(HELIX if HELIX.exists() else Path("helix")), + "evolve", + "--dir", + str(root), + "--config", + "helix.toml", + "--generations", + str(generations), + "--no-merge", + ] + return run_bounded( + command, + cwd=root, + timeout_seconds=timeout_seconds, + env=os.environ.copy(), + progress=progress, + ) + + +def source_diff(before: Path | str, after: Path | str) -> str: + before, after = Path(before), Path(after) + chunks: list[str] = [] + for name in ("prompt.py", "tools.py"): + old = (before / "solver" / name).read_text().splitlines(keepends=True) + new = (after / "solver" / name).read_text().splitlines(keepends=True) + chunks.extend( + difflib.unified_diff( + old, + new, + fromfile=f"seed/solver/{name}", + tofile=f"best/solver/{name}", + ) + ) + return "".join(chunks) + + +def export_best(root: Path | str = DEFAULT_ROOT) -> Path: + root = Path(root).resolve() + destination = root.parent / "live_best" + _safe_reset(destination) + completed = subprocess.run( + [ + str(HELIX if HELIX.exists() else Path("helix")), + "best", + "--dir", + str(root), + "--export", + str(destination), + ], + cwd=root, + capture_output=True, + text=True, + ) + return destination if completed.returncode == 0 and destination.is_dir() else root + + +def summarize_run(root: Path | str = DEFAULT_ROOT) -> dict[str, Any]: + root = Path(root).resolve() + best = export_best(root) + difference = source_diff(root, best) + state_path = root / ".helix" / "state.json" + state = json.loads(state_path.read_text()) if state_path.exists() else {} + return { + "accepted": bool(difference.strip()), + "improved_best": bool(difference.strip()), + "best": str(best), + "diff": difference, + "frontier": state.get("frontier", []), + "prompt": (best / "solver" / "prompt.py").read_text(), + "tools": (best / "solver" / "tools.py").read_text(), + } + + +def _main(argv: Sequence[str]) -> int: + if argv and argv[0] == "prepare": + print(prepare_workshop()) + return 0 + if argv and argv[0] == "evaluate": + return evaluate_cli(argv[1:]) + print("usage: rai_toy_demo.py {prepare|evaluate --task TASK_ID}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(_main(sys.argv[1:])) diff --git a/projects/LocalInference/scripts/rho_analyze.py b/projects/LocalInference/scripts/rho_analyze.py new file mode 100644 index 00000000..d4cb3d36 --- /dev/null +++ b/projects/LocalInference/scripts/rho_analyze.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Build a compact summary from recorded RHO study reports.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--recorded-root", type=Path, required=True) + parser.add_argument("--capx-analysis", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + return parser.parse_args() + + +def compact_single(path: Path) -> dict: + report = json.loads(path.read_text()) + return { + "id": path.parent.name, + "model": report["model"], + "task": report.get("task", "cube-stack"), + "surface": report["surface"], + "generations": report["generations"], + "artifact": report.get("artifact"), + "provenance": report.get("provenance", {}), + "accepted": bool(report["summary"]["accepted"]), + "before_reward": float(report["before"]["val"]["reward"]), + "after_reward": float(report["after"]["val"]["reward"]), + "before_completed": bool(report["before"]["val"]["task_completed"]), + "after_completed": bool(report["after"]["val"]["task_completed"]), + "helix_seconds": float(report["helix"]["elapsed_seconds"]), + "semantic_mutation": report["summary"]["semantic_mutation"], + } + + +def main() -> int: + args = parse_args() + root = args.recorded_root.expanduser().resolve() + singles = [ + compact_single(path) + for path in sorted((root / "rho_single").glob("*/report.json")) + ] + multi = json.loads((root / "rho_multitask_report.json").read_text()) + capx = json.loads(args.capx_analysis.read_text()) + + preflights = [ + run + for run in singles + if run["task"] == "cube-stack" and run["surface"] == "single-policy" + ] + restack_runs = [ + run + for run in singles + if run["task"] == "cube-restack" + ] + identities = { + ( + run["provenance"].get("image_id"), + run["provenance"].get("source_revision"), + ) + for run in preflights + } + qwen_runs = [ + run + for run in singles + if ( + run["task"] == "cube-stack" + and "Qwen3-Coder-30B-A3B" in run["model"] + ) + ] + criterion = multi["success_criterion"] + restack_depths = [ + int(run["id"].rsplit("depth", 1)[1]) + for run in restack_runs + if run["id"].rsplit("depth", 1)[-1].isdigit() + ] + restack = { + **capx["cube_restack_viability"], + "mutation_depth_run": max(restack_depths, default=0), + "mutation_runs": restack_runs, + } + if restack_runs: + restack["decision"] = ( + "mutation reached task completion" + if any(run["after_completed"] for run in restack_runs) + else "mutation remained below task completion" + ) + report = { + "schema_version": "rho-overnight-analysis/v1", + "agent_preflights_comparable": len(identities) == 1, + "agent_preflights": preflights, + "qwen_surface_study": { + "single_task_runs": qwen_runs, + "multi_task": { + "model": multi["mutation_model_loader_alias"], + "surface": "multi-task repository", + "generations": multi["generations"], + "selected_candidate": multi["selected_candidate"], + "completed_before": criterion["completed_before"], + "completed_after": criterion["completed_after"], + "completion_rate_before": criterion["completion_rate_before"], + "completion_rate_after": criterion["completion_rate_after"], + "mean_reward_before": criterion["mean_reward_before"], + "mean_reward_after": criterion["mean_reward_after"], + "criterion_met": criterion["met"], + "evolution_seconds": multi["timing"]["evolution_seconds"], + }, + }, + "cube_restack": restack, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, indent=2) + "\n") + print(args.output) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/projects/LocalInference/scripts/rho_demo.py b/projects/LocalInference/scripts/rho_demo.py new file mode 100644 index 00000000..af1a155f --- /dev/null +++ b/projects/LocalInference/scripts/rho_demo.py @@ -0,0 +1,1546 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Bounded HELIX/CaP-X plumbing for the RHO workshop notebook.""" + +from __future__ import annotations + +import atexit +import difflib +import io +import json +import os +import queue +import re +import shutil +import signal +import socket +import subprocess +import sys +import threading +import time +import traceback +from collections.abc import Callable, Mapping +from contextlib import redirect_stderr, redirect_stdout, suppress +from dataclasses import asdict, dataclass +from html import escape +from pathlib import Path +from typing import Any + +os.environ.setdefault("MUJOCO_GL", "egl") +os.environ.setdefault("PYOPENGL_PLATFORM", "egl") + +CAPX_ROOT = Path(os.environ.get("CAPX_ROOT", "/ryzers/cap-x")) +CAPX_PYTHON = Path(os.environ.get("CAPX_PYTHON", "/opt/capx-venv/bin/python")) +HELIX = Path(os.environ.get("HELIX_BIN", "/opt/capx-venv/bin/helix")) +CAPX_MODEL = "Gemma-4-E4B-it-GGUF" +DEFAULT_RHO_MODEL = "Gemma-4-E2B-it-GGUF" +MODEL = os.environ.get("RHO_MODEL", DEFAULT_RHO_MODEL) +MODEL_API_ID = MODEL.removeprefix("user.") +CONFIG_PATH = os.environ.get( + "RHO_CONFIG_PATH", + "env_configs/cube_stack/franka_robosuite_cube_stack.yaml", +) +WORKSHOP_ROOT = Path(os.environ.get("RHO_WORKSHOP_ROOT", "/tmp/rho_workshop")) +CANDIDATE_ROOT = WORKSHOP_ROOT / "candidate" +VIDEO_ROOT = Path(os.environ.get("RHO_VIDEO_ROOT", str(WORKSHOP_ROOT / "videos"))) +SERVICE_PORTS = (8113, 8115, 8116, 8117) +DEFAULT_TIMEOUT = 480 +EVALUATION_TIMEOUT = 120 + +_OWNED_SERVERS: list[subprocess.Popen[Any]] = [] +LAST_SERVICE_TIMING: dict[str, float] = {} + + +DEFAULT_PROGRAM = """\ +# Code block 0 +import numpy + +# --- 1. Get object poses and extents --- + +# Red cube data +red_pose, red_quat, red_extent = get_object_pose("red cube", return_bbox_extent=True) +# Green cube data +green_pose, _, green_extent = get_object_pose("green cube", return_bbox_extent=True) + +# --- 2. Sample grasp pose for red cube --- +red_grasp_position, red_grasp_quat = sample_grasp_pose("red cube") + +# --- 3. Approach and grasp the red cube --- +print("Approaching and grasping red cube...") +goto_pose(red_grasp_position, red_grasp_quat, z_approach=0.1) +close_gripper() + +# --- 4. Lift the red cube to a safe height --- +# Calculate lift position: original position + 0.2m in Z +lift_position = red_grasp_position.copy() +lift_position[2] += 0.2 +print("Lifting red cube to safe height...") +# Use z_approach=0.0 since we are actively moving the lifted object away from the initial grasp point +goto_pose(lift_position, red_grasp_quat, z_approach=0.0) + +# --- 5. Calculate the target placement pose on the green cube --- + +# Green cube center Z coordinate +green_center_z = green_pose[0][2] +# Half height of green cube +green_half_height = green_extent[2] / 2 +# Half height of red cube +red_half_height = red_extent[2] / 2 + +# Calculate stacking height +place_z = green_center_z + green_half_height + red_half_height + +# Target position (X, Y matches green cube center, Z is stacking height) +placement_position = numpy.array([green_pose[0][0], green_pose[0][1], place_z]) + +# --- 6. Approach and place the red cube --- +print("Moving to placement location on green cube...") +# Approach using z_approach=0.1 for controlled descent +goto_pose(placement_position, red_grasp_quat, z_approach=0.1) + +# Release the cube +print("Releasing red cube.") +open_gripper() + +# Optional: Move to a safe final pose if needed, but the task is complete. +# home_pose() +print("Task completed: Red cube stacked on green cube.") +""" + +DEFAULT_PROVENANCE: dict[str, Any] = { + "source": "recorded_capx_generation", + "artifact": None, + "model": CAPX_MODEL, + "trial": 1, + "recorded_reward": 0.7243017351331602, + "recorded_task_completed": False, + "source_run": "gemma-e4b-five-trials-20260821", + "recorded_bug": ( + "green_pose is already the XYZ position, but the generated program " + "indexes each scalar as though the position were nested." + ), +} + +POLICY_SOURCE = """\ +from pathlib import Path + + +def build_program() -> str: + return Path(__file__).with_name("program.py").read_text() +""" + +API_REFERENCE = """\ +# CaP-X cube-stack contract + +Repair the recorded CaP-X generated program that should stack the red cube on +the green cube. Preserve its overall approach and correct the runtime failure +using the API contract. + +- `sample_grasp_pose("red cube")` returns a grasp position and a reliable + gripper quaternion. +- `get_object_pose(name, return_bbox_extent=True)` returns center position, + quaternion, and full bounding-box side lengths. +- `goto_pose(position, quaternion, z_approach=0.1)` executes an approach and + target motion; use a non-zero approach for grasping and placement. +- Lift the grasped cube far enough to clear the table and the target cube. +- The placement center height is both cubes' half-heights above the green + center. Reuse the grasp quaternion for placement. +- `close_gripper()` and `open_gripper()` actuate the gripper. + +The evaluator runs the artifact's recorded trial for training and a separately +configured held-out trial for validation. +Only edit files below `solver/`. +""" + +OPENCODE_CONFIG = { + "$schema": "https://opencode.ai/config.json", + "model": f"lemonade/{MODEL_API_ID}", + "small_model": f"lemonade/{MODEL_API_ID}", + "agent": {"build": {"temperature": 0.0, "steps": 8}}, + "experimental": {"primary_tools": ["read", "edit", "bash"]}, + "provider": { + "lemonade": { + "npm": "@ai-sdk/openai-compatible", + "name": "Local Lemonade", + "options": { + "baseURL": "http://127.0.0.1:13305/api/v1", + "apiKey": "lemonade", + }, + "models": { + MODEL_API_ID: { + "name": MODEL_API_ID, + "tool_call": True, + "limit": {"context": 32768, "output": 4096}, + } + }, + } + }, + "permission": { + "*": "allow", + # OpenCode evaluates the last matching rule and tool paths are + # workspace-relative, so keep the broad deny first and allow solver/. + "edit": { + "*": "deny", + "solver/**": "allow", + "**/solver/**": "allow", + }, + "external_directory": "deny", + "webfetch": "deny", + "websearch": "deny", + "task": "deny", + "skill": "deny", + "todowrite": "deny", + "bash": { + "*": "deny", + "RHO_EVAL_ORIGIN=agent-self-check /opt/capx-venv/bin/python probe.py*": "allow", + "/opt/capx-venv/bin/python -m py_compile solver/*.py": "allow", + "git diff*": "allow", + "git status*": "allow", + }, + }, +} + +PROBE_SOURCE = """\ +import os +import sys + +sys.path.insert( + 0, os.environ.get("RHO_SUPPORT_ROOT", "/ryzers/notebooks/scripts") +) +from rho_demo import evaluate_cli + +raise SystemExit(evaluate_cli()) +""" + +DEFAULT_OBJECTIVE = """\ +Repair this authentic CaP-X generated program so it reliably stacks the red +cube on the green cube. Diagnose the recorded indexing failure, inspect +API_REFERENCE.md, and edit solver/program.py. get_object_pose returns a flat +XYZ vector: replace green_pose[0][2], green_pose[0][0], and green_pose[0][1] +with direct green_pose indexing. Keep the policy concise.""" + +DEFAULT_BACKGROUND = """\ +This is a bounded workshop mutation. Read API_REFERENCE.md and the evaluator +diagnostics first. Your first mutation action MUST be an `edit` tool call on +solver/program.py: change green_pose[0][2] to green_pose[2], +green_pose[0][0] to green_pose[0], and green_pose[0][1] to green_pose[1]. +Do not call skills, todo tools, or repeatedly re-read the same code. Inspect +`git diff` after the edit. Do not alter evaluation, configuration, permissions, +or files outside this repository. Run +`/opt/capx-venv/bin/python -m py_compile solver/*.py` and +`RHO_EVAL_ORIGIN=agent-self-check /opt/capx-venv/bin/python probe.py` +before finishing.""" + + +def helix_config( + generations: int = 1, + *, + objective: str = DEFAULT_OBJECTIVE, + background: str = DEFAULT_BACKGROUND, +) -> str: + if not 1 <= generations <= 4: + raise ValueError("workshop generations must be between 1 and 4") + if '"""' in objective or '"""' in background: + raise ValueError("HELIX prompts cannot contain TOML triple quotes") + max_evaluations = max(8, 2 + 3 * generations) + return f'''\ +objective = """{objective}""" +seed = "." +rng_seed = 7 +passthrough_env = [ + "CUDA_VISIBLE_DEVICES", + "HIP_VISIBLE_DEVICES", + "ROCR_VISIBLE_DEVICES", + "HSA_OVERRIDE_GFX_VERSION", + "LD_LIBRARY_PATH", + "HF_HOME", + "MUJOCO_GL", + "PYOPENGL_PLATFORM", + "CAPX_ROOT", + "RHO_CONFIG_PATH", + "RHO_PROGRESS_FILE", + "RHO_SUPPORT_ROOT", + "XDG_RUNTIME_DIR", + "RHO_MOCK_EVAL", +] + +[env] +CAPX_ROOT = "/ryzers/cap-x" +HF_HOME = "/opt/capx-cache" +MUJOCO_GL = "egl" +PYOPENGL_PLATFORM = "egl" +RHO_EVAL_TIMEOUT = "120" + +[evaluator] +command = "/opt/capx-venv/bin/python probe.py" +protected_files = [ + "probe.py", + "helix.toml", + "opencode.json", + "API_REFERENCE.md", + "provenance.json", +] + +[dataset] +train_size = 1 +val_size = 1 + +[evolution] +max_generations = {generations} +perfect_score_threshold = 1.0 +max_evaluations = {max_evaluations} +merge_enabled = false +num_parallel_proposals = 1 +mutations_per_parent = 1 +minibatch_size = 1 +max_workers = 1 +cache_evaluation = true +acceptance_criterion = "strict_improvement" +frontier_type = "instance" + +[agent] +backend = "opencode" +model = "lemonade/{MODEL_API_ID}" +max_turns = 8 +background = """{background}""" + +[sandbox] +enabled = false + +[worktree] +base_dir = ".helix/worktrees" +''' + + +@dataclass +class BoundedRun: + returncode: int + timed_out: bool + stdout: str + elapsed_seconds: float + + +class _NotebookHelixProgress: + """Collapse Rich frames and evaluator events into one notebook status.""" + + _ansi = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + + def __init__(self, evaluation_log: Path) -> None: + from IPython.display import HTML, display + + self._HTML = HTML + self._handle = display(HTML("HELIX: starting…"), display_id=True) + self._generation = "0/1" + self._phase = "Initializing" + self._phase_started = time.monotonic() + self._evaluations = 0 + self._max_evaluations = 8 + self._evaluation_log = evaluation_log + self._evaluation_log_offset = 0 + self._active_evaluation: dict[str, Any] | None = None + self._evaluation_history: list[dict[str, Any]] = [] + self._helix_evaluations = 0 + self._agent_self_checks = 0 + self._last_tick_second = -1 + self._last_html = "" + self._render() + + def _render( + self, + message: str | None = None, + *, + color: str = "#2563eb", + complete: bool = False, + ) -> None: + detail = escape(message or self._phase) + evaluation_details = "" + if self._evaluation_history: + rows = [] + for result in self._evaluation_history[-3:]: + solved = bool(result.get("task_completed")) + status = "solved" if solved else "not solved" + status_color = "#15803d" if solved else "#b45309" + task = ( + f"{escape(str(result['task']))} · " + if result.get("task") + else "" + ) + rows.append( + f' ' + f"{escape(str(result['_display_label']))}: " + f"{task}" + f"{escape(str(result.get('split', 'train')))} " + f"trial {escape(str(result.get('trial', '?')))} · " + f"reward {float(result.get('reward', 0.0)):.3f} · {status} · " + f"{float(result.get('elapsed_seconds') or 0.0):.1f}s" + ) + evaluation_details = ( + '
' + + "
".join(rows) + + "
" + ) + if complete: + progress = ( + ' ' + f"{self._evaluations} HELIX evaluations used " + f"({self._max_evaluations} maximum)" + ) + else: + progress = ( + f' ' + f"{self._evaluations} HELIX evaluations used " + f"({self._max_evaluations} maximum)" + ) + markup = ( + f"HELIX generation {escape(self._generation)} — {detail}
" + f"{progress}{evaluation_details}" + ) + if color != "#2563eb": + markup = f'{markup}' + if markup != self._last_html: + if self._handle is not None: + self._handle.update(self._HTML(markup)) + self._last_html = markup + + def _poll_evaluations(self) -> str | None: + try: + with self._evaluation_log.open(encoding="utf-8") as stream: + stream.seek(self._evaluation_log_offset) + lines = stream.readlines() + self._evaluation_log_offset = stream.tell() + except (FileNotFoundError, OSError): + return None + + message = None + for line in lines: + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("event") == "started": + self._active_evaluation = event + self._last_tick_second = -1 + elif event.get("event") == "completed": + self._active_evaluation = None + if event.get("origin") == "agent-self-check": + self._agent_self_checks += 1 + event["_display_label"] = ( + f"Agent self-check {self._agent_self_checks}" + ) + else: + self._helix_evaluations += 1 + event["_display_label"] = ( + f"HELIX eval {self._helix_evaluations}" + ) + self._evaluations = max( + self._evaluations, self._helix_evaluations + ) + self._evaluation_history.append(event) + outcome = "solved" if event.get("task_completed") else "not solved" + message = ( + f"{event['_display_label']} complete · " + f"reward {float(event.get('reward', 0.0)):.3f} · {outcome}" + ) + return message + + def tick(self) -> None: + evaluation_message = self._poll_evaluations() + if evaluation_message: + self._render(evaluation_message) + return + + if self._active_evaluation is not None: + elapsed = max( + 0, int(time.time() - float(self._active_evaluation["started_at"])) + ) + if elapsed != self._last_tick_second: + self._last_tick_second = elapsed + if self._active_evaluation.get("origin") == "agent-self-check": + label = f"Agent self-check {self._agent_self_checks + 1}" + else: + label = f"HELIX eval {self._helix_evaluations + 1}" + task = ( + f"{self._active_evaluation['task']} · " + if self._active_evaluation.get("task") + else "" + ) + self._render( + f"{label} running · " + f"{task}" + f"{self._active_evaluation.get('split', 'train')} " + f"trial {self._active_evaluation.get('trial', '?')} · " + f"{elapsed}s elapsed" + ) + return + + elapsed = int(time.monotonic() - self._phase_started) + if elapsed != self._last_tick_second: + self._last_tick_second = elapsed + self._render(f"{self._phase} · {elapsed}s elapsed") + + def __call__(self, raw_line: str) -> None: + evaluation_message = self._poll_evaluations() + line = self._ansi.sub("", raw_line).replace("\r", "\n").splitlines()[-1:] + if not line: + if evaluation_message: + self._render(evaluation_message) + return + text = line[0].strip() + + generation = re.search(r"Generation\s+(\d+)\s*/\s*(\d+)", text) + if generation: + self._generation = f"{generation.group(1)}/{generation.group(2)}" + + phase = re.search(r"Status:\s*([^│]+)", text) + if phase and self._active_evaluation is None: + new_phase = phase.group(1).strip() + if new_phase != self._phase: + self._phase = new_phase + self._phase_started = time.monotonic() + self._last_tick_second = -1 + + budget = re.search(r"(\d+)/(\d+)\s+evals", text) + if budget: + self._evaluations = int(budget.group(1)) + self._max_evaluations = int(budget.group(2)) + + message = None + for prefix in ( + "Creating seed worktree", + "Evaluating seed", + "Seed evaluated", + "Minibatch gate", + "Evolution complete", + ): + if prefix in text: + message = text[text.index(prefix) :].strip() + break + if evaluation_message: + self._render(evaluation_message) + elif self._active_evaluation is not None: + # Rich redraws its whole terminal frame while an evaluation runs. + # Keep the side-channel evaluation status authoritative instead of + # alternating it with stale "Applying mutation" frame lines. + self.tick() + elif message is not None: + self._render(message) + else: + # Keep the elapsed-time rendering authoritative between meaningful + # HELIX events. Rich redraws otherwise remove and restore the + # seconds suffix several times per second. + self.tick() + + def finish(self, result: BoundedRun) -> None: + self._poll_evaluations() + if result.returncode == 0: + self._render( + f"complete in {result.elapsed_seconds:.1f}s", + color="#15803d", + complete=True, + ) + elif result.timed_out: + self._render( + f"stopped at the {result.elapsed_seconds:.0f}s deadline", + color="#b45309", + ) + else: + self._render(f"failed (exit {result.returncode})", color="#b91c1c") + + +def _notebook_progress(evaluation_log: Path) -> _NotebookHelixProgress | None: + try: + from IPython import get_ipython + + shell = get_ipython() + if shell is not None and shell.__class__.__name__ == "ZMQInteractiveShell": + return _NotebookHelixProgress(evaluation_log) + except Exception: + pass + return None + + +def _safe_reset(path: Path) -> None: + resolved = path.resolve() + if not resolved.exists(): + return + temporary_root = Path("/tmp").resolve() + if resolved == temporary_root or temporary_root not in resolved.parents: + raise ValueError(f"refusing to remove non-workshop path: {resolved}") + shutil.rmtree(resolved) + + +def _write_candidate(root: Path, program: str) -> None: + (root / "solver").mkdir(parents=True, exist_ok=True) + (root / "solver" / "__init__.py").write_text("") + (root / "solver" / "geometry.py").write_text( + "# Compatibility placeholder: the authentic policy lives in program.py.\n" + ) + (root / "solver" / "program.py").write_text(program) + (root / "solver" / "policy.py").write_text(POLICY_SOURCE) + + +def _provenance_metadata( + provenance: Mapping[str, Any] | Path | str | None, +) -> dict[str, Any]: + if provenance is None: + return {} + if isinstance(provenance, Mapping): + return dict(provenance) + loaded = json.loads(Path(provenance).read_text()) + if not isinstance(loaded, dict): + raise ValueError("provenance JSON must contain an object") + return loaded + + +def _artifact_program( + artifact: Path | str | None, + provenance: Mapping[str, Any] | Path | str | None, +) -> tuple[str, dict[str, Any], Path | None]: + metadata = _provenance_metadata(provenance) + if artifact is None: + recorded = dict(DEFAULT_PROVENANCE) + recorded.update(metadata) + return DEFAULT_PROGRAM, recorded, None + + artifact_path = Path(artifact).expanduser().resolve() + if not artifact_path.exists(): + raise FileNotFoundError(f"CaP-X artifact does not exist: {artifact_path}") + code_path = artifact_path + if artifact_path.is_dir(): + embedded = artifact_path / "provenance.json" + if embedded.exists(): + embedded_metadata = _provenance_metadata(embedded) + embedded_metadata.update(metadata) + metadata = embedded_metadata + + direct = [path for path in (artifact_path / "code.py", artifact_path / "program.py") if path.is_file()] + candidates = direct or sorted(artifact_path.glob("trial_*/code.py")) + if not candidates: + candidates = sorted(artifact_path.rglob("code.py")) + if len(candidates) > 1: + selected_trial = int( + next( + (metadata[key] for key in ("trial", "artifact_trial", "training_trial") if key in metadata), + 1, + ) + ) + matching = [ + path + for path in candidates + if any(re.search(rf"(?:^|_)trial_0*{selected_trial}(?:_|$)", part) for part in path.parts) + ] + if len(matching) == 1: + candidates = matching + if len(candidates) != 1: + raise ValueError( + f"artifact directory must identify exactly one CaP-X code.py or program.py; found {len(candidates)}" + ) + code_path = candidates[0] + if not code_path.is_file(): + raise ValueError(f"CaP-X code path is not a file: {code_path}") + + metadata.setdefault("source", "capx_artifact") + metadata["artifact"] = str(artifact_path) + metadata["code_path"] = str(code_path) + return code_path.read_text(), metadata, code_path + + +def _artifact_trial(metadata: Mapping[str, Any], code_path: Path | None) -> int: + evaluation = metadata.get("evaluation") + nested_evaluation = evaluation if isinstance(evaluation, Mapping) else {} + source = metadata.get("provenance") + nested_provenance = source if isinstance(source, Mapping) else {} + declared = next( + ( + value + for value in ( + metadata.get("trial"), + metadata.get("artifact_trial"), + metadata.get("training_trial"), + nested_evaluation.get("trial"), + nested_provenance.get("source_trial"), + ) + if value is not None + ), + None, + ) + inferred: int | None = None + if code_path is not None: + for part in reversed(code_path.parts): + match = re.search(r"(?:^|_)trial_(\d+)(?:_|$)", part) + if match: + inferred = int(match.group(1)) + break + trial = int(declared if declared is not None else inferred or 1) + if inferred is not None and declared is not None and trial != inferred: + raise ValueError(f"provenance trial {trial} does not match artifact trial {inferred}") + if trial < 0: + raise ValueError("trial identifiers must be non-negative") + return trial + + +def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-c", "user.name=RHO Workshop", "-c", "user.email=rho@localhost", *args], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + + +def prepare_workshop( + root: Path | str = CANDIDATE_ROOT, + *, + artifact: Path | str | None = None, + provenance: Mapping[str, Any] | Path | str | None = None, + heldout_trial: int = 2, + generations: int = 1, + api_reference: str = API_REFERENCE, + objective: str = DEFAULT_OBJECTIVE, + background: str = DEFAULT_BACKGROUND, + support_files: Mapping[str, str] | None = None, + reset: bool = True, +) -> Path: + """Create a disposable repository around verbatim CaP-X generated code.""" + if not 1 <= generations <= 4: + raise ValueError("workshop generations must be between 1 and 4") + if heldout_trial < 0: + raise ValueError("held-out trial must be non-negative") + root = Path(root).expanduser().resolve() + if reset: + _safe_reset(root) + root.mkdir(parents=True, exist_ok=True) + program, source_metadata, code_path = _artifact_program(artifact, provenance) + training_trial = _artifact_trial(source_metadata, code_path) + if "training_trial" in source_metadata: + declared_training = int(source_metadata["training_trial"]) + if declared_training != training_trial: + raise ValueError("training trial must match the CaP-X artifact's recorded trial") + persisted_provenance = dict(source_metadata) + persisted_provenance.update( + { + "artifact_trial": training_trial, + "training_trial": training_trial, + "heldout_trial": int(heldout_trial), + } + ) + + _write_candidate(root, program) + for relative, source in (support_files or {}).items(): + path = Path(relative) + if path.is_absolute() or not path.parts or path.parts[0] != "solver": + raise ValueError(f"support file must stay below solver/: {relative}") + destination = root / path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(source) + (root / "API_REFERENCE.md").write_text(api_reference) + (root / "opencode.json").write_text(json.dumps(OPENCODE_CONFIG, indent=2) + "\n") + (root / "helix.toml").write_text( + helix_config( + generations, + objective=objective, + background=background, + ) + ) + (root / "probe.py").write_text(PROBE_SOURCE) + (root / "provenance.json").write_text(json.dumps(persisted_provenance, indent=2, sort_keys=True) + "\n") + (root / ".gitignore").write_text( + ".helix/\n.helix_artifacts/\n.helix_opencode_state/\n__pycache__/\n*.pyc\nhelix_batch.json\n" + ) + + _git(root, "init", "-b", "main") + _git(root, "add", ".") + _git(root, "commit", "-m", "Seed the recorded CaP-X program") + return root + + +def _port_open(port: int) -> bool: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + return True + except OSError: + return False + + +def service_status() -> dict[int, bool]: + return {port: _port_open(port) for port in SERVICE_PORTS} + + +def ensure_services( + progress: Callable[[str], None] = print, + *, + model: str = MODEL, +) -> list[Any]: + """Start/reuse Lemonade, OWLv2, SAM2, Contact-GraspNet, and PyRoKi.""" + from capx_demo import SERVICE_LOG, ensure_lemonade, quiet_output + + lemonade_seconds = ensure_lemonade(model, progress=progress) + started = time.monotonic() + old_cwd = Path.cwd() + try: + with quiet_output(): + os.chdir(CAPX_ROOT) + from capx.envs.launch import LaunchArgs + from capx.envs.runner import _start_api_servers + from capx.utils.launch_utils import _load_config + + args = LaunchArgs( + config_path=CONFIG_PATH, + model=MODEL, + server_url="http://127.0.0.1:13305/api/v1/chat/completions", + temperature=0.2, + max_tokens=4096, + ) + _, _, api_servers = _load_config(args) + servers = list(_start_api_servers(api_servers, 900.0)) + status = service_status() + if not all(status.values()): + missing = [port for port, ready in status.items() if not ready] + servers.extend(_start_api_servers(api_servers, 900.0)) + finally: + os.chdir(old_cwd) + + status = service_status() + if not all(status.values()): + missing = [port for port, ready in status.items() if not ready] + raise RuntimeError(f"CaP-X services failed to start on ports: {missing}") + + for proc in servers: + if hasattr(proc, "poll") and proc.poll() is None: + _OWNED_SERVERS.append(proc) + robotics_seconds = time.monotonic() - started + LAST_SERVICE_TIMING.update( + lemonade_seconds=lemonade_seconds, + robotics_seconds=robotics_seconds, + total_seconds=lemonade_seconds + robotics_seconds, + ) + progress(f"Services ready · LLM {lemonade_seconds:.1f}s · robotics {robotics_seconds:.1f}s · details {SERVICE_LOG}") + return servers + + +def stop_owned_services() -> None: + while _OWNED_SERVERS: + proc = _OWNED_SERVERS.pop() + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + + +atexit.register(stop_owned_services) + + +def _terminate_group(proc: subprocess.Popen[str]) -> None: + if proc.poll() is not None: + return + try: + os.killpg(proc.pid, signal.SIGTERM) + proc.wait(timeout=5) + except (ProcessLookupError, subprocess.TimeoutExpired): + if proc.poll() is None: + with suppress(ProcessLookupError): + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + + +def run_bounded( + command: list[str], + *, + cwd: Path | str, + timeout_seconds: float, + env: dict[str, str] | None = None, + progress: Callable[[str], None] | None = None, + heartbeat: Callable[[], None] | None = None, +) -> BoundedRun: + """Run a command in its own process group and enforce a wall-clock limit.""" + started = time.monotonic() + proc = subprocess.Popen( + command, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + start_new_session=True, + ) + assert proc.stdout is not None + lines: list[str] = [] + output_queue: queue.Queue[str | None] = queue.Queue() + + def _reader() -> None: + for line in proc.stdout: + output_queue.put(line) + output_queue.put(None) + + threading.Thread(target=_reader, daemon=True).start() + timed_out = False + stream_done = False + deadline = started + timeout_seconds + process_exited_at: float | None = None + + while proc.poll() is None or not stream_done: + if proc.poll() is not None: + if process_exited_at is None: + process_exited_at = time.monotonic() + elif not stream_done and time.monotonic() - process_exited_at >= 1.0: + # A detached descendant can inherit stdout after the bounded + # process group exits. Do not let that orphaned pipe defeat the + # wall-clock limit. + break + if proc.poll() is None and time.monotonic() >= deadline: + timed_out = True + _terminate_group(proc) + try: + line = output_queue.get(timeout=0.1) + except queue.Empty: + if heartbeat is not None: + heartbeat() + continue + if line is None: + stream_done = True + continue + lines.append(line) + if progress is not None: + progress(line.rstrip()) + + proc.stdout.close() + return BoundedRun( + returncode=124 if timed_out else int(proc.returncode or 0), + timed_out=timed_out, + stdout="".join(lines), + elapsed_seconds=time.monotonic() - started, + ) + + +def _trial_id(candidate_root: Path, split: str, example_id: str) -> int: + if split not in {"train", "val"}: + raise ValueError(f"unknown evaluation split: {split}") + override = os.environ.get("RHO_TRIAL_ID") + if override is not None: + trial = int(override) + if trial < 0: + raise ValueError("trial override must be non-negative") + return trial + int(example_id) # The legacy single-task evaluator uses positional IDs. + path = candidate_root / "provenance.json" + provenance = json.loads(path.read_text()) if path.exists() else {} + key = "training_trial" if split == "train" else "heldout_trial" + return int(provenance.get(key, 1 if split == "train" else 2)) + + +def _mock_evaluation(candidate_root: Path, split: str, example_id: str) -> dict[str, Any]: + program = (candidate_root / "solver" / "program.py").read_text() + compact = re.sub(r"\s+", "", program) + invalid_accesses = ( + "green_pose[0][2]", + "green_pose[0][0]", + "green_pose[0][1]", + ) + corrected_accesses = ("green_pose[2]", "green_pose[0]", "green_pose[1]") + syntax_error = "" + try: + compile(program, "solver/program.py", "exec") + except SyntaxError: + syntax_error = traceback.format_exc()[-2400:] + recorded_bug = any(access in compact for access in invalid_accesses) + success = not syntax_error and not recorded_bug and all(access in compact for access in corrected_accesses) + if syntax_error: + feedback = "Mock execution rejected invalid Python; inspect traceback." + elif recorded_bug: + feedback = ( + "Recorded CaP-X failure reproduced: green_pose is an XYZ vector, " + "so green_pose[0][…] indexes a scalar. Use green_pose[2], " + "green_pose[0], and green_pose[1]." + ) + elif success: + feedback = "Mock execution accepted the corrected green_pose indexing." + else: + feedback = "Mock execution did not find the three direct green_pose accesses required by the recorded repair." + return { + "reward": float(success), + "raw_reward": float(success), + "task_completed": success, + "split": split, + "trial": _trial_id(candidate_root, split, example_id), + "stdout": "", + "stderr": "", + "traceback": ( + syntax_error + or ("IndexError: invalid index to scalar variable (green_pose[0][...])" if recorded_bug else "") + ), + "feedback": feedback, + "video": None, + } + + +def _info_value(info: Any, names: tuple[str, ...]) -> Any: + if isinstance(info, Mapping): + for name in names: + if name in info and info[name] not in (None, ""): + return info[name] + for value in info.values(): + found = _info_value(value, names) + if found not in (None, ""): + return found + elif isinstance(info, (list, tuple)): + for value in info: + found = _info_value(value, names) + if found not in (None, ""): + return found + return None + + +def _feedback_text( + completed: bool, + info: Any, + stdout: str, + stderr: str, + sandbox_traceback: str, +) -> str: + parts = ["Task completed." if completed else "Task not completed."] + if stdout: + parts.append(f"Sandbox stdout:\n{stdout[-1600:]}") + if stderr: + parts.append(f"Sandbox stderr:\n{stderr[-1600:]}") + if sandbox_traceback: + parts.append(f"Sandbox traceback:\n{sandbox_traceback[-2400:]}") + if len(parts) == 1: + parts.append(f"Simulator info: {str(info)[-1200:]}") + return "\n\n".join(parts) + + +def _live_evaluation( + candidate_root: Path, + split: str, + example_id: str, + *, + capture: bool, + config_path: str = CONFIG_PATH, + policy_path: str = "solver/program.py", +) -> dict[str, Any]: + old_cwd = Path.cwd() + old_sys_path = list(sys.path) + env = None + try: + # Candidate policies may import editable repository modules such as + # solver.geometry and solver.runtime. Keep those imports available + # after changing into the CaP-X source tree for simulator setup. + sys.path.insert(0, str(candidate_root.resolve())) + os.chdir(CAPX_ROOT) + from capx.envs.configs.instantiate import instantiate + from capx.envs.launch import LaunchArgs + from capx.utils.launch_utils import _load_config + + args = LaunchArgs( + config_path=config_path, + model=MODEL, + server_url="http://127.0.0.1:13305/api/v1/chat/completions", + temperature=0.2, + max_tokens=4096, + ) + env_factory, _, _ = _load_config(args) + env = instantiate(env_factory) + trial = _trial_id(candidate_root, split, example_id) + env.reset(options={"trial": trial}, seed=trial) + + if capture: + env.enable_video_capture() + program = (candidate_root / policy_path).read_text() + captured_stdout = io.StringIO() + captured_stderr = io.StringIO() + try: + with redirect_stdout(captured_stdout), redirect_stderr(captured_stderr): + _, reward, _, _, info = env.step(program) + except Exception: + sandbox_traceback = traceback.format_exc()[-2400:] + stdout = captured_stdout.getvalue() + stderr = captured_stderr.getvalue() + return { + "reward": 0.0, + "raw_reward": 0.0, + "task_completed": False, + "split": split, + "trial": trial, + "stdout": stdout, + "stderr": stderr, + "traceback": sandbox_traceback, + "feedback": _feedback_text(False, {}, stdout, stderr, sandbox_traceback), + "video": None, + } + + raw_reward = float(reward) + sandbox_stdout = str(_info_value(info, ("sandbox_stdout", "stdout")) or "") + sandbox_stderr = str(_info_value(info, ("sandbox_stderr", "stderr")) or "") + sandbox_traceback = str( + _info_value( + info, + ("sandbox_traceback", "traceback", "exception", "error"), + ) + or "" + ) + sandbox_rc_value = _info_value(info, ("sandbox_rc", "sandbox_returncode", "returncode")) + stdout = captured_stdout.getvalue() + stderr = captured_stderr.getvalue() + if sandbox_stdout and sandbox_stdout not in stdout: + stdout = (stdout + sandbox_stdout).strip() + if sandbox_stderr and sandbox_stderr not in stderr: + stderr = (stderr + sandbox_stderr).strip() + execution_failed = bool( + (sandbox_rc_value is not None and int(sandbox_rc_value) != 0) or sandbox_traceback or sandbox_stderr.strip() + ) + # Partial robot motion can earn environment reward before generated + # code crashes. RHO optimizes deployable policies, so an execution + # failure receives zero evaluator score while retaining raw_reward for + # diagnostics. + score = 0.0 if execution_failed else raw_reward + completed_value = _info_value(info, ("task_completed", "task_success", "success")) + completed = not execution_failed and ( + bool(completed_value) if completed_value is not None else bool(score >= 1.0) + ) + video: str | None = None + if capture: + from capx.utils.video_utils import _write_video + + frames = env.get_video_frames(clear=True) + if frames: + VIDEO_ROOT.mkdir(parents=True, exist_ok=True) + suffix = f"{split}_{example_id}_{int(time.time())}" + _write_video(frames, str(VIDEO_ROOT), suffix=suffix) + video = str(VIDEO_ROOT / f"video_{suffix}.mp4") + return { + "reward": score, + "raw_reward": raw_reward, + "task_completed": completed, + "split": split, + "trial": trial, + "stdout": stdout, + "stderr": stderr, + "traceback": sandbox_traceback, + "feedback": _feedback_text(completed, info, stdout, stderr, sandbox_traceback) + + (f"\n\nRaw simulator reward before execution penalty: {raw_reward:.4f}." if execution_failed else ""), + "video": video, + } + finally: + if env is not None and hasattr(env, "close"): + env.close() + sys.path[:] = old_sys_path + os.chdir(old_cwd) + + +def _worker_result( + candidate_root: Path, + split: str, + example_id: str, + capture: bool, + config_path: str = CONFIG_PATH, + policy_path: str = "solver/program.py", +) -> dict[str, Any]: + try: + if os.environ.get("RHO_MOCK_EVAL") == "1": + return _mock_evaluation(candidate_root, split, example_id) + return _live_evaluation( + candidate_root, + split, + example_id, + capture=capture, + config_path=config_path, + policy_path=policy_path, + ) + except Exception: + return { + "reward": 0.0, + "raw_reward": 0.0, + "task_completed": False, + "split": split, + "trial": _trial_id(candidate_root, split, example_id), + "stdout": "", + "stderr": "", + "traceback": traceback.format_exc()[-2400:], + "feedback": "Candidate raised during execution; inspect traceback.", + "video": None, + } + + +def score_candidate( + candidate_root: Path | str, + split: str = "train", + example_id: str = "0", + *, + trial: int | None = None, + capture: bool = False, + timeout_seconds: float = EVALUATION_TIMEOUT, + config_path: str = CONFIG_PATH, + policy_path: str = "solver/program.py", +) -> dict[str, Any]: + """Evaluate one layout in a killable child without modifying the candidate.""" + candidate_root = Path(candidate_root).resolve() + if trial is not None and trial < 0: + raise ValueError("trial must be non-negative") + selected_trial = int(trial) if trial is not None else _trial_id(candidate_root, split, example_id) + worker_env = os.environ.copy() + worker_env["RHO_VIDEO_ROOT"] = str(VIDEO_ROOT) + if trial is not None: + worker_env["RHO_TRIAL_ID"] = str(trial) + worker = run_bounded( + [ + str(CAPX_PYTHON if CAPX_PYTHON.exists() else Path(sys.executable)), + str(Path(__file__).resolve()), + "_evaluate_worker", + str(candidate_root), + split, + str(example_id), + "1" if capture else "0", + config_path, + policy_path, + ], + cwd=candidate_root, + timeout_seconds=timeout_seconds, + env=worker_env, + ) + if worker.timed_out: + return { + "reward": 0.0, + "raw_reward": 0.0, + "task_completed": False, + "split": split, + "trial": selected_trial, + "stdout": "", + "stderr": "", + "traceback": "", + "feedback": f"Evaluation timed out after {timeout_seconds:.0f}s.", + "video": None, + "timed_out": True, + "elapsed_seconds": worker.elapsed_seconds, + } + for line in reversed(worker.stdout.splitlines()): + try: + result = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(result, dict) and "reward" in result: + result["execution_tail"] = "\n".join(worker.stdout.splitlines()[-12:-1])[-1200:] + result["timed_out"] = False + result["elapsed_seconds"] = worker.elapsed_seconds + return result + return { + "reward": 0.0, + "raw_reward": 0.0, + "task_completed": False, + "split": split, + "trial": selected_trial, + "stdout": "", + "stderr": "", + "traceback": worker.stdout[-2400:], + "feedback": "Evaluator worker returned no JSON result.", + "video": None, + "timed_out": False, + "elapsed_seconds": worker.elapsed_seconds, + } + + +def _append_progress_event(event: Mapping[str, Any]) -> None: + path_value = os.environ.get("RHO_PROGRESS_FILE") + if not path_value: + return + try: + with Path(path_value).open("a", encoding="utf-8") as stream: + stream.write(json.dumps(dict(event), separators=(",", ":")) + "\n") + except OSError: + # Notebook feedback must never make an evaluation fail. + pass + + +def evaluate_cli() -> int: + """Emit HELIX's exact positional per-example result protocol.""" + root = Path.cwd() + batch_path = root / "helix_batch.json" + ids = json.loads(batch_path.read_text()) if batch_path.exists() else ["0"] + if not isinstance(ids, list) or not all(isinstance(item, str) for item in ids): + raise ValueError("helix_batch.json must be a JSON list of strings") + split = os.environ.get("HELIX_SPLIT", "train") + timeout = float(os.environ.get("RHO_EVAL_TIMEOUT", EVALUATION_TIMEOUT)) + origin = os.environ.get("RHO_EVAL_ORIGIN", "helix") + payload: list[list[Any]] = [] + for example_id in ids: + trial = _trial_id(root, split, example_id) + evaluation_id = f"{os.getpid()}-{time.time_ns()}-{example_id}" + _append_progress_event( + { + "event": "started", + "evaluation_id": evaluation_id, + "started_at": time.time(), + "origin": origin, + "split": split, + "trial": trial, + } + ) + result = score_candidate(root, split, example_id, timeout_seconds=timeout) + _append_progress_event( + { + "event": "completed", + "evaluation_id": evaluation_id, + "origin": origin, + "split": result["split"], + "trial": result["trial"], + "reward": result["reward"], + "raw_reward": result.get("raw_reward", result["reward"]), + "task_completed": result["task_completed"], + "timed_out": result.get("timed_out", False), + "elapsed_seconds": result.get("elapsed_seconds"), + } + ) + side_info = { + "reward": result["reward"], + "raw_reward": result.get("raw_reward", result["reward"]), + "task_completed": result["task_completed"], + "split": result["split"], + "trial": result["trial"], + "stdout": result.get("stdout", ""), + "stderr": result.get("stderr", ""), + "traceback": result.get("traceback", ""), + "feedback": result.get("feedback", ""), + "video": result.get("video"), + "execution_tail": result.get("execution_tail", ""), + "timed_out": result.get("timed_out", False), + "elapsed_seconds": result.get("elapsed_seconds"), + "scores": {"completion": result["reward"]}, + } + payload.append([float(result["reward"]), side_info]) + print("HELIX_RESULT=" + json.dumps(payload, separators=(",", ":"))) + return 0 + + +def run_helix( + root: Path | str = CANDIDATE_ROOT, + *, + generations: int = 1, + timeout_seconds: float = DEFAULT_TIMEOUT, + progress: Callable[[str], None] = print, + merge: bool = False, +) -> BoundedRun: + """Stream a bounded HELIX evolution in the disposable candidate repo.""" + if not 1 <= generations <= 4: + raise ValueError("workshop generations must be between 1 and 4") + root = Path(root).resolve() + evaluation_log = Path( + f"/tmp/rho-evaluations-{os.getpid()}-{time.time_ns()}.jsonl" + ) + evaluation_log.write_text("", encoding="utf-8") + run_env = os.environ.copy() + run_env.pop("RHO_EVAL_ORIGIN", None) + run_env["RHO_PROGRESS_FILE"] = str(evaluation_log) + command = [ + str(HELIX if HELIX.exists() else Path("helix")), + "evolve", + "--dir", + str(root), + "--config", + "helix.toml", + "--generations", + str(generations), + ] + if not merge: + command.append("--no-merge") + notebook_display = ( + _notebook_progress(evaluation_log) if progress is print else None + ) + result = run_bounded( + command, + cwd=root, + timeout_seconds=timeout_seconds, + env=run_env, + progress=notebook_display or progress, + heartbeat=notebook_display.tick if notebook_display is not None else None, + ) + if notebook_display is not None: + notebook_display.finish(result) + evaluation_log.unlink(missing_ok=True) + if result.timed_out: + progress(f"HELIX stopped at the {timeout_seconds:.0f}s workshop deadline.") + return result + + +def source_diff(before: Path | str, after: Path | str) -> str: + before, after = Path(before), Path(after) + chunks: list[str] = [] + relatives = { + path.relative_to(before) + for path in (before / "solver").rglob("*") + if path.is_file() and "__pycache__" not in path.parts + } + relatives.update( + path.relative_to(after) + for path in (after / "solver").rglob("*") + if path.is_file() and "__pycache__" not in path.parts + ) + for relative in sorted(relatives, key=lambda path: path.as_posix()): + old_path = before / relative + new_path = after / relative + old = old_path.read_text().splitlines(keepends=True) if old_path.exists() else [] + new = new_path.read_text().splitlines(keepends=True) if new_path.exists() else [] + chunks.extend( + difflib.unified_diff( + old, + new, + fromfile=(f"seed/{relative.as_posix()}" if old_path.exists() else "/dev/null"), + tofile=(f"best/{relative.as_posix()}" if new_path.exists() else "/dev/null"), + ) + ) + return "".join(chunks) + + +def semantic_mutation(diff: str) -> list[str]: + removed: dict[str, str] = {} + changes: list[str] = [] + assignment = re.compile(r"^[+-]([A-Z][A-Z0-9_]*)\s*=\s*(.+)$") + for line in diff.splitlines(): + match = assignment.match(line) + if not match: + continue + name, value = match.groups() + if line.startswith("-"): + removed[name] = value + elif name in removed: + changes.append(f"{name}: {removed[name]} -> {value}") + if not changes and diff: + files = sorted({line.removeprefix("+++ best/") for line in diff.splitlines() if line.startswith("+++ best/")}) + changes = [f"Changed {name}" for name in files] + return changes + + +def export_best(root: Path | str = CANDIDATE_ROOT) -> Path: + root = Path(root) + destination = root.parent / "live_best" + _safe_reset(destination) + try: + done = subprocess.run( + [ + str(HELIX if HELIX.exists() else Path("helix")), + "best", + "--dir", + str(root), + "--export", + str(destination), + ], + cwd=root, + capture_output=True, + text=True, + ) + except OSError: + return root + return destination if done.returncode == 0 and destination.exists() else root + + +def summarize_run(root: Path | str = CANDIDATE_ROOT) -> dict[str, Any]: + root = Path(root) + best = export_best(root) + best_diff = source_diff(root, best) + state_path = root / ".helix" / "state.json" + state = json.loads(state_path.read_text()) if state_path.exists() else {} + child_ids = [candidate_id for candidate_id in state.get("frontier", []) if candidate_id != "g0-s0"] + candidates = [root / ".helix" / "worktrees" / candidate_id for candidate_id in child_ids] + candidate_diffs = { + candidate.name: source_diff(root, candidate) for candidate in candidates if (candidate / "solver").is_dir() + } + child = candidates[-1] if candidates else None + child_diff = candidate_diffs.get(child.name, "") if child is not None else "" + return { + "accepted": bool(best_diff.strip()), + "improved_best": bool(best_diff.strip()), + "live_best": str(best), + "best_diff": best_diff, + "child_candidate": str(child) if child is not None else None, + "semantic_mutation": semantic_mutation(child_diff or best_diff), + "child_diff": child_diff, + "candidate_diffs": candidate_diffs, + "fallback": { + "candidate": str(root), + "trace": {"mutation": []}, + "label": ( + "NO LIVE CHILD ACCEPTED — the authentic failed seed is retained; no prerecorded success is substituted." + ), + }, + } + + +def live_smoke_cli() -> int: + """Run the file-backed live path used by the optional image smoke test.""" + ensure_services() + # Cold-loading four perception/control services is a one-time environment + # setup cost. The workshop's ten-minute bound applies to the RHO mutation + # and paired rollouts that follow, matching the notebook's cell structure. + started = time.monotonic() + root = prepare_workshop() + seed = score_candidate(root, "train") + run = run_helix(root, generations=1, timeout_seconds=DEFAULT_TIMEOUT) + summary = summarize_run(root) + heldout = score_candidate(summary["live_best"], "val", capture=True) + elapsed = time.monotonic() - started + if run.timed_out: + raise RuntimeError("HELIX exceeded its 480-second hard deadline") + if run.returncode != 0: + raise RuntimeError(run.stdout[-4000:]) + if elapsed >= 600: + raise RuntimeError(f"live workshop path took {elapsed:.1f}s") + if heldout.get("timed_out"): + raise RuntimeError(f"held-out evaluation timed out: {heldout}") + result = { + "seed_reward": seed["reward"], + "accepted": summary["accepted"], + "live_best": summary["live_best"], + "best_diff": summary["best_diff"], + "heldout_reward": heldout["reward"], + "heldout_completed": heldout["task_completed"], + "heldout_video": heldout.get("video"), + "heldout_feedback": heldout.get("feedback", ""), + "helix_seconds": round(run.elapsed_seconds, 1), + "total_seconds": round(elapsed, 1), + } + print("RHO_LIVE_RESULT=" + json.dumps(result, separators=(",", ":"))) + return 0 + + +def _main(argv: list[str]) -> int: + if not argv: + print("usage: rho_demo.py {prepare|evaluate|run|summary}") + return 2 + if argv[0] == "prepare": + artifact = Path(argv[1]) if len(argv) > 1 else None + provenance = Path(argv[2]) if len(argv) > 2 else None + print(prepare_workshop(artifact=artifact, provenance=provenance)) + return 0 + if argv[0] == "evaluate": + return evaluate_cli() + if argv[0] == "run": + result = run_helix() + print(json.dumps(asdict(result), indent=2)) + return result.returncode + if argv[0] == "summary": + print(json.dumps(summarize_run(), indent=2)) + return 0 + if argv[0] == "live-smoke": + return live_smoke_cli() + if argv[0] == "_evaluate_worker": + result = _worker_result( + Path(argv[1]), + argv[2], + argv[3], + bool(int(argv[4])), + argv[5] if len(argv) > 5 else CONFIG_PATH, + argv[6] if len(argv) > 6 else "solver/program.py", + ) + print(json.dumps(result, separators=(",", ":"))) + return 0 + if argv[0] == "_sleep": + time.sleep(float(argv[1])) + return 0 + raise ValueError(f"unknown command: {argv[0]}") + + +if __name__ == "__main__": + raise SystemExit(_main(sys.argv[1:])) diff --git a/projects/LocalInference/scripts/rho_multitask_demo.py b/projects/LocalInference/scripts/rho_multitask_demo.py new file mode 100644 index 00000000..565089eb --- /dev/null +++ b/projects/LocalInference/scripts/rho_multitask_demo.py @@ -0,0 +1,1172 @@ +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Multi-task CaP-X repository evolution for the HELIX/GEPA workshop.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import shutil +import sys +import time +from collections.abc import Callable, Mapping, Sequence +from typing import Any + +import rho_demo + + +HERE = Path(__file__).resolve().parent +_LOCAL_FIXTURE_ROOT = HERE.parent / "fixtures" / "rho_multitask" +FIXTURE_ROOT = Path( + os.environ.get( + "RHO_MULTITASK_FIXTURE_ROOT", + str( + _LOCAL_FIXTURE_ROOT + if _LOCAL_FIXTURE_ROOT.exists() + else Path("/ryzers/notebooks/fixtures/rho_multitask") + ), + ) +) +DEFAULT_ROOT = Path("/tmp/rho_multitask_workshop/candidate") +DEFAULT_MODEL = os.environ.get( + "RHO_MULTITASK_MODEL", + os.environ.get("RHO_MODEL", "user.Qwen3-Coder-30B-A3B-Instruct-Q4_K_M"), +) +DEFAULT_GENERATIONS = 2 +DEFAULT_TIMEOUT = 1200.0 +MOCK_LABEL = "MOCK_STATIC_CONTRACT_NOT_LIVE_CAPX" + +CONFIGS = { + "cube_stack": "env_configs/cube_stack/franka_robosuite_cube_stack.yaml", + "spill_wipe": "env_configs/spill_wipe/franka_robosuite_spill_wipe.yaml", +} + +SCENARIOS: dict[str, dict[str, Any]] = { + "stack_train": { + "task": "cube_stack", + "trial": 1, + "policy_path": "solver/tasks/cube_stack.py", + }, + "wipe_train": { + "task": "spill_wipe", + "trial": 1, + "policy_path": "solver/tasks/spill_wipe.py", + }, + "stack_val": { + "task": "cube_stack", + "trial": 2, + "policy_path": "solver/tasks/cube_stack.py", + }, + "wipe_val": { + "task": "spill_wipe", + "trial": 2, + "policy_path": "solver/tasks/spill_wipe.py", + }, +} + +SPLITS = { + "train": ["stack_train", "wipe_train"], + "val": ["stack_val", "wipe_val"], +} + +CONTRACT = """\ +# Multi-task CaP-X repository contract + +This repository deploys two generated robot policies: + +- `solver/tasks/cube_stack.py` stacks a red cube on a green cube. +- `solver/tasks/spill_wipe.py` wipes the complete detected spill region. + +The evaluator runs different simulator layouts for training and validation. +Its feedback includes deployable reward, raw environment reward, task completion, +Python tracebacks, and the tail of robot-policy output. An execution failure has +deployable reward zero even if the robot made partial progress. + +You may edit any file below `solver/`, including extracting shared calculations +or safety behavior into `solver/geometry.py` and `solver/runtime.py`. Do not +special-case scenario identifiers, trial numbers, or fixed object coordinates. + +Important API facts: + +- `get_object_pose(..., return_bbox_extent=True)` returns flat XYZ position, + WXYZ quaternion, and full XYZ extents. +- `sample_grasp_pose(...)` returns a flat XYZ position and WXYZ quaternion. +- `goto_pose(...)` is blocking and may raise if code keeps issuing actions after + the simulator has already completed or terminated an episode. +- Imported helper modules cannot directly access the injected robot primitives; + use them for calculations, path generation, and reusable control decisions. +""" + +OBJECTIVE = """\ +Evolve this repository into a robust two-task robot policy package. Improve +the cube-stack and spill-wipe policies using evaluator evidence. Prefer general +repairs and shared helpers over trial-specific constants. Multiple specialist +candidates are valuable: HELIX will retain repositories that win different +validation tasks.""" + +BACKGROUND = """\ +This is a two-generation GEPA-style repository evolution. Read CONTRACT.md, +the policy files, and the evaluator diagnostics supplied for your sampled task. +Diagnose failures rather than assuming the repair. Treat the sampled task as +the primary edit target: read its policy first and do not inspect or modify +unrelated working task policies unless the diagnostic implicates them. Use the +read and glob tools for discovery instead of shell commands. Robot primitives +are injected only into task-script globals; imported helper modules must remain +pure calculations or control decisions and cannot call those primitives. +Do not edit protected configuration or encode scenario IDs, trial IDs, or fixed +poses. Reserve enough turns to make the edit and run the checks below. +Before finishing, run `/opt/capx-venv/bin/python -m py_compile solver/*.py +solver/tasks/*.py`, then run +`RHO_EVAL_ORIGIN=agent-self-check /opt/capx-venv/bin/python probe.py`, and +inspect `git diff`.""" + +PROBE_SOURCE = """\ +import os +import sys + +sys.path.insert( + 0, os.environ.get("RHO_SUPPORT_ROOT", "/ryzers/notebooks/scripts") +) +from rho_multitask_demo import evaluate_cli + +raise SystemExit(evaluate_cli()) +""" + +GEOMETRY_SOURCE = '''\ +"""Shared geometry helpers for robot policies. + +Candidates may add reusable, task-independent calculations here. +""" +''' + +RUNTIME_SOURCE = '''\ +"""Shared path and execution helpers for robot policies. + +Robot primitives are injected only into each task script's execution globals. +Helpers in this module should return data or control decisions to those scripts. +""" +''' + + +def opencode_model_id(model: str = DEFAULT_MODEL) -> str: + """Return the OpenAI API id for a Lemonade user-registry alias.""" + return model.removeprefix("user.") + + +def opencode_config(model: str = DEFAULT_MODEL) -> dict[str, Any]: + api_model = opencode_model_id(model) + return { + "$schema": "https://opencode.ai/config.json", + "model": f"lemonade/{api_model}", + "small_model": f"lemonade/{api_model}", + "agent": {"build": {"temperature": 0.1, "steps": 24}}, + "experimental": {"primary_tools": ["read", "edit", "bash"]}, + "provider": { + "lemonade": { + "npm": "@ai-sdk/openai-compatible", + "name": "Local Lemonade", + "options": { + "baseURL": "http://127.0.0.1:13305/api/v1", + "apiKey": "lemonade", + }, + "models": { + api_model: { + "name": api_model, + "tool_call": True, + "limit": {"context": 32768, "output": 8192}, + } + }, + } + }, + "permission": { + "*": "allow", + "edit": { + "*": "deny", + "solver/**": "allow", + "**/solver/**": "allow", + }, + "external_directory": "deny", + "webfetch": "deny", + "websearch": "deny", + "task": "deny", + "skill": "deny", + "todowrite": "deny", + "bash": { + "*": "deny", + ( + "RHO_EVAL_ORIGIN=agent-self-check " + "/opt/capx-venv/bin/python probe.py*" + ): "allow", + ( + "/opt/capx-venv/bin/python -m py_compile " + "solver/*.py solver/tasks/*.py" + ): "allow", + "git diff*": "allow", + "git status*": "allow", + }, + }, + } + + +def helix_config( + *, + model: str = DEFAULT_MODEL, + generations: int = DEFAULT_GENERATIONS, +) -> str: + if generations < 2: + raise ValueError("the multi-task workshop requires at least two generations") + if generations > 4: + raise ValueError("the multi-task workshop is capped at four generations") + api_model = opencode_model_id(model) + return f'''\ +objective = """{OBJECTIVE}""" +seed = "." +rng_seed = 29 +passthrough_env = [ + "CUDA_VISIBLE_DEVICES", + "HIP_VISIBLE_DEVICES", + "ROCR_VISIBLE_DEVICES", + "HSA_OVERRIDE_GFX_VERSION", + "LD_LIBRARY_PATH", + "HF_HOME", + "MUJOCO_GL", + "PYOPENGL_PLATFORM", + "CAPX_ROOT", + "RHO_MODEL", + "RHO_MULTITASK_MODEL", + "RHO_MULTITASK_MOCK", + "RHO_PROGRESS_FILE", + "RHO_SUPPORT_ROOT", + "XDG_RUNTIME_DIR", +] + +[env] +CAPX_ROOT = "/ryzers/cap-x" +HF_HOME = "/opt/capx-cache" +MUJOCO_GL = "egl" +PYOPENGL_PLATFORM = "egl" +RHO_EVAL_TIMEOUT = "180" + +[evaluator] +command = "/opt/capx-venv/bin/python probe.py" +protected_files = [ + "probe.py", + "helix.toml", + "opencode.json", + "CONTRACT.md", + "scenarios.json", + "provenance.json", +] + +[dataset] +train_size = 2 +val_size = 2 + +[evolution] +max_generations = {generations} +# Scores are bounded by 1.0; 1.1 deliberately prevents a generation-1 +# universal candidate from short-circuiting the two-generation lesson. +perfect_score_threshold = 1.1 +max_evaluations = 40 +merge_enabled = true +max_merge_invocations = 2 +merge_val_overlap_floor = 1 +merge_subsample_size = 2 +num_parallel_proposals = 2 +mutations_per_parent = 1 +minibatch_size = 1 +max_workers = 1 +cache_evaluation = true +acceptance_criterion = "strict_improvement" +frontier_type = "instance" +batch_sampler = "epoch_shuffled" + +[agent] +backend = "opencode" +model = "lemonade/{api_model}" +max_turns = 24 +background = """{BACKGROUND}""" + +[sandbox] +enabled = false + +[worktree] +base_dir = ".helix/worktrees" +''' + + +def scenario_manifest() -> dict[str, Any]: + return { + "schema_version": "rho-multitask-scenarios/v2", + "splits": SPLITS, + "scenarios": { + scenario_id: { + **scenario, + "config_path": CONFIGS[str(scenario["task"])], + } + for scenario_id, scenario in SCENARIOS.items() + }, + "hidden_rollouts_exposed_to_evolution": False, + } + + +def _safe_reset(path: Path) -> None: + resolved = path.resolve() + temporary_root = Path("/tmp").resolve() + if resolved == temporary_root or temporary_root not in resolved.parents: + raise ValueError(f"refusing to remove non-workshop path: {resolved}") + shutil.rmtree(resolved, ignore_errors=True) + + +def prepare_workshop( + root: Path | str = DEFAULT_ROOT, + *, + model: str = DEFAULT_MODEL, + generations: int = DEFAULT_GENERATIONS, + reset: bool = True, +) -> Path: + """Create a disposable multi-policy Git repository for HELIX.""" + root = Path(root).expanduser().resolve() + if reset: + _safe_reset(root) + tasks = root / "solver" / "tasks" + tasks.mkdir(parents=True, exist_ok=True) + (root / "solver" / "__init__.py").write_text("") + (tasks / "__init__.py").write_text("") + for task in ("cube_stack", "spill_wipe"): + shutil.copyfile(FIXTURE_ROOT / f"{task}.py", tasks / f"{task}.py") + (root / "solver" / "geometry.py").write_text(GEOMETRY_SOURCE) + (root / "solver" / "runtime.py").write_text(RUNTIME_SOURCE) + (root / "CONTRACT.md").write_text(CONTRACT) + (root / "probe.py").write_text(PROBE_SOURCE) + (root / "scenarios.json").write_text( + json.dumps(scenario_manifest(), indent=2, sort_keys=True) + "\n" + ) + (root / "provenance.json").write_text( + (FIXTURE_ROOT / "provenance.json").read_text() + ) + (root / "opencode.json").write_text( + json.dumps(opencode_config(model), indent=2) + "\n" + ) + (root / "helix.toml").write_text( + helix_config(model=model, generations=generations) + ) + (root / ".gitignore").write_text( + ".helix/\n.helix_artifacts/\n.helix_opencode_state/\n" + "__pycache__/\n*.pyc\nhelix_batch.json\n" + ) + rho_demo._git(root, "init", "-b", "main") + rho_demo._git(root, "add", ".") + rho_demo._git(root, "commit", "-m", "Seed multi-task CaP-X repository") + return root + + +def _load_manifest(root: Path) -> dict[str, Any]: + payload = json.loads((root / "scenarios.json").read_text()) + if not isinstance(payload, dict): + raise ValueError("scenarios.json must contain an object") + return payload + + +def resolve_scenarios( + root: Path | str, + split: str, + example_ids: Sequence[str], +) -> list[tuple[str, dict[str, Any]]]: + root = Path(root) + manifest = _load_manifest(root) + splits = manifest.get("splits", {}) + names = splits.get(split) + scenarios = manifest.get("scenarios", {}) + if split not in {"train", "val"} or not isinstance(names, list): + raise ValueError(f"unknown scenario split: {split}") + resolved: list[tuple[str, dict[str, Any]]] = [] + for example_id in example_ids: + if example_id in scenarios: + scenario_id = example_id + else: + try: + index = int(example_id) + if index < 0: + raise IndexError(index) + scenario_id = names[index] + except (ValueError, IndexError, TypeError) as exc: + raise ValueError( + f"invalid {split} example id: {example_id}" + ) from exc + scenario = scenarios.get(scenario_id) + if not isinstance(scenario, dict) or scenario_id not in names: + raise ValueError(f"scenario {scenario_id!r} is not in split {split}") + resolved.append((scenario_id, scenario)) + return resolved + + +def _mock_result( + root: Path, + split: str, + scenario_id: str, + scenario: Mapping[str, Any], +) -> dict[str, Any]: + task = str(scenario["task"]) + program = (root / str(scenario["policy_path"])).read_text() + compact = "".join(program.split()) + if task == "cube_stack": + passed = ( + "green_pose[2]" in compact + and "green_pose[0][2]" not in compact + and "green_pose[0][0]" not in compact + and "green_pose[0][1]" not in compact + ) + feedback = ( + "Stack placement used a flat XYZ position." + if passed + else "Stack policy raised while indexing a scalar from a flat XYZ pose." + ) + elif task == "spill_wipe": + passed = ( + "goto_pose" in program + and ( + "exceptValueError" in compact + or "exceptException" in compact + or "safe_goto" in program + ) + ) + feedback = ( + "Wipe policy stopped cleanly when the episode terminated." + if passed + else "Wipe policy continued issuing blocking poses after termination." + ) + else: + raise ValueError(f"unsupported mock task: {task}") + reward = float(passed) + return { + "reward": reward, + "raw_reward": reward, + "task_completed": passed, + "split": split, + "trial": int(scenario["trial"]), + "task": task, + "scenario_id": scenario_id, + "stdout": "", + "stderr": "", + "traceback": "", + "feedback": f"{MOCK_LABEL}: {feedback}", + "video": None, + "timed_out": False, + "elapsed_seconds": 0.0, + } + + +def score_scenario( + root: Path | str, + split: str, + scenario_id: str, + scenario: Mapping[str, Any], + *, + capture: bool = False, + timeout_seconds: float = 180.0, +) -> dict[str, Any]: + root = Path(root).resolve() + if os.environ.get("RHO_MULTITASK_MOCK") == "1": + return _mock_result(root, split, scenario_id, scenario) + result = rho_demo.score_candidate( + root, + split, + scenario_id, + trial=int(scenario["trial"]), + capture=capture, + timeout_seconds=timeout_seconds, + config_path=str(scenario["config_path"]), + policy_path=str(scenario["policy_path"]), + ) + result["task"] = str(scenario["task"]) + result["scenario_id"] = scenario_id + return result + + +def evaluate_cli() -> int: + """Evaluate HELIX's positional batch and emit one result per example.""" + root = Path.cwd() + batch_path = root / "helix_batch.json" + split = os.environ.get("HELIX_SPLIT", "train") + manifest = _load_manifest(root) + default_ids = [str(index) for index, _ in enumerate(manifest["splits"][split])] + ids = json.loads(batch_path.read_text()) if batch_path.exists() else default_ids + if not isinstance(ids, list) or not all(isinstance(item, str) for item in ids): + raise ValueError("helix_batch.json must be a JSON list of strings") + timeout = float(os.environ.get("RHO_EVAL_TIMEOUT", "180")) + origin = os.environ.get("RHO_EVAL_ORIGIN", "helix") + payload: list[list[Any]] = [] + for scenario_id, scenario in resolve_scenarios(root, split, ids): + evaluation_id = f"{os.getpid()}-{time.time_ns()}-{scenario_id}" + event = { + "evaluation_id": evaluation_id, + "origin": origin, + "split": split, + "trial": int(scenario["trial"]), + "task": str(scenario["task"]), + "scenario_id": scenario_id, + } + rho_demo._append_progress_event( + {"event": "started", "started_at": time.time(), **event} + ) + result = score_scenario( + root, + split, + scenario_id, + scenario, + timeout_seconds=timeout, + ) + rho_demo._append_progress_event( + { + "event": "completed", + **event, + "reward": result["reward"], + "raw_reward": result.get("raw_reward", result["reward"]), + "task_completed": result["task_completed"], + "timed_out": result.get("timed_out", False), + "elapsed_seconds": result.get("elapsed_seconds"), + } + ) + side_info = { + key: result.get(key) + for key in ( + "reward", + "raw_reward", + "task_completed", + "split", + "trial", + "task", + "scenario_id", + "stdout", + "stderr", + "traceback", + "feedback", + "video", + "execution_tail", + "timed_out", + "elapsed_seconds", + ) + } + side_info["scores"] = { + "completion": float(result["reward"]), + "raw_reward": float(result.get("raw_reward", result["reward"])), + "deployable": float( + not result.get("timed_out") + and not result.get("stderr") + and not result.get("traceback") + ), + } + payload.append([float(result["reward"]), side_info]) + print("HELIX_RESULT=" + json.dumps(payload, separators=(",", ":"))) + return 0 + + +def ensure_services(progress: Callable[[str], None] = print) -> list[Any]: + return rho_demo.ensure_services(progress, model=DEFAULT_MODEL) + + +def run_helix( + root: Path | str = DEFAULT_ROOT, + *, + generations: int = DEFAULT_GENERATIONS, + timeout_seconds: float = DEFAULT_TIMEOUT, + progress: Callable[[str], None] = print, +) -> rho_demo.BoundedRun: + return rho_demo.run_helix( + root, + generations=generations, + timeout_seconds=timeout_seconds, + progress=progress, + merge=True, + ) + + +def materialize_mock_evolution( + root: Path | str = DEFAULT_ROOT, +) -> rho_demo.BoundedRun: + """Create a clearly labeled deterministic two-generation teaching state.""" + root = Path(root) + helix_root = root / ".helix" + worktrees = helix_root / "worktrees" + evaluations = helix_root / "evaluations" + worktrees.mkdir(parents=True, exist_ok=True) + evaluations.mkdir(parents=True, exist_ok=True) + + candidates = { + "g0-s0": {"scores": [0.0, 0.0], "task": None}, + "g1-s1": {"scores": [1.0, 0.0], "task": "cube_stack"}, + "g1-s2": {"scores": [0.0, 1.0], "task": "spill_wipe"}, + "g2-m1": {"scores": [1.0, 1.0], "task": "merge"}, + } + for candidate_id, candidate in candidates.items(): + destination = worktrees / candidate_id + shutil.copytree( + root, + destination, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns(".git", ".helix", "__pycache__"), + ) + stack = destination / "solver" / "tasks" / "cube_stack.py" + wipe = destination / "solver" / "tasks" / "spill_wipe.py" + if candidate_id in {"g1-s1", "g2-m1"}: + stack.write_text( + stack.read_text() + .replace("green_pose[0][2]", "green_pose[2]") + .replace("green_pose[0][0]", "green_pose[0]") + .replace("green_pose[0][1]", "green_pose[1]") + ) + if candidate_id in {"g1-s2", "g2-m1"}: + wipe.write_text( + wipe.read_text() + + "\n# safe_goto handles terminated episodes in the static mock\n" + ) + if candidate["task"]: + (destination / ".agent_task_prompt.md").write_text( + "## Diagnostics\n\n### Example 0\n" + f"#### task\n{candidate['task']}\n" + f"#### scenario_id\n{candidate['task']}_train\n" + ) + side_info = [] + for scenario_id, score in zip(SPLITS["val"], candidate["scores"]): + task = str(SCENARIOS[scenario_id]["task"]) + side_info.append( + { + "scenario_id": scenario_id, + "task": task, + "reward": score, + "raw_reward": score, + "task_completed": bool(score), + "elapsed_seconds": 0.0, + "feedback": f"{MOCK_LABEL}: deterministic fixture", + } + ) + (evaluations / f"{candidate_id}.json").write_text( + json.dumps( + { + "candidate_id": candidate_id, + "instance_scores": { + str(index): score + for index, score in enumerate(candidate["scores"]) + }, + "per_example_side_info": side_info, + }, + indent=2, + ) + ) + + lineage = [ + { + "id": "g0-s0", + "parent": None, + "parents": [], + "operation": "seed", + "generation": 0, + "files_changed": [], + }, + { + "id": "g1-s1", + "parent": "g0-s0", + "parents": ["g0-s0"], + "operation": "mutate", + "generation": 1, + "files_changed": ["solver/tasks/cube_stack.py"], + }, + { + "id": "g1-s2", + "parent": "g0-s0", + "parents": ["g0-s0"], + "operation": "mutate", + "generation": 1, + "files_changed": ["solver/tasks/spill_wipe.py"], + }, + { + "id": "g2-m1", + "parent": "g1-s1", + "parents": ["g1-s1", "g1-s2"], + "operation": "merge", + "generation": 2, + "files_changed": [ + "solver/tasks/cube_stack.py", + "solver/tasks/spill_wipe.py", + ], + }, + ] + (helix_root / "lineage.json").write_text(json.dumps(lineage, indent=2)) + (helix_root / "state.json").write_text( + json.dumps( + { + "generation": 2, + "frontier": list(candidates), + "instance_scores": { + candidate_id: { + str(index): score + for index, score in enumerate(candidate["scores"]) + } + for candidate_id, candidate in candidates.items() + }, + "active_frontier": { + "0": ["g1-s1", "g2-m1"], + "1": ["g1-s2", "g2-m1"], + }, + "frontier_type": "instance", + "budget": {"evaluations": 20}, + "merge_counter": 1, + "merge_attempted_pairs": [["g1-s1", "g1-s2"]], + "merge_description_triplets": [ + ["g1-s1", "g1-s2", "4d6f636b4d65726765436f6d6d69745368613031"] + ], + }, + indent=2, + ) + ) + return rho_demo.BoundedRun( + returncode=0, + timed_out=False, + stdout=f"{MOCK_LABEL}: synthetic specialist and merge state", + elapsed_seconds=0.0, + ) + + +def frontier_summary(root: Path | str = DEFAULT_ROOT) -> dict[str, Any]: + root = Path(root) + state_path = root / ".helix" / "state.json" + state = json.loads(state_path.read_text()) if state_path.exists() else {} + manifest = _load_manifest(root) + val_ids = list(manifest["splits"]["val"]) + active = state.get("active_frontier", {}) + instance_scores = state.get("instance_scores", {}) + retained_ids = { + str(candidate_id) + for winners in active.values() + for candidate_id in winners + } + candidates: dict[str, dict[str, Any]] = {} + for candidate_id, scores in instance_scores.items(): + mapped_scores = { + val_ids[int(example_id)] if str(example_id).isdigit() else str(example_id): score + for example_id, score in scores.items() + if not str(example_id).isdigit() or int(example_id) < len(val_ids) + } + candidates[candidate_id] = { + "scores": mapped_scores, + "wins": [ + val_ids[int(example_id)] + if str(example_id).isdigit() and int(example_id) < len(val_ids) + else str(example_id) + for example_id, winners in active.items() + if candidate_id in winners + ], + "frontier": candidate_id in retained_ids, + } + lineage = candidate_lineage(root) + merge_ancestry = [ + { + "candidate": item["id"], + "parents": list(item.get("parents") or []), + } + for item in lineage + if item.get("operation") == "merge" + ] + return { + "generation": state.get("generation", 0), + "budget": state.get("budget", {}), + "frontier_type": state.get("frontier_type"), + "active_frontier": active, + "candidates": candidates, + "merge_counter": state.get("merge_counter", 0), + "merge_attempted_pairs": state.get("merge_attempted_pairs", []), + # HELIX stores [parent_a, parent_b, merged_git_sha] here to avoid + # repeating an equivalent merge. Candidate ancestry comes from + # lineage.json and is reported separately below. + "merge_output_dedup_triplets": state.get( + "merge_description_triplets", [] + ), + "merge_ancestry": merge_ancestry, + "lineage": lineage, + } + + +def candidate_lineage(root: Path | str = DEFAULT_ROOT) -> list[dict[str, Any]]: + """Join HELIX lineage, prompts, diffs, gates, and validation vectors.""" + root = Path(root) + helix_root = root / ".helix" + lineage_path = helix_root / "lineage.json" + if not lineage_path.exists(): + return [] + records = json.loads(lineage_path.read_text()) + output: list[dict[str, Any]] = [] + for record in records: + candidate_id = str(record["id"]) + parent_id = record.get("parent") + candidate_root = helix_root / "worktrees" / candidate_id + parent_root = ( + helix_root / "worktrees" / str(parent_id) + if parent_id + else None + ) + changed_files = list(record.get("files_changed") or []) + if not changed_files and parent_root and parent_root.exists(): + diff = rho_demo.source_diff(parent_root, candidate_root) + changed_files = sorted( + { + line.removeprefix("+++ best/") + for line in diff.splitlines() + if line.startswith("+++ best/") + and not line.endswith("/dev/null") + } + ) + prompt_path = candidate_root / ".agent_task_prompt.md" + prompt = prompt_path.read_text() if prompt_path.exists() else "" + task_match = re.search(r"#### task\s*\n([^\n]+)", prompt) + scenario_match = re.search(r"#### scenario_id\s*\n([^\n]+)", prompt) + evaluation_path = helix_root / "evaluations" / f"{candidate_id}.json" + evaluation = ( + json.loads(evaluation_path.read_text()) + if evaluation_path.exists() + else None + ) + attempt_path = helix_root / "attempts" / f"{candidate_id}.json" + attempt = ( + json.loads(attempt_path.read_text()) + if attempt_path.exists() + else {} + ) + attempt_side_info = attempt.get("per_example_side_info", []) + sampled_side_info = attempt_side_info[0] if attempt_side_info else {} + backend_path = candidate_root / ".helix_backend_result.json" + backend = ( + json.loads(backend_path.read_text()) if backend_path.exists() else {} + ) + events = backend.get("parsed", {}).get("events", []) + timestamps = [ + float(event["timestamp"]) / 1000.0 + for event in events + if event.get("timestamp") is not None + ] + prompt_wait_seconds = 0.0 + text_generation_seconds = 0.0 + last_step_start: float | None = None + for event in events: + if event.get("type") == "step_start" and event.get("timestamp"): + last_step_start = float(event["timestamp"]) / 1000.0 + if event.get("type") != "text": + continue + timing = event.get("part", {}).get("time", {}) + start = timing.get("start") + end = timing.get("end") + if start is not None and last_step_start is not None: + prompt_wait_seconds += max(0.0, float(start) / 1000.0 - last_step_start) + if start is not None and end is not None: + text_generation_seconds += max( + 0.0, (float(end) - float(start)) / 1000.0 + ) + last_step_start = None + validation_vector: dict[str, float] = {} + if evaluation: + for side_info in evaluation.get("per_example_side_info", []): + key = str( + side_info.get("scenario_id") + or side_info.get("task") + or side_info.get("trial") + ) + validation_vector[key] = float(side_info.get("reward", 0.0)) + output.append( + { + **record, + "changed_files": changed_files, + "sampled_train_task": ( + task_match.group(1).strip() + if task_match + else sampled_side_info.get("task") + ), + "sampled_train_scenario": ( + scenario_match.group(1).strip() + if scenario_match + else sampled_side_info.get("scenario_id") + ), + "gate_result": ( + "seed" + if record.get("operation") == "seed" + else ( + ( + "passed_merge_validation_gate" + if evaluation is not None + else "rejected_merge" + ) + if record.get("operation") == "merge" + else ( + "passed_strict_train_gate" + if evaluation is not None + else ( + "rejected_minibatch_gate" + if attempt.get("attempt", {}).get("reason") + == "minibatch_gate" + else "failed_before_full_validation" + ) + ) + ) + ), + "validation_vector": validation_vector, + "agent_metrics": { + "model": backend.get("command", "").split("--model ", 1)[-1] + .split(" ", 1)[0] + .strip("'\""), + "total_seconds": ( + max(timestamps) - min(timestamps) if timestamps else 0.0 + ), + "prompt_wait_seconds": prompt_wait_seconds, + "text_generation_seconds": text_generation_seconds, + "usage": backend.get("usage", {}), + }, + "validation_simulator_seconds": sum( + float(item.get("elapsed_seconds") or 0.0) + for item in ( + evaluation.get("per_example_side_info", []) + if evaluation + else [] + ) + ), + } + ) + return output + + +def evolution_lesson(summary: Mapping[str, Any]) -> dict[str, Any]: + """Extract the specialist/frontier/merge teaching claims from a run.""" + candidates = summary.get("candidates", {}) + stack_specialists: list[str] = [] + wipe_specialists: list[str] = [] + broad_candidates: list[str] = [] + covered_difficult_keys: set[str] = set() + for candidate_id, candidate in candidates.items(): + scores = candidate.get("scores", {}) + stack = float(scores.get("stack_val", 0.0)) + wipe = float(scores.get("wipe_val", 0.0)) + if candidate.get("frontier"): + if "stack_val" in candidate.get("wins", []) and stack > 0.0: + covered_difficult_keys.add("stack_val") + if "wipe_val" in candidate.get("wins", []) and wipe > 0.0: + covered_difficult_keys.add("wipe_val") + if stack > 0.0 and wipe <= 0.0: + stack_specialists.append(candidate_id) + if wipe > 0.0 and stack <= 0.0: + wipe_specialists.append(candidate_id) + if stack > 0.0 and wipe > 0.0: + broad_candidates.append(candidate_id) + return { + "multi_key_frontier": len(covered_difficult_keys) == 2, + "covered_difficult_keys": sorted(covered_difficult_keys), + "specialist_pair": bool(stack_specialists and wipe_specialists), + "stack_specialists": stack_specialists, + "wipe_specialists": wipe_specialists, + "broad_candidates": broad_candidates, + "merge_attempted": bool(summary.get("merge_attempted_pairs")), + "merge_ancestry": summary.get("merge_ancestry", []), + } + + +def hidden_rollouts( + root: Path | str, + *, + trials: Mapping[str, int | Sequence[int]], + capture: bool = True, +) -> list[dict[str, Any]]: + """Run frozen policies on caller-selected trials never used by HELIX.""" + root = Path(root) + results = [] + for task, task_trials in trials.items(): + if isinstance(task_trials, int): + normalized_trials = [task_trials] + elif isinstance(task_trials, Sequence) and not isinstance(task_trials, (str, bytes)): + normalized_trials = [int(trial) for trial in task_trials] + else: + raise TypeError(f"Hidden trials for {task!r} must be an integer or sequence of integers") + if not normalized_trials: + raise ValueError(f"Hidden trials for {task!r} cannot be empty") + if len(set(normalized_trials)) != len(normalized_trials): + raise ValueError(f"Hidden trials for {task!r} must be unique") + + for trial in normalized_trials: + scenario = { + "task": task, + "trial": trial, + "policy_path": f"solver/tasks/{task}.py", + "config_path": CONFIGS[task], + } + result = score_scenario( + root, + "val", + f"hidden_{task}_{trial}", + scenario, + capture=capture, + ) + results.append(result) + return results + + +def summarize_rollouts(results: Sequence[Mapping[str, Any]]) -> dict[str, dict[str, Any]]: + """Aggregate repeated rollout evidence without hiding individual trials.""" + buckets: dict[str, list[Mapping[str, Any]]] = {} + for result in results: + task = str(result.get("task") or "") + if not task: + raise ValueError("Every hidden rollout must include its task") + buckets.setdefault(task, []).append(result) + + summary: dict[str, dict[str, Any]] = {} + for task, task_results in buckets.items(): + count = len(task_results) + completed = sum(bool(result.get("task_completed")) for result in task_results) + rewards = [float(result.get("reward") or 0.0) for result in task_results] + raw_rewards = [float(result.get("raw_reward") or 0.0) for result in task_results] + execution_failures = sum( + bool(result.get("stderr") or result.get("traceback") or result.get("timed_out")) + for result in task_results + ) + summary[task] = { + "rollouts": count, + "trials": [int(result["trial"]) for result in task_results], + "completed": completed, + "completion_rate": completed / count, + "mean_reward": sum(rewards) / count, + "mean_raw_reward": sum(raw_rewards) / count, + "execution_failures": execution_failures, + } + return summary + + +def deployment_success_criterion( + before: Mapping[str, Mapping[str, Any]], + after: Mapping[str, Mapping[str, Any]], + *, + required_tasks: Sequence[str] = tuple(CONFIGS), + minimum_hard_reward_gain: float = 0.05, +) -> dict[str, Any]: + """Compare repeated before/after rollouts across required evolved tasks.""" + tasks = tuple(str(task) for task in required_tasks) + if not tasks: + raise ValueError("At least one evolved task is required") + if len(set(tasks)) != len(tasks): + raise ValueError("Required evolved tasks must be unique") + if minimum_hard_reward_gain < 0.0: + raise ValueError("minimum_hard_reward_gain must be non-negative") + + missing = [task for task in tasks if task not in before or task not in after] + if missing: + raise ValueError( + f"Missing deployment rollout summaries for: {', '.join(missing)}" + ) + + rollout_counts_before = { + task: int(before[task]["rollouts"]) for task in tasks + } + rollout_counts_after = { + task: int(after[task]["rollouts"]) for task in tasks + } + nonpositive = [ + task + for task in tasks + if rollout_counts_before[task] <= 0 or rollout_counts_after[task] <= 0 + ] + if nonpositive: + raise ValueError( + "Deployment rollout counts must be positive for: " + + ", ".join(nonpositive) + ) + mismatched = [ + task + for task in tasks + if rollout_counts_before[task] != rollout_counts_after[task] + ] + if mismatched: + raise ValueError( + "Before/after rollout counts must match for: " + ", ".join(mismatched) + ) + + rollouts = sum(rollout_counts_before.values()) + completed_before = sum(int(before[task]["completed"]) for task in tasks) + completed_after = sum(int(after[task]["completed"]) for task in tasks) + mean_reward_before = sum( + float(before[task]["mean_reward"]) * rollout_counts_before[task] + for task in tasks + ) / rollouts + mean_reward_after = sum( + float(after[task]["mean_reward"]) * rollout_counts_after[task] + for task in tasks + ) / rollouts + completion_improved = completed_after > completed_before + reward_improved = ( + completed_after == completed_before + and mean_reward_after >= mean_reward_before + minimum_hard_reward_gain + ) + deployment_improved = completion_improved or reward_improved + + return { + "required_tasks": list(tasks), + "rollouts": rollouts, + "completed_before": completed_before, + "completed_after": completed_after, + "completion_rate_before": completed_before / rollouts, + "completion_rate_after": completed_after / rollouts, + "mean_reward_before": mean_reward_before, + "mean_reward_after": mean_reward_after, + "minimum_hard_reward_gain": minimum_hard_reward_gain, + "completion_improved": completion_improved, + "reward_improved": reward_improved, + "deployment_improved": deployment_improved, + "met": deployment_improved, + } + + +def live_smoke() -> int: + """Run one real validation scenario from a spawn-safe module entrypoint.""" + root = Path("/tmp/rho_multitask_smoke/candidate") + try: + ensure_services() + prepare_workshop(root) + scenario_id, scenario = resolve_scenarios(root, "val", ["0"])[0] + result = score_scenario( + root, + "val", + scenario_id, + scenario, + timeout_seconds=180.0, + ) + print(json.dumps(result, indent=2)) + return int( + result.get("timed_out") + or result.get("raw_reward") is None + or bool(result.get("traceback")) + ) + finally: + rho_demo.stop_owned_services() + + +def _main(argv: Sequence[str]) -> int: + command = argv[0] if argv else "" + if command == "prepare": + print(prepare_workshop()) + return 0 + if command == "evaluate": + return evaluate_cli() + if command == "run": + result = run_helix() + print(json.dumps(rho_demo.asdict(result), indent=2)) + return result.returncode + if command == "frontier": + print(json.dumps(frontier_summary(), indent=2)) + return 0 + if command == "live-smoke": + return live_smoke() + print( + "usage: rho_multitask_demo.py " + "{prepare|evaluate|run|frontier|live-smoke}" + ) + return 2 + + +if __name__ == "__main__": + raise SystemExit(_main(sys.argv[1:])) diff --git a/projects/LocalInference/scripts/rho_multitask_study.py b/projects/LocalInference/scripts/rho_multitask_study.py new file mode 100644 index 00000000..82b206d4 --- /dev/null +++ b/projects/LocalInference/scripts/rho_multitask_study.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Run and record the two-task RHO repository-evolution study.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +import time +from dataclasses import asdict +from pathlib import Path +from time import monotonic + + +HIDDEN_TRIALS = { + "cube_stack": [8740, 9351, 6027, 7419, 4883], + "spill_wipe": [1854, 3167, 5279, 6481, 7903], +} + +RESULT_KEYS = ( + "scenario_id", + "task", + "trial", + "reward", + "raw_reward", + "task_completed", + "timed_out", + "stderr", + "traceback", + "feedback", + "video", + "elapsed_seconds", +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument( + "--model", + default="user.Qwen3-Coder-30B-A3B-Instruct-Q4_K_M", + ) + parser.add_argument("--generations", type=int, default=2, choices=(2, 3, 4)) + parser.add_argument("--timeout", type=float, default=1200.0) + return parser.parse_args() + + +def compact(result: dict) -> dict: + return {key: result.get(key) for key in RESULT_KEYS} + + +def evaluate_split(experiment, root: Path, split: str) -> list[dict]: + names = json.loads((root / "scenarios.json").read_text())["splits"][split] + resolved = experiment.resolve_scenarios( + root, + split, + [str(index) for index in range(len(names))], + ) + return [ + compact( + experiment.score_scenario( + root, + split, + scenario_id, + scenario, + timeout_seconds=180.0, + ) + ) + for scenario_id, scenario in resolved + ] + + +def selected_candidate(root: Path, frontier: dict) -> tuple[str, Path]: + candidate_id = max( + frontier["candidates"], + key=lambda current: ( + sum(frontier["candidates"][current]["scores"].values()), + current, + ), + ) + worktree = root / ".helix" / "worktrees" / candidate_id + return candidate_id, worktree if worktree.is_dir() else root + + +def main() -> int: + args = parse_args() + output_dir = args.output_dir.expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + os.environ["RHO_MODEL"] = args.model + os.environ["RHO_MULTITASK_MODEL"] = args.model + os.environ["RHO_WORKSHOP_ROOT"] = str(output_dir) + os.environ["RHO_VIDEO_ROOT"] = str(output_dir / "videos") + os.environ.setdefault("RHO_SUPPORT_ROOT", str(Path(__file__).resolve().parent)) + + import rho_demo + import rho_multitask_demo as experiment + + root = output_dir / "candidate" + report_path = output_dir / "rho_multitask_report.json" + try: + setup_started = monotonic() + experiment.ensure_services() + setup_seconds = monotonic() - setup_started + + experiment.prepare_workshop( + root, + model=args.model, + generations=args.generations, + ) + manifest = json.loads((root / "scenarios.json").read_text()) + provenance = json.loads((root / "provenance.json").read_text()) + + baseline_started = monotonic() + baseline_validation = evaluate_split(experiment, root, "val") + hidden_before = [ + compact(result) + for result in experiment.hidden_rollouts( + root, + trials=HIDDEN_TRIALS, + capture=True, + ) + ] + baseline_seconds = monotonic() - baseline_started + + evolution_started = monotonic() + run = experiment.run_helix( + root, + generations=args.generations, + timeout_seconds=args.timeout, + ) + evolution_seconds = monotonic() - evolution_started + frontier = experiment.frontier_summary(root) + lesson = experiment.evolution_lesson(frontier) + best_id, best_root = selected_candidate(root, frontier) + + hidden_after_started = monotonic() + hidden_after = [ + compact(result) + for result in experiment.hidden_rollouts( + best_root, + trials=HIDDEN_TRIALS, + capture=True, + ) + ] + hidden_after_seconds = monotonic() - hidden_after_started + + before_summary = experiment.summarize_rollouts(hidden_before) + after_summary = experiment.summarize_rollouts(hidden_after) + criterion = experiment.deployment_success_criterion( + before_summary, + after_summary, + ) + lineage = frontier["lineage"] + report = { + "schema_version": "rho-multitask-helix-report/v2", + "mode": "live_capx", + "recorded_fallback": False, + "study_provenance": { + "created_at": time.strftime( + "%Y-%m-%dT%H:%M:%SZ", + time.gmtime(), + ), + "hostname": socket.gethostname(), + "image_id": os.environ.get("EXPERIMENT_IMAGE_ID"), + "source_revision": os.environ.get("EXPERIMENT_SOURCE_REVISION"), + }, + "mutation_model_loader_alias": args.model, + "mutation_model_api_id": experiment.opencode_model_id(args.model), + "seed_model": provenance["seed_model"], + "generations": args.generations, + "proposal_slots_per_generation": 2, + "manifest": manifest, + "provenance": provenance, + "baseline_validation": baseline_validation, + "helix": asdict(run), + "frontier": frontier, + "lesson": lesson, + "selected_candidate": best_id, + "selected_diff": rho_demo.source_diff(root, best_root), + "hidden_trials_used_by_evolution": False, + "hidden_rollouts_per_task": 5, + "hidden_before": hidden_before, + "hidden_after": hidden_after, + "hidden_before_summary": before_summary, + "hidden_after_summary": after_summary, + "success_criterion": criterion, + "timing": { + "setup_seconds": setup_seconds, + "baseline_seconds": baseline_seconds, + "evolution_seconds": evolution_seconds, + "hidden_after_seconds": hidden_after_seconds, + "agent_event_span_seconds": sum( + float(item.get("agent_metrics", {}).get("total_seconds", 0.0)) + for item in lineage + ), + "retained_validation_simulator_seconds": sum( + float(item.get("validation_simulator_seconds", 0.0)) + for item in lineage + ), + }, + } + report_path.write_text(json.dumps(report, indent=2) + "\n") + print( + "RHO_MULTITASK_RESULT=" + + json.dumps( + { + "selected_candidate": best_id, + "criterion_met": criterion["met"], + "completed_before": criterion["completed_before"], + "completed_after": criterion["completed_after"], + "mean_reward_before": criterion["mean_reward_before"], + "mean_reward_after": criterion["mean_reward_after"], + "timed_out": run.timed_out, + "returncode": run.returncode, + "report": str(report_path), + }, + separators=(",", ":"), + ) + ) + return 124 if run.timed_out else run.returncode + finally: + rho_demo.stop_owned_services() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/projects/LocalInference/scripts/rho_study.py b/projects/LocalInference/scripts/rho_study.py new file mode 100644 index 00000000..70207e5b --- /dev/null +++ b/projects/LocalInference/scripts/rho_study.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Run reproducible single-task Robotics Harness Optimization studies.""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import sys +import time +from dataclasses import asdict +from pathlib import Path + + +SHARED_GEOMETRY = """\ +import numpy + + +def stack_center(target_position, target_extent, object_extent): + \"\"\"Return the XYZ center for stacking one object on a target object.\"\"\" + center = numpy.asarray(target_position, dtype=float).copy() + center[2] += float(target_extent[2] + object_extent[2]) / 2.0 + return center +""" + +SHARED_OBJECTIVE = """\ +Repair this authentic CaP-X cube-stack policy so it works across layouts. +Use the pure geometry helper in solver/geometry.py for the placement-center +calculation, and correct any misuse of the flat XYZ poses returned by the API.""" + +SHARED_BACKGROUND = """\ +This one-generation study exposes a generated task policy plus a reusable pure +geometry module. Read the evaluator diagnostics and API_REFERENCE.md, then edit +files only below solver/. Do not encode trial IDs or fixed poses. Imported +helpers may perform calculations but cannot directly call robot primitives. +Compile solver/*.py, run the permitted evaluator self-check once, and inspect +the source diff before finishing.""" + +RESTACK_CONFIG = "env_configs/cube_restack/franka_robosuite_cube_restack.yaml" +RESTACK_API_REFERENCE = """\ +# CaP-X cube-restack contract + +Repair an authentic generated policy that should gently place the red cube, +already held by the gripper at episode start, on top of the green cube and then +open the gripper. + +- `get_object_pose(name, return_bbox_extent=True)` returns a flat XYZ center, + WXYZ quaternion, and full XYZ side lengths. +- The target red-cube center height is the green center plus half the green + height and half the red height. +- Use a reliable downward or held-object orientation for placement. +- `goto_pose(position, quaternion, z_approach=0.1)` performs a controlled + approach. Do not drop the cube from a height. +- `open_gripper()` releases the cube after reaching the placement pose. +- Robot primitives are already injected; import numerical libraries explicitly. + +The evaluator uses the artifact's source trial for training and a separate +held-out trial for validation. Only edit files below `solver/`. +""" +RESTACK_OBJECTIVE = """\ +Repair this authentic CaP-X cube-restack program so it reliably places the +already-held red cube on the green cube and releases it without dropping. +Diagnose evaluator feedback and use scene-derived positions and full extents; +do not encode trial-specific coordinates.""" +RESTACK_BACKGROUND = """\ +This is a bounded cube-restack mutation-depth study. Read API_REFERENCE.md and +the evaluator diagnostics, then edit only solver/. The red cube begins in the +gripper, so focus on general placement geometry, controlled approach, and +release rather than adding an unrelated pickup sequence. Compile solver/*.py, +run the permitted evaluator self-check once, and inspect the diff.""" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--model", required=True) + parser.add_argument("--generations", type=int, default=1, choices=range(1, 5)) + parser.add_argument("--artifact", type=Path) + parser.add_argument( + "--config-path", + default=None, + ) + parser.add_argument( + "--task", + choices=("cube-stack", "cube-restack"), + default="cube-stack", + ) + parser.add_argument("--heldout-trial", type=int, default=2) + parser.add_argument( + "--surface", + choices=("single-policy", "one-task-shared-helper"), + default="single-policy", + ) + parser.add_argument("--objective") + parser.add_argument("--background") + parser.add_argument("--timeout", type=float, default=480.0) + parser.add_argument("--capture", action="store_true") + return parser.parse_args() + + +def compact_result(result: dict) -> dict: + keys = ( + "reward", + "raw_reward", + "task_completed", + "split", + "trial", + "feedback", + "traceback", + "video", + "timed_out", + "elapsed_seconds", + ) + return {key: result.get(key) for key in keys} + + +def main() -> int: + args = parse_args() + if args.task == "cube-restack" and args.surface != "single-policy": + raise SystemExit("cube-restack currently supports only the single-policy surface") + output_dir = args.output_dir.expanduser().resolve() + output_dir.mkdir(parents=True, exist_ok=True) + config_path = args.config_path or ( + RESTACK_CONFIG + if args.task == "cube-restack" + else "env_configs/cube_stack/franka_robosuite_cube_stack.yaml" + ) + + os.environ["RHO_MODEL"] = args.model + os.environ["RHO_CONFIG_PATH"] = config_path + os.environ["RHO_WORKSHOP_ROOT"] = str(output_dir) + os.environ["RHO_VIDEO_ROOT"] = str(output_dir / "videos") + os.environ.setdefault( + "RHO_SUPPORT_ROOT", + str(Path(__file__).resolve().parent), + ) + + # Import only after the model, config, and output roots are fixed because + # rho_demo intentionally captures those settings at module import time. + import rho_demo + + prepare_kwargs = { + "root": output_dir / "candidate", + "artifact": args.artifact, + "heldout_trial": args.heldout_trial, + "generations": args.generations, + } + if args.objective: + prepare_kwargs["objective"] = args.objective + if args.background: + prepare_kwargs["background"] = args.background + if args.task == "cube-restack": + prepare_kwargs.update( + { + "api_reference": RESTACK_API_REFERENCE, + "objective": args.objective or RESTACK_OBJECTIVE, + "background": args.background or RESTACK_BACKGROUND, + } + ) + if args.surface == "one-task-shared-helper": + prepare_kwargs.update( + { + "objective": args.objective or SHARED_OBJECTIVE, + "background": args.background or SHARED_BACKGROUND, + "api_reference": ( + rho_demo.API_REFERENCE + + "\n- `solver.geometry.stack_center(...)` computes the " + "placement center from flat XYZ inputs and full extents.\n" + ), + "support_files": { + "solver/geometry.py": SHARED_GEOMETRY, + }, + } + ) + + report_path = output_dir / "report.json" + try: + rho_demo.ensure_services(model=args.model) + root = rho_demo.prepare_workshop(**prepare_kwargs) + before_train = rho_demo.score_candidate(root, "train") + before_val = rho_demo.score_candidate(root, "val", capture=args.capture) + run = rho_demo.run_helix( + root, + generations=args.generations, + timeout_seconds=args.timeout, + progress=lambda line: print(line, flush=True), + ) + summary = rho_demo.summarize_run(root) + best = Path(summary["live_best"]) + after_train = rho_demo.score_candidate(best, "train") + after_val = rho_demo.score_candidate(best, "val", capture=args.capture) + report = { + "schema_version": "rho-study/v1", + "provenance": { + "created_at": time.strftime( + "%Y-%m-%dT%H:%M:%SZ", + time.gmtime(), + ), + "hostname": socket.gethostname(), + "image_id": os.environ.get("EXPERIMENT_IMAGE_ID"), + "source_revision": os.environ.get("EXPERIMENT_SOURCE_REVISION"), + }, + "model": args.model, + "task": args.task, + "surface": args.surface, + "config_path": config_path, + "generations": args.generations, + "artifact": str(args.artifact) if args.artifact else None, + "before": { + "train": compact_result(before_train), + "val": compact_result(before_val), + }, + "helix": asdict(run), + "summary": summary, + "after": { + "train": compact_result(after_train), + "val": compact_result(after_val), + }, + } + report_path.write_text(json.dumps(report, indent=2, default=str) + "\n") + print( + "RHO_STUDY_RESULT=" + + json.dumps( + { + "model": args.model, + "surface": args.surface, + "generations": args.generations, + "accepted": summary["accepted"], + "before_reward": before_val["reward"], + "after_reward": after_val["reward"], + "completed": after_val["task_completed"], + "helix_seconds": run.elapsed_seconds, + "report": str(report_path), + }, + separators=(",", ":"), + ) + ) + return 124 if run.timed_out else run.returncode + finally: + rho_demo.stop_owned_services() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/projects/LocalInference/temp_evolving_rai.ipynb b/projects/LocalInference/temp_evolving_rai.ipynb new file mode 100644 index 00000000..d6702696 --- /dev/null +++ b/projects/LocalInference/temp_evolving_rai.ipynb @@ -0,0 +1,408 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Evolving a RAI Agent in One Workshop Generation\n", + "\n", + "This notebook is the RAI counterpart to the toy RHO/CaP-X exercise. It does **not** reproduce the O3DE paper experiment or replay its results.\n", + "\n", + "Instead, a real RAI tool-calling agent controls a tiny in-memory tabletop world. Its repository begins with two related defects: the prompt reverses the left/right coordinate convention, and `tools.py` removes the sign from requested coordinates. We then:\n", + "\n", + "1. run the broken seed agent on one held-out object;\n", + "2. give HELIX/OpenCode one generation to edit `solver/prompt.py` and `solver/tools.py`;\n", + "3. display the selected diff and the complete evolved prompt and tools;\n", + "4. rerun the same held-out task and require a passing manipulation test.\n", + "\n", + "The world is deterministic and simulator-free, so the exercise demonstrates repository evolution in minutes while still using RAI for the actual agent/tool loop." + ], + "id": "37ef1f38" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The toy repository-as-policy\n", + "\n", + "The candidate is a normal Git repository:\n", + "\n", + "```text\n", + "candidate/\n", + " solver/\n", + " prompt.py # mutable RAI system prompt\n", + " tools.py # mutable LangChain tools used by RAI\n", + " CONTRACT.md # protected task and API contract\n", + " scenarios.json # protected train/validation/test IDs\n", + " probe.py # protected HELIX evaluator\n", + " helix.toml # protected evolution policy\n", + " opencode.json # protected edit permissions\n", + "```\n", + "\n", + "The evaluator creates a fresh in-memory world for each task, builds the candidate's tools, and runs `rai.agents.langchain.create_conversational_agent`. HELIX sees a red-cube training task and a blue-cylinder validation task. The green-cube test remains outside evolution. Only files under `solver/` are editable." + ], + "id": "4962c3f2" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "import hashlib\n", + "import json\n", + "import os\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "from IPython.display import Markdown, display\n", + "\n", + "sys.path.insert(0, \"/ryzers/notebooks/scripts\")\n", + "\n", + "import rai_toy_demo as rai_demo\n", + "\n", + "ROOT = Path(\"/tmp/rai_toy_notebook/candidate\")\n", + "MODEL = \"Gemma-4-E2B-it-GGUF\"\n", + "GENERATIONS = 1\n", + "HELIX_TIMEOUT_SECONDS = 600\n", + "TASK_TIMEOUT_SECONDS = 120\n", + "TEST_TASK = \"test-green-cube\"\n", + "\n", + "# Mock mode exists for CI plumbing only; the workshop must run the real RAI loop.\n", + "assert os.environ.get(\"RAI_TOY_MOCK\") != \"1\", (\n", + " \"Unset RAI_TOY_MOCK before running this live RAI notebook.\"\n", + ")\n", + "\n", + "print(\"RAI/OpenCode model:\", MODEL)\n", + "print(\"Evolution budget:\", GENERATIONS, \"generation\")\n", + "print(\"Held-out task:\", TEST_TASK)" + ], + "execution_count": null, + "outputs": [], + "id": "917d44d6" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Load the model and build the broken seed\n", + "\n", + "The same local Gemma E2B model plays two roles sequentially: RAI decides which tools to call during evaluation, and OpenCode edits the repository during mutation. Its GGUF is baked into `/opt/lemonade-cache`, so loading it does not trigger a network download. There is no O3DE, ROS graph, perception model, or GPU renderer to start.\n", + "\n", + "`prepare_workshop()` creates fresh train, validation, and test scenarios. The test ID is protected and never included in HELIX batches." + ], + "id": "41eecdc0" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "rai_demo.ensure_model(MODEL)\n", + "ROOT = rai_demo.prepare_workshop(\n", + " ROOT,\n", + " model=MODEL,\n", + " generations=GENERATIONS,\n", + ")\n", + "\n", + "manifest = json.loads((ROOT / \"scenarios.json\").read_text())\n", + "assert \"test\" not in manifest[\"splits\"]\n", + "assert TEST_TASK not in manifest[\"scenarios\"]\n", + "assert manifest[\"test_exposed_to_evolution\"] is False\n", + "\n", + "print(\"Candidate repository:\", ROOT)\n", + "print(\"Train:\", manifest[\"splits\"][\"train\"])\n", + "print(\"Validation:\", manifest[\"splits\"][\"val\"])\n", + "print(\"Held-out test (not stored in candidate):\", TEST_TASK)" + ], + "execution_count": null, + "outputs": [], + "id": "2438dae4" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Inspect the seed prompt and tools\n", + "\n", + "The defect is intentionally small enough to understand during a workshop. The prompt says positive y is left, while the world says negative y is left. The movement wrapper then applies `abs()` to every requested y value, so even a correct negative target becomes positive.\n", + "\n", + "The evaluator returns the actual RAI tool trace and final world state. OpenCode receives those diagnostics but cannot edit the evaluator or scenarios." + ], + "id": "e7d2db78" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "SEED_PROMPT = (ROOT / \"solver/prompt.py\").read_text()\n", + "SEED_TOOLS = (ROOT / \"solver/tools.py\").read_text()\n", + "SEED_SHA256 = hashlib.sha256((SEED_PROMPT + SEED_TOOLS).encode()).hexdigest()\n", + "\n", + "print(\"--- solver/prompt.py ---\")\n", + "print(SEED_PROMPT)\n", + "print(\"--- relevant solver/tools.py line ---\")\n", + "for line in SEED_TOOLS.splitlines():\n", + " if \"normalized_y\" in line:\n", + " print(line)\n", + "print(\"Seed policy SHA-256:\", SEED_SHA256)" + ], + "execution_count": null, + "outputs": [], + "id": "ae2d3245" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Run the broken seed on the held-out task\n", + "\n", + "RAI receives: “Move the green cube into the left target centered at y=-0.50.” The toy world records every tool call independently of the model's prose. Because the seed movement tool folds `-0.50` to `+0.50`, this policy cannot pass.\n", + "\n", + "This held-out result is shown to workshop participants, but it is not written into the candidate repository or exposed to HELIX." + ], + "id": "59cacc04" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "before = rai_demo.score_candidate(\n", + " ROOT,\n", + " TEST_TASK,\n", + " timeout_seconds=TASK_TIMEOUT_SECONDS,\n", + ")\n", + "assert before[\"side_info\"][\"is_live_rai\"] is True\n", + "assert before[\"passed\"] is False, \"The deliberately broken seed unexpectedly passed\"\n", + "assert (ROOT / \"solver/prompt.py\").read_text() == SEED_PROMPT\n", + "assert (ROOT / \"solver/tools.py\").read_text() == SEED_TOOLS\n", + "\n", + "display({\n", + " \"score\": before[\"score\"],\n", + " \"passed\": before[\"passed\"],\n", + " \"final_y\": before[\"side_info\"][\"final_y\"],\n", + " \"target_y\": before[\"side_info\"][\"target_y\"],\n", + " \"tool_trace\": before[\"side_info\"][\"tool_trace\"],\n", + " \"agent_response\": before[\"side_info\"][\"agent_response\"],\n", + "})" + ], + "execution_count": null, + "outputs": [], + "id": "ae93c10f" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Run one HELIX/OpenCode generation\n", + "\n", + "HELIX evaluates the seed on the red-cube training task, asks OpenCode to edit both solver files, and checks the child on the blue-cylinder validation task. Strict improvement decides whether the child enters the frontier.\n", + "\n", + "The evaluator feedback contains the RAI tool trace and final coordinate. OpenCode can also read `CONTRACT.md`, which explains the required signed-coordinate behavior. The green-cube task remains held out." + ], + "id": "58362ab9" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "helix_run = rai_demo.run_helix(\n", + " ROOT,\n", + " generations=GENERATIONS,\n", + " timeout_seconds=HELIX_TIMEOUT_SECONDS,\n", + ")\n", + "summary = rai_demo.summarize_run(ROOT)\n", + "\n", + "print(\n", + " f\"HELIX exit={helix_run.returncode} timed_out={helix_run.timed_out} \"\n", + " f\"elapsed={helix_run.elapsed_seconds:.1f}s\"\n", + ")\n", + "print(\"Accepted improved repository:\", summary[\"improved_best\"])\n", + "print(\"Frontier:\", summary[\"frontier\"])\n", + "\n", + "if helix_run.timed_out:\n", + " raise TimeoutError(\"HELIX exceeded the workshop deadline\")\n", + "if helix_run.returncode != 0:\n", + " raise RuntimeError(helix_run.stdout[-6000:])\n", + "if not summary[\"improved_best\"]:\n", + " raise RuntimeError(\n", + " \"No improved child was selected. Inspect the HELIX output and rerun \"\n", + " \"this one-generation cell before continuing.\"\n", + " )\n", + "\n", + "EVOLVED_ROOT = Path(summary[\"best\"])\n", + "print(\"Selected repository:\", EVOLVED_ROOT)" + ], + "execution_count": null, + "outputs": [], + "id": "9f11e790" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Show exactly what evolved\n", + "\n", + "Unlike a model-weight update, repository evolution leaves a reviewable software artifact. The next cell prints the unified diff followed by the complete selected `prompt.py` and `tools.py`, so participants can inspect both the language-level and implementation-level repair." + ], + "id": "2dc7539e" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "EVOLVED_PROMPT = (EVOLVED_ROOT / \"solver/prompt.py\").read_text()\n", + "EVOLVED_TOOLS = (EVOLVED_ROOT / \"solver/tools.py\").read_text()\n", + "EVOLVED_SHA256 = hashlib.sha256(\n", + " (EVOLVED_PROMPT + EVOLVED_TOOLS).encode()\n", + ").hexdigest()\n", + "\n", + "print(\"--- accepted source diff ---\")\n", + "print(summary[\"diff\"])\n", + "print(\"\\n--- evolved solver/prompt.py ---\")\n", + "print(EVOLVED_PROMPT)\n", + "print(\"\\n--- evolved solver/tools.py ---\")\n", + "print(EVOLVED_TOOLS)\n", + "print(\"Evolved policy SHA-256:\", EVOLVED_SHA256)" + ], + "execution_count": null, + "outputs": [], + "id": "dbbd9ae0" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Rerun the same held-out manipulation test\n", + "\n", + "The selected repository now controls a fresh RAI agent in a freshly reset world. The task, green object, initial coordinate, target coordinate, model, and evaluator are unchanged. Only the repository differs.\n", + "\n", + "A pass requires all of the following: the evolved prompt states the correct convention, RAI observes before moving, the movement tool preserves a negative target, and the green cube finishes at y=-0.50." + ], + "id": "99b7f59e" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "after = rai_demo.score_candidate(\n", + " EVOLVED_ROOT,\n", + " TEST_TASK,\n", + " timeout_seconds=TASK_TIMEOUT_SECONDS,\n", + ")\n", + "assert after[\"side_info\"][\"is_live_rai\"] is True\n", + "\n", + "comparison = {\n", + " \"seed_score\": before[\"score\"],\n", + " \"evolved_score\": after[\"score\"],\n", + " \"seed_passed\": before[\"passed\"],\n", + " \"evolved_passed\": after[\"passed\"],\n", + " \"target_y\": after[\"side_info\"][\"target_y\"],\n", + " \"final_y\": after[\"side_info\"][\"final_y\"],\n", + " \"tool_trace\": after[\"side_info\"][\"tool_trace\"],\n", + " \"agent_response\": after[\"side_info\"][\"agent_response\"],\n", + "}\n", + "display(comparison)\n", + "\n", + "if not after[\"passed\"]:\n", + " raise RuntimeError(\n", + " \"The selected repository did not pass the held-out RAI task. Rerun the \"\n", + " \"one-generation evolution cell to obtain another live mutation.\"\n", + " )\n", + "print(\"PASS: evolved RAI policy moved the held-out green cube to the left target.\")" + ], + "execution_count": null, + "outputs": [], + "id": "9ee06834" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "report = {\n", + " \"schema_version\": \"rai-toy-repository-evolution/v1\",\n", + " \"benchmark\": \"rai_toy_in_memory_manipulation\",\n", + " \"is_live_rai\": True,\n", + " \"model\": MODEL,\n", + " \"generations\": GENERATIONS,\n", + " \"splits\": manifest[\"splits\"],\n", + " \"held_out_test\": TEST_TASK,\n", + " \"test_exposed_to_evolution\": False,\n", + " \"seed_policy_sha256\": SEED_SHA256,\n", + " \"evolved_policy_sha256\": EVOLVED_SHA256,\n", + " \"accepted_diff\": summary[\"diff\"],\n", + " \"before\": before,\n", + " \"after\": after,\n", + "}\n", + "report_path = ROOT.parent / \"rai_toy_one_generation_report.json\"\n", + "report_path.write_text(json.dumps(report, indent=2) + \"\\n\")\n", + "print(\"Wrote\", report_path)" + ], + "execution_count": null, + "outputs": [], + "id": "44aa0f7a" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "display(Markdown(f\"\"\"\n", + "## Live result\n", + "\n", + "- Seed held-out test: **{'PASS' if before['passed'] else 'FAIL'}** (score {before['score']:.2f})\n", + "- Evolved held-out test: **{'PASS' if after['passed'] else 'FAIL'}** (score {after['score']:.2f})\n", + "- HELIX generations: **{GENERATIONS}**\n", + "- Evolution wall time: **{helix_run.elapsed_seconds:.1f} seconds**\n", + "- Changed policy: **{SEED_SHA256 != EVOLVED_SHA256}**\n", + "\n", + "This is a live toy RAI result from the current workshop run—not a reproduced or prerecorded O3DE result. The selected prompt and tools printed above are the exact files used by the passing test.\n", + "\"\"\"))" + ], + "execution_count": null, + "outputs": [], + "id": "a5adee7c" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## What this demonstrates\n", + "\n", + "RAI remains the runtime agent: it interprets the natural-language task and decides when and how to invoke candidate tools. HELIX does not update model weights; it selects a new versioned prompt-and-tools repository from evaluator feedback.\n", + "\n", + "The toy boundary is intentionally replaceable. A larger project can keep the same structure while swapping `ToyWorld` for ROS 2, O3DE, or hardware:\n", + "\n", + "1. keep prompts and tools in a narrow mutable directory;\n", + "2. keep task reset, scoring, and split definitions protected;\n", + "3. return traces and errors that help mutation without leaking test answers;\n", + "4. freeze the selected repository before final deployment tests.\n", + "\n", + "The implementation used here is `scripts/rai_toy_demo.py`. Its static contract test is `tests/test_rai_toy_evolution.sh`." + ], + "id": "b8fa725d" + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "print(\"Candidate repository:\", ROOT)\n", + "print(\"Selected repository:\", EVOLVED_ROOT)\n", + "print(\"Run report:\", report_path)\n", + "print(\"Lemonade remains loaded for the next workshop notebook.\")" + ], + "execution_count": null, + "outputs": [], + "id": "7b3624e1" + } + ], + "metadata": { + "kernelspec": { + "display_name": "RAI (ROCm)", + "language": "python", + "name": "rai" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/projects/LocalInference/tests/test_capx.sh b/projects/LocalInference/tests/test_capx.sh new file mode 100755 index 00000000..e7d8a91a --- /dev/null +++ b/projects/LocalInference/tests/test_capx.sh @@ -0,0 +1,205 @@ +#!/bin/bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# Test for the CaP-X stack used by 3_code_as_policy.ipynb. +# Three stages: +# 1. Sign-of-life: ROCm torch sees the GPU, the stack and Robosuite import, +# MuJoCo renders headless through EGL, and the ungated OWLv2 + SAM2 +# weights are in the image cache. +# 2. Oracle eval (always runs, no LLM): one full CaP-Gym episode driven by the +# environment's ground-truth code through the real sim + PyRoKi IK +# pipeline, asserting task success (reward 1.0). This is the deterministic +# correctness check for the eval pipeline on ROCm. +# 3. LLM eval (optional): a short real agentic eval - the model writes code, +# the sim executes it, the run is scored. Runs against Lemonade if it is +# already serving, or against any OpenAI-compatible endpoint given in +# CAPX_LLM_SERVER_URL. +set -euo pipefail + +CAPX_ROOT="${CAPX_ROOT:-/ryzers/cap-x}" +CAPX_PY="${CAPX_VENV:-/opt/capx-venv}/bin/python" +# The CaP-X kernel sets this too; a terminal run needs it to find the +# pre-fetched perception weights. +export HF_HOME="${CAPX_CACHE:-/opt/capx-cache}" +export MUJOCO_GL=egl +export PYOPENGL_PLATFORM=egl + +cd "${CAPX_ROOT}" + +echo "================ [1/3] CaP-X / ROCm sign-of-life ================" +"${CAPX_PY}" -c " +import torch +print(f'PyTorch : {torch.__version__}') +print(f'ROCm/HIP: {torch.version.hip}') +print(f'GPU ok : {torch.cuda.is_available()}') +if torch.version.hip is None: + raise SystemExit('This is not a ROCm/HIP PyTorch build') +if not torch.cuda.is_available(): + raise SystemExit('No ROCm GPU visible - check /dev/kfd and /dev/dri') +for i in range(torch.cuda.device_count()): + print(f' device {i}: {torch.cuda.get_device_name(i)}') +" + +# jax is CPU-only by design (matches CaP-X's lock: jax 0.4.29, no GPU plugin). +"${CAPX_PY}" -c " +import capx, contact_graspnet_pytorch +from capx.integrations.vision.owlvit import init_owlvit +from capx.integrations.vision.sam2 import init_sam2 +import jax; print('jax', jax.__version__, 'devices (CPU expected):', jax.devices()) +import pyroki, jaxls +import robosuite; print('robosuite', robosuite.__version__) +from capx.integrations import list_apis +assert 'FrankaControlApi' in list_apis(), 'perception API not registered' +print('capx + pyroki + API import OK') +" + +"${CAPX_PY}" -c " +import sys +sys.path.insert(0, '/ryzers/notebooks/scripts') +from capx_demo import analyze_program +analysis = analyze_program(''' +pose, _, extent = get_object_pose(\"red cube\", return_bbox_extent=True) +grasp, quat = sample_grasp_pose(\"red cube\") +goto_pose(grasp, quat, z_approach=0.1) +close_gripper() +''') +assert analysis['syntax_error'] is None +assert analysis['perception_calls'] == 2 +assert analysis['planner_calls'] == 1 +assert analysis['uses_bbox_extent'] +assert analysis['uses_approach_offset'] +print('generated-program introspection helpers OK') +" + +"${CAPX_PY}" -c " +import mujoco +m = mujoco.MjModel.from_xml_string('') +d = mujoco.MjData(m) +r = mujoco.Renderer(m, 64, 64) +try: + mujoco.mj_forward(m, d) + r.update_scene(d) + print('EGL render OK, frame shape', r.render().shape) +finally: + r.close() +" + +"${CAPX_PY}" -c " +from pathlib import Path +config = Path('${CAPX_ROOT}/env_configs/cube_stack/franka_robosuite_cube_stack.yaml') +assert config.is_file(), f'{config} missing' +fast_config = config.with_name('franka_robosuite_cube_stack_fast.yaml') +assert fast_config.is_file(), f'{fast_config} missing' +hub = Path('${HF_HOME}/hub') +repos = { + 'models--facebook--sam2.1-hiera-small', + 'models--google--owlv2-base-patch16-ensemble', + 'models--facebook--sam2.1-hiera-large', + 'models--google--owlv2-large-patch14-ensemble', +} +missing = sorted(repo for repo in repos if not (hub / repo).is_dir()) +assert not missing, f'Perception weights not cached: {missing}' +text = config.read_text() +assert 'launch_owlvit_server.main' in text +assert 'launch_sam2_server.main' in text +assert 'google/owlv2-large-patch14-ensemble' in text +assert 'facebook/sam2.1-hiera-large' in text +assert 'launch_sam3_server.main' not in text +fast_text = fast_config.read_text() +assert 'google/owlv2-base-patch16-ensemble' in fast_text +assert 'facebook/sam2.1-hiera-small' in fast_text +for server_name in ('launch_owlvit_server.py', 'launch_sam2_server.py'): + server_text = (Path('${CAPX_ROOT}/capx/serving') / server_name).read_text() + assert 'dtype=load_dtype' in server_text, server_name + assert '_MODEL = _MODEL.float()' in server_text, server_name +print('cube_stack large/default and optional fast perception profiles OK') +" + +echo "================ [2/3] Oracle eval (no LLM) ================" +# The oracle path runs the full sim + control pipeline using ground-truth code. +# It only needs the PyRoKi IK server - no LLM, no segmentation, no GraspNet. +PYROKI_PID="" +cleanup_pyroki() { + if [ -n "${PYROKI_PID:-}" ] && kill -0 "$PYROKI_PID" 2>/dev/null; then + kill "$PYROKI_PID" 2>/dev/null || true + wait "$PYROKI_PID" 2>/dev/null || true + fi +} +trap cleanup_pyroki EXIT + +"${CAPX_PY}" capx/serving/launch_pyroki_server.py \ + --port 8116 --robot panda_description --target-link panda_hand \ + > /tmp/pyroki.log 2>&1 & +PYROKI_PID=$! + +echo "waiting for PyRoKi IK server..." +PYROKI_READY=0 +for i in $(seq 1 120); do + if curl -sf http://127.0.0.1:8116/ik -X POST -H "Content-Type: application/json" \ + -d '{"target_pose_wxyz_xyz":[1,0,0,0,0.4,0,0.3]}' >/dev/null 2>&1; then + echo "PyRoKi ready (warmed JAX JIT) after ${i}s" + PYROKI_READY=1 + break + fi + sleep 1 +done +if [ "$PYROKI_READY" -ne 1 ]; then + echo "PyRoKi failed to become ready" + cat /tmp/pyroki.log + exit 1 +fi + +if ! ORACLE_OUT=$(timeout 400 "${CAPX_PY}" tests/test_environments.py \ + --env-name franka_robosuite_pick_place_code_env 2>&1); then + echo "ORACLE EVAL FAILED" + tail -20 <<<"$ORACLE_OUT" + exit 1 +fi +grep -aE "Time taken:|Reward:|Success" <<<"$ORACLE_OUT" | tail -4 || true +cleanup_pyroki +trap - EXIT + +if grep -aq '^Success$' <<<"$ORACLE_OUT"; then + echo "ORACLE EVAL PASSED (reward 1.0)" +else + echo "ORACLE EVAL FAILED" + tail -20 <<<"$ORACLE_OUT" + exit 1 +fi + +echo "================ [3/3] LLM eval (optional) ================" +# Default to the Lemonade server from notebook 1 when it is already serving. +if [ -z "${CAPX_LLM_SERVER_URL:-}" ] && lemonade status >/dev/null 2>&1; then + CAPX_LLM_SERVER_URL="http://localhost:13305/api/v1/chat/completions" + CAPX_LLM_MODEL="${CAPX_LLM_MODEL:-Gemma-4-E2B-it-GGUF}" +fi + +if [ -z "${CAPX_LLM_SERVER_URL:-}" ]; then + echo "SKIPPED - start Lemonade (lemond &) or set CAPX_LLM_SERVER_URL and" + echo "CAPX_LLM_MODEL to run an agentic eval against an OpenAI-compatible" + echo "endpoint." +else + MODEL="${CAPX_LLM_MODEL:-Gemma-4-E2B-it-GGUF}" + TRIALS="${CAPX_LLM_TRIALS:-2}" + echo "Running ${TRIALS}-trial eval: model=${MODEL} server=${CAPX_LLM_SERVER_URL}" + LLM_LOG=$(mktemp) + if ! timeout 1800 "${CAPX_PY}" capx/envs/launch.py \ + --config-path env_configs/cube_stack/franka_robosuite_cube_stack.yaml \ + --model "${MODEL}" \ + --server-url "${CAPX_LLM_SERVER_URL}" \ + --api-key "${CAPX_LLM_API_KEY:-}" \ + --total-trials "${TRIALS}" \ + --num-workers 1 \ + --max-tokens 4096 >"$LLM_LOG" 2>&1; then + echo "LLM EVAL FAILED" + tail -40 "$LLM_LOG" + rm -f "$LLM_LOG" + exit 1 + fi + grep -aE "Trial [0-9]+ took|reward_|task_completed|Time taken to query" "$LLM_LOG" | tail -12 || true + rm -f "$LLM_LOG" + echo "LLM EVAL COMPLETE (see outputs// for rewards and videos)" +fi + +echo "================ CaP-X tests PASSED ================" diff --git a/projects/LocalInference/tests/test_lemonade-sdk.sh b/projects/LocalInference/tests/test_lemonade-sdk.sh new file mode 100755 index 00000000..37474d56 --- /dev/null +++ b/projects/LocalInference/tests/test_lemonade-sdk.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +echo "Running tests for lemonade-sdk..." +echo "================================" + +# Use the same image-baked model as notebooks 02-04. +MODEL="${LEMONADE_TEST_MODEL:-Gemma-4-E2B-it-GGUF}" +WORKSHOP_CODER_MODEL="user.Qwen3-Coder-30B-A3B-Instruct-Q4_K_M" +WORKSHOP_CODER_DISPLAY_NAME="${WORKSHOP_CODER_MODEL#user.}" +LEMONADE_CACHE="${LEMONADE_CACHE:-/opt/lemonade-cache/lemonade}" +LEMONADE_HF_HOME="${LEMONADE_HF_HOME:-/opt/lemonade-cache/huggingface}" +export HF_HOME="${LEMONADE_HF_HOME}" +# v10.x default lemond port +PORT=13305 + +# Start the lemond server in the background. Upstream v10.x split the CLI: +# `lemond` runs the server; `lemonade` is the client CLI (pull/list/etc). +echo "" +echo "Starting lemond on default port ${PORT}..." +lemond "${LEMONADE_CACHE}" > /tmp/lemonade.log 2>&1 & +SERVER_PID=$! + +cleanup() { + echo "" + echo "Stopping server..." + kill $SERVER_PID 2>/dev/null + sleep 1 + pkill -9 lemond 2>/dev/null +} +trap cleanup EXIT + +# Wait for server to be ready +echo "Waiting for server to be ready..." +for i in {1..60}; do + if curl -s http://localhost:$PORT/api/v1/health > /dev/null 2>&1; then + echo "Server is ready!" + break + fi + if [ $i -eq 60 ]; then + echo "Server failed to start!" + cat /tmp/lemonade.log + exit 1 + fi + sleep 1 +done + +# Confirm the workshop model is baked, then load it without a network pull. +echo "" +echo "Loading image-cached model $MODEL..." +test -d "${LEMONADE_HF_HOME}/hub/models--unsloth--gemma-4-E2B-it-GGUF" +python3 - "${LEMONADE_HF_HOME}" <<'PY' +import sys +from pathlib import Path + +cache = Path(sys.argv[1]) +matches = list( + cache.glob( + "hub/models--unsloth--Qwen3-Coder-30B-A3B-Instruct-GGUF/" + "snapshots/*/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf" + ) +) +assert matches, "workshop Qwen3-Coder Q4_K_M file is not image-cached" +PY +lemonade list | grep -q "^${WORKSHOP_CODER_DISPLAY_NAME}[[:space:]]" +lemonade load "$MODEL" + +sleep 2 + +# Test the API with an actual completion request +echo "" +echo "Testing completion API with model $MODEL..." +RESPONSE=$(curl -s -X POST http://localhost:$PORT/api/v1/completions -H "Content-Type: application/json" -d '{ + "model": "'$MODEL'", + "prompt": "Why is the sky blue?", + "max_tokens": 100, + "temperature": 0.7 + }') + +echo "Raw API Response:" +echo "$RESPONSE" | python3 -m json.tool || echo "$RESPONSE" + +if echo "$RESPONSE" | grep -q "choices"; then + echo "" + echo "================================" + echo "✓ lemonade-sdk API test passed!" + exit 0 +else + echo "" + echo "✗ API test failed - no valid completion received" + exit 1 +fi diff --git a/projects/LocalInference/tests/test_o3de.sh b/projects/LocalInference/tests/test_o3de.sh new file mode 100755 index 00000000..62451851 --- /dev/null +++ b/projects/LocalInference/tests/test_o3de.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +set -e + +echo "Testing O3DE installation..." + +# Verify o3de is installed +if ! command -v o3de &> /dev/null; then + echo "FAIL: o3de command not found" + exit 1 +fi + +echo "o3de found at: $(which o3de)" + +# Check o3de version/help (non-blocking) +echo "Checking O3DE CLI..." +o3de --help > /dev/null 2>&1 || true + +# Verify critical O3DE components exist +echo "Verifying O3DE installation paths..." +O3DE_PATH="/opt/O3DE" +if [[ -d "$O3DE_PATH" ]]; then + echo "O3DE installed at: $O3DE_PATH" +else + echo "Warning: O3DE path not found at $O3DE_PATH" +fi + +# Check for Vulkan support +echo "Checking Vulkan support..." +if command -v vulkaninfo &> /dev/null; then + vulkaninfo --summary 2>/dev/null | head -20 || echo "Vulkan info not available (may need GPU)" +else + echo "vulkaninfo not found" +fi + +echo "SUCCESS: O3DE installation test passed" +exit 0 diff --git a/projects/LocalInference/tests/test_rai.sh b/projects/LocalInference/tests/test_rai.sh new file mode 100755 index 00000000..bf62de46 --- /dev/null +++ b/projects/LocalInference/tests/test_rai.sh @@ -0,0 +1,80 @@ +#!/bin/bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +set -e + +echo "==========================================" +echo "RAI Framework Test" +echo "==========================================" + +# Source ROS environment (ROS_DISTRO set by ros package) +if [ -n "$ROS_DISTRO" ]; then + source /opt/ros/${ROS_DISTRO}/setup.bash + echo "ROS 2 ${ROS_DISTRO} environment sourced" +else + echo "Warning: ROS_DISTRO not set." +fi + +# Test RAI core import +echo "" +echo "Testing RAI core import..." +PYTHONWARNINGS=ignore python3 -c " +from rai.agents import AgentRunner, ReActAgent +from rai.initialization import get_llm_model +print('RAI core imports successful!') +print(' - AgentRunner: available') +print(' - ReActAgent: available') +print(' - get_llm_model: available') +" + +# Test RAI whoami import +echo "" +echo "Testing RAI whoami import..." +python3 -c " +from rai_whoami import EmbodimentInfo, Pipeline, PipelineBuilder +print('RAI whoami imports successful!') +print(' - EmbodimentInfo: available') +print(' - Pipeline: available') +print(' - PipelineBuilder: available') +" + +# Test the perception stack and assets used by 2_robot_agents.ipynb. +echo "" +echo "Testing RAI manipulation demo..." +PYTHONWARNINGS=ignore python3 -c " +from pathlib import Path + +from rai_perception.services.detection_service import DetectionService +from rai_perception.services.segmentation_service import SegmentationService + +weights = Path('/opt/rai-cache/vision/weights') +expected = [ + weights / 'groundingdino_swint_ogc.pth', + weights / 'sam2_hiera_large.pt', +] +assert DetectionService.DEFAULT_WEIGHTS_ROOT_PATH == Path('/opt/rai-cache') +assert all(path.stat().st_size > 100_000_000 for path in expected) +assert Path('/ryzers/rai/examples/manipulation-demo-streamlit.py').is_file() +print('RAI perception imports and preloaded weights available!') +" +/usr/bin/python3 -c "import jupyter_server_proxy" +test -x /ryzers/notebooks/scripts/lemonade_env.sh +test -x /ryzers/notebooks/scripts/manipulation_demo_headless.sh +test -f /ryzers/notebooks/scripts/manipulation_demo_streamlit.py +echo "RAI demo scripts and Jupyter port proxy available!" + +# Check ROS 2 tools +echo "" +echo "Testing ROS 2 integration..." +ros2 --help > /dev/null 2>&1 && echo "ROS 2 CLI: available" + +# Print versions +echo "" +echo "==========================================" +echo "Version Information" +echo "==========================================" +RAI_VERSION=$(grep '^version' /ryzers/rai/src/rai_core/pyproject.toml | head -1 | cut -d'"' -f2) +echo "RAI version: ${RAI_VERSION}" +echo "ROS 2 distro: ${ROS_DISTRO}" +python3 --version diff --git a/projects/LocalInference/tests/test_rai_toy_evolution.sh b/projects/LocalInference/tests/test_rai_toy_evolution.sh new file mode 100644 index 00000000..02929f42 --- /dev/null +++ b/projects/LocalInference/tests/test_rai_toy_evolution.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +set -euo pipefail + +SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" +SCRIPT_DIR="$(cd "$(dirname "${SCRIPT_PATH}")" && pwd)" +HERE="$(cd "${SCRIPT_DIR}/../scripts" && pwd)" +PYTHON_BIN="${RAI_TOY_TEST_PYTHON:-python3}" +export PYTHONPATH="${HERE}${PYTHONPATH:+:${PYTHONPATH}}" +export RAI_TOY_SUPPORT_DIR="${HERE}" +export RAI_TOY_MOCK=1 + +echo "================ RAI toy static compile ================" +"${PYTHON_BIN}" -m py_compile "${HERE}/rai_toy_demo.py" + +echo "================ RAI toy mock contract ================" +"${PYTHON_BIN}" - <<'PY' +import json +import os +import subprocess +import tomllib +from pathlib import Path + +import rai_toy_demo as demo + +assert os.environ["RAI_TOY_MOCK"] == "1" +root = demo.prepare_workshop( + Path("/tmp/rai-toy-static/candidate"), + model="test-model", +) +manifest = json.loads((root / "scenarios.json").read_text()) +assert manifest["splits"] == { + "train": ["train-red-cube"], + "val": ["val-blue-cylinder"], +} +assert "test-green-cube" not in manifest["scenarios"] +assert manifest["test_exposed_to_evolution"] is False + +config = tomllib.loads((root / "helix.toml").read_text()) +assert config["dataset"] == {"train_size": 1, "val_size": 1} +assert config["evolution"]["max_generations"] == 1 +assert config["evolution"]["acceptance_criterion"] == "strict_improvement" +assert "scenarios.json" in config["evaluator"]["protected_files"] +assert config["sandbox"]["enabled"] is False + +permissions = json.loads((root / "opencode.json").read_text())["permission"] +assert permissions["edit"]["*"] == "deny" +assert permissions["edit"]["solver/**"] == "allow" +assert permissions["external_directory"] == "deny" + +seed_prompt = (root / "solver/prompt.py").read_text() +seed_tools = (root / "solver/tools.py").read_text() +before = demo.score_candidate(root, "test-green-cube", timeout_seconds=5) +assert before["side_info"]["benchmark_kind"] == demo.MOCK_LABEL +assert before["side_info"]["is_live_rai"] is False +assert before["score"] == 0.3 +assert before["passed"] is False +assert (root / "solver/prompt.py").read_text() == seed_prompt +assert (root / "solver/tools.py").read_text() == seed_tools + +(root / "solver/prompt.py").write_text( + seed_prompt.replace( + "positive y is LEFT and negative y is RIGHT", + "negative y is LEFT and positive y is RIGHT", + ) +) +(root / "solver/tools.py").write_text( + seed_tools.replace( + "normalized_y = abs(float(target_y))", + "normalized_y = float(target_y)", + ) +) +after = demo.score_candidate(root, "test-green-cube", timeout_seconds=5) +assert after["score"] == 1.0 +assert after["passed"] is True +assert demo.source_diff( + Path("/tmp/rai-toy-static/candidate") / ".git" / "..", root +) == "" + +completed = subprocess.run( + [os.sys.executable, "probe.py", "--task", "val-blue-cylinder"], + cwd=root, + check=True, + capture_output=True, + text=True, + env={**os.environ, "RAI_TOY_SUPPORT_DIR": str(Path(demo.__file__).parent)}, +) +assert "RAI_TOY_RESULT=" in completed.stdout +print("candidate, protection, explicit tasks, mock failure, and repaired pass OK") +PY + +if [[ -x /opt/capx-venv/bin/python ]]; then + /opt/capx-venv/bin/python - <<'PY' +from pathlib import Path +from helix.config import load_config + +load_config(Path("/tmp/rai-toy-static/candidate/helix.toml")) +print("workshop HELIX config schema OK") +PY +fi + +if [[ "${RAI_TOY_RUN_LIVE:-0}" != "1" ]]; then + echo "Live RAI check skipped; set RAI_TOY_RUN_LIVE=1 in the workshop image." + echo "================ RAI toy tests PASSED ================" + exit 0 +fi + +echo "================ RAI toy live agent gate ================" +unset RAI_TOY_MOCK +"${RAI_PYTHON:-/opt/rai-venv/bin/python}" - <<'PY' +from pathlib import Path + +import rai_toy_demo as demo + +demo.ensure_model() +root = demo.prepare_workshop(Path("/tmp/rai-toy-live/candidate")) +before = demo.score_candidate(root, "test-green-cube") +prompt = (root / "solver/prompt.py").read_text() +tools = (root / "solver/tools.py").read_text() +(root / "solver/prompt.py").write_text( + prompt.replace( + "positive y is LEFT and negative y is RIGHT", + "negative y is LEFT and positive y is RIGHT", + ) +) +(root / "solver/tools.py").write_text( + tools.replace( + "normalized_y = abs(float(target_y))", + "normalized_y = float(target_y)", + ) +) +after = demo.score_candidate(root, "test-green-cube") +assert before["passed"] is False, before +assert after["side_info"]["is_live_rai"] is True, after +assert after["passed"] is True, after +print("LIVE_SEED=", before) +print("LIVE_REPAIRED=", after) +PY + +echo "================ RAI toy tests PASSED ================" diff --git a/projects/LocalInference/tests/test_rho.sh b/projects/LocalInference/tests/test_rho.sh new file mode 100755 index 00000000..967b2c89 --- /dev/null +++ b/projects/LocalInference/tests/test_rho.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +# +# Static RHO harness checks always run. Set RHO_RUN_LIVE=1 for one real local +# Gemma/OpenCode/HELIX mutation against the CaP-X simulator. +set -euo pipefail + +CAPX_PY="${CAPX_VENV:-/opt/capx-venv}/bin/python" +HELIX_REF="29cfa6e5eae902f6bc6d2113e51499e92c6109ee" +OPENCODE_VERSION="1.18.18" + +export PYTHONPATH="/ryzers/notebooks/scripts${PYTHONPATH:+:${PYTHONPATH}}" +export CAPX_ROOT="${CAPX_ROOT:-/ryzers/cap-x}" +export HF_HOME="${CAPX_CACHE:-/opt/capx-cache}" +export MUJOCO_GL=egl +export PYOPENGL_PLATFORM=egl + +cd /ryzers + +echo "================ RHO toolchain ================" +test "$(git -C /ryzers/helix rev-parse HEAD)" = "${HELIX_REF}" +"${CAPX_PY}" -c "import capx, helix, torch; assert torch.version.hip" +"${CAPX_PY}" -c "from helix import __version__; assert __version__ == '0.2.1', __version__" +"${CAPX_VENV:-/opt/capx-venv}/bin/helix" --version +test "$(opencode --version)" = "${OPENCODE_VERSION}" +node --version + +echo "================ RHO static harness ================" +RHO_MOCK_EVAL=1 "${CAPX_PY}" - <<'PY' +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from unittest.mock import patch + +import rho_demo +from helix.config import load_config + +root = rho_demo.prepare_workshop( + support_files={"solver/strategy.py": "CLEARANCE = 0.1\n"} +) +required = { + "solver/__init__.py", + "solver/geometry.py", + "solver/program.py", + "solver/policy.py", + "solver/strategy.py", + "API_REFERENCE.md", + "probe.py", + "opencode.json", + "helix.toml", + "provenance.json", +} +assert required <= { + str(path.relative_to(root)) for path in root.rglob("*") if path.is_file() +} +assert (root / ".git").is_dir() +assert (root / "solver" / "program.py").read_text() == rho_demo.DEFAULT_PROGRAM +assert "program.py" in (root / "solver" / "policy.py").read_text() +assert (root / "solver" / "strategy.py").read_text() == "CLEARANCE = 0.1\n" + +with tempfile.TemporaryDirectory(prefix="rho-custom-root-", dir="/tmp") as temporary: + custom_root = Path(temporary) / "candidate" + custom_root.mkdir() + stale = custom_root / "stale.txt" + stale.write_text("remove me") + prepared = rho_demo.prepare_workshop(custom_root) + assert prepared == custom_root.resolve() + assert not stale.exists() + +provenance = json.loads((root / "provenance.json").read_text()) +assert provenance["source"] == "recorded_capx_generation" +assert provenance["artifact_trial"] == provenance["training_trial"] == 1 +assert provenance["heldout_trial"] == 2 +assert provenance["recorded_task_completed"] is False +assert not hasattr(rho_demo, "SEED_GEOMETRY") +assert not hasattr(rho_demo, "FROZEN_GEOMETRY") +assert not hasattr(rho_demo, "FALLBACK_ROOT") + +config = load_config(root / "helix.toml") +assert config.agent.backend == "opencode" +assert config.agent.model == f"lemonade/{rho_demo.MODEL}" +assert config.dataset.train_size == config.dataset.val_size == 1 +assert config.evolution.max_generations == 1 +assert config.evolution.minibatch_size == 1 +assert config.evolution.max_workers == 1 +assert config.evolution.merge_enabled is False +assert config.evolution.acceptance_criterion == "strict_improvement" +assert set(config.evaluator.protected_files) >= { + "probe.py", + "helix.toml", + "opencode.json", + "provenance.json", +} +assert "max_generations = 2" in rho_demo.helix_config(2) +four_generation_config = rho_demo.helix_config(4) +assert "max_generations = 4" in four_generation_config +assert "max_evaluations = 14" in four_generation_config +custom_config = rho_demo.helix_config( + objective="Repair another task.", + background="Edit solver/program.py first.", +) +assert 'objective = """Repair another task."""' in custom_config +assert 'background = """Edit solver/program.py first."""' in custom_config +assert '"RHO_CONFIG_PATH"' in custom_config +assert '"RHO_PROGRESS_FILE"' in custom_config +try: + rho_demo.helix_config(5) +except ValueError: + pass +else: + raise AssertionError("more than four generations must be rejected") + +opencode = json.loads((root / "opencode.json").read_text()) +permissions = opencode["permission"] +assert permissions["external_directory"] == "deny" +assert permissions["edit"]["*"] == "deny" +assert permissions["edit"]["solver/**"] == "allow" +assert permissions["edit"]["**/solver/**"] == "allow" +assert permissions["bash"]["*"] == "deny" +assert ( + permissions["bash"][ + "RHO_EVAL_ORIGIN=agent-self-check /opt/capx-venv/bin/python probe.py*" + ] + == "allow" +) +assert "/opt/capx-venv/bin/python probe.py*" not in permissions["bash"] +assert list(permissions["edit"])[0] == "*" +assert list(permissions["bash"])[0] == "*" +assert "RHO_EVAL_ORIGIN=agent-self-check" in rho_demo.DEFAULT_BACKGROUND + +# The default authentic recording deterministically reproduces its scalar-index +# error without importing CaP-X. +before = rho_demo.score_candidate(root, "train", timeout_seconds=5) +assert before["reward"] == 0.0 and before["task_completed"] is False +assert before["trial"] == 1 +assert before["elapsed_seconds"] >= 0.0 +assert "green_pose[0]" in before["traceback"] + +(root / "helix_batch.json").write_text('["0"]\n') +env = os.environ.copy() +with tempfile.NamedTemporaryFile(prefix="rho-evaluation-progress-", delete=False) as stream: + progress_path = Path(stream.name) +env.update( + RHO_MOCK_EVAL="1", + HELIX_SPLIT="train", + RHO_PROGRESS_FILE=str(progress_path), +) +done = subprocess.run( + [sys.executable, str(Path(rho_demo.__file__)), "evaluate"], + cwd=root, + env=env, + check=True, + capture_output=True, + text=True, +) +lines = done.stdout.splitlines() +assert len(lines) == 1 and lines[0].startswith("HELIX_RESULT="), done.stdout +payload = json.loads(lines[0].split("=", 1)[1]) +assert payload[0][0] == 0.0 +assert payload[0][1]["task_completed"] is False +assert payload[0][1]["trial"] == 1 +progress_events = [ + json.loads(line) for line in progress_path.read_text().splitlines() +] +progress_path.unlink() +assert [event["event"] for event in progress_events] == ["started", "completed"] +assert progress_events[0]["trial"] == progress_events[1]["trial"] == 1 +assert progress_events[1]["reward"] == 0.0 +assert progress_events[0]["origin"] == progress_events[1]["origin"] == "helix" + +class DisplayHandle: + def __init__(self): + self.updates = [] + + def update(self, value): + self.updates.append(value.data) + + +with tempfile.NamedTemporaryFile( + prefix="rho-widget-progress-", mode="w", delete=False +) as stream: + widget_progress_path = Path(stream.name) +handle = DisplayHandle() +with patch("IPython.display.display", return_value=handle): + widget = rho_demo._NotebookHelixProgress(widget_progress_path) +with widget_progress_path.open("a") as stream: + stream.write(json.dumps(progress_events[0]) + "\n") +widget("Status: Applying mutation") +assert "HELIX eval 1 running" in handle.updates[-1] +widget("Status: Evaluating") +assert "Applying mutation" not in handle.updates[-1] +assert "Status: Evaluating" not in handle.updates[-1] +with widget_progress_path.open("a") as stream: + stream.write(json.dumps(progress_events[1]) + "\n") +widget.tick() +assert "reward 0.000" in handle.updates[-1] +assert "HELIX eval 1" in handle.updates[-1] +assert widget._evaluations == 1 +widget("Status: Applying mutation") +assert "Applying mutation · 0s elapsed" in handle.updates[-1] +stable_update = handle.updates[-1] +widget("unrelated Rich redraw") +assert handle.updates[-1] == stable_update + +self_check_started = { + **progress_events[0], + "evaluation_id": "self-check", + "origin": "agent-self-check", +} +self_check_completed = { + **progress_events[1], + "evaluation_id": "self-check", + "origin": "agent-self-check", + "reward": 1.0, + "task_completed": True, +} +with widget_progress_path.open("a") as stream: + stream.write(json.dumps(self_check_started) + "\n") + stream.write(json.dumps(self_check_completed) + "\n") +widget.tick() +assert "Agent self-check 1" in handle.updates[-1] +assert widget._evaluations == 1 +widget_progress_path.unlink() + +assert { + "reward", + "stdout", + "stderr", + "traceback", + "feedback", + "video", + "scores", +} <= payload[0][1].keys() + +program_path = root / "solver" / "program.py" +corrected = program_path.read_text() +corrected = corrected.replace("green_pose[0][2]", "green_pose[2]") +corrected = corrected.replace("green_pose[0][0]", "green_pose[0]") +corrected = corrected.replace("green_pose[0][1]", "green_pose[1]") +program_path.write_text(corrected) +after = rho_demo.score_candidate(root, "val", timeout_seconds=5) +assert after["reward"] == 1.0 and after["task_completed"] +assert after["trial"] == 2 +provenance_before_override = (root / "provenance.json").read_text() +random_trial = rho_demo.score_candidate( + root, "val", trial=7331, timeout_seconds=5 +) +assert random_trial["reward"] == 1.0 and random_trial["trial"] == 7331 +assert (root / "provenance.json").read_text() == provenance_before_override + +# A real CaP-X trial directory is accepted as input, copied byte-for-byte, and +# fixes the training trial to the artifact while preserving caller metadata. +with tempfile.TemporaryDirectory(prefix="rho-artifact-") as temporary: + temporary = Path(temporary) + artifact = temporary / "trial_07_sandboxrc_1_reward_0.000_taskcompleted_0" + artifact.mkdir() + artifact_code = "# authentic generated bytes\nprint('artifact')\n" + (artifact / "code.py").write_text(artifact_code) + artifact_root = temporary / "candidate" + rho_demo.prepare_workshop( + artifact_root, + artifact=artifact, + provenance={"run": "workshop-recording"}, + heldout_trial=9, + ) + assert (artifact_root / "solver" / "program.py").read_text() == artifact_code + artifact_provenance = json.loads( + (artifact_root / "provenance.json").read_text() + ) + assert artifact_provenance["training_trial"] == 7 + assert artifact_provenance["heldout_trial"] == 9 + assert artifact_provenance["run"] == "workshop-recording" + +bounded = rho_demo.run_bounded( + [sys.executable, str(Path(rho_demo.__file__)), "_sleep", "2"], + cwd=root, + timeout_seconds=0.2, +) +assert bounded.timed_out and bounded.returncode == 124 +print("mock repair, HELIX_RESULT, permissions, and timeout OK") +PY + +if [[ "${RHO_RUN_LIVE:-0}" != "1" ]]; then + echo "Live mutation skipped; run with RHO_RUN_LIVE=1 on a GPU workshop pod." + echo "================ RHO tests PASSED ================" + exit 0 +fi + +echo "================ RHO live one-generation smoke ================" +"${CAPX_PY}" /ryzers/notebooks/scripts/rho_demo.py live-smoke + +echo "================ RHO tests PASSED ================" diff --git a/projects/LocalInference/tests/test_rho_multitask.sh b/projects/LocalInference/tests/test_rho_multitask.sh new file mode 100755 index 00000000..d3fbdbf6 --- /dev/null +++ b/projects/LocalInference/tests/test_rho_multitask.sh @@ -0,0 +1,399 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +set -euo pipefail + +CAPX_PY="${CAPX_VENV:-/opt/capx-venv}/bin/python" +export PYTHONPATH="/ryzers/notebooks/scripts${PYTHONPATH:+:${PYTHONPATH}}" + +echo "================ Multi-task RHO static harness ================" +RHO_MULTITASK_MOCK=1 "${CAPX_PY}" - <<'PY' +import json +import hashlib +import os +import random +import subprocess +import sys +import tempfile +from pathlib import Path + +from helix.config import load_config +import rho_demo +import rho_multitask_demo as demo + +override_env = os.environ.copy() +override_env.pop("RHO_MULTITASK_MODEL", None) +override_env["RHO_MODEL"] = "rho-model-override" +override = subprocess.run( + [ + sys.executable, + "-c", + "import rho_multitask_demo as demo; print(demo.DEFAULT_MODEL)", + ], + env=override_env, + check=True, + capture_output=True, + text=True, +) +assert override.stdout.strip() == "rho-model-override" + + +with tempfile.TemporaryDirectory(prefix="rho-multitask-", dir="/tmp") as temporary: + root = demo.prepare_workshop(Path(temporary) / "candidate") + manifest = json.loads((root / "scenarios.json").read_text()) + assert manifest["schema_version"] == "rho-multitask-scenarios/v2" + assert manifest["splits"] == { + "train": ["stack_train", "wipe_train"], + "val": ["stack_val", "wipe_val"], + } + assert set(manifest["scenarios"]) == { + "stack_train", + "wipe_train", + "stack_val", + "wipe_val", + } + assert {scenario["task"] for scenario in manifest["scenarios"].values()} == { + "cube_stack", + "spill_wipe", + } + assert { + path.name for path in (root / "solver" / "tasks").glob("*.py") + } == {"__init__.py", "cube_stack.py", "spill_wipe.py"} + assert 'os.environ.get("RHO_SUPPORT_ROOT", "/ryzers/notebooks/scripts")' in ( + root / "probe.py" + ).read_text() + assert '"RHO_SUPPORT_ROOT"' in (root / "helix.toml").read_text() + os.environ["RHO_TRIAL_ID"] = "2" + try: + assert rho_demo._trial_id(root, "val", "stack_val") == 2 + finally: + os.environ.pop("RHO_TRIAL_ID") + provenance = json.loads((root / "provenance.json").read_text()) + assert provenance["schema_version"] == "rho-multitask-fixtures/v2" + assert provenance["seed_model"] == "Gemma-4-E4B-it-GGUF" + assert set(provenance["policies"]) == {"cube_stack", "spill_wipe"} + for policy in provenance["policies"].values(): + assert len(policy["source_policy_sha256"]) == 64 + assert policy["source_prompt"] + assert policy["source_trial"] == 1 + assert policy["source_git_commit"] + assert demo.resolve_scenarios(root, "train", ["0", "1"])[0][0] == "stack_train" + assert demo.resolve_scenarios(root, "val", ["1"])[0][0] == "wipe_val" + for invalid_id in ("stack_val", "-1", "2"): + try: + demo.resolve_scenarios(root, "train", [invalid_id]) + except ValueError: + pass + else: + raise AssertionError(f"invalid train scenario was accepted: {invalid_id}") + shuffled = list(manifest["splits"]["train"]) + random.Random(29).shuffle(shuffled) + assert shuffled == ["wipe_train", "stack_train"] + + config = load_config(root / "helix.toml") + assert config.dataset.train_size == 2 + assert config.dataset.val_size == 2 + assert config.evolution.max_generations == 2 + assert config.evolution.perfect_score_threshold == 1.1 + assert config.evolution.minibatch_size == 1 + assert config.evolution.num_parallel_proposals == 2 + assert config.evolution.max_workers == 1 + assert config.evolution.merge_enabled is True + assert config.evolution.max_merge_invocations == 2 + assert config.evolution.merge_val_overlap_floor == 1 + assert config.evolution.merge_subsample_size == 2 + assert config.evolution.frontier_type == "instance" + assert config.evolution.acceptance_criterion == "strict_improvement" + + opencode = json.loads((root / "opencode.json").read_text()) + assert opencode["model"] == f"lemonade/{demo.opencode_model_id()}" + assert opencode["provider"]["lemonade"]["models"][ + demo.opencode_model_id() + ]["tool_call"] is True + assert opencode["agent"]["build"]["steps"] == 24 + assert config.agent.model == f"lemonade/{demo.opencode_model_id()}" + assert config.agent.max_turns == 24 + assert opencode["permission"]["edit"]["solver/**"] == "allow" + assert opencode["permission"]["edit"]["*"] == "deny" + assert "change green_pose" not in demo.BACKGROUND + assert "except ValueError" not in demo.BACKGROUND + + protected = [ + "probe.py", + "helix.toml", + "opencode.json", + "CONTRACT.md", + "scenarios.json", + "provenance.json", + ] + protected_before = { + name: hashlib.sha256((root / name).read_bytes()).hexdigest() + for name in protected + } + (root / "helix_batch.json").write_text('["0","1"]\n') + env = os.environ.copy() + env.update(RHO_MULTITASK_MOCK="1", HELIX_SPLIT="train") + done = subprocess.run( + [sys.executable, str(Path(demo.__file__)), "evaluate"], + cwd=root, + env=env, + check=True, + capture_output=True, + text=True, + ) + payload = json.loads(done.stdout.split("HELIX_RESULT=", 1)[1]) + assert [entry[1]["task"] for entry in payload] == ["cube_stack", "spill_wipe"] + assert [entry[0] for entry in payload] == [0.0, 0.0] + assert set(payload[0][1]["scores"]) == {"completion", "raw_reward", "deployable"} + assert demo.MOCK_LABEL in payload[0][1]["feedback"] + assert protected_before == { + name: hashlib.sha256((root / name).read_bytes()).hexdigest() + for name in protected + } + + stack = root / "solver" / "tasks" / "cube_stack.py" + stack.write_text( + stack.read_text() + .replace("green_pose[0][2]", "green_pose[2]") + .replace("green_pose[0][0]", "green_pose[0]") + .replace("green_pose[0][1]", "green_pose[1]") + ) + wipe = root / "solver" / "tasks" / "spill_wipe.py" + wipe.write_text(wipe.read_text() + "\n# safe_goto handles terminated episodes\n") + train_results = [ + demo.score_scenario(root, "train", scenario_id, scenario) + for scenario_id, scenario in demo.resolve_scenarios(root, "train", ["0", "1"]) + ] + assert [result["reward"] for result in train_results] == [1.0, 1.0] + try: + demo.score_scenario( + root, + "val", + "unknown_val", + { + "task": "unknown_task", + "trial": 2, + "policy_path": "solver/tasks/cube_stack.py", + "config_path": "unused.yaml", + }, + ) + except ValueError as exc: + assert "unsupported mock task" in str(exc) + else: + raise AssertionError("unsupported mock task was accepted") + + root = demo.prepare_workshop(root) + mocked = demo.materialize_mock_evolution(root) + assert demo.MOCK_LABEL in mocked.stdout + summary = demo.frontier_summary(root) + assert summary["generation"] == 2 + assert summary["candidates"]["g1-s1"]["wins"] == ["stack_val"] + assert summary["candidates"]["g1-s2"]["wins"] == ["wipe_val"] + assert summary["candidates"]["g0-s0"]["frontier"] is False + assert summary["candidates"]["g2-m1"]["frontier"] is True + assert all( + set(candidate["scores"]) == {"stack_val", "wipe_val"} + for candidate in summary["candidates"].values() + ) + assert summary["merge_counter"] == 1 + lesson = demo.evolution_lesson(summary) + assert lesson["multi_key_frontier"] is True + assert lesson["covered_difficult_keys"] == ["stack_val", "wipe_val"] + assert lesson["specialist_pair"] is True + assert lesson["stack_specialists"] == ["g1-s1"] + assert lesson["wipe_specialists"] == ["g1-s2"] + assert lesson["broad_candidates"] == ["g2-m1"] + assert lesson["merge_attempted"] is True + assert summary["merge_ancestry"] == [ + {"candidate": "g2-m1", "parents": ["g1-s1", "g1-s2"]} + ] + assert summary["merge_output_dedup_triplets"] == [ + ["g1-s1", "g1-s2", "4d6f636b4d65726765436f6d6d69745368613031"] + ] + lineage = {item["id"]: item for item in summary["lineage"]} + assert lineage["g1-s1"]["changed_files"] == ["solver/tasks/cube_stack.py"] + assert lineage["g1-s2"]["changed_files"] == ["solver/tasks/spill_wipe.py"] + assert lineage["g2-m1"]["parents"] == ["g1-s1", "g1-s2"] + assert all( + lineage[candidate_id]["gate_result"] == "passed_strict_train_gate" + for candidate_id in ("g1-s1", "g1-s2") + ) + assert lineage["g2-m1"]["gate_result"] == "passed_merge_validation_gate" + assert "cube_stack.py" in rho_demo.source_diff( + root / ".helix" / "worktrees" / "g0-s0", + root / ".helix" / "worktrees" / "g1-s1", + ) + assert "spill_wipe.py" in rho_demo.source_diff( + root / ".helix" / "worktrees" / "g0-s0", + root / ".helix" / "worktrees" / "g1-s2", + ) + + captured = {} + original_run_bounded = rho_demo.run_bounded + + def fake_run_bounded(command, **kwargs): + captured["command"] = command + return rho_demo.BoundedRun(0, False, "", 0.1) + + rho_demo.run_bounded = fake_run_bounded + try: + run = demo.run_helix(root, progress=lambda _: None) + finally: + rho_demo.run_bounded = original_run_bounded + assert run.returncode == 0 + assert "--no-merge" not in captured["command"] + assert captured["command"][-2:] == ["--generations", "2"] + + hidden_calls = [] + original_score_scenario = demo.score_scenario + + def fake_score_scenario(candidate_root, split, scenario_id, scenario, **kwargs): + hidden_calls.append((scenario_id, scenario["task"], scenario["trial"], kwargs["capture"])) + return { + "scenario_id": scenario_id, + "task": scenario["task"], + "trial": scenario["trial"], + "reward": 1.0, + "raw_reward": 1.0, + "task_completed": True, + "stderr": "", + "traceback": "", + "timed_out": False, + } + + demo.score_scenario = fake_score_scenario + try: + hidden = demo.hidden_rollouts( + root, + trials={ + "cube_stack": [100, 101, 102, 103, 104], + "spill_wipe": [200, 201, 202, 203, 204], + }, + capture=True, + ) + finally: + demo.score_scenario = original_score_scenario + assert len(hidden) == 10 + assert len(hidden_calls) == 10 + assert all(call[3] is True for call in hidden_calls) + assert hidden_calls[0][:3] == ("hidden_cube_stack_100", "cube_stack", 100) + assert hidden_calls[-1][:3] == ("hidden_spill_wipe_204", "spill_wipe", 204) + + def rollout(task, trial, reward, completed): + return { + "task": task, + "trial": trial, + "reward": reward, + "raw_reward": reward, + "task_completed": completed, + "stderr": "", + "traceback": "", + "timed_out": False, + } + + before_rollouts = [ + *[rollout("cube_stack", trial, 0.0, False) for trial in range(100, 105)], + *[rollout("spill_wipe", trial, 1.0, True) for trial in range(200, 205)], + ] + after_rollouts = [ + rollout("cube_stack", 100, 1.0, True), + *[rollout("cube_stack", trial, 0.0, False) for trial in range(101, 105)], + *[rollout("spill_wipe", trial, 1.0, True) for trial in range(200, 205)], + ] + before_summary = demo.summarize_rollouts(before_rollouts) + after_summary = demo.summarize_rollouts(after_rollouts) + assert set(before_summary) == {"cube_stack", "spill_wipe"} + assert not hasattr(demo, "hidden_success_criterion") + criterion = demo.deployment_success_criterion(before_summary, after_summary) + assert criterion["required_tasks"] == ["cube_stack", "spill_wipe"] + assert criterion["rollouts"] == 10 + assert criterion["completed_before"] == 5 + assert criterion["completed_after"] == 6 + assert criterion["completion_improved"] is True + assert criterion["reward_improved"] is False + assert criterion["deployment_improved"] is True + assert criterion["met"] is True + + noisy_rollouts = [ + *[rollout("cube_stack", trial, 0.001, False) for trial in range(100, 105)], + *[rollout("spill_wipe", trial, 1.0, True) for trial in range(200, 205)], + ] + noisy_criterion = demo.deployment_success_criterion( + before_summary, + demo.summarize_rollouts(noisy_rollouts), + ) + assert noisy_criterion["completion_improved"] is False + assert noisy_criterion["reward_improved"] is False + assert noisy_criterion["met"] is False + + reward_gain_rollouts = [ + *[rollout("cube_stack", trial, 0.2, False) for trial in range(100, 105)], + *[rollout("spill_wipe", trial, 1.0, True) for trial in range(200, 205)], + ] + reward_criterion = demo.deployment_success_criterion( + before_summary, + demo.summarize_rollouts(reward_gain_rollouts), + ) + assert reward_criterion["completed_before"] == reward_criterion["completed_after"] + assert reward_criterion["mean_reward_after"] == 0.6 + assert reward_criterion["completion_improved"] is False + assert reward_criterion["reward_improved"] is True + assert reward_criterion["met"] is True + + neutral_before = { + "pick": {"rollouts": 2, "completed": 1, "mean_reward": 0.25}, + "place": {"rollouts": 2, "completed": 1, "mean_reward": 0.25}, + } + neutral_after = { + "pick": {"rollouts": 2, "completed": 1, "mean_reward": 0.35}, + "place": {"rollouts": 2, "completed": 1, "mean_reward": 0.35}, + } + neutral_criterion = demo.deployment_success_criterion( + neutral_before, + neutral_after, + required_tasks=("pick", "place"), + ) + assert neutral_criterion["required_tasks"] == ["pick", "place"] + assert neutral_criterion["reward_improved"] is True + + invalid_criteria = [ + ( + before_summary, + {"cube_stack": after_summary["cube_stack"]}, + {}, + ), + ( + before_summary, + after_summary, + {"required_tasks": ("cube_stack", "cube_stack")}, + ), + ( + before_summary, + after_summary, + {"minimum_hard_reward_gain": -0.01}, + ), + ( + before_summary, + { + **after_summary, + "cube_stack": {**after_summary["cube_stack"], "rollouts": 4}, + }, + {}, + ), + ] + for invalid_before, invalid_after, kwargs in invalid_criteria: + try: + demo.deployment_success_criterion( + invalid_before, + invalid_after, + **kwargs, + ) + except ValueError: + pass + else: + raise AssertionError("invalid deployment summaries were accepted") + +print("multi-task manifest, evaluator, frontier, and merge plumbing OK") +PY + +echo "================ Multi-task RHO tests PASSED ================" diff --git a/projects/LocalInference/tests/test_ros.sh b/projects/LocalInference/tests/test_ros.sh new file mode 100755 index 00000000..c50b3bdb --- /dev/null +++ b/projects/LocalInference/tests/test_ros.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Copyright (C) 2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +set -e + +echo "Testing ROS 2 installation..." + +# Source ROS environment +source /opt/ros/${ROS_DISTRO}/setup.bash + +echo "ROS_DISTRO: ${ROS_DISTRO}" +echo "ROS_VERSION: ${ROS_VERSION}" + +# Verify ros2 command exists +if ! command -v ros2 &> /dev/null; then + echo "FAIL: ros2 command not found" + exit 1 +fi + +echo "ros2 found at: $(which ros2)" + +# Check ROS 2 daemon +echo "Checking ROS 2 daemon..." +ros2 daemon status || ros2 daemon start || true + +# List available packages +echo "Listing installed ROS 2 packages..." +ros2 pkg list | head -20 +echo "... (truncated, $(ros2 pkg list | wc -l) total packages)" + +# Test basic ROS 2 functionality +echo "Testing ros2 topic list..." +timeout 5s ros2 topic list 2>/dev/null || echo "No topics (expected without running nodes)" + +echo "SUCCESS: ROS 2 installation test passed" +exit 0 diff --git a/projects/RLLearning/.gitignore b/projects/RLLearning/.gitignore new file mode 100644 index 00000000..447b85ba --- /dev/null +++ b/projects/RLLearning/.gitignore @@ -0,0 +1,10 @@ +# Extracted training checkpoints (keep PandaPickCube-*.zip tracked) +PandaPickCube-*/ + +__pycache__/ +*.pyc + +# Notebook outputs +trajectory.npz +panda_pick_cube*.npz +*.mp4 diff --git a/projects/RLLearning/PandaPickCube-20260807-131132.zip b/projects/RLLearning/PandaPickCube-20260807-131132.zip new file mode 100644 index 00000000..b321780a Binary files /dev/null and b/projects/RLLearning/PandaPickCube-20260807-131132.zip differ diff --git a/projects/RLLearning/PandaPickCube-20260817-150103.zip b/projects/RLLearning/PandaPickCube-20260817-150103.zip new file mode 100644 index 00000000..dd03e8e7 Binary files /dev/null and b/projects/RLLearning/PandaPickCube-20260817-150103.zip differ diff --git a/projects/RLLearning/README.md b/projects/RLLearning/README.md new file mode 100644 index 00000000..6a37a2d2 --- /dev/null +++ b/projects/RLLearning/README.md @@ -0,0 +1,62 @@ + + +# ROSCon 2026: RL Learning + +PandaPickCube inference demo for ROSCon. Open [`hands-on.ipynb`](hands-on.ipynb) to roll out three Brax PPO checkpoints (weak → improving → strong) and render comparison videos. + +## Contents + +| File | Purpose | +|------|---------| +| `hands-on.ipynb` | Main inference notebook (checkpoint progression demo) | +| `headless_gl.py` | Headless MuJoCo rendering via system Mesa/OSMesa | +| `scripts/render_trajectory.py` | Re-render a saved rollout without rerunning inference | +| `scripts/render_checkpoint_once.py` | One-off rollout + render for a single checkpoint | +| `PandaPickCube-20260807-131132.zip` | Early-training checkpoints (demo uses `000008192000`) | +| `PandaPickCube-20260817-150103.zip` | Mid/final checkpoints (demo uses `000006553600`, `000045875200`) | + +## Checkpoint progression + +The notebook runs three stages in **demo order** (picked by rollout quality, not step count alone): + +| Stage | Step folder | Video output | +|-------|-------------|--------------| +| Early (weak) | `131132` → `000008192000` (8.2M) | `panda_pick_cube_early.mp4` | +| Mid (improving) | `150103` → `000006553600` (6.5M) | `panda_pick_cube_mid.mp4` | +| Final (strong) | `150103` → `000045875200` (45.9M) | `panda_pick_cube.mp4` | + +## Docker image + +All dependencies are installed in [`dockerfiles/Courses/RLLearning/Dockerfile`](../../dockerfiles/Courses/RLLearning/Dockerfile): Python packages, headless GL libraries, and both checkpoint archives extracted at build time. + +From a sparse checkout that includes `dockerfiles/Courses/RLLearning` and `dockerfiles/Makefile`: + +```bash +make -C dockerfiles rl-learning GPU_TARGET=gfx1151 +``` + +## Notebook Instructions + +Course notebooks are staged at `/ryzers/notebooks` in the image. Open `hands-on.ipynb` there and run the cells in order — no `%pip` or pixi steps in the notebook. + +**What you will show the audience** + +- The simulation stack already baked into the course image (MuJoCo Playground, JAX/MJX, Brax). +- Three saved checkpoints from different points in training — early, mid, and final. +- One rollout per checkpoint, rendered to video, so behavior and reward visibly improve. + +**What this notebook does not do** + +- It does not train a policy. All weights are pre-baked in the Docker image. +- CPU JAX is enough. We run deterministic inference only. + +**Run the cells in order** + +1. Verify imports and the render backend +2. Confirm checkpoint paths on disk +3. Load the `PandaPickCube` environment +4. Define the Brax PPO loader +5. Prepare rollout helpers, then run **4a → 4b → 4c** (one cell per video) +6. Summary table and closing notes + +Use the RLLearning course Docker image. It installs Python packages, headless GL libraries, and both checkpoint archives before you open the notebook. diff --git a/projects/RLLearning/hands-on.ipynb b/projects/RLLearning/hands-on.ipynb new file mode 100644 index 00000000..3f8e4c89 --- /dev/null +++ b/projects/RLLearning/hands-on.ipynb @@ -0,0 +1,474 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# PandaPickCube inference\n", + "\n", + "This notebook shows how a Brax PPO policy improves while training on the `PandaPickCube` task: a Franka arm learns to reach, grasp, and lift a cube in MuJoCo.\n" + ] + }, + { + "cell_type": "markdown", + "id": "4330c5c3", + "metadata": {}, + "source": [ + "## Verify imports and the render backend\n", + "\n", + "### MuJoCo Playground + JAX/MJX\n", + "The models for the manipulation environment we load was trained on JAX. The notebook sets `impl=jax` so inference stays on the same path we used before for checkpoint compatibality. Exporting to another format like ONNX can be done if cross-simulator/framework interoperability is needed.\n", + "\n", + "### Headless rendering\n", + "If your computer runs without a display, like this one, we can probe Mesa/OSMesa or EGL in a subprocess, and then render each rollout video outside the notebook kernel. The kernel itself sets `MUJOCO_GL=disable` so stepping stays lightweight." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "step2-code", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import sys\n", + "from importlib.metadata import version\n", + "from pathlib import Path\n", + "\n", + "REPO_ROOT = Path.cwd().resolve()\n", + "sys.path.insert(0, str(REPO_ROOT))\n", + "\n", + "from headless_gl import (\n", + " build_gl_env,\n", + " enable_demo_quiet_mode,\n", + " probe_render_subprocess,\n", + " quiet_demo_output,\n", + ")\n", + "\n", + "enable_demo_quiet_mode()\n", + "\n", + "CAN_RENDER, probe_report, GL_ENV = probe_render_subprocess(REPO_ROOT)\n", + "RENDER_BACKEND = probe_report if CAN_RENDER else None\n", + "\n", + "if CAN_RENDER:\n", + " print(f\"Render backend: {RENDER_BACKEND}\")\n", + "else:\n", + " GL_ENV = build_gl_env(REPO_ROOT)\n", + " print(\"No render backend worked, so section 4 will fail. Backends tried:\\n\")\n", + " print(probe_report)\n", + "\n", + "# This kernel only steps the environment; section 4 renders in subprocesses.\n", + "os.environ[\"MUJOCO_GL\"] = \"disable\"\n", + "\n", + "import imageio_ffmpeg\n", + "\n", + "ffmpeg_dir = str(Path(imageio_ffmpeg.get_ffmpeg_exe()).parent)\n", + "os.environ[\"PATH\"] = ffmpeg_dir + os.pathsep + os.environ.get(\"PATH\", \"\")\n", + "\n", + "with quiet_demo_output():\n", + " import jax\n", + " import mujoco\n", + " from mujoco_playground import registry\n", + "\n", + "import numpy as np\n", + "from IPython.display import Video, display\n", + "\n", + "print(\"Working directory:\", REPO_ROOT)\n", + "print(\"JAX backend:\", jax.default_backend())\n", + "print(\"JAX devices:\", jax.devices())\n", + "for package in (\"mujoco\", \"mujoco-mjx\", \"jax\", \"jaxlib\", \"brax\", \"playground\"):\n", + " print(f\"{package}: {version(package)}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "step3-md", + "metadata": {}, + "source": [ + "## 1. Confirm checkpoint paths\n", + "\n", + "Brax saves PPO policies as Orbax checkpoint folders. Each folder contains `ppo_network_config.json` plus weight shards.\n", + "\n", + "The next bit of Python defines the three policies we will compare in section 4: early, mid, and final, and verifies they are present on disk.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "step3-code", + "metadata": {}, + "outputs": [], + "source": [ + "CHECKPOINT_STAGES = [\n", + " {\n", + " \"label\": \"early — weak policy\",\n", + " \"path\": REPO_ROOT / \"PandaPickCube-20260807-131132/checkpoints/000008192000\",\n", + " \"video\": \"panda_pick_cube_early.mp4\",\n", + " },\n", + " {\n", + " \"label\": \"mid — improving policy\",\n", + " \"path\": REPO_ROOT / \"PandaPickCube-20260817-150103/checkpoints/000006553600\",\n", + " \"video\": \"panda_pick_cube_mid.mp4\",\n", + " },\n", + " {\n", + " \"label\": \"final — strong policy\",\n", + " \"path\": REPO_ROOT / \"PandaPickCube-20260817-150103/checkpoints/000045875200\",\n", + " \"video\": \"panda_pick_cube.mp4\",\n", + " },\n", + "]\n", + "\n", + "for index, stage in enumerate(CHECKPOINT_STAGES, start=1):\n", + " checkpoint = stage[\"path\"]\n", + " if not (checkpoint / \"ppo_network_config.json\").is_file():\n", + " raise FileNotFoundError(\n", + " f\"Stage {index} missing ppo_network_config.json: {checkpoint}\\n\"\n", + " \"Rebuild the course Docker image so both checkpoint archives are extracted.\"\n", + " )\n", + " print(f\"{index}. {stage['label']}\")\n", + " print(f\" checkpoint: {checkpoint}\")\n", + " print(f\" video: {stage['video']}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "step4-md", + "metadata": {}, + "source": [ + "## 2. Load `PandaPickCube`\n", + "\n", + "`PandaPickCube` comes from [MuJoCo Playground](https://github.com/google-deepmind/mujoco_playground): a tabletop scene with a Franka Panda arm, a free cube, and a sparse reward for moving the cube toward a target pose.\n", + "\n", + "**Menagerie assets**: the Franka model lives in [MuJoCo Menagerie](https://github.com/google-deepmind/mujoco_menagerie), the digital twin of the Franka Emika Panda robot.\n", + "\n", + "Notice the observation size, action size, timestep, and episode length. That frames what the policy network sees and how long each rollout can run (150 steps × 0.02 s ≈ 3 s of sim time).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "step4-code", + "metadata": {}, + "outputs": [], + "source": [ + "ENV_NAME = \"PandaPickCube\"\n", + "env_cfg = registry.get_default_config(ENV_NAME)\n", + "with quiet_demo_output():\n", + " env = registry.load(\n", + " ENV_NAME,\n", + " config=env_cfg,\n", + " config_overrides={\"impl\": \"jax\"},\n", + " )\n", + "\n", + "print(\n", + " f\"Loaded {ENV_NAME}: impl={env._config.impl}, \"\n", + " f\"obs={env.observation_size}, actions={env.action_size}, \"\n", + " f\"dt={env.dt}s, episode_length={int(env_cfg.episode_length)}\"\n", + ")\n", + "print(\"MuJoCo version:\", mujoco.__version__)\n" + ] + }, + { + "cell_type": "markdown", + "id": "step5-md", + "metadata": {}, + "source": [ + "## 3. PPO policy loader\n", + "\n", + "Training saved three things we need at inference time:\n", + "\n", + "1. **Network architecture**: in `ppo_network_config.json` (layer sizes, activation, PPO hyperparameters metadata).\n", + "2. **Learned weights**: Orbax checkpoint shards in the same folder.\n", + "3. **Environment contract**: observation and action sizes must match the live `PandaPickCube` env.\n", + "\n", + "The helper in the next cell rebuilds the Brax actor, loads weights, and returns a deterministic policy function.\n", + "\n", + "We load a **fresh** policy for each checkpoint in section 4.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "step5-code", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "\n", + "from brax.training import checkpoint as brax_checkpoint\n", + "from brax.training.agents.ppo import networks as ppo_networks\n", + "from ml_collections import config_dict\n", + "\n", + "\n", + "def load_ppo_policy(checkpoint_path, env, deterministic=True):\n", + " \"\"\"Load Brax PPO policy; tolerate malformed Brax 0.14.2 checkpoint JSON.\"\"\"\n", + " path = Path(checkpoint_path)\n", + " loaded_dict = json.loads((path / \"ppo_network_config.json\").read_text())\n", + " factory_kwargs = loaded_dict[\"network_factory_kwargs\"]\n", + "\n", + " if \"activation\" in factory_kwargs:\n", + " factory_kwargs[\"activation\"] = brax_checkpoint.networks.ACTIVATION[\n", + " factory_kwargs[\"activation\"]\n", + " ]\n", + "\n", + " for init_fn_name in brax_checkpoint._KERNEL_INIT_FN_KEYWORDS:\n", + " if init_fn_name not in factory_kwargs:\n", + " continue\n", + " init_fn_value = factory_kwargs[init_fn_name]\n", + " if init_fn_value is None:\n", + " del factory_kwargs[init_fn_name]\n", + " continue\n", + " factory_kwargs[init_fn_name] = brax_checkpoint.networks.KERNEL_INITIALIZER[\n", + " init_fn_value\n", + " ]\n", + "\n", + " loaded_dict[\"observation_size\"] = env.observation_size\n", + " loaded_dict[\"action_size\"] = env.action_size\n", + "\n", + " config = config_dict.create(**loaded_dict)\n", + " params = brax_checkpoint.load(path)\n", + " ppo_network = brax_checkpoint.get_network(config, ppo_networks.make_ppo_networks)\n", + " make_inference_fn = ppo_networks.make_inference_fn(ppo_network)\n", + " return make_inference_fn(params, deterministic=deterministic)\n", + "\n", + "\n", + "print(\"PPO loader ready.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "step6-md", + "metadata": {}, + "source": [ + "## 4. Roll out and render each checkpoint\n", + "\n", + "Our three checkpoints:\n", + "\n", + "| Stage | What to say while the video plays |\n", + "|-------|-----------------------------------|\n", + "| Early | Unreliable grasp |\n", + "| Mid | Clear improvement, better approach and contact |\n", + "| Final | Stable pick-and-place; this is the policy you would ship. |\n", + "\n", + "\n", + "The next cell defines rollout helpers. Then run **4a → 4b → 4c** one at a time. \n", + "Each code cell loads a checkpoint, rolls out one episode (`SEED=42`), and outputs its video. \n", + "\n", + "Here, videos are generated from trajectory `.npz` files let us re-render without rerunning inference.\n", + "\n", + "The first JIT compile per checkpoint can take up to a minute.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "step6-code", + "metadata": {}, + "outputs": [], + "source": [ + "import subprocess\n", + "import sys\n", + "\n", + "NUM_EPISODES = 1\n", + "SEED = 42\n", + "\n", + "jit_reset = jax.jit(env.reset)\n", + "jit_step = jax.jit(env.step)\n", + "progression_results: list[dict] = []\n", + "\n", + "if not CAN_RENDER:\n", + " raise RuntimeError(\n", + " \"No working render backend. Rebuild the course Docker image or \"\n", + " \"restart the kernel, then rerun section 1.\"\n", + " )\n", + "\n", + "\n", + "def rollout_episode(jit_policy, seed: int) -> tuple[float, list]:\n", + " rng = jax.random.PRNGKey(seed)\n", + " episodes = []\n", + " for ep in range(NUM_EPISODES):\n", + " rng, reset_rng = jax.random.split(rng)\n", + " state = jit_reset(reset_rng)\n", + " trajectory = [state]\n", + " episode_reward = 0.0\n", + " for _ in range(int(env_cfg.episode_length)):\n", + " rng, action_rng = jax.random.split(rng)\n", + " action, _ = jit_policy(state.obs, action_rng)\n", + " state = jit_step(state, action)\n", + " trajectory.append(state)\n", + " episode_reward += float(np.asarray(state.reward))\n", + " if bool(np.asarray(state.done)):\n", + " break\n", + " episodes.append((episode_reward, trajectory))\n", + " print(f\" Episode {ep + 1}: reward={episode_reward:.3f} steps={len(trajectory) - 1}\")\n", + " return max(episodes, key=lambda item: item[0])\n", + "\n", + "\n", + "def save_trajectory_npz(path: Path, trajectory, episode_reward: float) -> None:\n", + " qpos = np.stack([np.asarray(s.data.qpos) for s in trajectory])\n", + " qvel = np.stack([np.asarray(s.data.qvel) for s in trajectory])\n", + " mocap_pos = np.stack([np.asarray(s.data.mocap_pos) for s in trajectory])\n", + " mocap_quat = np.stack([np.asarray(s.data.mocap_quat) for s in trajectory])\n", + " rewards = np.array(\n", + " [float(np.asarray(s.reward)) for s in trajectory[1:]], dtype=np.float32\n", + " )\n", + " np.savez(\n", + " path,\n", + " qpos=qpos,\n", + " qvel=qvel,\n", + " mocap_pos=mocap_pos,\n", + " mocap_quat=mocap_quat,\n", + " rewards=rewards,\n", + " episode_reward=episode_reward,\n", + " dt=float(env.dt),\n", + " env_name=ENV_NAME,\n", + " )\n", + " print(f\" Saved trajectory to {path} ({qpos.shape[0]} steps)\")\n", + "\n", + "\n", + "def render_trajectory(traj_path: Path, video_path: Path) -> None:\n", + " result = subprocess.run(\n", + " [\n", + " sys.executable,\n", + " str(REPO_ROOT / \"scripts\" / \"render_trajectory.py\"),\n", + " str(traj_path),\n", + " \"-o\",\n", + " str(video_path),\n", + " \"--env\",\n", + " ENV_NAME,\n", + " ],\n", + " cwd=REPO_ROOT,\n", + " env=GL_ENV,\n", + " capture_output=True,\n", + " text=True,\n", + " )\n", + " if result.stdout:\n", + " print(result.stdout.strip())\n", + " if result.returncode != 0:\n", + " raise RuntimeError(\n", + " f\"Rendering failed with backend {RENDER_BACKEND!r}.\\n\\n\"\n", + " f\"{result.stderr or result.stdout}\"\n", + " )\n", + "\n", + "\n", + "def run_checkpoint_stage(stage: dict) -> dict:\n", + " \"\"\"Load one checkpoint, roll out, render, and show the video inline.\"\"\"\n", + " print(f\"\\n=== {stage['label']} ===\")\n", + " checkpoint = stage[\"path\"]\n", + " video_path = REPO_ROOT / stage[\"video\"]\n", + " traj_path = video_path.with_suffix(\".npz\")\n", + "\n", + " inference_fn = load_ppo_policy(checkpoint, env, deterministic=True)\n", + " jit_policy = jax.jit(inference_fn)\n", + "\n", + " episode_reward, trajectory = rollout_episode(jit_policy, SEED)\n", + " save_trajectory_npz(traj_path, trajectory, episode_reward)\n", + " render_trajectory(traj_path, video_path)\n", + "\n", + " display(Video(str(video_path), embed=True, width=640))\n", + " print(f\" Done: {video_path}\")\n", + "\n", + " result = {\n", + " \"label\": stage[\"label\"],\n", + " \"reward\": episode_reward,\n", + " \"steps\": len(trajectory) - 1,\n", + " \"video\": video_path,\n", + " }\n", + " progression_results.append(result)\n", + " return result\n", + "\n", + "\n", + "print(\"Rollout helpers ready. Run the next three cells when you are ready for each video.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "fc8a92ea", + "metadata": {}, + "source": [ + "### 4a. Early checkpoint, weak policy" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ff8f124", + "metadata": {}, + "outputs": [], + "source": [ + "run_checkpoint_stage(CHECKPOINT_STAGES[0])" + ] + }, + { + "cell_type": "markdown", + "id": "32b5fac9", + "metadata": {}, + "source": [ + "### 4b. Mid checkpoint, improving policy" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d404e4f9", + "metadata": {}, + "outputs": [], + "source": [ + "run_checkpoint_stage(CHECKPOINT_STAGES[1])" + ] + }, + { + "cell_type": "markdown", + "id": "ab1128d0", + "metadata": {}, + "source": [ + "### 4c. Final checkpoint, strong policy" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b8b9846c", + "metadata": {}, + "outputs": [], + "source": [ + "run_checkpoint_stage(CHECKPOINT_STAGES[2])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "829e8200", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"=== Checkpoint progression summary ===\")\n", + "for row in progression_results:\n", + " print(f\"{row['label']}: reward={row['reward']:.3f}, steps={row['steps']}, video={row['video'].name}\")" + ] + }, + { + "cell_type": "markdown", + "id": "step7-md", + "metadata": {}, + "source": [ + "**Remarks**\n", + "\n", + "- Same environment, same seed, different checkpoints → behavior change is purely from learning." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/projects/RLLearning/headless_gl.py b/projects/RLLearning/headless_gl.py new file mode 100644 index 00000000..c6ba5a31 --- /dev/null +++ b/projects/RLLearning/headless_gl.py @@ -0,0 +1,190 @@ +"""Headless MuJoCo GL setup using system Mesa/OSMesa libraries. + +Expects `libosmesa6`, Mesa EGL, and DRI drivers from the course Docker image. +Rendering must run in a subprocess started with GL variables already set, because +the dynamic loader captures its search path at process start. + +Backends are probed in order because which one works depends on the Mesa build: + +- `osmesa` needs `libOSMesa.so.8`. +- the EGL backends need software rendering (`LIBGL_ALWAYS_SOFTWARE=1`). +""" + +from __future__ import annotations + +import contextlib +import io +import os +import subprocess +import sys +import warnings +from pathlib import Path + +# Set in child environments so a re-executed process does not loop. +CONFIGURED_FLAG = "HEADLESS_GL_CONFIGURED" + + +@contextlib.contextmanager +def quiet_demo_output(): + """Hide optional-backend chatter and benign JAX cast warnings during demos.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + sink = io.StringIO() + with contextlib.redirect_stdout(sink): + yield + + +def enable_demo_quiet_mode() -> None: + """Apply process-wide filters for a clean notebook demo.""" + warnings.filterwarnings("ignore", category=RuntimeWarning) + warnings.filterwarnings("ignore", message="overflow encountered in cast") + +# Backend name -> variables that select it. Every GL variable listed in any +# candidate is cleared first, so candidates never inherit each other's state. +BACKEND_CANDIDATES: tuple[tuple[str, dict[str, str]], ...] = ( + ( + "osmesa", + {"MUJOCO_GL": "osmesa", "PYOPENGL_PLATFORM": "osmesa"}, + ), + ( + "egl-surfaceless", + { + "MUJOCO_GL": "egl", + "PYOPENGL_PLATFORM": "egl", + "LIBGL_ALWAYS_SOFTWARE": "1", + "EGL_PLATFORM": "surfaceless", + }, + ), + ( + "egl-device", + { + "MUJOCO_GL": "egl", + "PYOPENGL_PLATFORM": "egl", + "LIBGL_ALWAYS_SOFTWARE": "1", + "EGL_PLATFORM": "device", + }, + ), + ( + "egl-default", + { + "MUJOCO_GL": "egl", + "PYOPENGL_PLATFORM": "egl", + "LIBGL_ALWAYS_SOFTWARE": "1", + }, + ), +) + +DEFAULT_BACKEND = BACKEND_CANDIDATES[0][0] + +_BACKEND_KEYS = ( + "MUJOCO_GL", + "PYOPENGL_PLATFORM", + "LIBGL_ALWAYS_SOFTWARE", + "EGL_PLATFORM", +) + +_GL_ENV_KEYS = ( + *_BACKEND_KEYS, + "LD_LIBRARY_PATH", + "MESA_LOADER_DRIVER_OVERRIDE", + "GALLIUM_DRIVER", + "LIBGL_DRIVERS_PATH", + "__EGL_VENDOR_LIBRARY_FILENAMES", + CONFIGURED_FLAG, +) + +_PROBE_CODE = ( + "import mujoco\n" + "model = mujoco.MjModel.from_xml_string('')\n" + "renderer = mujoco.Renderer(model, height=64, width=64)\n" + "renderer.render()\n" + "renderer.close()\n" + "print('probe ok')\n" +) + +_SYSTEM_DRI = Path("/usr/lib/x86_64-linux-gnu/dri") +_EGL_VENDOR_FILES = ( + Path("/usr/share/glvnd/egl_vendor.d/50_mesa.json"), + Path("/usr/share/glvnd/egl_vendor.d/10_mesa.json"), +) + + +def build_gl_env(repo_root: Path, backend: str = DEFAULT_BACKEND) -> dict[str, str]: + """Environment for rendering with the named backend.""" + del repo_root # kept for call-site compatibility + overrides = dict(BACKEND_CANDIDATES).get(backend) + if overrides is None: + raise ValueError(f"Unknown backend {backend!r}") + + env = os.environ.copy() + for key in _BACKEND_KEYS: + env.pop(key, None) + + env["MESA_LOADER_DRIVER_OVERRIDE"] = "llvmpipe" + env["GALLIUM_DRIVER"] = "llvmpipe" + env.update(overrides) + + if _SYSTEM_DRI.is_dir(): + env["LIBGL_DRIVERS_PATH"] = str(_SYSTEM_DRI) + + for vendor in _EGL_VENDOR_FILES: + if vendor.is_file(): + env["__EGL_VENDOR_LIBRARY_FILENAMES"] = str(vendor) + break + + env[CONFIGURED_FLAG] = "1" + return env + + +def apply_gl_env(env: dict[str, str]) -> None: + """Copy GL variables into this process, for reporting and child processes. + + This does not make rendering work in the current interpreter; `dlopen` + ignores `LD_LIBRARY_PATH` changes made after process start. + """ + for key in _GL_ENV_KEYS: + if key in env: + os.environ[key] = env[key] + else: + os.environ.pop(key, None) + + +def probe_render_subprocess( + repo_root: Path, +) -> tuple[bool, str, dict[str, str] | None]: + """Find a backend that can render, by trying each one in a fresh process. + + Returns whether rendering worked, a description of the outcome, and the + environment that succeeded. + """ + failures = [] + for backend, _ in BACKEND_CANDIDATES: + env = build_gl_env(repo_root, backend) + result = subprocess.run( + [sys.executable, "-c", _PROBE_CODE], + env=env, + capture_output=True, + text=True, + cwd=repo_root, + ) + if result.returncode == 0: + return True, backend, env + error = (result.stderr or result.stdout or "no output").strip() + failures.append(f"--- {backend} ---\n{error[-600:]}") + + return False, "\n\n".join(failures), None + + +def resolve_gl_env(repo_root: Path) -> dict[str, str]: + """Environment that renders successfully, or the first candidate.""" + ok, _, env = probe_render_subprocess(repo_root) + if ok and env is not None: + return env + return build_gl_env(repo_root) + + +def reexec_with_gl_env(repo_root: Path) -> None: + """Restart this process so the dynamic loader sees the GL libraries.""" + if os.environ.get(CONFIGURED_FLAG) == "1": + return + os.execve(sys.executable, [sys.executable, *sys.argv], resolve_gl_env(repo_root)) diff --git a/projects/RLLearning/scripts/render_checkpoint_once.py b/projects/RLLearning/scripts/render_checkpoint_once.py new file mode 100644 index 00000000..1547abb4 --- /dev/null +++ b/projects/RLLearning/scripts/render_checkpoint_once.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""One-off: rollout + render a single Brax checkpoint (same seed as hands-on.ipynb).""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from headless_gl import ( # noqa: E402 + build_gl_env, + enable_demo_quiet_mode, + probe_render_subprocess, + quiet_demo_output, +) + +enable_demo_quiet_mode() + +import jax # noqa: E402 +import numpy as np # noqa: E402 +from brax.training import checkpoint as brax_checkpoint # noqa: E402 +from brax.training.agents.ppo import networks as ppo_networks # noqa: E402 +from ml_collections import config_dict # noqa: E402 +from mujoco_playground import registry # noqa: E402 + + +def load_ppo_policy(checkpoint_path: Path, env, deterministic=True): + loaded_dict = json.loads((checkpoint_path / "ppo_network_config.json").read_text()) + factory_kwargs = loaded_dict["network_factory_kwargs"] + if "activation" in factory_kwargs: + factory_kwargs["activation"] = brax_checkpoint.networks.ACTIVATION[ + factory_kwargs["activation"] + ] + for init_fn_name in brax_checkpoint._KERNEL_INIT_FN_KEYWORDS: + if init_fn_name not in factory_kwargs: + continue + init_fn_value = factory_kwargs[init_fn_name] + if init_fn_value is None: + del factory_kwargs[init_fn_name] + continue + factory_kwargs[init_fn_name] = brax_checkpoint.networks.KERNEL_INITIALIZER[ + init_fn_value + ] + loaded_dict["observation_size"] = env.observation_size + loaded_dict["action_size"] = env.action_size + config = config_dict.create(**loaded_dict) + params = brax_checkpoint.load(checkpoint_path) + ppo_network = brax_checkpoint.get_network(config, ppo_networks.make_ppo_networks) + make_inference_fn = ppo_networks.make_inference_fn(ppo_network) + return make_inference_fn(params, deterministic=deterministic) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("checkpoint", type=Path, help="checkpoint step directory") + parser.add_argument("-o", "--output", type=Path, required=True, help="output mp4") + parser.add_argument("--seed", type=int, default=42) + args = parser.parse_args() + + checkpoint = args.checkpoint.resolve() + video_path = args.output.resolve() + traj_path = video_path.with_suffix(".npz") + + can_render, probe_report, gl_env = probe_render_subprocess(REPO_ROOT) + if not can_render: + gl_env = build_gl_env(REPO_ROOT) + raise SystemExit(f"No render backend worked:\n{probe_report}") + + with quiet_demo_output(): + env_cfg = registry.get_default_config("PandaPickCube") + env = registry.load( + "PandaPickCube", + config=env_cfg, + config_overrides={"impl": "jax"}, + ) + + inference_fn = load_ppo_policy(checkpoint, env, deterministic=True) + jit_reset = jax.jit(env.reset) + jit_step = jax.jit(env.step) + jit_policy = jax.jit(inference_fn) + + rng = jax.random.PRNGKey(args.seed) + rng, reset_rng = jax.random.split(rng) + state = jit_reset(reset_rng) + trajectory = [state] + episode_reward = 0.0 + for _ in range(int(env_cfg.episode_length)): + rng, action_rng = jax.random.split(rng) + action, _ = jit_policy(state.obs, action_rng) + state = jit_step(state, action) + trajectory.append(state) + episode_reward += float(np.asarray(state.reward)) + if bool(np.asarray(state.done)): + break + + qpos = np.stack([np.asarray(s.data.qpos) for s in trajectory]) + qvel = np.stack([np.asarray(s.data.qvel) for s in trajectory]) + mocap_pos = np.stack([np.asarray(s.data.mocap_pos) for s in trajectory]) + mocap_quat = np.stack([np.asarray(s.data.mocap_quat) for s in trajectory]) + rewards = np.array( + [float(np.asarray(s.reward)) for s in trajectory[1:]], dtype=np.float32 + ) + np.savez( + traj_path, + qpos=qpos, + qvel=qvel, + mocap_pos=mocap_pos, + mocap_quat=mocap_quat, + rewards=rewards, + episode_reward=episode_reward, + dt=float(env.dt), + env_name="PandaPickCube", + ) + + result = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "scripts" / "render_trajectory.py"), + str(traj_path), + "-o", + str(video_path), + "--env", + "PandaPickCube", + ], + cwd=REPO_ROOT, + env=gl_env, + capture_output=True, + text=True, + ) + if result.stdout: + print(result.stdout.strip()) + if result.returncode != 0: + raise SystemExit(result.stderr or result.stdout) + + steps = len(trajectory) - 1 + print(f"checkpoint: {checkpoint}") + print(f"reward={episode_reward:.3f} steps={steps}") + print(f"video: {video_path}") + + +if __name__ == "__main__": + main() diff --git a/projects/RLLearning/scripts/render_trajectory.py b/projects/RLLearning/scripts/render_trajectory.py new file mode 100644 index 00000000..1a4f45a3 --- /dev/null +++ b/projects/RLLearning/scripts/render_trajectory.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Render trajectory.npz to mp4 using headless MuJoCo + system Mesa/OSMesa.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from headless_gl import reexec_with_gl_env # noqa: E402 + +# Must happen before MuJoCo is imported: LD_LIBRARY_PATH only takes effect for a +# freshly started process. +reexec_with_gl_env(REPO_ROOT) + +import imageio.v2 as imageio # noqa: E402 +import mujoco # noqa: E402 +import numpy as np # noqa: E402 +from mujoco_playground import registry # noqa: E402 + + +def render_npz(npz_path: Path, output_path: Path, env_name: str) -> None: + data = np.load(npz_path) + qpos = data["qpos"] + qvel = data["qvel"] if "qvel" in data else None + mocap_pos = data["mocap_pos"] if "mocap_pos" in data else None + mocap_quat = data["mocap_quat"] if "mocap_quat" in data else None + dt = float(data["dt"]) + + env_cfg = registry.get_default_config(env_name) + env = registry.load(env_name, config=env_cfg, config_overrides={"impl": "jax"}) + model = env.mj_model + + renderer = mujoco.Renderer(model, height=480, width=640) + mj_data = mujoco.MjData(model) + frames = [] + for i in range(len(qpos)): + mj_data.qpos[:] = qpos[i] + if qvel is not None: + mj_data.qvel[:] = qvel[i] + if mocap_pos is not None: + mj_data.mocap_pos[:] = mocap_pos[i] + if mocap_quat is not None: + mj_data.mocap_quat[:] = mocap_quat[i] + mujoco.mj_forward(model, mj_data) + renderer.update_scene(mj_data) + frames.append(renderer.render()) + renderer.close() + + fps = 1.0 / dt + imageio.mimwrite(output_path, frames, fps=fps, codec="libx264", quality=8) + print(f"Wrote {len(frames)} frames to {output_path} at {fps:.1f} fps") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("npz", type=Path, help="trajectory.npz from hands-on.ipynb") + parser.add_argument( + "-o", + "--output", + type=Path, + default=REPO_ROOT / "panda_pick_cube.mp4", + help="output mp4 path", + ) + parser.add_argument("--env", default="PandaPickCube", help="MuJoCo Playground env") + args = parser.parse_args() + render_npz(args.npz.resolve(), args.output.resolve(), args.env) + + +if __name__ == "__main__": + main() diff --git a/runtime/values.yaml b/runtime/values.yaml index 45d51845..4b657f09 100644 --- a/runtime/values.yaml +++ b/runtime/values.yaml @@ -286,12 +286,16 @@ custom: Course-DL: "ghcr.io/amdresearch/auplc-dl:latest" Course-LLM: "ghcr.io/amdresearch/auplc-llm:latest" Course-PhySim: "ghcr.io/amdresearch/auplc-physim:latest" + Course-Finetuning: "ghcr.io/amdresearch/auplc-finetuning:latest" + Course-LocalInference: "ghcr.io/amdresearch/auplc-localinference:latest" + Course-RLLearning: "ghcr.io/amdresearch/auplc-rl-learning:latest" # Custom-URL: "ghcr.io/amdresearch/auplc-cv:latest" # Optional spawn/Home group display order. Groups not listed here are shown # after these entries using the default alphabetical order. # Example: move DEVELOPMENT before COURSE by listing DEVELOPMENT first. groupOrder: + - ROSCON 2026 - TEACHING LABS - DEVELOPMENT ENVIRONMENTS - TUTORIALS @@ -330,6 +334,18 @@ custom: cpu: "0" memory: "0Gi" amd.com/gpu: "1" + Course-Finetuning: + cpu: "0" + memory: "0Gi" + amd.com/gpu: "1" + Course-LocalInference: + cpu: "0" + memory: "0Gi" + amd.com/gpu: "1" + Course-RLLearning: + cpu: "0" + memory: "0Gi" + amd.com/gpu: "1" none: cpu: "0" memory: "0Gi" @@ -502,6 +518,33 @@ custom: image: "ghcr.io/amdresearch/auplc-physim:latest-gfx120x" defaultPath: "/opt/workspace/PhySim" resourceType: "notebook" + Course-Finetuning: + group: "ROSCON 2026" + description: "Fine-tuning on GPUs: from cloud to robot" + subDescription: "Fine-tune MolmoAct2 policies with ROCm" + accelerator: "GPU" + acceleratorKeys: + - strix-halo + defaultPath: "/ryzers/notebooks" + resourceType: "notebook" + Course-LocalInference: + group: "ROSCON 2026" + description: "Local inference of embodied AI" + subDescription: "Run local models with ROS 2 and RAI" + accelerator: "GPU" + acceleratorKeys: + - strix-halo + defaultPath: "/ryzers/notebooks" + resourceType: "notebook" + Course-RLLearning: + group: "ROSCON 2026" + description: "Reinforcement learning for robotics" + subDescription: "Course materials under development" + accelerator: "GPU" + acceleratorKeys: + - strix-halo + defaultPath: "/ryzers/notebooks" + resourceType: "notebook" # ============================================================================ # Team Permission Configuration @@ -518,6 +561,9 @@ custom: - Course-DL - Course-LLM - Course-PhySim + - Course-Finetuning + - Course-LocalInference + - Course-RLLearning official: - cpu - gpu @@ -527,11 +573,17 @@ custom: - Course-DL - Course-LLM - Course-PhySim + - Course-Finetuning + - Course-LocalInference + - Course-RLLearning AUP: - Course-CV - Course-DL - Course-LLM - Course-PhySim + - Course-Finetuning + - Course-LocalInference + - Course-RLLearning native-users: - code-cpu - code-gpu @@ -539,6 +591,9 @@ custom: - Course-DL - Course-LLM - Course-PhySim + - Course-Finetuning + - Course-LocalInference + - Course-RLLearning - cpu - gpu github-users: @@ -550,6 +605,9 @@ custom: - Course-DL - Course-LLM - Course-PhySim + - Course-Finetuning + - Course-LocalInference + - Course-RLLearning # ============================================================================ # Quota Management @@ -667,6 +725,21 @@ singleuser: storage: dynamic: storageClass: local-path + # Workshop assets are BAKED INTO the finetuning course image (see + # dockerfiles/Courses/Finetuning/Dockerfile: HF cache under /opt/auplc-hf, reference + # checkpoint under /opt/auplc-ref). No shared host mount or spawn-time link step is needed; + # the image is fully self-contained and distributes to every node on its own. + # Containers get a 64Mi /dev/shm by default, which is far too small for the + # ROS 2 courses - Fast-DDS allocates its shared-memory transport there and + # fails with "open_and_lock_file failed", silently degrading to UDP. + extraVolumes: + - name: dshm + emptyDir: + medium: Memory + sizeLimit: 2Gi + extraVolumeMounts: + - name: dshm + mountPath: /dev/shm # Use NodePort for direct access proxy: diff --git a/scripts/verify-resource-contracts.py b/scripts/verify-resource-contracts.py index bf873ec9..0e7fa392 100644 --- a/scripts/verify-resource-contracts.py +++ b/scripts/verify-resource-contracts.py @@ -27,6 +27,8 @@ "Course-DL", "Course-LLM", "Course-PhySim", + "Course-Finetuning", + "Course-LocalInference", ) CODE_SERVER_START_SCRIPT = "/usr/local/bin/start-code-server.sh" diff --git a/tests/installer/test_catalog.py b/tests/installer/test_catalog.py index eb26d164..dd8bd89d 100644 --- a/tests/installer/test_catalog.py +++ b/tests/installer/test_catalog.py @@ -103,8 +103,8 @@ def test_gpu_image_basenames_only_returns_gpu_required() -> None: def test_make_targets_includes_every_selected_course() -> None: - sel = CourseSelection(picks=["cpu", "code-cpu", "Course-DL"]) - assert sel.make_targets() == ["base-cpu", "code-cpu", "dl"] + sel = CourseSelection(picks=["cpu", "code-cpu", "Course-DL", "Course-Finetuning", "Course-LocalInference"]) + assert sel.make_targets() == ["base-cpu", "code-cpu", "dl", "finetuning", "local-inference"] def test_description_default() -> None: diff --git a/tests/installer/test_overlay.py b/tests/installer/test_overlay.py index 8e4ece63..044c006a 100644 --- a/tests/installer/test_overlay.py +++ b/tests/installer/test_overlay.py @@ -130,6 +130,8 @@ def test_resource_images_use_primary_tag() -> None: assert images["code-gpu"] == "ghcr.io/amdresearch/auplc-code-gpu:v1.0-gfx1151" assert images["Course-CV"] == "ghcr.io/amdresearch/auplc-cv:v1.0-gfx1151" assert images["Course-PhySim"] == "ghcr.io/amdresearch/auplc-physim:v1.0-gfx1151" + assert images["Course-Finetuning"] == "ghcr.io/amdresearch/auplc-finetuning:v1.0-gfx1151" + assert images["Course-LocalInference"] == "ghcr.io/amdresearch/auplc-localinference:v1.0-gfx1151" def test_homogeneous_target_emits_matching_accelerator_overrides() -> None: diff --git a/tests/scripts/test_verify_resource_contracts.py b/tests/scripts/test_verify_resource_contracts.py index 6611c873..e7ba5772 100644 --- a/tests/scripts/test_verify_resource_contracts.py +++ b/tests/scripts/test_verify_resource_contracts.py @@ -34,6 +34,8 @@ def load_verifier(): "Course-DL": "/opt/workspace/DL", "Course-LLM": "/opt/workspace/LLM", "Course-PhySim": "/opt/workspace/PhySim", + "Course-Finetuning": "/ryzers/notebooks", + "Course-LocalInference": "/ryzers/notebooks", }