XiangpengYang commited on
Commit
3287fba
·
1 Parent(s): a3122c5

docs: plan pi05 UR Gradio Space

Browse files
docs/superpowers/plans/2026-07-22-pi05-ur-gradio.md ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # π₀.₅ UR Hugging Face Gradio Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Build a self-contained Hugging Face Gradio Space that loads a state-conditioned `pi05_ur_demo_state` checkpoint and predicts ten 7-dimensional UR actions from two RGB images, a task instruction, and current TCP/gripper state.
6
+
7
+ **Architecture:** Vendor the proven local OpenPI runtime into the Space, while keeping Space-specific artifact resolution, model lifecycle, inference validation, and Gradio wiring in focused modules. Load one CUDA policy lazily, cache it by Hugging Face repository/checkpoint identity, and keep all CPU tests independent of model downloads.
8
+
9
+ **Tech Stack:** Python 3.11, Gradio 6.20.0, Hugging Face Hub, OpenPI/JAX/Flax, NumPy, pandas, Pillow, pytest.
10
+
11
+ ## Global Constraints
12
+
13
+ - Support only `pi05_ur_demo_state`; do not expose LIBERO, DROID, no-state inference, websocket serving, or robot control.
14
+ - Input state order is exactly `x, y, z, roll, pitch, yaw, gripper`.
15
+ - Output shape is exactly `(10, 7)` with labels `dx`, `dy`, `dz`, `droll`, `dpitch`, `dyaw`, `gripper`.
16
+ - Use Python 3.11 and Gradio 6.20.0 in Hugging Face Space metadata.
17
+ - Model loading is lazy, CUDA-only, thread-safe, and cached for one `(model_id, checkpoint_path)` key.
18
+ - CPU tests must not download checkpoints or require CUDA.
19
+
20
+ ---
21
+
22
+ ### Task 1: Space metadata and vendored OpenPI runtime
23
+
24
+ **Files:**
25
+ - Modify: `README.md`
26
+ - Create: `requirements.txt`
27
+ - Create: `openpi_runtime/openpi/**` by copying `../VLA/openpi/src/openpi/**`
28
+ - Create: `openpi_runtime/openpi_client/**` by copying `../VLA/openpi/packages/openpi-client/src/openpi_client/**`
29
+ - Test: `tests/test_space_config.py`
30
+
31
+ **Interfaces:**
32
+ - Consumes: local source trees `../VLA/openpi/src/openpi` and `../VLA/openpi/packages/openpi-client/src/openpi_client`.
33
+ - Produces: importable `openpi.training.config`, `openpi.policies.policy_config`, and `openpi_client`; Space dependency and metadata contract.
34
+
35
+ - [ ] **Step 1: Write the failing metadata test**
36
+
37
+ ```python
38
+ from pathlib import Path
39
+
40
+
41
+ ROOT = Path(__file__).parents[1]
42
+
43
+
44
+ def test_space_metadata_and_runtime_files():
45
+ readme = (ROOT / "README.md").read_text()
46
+ requirements = (ROOT / "requirements.txt").read_text()
47
+ assert "sdk_version: 6.20.0" in readme
48
+ assert "python_version: '3.11'" in readme
49
+ assert "app_file: app.py" in readme
50
+ assert "gradio==6.20.0" in requirements
51
+ assert (ROOT / "openpi_runtime/openpi/policies/ur_policy.py").is_file()
52
+ assert (ROOT / "openpi_runtime/openpi_client/base_policy.py").is_file()
53
+
54
+
55
+ def test_vendored_config_contains_state_conditioned_ur_policy():
56
+ source = (ROOT / "openpi_runtime/openpi/training/config.py").read_text()
57
+ assert 'name="pi05_ur_demo_state"' in source
58
+ assert "LeRobotURDataConfig" in source
59
+ ```
60
+
61
+ - [ ] **Step 2: Run the test to verify it fails**
62
+
63
+ Run: `pytest tests/test_space_config.py -v`
64
+
65
+ Expected: FAIL because `requirements.txt` and `openpi_runtime` do not exist and the README still selects Python 3.12.
66
+
67
+ - [ ] **Step 3: Vendor runtime and add pinned deployment dependencies**
68
+
69
+ Run these bulk-copy commands from `pi0.5`:
70
+
71
+ ```bash
72
+ mkdir -p openpi_runtime
73
+ rsync -a --exclude='*_test.py' --exclude='__pycache__' ../VLA/openpi/src/openpi/ openpi_runtime/openpi/
74
+ rsync -a --exclude='*_test.py' --exclude='__pycache__' ../VLA/openpi/packages/openpi-client/src/openpi_client/ openpi_runtime/openpi_client/
75
+ ```
76
+
77
+ Change README metadata to `python_version: '3.11'`. Create `requirements.txt` from the OpenPI runtime pins, including:
78
+
79
+ ```text
80
+ gradio==6.20.0
81
+ huggingface-hub==0.36.2
82
+ pandas==2.2.3
83
+ pillow==11.1.0
84
+ numpy==1.26.4
85
+ augmax>=0.3.4
86
+ dm-tree>=0.1.8
87
+ einops>=0.8.0
88
+ equinox>=0.11.8
89
+ flax==0.10.2
90
+ fsspec[gcs]>=2024.6.0
91
+ jax[cuda12]==0.5.3
92
+ jaxtyping==0.2.36
93
+ ml-collections==1.0.0
94
+ numpydantic>=1.6.6
95
+ opencv-python-headless>=4.10.0.84
96
+ orbax-checkpoint==0.11.13
97
+ sentencepiece>=0.2.0
98
+ torch==2.7.1
99
+ typing-extensions>=4.12.2
100
+ tyro>=0.9.5
101
+ wandb>=0.19.1
102
+ filelock>=3.16.1
103
+ beartype==0.19.0
104
+ chex==0.1.90
105
+ treescope>=0.1.7
106
+ transformers==4.53.2
107
+ rich>=14.0.0
108
+ polars>=1.30.0
109
+ ```
110
+
111
+ - [ ] **Step 4: Run metadata tests and import smoke test**
112
+
113
+ Run: `pytest tests/test_space_config.py -v && PYTHONPATH=openpi_runtime python -c "from openpi.training import config; assert config.get_config('pi05_ur_demo_state').model.action_horizon == 10"`
114
+
115
+ Expected: tests PASS and the import command exits zero.
116
+
117
+ - [ ] **Step 5: Commit**
118
+
119
+ ```bash
120
+ git add README.md requirements.txt openpi_runtime tests/test_space_config.py
121
+ git commit -m "build: add OpenPI runtime for UR Space"
122
+ ```
123
+
124
+ ---
125
+
126
+ ### Task 2: Artifact identity, download, and validation
127
+
128
+ **Files:**
129
+ - Create: `artifacts.py`
130
+ - Test: `tests/test_artifacts.py`
131
+
132
+ **Interfaces:**
133
+ - Consumes: environment variables `PI05_MODEL_ID` and `PI05_CHECKPOINT_PATH`; `huggingface_hub.snapshot_download`.
134
+ - Produces: `ArtifactPaths(checkpoint: Path)`, `resolve_model_id() -> str`, `resolve_checkpoint_path() -> str`, `normalize_model_id(str) -> str`, `normalize_checkpoint_path(str) -> str`, and `download_checkpoint(str, str) -> ArtifactPaths`.
135
+
136
+ - [ ] **Step 1: Write failing validation and download tests**
137
+
138
+ ```python
139
+ from pathlib import Path
140
+ import pytest
141
+ import artifacts
142
+
143
+
144
+ def test_checkpoint_path_rejects_absolute_and_parent_paths():
145
+ for value in ("/tmp/model", "../model", "a/../../model", ""):
146
+ with pytest.raises(ValueError):
147
+ artifacts.normalize_checkpoint_path(value)
148
+
149
+
150
+ def test_download_checkpoint_validates_required_layout(tmp_path, monkeypatch):
151
+ checkpoint = tmp_path / "checkpoints/30000"
152
+ (checkpoint / "params").mkdir(parents=True)
153
+ (checkpoint / "assets/ur_demo").mkdir(parents=True)
154
+ (checkpoint / "assets/ur_demo/norm_stats.json").write_text("{}")
155
+ monkeypatch.setattr(artifacts, "snapshot_download", lambda **_: str(tmp_path))
156
+ paths = artifacts.download_checkpoint("owner/model", "checkpoints/30000")
157
+ assert paths.checkpoint == checkpoint
158
+
159
+
160
+ def test_download_checkpoint_reports_missing_files(tmp_path, monkeypatch):
161
+ monkeypatch.setattr(artifacts, "snapshot_download", lambda **_: str(tmp_path))
162
+ with pytest.raises(FileNotFoundError, match="params|model.safetensors"):
163
+ artifacts.download_checkpoint("owner/model", "checkpoint")
164
+ ```
165
+
166
+ - [ ] **Step 2: Run the tests to verify they fail**
167
+
168
+ Run: `pytest tests/test_artifacts.py -v`
169
+
170
+ Expected: FAIL with `ModuleNotFoundError: No module named 'artifacts'`.
171
+
172
+ - [ ] **Step 3: Implement artifact helpers**
173
+
174
+ Implement strict non-empty model IDs, POSIX-relative checkpoint paths without `..`, environment defaults, snapshot download, and checks for either `params/` or `model.safetensors` plus `assets/ur_demo/norm_stats.json`:
175
+
176
+ ```python
177
+ @dataclass(frozen=True)
178
+ class ArtifactPaths:
179
+ checkpoint: Path
180
+
181
+
182
+ def download_checkpoint(model_id: str, checkpoint_path: str) -> ArtifactPaths:
183
+ model_id = normalize_model_id(model_id)
184
+ relative = normalize_checkpoint_path(checkpoint_path)
185
+ root = Path(snapshot_download(repo_id=model_id))
186
+ checkpoint = root.joinpath(*PurePosixPath(relative).parts)
187
+ if not (checkpoint / "params").exists() and not (checkpoint / "model.safetensors").is_file():
188
+ raise FileNotFoundError(f"checkpoint has neither params/ nor model.safetensors: {checkpoint}")
189
+ stats = checkpoint / "assets/ur_demo/norm_stats.json"
190
+ if not stats.is_file():
191
+ raise FileNotFoundError(f"UR normalization statistics not found: {stats}")
192
+ return ArtifactPaths(checkpoint)
193
+ ```
194
+
195
+ - [ ] **Step 4: Run artifact tests**
196
+
197
+ Run: `pytest tests/test_artifacts.py -v`
198
+
199
+ Expected: PASS.
200
+
201
+ - [ ] **Step 5: Commit**
202
+
203
+ ```bash
204
+ git add artifacts.py tests/test_artifacts.py
205
+ git commit -m "feat: resolve pi05 checkpoint artifacts"
206
+ ```
207
+
208
+ ---
209
+
210
+ ### Task 3: UR inference adapter and result serialization
211
+
212
+ **Files:**
213
+ - Create: `inference.py`
214
+ - Test: `tests/test_inference.py`
215
+
216
+ **Interfaces:**
217
+ - Consumes: an object exposing `infer(observation: dict) -> dict`; two PIL/array images; instruction; seven numeric state values; trial index; artifact identity.
218
+ - Produces: `ACTION_LABELS`, `ACTION_HORIZON`, `PredictionResult(actions: pandas.DataFrame, json_path: str, status: str)`, `set_seed(int)`, and `run_prediction(...) -> PredictionResult`.
219
+
220
+ - [ ] **Step 1: Write failing mapping, validation, shape, and JSON tests**
221
+
222
+ ```python
223
+ import json
224
+ import numpy as np
225
+ from PIL import Image
226
+ import pytest
227
+ from inference import ACTION_LABELS, run_prediction
228
+
229
+
230
+ class FakePolicy:
231
+ def __init__(self, actions=None):
232
+ self.observation = None
233
+ self.actions = np.ones((10, 7)) if actions is None else actions
234
+
235
+ def infer(self, observation):
236
+ self.observation = observation
237
+ return {"actions": self.actions}
238
+
239
+
240
+ def test_prediction_maps_ur_observation_and_writes_json():
241
+ policy = FakePolicy()
242
+ image = Image.new("RGB", (8, 6), "red")
243
+ result = run_prediction(policy, image, image, "pick up", [1, 2, 3, 4, 5, 6, 0], 2, "owner/model", "checkpoint")
244
+ assert tuple(result.actions.columns) == ACTION_LABELS
245
+ assert policy.observation["observation/state"].tolist() == [1, 2, 3, 4, 5, 6, 0]
246
+ assert policy.observation["observation/image"].dtype == np.uint8
247
+ assert policy.observation["prompt"] == "pick up"
248
+ assert json.loads(open(result.json_path).read())["seed"] == 44
249
+
250
+
251
+ def test_prediction_rejects_invalid_state_and_action_shape():
252
+ image = Image.new("RGB", (8, 6))
253
+ with pytest.raises(ValueError, match="seven"):
254
+ run_prediction(FakePolicy(), image, image, "task", [1, 2], 0, "m", "c")
255
+ with pytest.raises(RuntimeError, match=r"\(10, 7\)"):
256
+ run_prediction(FakePolicy(np.zeros((8, 7))), image, image, "task", [0] * 7, 0, "m", "c")
257
+ ```
258
+
259
+ - [ ] **Step 2: Run tests to verify they fail**
260
+
261
+ Run: `pytest tests/test_inference.py -v`
262
+
263
+ Expected: FAIL with `ModuleNotFoundError: No module named 'inference'`.
264
+
265
+ - [ ] **Step 3: Implement validated inference**
266
+
267
+ Define `ACTION_LABELS = ("dx", "dy", "dz", "droll", "dpitch", "dyaw", "gripper")`, `ACTION_HORIZON = 10`, and `BASE_SEED = 42`. Validate both images, trimmed instruction, integer non-negative trial index, exactly seven finite float state values, and exact action shape. Convert images with `Image.fromarray(np.asarray(value)).convert("RGB")`, seed Python/NumPy/Torch/JAX-compatible randomness, invoke `policy.infer`, build a pandas table, and serialize inputs and actions to a temporary JSON file.
268
+
269
+ - [ ] **Step 4: Run inference tests**
270
+
271
+ Run: `pytest tests/test_inference.py -v`
272
+
273
+ Expected: PASS.
274
+
275
+ - [ ] **Step 5: Commit**
276
+
277
+ ```bash
278
+ git add inference.py tests/test_inference.py
279
+ git commit -m "feat: add validated UR action inference"
280
+ ```
281
+
282
+ ---
283
+
284
+ ### Task 4: Thread-safe lazy OpenPI model manager
285
+
286
+ **Files:**
287
+ - Create: `model_loader.py`
288
+ - Test: `tests/test_model_loader.py`
289
+
290
+ **Interfaces:**
291
+ - Consumes: `artifacts.download_checkpoint`, vendored `_config.get_config("pi05_ur_demo_state")`, and `policy_config.create_trained_policy(..., pytorch_device="cuda")`.
292
+ - Produces: `ModelManager.get(model_id: str, checkpoint_path: str)`, `ModelManager.health_message`, `ModelUnavailableError`, and singleton `MODEL_MANAGER`.
293
+
294
+ - [ ] **Step 1: Write failing cache and failure tests**
295
+
296
+ ```python
297
+ import pytest
298
+ from model_loader import ModelManager, ModelUnavailableError
299
+
300
+
301
+ def test_manager_caches_same_key_and_replaces_changed_key():
302
+ calls = []
303
+ manager = ModelManager(loader=lambda model, path: calls.append((model, path)) or object())
304
+ first = manager.get("owner/model", "a")
305
+ assert manager.get("owner/model", "a") is first
306
+ second = manager.get("owner/model", "b")
307
+ assert second is not first
308
+ assert calls == [("owner/model", "a"), ("owner/model", "b")]
309
+
310
+
311
+ def test_failed_load_is_reported_and_can_retry():
312
+ attempts = 0
313
+ def loader(*_):
314
+ nonlocal attempts
315
+ attempts += 1
316
+ raise RuntimeError("bad checkpoint")
317
+ manager = ModelManager(loader=loader)
318
+ with pytest.raises(ModelUnavailableError, match="bad checkpoint"):
319
+ manager.get("owner/model", "a")
320
+ with pytest.raises(ModelUnavailableError):
321
+ manager.get("owner/model", "a")
322
+ assert attempts == 2
323
+ assert "bad checkpoint" in manager.health_message
324
+ ```
325
+
326
+ - [ ] **Step 2: Run tests to verify they fail**
327
+
328
+ Run: `pytest tests/test_model_loader.py -v`
329
+
330
+ Expected: FAIL with `ModuleNotFoundError: No module named 'model_loader'`.
331
+
332
+ - [ ] **Step 3: Implement the model manager**
333
+
334
+ Use a `threading.Lock`, one cached value, one active key, and an error string. The default loader must require `torch.cuda.is_available()`, call `download_checkpoint`, add `openpi_runtime` to the import path before importing OpenPI, resolve the exact UR config, and call:
335
+
336
+ ```python
337
+ config = openpi_config.get_config("pi05_ur_demo_state")
338
+ return policy_config.create_trained_policy(
339
+ config,
340
+ paths.checkpoint,
341
+ pytorch_device="cuda",
342
+ )
343
+ ```
344
+
345
+ On replacement or failure, drop references, run `gc.collect()`, and call `torch.cuda.empty_cache()` when CUDA is available.
346
+
347
+ - [ ] **Step 4: Run manager tests**
348
+
349
+ Run: `pytest tests/test_model_loader.py -v`
350
+
351
+ Expected: PASS.
352
+
353
+ - [ ] **Step 5: Commit**
354
+
355
+ ```bash
356
+ git add model_loader.py tests/test_model_loader.py
357
+ git commit -m "feat: add lazy pi05 policy lifecycle"
358
+ ```
359
+
360
+ ---
361
+
362
+ ### Task 5: Gradio application and end-to-end CPU verification
363
+
364
+ **Files:**
365
+ - Create: `app.py`
366
+ - Create: `tests/test_app.py`
367
+ - Modify: `README.md`
368
+
369
+ **Interfaces:**
370
+ - Consumes: `MODEL_MANAGER.get`, `run_prediction`, artifact defaults, thirteen Gradio input components, and three output components.
371
+ - Produces: `predict_ui(...) -> tuple[pandas.DataFrame | None, str | None, str]` and queued Gradio `demo`.
372
+
373
+ - [ ] **Step 1: Write failing UI-boundary tests**
374
+
375
+ ```python
376
+ import app
377
+
378
+
379
+ def test_predict_ui_returns_table_file_and_status(monkeypatch):
380
+ sentinel_policy = object()
381
+ monkeypatch.setattr(app.MODEL_MANAGER, "get", lambda *_: sentinel_policy)
382
+ monkeypatch.setattr(
383
+ app,
384
+ "run_prediction",
385
+ lambda *args: type("R", (), {"actions": "table", "json_path": "/tmp/result.json", "status": "done"})(),
386
+ )
387
+ result = app.predict_ui("owner/model", "checkpoint", object(), object(), "task", 1, 2, 3, 4, 5, 6, 0, 0)
388
+ assert result == ("table", "/tmp/result.json", "done")
389
+
390
+
391
+ def test_predict_ui_turns_exceptions_into_status(monkeypatch):
392
+ monkeypatch.setattr(app.MODEL_MANAGER, "get", lambda *_: (_ for _ in ()).throw(RuntimeError("load failed")))
393
+ table, output_file, status = app.predict_ui("m", "c", object(), object(), "task", *([0] * 8))
394
+ assert table is None and output_file is None
395
+ assert status == "Error: load failed"
396
+
397
+
398
+ def test_demo_exposes_prediction_controls():
399
+ config = app.demo.get_config_file()
400
+ labels = {component["props"].get("label") for component in config["components"]}
401
+ assert {"Fixed camera", "Wrist camera", "Task instruction", "Predict actions"} <= labels
402
+ ```
403
+
404
+ - [ ] **Step 2: Run app tests to verify they fail**
405
+
406
+ Run: `pytest tests/test_app.py -v`
407
+
408
+ Expected: FAIL with `ModuleNotFoundError: No module named 'app'`.
409
+
410
+ - [ ] **Step 3: Build the Gradio Space**
411
+
412
+ Follow the `qwengr00t/app.py` layout. Add artifact textboxes, two `gr.Image(type="pil")` inputs, task textbox, seven labelled `gr.Number` state inputs, a non-negative integer trial index, primary predict button, status Markdown, non-editable action Dataframe, and JSON File output. Decorate `predict_ui` with `@spaces.GPU(duration=120)`, provide a no-op local fallback, and wire inputs in the exact signature order. Launch only under `if __name__ == "__main__"` using:
413
+
414
+ ```python
415
+ demo.queue(default_concurrency_limit=1).launch()
416
+ ```
417
+
418
+ Update README body with model-repository environment variables, checkpoint layout, input/state/action schemas, GPU requirement, Space deployment steps, and the optional local command `PYTHONPATH=openpi_runtime python app.py`.
419
+
420
+ - [ ] **Step 4: Run focused and full CPU verification**
421
+
422
+ Run: `pytest tests/test_app.py -v && pytest -v && python -m compileall -q app.py artifacts.py inference.py model_loader.py openpi_runtime/openpi openpi_runtime/openpi_client && git diff --check`
423
+
424
+ Expected: all tests PASS, compilation exits zero, and `git diff --check` prints nothing.
425
+
426
+ - [ ] **Step 5: Commit**
427
+
428
+ ```bash
429
+ git add app.py README.md tests/test_app.py
430
+ git commit -m "feat: deploy pi05 UR Gradio Space"
431
+ ```
432
+
433
+ ---
434
+
435
+ ### Task 6: Optional GPU checkpoint smoke test documentation
436
+
437
+ **Files:**
438
+ - Create: `tests/test_gpu_smoke.py`
439
+ - Modify: `README.md`
440
+
441
+ **Interfaces:**
442
+ - Consumes: `PI05_GPU_SMOKE=1`, `PI05_MODEL_ID`, `PI05_CHECKPOINT_PATH`, CUDA, and external Hugging Face artifacts.
443
+ - Produces: opt-in end-to-end proof that the real policy returns `(10, 7)` actions.
444
+
445
+ - [ ] **Step 1: Add the skipped-by-default GPU test**
446
+
447
+ ```python
448
+ import os
449
+ import numpy as np
450
+ from PIL import Image
451
+ import pytest
452
+
453
+
454
+ pytestmark = pytest.mark.skipif(os.getenv("PI05_GPU_SMOKE") != "1", reason="set PI05_GPU_SMOKE=1")
455
+
456
+
457
+ def test_real_checkpoint_prediction():
458
+ from artifacts import resolve_checkpoint_path, resolve_model_id
459
+ from inference import run_prediction
460
+ from model_loader import MODEL_MANAGER
461
+ model_id = resolve_model_id()
462
+ checkpoint = resolve_checkpoint_path()
463
+ policy = MODEL_MANAGER.get(model_id, checkpoint)
464
+ image = Image.fromarray(np.zeros((224, 224, 3), dtype=np.uint8))
465
+ result = run_prediction(policy, image, image, "move safely", [0.0] * 7, 0, model_id, checkpoint)
466
+ assert result.actions.shape == (10, 7)
467
+ ```
468
+
469
+ - [ ] **Step 2: Verify default test execution skips it safely**
470
+
471
+ Run: `pytest tests/test_gpu_smoke.py -v`
472
+
473
+ Expected: one SKIPPED test with reason `set PI05_GPU_SMOKE=1`.
474
+
475
+ - [ ] **Step 3: Document the opt-in command**
476
+
477
+ Add this command to README without embedding tokens:
478
+
479
+ ```bash
480
+ PI05_GPU_SMOKE=1 PI05_MODEL_ID=owner/model PI05_CHECKPOINT_PATH=checkpoints/30000 pytest tests/test_gpu_smoke.py -v
481
+ ```
482
+
483
+ - [ ] **Step 4: Run final verification**
484
+
485
+ Run: `pytest -v && git diff --check && git status --short`
486
+
487
+ Expected: CPU tests PASS with exactly the GPU test skipped; diff check is clean; status contains only Task 6 files before commit.
488
+
489
+ - [ ] **Step 5: Commit**
490
+
491
+ ```bash
492
+ git add README.md tests/test_gpu_smoke.py
493
+ git commit -m "test: document pi05 GPU smoke check"
494
+ ```