From 0c23dfeaf85933eb540cd529213d2f068e958e7a Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sun, 6 Sep 2026 10:32:24 -0300 Subject: [PATCH 1/3] fix: restore source media precedence and roulade horizon --- registry/schema/catalog.ts | 2 +- simulation/execution_recipes.py | 18 +++++- simulation/microduck_sim/preflight.py | 30 ++++++++-- simulation/microduck_sim/robot.py | 1 + simulation/microduck_sim/scenarios.py | 56 ++++++++++++++--- simulation/run_check.py | 6 +- simulation/tests/test_execution_recipes.py | 37 ++++++++++++ simulation/tests/test_preflight.py | 30 ++++++++++ simulation/tests/test_runtime_observations.py | 20 +++++++ src/app/behaviors/[id]/page.tsx | 20 ++++++- src/app/globals.css | 4 ++ src/lib/catalog.ts | 34 ++++++----- tests/catalog.test.ts | 60 ++++++++++++++++++- 13 files changed, 282 insertions(+), 36 deletions(-) diff --git a/registry/schema/catalog.ts b/registry/schema/catalog.ts index d434235..7cfa5ea 100644 --- a/registry/schema/catalog.ts +++ b/registry/schema/catalog.ts @@ -463,7 +463,7 @@ export function catalogEntryFromPolicy( media: { author: authorMedia, registry, - primary: registry ? "registry" : authorMedia.length > 0 ? "author" : "none", + primary: authorMedia.length > 0 ? "author" : registry ? "registry" : "none", }, }); } diff --git a/simulation/execution_recipes.py b/simulation/execution_recipes.py index ce3e9bd..d1af38d 100644 --- a/simulation/execution_recipes.py +++ b/simulation/execution_recipes.py @@ -34,6 +34,7 @@ UPSTREAM_MANIFEST_URL = f"https://github.com/pollen-robotics/microduck/blob/{UPSTREAM_PIN}/docs/policy-manifest.md" UPSTREAM_CHEATSHEET_URL = f"https://github.com/pollen-robotics/microduck/blob/{UPSTREAM_PIN}/docs/robot/cheatsheet.md" UPSTREAM_CONTROL_URL = f"https://github.com/pollen-robotics/microduck/blob/{UPSTREAM_PIN}/robotd/src/control.rs" +ROULADE_RECOVERY_TAIL_S = 3.0 POLLEN_POLICY_REPO = "pollen-robotics/microduck-policies" POLLEN_POLICY_REVISION = "088524a64e2557dc453256b6071dbb9d23888802" @@ -257,7 +258,9 @@ def _official_recipe(manifest: dict[str, Any], source: dict[str, Any]) -> dict[s return None if manifest.get("command") not in (None, {}): return None - duration = float(manifest["duration_s"]) + command_duration = float(manifest["duration_s"]) + recovery_tail = ROULADE_RECOVERY_TAIL_S if artifact_path == "roulade.onnx" else 0.0 + capture_duration = command_duration + recovery_tail checks = ["recover_upright"] if artifact_path == "roulade.onnx" else ["no_fall", "ends_upright"] return { "runner": RUNNER, @@ -265,14 +268,20 @@ def _official_recipe(manifest: dict[str, Any], source: dict[str, Any]) -> dict[s "scene": SCENE, "start": deepcopy(START), "scenario": "oneshot_zero", - "duration_s": duration, + # duration_s remains the runner's full rollout horizon. The + # explicit fields keep the upstream activation window distinct + # from the registry-owned recovery/evaluation tail. + "duration_s": capture_duration, + "command_duration_s": command_duration, + "post_command_settle_s": recovery_tail, + "capture_duration_s": capture_duration, "checks": checks, "action_scale": 1.0, "chain": bool(manifest.get("chain", False)), "provenance": _provenance( "Exact per-file Pollen schema-2 manifest and the pinned robotd zero-command skill contract", UPSTREAM_MANIFEST_URL, - "Registry diagnostic rollout of the exact policy window under flat-v1; this does not establish intended-task success or hardware verification.", + "Registry diagnostic rollout of the exact policy command window followed by an explicit recovery/settle tail under flat-v1; final checks cover the full capture horizon and this does not establish intended-task success or hardware verification.", policy_set_revision=POLLEN_POLICY_REVISION, manifest_sha256=POLLEN_MANIFEST_SHA256, artifact_path=artifact_path, @@ -281,6 +290,9 @@ def _official_recipe(manifest: dict[str, Any], source: dict[str, Any]) -> dict[s action_scale=1.0, action_scale_source=UPSTREAM_CONTROL_URL, chain=bool(manifest.get("chain", False)), + command_duration_s=command_duration, + post_command_settle_s=recovery_tail, + capture_duration_s=capture_duration, ), } diff --git a/simulation/microduck_sim/preflight.py b/simulation/microduck_sim/preflight.py index 25ef33c..2ad864f 100644 --- a/simulation/microduck_sim/preflight.py +++ b/simulation/microduck_sim/preflight.py @@ -92,6 +92,28 @@ def preflight_execution(spec: "ExecutionSpec") -> PreflightResult: if duration_value is None or not 0 < duration_value <= 30: errors.append("execution duration_s must be finite, positive, and at most 30 seconds") + capture_duration = recipe.get("capture_duration_s", duration) + capture_value = float(capture_duration) if _finite(capture_duration) else None + if capture_value is None or not 0 < capture_value <= 30: + errors.append("execution capture_duration_s must be finite, positive, and at most 30 seconds") + elif duration_value is not None and abs(capture_value - duration_value) > 1e-9: + errors.append("execution capture_duration_s must equal execution duration_s") + + command_duration = recipe.get("command_duration_s", duration) + command_value = float(command_duration) if _finite(command_duration) else None + if command_value is None or command_value <= 0: + errors.append("execution command_duration_s must be finite and positive") + + settle = recipe.get("post_command_settle_s", 0.0) + settle_value = float(settle) if _finite(settle) else None + if settle_value is None or settle_value < 0: + errors.append("execution post_command_settle_s must be finite and non-negative") + if command_value is not None and settle_value is not None and capture_value is not None and abs(command_value + settle_value - capture_value) > 1e-9: + errors.append("execution command duration plus settle tail must equal capture duration") + has_settle_tail = command_value is not None and settle_value is not None and settle_value > 0 + schedule_value = command_value if has_settle_tail else duration_value + schedule_name = "command_duration_s" if has_settle_tail else "duration_s" + segments = recipe.get("segments") if scenario == "velocity": if not isinstance(segments, list) or not segments: @@ -109,8 +131,8 @@ def preflight_execution(spec: "ExecutionSpec") -> PreflightResult: else: total += float(segment_duration) errors.extend(_velocity_errors(segment.get("vx"), segment.get("vy"), segment.get("wz"), prefix)) - if duration_value is not None and abs(total - duration_value) > 1e-9: - errors.append(f"execution segments cover {total:g}s but execution duration_s={duration_value:g}s") + if schedule_value is not None and abs(total - schedule_value) > 1e-9: + errors.append(f"execution segments cover {total:g}s but execution {schedule_name}={schedule_value:g}s") elif scenario == "command_schedule": if not isinstance(segments, list) or not segments: errors.append("execution segments are required for the command_schedule scenario") @@ -133,8 +155,8 @@ def preflight_execution(spec: "ExecutionSpec") -> PreflightResult: for axis, value in enumerate(command): if not _finite(value) or value < -3 or value > 3: errors.append(f"{prefix}.command[{axis}] must be finite and in [-3, 3]") - if duration_value is not None and abs(total - duration_value) > 1e-9: - errors.append(f"execution segments cover {total:g}s but execution duration_s={duration_value:g}s") + if schedule_value is not None and abs(total - schedule_value) > 1e-9: + errors.append(f"execution segments cover {total:g}s but execution {schedule_name}={schedule_value:g}s") elif "segments" in recipe: errors.append("execution segments are only valid with velocity and command_schedule scenarios") diff --git a/simulation/microduck_sim/robot.py b/simulation/microduck_sim/robot.py index 4ac58f3..d0cc35c 100644 --- a/simulation/microduck_sim/robot.py +++ b/simulation/microduck_sim/robot.py @@ -155,6 +155,7 @@ def metrics(self) -> dict: max_unilateral_s = max(max_unilateral_s, run) return { "duration_s": round(self.duration_s, 3), + "final_sample_time_s": round(float(self.samples[-1].t), 3), "control_steps": self.control_steps, "obs_dim": self.obs_dim, "command_dim": 13 if self.use_13d else 3, diff --git a/simulation/microduck_sim/scenarios.py b/simulation/microduck_sim/scenarios.py index de19ee8..0c4e1b2 100644 --- a/simulation/microduck_sim/scenarios.py +++ b/simulation/microduck_sim/scenarios.py @@ -29,6 +29,11 @@ class ScenarioSpec: hold_s: float = 2.0 # oneshot_zero: seconds the zeroed command window lasts (kicks, roulade). duration_s: float = 0.5 + # A diagnostic can keep recording after the policy command window so final + # checks observe recovery at the end of the rollout, not mid-trajectory. + command_duration_s: float = 0.5 + post_command_settle_s: float = 0.0 + capture_duration_s: float = 0.5 # oneshot_trigger: binary launch request followed by the zero command # (publisher-specific one-shot policies such as jumps). trigger_s: float = 0.2 @@ -75,7 +80,31 @@ def scenario_from_recipe(sim_block: dict) -> ScenarioSpec: spec.kind = sim_block["scenario"] spec.name = spec.kind spec.checks = list(sim_block.get("checks", [])) - spec.duration_s = float(sim_block["duration_s"]) + def duration_field(name: str, value: object, *, allow_zero: bool = False) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not np.isfinite(value): + raise ValueError(f"simulation.{name} must be a finite number") + result = float(value) + invalid = result < 0 if allow_zero else result <= 0 + if invalid: + qualifier = "non-negative" if allow_zero else "positive" + raise ValueError(f"simulation.{name} must be {qualifier}") + return result + + duration = duration_field("duration_s", sim_block.get("duration_s")) + spec.duration_s = duration + spec.command_duration_s = duration_field( + "command_duration_s", sim_block.get("command_duration_s", duration) + ) + spec.post_command_settle_s = duration_field( + "post_command_settle_s", sim_block.get("post_command_settle_s", 0.0), allow_zero=True + ) + spec.capture_duration_s = duration_field( + "capture_duration_s", sim_block.get("capture_duration_s", duration) + ) + if abs(spec.capture_duration_s - spec.duration_s) > 1e-9: + raise ValueError("simulation.capture_duration_s must equal simulation.duration_s") + if abs(spec.command_duration_s + spec.post_command_settle_s - spec.capture_duration_s) > 1e-9: + raise ValueError("simulation command duration plus settle tail must equal capture duration") if spec.kind == "oneshot_trigger": if "trigger_s" not in sim_block: raise ValueError("oneshot_trigger requires an explicit trigger_s") @@ -140,6 +169,17 @@ def wrap(cmd: np.ndarray) -> np.ndarray: return cmd.astype(np.float32) return np.concatenate([cmd, np.zeros(10, dtype=np.float32)]).astype(np.float32) + def with_settle_tail(command_fn: Callable[[float], np.ndarray]) -> Callable[[float], np.ndarray]: + if spec.post_command_settle_s <= 0: + return command_fn + + def scheduled_fn(t: float) -> np.ndarray: + if t >= spec.command_duration_s: + return wrap(np.zeros(3, dtype=np.float32)) + return command_fn(t) + + return scheduled_fn + if spec.kind == "velocity": if not spec.segments: raise ValueError("velocity scenario requires explicit segments") @@ -156,7 +196,7 @@ def vel_fn(t: float) -> np.ndarray: _, vx, vy, wz = chosen return wrap(np.array(validate_velocity(vx, vy, wz), dtype=np.float32)) - return vel_fn + return with_settle_tail(vel_fn) if spec.kind == "command_schedule": if not spec.command_segments: @@ -173,12 +213,12 @@ def command_fn(t: float) -> np.ndarray: remaining -= duration return wrap(np.asarray(chosen, dtype=np.float32)) - return command_fn + return with_settle_tail(command_fn) if spec.kind == "standing": def stand_fn(t: float) -> np.ndarray: return wrap(np.zeros(3, dtype=np.float32)) - return stand_fn + return with_settle_tail(stand_fn) if spec.kind == "sitstand": # Posture flag in the twist-x slot: 1 = sit, 0 = stand (upstream docs). @@ -188,7 +228,7 @@ def sitstand_fn(t: float) -> np.ndarray: flag = 1.0 if t < hold else 0.0 return wrap(np.array([flag, 0.0, 0.0], dtype=np.float32)) - return sitstand_fn + return with_settle_tail(sitstand_fn) if spec.kind == "oneshot_phase": # Phase encoding in the twist slots: [cos(2pi phi), sin(2pi phi), 0], @@ -203,13 +243,13 @@ def phase_fn(t: float) -> np.ndarray: cmd = np.array([math.cos(phi), math.sin(phi), 0.0], dtype=np.float32) return wrap(cmd) - return phase_fn + return with_settle_tail(phase_fn) if spec.kind == "oneshot_zero": # Blind one-shot window with an all-zero command (kicks, roulade). def zero_fn(t: float) -> np.ndarray: return wrap(np.zeros(3, dtype=np.float32)) - return zero_fn + return with_settle_tail(zero_fn) if spec.kind == "oneshot_trigger": # Publisher-specific one-shot policies documented by their authors as a binary @@ -221,6 +261,6 @@ def trigger_fn(t: float) -> np.ndarray: if t < trigger_s else np.zeros(3, dtype=np.float32)) return wrap(cmd) - return trigger_fn + return with_settle_tail(trigger_fn) raise ValueError(f"Unknown simulation scenario kind: {spec.kind!r}") diff --git a/simulation/run_check.py b/simulation/run_check.py index 3b8ffbe..647df9e 100644 --- a/simulation/run_check.py +++ b/simulation/run_check.py @@ -134,7 +134,7 @@ def run(entry_id: str, out_dir: Path, keep_media: bool) -> int: simulation_model = spec.model scenario = scenario_from_recipe(spec.recipe) - duration = float(spec.recipe["duration_s"]) + duration = scenario.capture_duration_s with tempfile.TemporaryDirectory(prefix="uduck-sim-") as temporary: onnx_path = download_onnx(spec, Path(temporary)) from fetch_assets import fetch @@ -164,6 +164,10 @@ def hook(step, sample): "manifest": spec.manifest, "recipe": spec.recipe, "duration_s": duration, + "command_duration_s": scenario.command_duration_s, + "post_command_settle_s": scenario.post_command_settle_s, + "capture_duration_s": scenario.capture_duration_s, + "evaluation_final_sample_s": report["observations"].get("final_sample_time_s"), "policy": {"url": spec.artifact_url, "sha256": spec.artifact_sha256}, "media": media, "preflight": {"status": "passed", "warnings": list(preflight.warnings)}, diff --git a/simulation/tests/test_execution_recipes.py b/simulation/tests/test_execution_recipes.py index 130feaf..89a98ee 100644 --- a/simulation/tests/test_execution_recipes.py +++ b/simulation/tests/test_execution_recipes.py @@ -8,6 +8,7 @@ from execution import execution_spec_from_policy from execution_recipes import ( POLLEN_ARTIFACT_SHA256, + POLLEN_MANIFEST_PATH, POLLEN_MANIFEST_SHA256, POLLEN_POLICY_REPO, POLLEN_POLICY_REVISION, @@ -140,6 +141,42 @@ def test_official_recipes_bind_to_exact_artifact_and_manifest_identity(self) -> self.assertEqual(kick["duration_s"], 0.5) self.assertEqual(kick["scenario"], "oneshot_zero") + def test_roulade_keeps_command_and_recovery_windows_distinct(self) -> None: + manifest = {"file": "roulade.onnx", "kind": "episodic", "duration_s": 1.0, "chain": True} + source = { + "provider": "huggingface-model", + "repo": POLLEN_POLICY_REPO, + "revision": POLLEN_POLICY_REVISION, + "artifact_path": "roulade.onnx", + "artifact_sha256": POLLEN_ARTIFACT_SHA256["roulade.onnx"], + "manifest_path": POLLEN_MANIFEST_PATH, + "manifest_sha256": POLLEN_MANIFEST_SHA256, + } + recipe = recipe_for_policy(POLLEN_POLICY_REPO, manifest, source) + self.assertIsNotNone(recipe) + assert recipe is not None + self.assertEqual(recipe["command_duration_s"], 1.0) + self.assertGreater(recipe["post_command_settle_s"], 0.0) + self.assertEqual(recipe["capture_duration_s"], recipe["duration_s"]) + self.assertGreater(recipe["capture_duration_s"], recipe["command_duration_s"]) + + scenario = scenario_from_recipe(recipe) + self.assertEqual(scenario.capture_duration_s, 4.0) + + def test_settle_tail_returns_to_idle_after_command_window(self) -> None: + scenario = scenario_from_recipe({ + "runner": "microduck-standard-v1", + "scenario": "command_schedule", + "duration_s": 2.0, + "command_duration_s": 1.0, + "post_command_settle_s": 1.0, + "capture_duration_s": 2.0, + "segments": [{"duration_s": 1.0, "command": [1.0, 0.0, 0.0]}], + }) + command_fn = make_command_fn(scenario, use_13d=False) + self.assertEqual(command_fn(0.99).tolist(), [1.0, 0.0, 0.0]) + self.assertEqual(command_fn(1.0).tolist(), [0.0, 0.0, 0.0]) + def test_exact_no_manifest_recipes_supply_only_their_pinned_contract(self) -> None: from execution_recipes import GENESIS_ARTIFACT_SHA256, GENESIS_REPO, GENESIS_REVISION diff --git a/simulation/tests/test_preflight.py b/simulation/tests/test_preflight.py index 02e8c55..818549f 100644 --- a/simulation/tests/test_preflight.py +++ b/simulation/tests/test_preflight.py @@ -73,6 +73,36 @@ def test_runtime_command_defense_does_not_clip(self) -> None: with self.assertRaisesRegex(ValueError, "exceeds"): require_valid(candidate) + def test_validates_a_separate_command_and_capture_horizon(self) -> None: + candidate = spec() + candidate.recipe.update({ + "duration_s": 4.0, + "command_duration_s": 1.0, + "post_command_settle_s": 3.0, + "capture_duration_s": 4.0, + "scenario": "oneshot_zero", + }) + candidate.recipe.pop("segments") + result = preflight_execution(candidate) + self.assertTrue(result.valid, result.errors) + + scheduled = spec() + scheduled.recipe.update({ + "duration_s": 2.0, + "command_duration_s": 1.0, + "post_command_settle_s": 1.0, + "capture_duration_s": 2.0, + "scenario": "command_schedule", + "segments": [{"duration_s": 1.0, "command": [1.0, 0.0, 0.0]}], + }) + result = preflight_execution(scheduled) + self.assertTrue(result.valid, result.errors) + + candidate.recipe["capture_duration_s"] = 1.0 + result = preflight_execution(candidate) + self.assertFalse(result.valid) + self.assertIn("command duration plus settle tail", " ".join(result.errors)) + if __name__ == "__main__": unittest.main() diff --git a/simulation/tests/test_runtime_observations.py b/simulation/tests/test_runtime_observations.py index 6caca1f..d968d2f 100644 --- a/simulation/tests/test_runtime_observations.py +++ b/simulation/tests/test_runtime_observations.py @@ -89,6 +89,26 @@ def test_scenario_is_selected_without_a_robotd_slot(self) -> None: self.assertEqual(spec.kind, "oneshot_zero") self.assertEqual(spec.checks, ["recover_upright"]) + def test_final_observation_is_after_the_recovery_tail(self) -> None: + spec = scenario_from_recipe({ + "runner": "microduck-standard-v1", + "scenario": "oneshot_zero", + "duration_s": 4.0, + "command_duration_s": 1.0, + "post_command_settle_s": 3.0, + "capture_duration_s": 4.0, + "checks": ["recover_upright"], + }) + result = self.result([ + sample(0.98, True, True, upright_z=0.0), + sample(1.00, True, True, upright_z=-1.0), + sample(3.98, True, True, upright_z=-1.0), + ]) + result.duration_s = 4.0 + metrics = result.metrics() + self.assertGreater(metrics["final_sample_time_s"], spec.command_duration_s) + self.assertEqual(metrics["final_sample_time_s"], 3.98) + if __name__ == "__main__": unittest.main() diff --git a/src/app/behaviors/[id]/page.tsx b/src/app/behaviors/[id]/page.tsx index f341fbe..f415081 100644 --- a/src/app/behaviors/[id]/page.tsx +++ b/src/app/behaviors/[id]/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { AlertTriangle, ArrowLeft, ArrowUpRight, Box, CheckCircle2, Download, ExternalLink, GitFork, Layers, ShieldCheck, Terminal, XCircle } from "lucide-react"; import { getCatalogEntries, getCatalogEntryById } from "@/lib/registry"; -import { coverageLabel, hardwareLabel, primaryMedia, runtimeLabel, runtimeKindLabel } from "@/lib/catalog"; +import { coverageLabel, hardwareLabel, primaryMedia, registryMediaPreview, runtimeLabel, runtimeKindLabel } from "@/lib/catalog"; import { formatAccessory, formatCategory } from "@/lib/labels"; import { ContractSpec } from "@/components/ContractSpec"; import { MediaPreview } from "@/components/MediaPreview"; @@ -50,6 +50,7 @@ function renderNullable(value: string | number | null): string { function EvidenceBlock({ entry }: { entry: import("@registry/schema/catalog").CatalogEntry }) { const inspection = entry.coverage.package_inspection; const simulation = entry.coverage.registry_simulation; + const registryPreview = registryMediaPreview(entry); return (

@@ -72,6 +73,19 @@ function EvidenceBlock({ entry }: { entry: import("@registry/schema/catalog").Ca ))} )} + {registryPreview && ( +
+

Registry simulation media

+

This diagnostic render is evidence for the pinned artifact and is kept separate from publisher media.

+
+ +
+
+ Loop videoOpen registry render + PosterOpen registry thumbnail +
+
+ )} {simulation.report_url &&

Read the complete diagnostic report ↗

}

Registry diagnostics describe this pinned artifact in the stated runner. They do not reproduce arbitrary publisher environments or verify physical hardware.

@@ -163,8 +177,8 @@ export default async function BehaviorDetailPage({ params }: Props) { {entry.media.author.length > 0 && (
-

-

Publisher media is presented as a showcase and is separate from registry evidence.

+

+

Publisher/source media is presented as a showcase and is separate from registry evidence.

{entry.media.author.map((media) => {media.type === "video" ? "Video" : "Image"}{media.label})}
)} diff --git a/src/app/globals.css b/src/app/globals.css index 6fb2875..756d907 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -863,6 +863,10 @@ code { .registry-check span { display: grid; gap: 0.18rem; } .registry-check strong { font-family: var(--font-mono); font-size: 0.64rem; letter-spacing: 0.06em; text-transform: uppercase; } .registry-check small { color: var(--quiet); font-size: 0.7rem; line-height: 1.45; } +.registry-media-block { display: grid; gap: 0.65rem; margin-top: 1rem; } +.registry-media-block h3 { margin: 0; font-size: 0.82rem; } +.registry-media-block p { margin: 0; } +.registry-media-frame { margin-top: 0.15rem; } .detail-list { margin: 0; } .detail-list div { padding: 0.55rem 0; border-bottom: 1px dashed var(--line); } diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts index 73b3377..d316344 100644 --- a/src/lib/catalog.ts +++ b/src/lib/catalog.ts @@ -8,25 +8,31 @@ export interface CatalogPreviewMedia { caption?: string; } -export function primaryMedia(entry: CatalogEntry): CatalogPreviewMedia { +export function registryMediaPreview(entry: CatalogEntry): CatalogPreviewMedia | null { const registry = entry.media.registry; - if (entry.media.primary === "registry" && registry) { + return registry ? { + thumbnail_url: registry.poster_url, + loop_url: registry.loop_url, + video_url: registry.loop_url, + hero_type: "video", + caption: "Registry-owned diagnostic render", + } : null; +} + +export function primaryMedia(entry: CatalogEntry): CatalogPreviewMedia { + const image = entry.media.author.find((item) => item.type === "image"); + const video = entry.media.author.find((item) => item.type === "video"); + if (image || video) { return { - thumbnail_url: registry.poster_url, - loop_url: registry.loop_url, - video_url: registry.loop_url, - hero_type: "video", - caption: "Registry-owned diagnostic render", + ...(image ? { thumbnail_url: image.url } : {}), + ...(video ? { loop_url: video.url, video_url: video.url } : {}), + hero_type: video ? "video" : "image", + caption: image?.label ?? video?.label, }; } - const image = entry.media.author.find((item) => item.type === "image"); - const video = entry.media.author.find((item) => item.type === "video"); - return { - ...(image ? { thumbnail_url: image.url } : {}), - ...(video ? { loop_url: video.url, video_url: video.url } : {}), - hero_type: video ? "video" : image ? "image" : "badge", - caption: image?.label ?? video?.label, + return registryMediaPreview(entry) ?? { + hero_type: "badge", }; } diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index bb27b3c..4da0419 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { describe, expect, it } from "vitest"; import { catalogEntries, catalogEntryFromPolicy } from "../registry/schema/catalog"; import { PolicySchema, type ResolvedPolicy } from "../registry/schema/policy"; +import { primaryMedia } from "../src/lib/catalog"; function flamingoPolicy(): ResolvedPolicy { const policy = PolicySchema.parse(JSON.parse(fs.readFileSync("registry/policies/flamingo-cycle.json", "utf8"))); @@ -133,7 +134,7 @@ describe("policy catalog boundary", () => { expect(entry.coverage.registry_simulation.status).toBe("passed"); }); - it("uses complete registry evidence media as the primary catalog media", () => { + it("keeps source media primary while retaining complete registry evidence media", () => { const entry = catalogEntryFromPolicy(flamingoPolicy(), { status: "failed", evidence_key: "a".repeat(64), @@ -147,15 +148,70 @@ describe("policy catalog boundary", () => { checks: [{ check: "no_fall", passed: false, detail: "measured" }], reason: "The requested diagnostic check failed.", }); - expect(entry.media.primary).toBe("registry"); + expect(entry.media.primary).toBe("author"); expect(entry.media.registry).toEqual({ loop_url: "/media/registry-sim/flamingo-cycle/loop.mp4", poster_url: "/media/registry-sim/flamingo-cycle/poster.png", report_url: "/media/registry-sim/flamingo-cycle/report.json", }); + expect(primaryMedia(entry).hero_type).toBe("video"); + expect(primaryMedia(entry).video_url).toBe(entry.media.author.find((item) => item.type === "video")?.url); expect(entry.coverage.registry_simulation.status).toBe("failed"); }); + it("uses registry evidence as the hero only when source media is absent", () => { + const policy = flamingoPolicy(); + policy.media = []; + const entry = catalogEntryFromPolicy(policy, { + status: "failed", + evidence_key: "a".repeat(64), + inputs_sha256: "b".repeat(64), + runner: "microduck-standard-v1", + scene: "flat-v1", + scenario: "oneshot_zero", + report_url: "/media/registry-sim/flamingo-cycle/report.json", + loop_url: "/media/registry-sim/flamingo-cycle/loop.mp4", + poster_url: "/media/registry-sim/flamingo-cycle/poster.png", + checks: [{ check: "recover_upright", passed: true, detail: "measured" }], + }); + expect(entry.media.primary).toBe("registry"); + expect(primaryMedia(entry)).toMatchObject({ + hero_type: "video", + loop_url: "/media/registry-sim/flamingo-cycle/loop.mp4", + thumbnail_url: "/media/registry-sim/flamingo-cycle/poster.png", + }); + }); + + it("keeps publisher media primary for a not-covered Courier entry", () => { + const policy = PolicySchema.parse(JSON.parse(fs.readFileSync("registry/policies/courier.json", "utf8"))); + const entry = catalogEntryFromPolicy({ + ...policy, + resolved: { + source: policy.source, + manifest: null, + license: policy.curation.license ?? null, + resolution: "review", + install_route: "review", + unresolved: ["No machine-readable package manifest is published with this artifact."], + install_unresolved: [], + policy_set: false, + onnx: { input: [], output: [], smoke: "failed", scope: "Shape inspection only." }, + simulation: { status: "not-covered", reason: "No maintainer-owned execution recipe covers this source." }, + }, + }, null); + expect(entry.media.primary).toBe("author"); + expect(primaryMedia(entry).video_url).toBe(entry.media.author.find((item) => item.type === "video")?.url); + expect(entry.coverage.registry_simulation.status).toBe("not-covered"); + }); + + it("falls back to the duckmark when neither source nor registry media exists", () => { + const policy = flamingoPolicy(); + policy.media = []; + const entry = catalogEntryFromPolicy(policy, null); + expect(entry.media.primary).toBe("none"); + expect(primaryMedia(entry)).toEqual({ hero_type: "badge" }); + }); + it("only synthesizes exact robotctl targets for single-artifact Hugging Face models", () => { const base = flamingoPolicy(); base.resolved = { From 2ebd788cbd6905044e9104f5cfe8d73c08779809 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sun, 6 Sep 2026 11:13:56 -0300 Subject: [PATCH 2/3] fix: hand off roulade simulation to pinned stand policy --- scripts/evidence_store.py | 2 +- simulation/README.md | 4 +- simulation/evidence.py | 4 +- simulation/execution.py | 57 +++++++++++++++ simulation/execution_recipes.py | 22 +++++- simulation/microduck_sim/preflight.py | 22 ++++++ simulation/microduck_sim/robot.py | 72 ++++++++++++++----- simulation/microduck_sim/scenarios.py | 7 +- simulation/run_check.py | 50 ++++++++++--- simulation/tests/test_evidence_identity.py | 13 +++- simulation/tests/test_evidence_store.py | 4 +- simulation/tests/test_execution_recipes.py | 24 +++++++ simulation/tests/test_runtime_observations.py | 32 ++++++++- src/app/globals.css | 12 ++++ 14 files changed, 285 insertions(+), 40 deletions(-) diff --git a/scripts/evidence_store.py b/scripts/evidence_store.py index a8b6970..bfe488a 100644 --- a/scripts/evidence_store.py +++ b/scripts/evidence_store.py @@ -58,7 +58,7 @@ RELEASE_TAG = "registry-evidence" FORMAT_VERSION = 2 # The release index container remains format v2 so it can retain historical -# blobs, while report/evidence keys use the v3 semantic identity namespace in +# blobs, while report/evidence keys use the v4 semantic identity namespace in # simulation/evidence.py. EVIDENCE_FORMAT = "uduck-evidence-v2" # Wall-clock fields are useful transiently but must not affect content diff --git a/simulation/README.md b/simulation/README.md index 91439f0..f3a73b1 100644 --- a/simulation/README.md +++ b/simulation/README.md @@ -18,7 +18,7 @@ content-addressed Release blob ## ExecutionSpec -An `ExecutionSpec` must state the entry id, exact artifact URL and SHA-256, supported model, runner contract, reviewed recipe, source identity, and resolved manifest. The current runner owns one flat `flat-v1` scene with the official 61-observation/14-action Microduck contract. Recipes state the start preset, scenario, duration, explicit schedule, checks, and provenance. +An `ExecutionSpec` must state the entry id, exact artifact URL and SHA-256, supported model, runner contract, reviewed recipe, source identity, and resolved manifest. The current runner owns one flat `flat-v1` scene with the official 61-observation/14-action Microduck contract. Recipes state the start preset, scenario, duration, explicit schedule, checks, and provenance. A recipe may also declare a source-bound policy handoff; the runner downloads and verifies that artifact, then switches the same physical simulation state at the declared deadline. Preflight runs before download or inference. It verifies the runner, model, scene, start state, duration, schedule, contract, and HTTPS artifact URL. It rejects malformed or out-of-range commands; it never clips them and never substitutes defaults. @@ -45,7 +45,7 @@ Exit code 0 means the diagnostic passed or was not-covered; 1 means measured che ## Evidence identity -`simulation/evidence.py` computes an entry-specific v3 identity from the immutable source, execution-relevant manifest fields, that entry's resolved recipe/status, the executable runner code, the asset lock, dependency pins, and the environment contract. Editorial curation does not enter the digest. The evidence key additionally binds the artifact SHA-256. +`simulation/evidence.py` computes an entry-specific v4 identity from the immutable source, execution-relevant manifest fields, that entry's resolved recipe/status (including any source-bound policy handoff), the executable runner code, the asset lock, dependency pins, and the environment contract. Editorial curation does not enter the digest. The evidence key additionally binds the artifact SHA-256. The evidence store archives deterministic reports and media as `.tar.gz` assets in the `registry-evidence` GitHub Release. Its mutable index maps current entry ids to immutable blobs while retaining historical blobs. Hydration accepts only an exact current entry identity and exact authored artifact hash. diff --git a/simulation/evidence.py b/simulation/evidence.py index 127cc13..6831847 100644 --- a/simulation/evidence.py +++ b/simulation/evidence.py @@ -7,8 +7,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent -IDENTITY_VERSION = "uduck-execution-inputs-v3" -EVIDENCE_VERSION = "uduck-evidence-v3" +IDENTITY_VERSION = "uduck-execution-inputs-v4" +EVIDENCE_VERSION = "uduck-evidence-v4" EVIDENCE_ENV = "uduck-evidence-env-v1:ubuntu-24.04:python3.12:mujoco==3.12.0:onnxruntime==1.29.0:numpy==2.5.2:pillow==12.3.0" diff --git a/simulation/execution.py b/simulation/execution.py index b095970..426adeb 100644 --- a/simulation/execution.py +++ b/simulation/execution.py @@ -3,9 +3,22 @@ from __future__ import annotations from dataclasses import dataclass +from math import isfinite from typing import Any +@dataclass(frozen=True) +class ExecutionHandoff: + """A source-bound policy selected after the primary policy window ends.""" + + at_s: float + name: str + artifact_url: str + artifact_sha256: str + action_scale: float + source: dict[str, Any] + + @dataclass(frozen=True) class ExecutionSpec: """All inputs needed by the deterministic registry runner. @@ -22,6 +35,7 @@ class ExecutionSpec: recipe: dict[str, Any] source: dict[str, Any] manifest: dict[str, Any] | None + handoff: ExecutionHandoff | None = None def artifact_url(source: dict[str, Any]) -> str: @@ -35,6 +49,45 @@ def artifact_url(source: dict[str, Any]) -> str: return f"https://huggingface.co/{prefix}{repo}/resolve/{revision}/{artifact_path}" +def _handoff_from_recipe(recipe: dict[str, Any]) -> ExecutionHandoff | None: + value = recipe.get("handoff") + if value is None: + return None + if not isinstance(value, dict): + return None + source = value.get("source") + at_s = value.get("at_s") + name = value.get("name") + action_scale = value.get("action_scale") + if ( + not isinstance(source, dict) + or not isinstance(at_s, (int, float)) + or isinstance(at_s, bool) + or not isfinite(float(at_s)) + or float(at_s) <= 0 + or not isinstance(name, str) + or not name + or isinstance(action_scale, bool) + or not isinstance(action_scale, (int, float)) + or not isfinite(float(action_scale)) + or float(action_scale) <= 0 + or not isinstance(source.get("artifact_sha256"), str) + ): + return None + try: + url = artifact_url(source) + except (KeyError, TypeError): + return None + return ExecutionHandoff( + at_s=float(at_s), + name=name, + artifact_url=url, + artifact_sha256=source["artifact_sha256"], + action_scale=float(action_scale), + source=source, + ) + + def execution_spec_from_policy(policy: dict[str, Any], resolved: dict[str, Any]) -> ExecutionSpec | None: """Build an executable spec from resolved policy data, or return ``None``.""" @@ -80,6 +133,9 @@ def execution_spec_from_policy(policy: dict[str, Any], resolved: dict[str, Any]) model = recipe.get("model") if not isinstance(model, str): return None + handoff = _handoff_from_recipe(recipe) + if recipe.get("handoff") is not None and handoff is None: + return None return ExecutionSpec( entry_id=str(policy["id"]), artifact_url=artifact_url(source), @@ -89,4 +145,5 @@ def execution_spec_from_policy(policy: dict[str, Any], resolved: dict[str, Any]) recipe=recipe, source=source, manifest=manifest, + handoff=handoff, ) diff --git a/simulation/execution_recipes.py b/simulation/execution_recipes.py index d1af38d..0efba13 100644 --- a/simulation/execution_recipes.py +++ b/simulation/execution_recipes.py @@ -43,6 +43,7 @@ POLLEN_MANIFEST_URL = f"https://huggingface.co/{POLLEN_POLICY_REPO}/blob/{POLLEN_POLICY_REVISION}/{POLLEN_MANIFEST_PATH}" POLLEN_ARTIFACT_SHA256 = { "alpha_walking.onnx": "e36332d383997d51401897734cd3e79cf5038406feddb18b4d57ecfb141daa6c", + "alpha_stand.onnx": "1569268713e40deea795dd2922dba50d3621e15a872855408b6b1b125b1c094b", "alpha_ground_pick.onnx": "ffbf5109982ff999b0ba53afe86b9ae731bbec679d67fb7f8ab4c52152c88872", "roller.onnx": "cf05651d2708a2f9364212e86b866c97a70ace8131c492500105e8f28bf99afd", "roller_crouch.onnx": "a1a084be240469c76ac9d3fa44d4792f16d4b1da60398b3ecd3cfc5e2244d990", @@ -278,10 +279,27 @@ def _official_recipe(manifest: dict[str, Any], source: dict[str, Any]) -> dict[s "checks": checks, "action_scale": 1.0, "chain": bool(manifest.get("chain", False)), + "handoff": { + "at_s": command_duration, + "name": "stand", + "source": _pollen_source("alpha_stand.onnx"), + "action_scale": 1.0, + "provenance": _provenance( + "Exact pinned Pollen alpha stand artifact and the pinned robotd skill-expiry selection", + UPSTREAM_CONTROL_URL, + "At the released episodic skill deadline, the zero external twist hands control back to the exact pinned stand policy; this is part of the registry diagnostic execution, not publisher hardware evidence.", + policy_set_revision=POLLEN_POLICY_REVISION, + manifest_sha256=POLLEN_MANIFEST_SHA256, + artifact_path="alpha_stand.onnx", + artifact_sha256=POLLEN_ARTIFACT_SHA256["alpha_stand.onnx"], + action_scale=1.0, + selection="zero external command selects stand after the active skill expires", + ), + }, "provenance": _provenance( "Exact per-file Pollen schema-2 manifest and the pinned robotd zero-command skill contract", UPSTREAM_MANIFEST_URL, - "Registry diagnostic rollout of the exact policy command window followed by an explicit recovery/settle tail under flat-v1; final checks cover the full capture horizon and this does not establish intended-task success or hardware verification.", + "Registry diagnostic rollout of the exact policy command window followed by the pinned stand-policy handoff under flat-v1; final checks cover the full capture horizon and this does not establish intended-task success or hardware verification.", policy_set_revision=POLLEN_POLICY_REVISION, manifest_sha256=POLLEN_MANIFEST_SHA256, artifact_path=artifact_path, @@ -293,6 +311,8 @@ def _official_recipe(manifest: dict[str, Any], source: dict[str, Any]) -> dict[s command_duration_s=command_duration, post_command_settle_s=recovery_tail, capture_duration_s=capture_duration, + handoff_artifact_path="alpha_stand.onnx", + handoff_artifact_sha256=POLLEN_ARTIFACT_SHA256["alpha_stand.onnx"], ), } diff --git a/simulation/microduck_sim/preflight.py b/simulation/microduck_sim/preflight.py index 2ad864f..2d45b9b 100644 --- a/simulation/microduck_sim/preflight.py +++ b/simulation/microduck_sim/preflight.py @@ -114,6 +114,28 @@ def preflight_execution(spec: "ExecutionSpec") -> PreflightResult: schedule_value = command_value if has_settle_tail else duration_value schedule_name = "command_duration_s" if has_settle_tail else "duration_s" + handoff = getattr(spec, "handoff", None) + if recipe.get("handoff") is not None and handoff is None: + errors.append("execution recipe handoff could not be assembled into the ExecutionSpec") + if handoff is not None: + if not _finite(handoff.at_s) or not 0 < handoff.at_s < (capture_value or 0): + errors.append("execution handoff at_s must be inside the capture horizon") + if command_value is not None and abs(handoff.at_s - command_value) > 1e-9: + errors.append("execution handoff at_s must equal command_duration_s") + if not isinstance(handoff.name, str) or not handoff.name: + errors.append("execution handoff name must be non-empty") + if not _finite(handoff.action_scale) or handoff.action_scale <= 0: + errors.append("execution handoff action_scale must be finite and positive") + if not isinstance(handoff.artifact_sha256, str) or len(handoff.artifact_sha256) != 64 or any(char not in "0123456789abcdef" for char in handoff.artifact_sha256): + errors.append("execution handoff artifact SHA-256 is invalid") + if not isinstance(handoff.artifact_url, str) or not handoff.artifact_url.startswith("https://"): + errors.append("execution handoff artifact_url must be an HTTPS URL") + if ( + not isinstance(handoff.source, dict) + or handoff.source.get("artifact_sha256") != handoff.artifact_sha256 + ): + errors.append("execution handoff source hash does not match the handoff artifact hash") + segments = recipe.get("segments") if scenario == "velocity": if not isinstance(segments, list) or not segments: diff --git a/simulation/microduck_sim/robot.py b/simulation/microduck_sim/robot.py index d0cc35c..d76e796 100644 --- a/simulation/microduck_sim/robot.py +++ b/simulation/microduck_sim/robot.py @@ -16,6 +16,7 @@ import os from dataclasses import dataclass, field from pathlib import Path +from typing import Callable import mujoco import numpy as np @@ -187,24 +188,9 @@ class DuckRuntime: def __init__(self, model: mujoco.MjModel, onnx_path, action_scale: float = ACTION_SCALE): self.model = model self.data = mujoco.MjData(model) - self.action_scale = float(action_scale) - - so = ort.SessionOptions() - so.intra_op_num_threads = 2 - self.session = ort.InferenceSession(str(onnx_path), so, - providers=["CPUExecutionProvider"]) - self.input_name = self.session.get_inputs()[0].name - self.output_name = self.session.get_outputs()[0].name - in_shape = self.session.get_inputs()[0].shape - out_shape = self.session.get_outputs()[0].shape - input_dim = in_shape[-1] if in_shape and isinstance(in_shape[-1], int) else None - output_dim = out_shape[-1] if out_shape and isinstance(out_shape[-1], int) else None - if input_dim != OBSERVATION_DIM: - raise ValueError(f"Policy expects {in_shape}; expected {OBSERVATION_DIM} obs dims") - if output_dim != ACTION_DIM: - raise ValueError(f"Policy returns {out_shape}; expected {ACTION_DIM} actions") self.use_13d = True self.obs_dim = OBSERVATION_DIM + self._load_policy(onnx_path, action_scale) self.imu_ang_vel_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_SENSOR, IMU_GYRO_SENSOR) @@ -232,6 +218,35 @@ def __init__(self, model: mujoco.MjModel, onnx_path, action_scale: float = ACTIO self.last_action = np.zeros(self.n_joints, dtype=np.float32) self.reset() + def _load_policy(self, onnx_path, action_scale: float) -> None: + """Load one validated policy without resetting physical or observation state.""" + so = ort.SessionOptions() + so.intra_op_num_threads = 2 + session = ort.InferenceSession(str(onnx_path), so, + providers=["CPUExecutionProvider"]) + input_name = session.get_inputs()[0].name + output_name = session.get_outputs()[0].name + in_shape = session.get_inputs()[0].shape + out_shape = session.get_outputs()[0].shape + input_dim = in_shape[-1] if in_shape and isinstance(in_shape[-1], int) else None + output_dim = out_shape[-1] if out_shape and isinstance(out_shape[-1], int) else None + if input_dim != OBSERVATION_DIM: + raise ValueError(f"Policy expects {in_shape}; expected {OBSERVATION_DIM} obs dims") + if output_dim != ACTION_DIM: + raise ValueError(f"Policy returns {out_shape}; expected {ACTION_DIM} actions") + self.session = session + self.input_name = input_name + self.output_name = output_name + self.action_scale = float(action_scale) + + def switch_policy(self, onnx_path, action_scale: float) -> None: + """Hand the same physical rollout to another source-bound policy. + + The MuJoCo state, filtered pose state, and shared last action are kept, + matching robotd's next-tick network selection after a skill expires. + """ + self._load_policy(onnx_path, action_scale) + def reset(self) -> None: mujoco.mj_resetData(self.model, self.data) adr = self._trunk_qpos_adr @@ -384,15 +399,36 @@ def step_control(self, t: float, command: np.ndarray) -> StepSample: right_foot_contact=right_contact, ) - def rollout(self, command_fn, duration_s: float, frame_hook=None) -> RolloutResult: - """Run a rollout; `frame_hook(k, sample)` fires after every control step.""" + def rollout( + self, + command_fn, + duration_s: float, + frame_hook=None, + handoffs: list[tuple[float, Callable[[], None]]] | None = None, + ) -> RolloutResult: + """Run a rollout; handoffs occur before the first tick at their deadline.""" result = RolloutResult(obs_dim=self.obs_dim, use_13d=self.use_13d) initial_left, initial_right = self.foot_contacts() result.initial_left_foot_contact = initial_left result.initial_right_foot_contact = initial_right n_steps = int(round(duration_s * 50)) + schedule = list(handoffs or []) + if any( + not isinstance(at_s, (int, float)) + or isinstance(at_s, bool) + or not 0.0 < float(at_s) < duration_s + or not callable(callback) + for at_s, callback in schedule + ): + raise ValueError("policy handoffs must occur inside the rollout horizon") + if any(schedule[index][0] >= schedule[index + 1][0] for index in range(len(schedule) - 1)): + raise ValueError("policy handoffs must be strictly ordered") + next_handoff = 0 for k in range(n_steps): t = k / 50.0 + while next_handoff < len(schedule) and t >= schedule[next_handoff][0]: + schedule[next_handoff][1]() + next_handoff += 1 command = command_fn(t) if not self.use_13d: command = command[:3] diff --git a/simulation/microduck_sim/scenarios.py b/simulation/microduck_sim/scenarios.py index 0c4e1b2..a005da1 100644 --- a/simulation/microduck_sim/scenarios.py +++ b/simulation/microduck_sim/scenarios.py @@ -29,8 +29,9 @@ class ScenarioSpec: hold_s: float = 2.0 # oneshot_zero: seconds the zeroed command window lasts (kicks, roulade). duration_s: float = 0.5 - # A diagnostic can keep recording after the policy command window so final - # checks observe recovery at the end of the rollout, not mid-trajectory. + # A diagnostic can keep recording after the policy command window. The + # external command returns to idle; any source-bound policy handoff is + # carried by ExecutionSpec and applied by the runner at this boundary. command_duration_s: float = 0.5 post_command_settle_s: float = 0.0 capture_duration_s: float = 0.5 @@ -175,6 +176,8 @@ def with_settle_tail(command_fn: Callable[[float], np.ndarray]) -> Callable[[flo def scheduled_fn(t: float) -> np.ndarray: if t >= spec.command_duration_s: + # This is the external command after the active skill expires; + # it is not a substitute for the policy handoff itself. return wrap(np.zeros(3, dtype=np.float32)) return command_fn(t) diff --git a/simulation/run_check.py b/simulation/run_check.py index 647df9e..a87882c 100644 --- a/simulation/run_check.py +++ b/simulation/run_check.py @@ -60,13 +60,13 @@ def load_policy_resolution(entry_id: str) -> tuple[dict, dict]: return policy, resolved -def download_onnx(spec: ExecutionSpec, dest_dir: Path) -> Path: - parsed = urllib.parse.urlsplit(spec.artifact_url) +def download_artifact(url: str, artifact_sha256: str, dest_dir: Path) -> Path: + parsed = urllib.parse.urlsplit(url) if parsed.hostname not in ALLOWED_HOSTS: - raise ValueError(f"artifact host not allowed: {spec.artifact_url}") - filename = Path(parsed.path).name or f"{spec.entry_id}.onnx" + raise ValueError(f"artifact host not allowed: {url}") + filename = Path(parsed.path).name or "policy.onnx" dest = dest_dir / filename - request = urllib.request.Request(spec.artifact_url, headers={"User-Agent": "uduck-registry-ci"}) + request = urllib.request.Request(url, headers={"User-Agent": "uduck-registry-ci"}) with open_download(request, timeout=300) as response, dest.open("wb") as output: size = 0 while True: @@ -78,11 +78,15 @@ def download_onnx(spec: ExecutionSpec, dest_dir: Path) -> Path: raise ValueError("ONNX artifact exceeds 100 MB sanity bound") output.write(chunk) actual = hashlib.sha256(dest.read_bytes()).hexdigest() - if actual != spec.artifact_sha256: - raise ValueError(f"policy artifact hash mismatch: expected {spec.artifact_sha256}, got {actual}") + if actual != artifact_sha256: + raise ValueError(f"policy artifact hash mismatch: expected {artifact_sha256}, got {actual}") return dest +def download_onnx(spec: ExecutionSpec, dest_dir: Path) -> Path: + return download_artifact(spec.artifact_url, spec.artifact_sha256, dest_dir) + + def identity_fields(entry_id: str, source: dict) -> dict[str, str]: inputs = inputs_digest(entry_id) artifact = source["artifact_sha256"] @@ -137,6 +141,13 @@ def run(entry_id: str, out_dir: Path, keep_media: bool) -> int: duration = scenario.capture_duration_s with tempfile.TemporaryDirectory(prefix="uduck-sim-") as temporary: onnx_path = download_onnx(spec, Path(temporary)) + handoff_path = None + if spec.handoff is not None: + handoff_path = download_artifact( + spec.handoff.artifact_url, + spec.handoff.artifact_sha256, + Path(temporary), + ) from fetch_assets import fetch asset_variant = "rollers" if simulation_model == "microduck-rollers" else "standard" model = load_model(fetch(variant=asset_variant)) @@ -147,6 +158,28 @@ def run(entry_id: str, out_dir: Path, keep_media: bool) -> int: runtime = DuckRuntime(model, onnx_path, action_scale=float(action_scale)) runtime.prepare_start(spec.recipe["start"]) command_fn = make_command_fn(scenario, runtime.use_13d) + handoffs = [] + policy_timeline = [{ + "at_s": 0.0, + "name": "primary", + "artifact_path": spec.source["artifact_path"], + "artifact_sha256": spec.artifact_sha256, + "action_scale": float(action_scale), + }] + if spec.handoff is not None: + assert handoff_path is not None + + def switch_to_handoff() -> None: + runtime.switch_policy(handoff_path, spec.handoff.action_scale) + + handoffs.append((spec.handoff.at_s, switch_to_handoff)) + policy_timeline.append({ + "at_s": spec.handoff.at_s, + "name": spec.handoff.name, + "artifact_path": spec.handoff.source["artifact_path"], + "artifact_sha256": spec.handoff.artifact_sha256, + "action_scale": spec.handoff.action_scale, + }) renderer = render.LoopRenderer(model) renderer.attach(runtime.data) @@ -155,7 +188,7 @@ def hook(step, sample): print(f"[sim] step {step}/{int(duration * 50)}", flush=True) renderer.capture(step, sample) - result = runtime.rollout(command_fn, duration, frame_hook=hook) + result = runtime.rollout(command_fn, duration, frame_hook=hook, handoffs=handoffs) report = checks.evaluate(result, scenario) media = renderer.finalize(out_dir / entry_id, f"registry sim {entry_id} (flat-v1, 50 Hz)") if keep_media else None report.update({ @@ -168,6 +201,7 @@ def hook(step, sample): "post_command_settle_s": scenario.post_command_settle_s, "capture_duration_s": scenario.capture_duration_s, "evaluation_final_sample_s": report["observations"].get("final_sample_time_s"), + "policy_timeline": policy_timeline, "policy": {"url": spec.artifact_url, "sha256": spec.artifact_sha256}, "media": media, "preflight": {"status": "passed", "warnings": list(preflight.warnings)}, diff --git a/simulation/tests/test_evidence_identity.py b/simulation/tests/test_evidence_identity.py index 54a0901..4ec7899 100644 --- a/simulation/tests/test_evidence_identity.py +++ b/simulation/tests/test_evidence_identity.py @@ -11,9 +11,9 @@ class EvidenceIdentityTests(unittest.TestCase): - def test_identity_namespace_is_v3(self) -> None: - self.assertEqual(IDENTITY_VERSION, "uduck-execution-inputs-v3") - self.assertEqual(EVIDENCE_VERSION, "uduck-evidence-v3") + def test_identity_namespace_is_v4(self) -> None: + self.assertEqual(IDENTITY_VERSION, "uduck-execution-inputs-v4") + self.assertEqual(EVIDENCE_VERSION, "uduck-evidence-v4") def test_identity_is_entry_scoped(self) -> None: self.assertNotEqual(inputs_digest("alpha-walking"), inputs_digest("jump")) @@ -25,6 +25,13 @@ def test_identity_contains_execution_inputs_but_not_curation(self) -> None: self.assertIn("simulation", inputs) self.assertNotIn("curation", inputs) + def test_roulade_identity_contains_the_standing_handoff_artifact(self) -> None: + handoff = execution_inputs("roulade")["simulation"]["recipe"]["handoff"] + self.assertEqual(handoff["at_s"], 1.0) + self.assertEqual(handoff["name"], "stand") + self.assertEqual(handoff["source"]["artifact_path"], "alpha_stand.onnx") + self.assertEqual(len(handoff["source"]["artifact_sha256"]), 64) + def test_recipe_change_is_scoped_to_the_changed_entry(self) -> None: original_alpha = inputs_digest("alpha-walking") original_jump = inputs_digest("jump") diff --git a/simulation/tests/test_evidence_store.py b/simulation/tests/test_evidence_store.py index ba422d6..cab4cd4 100644 --- a/simulation/tests/test_evidence_store.py +++ b/simulation/tests/test_evidence_store.py @@ -16,7 +16,7 @@ def evidence_key(inputs: str, artifact: str) -> str: return hashlib.sha256( - b"uduck-evidence-v3\0" + inputs.encode() + b"\0" + artifact.encode() + b"uduck-evidence-v4\0" + inputs.encode() + b"\0" + artifact.encode() ).hexdigest() @@ -34,7 +34,7 @@ def report(entry: str = "test", inputs: str = "a" * 64, artifact: str = "b" * 64 class EvidenceStoreTests(unittest.TestCase): - def test_actual_pre20_index_transitions_to_v3_identity_and_store(self) -> None: + def test_actual_pre20_index_transitions_to_v4_identity_and_store(self) -> None: fixture = Path(__file__).parent / "fixtures" / "pre20-evidence-index.json" old_index = json.loads(fixture.read_text()) self.assertEqual(old_index["format"], "uduck-evidence-v2") diff --git a/simulation/tests/test_execution_recipes.py b/simulation/tests/test_execution_recipes.py index 89a98ee..45d18b8 100644 --- a/simulation/tests/test_execution_recipes.py +++ b/simulation/tests/test_execution_recipes.py @@ -159,10 +159,34 @@ def test_roulade_keeps_command_and_recovery_windows_distinct(self) -> None: self.assertGreater(recipe["post_command_settle_s"], 0.0) self.assertEqual(recipe["capture_duration_s"], recipe["duration_s"]) self.assertGreater(recipe["capture_duration_s"], recipe["command_duration_s"]) + self.assertEqual(recipe["handoff"]["at_s"], 1.0) + self.assertEqual(recipe["handoff"]["name"], "stand") + self.assertEqual(recipe["handoff"]["source"]["artifact_path"], "alpha_stand.onnx") + self.assertEqual(recipe["handoff"]["source"]["artifact_sha256"], POLLEN_ARTIFACT_SHA256["alpha_stand.onnx"]) + self.assertEqual(recipe["handoff"]["action_scale"], 1.0) scenario = scenario_from_recipe(recipe) self.assertEqual(scenario.capture_duration_s, 4.0) + manifest_for_spec = { + "schema_version": 2, + "model_api": 1, + "obs_len": 61, + "action_len": 14, + "robot": {"model": "microduck", "hw_rev": 1, "servos": "xl330", "control_hz": 50}, + **manifest, + } + spec = execution_spec_from_policy( + {"id": "roulade", "source": source}, + {"manifest": manifest_for_spec, "simulation": {"status": "covered", "recipe": recipe}}, + ) + self.assertIsNotNone(spec) + assert spec is not None + assert spec.handoff is not None + self.assertEqual(spec.handoff.at_s, 1.0) + self.assertEqual(spec.handoff.source["artifact_path"], "alpha_stand.onnx") + self.assertEqual(spec.handoff.artifact_sha256, POLLEN_ARTIFACT_SHA256["alpha_stand.onnx"]) + def test_settle_tail_returns_to_idle_after_command_window(self) -> None: scenario = scenario_from_recipe({ "runner": "microduck-standard-v1", diff --git a/simulation/tests/test_runtime_observations.py b/simulation/tests/test_runtime_observations.py index d968d2f..848aa23 100644 --- a/simulation/tests/test_runtime_observations.py +++ b/simulation/tests/test_runtime_observations.py @@ -4,7 +4,7 @@ import numpy as np -from microduck_sim.robot import RolloutResult, StepSample +from microduck_sim.robot import DuckRuntime, RolloutResult, StepSample from microduck_sim.scenarios import scenario_from_recipe @@ -109,6 +109,36 @@ def test_final_observation_is_after_the_recovery_tail(self) -> None: self.assertGreater(metrics["final_sample_time_s"], spec.command_duration_s) self.assertEqual(metrics["final_sample_time_s"], 3.98) + def test_policy_handoff_occurs_before_the_first_tick_at_the_deadline(self) -> None: + class StubRuntime: + use_13d = True + obs_dim = 61 + + def __init__(self) -> None: + self.active = "roulade" + self.seen: list[tuple[float, str]] = [] + + def foot_contacts(self) -> tuple[bool, bool]: + return True, True + + def switch_policy(self, _path, _action_scale) -> None: + self.active = "stand" + + def step_control(self, t: float, command: np.ndarray) -> StepSample: + self.seen.append((t, self.active)) + return sample(t, True, True) + + runtime = StubRuntime() + DuckRuntime.rollout( + runtime, + lambda _t: np.zeros(13, dtype=np.float32), + 2.0, + handoffs=[(1.0, lambda: runtime.switch_policy(None, 1.0))], + ) + self.assertEqual(runtime.seen[0], (0.0, "roulade")) + self.assertEqual(runtime.seen[49], (0.98, "roulade")) + self.assertEqual(runtime.seen[50], (1.0, "stand")) + if __name__ == "__main__": unittest.main() diff --git a/src/app/globals.css b/src/app/globals.css index 756d907..bfceaa6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -720,6 +720,18 @@ code { } .behavior-accessory { color: var(--cyan); } +.behavior-facts { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.8rem; + margin-top: 0.62rem; + color: var(--quiet); + font-family: var(--font-mono); + font-size: 0.58rem; + letter-spacing: 0.04em; + line-height: 1.4; + text-transform: uppercase; +} .behavior-footer { min-width: 7.5rem; align-self: center; display: flex; justify-content: flex-end; border: 0; padding: 0; background: transparent; } .inspect-link { From 181fa6478e83127c0d7af7a50252038460feecc5 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Sun, 6 Sep 2026 11:19:45 -0300 Subject: [PATCH 3/3] fix: limit stand handoff to roulade --- simulation/execution_recipes.py | 88 +++++++++++++--------- simulation/tests/test_execution_recipes.py | 18 +++++ 2 files changed, 70 insertions(+), 36 deletions(-) diff --git a/simulation/execution_recipes.py b/simulation/execution_recipes.py index 0efba13..c1dbd16 100644 --- a/simulation/execution_recipes.py +++ b/simulation/execution_recipes.py @@ -206,6 +206,26 @@ def _official_phase_recipe(manifest: dict[str, Any], source: dict[str, Any], *, } +def _official_stand_handoff(at_s: float) -> dict[str, Any]: + return { + "at_s": at_s, + "name": "stand", + "source": _pollen_source("alpha_stand.onnx"), + "action_scale": 1.0, + "provenance": _provenance( + "Exact pinned Pollen alpha stand artifact and the pinned robotd skill-expiry selection", + UPSTREAM_CONTROL_URL, + "At the released episodic skill deadline, the zero external twist hands control back to the exact pinned stand policy; this is part of the registry diagnostic execution, not publisher hardware evidence.", + policy_set_revision=POLLEN_POLICY_REVISION, + manifest_sha256=POLLEN_MANIFEST_SHA256, + artifact_path="alpha_stand.onnx", + artifact_sha256=POLLEN_ARTIFACT_SHA256["alpha_stand.onnx"], + action_scale=1.0, + selection="zero external command selects stand after the active skill expires", + ), + } + + def _official_recipe(manifest: dict[str, Any], source: dict[str, Any]) -> dict[str, Any] | None: artifact_path = source.get("artifact_path") if not isinstance(artifact_path, str) or artifact_path not in POLLEN_ARTIFACT_SHA256: @@ -263,7 +283,34 @@ def _official_recipe(manifest: dict[str, Any], source: dict[str, Any]) -> dict[s recovery_tail = ROULADE_RECOVERY_TAIL_S if artifact_path == "roulade.onnx" else 0.0 capture_duration = command_duration + recovery_tail checks = ["recover_upright"] if artifact_path == "roulade.onnx" else ["no_fall", "ends_upright"] - return { + handoff = _official_stand_handoff(command_duration) if artifact_path == "roulade.onnx" else None + scope = ( + "Registry diagnostic rollout of the exact policy command window followed by the pinned stand-policy handoff under flat-v1; final checks cover the full capture horizon and this does not establish intended-task success or hardware verification." + if handoff is not None + else "Registry diagnostic rollout of the exact policy command window under flat-v1; this does not establish intended-task success or hardware verification." + ) + provenance = _provenance( + "Exact per-file Pollen schema-2 manifest and the pinned robotd zero-command skill contract", + UPSTREAM_MANIFEST_URL, + scope, + policy_set_revision=POLLEN_POLICY_REVISION, + manifest_sha256=POLLEN_MANIFEST_SHA256, + artifact_path=artifact_path, + command=[0.0, 0.0, 0.0], + command_semantics="Selecting an ordinary constant episodic skill is the trigger; the upstream runtime feeds the all-zero twist.", + action_scale=1.0, + action_scale_source=UPSTREAM_CONTROL_URL, + chain=bool(manifest.get("chain", False)), + command_duration_s=command_duration, + post_command_settle_s=recovery_tail, + capture_duration_s=capture_duration, + ) + if handoff is not None: + provenance.update({ + "handoff_artifact_path": "alpha_stand.onnx", + "handoff_artifact_sha256": POLLEN_ARTIFACT_SHA256["alpha_stand.onnx"], + }) + recipe = { "runner": RUNNER, "model": MODEL, "scene": SCENE, @@ -279,42 +326,11 @@ def _official_recipe(manifest: dict[str, Any], source: dict[str, Any]) -> dict[s "checks": checks, "action_scale": 1.0, "chain": bool(manifest.get("chain", False)), - "handoff": { - "at_s": command_duration, - "name": "stand", - "source": _pollen_source("alpha_stand.onnx"), - "action_scale": 1.0, - "provenance": _provenance( - "Exact pinned Pollen alpha stand artifact and the pinned robotd skill-expiry selection", - UPSTREAM_CONTROL_URL, - "At the released episodic skill deadline, the zero external twist hands control back to the exact pinned stand policy; this is part of the registry diagnostic execution, not publisher hardware evidence.", - policy_set_revision=POLLEN_POLICY_REVISION, - manifest_sha256=POLLEN_MANIFEST_SHA256, - artifact_path="alpha_stand.onnx", - artifact_sha256=POLLEN_ARTIFACT_SHA256["alpha_stand.onnx"], - action_scale=1.0, - selection="zero external command selects stand after the active skill expires", - ), - }, - "provenance": _provenance( - "Exact per-file Pollen schema-2 manifest and the pinned robotd zero-command skill contract", - UPSTREAM_MANIFEST_URL, - "Registry diagnostic rollout of the exact policy command window followed by the pinned stand-policy handoff under flat-v1; final checks cover the full capture horizon and this does not establish intended-task success or hardware verification.", - policy_set_revision=POLLEN_POLICY_REVISION, - manifest_sha256=POLLEN_MANIFEST_SHA256, - artifact_path=artifact_path, - command=[0.0, 0.0, 0.0], - command_semantics="Selecting an ordinary constant episodic skill is the trigger; the upstream runtime feeds the all-zero twist.", - action_scale=1.0, - action_scale_source=UPSTREAM_CONTROL_URL, - chain=bool(manifest.get("chain", False)), - command_duration_s=command_duration, - post_command_settle_s=recovery_tail, - capture_duration_s=capture_duration, - handoff_artifact_path="alpha_stand.onnx", - handoff_artifact_sha256=POLLEN_ARTIFACT_SHA256["alpha_stand.onnx"], - ), + "provenance": provenance, } + if handoff is not None: + recipe["handoff"] = handoff + return recipe if artifact_path == "alpha_sitstand.onnx": command = manifest.get("command") diff --git a/simulation/tests/test_execution_recipes.py b/simulation/tests/test_execution_recipes.py index 45d18b8..23c99ce 100644 --- a/simulation/tests/test_execution_recipes.py +++ b/simulation/tests/test_execution_recipes.py @@ -201,6 +201,24 @@ def test_settle_tail_returns_to_idle_after_command_window(self) -> None: self.assertEqual(command_fn(0.99).tolist(), [1.0, 0.0, 0.0]) self.assertEqual(command_fn(1.0).tolist(), [0.0, 0.0, 0.0]) + def test_kicks_do_not_declare_a_post_window_policy_handoff(self) -> None: + common = { + "provider": "huggingface-model", + "repo": POLLEN_POLICY_REPO, + "revision": POLLEN_POLICY_REVISION, + "manifest_path": POLLEN_MANIFEST_PATH, + "manifest_sha256": POLLEN_MANIFEST_SHA256, + } + for artifact_path in ("ball_kick_left.onnx", "ball_kick_right.onnx"): + recipe = recipe_for_policy( + POLLEN_POLICY_REPO, + {"file": artifact_path, "kind": "episodic", "duration_s": 0.5}, + {**common, "artifact_path": artifact_path, "artifact_sha256": POLLEN_ARTIFACT_SHA256[artifact_path]}, + ) + self.assertIsNotNone(recipe) + assert recipe is not None + self.assertNotIn("handoff", recipe) + def test_exact_no_manifest_recipes_supply_only_their_pinned_contract(self) -> None: from execution_recipes import GENESIS_ARTIFACT_SHA256, GENESIS_REPO, GENESIS_REVISION