diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bd40124..1d37d7e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,8 @@ # CODEOWNERS — uDuck Registry -/registry/behaviors/ @uduck-registry-maintainers +/registry/policies/ @uduck-registry-maintainers # Registry structure and tooling are maintainer-owned: /registry/schema/ @uduck-registry-maintainers /scripts/ @uduck-registry-maintainers +/simulation/ @uduck-registry-maintainers +/.github/workflows/ @uduck-registry-maintainers diff --git a/.github/ISSUE_TEMPLATE/register-policy.yml b/.github/ISSUE_TEMPLATE/register-policy.yml index e4ff3ba..1f07a19 100644 --- a/.github/ISSUE_TEMPLATE/register-policy.yml +++ b/.github/ISSUE_TEMPLATE/register-policy.yml @@ -1,18 +1,18 @@ name: Register a Microduck policy -description: Submit a Hugging Face package URL; the bot resolves it and prepares a pull request. +description: Submit a Hugging Face package or exact ONNX file URL; the bot resolves it and prepares a pull request. labels: - policy-submission body: - type: markdown attributes: value: | - Publish with Pollen's publisher, then paste the Hugging Face model repository URL below. - Custom sources and official multi-policy sets need maintainer review. Do not guess runtime fields. + Publish with Pollen's publisher, then paste the Hugging Face model repository or exact ONNX file URL below. + For a multi-ONNX repository use `/blob//.onnx`; sources without one explicit artifact need maintainer review. Do not guess runtime fields. - type: input id: url attributes: label: Policy URL - placeholder: https://huggingface.co/your-name/microduck-your-move + placeholder: https://huggingface.co/your-name/microduck-your-move/blob//policy.onnx validations: required: true - type: dropdown diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2bc4220..f61e31f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,9 +4,9 @@ Describe the policy or tooling change and its source. ## Validation -For a Pollen policy, commit only the pinned pointer and curation overlay. CI resolves and checks the upstream package. For custom entries, cite the sources for runtime facts and any hand-authored diagnostic recipe. +Commit one immutable policy source and its curation overlay. CI resolves and checks the upstream artifact. Maintainers own any execution recipe; cite its source and keep it separate from authored policy data. - [ ] License and provenance reviewed - [ ] Hardware claims, publisher media, and registry diagnostics kept separate - [ ] No generated index or simulation media committed -- [ ] Relevant checks pass; any failed or uncovered behavioral checks explained +- [ ] Relevant checks pass; any failed or not-covered diagnostics explained diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a62de69..0d3219a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ env: jobs: resolve-policies: - name: Resolve pinned Hub packages + name: Resolve pinned upstream policy artifacts runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -90,12 +90,12 @@ jobs: Path('sim-results').mkdir(parents=True, exist_ok=True) for item in plan['items']: if item['status'] == 'cached': - print(f"[{item['behavior']}] CACHED {item['evidence_key']}") + print(f"[{item['entry']}] CACHED {item['evidence_key']}") continue result = subprocess.run([ sys.executable, 'simulation/run_check.py', - '--behavior', item['behavior'], + '--entry', item['entry'], '--out', 'sim-results', '--keep-media', ]) @@ -205,15 +205,14 @@ jobs: - name: Run Vitest Suite run: pnpm test - - name: Compile Public Registry Index - run: pnpm compile - - name: Add this run's temporary diagnostics to the build view run: | python3 scripts/evidence_store.py package --results ci-evidence/sim-results --out ci-evidence/local-assets --fragment ci-evidence/local-fragment.json python3 scripts/evidence_store.py merge --existing ci-evidence/evidence-index.json --fragment ci-evidence/local-fragment.json --out build-evidence-index.json - name: Hydrate matching evidence into this build run: python3 scripts/evidence_store.py hydrate --index build-evidence-index.json --release-url "$EVIDENCE_RELEASE_URL" --local ci-evidence/sim-results --out public/media/registry-sim + - name: Compile Public Registry Index + run: pnpm compile - name: Build Web Application run: pnpm build diff --git a/.github/workflows/register-policy.yml b/.github/workflows/register-policy.yml index 3103b41..87a746c 100644 --- a/.github/workflows/register-policy.yml +++ b/.github/workflows/register-policy.yml @@ -54,7 +54,7 @@ jobs: with: name: policy-submission path: candidate - - name: Validate pointer data and open a review PR + - name: Validate policy data and open a review PR env: GH_TOKEN: ${{ github.token }} ISSUE_NUMBER: ${{ github.event.issue.number }} diff --git a/.github/workflows/sim-check.yml b/.github/workflows/sim-check.yml deleted file mode 100644 index e68759c..0000000 --- a/.github/workflows/sim-check.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: Sim Check - -# Manual diagnostic utility. PR and main-build evidence are generated by ci.yml. - -on: - workflow_dispatch: - inputs: - behavior: - description: "Behavior id to check (empty = all descriptors)" - required: false - default: "" - -permissions: - contents: read - -env: - MUJOCO_GL: egl - PYOUT: sim-results - -jobs: - detect: - name: Detect changed behaviors - runs-on: ubuntu-latest - outputs: - ids: ${{ steps.set.outputs.ids }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - fetch-depth: 0 - - id: set - run: | - requested="${{ github.event.inputs.behavior }}" - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "$requested" ]; then - ids=$(jq -cn --arg id "$requested" '[$id]') - elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - ids=$(find registry/behaviors -maxdepth 1 -name '*.json' -printf '%f\n' \ - | sed 's/\.json$//' | sort | jq -R -s -c 'split("\n") | map(select(length > 0))') - else - base="${{ github.event.pull_request.base.sha }}" - changed=$(git diff --diff-filter=ACMR --name-only "$base" HEAD) - descriptor_ids=$(printf '%s\n' "$changed" \ - | sed -n 's#registry/behaviors/\(.*\)\.json$#\1#p' \ - | sort -u | jq -R -s -c 'split("\n") | map(select(length > 0))') - shared=$(printf '%s\n' "$changed" \ - | sed -n '\#^simulation/\|^registry/schema/\|^\.github/workflows/sim-check.yml$#p') - if [ -n "$shared" ]; then - golden='["alpha-walking","jump","max-height-jump","roulade"]' - ids=$(jq -cn --argjson changed "$descriptor_ids" --argjson golden "$golden" \ - '$changed + $golden | unique') - else - ids="$descriptor_ids" - fi - fi - echo "ids=$ids" >> "$GITHUB_OUTPUT" - echo "changed behaviors: $ids" - - simulate: - name: Sim ${{ matrix.id }} - needs: detect - if: needs.detect.outputs.ids != '[]' - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - id: ${{ fromJson(needs.detect.outputs.ids) }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install GL + ffmpeg - run: | - sudo apt-get update -qq - sudo apt-get install -y -qq libegl1 libgl1 ffmpeg - - - name: Install Python deps - run: pip install -r simulation/requirements.txt - - - name: Cache pinned sim assets - uses: actions/cache@v4 - with: - path: .simcache - key: sim-assets-${{ hashFiles('simulation/assets.lock.json') }} - - - name: Test simulation runner - run: PYTHONPATH=simulation python -m unittest discover -s simulation/tests - - - name: Run simulation check - run: | - python simulation/run_check.py \ - --behavior "${{ matrix.id }}" \ - --out "$PYOUT" \ - --keep-media - - - name: Upload sim report + render - if: always() - uses: actions/upload-artifact@v4 - with: - name: sim-${{ matrix.id }} - path: | - ${{ env.PYOUT }}/${{ matrix.id }}/report.json - ${{ env.PYOUT }}/${{ matrix.id }}/loop.mp4 - ${{ env.PYOUT }}/${{ matrix.id }}/poster.png - if-no-files-found: warn - retention-days: 14 - - - name: Job summary - if: always() - run: | - { - echo "## Sim check: ${{ matrix.id }}" - echo "Diagnostic render only — this does not validate hardware behavior or reproduce arbitrary publisher environments." - echo - echo "Download \`sim-${{ matrix.id }}\` from the artifacts on [this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})." - echo - if [ -f "$PYOUT/${{ matrix.id }}/report.json" ]; then - echo '```json' - cat "$PYOUT/${{ matrix.id }}/report.json" - echo '```' - else - echo "No report produced (run error)." - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index 5ce056f..4d76ab0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,10 +12,10 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex Read [CONTRIBUTING.md](CONTRIBUTING.md). Prefer a Pollen Hub package URL through the issue form or `pnpm uduck register `. -- `registry/policies/*.json` is authored pointer + curation state. `registry/behaviors/` is the legacy/manual path. -- One public shape: `CatalogEntry`. No `/policies` routes; Flamingo lives at `/behaviors/flamingo-cycle`. +- `registry/policies/*.json` is the only authored format: immutable upstream source identity plus curation state. +- The resolver produces prepared facts, the resolver/recipe boundary produces `ExecutionSpec`, and `CatalogEntry` is the only public shape. No `/policies` routes; Flamingo lives at `/behaviors/flamingo-cycle`. - Do not guess normalizers, action scales, runtime slots, hardware evidence, or command values. - `.generated/`, public indexes, and registry renders are build outputs. Do not commit them. - ONNX inspection is not a behavior simulation. Upstream `eval` and author media are publisher claims. -- Execution identity v2 covers execution-relevant inputs only; curation edits must not rerun simulation. Evidence blobs are content-addressed (`.tar.gz`). +- Execution identity v3 covers execution-relevant inputs only; curation edits must not rerun simulation. Unsupported or recipe-less policies remain visible as `not-covered`. Evidence blobs are content-addressed (`.tar.gz`). - Run `pnpm validate`, resolver tests, and relevant TypeScript/Python tests for tooling changes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85bee0a..675b696 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Add a Microduck policy -Submit the **Hugging Face model repository URL** through [Register a policy](https://github.com/ob1-s/uduck-registry/issues/new?template=register-policy.yml). No fork, JSON, or local simulator required. +Submit a **Hugging Face model repository URL or exact ONNX file URL** through [Register a policy](https://github.com/ob1-s/uduck-registry/issues/new?template=register-policy.yml). For a repository containing multiple ONNX files, use its `/blob//.onnx` URL so the resolver cannot guess which artifact you mean. The resolver pins one immutable upstream revision, verifies the exact artifact bytes, and reads a machine-readable manifest when the publisher provides one. For an agent using `gh`, the equivalent is: @@ -18,9 +18,7 @@ experimental Optional reviewer context' ``` -The bot pins the Hub commit, reads Pollen's schema-2 manifest, hashes the manifest and `policy.onnx`, checks the ONNX interface and finite outputs, and opens a review PR. It explicitly starts CI for that branch. Maintainers review the license, commands, and curation before merging. Failed ingestion is reported back on the issue; correct the form and save the edit to retry automatically. Reopening also retries. Notes are bounded reviewer context and are not executed or treated as runtime evidence. - -Publish a package with [Pollen's publisher](https://github.com/pollen-robotics/microduck_rl#publishing-a-policy) first if you only have a raw ONNX file. If registry simulation reports a missing `action_scale`, republish with the policy's trained scale (`uv run publish ... --action-scale `); uDuck deliberately does not guess one. If a Hub repository is already registered and you are publishing a new revision, use a normal PR to update its existing `registry/policies/.json` pointer for now. Official multi-policy sets, custom runtimes, and legacy sources remain possible through a normal issue and maintainer review. +The bot resolves the package without loading the ONNX in the write-capable job. It opens a review PR containing one file at `registry/policies/.json`; CI performs package inspection and any covered registry diagnostic. Edit the issue to retry a failed resolution; reopening it is an alternative retry. Notes are bounded reviewer context and are never treated as runtime evidence. ## Local contribution @@ -32,29 +30,40 @@ pnpm install pnpm uduck resolve https://huggingface.co/your-name/microduck-your-move pnpm uduck register https://huggingface.co/your-name/microduck-your-move --category agility-tricks pnpm policies:prepare -pnpm check +pnpm validate +pnpm test +pnpm compile ``` -Commit only `registry/policies/.json`. You may edit its category, tags, summary, notes, and author media URLs. Runtime facts come from the pinned upstream manifest; hashes come from downloaded bytes. Do not invent missing values, translate prose into simulation commands, or label an ONNX smoke check a successful behavior test. +Only `registry/policies/.json` belongs in a contribution. It contains: + +- `source`: provider, repository, immutable 40-hex revision, safe ONNX path, artifact SHA-256, and optional manifest path/SHA-256; +- `curation`: category, tags, editorial copy, authors, license, notes, optional author media, source-backed setup requirements, and separately labeled publisher hardware claims. + +Runtime facts are resolved from the pinned upstream manifest. Missing facts remain unknown. Do not invent normalizers, action scales, slots, hardware evidence, command values, or environment details from prose. Do not commit `.generated/`, public indexes, or diagnostic media. -`pnpm validate` is an offline schema/identity check. `pnpm policies:prepare` performs network resolution and ONNX inspection. `pnpm build` produces the public indexes and static site from prepared facts. CI does all three. Generated indexes, resolved facts, and simulation videos are build outputs and do not belong in contributions. +The accepted providers are GitHub, Hugging Face model repositories, and Hugging Face Spaces. Each entry identifies one ONNX artifact. A repository containing several policies needs a separately reviewed entry for each artifact, with the exact path and hash recorded. Cataloging a GitHub or Hugging Face Space artifact does not make it robotctl-installable; install commands are synthesized only for supported single-artifact Hugging Face model sources. -## Custom and existing entries +## Maintainer execution recipes -`registry/behaviors/` contains the existing, manually reviewed descriptor format. Its fields are historical publisher/curator claims, not a second package standard. Keep existing URLs stable. Prefer migrating a published Pollen package to a pointer; do not mechanically infer missing metadata from an older descriptor. +Execution recipes live in `simulation/execution_recipes.py`, not in authored policy JSON. A recipe is allowed only when the maintainer can state the runner, model, scene, start state, command schedule, duration, checks, and provenance precisely. The resolver turns a covered recipe plus resolved manifest into one concrete `ExecutionSpec`. -`pnpm new-behavior id=my-move` emits an intentionally incomplete draft with unknown runtime sections set to `null`. Save it outside `registry/behaviors/`. `pnpm preflight ` reports missing/invalid values. Resolve them from source evidence before proposing a custom entry. A default walk slot, action scale, normalizer flag, or simulated terrain is never evidence. +The runner accepts only a valid `ExecutionSpec`. A source without a recipe, an incomplete manifest, or an unsupported environment produces visible `not-covered` evidence; it is not coerced into a generic command or alternate runner. ONNX shape inspection is package evidence, not a behavior simulation, and a registry diagnostic is not hardware verification. -An explicit legacy simulation recipe remains a maintainer-owned diagnostic. Unsupported objects, scenes, command encodings, or actuator physics must be described honestly. See [simulation/README.md](simulation/README.md). +See [simulation/README.md](simulation/README.md) for the runner contract. ## Evidence and media -Author media is welcome, including bespoke scenes and hardware clips; link to the publisher's HTTPS media. It remains separate from registry evidence. Existing cached author media is retained for continuity. +Author media may show bespoke environments or hardware, but remains publisher material. Registry evidence is produced by trusted CI, binds the exact source artifact to execution-relevant inputs, and is archived as a content-addressed Release blob named `.tar.gz`. -CI runs registry diagnostics when their execution identity is not already represented by trusted durable evidence, publishes matching reports and renders into the static build, and archives main-branch outputs in a content-addressed GitHub Release. Contributors never commit generated videos. Reports bind the policy hash to execution-relevant inputs only (source revision, manifest/artifact hashes, maintainer recipe, simulator code, asset lock, dependency pins, environment contract). Curation-only edits such as tags or summaries do not rerun simulation. Changing execution inputs invalidates earlier display evidence. A failed measured check remains visible as failed. Package inspection (ONNX shape/smoke), registry simulation (pinned runner + recipe), publisher facts, and hardware claims are independent axes. No diagnostic establishes hardware verification. +The execution identity v3 includes the immutable source, the resolved manifest fields used by the recipe, that entry's recipe, the executable runner code, asset/dependency locks, and the environment contract. Curation-only edits do not invalidate evidence. Changing one entry's source or recipe invalidates that entry's evidence only. Failed diagnostics remain visible as failed; uncovered diagnostics remain visible as not-covered. ## Repository setup -The URL bot requires the repository label `policy-submission` and Actions to be allowed to create pull requests (repository Settings → Actions → General). Create the label once with `gh label create policy-submission --repo ob1-s/uduck-registry --color 0E8A16 --description 'Policy URL submissions processed by the registry bot'`. It uses `GITHUB_TOKEN`; no PAT or external storage credentials are needed. The existing Cloudflare deployment secrets remain the deployment mechanism. New workflows take effect after this change reaches the default branch. +The URL bot requires the `policy-submission` label and Actions permission to create pull requests. Create the label once with: + +```sh +gh label create policy-submission --repo ob1-s/uduck-registry --color 0E8A16 --description 'Policy URL submissions processed by the registry bot' +``` -See [research/registry-direction.md](research/registry-direction.md) for responsibilities, upstream findings, and the branch reconciliation. +The bot uses `GITHUB_TOKEN`; no contributor storage credentials are needed. See [AGENTS.md](AGENTS.md) for repository invariants. diff --git a/NOTICE b/NOTICE index f8b5582..7b455d6 100644 --- a/NOTICE +++ b/NOTICE @@ -7,6 +7,6 @@ Microduck and its upstream robot materials are Pollen Robotics work. This repository links to upstream materials and does not relicense them. Follow the terms published by each upstream project when using them. -Each behavior descriptor declares the license for its policy artifact. Third- -party policies and media remain under their authors' licenses; this repository -does not relicense them. +Where an upstream license is known, authored policy records preserve it. +Third-party policies and media remain under their authors' licenses; this +repository does not relicense them or infer a missing license. diff --git a/README.md b/README.md index 2a02d9f..0c8ac53 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,43 @@ # 🦆 uDuck Registry -An independent community library of [Microduck](https://github.com/pollen-robotics/microduck) policies. +An independent community catalog of [Microduck](https://github.com/pollen-robotics/microduck) policy artifacts. [Browse the library](https://uduckmoves.com) · [Submit a policy URL](https://github.com/ob1-s/uduck-registry/issues/new?template=register-policy.yml) · [Contributing](CONTRIBUTING.md) -Pollen owns the policy package, publisher, installation commands, and robot runtime. Hugging Face hosts the artifacts. uDuck adds discovery, curation, pinned source identity, and independent diagnostic evidence. +Pollen owns the policy package, publisher, installation commands, and robot runtime. Hugging Face and GitHub host upstream artifacts. uDuck adds discovery, curation, immutable source identity, and independent diagnostic evidence. -## Contribute +## One registry boundary -Publish with Pollen, submit your Hugging Face repository URL, and the bot prepares a pinned pointer PR. It reads the manifest and inspects the ONNX without asking you to restate runtime metadata. Maintainers review the result. Custom and legacy sources have a manual review path. +Every authored entry is a `registry/policies/.json` file containing one immutable upstream ONNX artifact and editorial curation. Preparation resolves the source and manifest, the maintainer recipe layer creates an `ExecutionSpec` when coverage is possible, and the website consumes one public `CatalogEntry` shape. -See [CONTRIBUTING.md](CONTRIBUTING.md) for the form, agent command, and local workflow. +Missing runtime facts stay unknown. A package inspection proves only that the pinned ONNX has the expected interface and finite zero-input output. A registry diagnostic measures the stated runner; neither is hardware verification or a reproduction of arbitrary publisher evaluation. -## Read the evidence +## Catalog -- **Publisher media and eval:** the author's demonstration or claim, including bespoke environments. -- **Package inspection:** pinned manifest and artifact hashes, ONNX interface, finite-output smoke check. -- **Registry simulation:** measured checks in our stated diagnostic runner, with exact inputs. A completed video is not necessarily a passed check. -- **Hardware:** a separate evidence axis. Upstream origin alone does not establish independent registry verification. - -The existing catalog retains legacy publisher/curator descriptors. New Pollen packages use small pointers in `registry/policies/`; resolved facts are generated from upstream. Missing facts remain unknown. +The live catalog is generated from the authored policies and served at [uduckmoves.com](https://uduckmoves.com). Machine consumers can use the generated [`registry.json`](https://uduckmoves.com/registry.json) index. ## Develop ```sh pnpm install -# If the catalog contains Pollen pointers, first set up Python as in CONTRIBUTING.md. +python3 -m venv .venv +.venv/bin/pip install -r scripts/policy/requirements.txt +export UDUCK_PYTHON="$PWD/.venv/bin/python" pnpm policies:prepare -pnpm check +pnpm validate +pnpm test +pnpm compile pnpm dev ``` -`pnpm validate` checks authored state offline. `pnpm policies:prepare` fetches pinned upstream facts and checks ONNX. `pnpm build` compiles the indexes and exports the site. Generated indexes and simulation media are not committed. +`pnpm validate` checks authored policy data offline. `pnpm policies:prepare` fetches and verifies the pinned upstream artifacts, reads manifests, and inspects ONNX interfaces. `pnpm compile` emits `public/registry.json`; generated facts, indexes, and simulation media are build outputs. -CI reruns diagnostics for the static deployment and archives main-branch evidence in GitHub Releases. See [simulation/README.md](simulation/README.md) and [registry direction](research/registry-direction.md). +## Machine interfaces -The v2 registry index keeps legacy `behaviors` and new `policies` pointers in separate arrays; `count` covers both. Resolved package facts are available from `/policies.json`. +- [`/registry.json`](https://uduckmoves.com/registry.json) — the generated `RegistryIndex` with `entries` only. +- [`/api/behaviors/`](https://uduckmoves.com/api/behaviors/flamingo-cycle) — one `CatalogEntry` for a catalog item. +- [`/llms.txt`](https://uduckmoves.com/llms.txt) — compact machine-oriented guidance. -Machine interfaces: [`/policies.json`](https://uduckmoves.com/policies.json), [`/registry.json`](https://uduckmoves.com/registry.json), [`/llms.txt`](https://uduckmoves.com/llms.txt). +Product URLs remain `/behaviors/`. -Apache-2.0; policy licenses remain those declared by their publishers. +Apache-2.0; upstream policy artifacts and media remain under the licenses declared by their authors. diff --git a/package.json b/package.json index 088c6bb..ea5bbbb 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,7 @@ "start": "python3 -m http.server 3000 --directory out", "test": "vitest run", "check": "pnpm validate && pnpm test && pnpm build", - "new-behavior": "tsx scripts/new-behavior.ts", "validate": "tsx scripts/validate-registry.ts", - "preflight": "tsx scripts/preflight.ts", "compile": "tsx scripts/generate-registry-index.ts", "uduck": "tsx scripts/uduck.ts", "policies:prepare": "tsx scripts/uduck.ts prepare" diff --git a/public/llms.txt b/public/llms.txt index 670d234..b5cd703 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,18 +1,19 @@ # uDuck Registry Independent Microduck policy discovery, curation, and diagnostic evidence. -Pollen owns package format, publisher, robotctl install, and robot runtime. +Pollen owns package format, publishing, robotctl installation, and robot runtime. +Catalog: https://uduckmoves.com/registry.json +Entry API: https://uduckmoves.com/api/behaviors/ +Human pages: https://uduckmoves.com/behaviors/ Submit: https://github.com/ob1-s/uduck-registry/issues/new?template=register-policy.yml Instructions: https://github.com/ob1-s/uduck-registry/blob/main/CONTRIBUTING.md Pollen manifest: https://github.com/pollen-robotics/microduck/blob/main/docs/policy-manifest.md -Local: pnpm uduck resolve ; pnpm uduck register -Authored pointers: registry/policies/*.json -Legacy custom descriptors: registry/behaviors/*.json -Generated Pollen index: /policies.json -Legacy index: /registry.json +Authored source: registry/policies/.json +Public model: CatalogEntry, inside RegistryIndex.entries +Evidence archive: GitHub Release `registry-evidence`, with content-addressed `.tar.gz` assets Never guess runtime fields or activation commands from prose. -Never treat author eval/media, shape checks, and registry simulation as equivalent. -Never infer hardware verification from publisher identity. -Do not commit generated indexes or simulation media. +Never treat author eval/media, package inspection, registry diagnostics, and hardware claims as equivalent. +Do not infer hardware verification from publisher identity. +Do not commit generated indexes, resolved facts, or diagnostic media. diff --git a/public/media/max-height-jump/preview-4x.mp4 b/public/media/max-height-jump/preview-4x.mp4 deleted file mode 100644 index 76f1e36..0000000 Binary files a/public/media/max-height-jump/preview-4x.mp4 and /dev/null differ diff --git a/public/media/max-height-jump/preview-loop.mp4 b/public/media/max-height-jump/preview-loop.mp4 deleted file mode 100644 index d70abec..0000000 Binary files a/public/media/max-height-jump/preview-loop.mp4 and /dev/null differ diff --git a/public/media/remote-cache/README.md b/public/media/remote-cache/README.md deleted file mode 100644 index a9bf5db..0000000 --- a/public/media/remote-cache/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# Remote media cache - -Existing mirrored author media retained for continuity. New submissions link to -publisher-hosted media. Serving a local cache does not guarantee reachability -in any particular country. - -Files are byte-identical copies of the upstream artifacts. Upstream links and -licensing live in each behavior descriptor under `registry/behaviors/` -(`sources` section). Behavior → upstream mapping: - -| File | Upstream | -| -------------------- | --------------------------------------------------------------------------- | -| `courier.mp4` | selinayfilizp/microduck-courier @ `2cd9da8d1` `artifacts/courier-policy-track.mp4` | -| `courier.gif` | selinayfilizp/microduck-courier @ `2cd9da8d1` `artifacts/courier-policy.gif` | -| `flamingo-cycle.mp4` | RemiFabre/microduck-flamingo-cycle @ `6646428` `media/preview.mp4` | -| `rough-walk-e.mp4` | RemiFabre/microduck-rough-walk-e @ `fa7b27ee` `media/preview.mp4` | -| `rough-walk-g.mp4` | RemiFabre/microduck-rough-walk-g @ `242876a0` `media/preview.mp4` | -| `running.mp4` | HannesVonEssen/microduck-running @ `d839a07c` `media/preview.mp4` | - -If you are an upstream author and want your media removed or updated, open an -issue or PR — the canonical artifacts remain on your repo; these are caches, -not forks. diff --git a/public/media/remote-cache/courier.gif b/public/media/remote-cache/courier.gif deleted file mode 100644 index d44f2b9..0000000 Binary files a/public/media/remote-cache/courier.gif and /dev/null differ diff --git a/public/media/remote-cache/courier.mp4 b/public/media/remote-cache/courier.mp4 deleted file mode 100644 index 5d2096f..0000000 Binary files a/public/media/remote-cache/courier.mp4 and /dev/null differ diff --git a/public/media/remote-cache/flamingo-cycle.mp4 b/public/media/remote-cache/flamingo-cycle.mp4 deleted file mode 100644 index a82f37b..0000000 Binary files a/public/media/remote-cache/flamingo-cycle.mp4 and /dev/null differ diff --git a/public/media/remote-cache/rough-walk-e.mp4 b/public/media/remote-cache/rough-walk-e.mp4 deleted file mode 100644 index 1332501..0000000 Binary files a/public/media/remote-cache/rough-walk-e.mp4 and /dev/null differ diff --git a/public/media/remote-cache/rough-walk-g.mp4 b/public/media/remote-cache/rough-walk-g.mp4 deleted file mode 100644 index 9d3f4d7..0000000 Binary files a/public/media/remote-cache/rough-walk-g.mp4 and /dev/null differ diff --git a/public/media/remote-cache/running.mp4 b/public/media/remote-cache/running.mp4 deleted file mode 100644 index cf9fe1e..0000000 Binary files a/public/media/remote-cache/running.mp4 and /dev/null differ diff --git a/registry/behaviors/alpha-walking.json b/registry/behaviors/alpha-walking.json deleted file mode 100644 index 561ee7e..0000000 --- a/registry/behaviors/alpha-walking.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "id": "alpha-walking", - "name": "Alpha Dynamic Walk", - "version": "1.0.0", - "description": "Standard bipedal walking gait with 50 Hz velocity tracking (vx, vy, yaw_rate) and active head-pose orientation control.", - "details": "The flagship locomotion policy for Microduck. Trained in MuJoCo Warp via mjlab and PPO using the BAM M6 actuator model for Dynamixel XL330 servos. Includes domain randomization on battery voltage, friction, and motor command delay. Exports a baked observation normalizer into the ONNX computational graph.", - "category": "locomotion", - "tags": [ - "official", - "bipedal", - "velocity-tracking", - "head-control", - "50hz", - "flat-ground" - ], - "authors": [ - { - "name": "Pollen Robotics", - "affiliation": "Pollen Robotics / Hugging Face", - "github": "pollen-robotics", - "url": "https://pollen-robotics.com" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "claimed_hardware", - "summary": "Upstream-provided behavior; not independently hardware-verified by uDuck. Verified on physical Microduck hardware with Rockchip RK3566 and 14 Dynamixel XL330 servos. Shipped as default walking policy.", - "hardware_target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077, 2S LiPo)", - "notes": "Operates at 50 Hz control loop. Velocity envelope: -0.2 to +0.25 m/s forward/back, \u00b11.0 rad/s angular yaw rate." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat" - ], - "robotd_slot": "walk" - }, - "simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "velocity", - "duration_s": 6, - "checks": [ - "no_fall", - "ends_upright", - "velocity_tracking" - ], - "segments": [ - { - "duration_s": 1, - "vx": 0, - "vy": 0, - "wz": 0 - }, - { - "duration_s": 3, - "vx": 0.25, - "vy": 0, - "wz": 0 - }, - { - "duration_s": 2, - "vx": 0.25, - "vy": 0, - "wz": 0.5 - } - ] - }, - "artifacts": { - "onnx": { - "filename": "BEST_alpha_walking.onnx", - "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/BEST_alpha_walking.onnx", - "baked_normalizer": true - } - }, - "media": { - "video_url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/walk.webm", - "hero_type": "video", - "caption": "Microduck walking under gamepad velocity control on hardware", - "thumbnail_url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/posters/walk.png" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl", - "task_id": "Mjlab-Velocity-Flat-MicroDuck", - "huggingface_space": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator" - }, - "deployment": { - "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/BEST_alpha_walking.onnx\"" - } -} diff --git a/registry/behaviors/ball-kick-left.json b/registry/behaviors/ball-kick-left.json deleted file mode 100644 index b80d8d6..0000000 --- a/registry/behaviors/ball-kick-left.json +++ /dev/null @@ -1,95 +0,0 @@ -{ - "id": "ball-kick-left", - "name": "Impulse Ball Kick (Left Foot)", - "version": "1.0.0", - "description": "One-shot dynamic kick with the left foot designed to strike a 70mm / 15g ball forward while maintaining single-leg biped balance.", - "details": "Actor is ball-blind (the operator or higher-level vision aims the robot). The runtime hot-swaps this policy for a 0.5-second execution window with command channels zeroed. Runs at standing gain tuning to maintain stance leg stability while the swing leg delivers an impulse strike.", - "category": "manipulation", - "tags": [ - "official", - "kick", - "soccer", - "impulse", - "single-leg-balance", - "50hz" - ], - "authors": [ - { - "name": "Pollen Robotics", - "affiliation": "Pollen Robotics / Hugging Face", - "github": "pollen-robotics", - "url": "https://pollen-robotics.com" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "claimed_hardware", - "summary": "Upstream-provided behavior; not independently hardware-verified by uDuck. Verified on physical Microduck hardware kicking a standard 70 mm lightweight practice ball.", - "hardware_target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077)", - "notes": "Targeted for 70mm diameter, 15g mass spheres." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [ - "70mm_practice_ball" - ], - "terrain": [ - "flat" - ], - "robotd_slot": "kick_left" - }, - "simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "oneshot_zero", - "duration_s": 2.5, - "checks": [ - "no_fall", - "ends_upright" - ] - }, - "artifacts": { - "onnx": { - "filename": "ball_kick_left.onnx", - "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/ball_kick_left.onnx", - "baked_normalizer": true - } - }, - "media": { - "video_url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/kickL.webm", - "hero_type": "video", - "caption": "Official Microduck left-foot kick policy preview in simulation", - "thumbnail_url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/posters/kickL.png" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl", - "task_id": "Mjlab-BallKick-Flat-MicroDuck", - "huggingface_space": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator" - }, - "deployment": { - "robotd_toml": "[policy]\nkick_left = \"/opt/robot/policies/ball_kick_left.onnx\"" - } -} diff --git a/registry/behaviors/ball-kick-right.json b/registry/behaviors/ball-kick-right.json deleted file mode 100644 index 2d64b5d..0000000 --- a/registry/behaviors/ball-kick-right.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "id": "ball-kick-right", - "name": "Impulse Ball Kick (Right Foot)", - "version": "1.0.0", - "description": "One-shot dynamic kick with the right foot designed to strike a 70mm / 15g ball forward while maintaining single-leg biped balance.", - "details": "Actor is ball-blind (the operator or higher-level vision aims the robot). The runtime hot-swaps this policy for a 0.5-second execution window with command channels zeroed. Runs at standing gain tuning to maintain stance leg stability while the right swing leg delivers an impulse strike.", - "category": "manipulation", - "tags": [ - "official", - "kick", - "soccer", - "impulse", - "single-leg-balance", - "50hz" - ], - "authors": [ - { - "name": "Pollen Robotics", - "affiliation": "Pollen Robotics / Hugging Face", - "github": "pollen-robotics", - "url": "https://pollen-robotics.com" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "claimed_hardware", - "summary": "Upstream-provided behavior; not independently hardware-verified by uDuck. Verified on physical Microduck hardware kicking a standard 70 mm lightweight practice ball.", - "hardware_target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077)", - "notes": "Targeted for 70mm diameter, 15g mass spheres." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [ - "70mm_practice_ball" - ], - "terrain": [ - "flat" - ], - "robotd_slot": "kick_right" - }, - "simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "oneshot_zero", - "duration_s": 2.5, - "checks": [ - "no_fall", - "ends_upright" - ] - }, - "artifacts": { - "onnx": { - "filename": "ball_kick_right.onnx", - "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/ball_kick_right.onnx", - "baked_normalizer": true - } - }, - "media": { - "hero_type": "badge", - "caption": "Microduck right foot impulse kick striking a mini soccer ball" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl", - "task_id": "Mjlab-BallKick-Flat-MicroDuck", - "huggingface_space": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator" - }, - "deployment": { - "robotd_toml": "[policy]\nkick_right = \"/opt/robot/policies/ball_kick_right.onnx\"" - } -} diff --git a/registry/behaviors/courier.json b/registry/behaviors/courier.json deleted file mode 100644 index ab729d3..0000000 --- a/registry/behaviors/courier.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "id": "courier", - "name": "Microduck Courier", - "version": "1.0.0", - "description": "Pick, carry, and place a duck-scale paperback in a simulated Microduck apartment.", - "details": "Custom Mjlab-Courier-Flat-Microduck task with an apartment scene, book, seated reader, and one ONNX export. The source reports 16/16 and 32/32 successful simulated delivery runs. The task-specific command slots carry book and reader targets; no physical robot deployment is documented.", - "category": "manipulation", - "tags": [ - "community", - "courier", - "pick-carry-place", - "apartment", - "simulation-only", - "50hz" - ], - "authors": [ - { - "name": "selinayfilizp", - "github": "selinayfilizp", - "url": "https://github.com/selinayfilizp" - } - ], - "license": "Not separately specified", - "verification": { - "status": "community_experimental", - "summary": "Successful simulated courier runs are documented in the source; no physical hardware evidence is listed.", - "hardware_target": "Microduck v1 (14 Dynamixel XL330 servos)", - "notes": "The source repository includes the Apache-2.0 microduck_rl component but no separate top-level artifact license. No physical robot deployment is claimed." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1.0 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat" - ], - "robotd_slot": "custom" - }, - "simulation": { - "runner": "external", - "reason": "custom_environment", - "notes": "Uses a publisher-specific apartment, objects, and task command semantics that the registry runner does not reproduce." - }, - "artifacts": { - "onnx": { - "filename": "courier-policy.onnx", - "url": "https://raw.githubusercontent.com/selinayfilizp/microduck-courier/ba02a6b507b13c19bded8af0cbb2dd4a1eeb5cb1/artifacts/courier-policy.onnx", - "baked_normalizer": true - } - }, - "media": { - "video_url": "/media/remote-cache/courier.mp4", - "hero_type": "video", - "caption": "Tracked simulation rollout of the community Microduck courier policy", - "thumbnail_url": "/media/remote-cache/courier.gif" - }, - "sources": { - "upstream_repo": "https://github.com/selinayfilizp/microduck-courier", - "training_code_url": "https://github.com/selinayfilizp/microduck-courier/tree/ba02a6b507b13c19bded8af0cbb2dd4a1eeb5cb1/microduck_rl", - "task_id": "Mjlab-Courier-Flat-MicroDuck" - }, - "deployment": { - "robotd_toml": "[policy]\ncustom = \"/opt/robot/policies/courier-policy.onnx\"" - } -} diff --git a/registry/behaviors/fall-recovery.json b/registry/behaviors/fall-recovery.json deleted file mode 100644 index 188919d..0000000 --- a/registry/behaviors/fall-recovery.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "id": "fall-recovery", - "name": "Dynamic Fall Recovery", - "version": "1.0.0", - "description": "Automatic fall recovery policy capable of restoring Microduck to standing posture from face-down, face-up, or sitting positions.", - "details": "Triggered automatically when the onboard IMU's projected gravity vector exceeds tilt thresholds (or commanded via gamepad recovery button). The policy executes robust arm-less biped pushups, righting the duck without human intervention before handing off control back to the standing/walking state machine.", - "category": "recovery", - "tags": [ - "official", - "fall-recovery", - "getup", - "self-righting", - "50hz", - "safety" - ], - "authors": [ - { - "name": "Pollen Robotics", - "affiliation": "Pollen Robotics / Hugging Face", - "github": "pollen-robotics", - "url": "https://pollen-robotics.com" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "claimed_hardware", - "summary": "Upstream-provided behavior; not independently hardware-verified by uDuck. Verified on physical Microduck hardware with fall detection triggers enabled. Robot self-rights after tip-over.", - "hardware_target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077, 2S LiPo)", - "notes": "Requires full-body contact handling." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat", - "rough" - ], - "robotd_slot": "stand" - }, - "simulation": { - "runner": "external", - "reason": "custom_environment", - "notes": "Recovery starts from publisher-specific fall states and full-body contacts that the registry runner does not reproduce." - }, - "artifacts": { - "onnx": { - "filename": "BEST_alpha_stand.onnx", - "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/BEST_alpha_stand.onnx", - "baked_normalizer": true - } - }, - "media": { - "video_url": "https://pollen-robotics.com/assets/microduck/gallery/balance-recovery.mp4", - "hero_type": "video", - "caption": "Microduck pushed over on carpet recovering to standing posture", - "thumbnail_url": "https://pollen-robotics.com/assets/microduck/gallery/balance-recovery-poster.jpg" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl", - "task_id": "Mjlab-StandUp-Flat-MicroDuck", - "huggingface_space": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator" - }, - "deployment": { - "robotd_toml": "[policy]\nstand = \"/opt/robot/policies/BEST_alpha_stand.onnx\"" - } -} diff --git a/registry/behaviors/genesis-backlash.json b/registry/behaviors/genesis-backlash.json deleted file mode 100644 index bd35b7b..0000000 --- a/registry/behaviors/genesis-backlash.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "id": "genesis-backlash", - "name": "Genesis Backlash Walk", - "version": "1.0.0", - "description": "Walking policy fine-tuned with simulated ±1° gearbox play on every Microduck servo in Genesis.", - "details": "Macmachi's Genesis port resumes flat walking with ±1° of backlash per servo. The standalone ONNX includes the observation normalizer. No physical robot test is documented.", - "category": "locomotion", - "tags": [ - "community", - "genesis", - "backlash", - "sim2real", - "50hz" - ], - "authors": [ - { - "name": "Macmachi", - "github": "Macmachi", - "url": "https://github.com/Macmachi" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "community_experimental", - "summary": "Simulation-only backlash export from a community Genesis training port; no physical Microduck test is documented.", - "hardware_target": "Microduck v1 (14 Dynamixel XL330 servos)", - "notes": "No physical robot test is documented. This ONNX export declares a dynamic batch axis but preserves the 61-D input and 14-D output contract." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law; ±1° simulated backlash)", - "action_scale": 1.0 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat" - ], - "robotd_slot": "walk" - }, - "simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "velocity", - "duration_s": 6, - "checks": [ - "no_fall", - "ends_upright", - "velocity_tracking" - ], - "segments": [ - { - "duration_s": 1, - "vx": 0, - "vy": 0, - "wz": 0 - }, - { - "duration_s": 3, - "vx": 0.25, - "vy": 0, - "wz": 0 - }, - { - "duration_s": 2, - "vx": 0.25, - "vy": 0, - "wz": 0.5 - } - ] - }, - "artifacts": { - "onnx": { - "filename": "backlash.onnx", - "url": "https://raw.githubusercontent.com/Macmachi/microduck-rl-genesis/9fa4b270023b8b9b50809fa6dc15a28996f5c724/policies/backlash.onnx", - "baked_normalizer": true - } - }, - "media": { - "hero_type": "badge", - "caption": "Community Genesis backlash-tolerant walking policy" - }, - "sources": { - "upstream_repo": "https://github.com/Macmachi/microduck-rl-genesis", - "training_code_url": "https://github.com/Macmachi/microduck-rl-genesis", - "task_id": "microduck-backlash" - }, - "deployment": { - "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/backlash.onnx\"" - } -} diff --git a/registry/behaviors/genesis-rough.json b/registry/behaviors/genesis-rough.json deleted file mode 100644 index f8261b1..0000000 --- a/registry/behaviors/genesis-rough.json +++ /dev/null @@ -1,80 +0,0 @@ -{ - "id": "genesis-rough", - "name": "Genesis Rough-Terrain Walk", - "version": "1.0.0", - "description": "Velocity-tracking walking policy fine-tuned for rough terrain in a Genesis port of the Microduck environment.", - "details": "Macmachi's Genesis port resumes the flat walking run for rough-terrain fine-tuning. The standalone ONNX includes the observation normalizer. No physical robot test is documented.", - "category": "locomotion", - "tags": [ - "community", - "genesis", - "rough-terrain", - "velocity-tracking", - "50hz" - ], - "authors": [ - { - "name": "Macmachi", - "github": "Macmachi", - "url": "https://github.com/Macmachi" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "community_experimental", - "summary": "Simulation-only rough-terrain export from a community Genesis training port; no physical Microduck test is documented.", - "hardware_target": "Microduck v1 (14 Dynamixel XL330 servos)", - "notes": "No physical robot test is documented. This ONNX export declares a dynamic batch axis but preserves the 61-D input and 14-D output contract." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1.0 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "rough" - ], - "robotd_slot": "walk" - }, - "simulation": { - "runner": "external", - "reason": "custom_environment", - "notes": "Trained for rough terrain in a Genesis environment; the registry runner currently owns only a flat scene." - }, - "artifacts": { - "onnx": { - "filename": "rough.onnx", - "url": "https://raw.githubusercontent.com/Macmachi/microduck-rl-genesis/9fa4b270023b8b9b50809fa6dc15a28996f5c724/policies/rough.onnx", - "baked_normalizer": true - } - }, - "media": { - "hero_type": "badge", - "caption": "Community Genesis rough-terrain walking policy" - }, - "sources": { - "upstream_repo": "https://github.com/Macmachi/microduck-rl-genesis", - "training_code_url": "https://github.com/Macmachi/microduck-rl-genesis", - "task_id": "microduck-rough" - }, - "deployment": { - "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/rough.onnx\"" - } -} diff --git a/registry/behaviors/genesis-velocity.json b/registry/behaviors/genesis-velocity.json deleted file mode 100644 index 4f8fc96..0000000 --- a/registry/behaviors/genesis-velocity.json +++ /dev/null @@ -1,109 +0,0 @@ -{ - "id": "genesis-velocity", - "name": "Genesis Flat Walk", - "version": "1.0.0", - "description": "Velocity-tracking walking policy trained in a Genesis port of the Microduck environment for flat terrain.", - "details": "Macmachi's Genesis port of Pollen's Microduck walking task, with this export trained on flat terrain. The standalone ONNX includes the observation normalizer. No physical robot test is documented.", - "category": "locomotion", - "tags": [ - "community", - "genesis", - "velocity-tracking", - "flat-ground", - "50hz" - ], - "authors": [ - { - "name": "Macmachi", - "github": "Macmachi", - "url": "https://github.com/Macmachi" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "community_experimental", - "summary": "Simulation-only export from a community Genesis training port; no physical Microduck test is documented.", - "hardware_target": "Microduck v1 (14 Dynamixel XL330 servos)", - "notes": "No physical robot test is documented. This ONNX export declares a dynamic batch axis but preserves the 61-D input and 14-D output contract." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1.0 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat" - ], - "robotd_slot": "walk" - }, - "simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "velocity", - "duration_s": 6, - "checks": [ - "no_fall", - "ends_upright", - "velocity_tracking" - ], - "segments": [ - { - "duration_s": 1, - "vx": 0, - "vy": 0, - "wz": 0 - }, - { - "duration_s": 3, - "vx": 0.25, - "vy": 0, - "wz": 0 - }, - { - "duration_s": 2, - "vx": 0.25, - "vy": 0, - "wz": 0.5 - } - ] - }, - "artifacts": { - "onnx": { - "filename": "velocity.onnx", - "url": "https://raw.githubusercontent.com/Macmachi/microduck-rl-genesis/9fa4b270023b8b9b50809fa6dc15a28996f5c724/policies/velocity.onnx", - "baked_normalizer": true - } - }, - "media": { - "hero_type": "badge", - "caption": "Community Genesis flat-terrain walking policy" - }, - "sources": { - "upstream_repo": "https://github.com/Macmachi/microduck-rl-genesis", - "training_code_url": "https://github.com/Macmachi/microduck-rl-genesis", - "task_id": "microduck-velocity" - }, - "deployment": { - "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/velocity.onnx\"" - } -} diff --git a/registry/behaviors/ground-pick.json b/registry/behaviors/ground-pick.json deleted file mode 100644 index 52bb425..0000000 --- a/registry/behaviors/ground-pick.json +++ /dev/null @@ -1,96 +0,0 @@ -{ - "id": "ground-pick", - "name": "Autonomous Ground Pick", - "version": "1.0.0", - "description": "One-shot crouching behavior that reaches beak to the floor to pick up objects with its articulated mouth gripper, then smoothly returns to stand.", - "details": "Triggered via gamepad 'A' button or RPC call. Driven by a phase parameter [cos(2pi*t), sin(2pi*t), 0] fed into the velocity command channels over a 3.5-second gesture window. Coordinates beak opening, deep leg squat, and pitch compensation so the soft beak contacts the ground reliably without tipping.", - "category": "manipulation", - "tags": [ - "official", - "peck", - "pick-and-place", - "gripper", - "mouth-beak", - "one-shot", - "50hz" - ], - "authors": [ - { - "name": "Pollen Robotics", - "affiliation": "Pollen Robotics / Hugging Face", - "github": "pollen-robotics", - "url": "https://pollen-robotics.com" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "claimed_hardware", - "summary": "Upstream-provided behavior; not independently hardware-verified by uDuck. Verified on physical Microduck hardware picking up objects from carpet and hard flooring with the beak gripper.", - "hardware_target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077, articulated beak)", - "notes": "Executes on 3.5s phase clock (phase advances 1/5.0 per sec, ends at 0.7 phase)." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat" - ], - "robotd_slot": "ground_pick" - }, - "simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "oneshot_phase", - "duration_s": 2.5, - "checks": [ - "no_fall", - "ends_upright" - ], - "period_s": 4, - "end_phase": 0.7 - }, - "artifacts": { - "onnx": { - "filename": "alpha_ground_pick.onnx", - "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/alpha_ground_pick.onnx", - "baked_normalizer": true - } - }, - "media": { - "video_url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/grab.webm", - "hero_type": "video", - "caption": "Microduck picking up small object from the floor with its beak", - "thumbnail_url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/posters/grab.png" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl", - "task_id": "Mjlab-GroundPick-Flat-MicroDuck", - "huggingface_space": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator" - }, - "deployment": { - "robotd_toml": "[policy]\nground_pick = \"/opt/robot/policies/alpha_ground_pick.onnx\"" - } -} diff --git a/registry/behaviors/jump.json b/registry/behaviors/jump.json deleted file mode 100644 index b19cfb5..0000000 --- a/registry/behaviors/jump.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "id": "jump", - "name": "Vertical Jump", - "version": "1.0.0", - "description": "One-shot vertical jump policy that crouches, launches, lands, and returns to a standing pose.", - "details": "Custom policy from a browser simulator fork. The fork runs Microduck in MuJoCo WebAssembly and includes Pollen's official policies; this entry covers only its custom jump.onnx. It is a simulation/demo artifact with no physical deployment evidence.", - "category": "agility-tricks", - "tags": [ - "community", - "jump", - "vertical-hop", - "simulation-only", - "50hz" - ], - "authors": [ - { - "name": "Liyucheng1997", - "github": "Liyucheng1997", - "url": "https://github.com/Liyucheng1997" - } - ], - "license": "Not provided", - "verification": { - "status": "community_experimental", - "summary": "Custom policy shown in a browser Microduck simulator; no physical hardware evidence reported.", - "hardware_target": "Microduck v1 (14 Dynamixel XL330 servos)", - "notes": "The source repository is a simulator fork and includes Pollen's official policies; this descriptor indexes only its custom jump.onnx. The repository does not include a license file." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1.0 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat" - ], - "robotd_slot": "custom" - }, - "simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "oneshot_trigger", - "duration_s": 4, - "trigger_s": 0.2 - }, - "artifacts": { - "onnx": { - "filename": "jump.onnx", - "url": "https://raw.githubusercontent.com/Liyucheng1997/318_lab-microduck-simulator/512d4bec6fc3ba321d29c93312be72856ad21268/app/public/policies/jump.onnx", - "baked_normalizer": true - } - }, - "media": { - "hero_type": "badge", - "caption": "Community vertical-jump policy in the Microduck browser simulator" - }, - "sources": { - "upstream_repo": "https://github.com/Liyucheng1997/318_lab-microduck-simulator", - "training_code_url": "https://github.com/Liyucheng1997/318_lab-microduck-simulator/tree/512d4bec6fc3ba321d29c93312be72856ad21268/training", - "task_id": "Mjlab-Jump-Flat-MicroDuck" - }, - "deployment": { - "robotd_toml": "[policy]\ncustom = \"/opt/robot/policies/jump.onnx\"" - } -} diff --git a/registry/behaviors/max-height-jump.json b/registry/behaviors/max-height-jump.json deleted file mode 100644 index 07c4fbe..0000000 --- a/registry/behaviors/max-height-jump.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "id": "max-height-jump", - "name": "Maximum-Height Jump", - "version": "1.0.0", - "description": "Height-optimized one-shot vertical hop with an optional standing-policy handoff for a quiet reset.", - "details": "Trained with PPO in mjlab using twist-vx as a binary one-shot launch request: 1 requests launch and 0 requests settling. Height measures qualified whole-body visible rise after bilateral takeoff rather than toe extension. The canonical ONNX contains only the jump controller. In direct ONNX Runtime evaluation it crossed the stable-landing threshold with 40 ms of recovery margin but ended with a rotated head and narrow stance, so durable_landing remained false. The clean reset shown in the preview additionally evaluates a compatible 61D standing policy and smoothstep-blends the 14 actions beginning 0.22 s after trigger over 0.14 s.", - "category": "agility-tricks", - "tags": [ - "community", - "jump", - "vertical-hop", - "maximum-height", - "one-shot", - "policy-handoff", - "simulation-only", - "50hz" - ], - "authors": [ - { - "name": "Thomas Burgess", - "affiliation": "Burgess Software", - "github": "ThomasBurgess2000", - "url": "https://github.com/ThomasBurgess2000" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "community_experimental", - "summary": "The published ONNX was evaluated directly in CPU MuJoCo with qualified takeoff and touchdown; durable standing requires the documented two-policy handoff, and no physical-hardware evidence exists.", - "hardware_target": "Microduck v1 (14 Dynamixel XL330 servos)", - "notes": "Model 34995's published ONNX reached 0.628 m/s launch velocity, 31.67 mm whole-body visible rise, 31.67 mm bilateral sole clearance, and 140 ms airtime. Alone it had stable_landing=true, failed_recovery=false, durable_landing=false, settle score 0.778, 44.02 degree maximum final head-joint error, and 37.26 mm final foot spacing. The demonstrated 0.22 s / 0.14 s standing-policy handoff had stable_landing=true, failed_recovery=false, durable_landing=true, 100 ms recovery margin, no non-foot body contact, settle score 0.860, 2.87 degree final tilt, 7.27 degree maximum final head-joint error, and 81.95 mm final foot spacing. This is one seeded standard-model simulation, not a randomized battery, backlash-model result, or hardware test." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1.0 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat" - ], - "robotd_slot": "custom" - }, - "simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "oneshot_trigger", - "duration_s": 4, - "trigger_s": 0.2 - }, - "artifacts": { - "onnx": { - "filename": "max_height_jump.onnx", - "url": "https://raw.githubusercontent.com/ThomasBurgess2000/microduck-max-height-jump/7e5dc6028900f13d145e6710847378b007a675e9/policy/max_height_jump.onnx", - "baked_normalizer": true - }, - "checkpoint": { - "url": "https://raw.githubusercontent.com/ThomasBurgess2000/microduck-max-height-jump/7e5dc6028900f13d145e6710847378b007a675e9/checkpoint/model_34995.pt", - "framework": "RSL-RL PPO on mjlab/MuJoCo Warp" - }, - "config": { - "url": "https://raw.githubusercontent.com/ThomasBurgess2000/microduck-max-height-jump/7e5dc6028900f13d145e6710847378b007a675e9/training/env.yaml" - } - }, - "media": { - "loop_url": "/media/max-height-jump/preview-loop.mp4", - "video_url": "/media/max-height-jump/preview-4x.mp4", - "hero_type": "video", - "caption": "Four-times slow-motion direct-ONNX MuJoCo rollout; the quiet reset uses the separately documented standing-policy handoff." - }, - "sources": { - "upstream_repo": "https://github.com/ThomasBurgess2000/microduck-max-height-jump", - "training_code_url": "https://github.com/ThomasBurgess2000/microduck-max-height-jump/tree/7e5dc6028900f13d145e6710847378b007a675e9/training", - "task_id": "Mjlab-Jump-Flat-MicroDuck" - }, - "deployment": { - "robotd_toml": "[policy]\ncustom = \"/opt/robot/policies/max_height_jump.onnx\"\n# twist-vx: 1 requests launch; return it to 0 after touchdown.\n# The preview's durable reset also needs the companion standing ONNX and the documented 0.22 s / 0.14 s runtime blend." - } -} diff --git a/registry/behaviors/roller-crouch.json b/registry/behaviors/roller-crouch.json deleted file mode 100644 index 66d10c7..0000000 --- a/registry/behaviors/roller-crouch.json +++ /dev/null @@ -1,97 +0,0 @@ -{ - "id": "roller-crouch", - "name": "Roller Blade Crouch Glide", - "version": "1.0.0", - "description": "Aerodynamic tuck crouch maneuver while coasting on roller skates, lowering center of gravity for high-speed stability.", - "details": "A 3.5-second one-shot gesture on rollers. Driven by phase encoding [cos(2pi*t), sin(2pi*t), 0] in the command slots (matching ground-pick architecture). The duck folds its legs, lowers its center of mass near the wheel axles, and smoothly returns to an upright skating tuck.", - "category": "roller-skate", - "tags": [ - "official", - "roller-skate", - "crouch", - "glide", - "low-cg", - "aerodynamic", - "50hz" - ], - "authors": [ - { - "name": "Pollen Robotics", - "affiliation": "Pollen Robotics / Hugging Face", - "github": "pollen-robotics", - "url": "https://pollen-robotics.com" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "claimed_hardware", - "summary": "Upstream-provided behavior; not independently hardware-verified by uDuck. Verified on physical Microduck on roller wheels performing controlled crouch-glide cycles.", - "hardware_target": "Microduck v1 + Roller Skate Blades", - "notes": "Cycle duration: 3.5 seconds (period 5.0s, end phase 0.7)." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1 - }, - "compatibility": { - "robot_model": "microduck-rollers", - "accessories_required": [ - "roller_skate_blades" - ], - "terrain": [ - "flat" - ], - "robotd_slot": "ground_pick" - }, - "simulation": { - "runner": "microduck-standard-v1", - "model": "microduck-rollers", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "oneshot_phase", - "duration_s": 5, - "period_s": 5, - "end_phase": 0.6, - "checks": [ - "no_fall", - "ends_upright" - ] - }, - "artifacts": { - "onnx": { - "filename": "BEST_roller_crouch.onnx", - "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/BEST_roller_crouch.onnx", - "baked_normalizer": true - } - }, - "media": { - "hero_type": "badge", - "caption": "Microduck crouching into a low tuck while gliding on wheels" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl", - "task_id": "Mjlab-RollerCrouch-Flat-MicroDuck", - "huggingface_space": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator" - }, - "deployment": { - "robotd_toml": "[policy]\nmode = \"roller\"\nground_pick = \"/opt/robot/policies/BEST_roller_crouch.onnx\"" - } -} diff --git a/registry/behaviors/roller-drive.json b/registry/behaviors/roller-drive.json deleted file mode 100644 index 831cf8e..0000000 --- a/registry/behaviors/roller-drive.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "id": "roller-drive", - "name": "Roller Skate Velocity Drive", - "version": "1.0.0", - "description": "High-speed skating locomotion utilizing passive roller skate wheels mounted under the feet, featuring asymmetric velocity limits and turn rate damping.", - "details": "Converts Microduck into a mobile skating robot. When passive roller blade attachments are installed and D-pad up is held, robotd switches to the roller policy suite. Velocity profile allows up to +0.6 m/s forward acceleration and -0.5 m/s braking, with angular velocity clamped to 0.3 rad/s to prevent tipping.", - "category": "roller-skate", - "tags": [ - "official", - "roller-skate", - "wheels", - "high-speed", - "skating", - "50hz" - ], - "authors": [ - { - "name": "Pollen Robotics", - "affiliation": "Pollen Robotics / Hugging Face", - "github": "pollen-robotics", - "url": "https://pollen-robotics.com" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "claimed_hardware", - "summary": "Upstream-provided behavior; not independently hardware-verified by uDuck. Verified on physical Microduck equipped with official Pollen 3D-printed roller skate foot accessories.", - "hardware_target": "Microduck v1 + Roller Skate Accessories (passive bearings/wheels)", - "notes": "Requires passive roller foot frames and 22x16x4 bearings. Robot model is robot_allcollisions_rollers.xml." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1 - }, - "compatibility": { - "robot_model": "microduck-rollers", - "accessories_required": [ - "roller_skate_blades" - ], - "terrain": [ - "flat" - ], - "robotd_slot": "roller" - }, - "simulation": { - "runner": "external", - "reason": "custom_assets", - "notes": "Requires the publisher's roller-wheel model; the registry runner currently renders the standard-foot model only." - }, - "artifacts": { - "onnx": { - "filename": "BEST_roller.onnx", - "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/BEST_roller.onnx", - "baked_normalizer": true - } - }, - "media": { - "video_url": "https://pollen-robotics.com/assets/microduck/gallery/roller-skating.mp4", - "hero_type": "video", - "caption": "Microduck gliding on roller skate wheels across smooth floor", - "thumbnail_url": "https://pollen-robotics.com/assets/microduck/gallery/roller-skating-poster.jpg" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl", - "task_id": "Mjlab-Velocity-Flat-MicroDuck-Rollers", - "huggingface_space": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator" - }, - "deployment": { - "robotd_toml": "[policy]\nmode = \"roller\"\nwalk = \"/opt/robot/policies/BEST_roller.onnx\"" - } -} diff --git a/registry/behaviors/rough-walk-e.json b/registry/behaviors/rough-walk-e.json deleted file mode 100644 index 04ba0eb..0000000 --- a/registry/behaviors/rough-walk-e.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "id": "rough-walk-e", - "name": "Rough Walk E", - "version": "1.0.0", - "description": "Velocity-commanded walking fine-tuned for grids, small steps, rubble, and slopes.", - "details": "Drop-in walking replacement for alpha-walking, fine-tuned for hostile terrain while retaining the same velocity and head-command layout. The publisher reports 14 falls in 110 rough-ground trials versus 32 for alpha-walking at similar flat-ground motor power. It remains a simulation-only export and has never been tested on hardware.", - "category": "locomotion", - "tags": [ - "community", - "rough-terrain", - "velocity-tracking", - "sim2real", - "simulation-only", - "50hz" - ], - "authors": [ - { - "name": "RemiFabre", - "github": "RemiFabre", - "url": "https://huggingface.co/RemiFabre" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "community_experimental", - "summary": "Public simulation-only rough-terrain walking export; no physical Microduck deployment evidence is documented.", - "hardware_target": "Microduck v1 (14 Dynamixel XL330 servos)", - "notes": "The simulation battery includes grids, small stairs, rubble, and slopes. Known limits include descending steps of 2 cm or more and stalled progress on larger rubble bumps." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1.0 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat", - "rough", - "slope" - ], - "robotd_slot": "walk" - }, - "simulation": { - "runner": "external", - "reason": "custom_environment", - "notes": "The published behavior targets rough ground, stairs, rubble, and slopes; the registry runner currently owns only a flat scene." - }, - "artifacts": { - "onnx": { - "filename": "policy.onnx", - "url": "https://huggingface.co/RemiFabre/microduck-rough-walk-e/resolve/fa7b27eeb5610d3b351362f4bd71691ee8be3d7d/policy.onnx", - "baked_normalizer": true - } - }, - "media": { - "video_url": "/media/remote-cache/rough-walk-e.mp4", - "hero_type": "video", - "caption": "Microduck walking across rough terrain in simulation" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck_rl", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl/commit/6cd45fc7a865299f118f7671142465d377853928", - "task_id": "Mjlab-Hostile-FinetuneFeetProgress-MicroDuck" - }, - "deployment": { - "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/rough-walk-e/policy.onnx\"" - } -} diff --git a/registry/behaviors/rough-walk-g.json b/registry/behaviors/rough-walk-g.json deleted file mode 100644 index 7256876..0000000 --- a/registry/behaviors/rough-walk-g.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "id": "rough-walk-g", - "name": "Rough Walk G", - "version": "1.0.0", - "description": "Velocity-commanded walking trained from scratch for small stairs, rubble, and slopes.", - "details": "A from-scratch rough-terrain gait from the same policy family as rough-walk-e. The publisher reports 12 falls in 110 synchronized trials and survival across both 2 cm stairs and a 9-degree slope, with 17 percent higher flat-ground motor power than alpha-walking. It is simulation-only and has never been tested on hardware.", - "category": "locomotion", - "tags": [ - "community", - "rough-terrain", - "velocity-tracking", - "from-scratch", - "simulation-only", - "50hz" - ], - "authors": [ - { - "name": "RemiFabre", - "github": "RemiFabre", - "url": "https://huggingface.co/RemiFabre" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "community_experimental", - "summary": "Public simulation-only rough-terrain walking export; no physical Microduck deployment evidence is documented.", - "hardware_target": "Microduck v1 (14 Dynamixel XL330 servos)", - "notes": "The publisher reports higher motor power and weak turn response compared with alpha-walking. Watch actuator temperature during any future hardware testing." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1.0 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat", - "rough", - "slope" - ], - "robotd_slot": "walk" - }, - "simulation": { - "runner": "external", - "reason": "custom_environment", - "notes": "The published behavior targets rough ground, stairs, rubble, and slopes; the registry runner currently owns only a flat scene." - }, - "artifacts": { - "onnx": { - "filename": "policy.onnx", - "url": "https://huggingface.co/RemiFabre/microduck-rough-walk-g/resolve/242876a0aa8b40b702142fb0a5677fd43bc88a4c/policy.onnx", - "baked_normalizer": true - } - }, - "media": { - "video_url": "/media/remote-cache/rough-walk-g.mp4", - "hero_type": "video", - "caption": "Microduck walking across rough terrain in simulation" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck_rl", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl/commit/6cd45fc7a865299f118f7671142465d377853928", - "task_id": "Mjlab-Hostile-FinetuneFeetProgress-MicroDuck" - }, - "deployment": { - "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/rough-walk-g/policy.onnx\"" - } -} diff --git a/registry/behaviors/roulade.json b/registry/behaviors/roulade.json deleted file mode 100644 index 246c27b..0000000 --- a/registry/behaviors/roulade.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "id": "roulade", - "name": "Acrobatic Roulade (Forward Roll)", - "version": "1.0.0", - "description": "Dynamic forward somersault over the duck's head, rolling on its back and landing back onto its feet in a standing position.", - "details": "A high-agility maneuver demonstrating full-body dynamic balance and shock resilience. Trained in MuJoCo Warp with full contact physics (robot_allcollisions.xml). Shipped in the official Pollen Alpha stack and hot-swapped by robotd during an explicit rolling trigger window.", - "category": "agility-tricks", - "tags": [ - "official", - "somersault", - "gymnastics", - "acrobatic", - "full-body-dynamics", - "50hz" - ], - "authors": [ - { - "name": "Pollen Robotics", - "affiliation": "Pollen Robotics / Hugging Face", - "github": "pollen-robotics", - "url": "https://pollen-robotics.com" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "claimed_hardware", - "summary": "Upstream-provided behavior; not independently hardware-verified by uDuck. Verified on physical Microduck hardware on padded mats and carpet. Shipped in official Microduck release video montage.", - "hardware_target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077)", - "notes": "Recommend performing on padded surface, yoga mat, or carpet to avoid cosmetic shell scratches." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat" - ], - "robotd_slot": "roulade" - }, - "simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "oneshot_zero", - "duration_s": 4, - "checks": [ - "recover_upright" - ] - }, - "artifacts": { - "onnx": { - "filename": "roulade.onnx", - "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/roulade.onnx", - "baked_normalizer": true - } - }, - "media": { - "hero_type": "badge", - "caption": "Microduck performing forward roll and recovering to feet" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl", - "task_id": "Mjlab-Roulade-Flat-MicroDuck", - "huggingface_space": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator" - }, - "deployment": { - "robotd_toml": "[policy]\nroulade = \"/opt/robot/policies/roulade.onnx\"" - } -} diff --git a/registry/behaviors/running.json b/registry/behaviors/running.json deleted file mode 100644 index 83c45fd..0000000 --- a/registry/behaviors/running.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "id": "running", - "name": "Microduck Running", - "version": "1.0.0", - "description": "Fast forward-running policy for Microduck, trained for flat ground at commands up to 2.2 m/s.", - "details": "Public ONNX export from HannesVonEssen. The policy is designed primarily for forward speed: lateral and yaw commands were only weakly represented during training, while head and body command slots are unused. The author reports simulation speeds around 1.6 m/s, but the command is not a hard speed limit and the policy has never been tested on hardware.", - "category": "locomotion", - "tags": [ - "community", - "running", - "fast-locomotion", - "flat-ground", - "simulation-only", - "50hz" - ], - "authors": [ - { - "name": "HannesVonEssen", - "url": "https://huggingface.co/HannesVonEssen" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "community_experimental", - "summary": "Public simulation-only running export; no physical Microduck deployment evidence is documented.", - "hardware_target": "Microduck v1 (14 Dynamixel XL330 servos)", - "notes": "The model card reports simulation evaluation at a 2.20 m/s command, with substantial heading drift. Do not begin a hardware test at the simulation command speed." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1.0 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat" - ], - "robotd_slot": "walk" - }, - "simulation": { - "runner": "external", - "reason": "custom_environment", - "notes": "The published Mjlab-Running-Flat-MicroDuck task uses a 2.2 m/s command envelope and training/runtime details outside the registry standard-v1 diagnostic runner." - }, - "artifacts": { - "onnx": { - "filename": "policy.onnx", - "url": "https://huggingface.co/HannesVonEssen/microduck-running/resolve/d839a07cd2cb4bdc2850ca72bf00d9b549ec600a/policy.onnx", - "baked_normalizer": true - } - }, - "media": { - "video_url": "/media/remote-cache/running.mp4", - "hero_type": "video", - "caption": "Microduck running in simulation" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck_rl", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl/tree/d424a0c899f6b33cbd3daeb279913134349c0b63", - "task_id": "Mjlab-Running-Flat-MicroDuck" - }, - "deployment": { - "robotd_toml": "[policy]\nwalk = \"/opt/robot/policies/running/policy.onnx\"" - } -} diff --git a/registry/behaviors/sit-stand.json b/registry/behaviors/sit-stand.json deleted file mode 100644 index 79049da..0000000 --- a/registry/behaviors/sit-stand.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "id": "sit-stand", - "name": "Smooth Sit \u2194 Stand", - "version": "1.0.0", - "description": "Controlled bi-directional transition between upright standing and rested sitting posture with continuous head commandability.", - "details": "A unified policy trained in MuJoCo to handle both gentle sitting down and standing back up. Softens motor gains during transition to ensure the duck does not slam into the ground. While sitting, head orientation remains commandable through the 4-dim head pose observation slot.", - "category": "locomotion", - "tags": [ - "official", - "sit-down", - "stand-up", - "gentle-transition", - "posture", - "50hz" - ], - "authors": [ - { - "name": "Pollen Robotics", - "affiliation": "Pollen Robotics / Hugging Face", - "github": "pollen-robotics", - "url": "https://pollen-robotics.com" - } - ], - "license": "Apache-2.0", - "verification": { - "status": "claimed_hardware", - "summary": "Upstream-provided behavior; not independently hardware-verified by uDuck. Verified on physical Microduck hardware with gamepad Y button toggle. Robot smoothly sits down and stands back up.", - "hardware_target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077)", - "notes": "Transition utilizes posture flags in twist magnitude; rise runs with softened standing gains." - }, - "contract": { - "observation_dim": 61, - "observation_breakdown": { - "proprioception": 48, - "twist": 3, - "head_pose": 4, - "body_pose": 6 - }, - "action_dim": 14, - "action_breakdown": { - "left_leg": 5, - "neck_head": 4, - "right_leg": 5 - }, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - "action_scale": 1 - }, - "compatibility": { - "robot_model": "microduck-standard", - "accessories_required": [], - "terrain": [ - "flat" - ], - "robotd_slot": "sitstand" - }, - "simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { - "preset": "settled_standing" - }, - "scenario": "sitstand", - "duration_s": 6, - "checks": [ - "recover_upright" - ], - "hold_s": 2 - }, - "artifacts": { - "onnx": { - "filename": "BEST_alpha_sitstand.onnx", - "url": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator/resolve/main/app/public/policies/BEST_alpha_sitstand.onnx", - "baked_normalizer": true - } - }, - "media": { - "video_url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/sitstand.webm", - "hero_type": "video", - "caption": "Official Microduck sit-and-stand policy preview in simulation", - "thumbnail_url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/posters/sitstand.png" - }, - "sources": { - "upstream_repo": "https://github.com/pollen-robotics/microduck", - "training_code_url": "https://github.com/pollen-robotics/microduck_rl", - "task_id": "Mjlab-SitStand-Flat-MicroDuck", - "huggingface_space": "https://huggingface.co/spaces/pollen-robotics/microduck-simulator" - }, - "deployment": { - "robotd_toml": "[policy]\nsitstand = \"/opt/robot/policies/BEST_alpha_sitstand.onnx\"" - } -} diff --git a/registry/policies/alpha-walking.json b/registry/policies/alpha-walking.json new file mode 100644 index 0000000..6fd0ca0 --- /dev/null +++ b/registry/policies/alpha-walking.json @@ -0,0 +1,28 @@ +{ + "id": "alpha-walking", + "source": { + "provider": "huggingface-model", + "repo": "pollen-robotics/microduck-policies", + "revision": "088524a64e2557dc453256b6071dbb9d23888802", + "artifact_path": "alpha_walking.onnx", + "artifact_sha256": "e36332d383997d51401897734cd3e79cf5038406feddb18b4d57ecfb141daa6c", + "manifest_path": "manifest.json", + "manifest_sha256": "d0c36e7b71129dd617339c63bcb1d704eab282c8617ebf14c2013a01abfb2dda" + }, + "curation": { + "category": "locomotion", + "tags": ["official", "bipedal", "velocity-tracking", "head-control", "50hz", "flat-ground"], + "name": "Alpha Dynamic Walk", + "summary": "Standard bipedal walking gait with 50 Hz velocity tracking (vx, vy, yaw_rate) and active head-pose orientation control.", + "details": "The flagship locomotion policy for Microduck. Publisher materials describe MuJoCo training with a baked observation normalizer and Dynamixel XL330-oriented actuator modeling.", + "authors": [{"name": "Pollen Robotics", "affiliation": "Pollen Robotics", "github": "pollen-robotics", "url": "https://pollen-robotics.com"}], + "license": "Apache-2.0", + "notes": "Publisher hardware and evaluation claims are not independent registry evidence.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat"]}, + "publisher_hardware": {"status": "claimed", "target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077, 2S LiPo)", "source_url": "https://github.com/pollen-robotics/microduck", "note": "Upstream materials claim physical Microduck verification and default walking deployment; uDuck has not independently verified hardware."} + }, + "media": [ + {"type": "video", "url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/walk.webm", "label": "Publisher walking preview"}, + {"type": "image", "url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/posters/walk.png", "label": "Publisher walking preview poster"} + ] +} diff --git a/registry/policies/ball-kick-left.json b/registry/policies/ball-kick-left.json new file mode 100644 index 0000000..af60b48 --- /dev/null +++ b/registry/policies/ball-kick-left.json @@ -0,0 +1,28 @@ +{ + "id": "ball-kick-left", + "source": { + "provider": "huggingface-model", + "repo": "pollen-robotics/microduck-policies", + "revision": "088524a64e2557dc453256b6071dbb9d23888802", + "artifact_path": "ball_kick_left.onnx", + "artifact_sha256": "d6928284dccd3dd61e08bf2f760effa74309fbefd97b2b31afb2a60f526d196a", + "manifest_path": "manifest.json", + "manifest_sha256": "d0c36e7b71129dd617339c63bcb1d704eab282c8617ebf14c2013a01abfb2dda" + }, + "curation": { + "category": "manipulation", + "tags": ["official", "kick", "soccer", "impulse", "single-leg-balance", "50hz"], + "name": "Impulse Ball Kick (Left Foot)", + "summary": "One-shot dynamic kick with the left foot designed to strike a 70mm / 15g ball forward while maintaining single-leg biped balance.", + "details": "Publisher materials describe a ball-blind, short impulse-kick maneuver for a lightweight practice ball.", + "authors": [{"name": "Pollen Robotics", "affiliation": "Pollen Robotics", "github": "pollen-robotics", "url": "https://pollen-robotics.com"}], + "license": "Apache-2.0", + "notes": "The target ball and hardware claims are publisher context, not independent registry evidence.", + "requirements": {"robot_model": "microduck-standard", "accessories": ["70mm_practice_ball"], "terrain": ["flat"]}, + "publisher_hardware": {"status": "claimed", "target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077)", "source_url": "https://github.com/pollen-robotics/microduck", "note": "Upstream materials claim a physical kick with a standard 70 mm lightweight practice ball; uDuck has not independently verified hardware."} + }, + "media": [ + {"type": "video", "url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/kickL.webm", "label": "Publisher left-foot kick preview"}, + {"type": "image", "url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/posters/kickL.png", "label": "Publisher left-foot kick poster"} + ] +} diff --git a/registry/policies/ball-kick-right.json b/registry/policies/ball-kick-right.json new file mode 100644 index 0000000..2eb5ae8 --- /dev/null +++ b/registry/policies/ball-kick-right.json @@ -0,0 +1,24 @@ +{ + "id": "ball-kick-right", + "source": { + "provider": "huggingface-model", + "repo": "pollen-robotics/microduck-policies", + "revision": "088524a64e2557dc453256b6071dbb9d23888802", + "artifact_path": "ball_kick_right.onnx", + "artifact_sha256": "147a32c388c6b19111b3ac3b550a9a6dc8b8bf267118af4d8c3712522eedb5af", + "manifest_path": "manifest.json", + "manifest_sha256": "d0c36e7b71129dd617339c63bcb1d704eab282c8617ebf14c2013a01abfb2dda" + }, + "curation": { + "category": "manipulation", + "tags": ["official", "kick", "soccer", "impulse", "single-leg-balance", "50hz"], + "name": "Impulse Ball Kick (Right Foot)", + "summary": "One-shot dynamic kick with the right foot designed to strike a 70mm / 15g ball forward while maintaining single-leg biped balance.", + "details": "Publisher materials describe a ball-blind, short impulse-kick maneuver for a lightweight practice ball.", + "authors": [{"name": "Pollen Robotics", "affiliation": "Pollen Robotics", "github": "pollen-robotics", "url": "https://pollen-robotics.com"}], + "license": "Apache-2.0", + "notes": "The target ball and hardware claims are publisher context, not independent registry evidence.", + "requirements": {"robot_model": "microduck-standard", "accessories": ["70mm_practice_ball"], "terrain": ["flat"]}, + "publisher_hardware": {"status": "claimed", "target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077)", "source_url": "https://github.com/pollen-robotics/microduck", "note": "Upstream materials claim a physical kick with a standard 70 mm lightweight practice ball; uDuck has not independently verified hardware."} + } +} diff --git a/registry/policies/courier.json b/registry/policies/courier.json new file mode 100644 index 0000000..723321f --- /dev/null +++ b/registry/policies/courier.json @@ -0,0 +1,28 @@ +{ + "id": "courier", + "source": { + "provider": "github", + "repo": "selinayfilizp/microduck-courier", + "revision": "2cd9da8d1ecfb850c0ab062654a63007cbd21b9d", + "artifact_path": "artifacts/courier-policy.onnx", + "artifact_sha256": "c95396269a48bbab3ebd46032f6c40bc1323ccb7a6a933487ad75810d8785c47", + "manifest_path": null, + "manifest_sha256": null + }, + "curation": { + "category": "manipulation", + "tags": ["community", "courier", "pick-carry-place", "apartment", "simulation-only", "50hz"], + "name": "Microduck Courier", + "summary": "Pick, carry, and place a duck-scale paperback in a simulated Microduck apartment.", + "details": "The repository contains a task-specific apartment scene, book, seated reader, and one ONNX export. Its simulation results and command semantics remain publisher claims outside the registry runner.", + "authors": [{"name": "selinayfilizp", "github": "selinayfilizp", "url": "https://github.com/selinayfilizp"}], + "license": "Apache-2.0", + "notes": "No machine-readable package manifest is published with this artifact.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat"]}, + "publisher_hardware": {"status": "not-claimed", "target": "Microduck v1 (14 Dynamixel XL330 servos)", "source_url": "https://github.com/selinayfilizp/microduck-courier", "note": "The source documents simulated courier runs and no physical robot deployment."} + }, + "media": [ + {"type": "video", "url": "https://raw.githubusercontent.com/selinayfilizp/microduck-courier/2cd9da8d1ecfb850c0ab062654a63007cbd21b9d/artifacts/courier-policy-track.mp4", "label": "Publisher tracked courier rollout"}, + {"type": "image", "url": "https://raw.githubusercontent.com/selinayfilizp/microduck-courier/2cd9da8d1ecfb850c0ab062654a63007cbd21b9d/artifacts/courier-policy-track.gif", "label": "Publisher courier rollout preview"} + ] +} diff --git a/registry/policies/flamingo-cycle.json b/registry/policies/flamingo-cycle.json index 9b03070..c2b1cc6 100644 --- a/registry/policies/flamingo-cycle.json +++ b/registry/policies/flamingo-cycle.json @@ -1,28 +1,25 @@ { "id": "flamingo-cycle", "source": { + "provider": "huggingface-model", "repo": "RemiFabre/microduck-flamingo-cycle", "revision": "6646428394c6997106d2dc07c1588f20f6fea026", - "manifest_sha256": "ac9b9ae16b4f21733990710275bd934c97558c6028e060bd2b34ec1f5341d302", - "artifact_sha256": "df77929c39d7695092bdaf810c2075e20a9ba91abd8192b4073d3de593d56904" + "artifact_path": "policy.onnx", + "artifact_sha256": "df77929c39d7695092bdaf810c2075e20a9ba91abd8192b4073d3de593d56904", + "manifest_path": "manifest.json", + "manifest_sha256": "ac9b9ae16b4f21733990710275bd934c97558c6028e060bd2b34ec1f5341d302" }, "curation": { "category": "agility-tricks", - "tags": [ - "community", - "balance", - "one-foot", - "flamingo", - "simulation-only", - "50hz" - ], - "summary": "One-foot balance cycle that lifts either leg on command and returns to a two-foot stand." + "tags": ["community", "balance", "one-foot", "flamingo", "simulation-only", "50hz"], + "name": "Flamingo Cycle", + "summary": "One-foot balance cycle that lifts either leg on command and returns to a two-foot stand.", + "details": "A maintainer-reviewed execution recipe covers the documented one-foot command hold; publisher evaluation and hardware statements remain separate from registry evidence.", + "authors": [{"name": "RemiFabre", "github": "RemiFabre", "url": "https://huggingface.co/RemiFabre"}], + "license": "Apache-2.0", + "notes": "The registry diagnostic is a five-second active-command hold under the pinned standard scene." }, "media": [ - { - "type": "video", - "url": "https://huggingface.co/RemiFabre/microduck-flamingo-cycle/resolve/6646428394c6997106d2dc07c1588f20f6fea026/media/preview.mp4", - "label": "Publisher simulation of the one-foot balance cycle" - } + {"type": "video", "url": "https://huggingface.co/RemiFabre/microduck-flamingo-cycle/resolve/6646428394c6997106d2dc07c1588f20f6fea026/media/preview.mp4", "label": "Publisher one-foot balance simulation"} ] } diff --git a/registry/policies/genesis-backlash.json b/registry/policies/genesis-backlash.json new file mode 100644 index 0000000..fcd6c71 --- /dev/null +++ b/registry/policies/genesis-backlash.json @@ -0,0 +1,24 @@ +{ + "id": "genesis-backlash", + "source": { + "provider": "github", + "repo": "Macmachi/microduck-rl-genesis", + "revision": "9d1f213879650f2623e3bbd7bf06fe63dbf71a10", + "artifact_path": "policies/backlash.onnx", + "artifact_sha256": "3f8db8bc2c11b2e41665633c1780af21bae3fda7db229eb5035e6c2d5698c075", + "manifest_path": null, + "manifest_sha256": null + }, + "curation": { + "category": "locomotion", + "tags": ["community", "genesis", "backlash", "sim2real", "50hz"], + "name": "Genesis Backlash Walk", + "summary": "Walking policy fine-tuned with simulated ±1° gearbox play on every Microduck servo in Genesis.", + "details": "The standalone ONNX export comes from a community Genesis training port. No physical robot test is documented in the source.", + "authors": [{"name": "Macmachi", "github": "Macmachi", "url": "https://github.com/Macmachi"}], + "license": "Apache-2.0", + "notes": "No machine-readable package manifest is published with this artifact.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat"]}, + "publisher_hardware": {"status": "not-claimed", "target": "Microduck v1 (14 Dynamixel XL330 servos)", "source_url": "https://github.com/Macmachi/microduck-rl-genesis", "note": "No physical Microduck test is documented in the source."} + } +} diff --git a/registry/policies/genesis-rough.json b/registry/policies/genesis-rough.json new file mode 100644 index 0000000..f7809cd --- /dev/null +++ b/registry/policies/genesis-rough.json @@ -0,0 +1,24 @@ +{ + "id": "genesis-rough", + "source": { + "provider": "github", + "repo": "Macmachi/microduck-rl-genesis", + "revision": "9d1f213879650f2623e3bbd7bf06fe63dbf71a10", + "artifact_path": "policies/rough.onnx", + "artifact_sha256": "04261902d3651dc02303e3e9e5ab756062c4d93c45400f431a0ae68b5969185c", + "manifest_path": null, + "manifest_sha256": null + }, + "curation": { + "category": "locomotion", + "tags": ["community", "genesis", "rough-terrain", "velocity-tracking", "50hz"], + "name": "Genesis Rough-Terrain Walk", + "summary": "Velocity-tracking walking policy fine-tuned for rough terrain in a Genesis port of the Microduck environment.", + "details": "The standalone ONNX export comes from a community Genesis training port. No physical robot test is documented in the source.", + "authors": [{"name": "Macmachi", "github": "Macmachi", "url": "https://github.com/Macmachi"}], + "license": "Apache-2.0", + "notes": "No machine-readable package manifest is published with this artifact.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["rough"]}, + "publisher_hardware": {"status": "not-claimed", "target": "Microduck v1 (14 Dynamixel XL330 servos)", "source_url": "https://github.com/Macmachi/microduck-rl-genesis", "note": "No physical Microduck test is documented in the source; the policy is described for rough terrain in Genesis."} + } +} diff --git a/registry/policies/genesis-velocity.json b/registry/policies/genesis-velocity.json new file mode 100644 index 0000000..bae1299 --- /dev/null +++ b/registry/policies/genesis-velocity.json @@ -0,0 +1,24 @@ +{ + "id": "genesis-velocity", + "source": { + "provider": "github", + "repo": "Macmachi/microduck-rl-genesis", + "revision": "9d1f213879650f2623e3bbd7bf06fe63dbf71a10", + "artifact_path": "policies/velocity.onnx", + "artifact_sha256": "c315b9159a1b6f30976c90074ed6df2a33e7e1d14ef1505aed6c2c673f59061d", + "manifest_path": null, + "manifest_sha256": null + }, + "curation": { + "category": "locomotion", + "tags": ["community", "genesis", "velocity-tracking", "flat-ground", "50hz"], + "name": "Genesis Flat Walk", + "summary": "Velocity-tracking walking policy trained in a Genesis port of the Microduck environment for flat terrain.", + "details": "The standalone ONNX export comes from a community Genesis training port. No physical robot test is documented in the source.", + "authors": [{"name": "Macmachi", "github": "Macmachi", "url": "https://github.com/Macmachi"}], + "license": "Apache-2.0", + "notes": "No machine-readable package manifest is published with this artifact.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat"]}, + "publisher_hardware": {"status": "not-claimed", "target": "Microduck v1 (14 Dynamixel XL330 servos)", "source_url": "https://github.com/Macmachi/microduck-rl-genesis", "note": "No physical Microduck test is documented in the source."} + } +} diff --git a/registry/policies/ground-pick.json b/registry/policies/ground-pick.json new file mode 100644 index 0000000..739c137 --- /dev/null +++ b/registry/policies/ground-pick.json @@ -0,0 +1,28 @@ +{ + "id": "ground-pick", + "source": { + "provider": "huggingface-model", + "repo": "pollen-robotics/microduck-policies", + "revision": "088524a64e2557dc453256b6071dbb9d23888802", + "artifact_path": "alpha_ground_pick.onnx", + "artifact_sha256": "ffbf5109982ff999b0ba53afe86b9ae731bbec679d67fb7f8ab4c52152c88872", + "manifest_path": "manifest.json", + "manifest_sha256": "d0c36e7b71129dd617339c63bcb1d704eab282c8617ebf14c2013a01abfb2dda" + }, + "curation": { + "category": "manipulation", + "tags": ["official", "peck", "pick-and-place", "gripper", "mouth-beak", "one-shot", "50hz"], + "name": "Autonomous Ground Pick", + "summary": "One-shot crouching behavior that reaches beak to the floor to pick up objects with its articulated mouth gripper, then smoothly returns to stand.", + "details": "Publisher materials describe a phase-driven floor-pick gesture and articulated beak coordination.", + "authors": [{"name": "Pollen Robotics", "affiliation": "Pollen Robotics", "github": "pollen-robotics", "url": "https://pollen-robotics.com"}], + "license": "Apache-2.0", + "notes": "Publisher hardware and command claims are not independent registry evidence.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat"]}, + "publisher_hardware": {"status": "claimed", "target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077, articulated beak)", "source_url": "https://github.com/pollen-robotics/microduck", "note": "Upstream materials claim physical object pickup on carpet and hard flooring; uDuck has not independently verified hardware."} + }, + "media": [ + {"type": "video", "url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/grab.webm", "label": "Publisher ground-pick preview"}, + {"type": "image", "url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/posters/grab.png", "label": "Publisher ground-pick poster"} + ] +} diff --git a/registry/policies/jump.json b/registry/policies/jump.json new file mode 100644 index 0000000..9fa8c9e --- /dev/null +++ b/registry/policies/jump.json @@ -0,0 +1,23 @@ +{ + "id": "jump", + "source": { + "provider": "github", + "repo": "Liyucheng1997/318_lab-microduck-simulator", + "revision": "512d4bec6fc3ba321d29c93312be72856ad21268", + "artifact_path": "app/public/policies/jump.onnx", + "artifact_sha256": "0b10d7f50f2225467771c1fd11e027490e775b762c2e50c9e25f82c0f488e5c4", + "manifest_path": null, + "manifest_sha256": null + }, + "curation": { + "category": "agility-tricks", + "tags": ["community", "jump", "vertical-hop", "simulation-only", "50hz"], + "name": "Vertical Jump", + "summary": "One-shot vertical jump policy that crouches, launches, lands, and returns to a standing pose.", + "details": "A community-trained policy in a browser simulator fork. The indexed artifact is the fork's jump ONNX, not the official policies also present in that repository.", + "authors": [{"name": "Liyucheng1997", "github": "Liyucheng1997", "url": "https://github.com/Liyucheng1997"}], + "notes": "No machine-readable package manifest or separate artifact license is published with this source.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat"]}, + "publisher_hardware": {"status": "not-claimed", "target": "Microduck v1 (14 Dynamixel XL330 servos)", "source_url": "https://github.com/Liyucheng1997/318_lab-microduck-simulator", "note": "The source presents simulator results and no physical hardware evidence."} + } +} diff --git a/registry/policies/max-height-jump.json b/registry/policies/max-height-jump.json new file mode 100644 index 0000000..a19213a --- /dev/null +++ b/registry/policies/max-height-jump.json @@ -0,0 +1,27 @@ +{ + "id": "max-height-jump", + "source": { + "provider": "github", + "repo": "ThomasBurgess2000/microduck-max-height-jump", + "revision": "7e5dc6028900f13d145e6710847378b007a675e9", + "artifact_path": "policy/max_height_jump.onnx", + "artifact_sha256": "046debd3eebd61a8c027d5595c1bca4fe32056fbb0ae63ac0b2f4e3798e1270f", + "manifest_path": null, + "manifest_sha256": null + }, + "curation": { + "category": "agility-tricks", + "tags": ["community", "jump", "vertical-hop", "maximum-height", "one-shot", "policy-handoff", "simulation-only", "50hz"], + "name": "Maximum-Height Jump", + "summary": "Height-optimized one-shot vertical hop with an optional standing-policy handoff for a quiet reset.", + "details": "The canonical ONNX contains the jump controller. The repository documents a separate standing-policy handoff and publisher-side MuJoCo measurements; those claims are not registry simulation evidence.", + "authors": [{"name": "Thomas Burgess", "affiliation": "Burgess Software", "github": "ThomasBurgess2000", "url": "https://github.com/ThomasBurgess2000"}], + "license": "Apache-2.0", + "notes": "The handoff policy is not folded into this entry's immutable artifact identity.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat"]}, + "publisher_hardware": {"status": "not-claimed", "target": "Microduck v1 (14 Dynamixel XL330 servos)", "source_url": "https://github.com/ThomasBurgess2000/microduck-max-height-jump", "note": "The source reports a CPU MuJoCo evaluation and explicitly documents no physical-hardware evidence."} + }, + "media": [ + {"type": "video", "url": "https://raw.githubusercontent.com/ThomasBurgess2000/microduck-max-height-jump/7e5dc6028900f13d145e6710847378b007a675e9/media/preview-4x.mp4", "label": "Publisher maximum-height jump preview"} + ] +} diff --git a/registry/policies/roller-crouch.json b/registry/policies/roller-crouch.json new file mode 100644 index 0000000..d68ae00 --- /dev/null +++ b/registry/policies/roller-crouch.json @@ -0,0 +1,24 @@ +{ + "id": "roller-crouch", + "source": { + "provider": "huggingface-model", + "repo": "pollen-robotics/microduck-policies", + "revision": "088524a64e2557dc453256b6071dbb9d23888802", + "artifact_path": "roller_crouch.onnx", + "artifact_sha256": "a1a084be240469c76ac9d3fa44d4792f16d4b1da60398b3ecd3cfc5e2244d990", + "manifest_path": "manifest.json", + "manifest_sha256": "d0c36e7b71129dd617339c63bcb1d704eab282c8617ebf14c2013a01abfb2dda" + }, + "curation": { + "category": "roller-skate", + "tags": ["official", "roller-skate", "crouch", "glide", "low-cg", "aerodynamic", "50hz"], + "name": "Roller Blade Crouch Glide", + "summary": "Aerodynamic tuck crouch maneuver while coasting on roller skates, lowering center of gravity for high-speed stability.", + "details": "Publisher materials describe a short phase-driven tuck gesture for the roller configuration.", + "authors": [{"name": "Pollen Robotics", "affiliation": "Pollen Robotics", "github": "pollen-robotics", "url": "https://pollen-robotics.com"}], + "license": "Apache-2.0", + "notes": "Publisher hardware and command claims are not independent registry evidence.", + "requirements": {"robot_model": "microduck-rollers", "accessories": ["roller_skate_blades"], "terrain": ["flat"]}, + "publisher_hardware": {"status": "claimed", "target": "Microduck v1 + Roller Skate Blades", "source_url": "https://github.com/pollen-robotics/microduck", "note": "Upstream materials claim physical roller-wheel crouch-glide cycles; uDuck has not independently verified hardware."} + } +} diff --git a/registry/policies/roller-drive.json b/registry/policies/roller-drive.json new file mode 100644 index 0000000..4bba560 --- /dev/null +++ b/registry/policies/roller-drive.json @@ -0,0 +1,24 @@ +{ + "id": "roller-drive", + "source": { + "provider": "huggingface-model", + "repo": "pollen-robotics/microduck-policies", + "revision": "088524a64e2557dc453256b6071dbb9d23888802", + "artifact_path": "roller.onnx", + "artifact_sha256": "cf05651d2708a2f9364212e86b866c97a70ace8131c492500105e8f28bf99afd", + "manifest_path": "manifest.json", + "manifest_sha256": "d0c36e7b71129dd617339c63bcb1d704eab282c8617ebf14c2013a01abfb2dda" + }, + "curation": { + "category": "roller-skate", + "tags": ["official", "roller-skate", "velocity", "glide", "50hz"], + "name": "Roller Skate Velocity Drive", + "summary": "High-speed skating locomotion utilizing passive roller skate wheels mounted under the feet.", + "details": "Publisher materials describe a roller-wheel locomotion policy selected for the Microduck roller configuration.", + "authors": [{"name": "Pollen Robotics", "affiliation": "Pollen Robotics", "github": "pollen-robotics", "url": "https://pollen-robotics.com"}], + "license": "Apache-2.0", + "notes": "The roller-wheel scene is not represented by the registry's standard-foot execution runner.", + "requirements": {"robot_model": "microduck-rollers", "accessories": ["roller_skate_blades"], "terrain": ["flat"]}, + "publisher_hardware": {"status": "claimed", "target": "Microduck v1 + Roller Skate Accessories (passive bearings/wheels)", "source_url": "https://github.com/pollen-robotics/microduck", "note": "Upstream materials claim physical roller-wheel deployment with passive roller foot frames and 22x16x4 bearings; uDuck has not independently verified hardware."} + } +} diff --git a/registry/policies/rough-walk-e.json b/registry/policies/rough-walk-e.json new file mode 100644 index 0000000..ca6ed85 --- /dev/null +++ b/registry/policies/rough-walk-e.json @@ -0,0 +1,27 @@ +{ + "id": "rough-walk-e", + "source": { + "provider": "huggingface-model", + "repo": "RemiFabre/microduck-rough-walk-e", + "revision": "fa7b27eeb5610d3b351362f4bd71691ee8be3d7d", + "artifact_path": "policy.onnx", + "artifact_sha256": "5aa423bd693e431b19e2ead77f99cbae6184e40a529eb2f7c1b4f85bb7f57040", + "manifest_path": "manifest.json", + "manifest_sha256": "f9b9cdbd7450de266ae1c7f6dd3ed1cc82fd5de01582bf073cc73d81cb2c0332" + }, + "curation": { + "category": "locomotion", + "tags": ["community", "rough-terrain", "velocity-tracking", "sim2real", "simulation-only", "50hz"], + "name": "Rough Walk E", + "summary": "Velocity-commanded walking fine-tuned for grids, small steps, rubble, and slopes.", + "details": "The publisher presents this as a drop-in walking replacement for alpha-walking and reports rough-ground trials. Those evaluation results are publisher claims.", + "authors": [{"name": "RemiFabre", "github": "RemiFabre", "url": "https://huggingface.co/RemiFabre"}], + "license": "Apache-2.0", + "notes": "The package manifest is resolved and inspected; no independent hardware evidence is recorded.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat", "rough", "slope"]}, + "publisher_hardware": {"status": "not-claimed", "target": "Microduck v1 (14 Dynamixel XL330 servos)", "source_url": "https://huggingface.co/RemiFabre/microduck-rough-walk-e", "note": "The publisher reports simulation trials and no physical Microduck deployment evidence."} + }, + "media": [ + {"type": "video", "url": "https://huggingface.co/RemiFabre/microduck-rough-walk-e/resolve/fa7b27eeb5610d3b351362f4bd71691ee8be3d7d/media/preview.mp4", "label": "Publisher rough-walk simulation"} + ] +} diff --git a/registry/policies/rough-walk-g.json b/registry/policies/rough-walk-g.json new file mode 100644 index 0000000..47112e5 --- /dev/null +++ b/registry/policies/rough-walk-g.json @@ -0,0 +1,27 @@ +{ + "id": "rough-walk-g", + "source": { + "provider": "huggingface-model", + "repo": "RemiFabre/microduck-rough-walk-g", + "revision": "242876a0aa8b40b702142fb0a5677fd43bc88a4c", + "artifact_path": "policy.onnx", + "artifact_sha256": "7a0d132f121d4bea3b713d3d7509500319389e0ac8de1ec9b390256471bbfc18", + "manifest_path": "manifest.json", + "manifest_sha256": "a304b650a9fe558eb054695654d2b2a346a2c246c05dd56f4524a1fff42174c6" + }, + "curation": { + "category": "locomotion", + "tags": ["community", "rough-terrain", "velocity-tracking", "from-scratch", "simulation-only", "50hz"], + "name": "Rough Walk G", + "summary": "Velocity-commanded walking trained from scratch for small stairs, rubble, and slopes.", + "details": "The publisher presents this as a from-scratch rough-terrain gait and reports synchronized trials. Those evaluation results are publisher claims.", + "authors": [{"name": "RemiFabre", "github": "RemiFabre", "url": "https://huggingface.co/RemiFabre"}], + "license": "Apache-2.0", + "notes": "The package manifest is resolved and inspected; no independent hardware evidence is recorded.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat", "rough", "slope"]}, + "publisher_hardware": {"status": "not-claimed", "target": "Microduck v1 (14 Dynamixel XL330 servos)", "source_url": "https://huggingface.co/RemiFabre/microduck-rough-walk-g", "note": "The publisher reports simulation trials and no physical Microduck deployment evidence."} + }, + "media": [ + {"type": "video", "url": "https://huggingface.co/RemiFabre/microduck-rough-walk-g/resolve/242876a0aa8b40b702142fb0a5677fd43bc88a4c/media/preview.mp4", "label": "Publisher rough-walk simulation"} + ] +} diff --git a/registry/policies/roulade.json b/registry/policies/roulade.json new file mode 100644 index 0000000..5a459c3 --- /dev/null +++ b/registry/policies/roulade.json @@ -0,0 +1,24 @@ +{ + "id": "roulade", + "source": { + "provider": "huggingface-model", + "repo": "pollen-robotics/microduck-policies", + "revision": "088524a64e2557dc453256b6071dbb9d23888802", + "artifact_path": "roulade.onnx", + "artifact_sha256": "3d60da08fc13f29c1b57f41977aa898132c0d60042100149d8e775affcbca32b", + "manifest_path": "manifest.json", + "manifest_sha256": "d0c36e7b71129dd617339c63bcb1d704eab282c8617ebf14c2013a01abfb2dda" + }, + "curation": { + "category": "agility-tricks", + "tags": ["official", "somersault", "gymnastics", "acrobatic", "full-body-dynamics", "50hz"], + "name": "Acrobatic Roulade (Forward Roll)", + "summary": "Dynamic forward somersault over the duck's head, rolling on its back and landing back onto its feet in a standing position.", + "details": "Publisher materials describe a full-contact forward roll maneuver from the official simulator policy set.", + "authors": [{"name": "Pollen Robotics", "affiliation": "Pollen Robotics", "github": "pollen-robotics", "url": "https://pollen-robotics.com"}], + "license": "Apache-2.0", + "notes": "Publisher hardware and recovery claims are not independent registry evidence.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat"]}, + "publisher_hardware": {"status": "claimed", "target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077)", "source_url": "https://github.com/pollen-robotics/microduck", "note": "Upstream materials claim physical roulade use on padded mats and carpet; uDuck has not independently verified hardware."} + } +} diff --git a/registry/policies/running.json b/registry/policies/running.json new file mode 100644 index 0000000..64a11f4 --- /dev/null +++ b/registry/policies/running.json @@ -0,0 +1,27 @@ +{ + "id": "running", + "source": { + "provider": "huggingface-model", + "repo": "HannesVonEssen/microduck-running", + "revision": "d839a07cd2cb4bdc2850ca72bf00d9b549ec600a", + "artifact_path": "policy.onnx", + "artifact_sha256": "007707dd7779b2756ded67c58b2e9f94fe5071794a48c2b5a20d5f8d841efbeb", + "manifest_path": "manifest.json", + "manifest_sha256": "7d70763e525e23d6c37b4f991be3732e2cd67be84f8c7c510ce4652cd63f1487" + }, + "curation": { + "category": "locomotion", + "tags": ["community", "running", "fast-locomotion", "flat-ground", "simulation-only", "50hz"], + "name": "Microduck Running", + "summary": "Fast forward-running policy for Microduck, trained for flat ground at commands up to 2.2 m/s.", + "details": "The public package is designed primarily for forward speed; its published command and simulation observations are not a hard hardware speed limit.", + "authors": [{"name": "HannesVonEssen", "url": "https://huggingface.co/HannesVonEssen"}], + "license": "Apache-2.0", + "notes": "The package manifest is resolved and inspected; no independent hardware evidence is recorded.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat"]}, + "publisher_hardware": {"status": "not-claimed", "target": "Microduck v1 (14 Dynamixel XL330 servos)", "source_url": "https://huggingface.co/HannesVonEssen/microduck-running", "note": "The publisher reports simulation evaluation and no physical Microduck deployment evidence."} + }, + "media": [ + {"type": "video", "url": "https://huggingface.co/HannesVonEssen/microduck-running/resolve/d839a07cd2cb4bdc2850ca72bf00d9b549ec600a/media/preview.mp4", "label": "Publisher running simulation"} + ] +} diff --git a/registry/policies/sit-stand.json b/registry/policies/sit-stand.json new file mode 100644 index 0000000..f0f4d00 --- /dev/null +++ b/registry/policies/sit-stand.json @@ -0,0 +1,28 @@ +{ + "id": "sit-stand", + "source": { + "provider": "huggingface-model", + "repo": "pollen-robotics/microduck-policies", + "revision": "088524a64e2557dc453256b6071dbb9d23888802", + "artifact_path": "alpha_sitstand.onnx", + "artifact_sha256": "c6c40e35e726eabd803d633e090d112994f469921152448367953fbaf9799bc8", + "manifest_path": "manifest.json", + "manifest_sha256": "d0c36e7b71129dd617339c63bcb1d704eab282c8617ebf14c2013a01abfb2dda" + }, + "curation": { + "category": "locomotion", + "tags": ["official", "sit-down", "stand-up", "gentle-transition", "posture", "50hz"], + "name": "Smooth Sit ↔ Stand", + "summary": "Controlled bi-directional transition between upright standing and rested sitting posture with continuous head commandability.", + "details": "Publisher materials describe a unified sit-and-stand policy with softened transition gains and head commandability.", + "authors": [{"name": "Pollen Robotics", "affiliation": "Pollen Robotics", "github": "pollen-robotics", "url": "https://pollen-robotics.com"}], + "license": "Apache-2.0", + "notes": "Publisher hardware and command claims are not independent registry evidence.", + "requirements": {"robot_model": "microduck-standard", "accessories": [], "terrain": ["flat"]}, + "publisher_hardware": {"status": "claimed", "target": "Microduck v1 (Rockchip RK3566, 14 Dynamixel XL330-M077)", "source_url": "https://github.com/pollen-robotics/microduck", "note": "Upstream materials claim physical sit-and-stand use with the gamepad toggle; uDuck has not independently verified hardware."} + }, + "media": [ + {"type": "video", "url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/sitstand.webm", "label": "Publisher sit-and-stand preview"}, + {"type": "image", "url": "https://pollen-robotics.com/assets/microduck/moves-portrait-alpha/posters/sitstand.png", "label": "Publisher sit-and-stand poster"} + ] +} diff --git a/registry/schema/allowlist.ts b/registry/schema/allowlist.ts index 365da21..dd33d2c 100644 --- a/registry/schema/allowlist.ts +++ b/registry/schema/allowlist.ts @@ -1,4 +1,4 @@ -/** Shared registry validation constants used by the descriptor schema and validator. */ +/** Shared registry validation constants used by the policy schema and validator. */ /** Only these hosts may serve canonical ONNX artifacts (HTTPS only). */ export const HOST_ALLOWLIST = ["huggingface.co", "raw.githubusercontent.com"] as const; diff --git a/registry/schema/behavior.schema.json b/registry/schema/behavior.schema.json deleted file mode 100644 index 9061661..0000000 --- a/registry/schema/behavior.schema.json +++ /dev/null @@ -1,474 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://uduck.dev/schema/behavior.schema.json", - "title": "MicroDuckBehavior", - "description": "Schema definition for Microduck behavior policies in uDuck Registry", - "$defs": { - "nonEmptyString": { - "type": "string", - "minLength": 1 - }, - "id": { - "type": "string", - "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" - }, - "semver": { - "type": "string", - "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?$" - }, - "githubUsername": { - "type": "string", - "pattern": "^(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]{0,37}[A-Za-z0-9])$" - }, - "httpsUrl": { - "type": "string", - "format": "uri", - "pattern": "^https://[^/@?#]+(?:[/?#].*)?$" - }, - "artifactUrl": { - "type": "string", - "format": "uri", - "pattern": "^https://(?:huggingface\\.co|raw\\.githubusercontent\\.com)(?:[/?#].*)?$" - }, - "mediaUrl": { - "anyOf": [ - { - "$ref": "#/$defs/httpsUrl" - }, - { - "type": "string", - "pattern": "^/(?!/)(?:[A-Za-z0-9._-]+/)*[A-Za-z0-9._-]+$" - } - ] - }, - "onnxFilename": { - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*\\.onnx$" - } - }, - "type": "object", - "additionalProperties": false, - "required": [ - "id", - "name", - "version", - "description", - "category", - "tags", - "authors", - "license", - "verification", - "contract", - "compatibility", - "artifacts", - "media", - "sources", - "deployment" - ], - "properties": { - "id": { - "$ref": "#/$defs/id", - "description": "Unique lowercase kebab-case identifier" - }, - "name": { - "type": "string", - "minLength": 2, - "description": "Display name of the behavior" - }, - "version": { - "$ref": "#/$defs/semver", - "description": "Semantic version" - }, - "description": { - "type": "string", - "minLength": 10, - "description": "Crisp summary of what the duck does" - }, - "details": { - "type": "string", - "description": "Additional context, training notes, or usage notes" - }, - "category": { - "type": "string", - "enum": [ - "locomotion", - "agility-tricks", - "manipulation", - "recovery", - "roller-skate", - "experimental" - ] - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/$defs/nonEmptyString" - }, - "minItems": 1 - }, - "authors": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["name"], - "properties": { - "name": { - "$ref": "#/$defs/nonEmptyString" - }, - "affiliation": { - "$ref": "#/$defs/nonEmptyString" - }, - "github": { - "$ref": "#/$defs/githubUsername" - }, - "url": { - "$ref": "#/$defs/httpsUrl" - } - } - } - }, - "license": { - "$ref": "#/$defs/nonEmptyString" - }, - "verification": { - "type": "object", - "additionalProperties": false, - "required": ["status", "summary", "hardware_target"], - "properties": { - "status": { - "type": "string", - "enum": [ - "verified_hardware", - "claimed_hardware", - "community_experimental" - ] - }, - "summary": { - "$ref": "#/$defs/nonEmptyString" - }, - "hardware_target": { - "$ref": "#/$defs/nonEmptyString" - }, - "notes": { - "$ref": "#/$defs/nonEmptyString" - } - } - }, - "contract": { - "type": "object", - "additionalProperties": false, - "required": [ - "observation_dim", - "observation_breakdown", - "action_dim", - "action_breakdown", - "control_frequency_hz", - "decimation", - "actuator_model", - "action_scale" - ], - "properties": { - "observation_dim": { - "type": "integer", - "const": 61 - }, - "observation_breakdown": { - "type": "object", - "additionalProperties": false, - "required": ["proprioception", "twist", "head_pose", "body_pose"], - "properties": { - "proprioception": { - "type": "integer", - "const": 48 - }, - "twist": { - "type": "integer", - "const": 3 - }, - "head_pose": { - "type": "integer", - "const": 4 - }, - "body_pose": { - "type": "integer", - "const": 6 - } - } - }, - "action_dim": { - "type": "integer", - "const": 14 - }, - "action_breakdown": { - "type": "object", - "additionalProperties": false, - "required": ["left_leg", "neck_head", "right_leg"], - "properties": { - "left_leg": { - "type": "integer", - "const": 5 - }, - "neck_head": { - "type": "integer", - "const": 4 - }, - "right_leg": { - "type": "integer", - "const": 5 - } - } - }, - "control_frequency_hz": { - "type": "number", - "const": 50 - }, - "decimation": { - "type": "integer", - "minimum": 1 - }, - "actuator_model": { - "$ref": "#/$defs/nonEmptyString" - }, - "action_scale": { - "type": "number" - } - } - }, - "compatibility": { - "type": "object", - "additionalProperties": false, - "required": ["robot_model", "accessories_required", "terrain", "robotd_slot"], - "properties": { - "robot_model": { - "type": "string", - "enum": ["microduck-standard", "microduck-rollers", "custom-duck"] - }, - "accessories_required": { - "type": "array", - "items": { - "$ref": "#/$defs/nonEmptyString" - } - }, - "terrain": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "enum": ["flat", "rough", "slope", "any"] - } - }, - "robotd_slot": { - "type": "string", - "enum": [ - "walk", - "stand", - "sitstand", - "roulade", - "kick_left", - "kick_right", - "ground_pick", - "roller", - "custom" - ] - } - } - }, - "artifacts": { - "type": "object", - "additionalProperties": false, - "required": ["onnx"], - "properties": { - "onnx": { - "type": "object", - "additionalProperties": false, - "required": ["filename", "url", "baked_normalizer"], - "properties": { - "filename": { - "$ref": "#/$defs/onnxFilename" - }, - "url": { - "$ref": "#/$defs/artifactUrl" - }, - "baked_normalizer": { - "type": "boolean" - } - } - }, - "checkpoint": { - "type": "object", - "additionalProperties": false, - "properties": { - "url": { - "$ref": "#/$defs/httpsUrl" - }, - "framework": { - "$ref": "#/$defs/nonEmptyString" - } - } - }, - "config": { - "type": "object", - "additionalProperties": false, - "properties": { - "url": { - "$ref": "#/$defs/httpsUrl" - } - } - } - } - }, - "media": { - "type": "object", - "additionalProperties": false, - "required": ["hero_type"], - "properties": { - "thumbnail_url": { - "$ref": "#/$defs/mediaUrl" - }, - "loop_url": { - "$ref": "#/$defs/mediaUrl" - }, - "video_url": { - "$ref": "#/$defs/mediaUrl" - }, - "hero_type": { - "type": "string", - "enum": ["video", "image", "badge"] - }, - "caption": { - "$ref": "#/$defs/nonEmptyString" - } - } - }, - "simulation": { - "description": "Optional diagnostic render recipe, independent from compatibility.robotd_slot.", - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["runner", "scene", "start", "scenario", "duration_s"], - "properties": { - "runner": { "type": "string", "const": "microduck-standard-v1" }, - "model": { "type": "string", "enum": ["microduck-standard", "microduck-rollers"] }, - "scene": { "type": "string", "const": "flat-v1" }, - "start": { - "oneOf": [ - { - "type": "object", - "additionalProperties": false, - "required": ["preset"], - "properties": { "preset": { "type": "string", "const": "standing_pose" } } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["preset"], - "properties": { - "preset": { "type": "string", "const": "settled_standing" }, - "settle_s": { "type": "number", "minimum": 0.05, "maximum": 1 } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["preset", "trunk_height_m", "orientation"], - "properties": { - "preset": { "type": "string", "const": "airborne_drop" }, - "trunk_height_m": { "type": "number", "minimum": 0.15, "maximum": 0.5 }, - "orientation": { "type": "string", "enum": ["upright", "front", "back", "left", "right"] }, - "linear_velocity_mps": { - "type": "array", - "minItems": 3, - "maxItems": 3, - "items": { "type": "number", "minimum": -3, "maximum": 3 } - } - } - } - ] - }, - "scenario": { - "type": "string", - "enum": ["velocity", "standing", "sitstand", "oneshot_phase", "oneshot_zero", "oneshot_trigger"] - }, - "duration_s": { "type": "number", "minimum": 1, "maximum": 30 }, - "checks": { - "type": "array", - "maxItems": 8, - "items": { - "type": "string", - "enum": ["no_fall", "ends_upright", "recover_upright", "velocity_tracking", "takeoff", "touchdown_after_takeoff"] - } - }, - "trigger_s": { "type": "number", "minimum": 0, "maximum": 5 }, - "period_s": { "type": "number", "exclusiveMinimum": 0, "maximum": 30 }, - "end_phase": { "type": "number", "exclusiveMinimum": 0, "maximum": 1 }, - "hold_s": { "type": "number", "minimum": 0, "maximum": 30 }, - "segments": { - "type": "array", - "minItems": 1, - "maxItems": 12, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["duration_s", "vx", "vy", "wz"], - "properties": { - "duration_s": { "type": "number", "exclusiveMinimum": 0, "maximum": 30 }, - "vx": { "type": "number" }, - "vy": { "type": "number" }, - "wz": { "type": "number" } - } - } - } - } - }, - { - "type": "object", - "additionalProperties": false, - "required": ["runner", "reason"], - "properties": { - "runner": { "type": "string", "const": "external" }, - "reason": { - "type": "string", - "enum": ["custom_environment", "custom_contract", "custom_assets", "publisher_only"] - }, - "notes": { "$ref": "#/$defs/nonEmptyString" } - } - } - ] - }, - "sources": { - "type": "object", - "additionalProperties": false, - "required": ["upstream_repo"], - "properties": { - "upstream_repo": { - "$ref": "#/$defs/httpsUrl" - }, - "training_code_url": { - "$ref": "#/$defs/httpsUrl" - }, - "task_id": { - "$ref": "#/$defs/nonEmptyString" - }, - "huggingface_space": { - "$ref": "#/$defs/httpsUrl" - }, - "discussion_url": { - "$ref": "#/$defs/httpsUrl" - } - } - }, - "deployment": { - "type": "object", - "additionalProperties": false, - "required": ["robotd_toml"], - "properties": { - "robotd_toml": { - "$ref": "#/$defs/nonEmptyString" - } - } - } - } -} diff --git a/registry/schema/behavior.ts b/registry/schema/behavior.ts deleted file mode 100644 index 777dd5d..0000000 --- a/registry/schema/behavior.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { z } from "zod"; -import { - GITHUB_USERNAME_PATTERN, - ID_PATTERN, - isAllowedArtifactUrl, - isAllowedMediaUrl, - isHttpsUrl, - ONNX_FILENAME_PATTERN, -} from "./allowlist"; - -/** Every object in the schema is strict — no coercion, no unknown keys. */ -const strict = (shape: T) => z.strictObject(shape); - -const NonEmptyStringSchema = z.string().min(1); -const HttpsUrlSchema = z.string().url().refine(isHttpsUrl, { - message: "Must be a valid https:// URL without embedded credentials", -}); -const MediaUrlSchema = z.string().refine(isAllowedMediaUrl, { - message: "Must be a valid https:// URL or a safe local asset path", -}); -const SemverSchema = z - .string() - .regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/, "Must follow semver"); -const BoundedSimulationVelocitySchema = z.number().min(-3).max(3); - -/** Verification labels describe the evidence available for each behavior. */ -export const VerificationStatusSchema = z.enum([ - "verified_hardware", // Independent physical-run verification; upstream origin is not sufficient - "claimed_hardware", // Author claims physical hardware deployment - "community_experimental", // Community work-in-progress or conceptual entry -]); - -export type VerificationStatus = z.infer; - -export const BehaviorCategorySchema = z.enum([ - "locomotion", - "agility-tricks", - "manipulation", - "recovery", - "roller-skate", - "experimental", -]); -export type BehaviorCategory = z.infer; - -export const RobotModelSchema = z.enum([ - "microduck-standard", - "microduck-rollers", - "custom-duck", -]); -export type RobotModel = z.infer; - -export const RobotDSlotSchema = z.enum([ - "walk", - "stand", - "sitstand", - "roulade", - "kick_left", - "kick_right", - "ground_pick", - "roller", - "custom", -]); -export type RobotDSlot = z.infer; - -export const TerrainSchema = z.enum(["flat", "rough", "slope", "any"]); -export type Terrain = z.infer; - -export const SimulationCheckSchema = z.enum([ - "no_fall", - "ends_upright", - "recover_upright", - "velocity_tracking", - "takeoff", - "touchdown_after_takeoff", -]); - -const SimulationStartSchema = z.discriminatedUnion("preset", [ - strict({ - preset: z.literal("standing_pose"), - }), - strict({ - preset: z.literal("settled_standing"), - settle_s: z.number().min(0.05).max(1).optional(), - }), - strict({ - preset: z.literal("airborne_drop"), - trunk_height_m: z.number().min(0.15).max(0.5), - orientation: z.enum(["upright", "front", "back", "left", "right"]), - linear_velocity_mps: z.tuple([ - BoundedSimulationVelocitySchema, - BoundedSimulationVelocitySchema, - BoundedSimulationVelocitySchema, - ]).optional(), - }), -]); - -const RegistrySimulationSchema = strict({ - runner: z.literal("microduck-standard-v1"), - model: z.enum(["microduck-standard", "microduck-rollers"]).optional(), - scene: z.literal("flat-v1"), - start: SimulationStartSchema, - scenario: z.enum([ - "velocity", - "standing", - "sitstand", - "oneshot_phase", - "oneshot_zero", - "oneshot_trigger", - ]), - duration_s: z.number().min(1).max(30), - checks: z.array(SimulationCheckSchema).max(8).optional(), - trigger_s: z.number().min(0).max(5).optional(), - period_s: z.number().positive().max(30).optional(), - end_phase: z.number().positive().max(1).optional(), - hold_s: z.number().min(0).max(30).optional(), - segments: z.array(strict({ - duration_s: z.number().positive().max(30), - vx: z.number(), - vy: z.number(), - wz: z.number(), - })).min(1).max(12).optional(), -}); - -const ExternalSimulationSchema = strict({ - runner: z.literal("external"), - reason: z.enum([ - "custom_environment", - "custom_contract", - "custom_assets", - "publisher_only", - ]), - notes: NonEmptyStringSchema.optional(), -}); - -const BehaviorInputSchema = strict({ - id: z.string().regex(ID_PATTERN, "Must be a lowercase kebab-case slug"), - name: z.string().min(2), - version: SemverSchema, - description: z.string().min(10), - details: z.string().optional(), - category: BehaviorCategorySchema, - tags: z.array(NonEmptyStringSchema).min(1), - authors: z.array( - strict({ - name: NonEmptyStringSchema, - affiliation: NonEmptyStringSchema.optional(), - github: z.string().regex(GITHUB_USERNAME_PATTERN, "Must be a GitHub username").optional(), - url: HttpsUrlSchema.optional(), - }) - ).min(1), - license: NonEmptyStringSchema, - - verification: strict({ - status: VerificationStatusSchema, - summary: NonEmptyStringSchema, - hardware_target: NonEmptyStringSchema, // e.g. "Microduck RK3566 Dev Board, Dynamixel XL330-M077" - notes: NonEmptyStringSchema.optional(), - }), - - contract: strict({ - observation_dim: z.literal(61), - observation_breakdown: strict({ - proprioception: z.literal(48), - twist: z.literal(3), - head_pose: z.literal(4), - body_pose: z.literal(6), - }), - action_dim: z.literal(14), - action_breakdown: strict({ - left_leg: z.literal(5), - neck_head: z.literal(4), - right_leg: z.literal(5), - }), - control_frequency_hz: z.literal(50), - decimation: z.number().int().positive(), - actuator_model: NonEmptyStringSchema, - action_scale: z.number().finite(), - }), - - compatibility: strict({ - robot_model: RobotModelSchema, - accessories_required: z.array(NonEmptyStringSchema), - terrain: z.array(TerrainSchema).min(1), - robotd_slot: RobotDSlotSchema, - }), - - artifacts: strict({ - onnx: strict({ - filename: z.string().regex(ONNX_FILENAME_PATTERN, "Must be a safe .onnx filename"), - // Canonical URL must be HTTPS on the host allowlist. - url: z.string().refine(isAllowedArtifactUrl, { - message: `Must be an https:// URL on the allowlist (huggingface.co, raw.githubusercontent.com)`, - }), - baked_normalizer: z.boolean(), - }), - checkpoint: strict({ - url: HttpsUrlSchema.optional(), - framework: NonEmptyStringSchema.optional(), - }).optional(), - config: strict({ - url: HttpsUrlSchema.optional(), - }).optional(), - }), - - media: strict({ - thumbnail_url: MediaUrlSchema.optional(), - loop_url: MediaUrlSchema.optional(), - video_url: MediaUrlSchema.optional(), - hero_type: z.enum(["video", "image", "badge"]), - caption: NonEmptyStringSchema.optional(), - }), - - sources: strict({ - upstream_repo: HttpsUrlSchema, - training_code_url: HttpsUrlSchema.optional(), - task_id: NonEmptyStringSchema.optional(), - huggingface_space: HttpsUrlSchema.optional(), - discussion_url: HttpsUrlSchema.optional(), - }), - - deployment: strict({ - robotd_toml: NonEmptyStringSchema, - }), - - // Optional registry-owned diagnostic render recipe. Compatibility and - // installation slots never select or imply this scenario. - simulation: z.discriminatedUnion("runner", [ - RegistrySimulationSchema, - ExternalSimulationSchema, - ]).optional(), -}); - -export const BehaviorSchema = BehaviorInputSchema; - -export type Behavior = z.infer; - -export interface RegistryIndex { - version: string; - updated_at: string; - count: number; - entries: import("./catalog").CatalogEntry[]; -} diff --git a/registry/schema/catalog.ts b/registry/schema/catalog.ts index c6110fa..d434235 100644 --- a/registry/schema/catalog.ts +++ b/registry/schema/catalog.ts @@ -1,22 +1,13 @@ import { z } from "zod"; -import type { Behavior } from "./behavior"; -import type { PolicyPointer, ResolvedPolicy } from "./policy"; +import type { Policy, ResolvedPolicy } from "./policy"; import { isAllowedArtifactUrl, isAllowedMediaUrl, isHttpsUrl } from "./allowlist"; -/** - * The public catalog is the boundary between authored registry inputs and - * consumers. A Hub package and a manually curated record have one shape here; - * missing upstream facts stay null instead of being filled with a runtime - * default. - */ - const strict = (shape: T) => z.strictObject(shape); const NullableString = z.string().nullable(); const NullableNumber = z.number().finite().nullable(); const NullableUrl = z.string().url().refine(isHttpsUrl).nullable(); const NullableMediaUrl = z.string().refine(isAllowedMediaUrl).nullable(); const NullableSha256 = z.string().regex(/^[a-f0-9]{64}$/).nullable(); -const NullableRevision = z.string().regex(/^[a-f0-9]{40}$/).nullable(); const CatalogAuthorSchema = strict({ name: z.string().min(1), @@ -25,23 +16,22 @@ const CatalogAuthorSchema = strict({ url: NullableUrl, }); -export const CatalogSourceKindSchema = z.enum(["pollen-hub", "manual"]); +/** Source providers are immutable upstream artifacts, never editorial records. */ +export const CatalogSourceKindSchema = z.enum(["github", "huggingface-model", "huggingface-space"]); export type CatalogSourceKind = z.infer; const CatalogSourceSchema = strict({ - /** A Hub package was resolved from a Pollen schema-2 repository. */ kind: CatalogSourceKindSchema, - repository_url: NullableUrl, - package_url: NullableUrl, - revision: NullableRevision, + repository_url: z.string().url().refine(isHttpsUrl), + package_url: z.string().url().refine(isHttpsUrl), + revision: z.string().regex(/^[a-f0-9]{40}$/), manifest_sha256: NullableSha256, artifact: strict({ - filename: z.string().min(1).nullable(), - url: z.string().refine(isAllowedArtifactUrl).nullable(), - sha256: NullableSha256, - }).nullable(), + filename: z.string().min(1), + url: z.string().refine(isAllowedArtifactUrl), + sha256: z.string().regex(/^[a-f0-9]{64}$/), + }), upstream: strict({ - runtime_url: NullableUrl, training_url: NullableUrl, simulator_url: NullableUrl, task_id: NullableString, @@ -59,21 +49,19 @@ const CatalogContractSchema = strict({ }); const CatalogCompatibilitySchema = strict({ - /** Exact manifest string when available; legacy values are source-labelled. */ robot_model: NullableString, accessories_required: z.array(z.string().min(1)).nullable(), terrain: z.array(z.string().min(1)).nullable(), }); const CatalogInstallSchema = strict({ - route: z.enum(["skill", "slot", "review", "manual"]).nullable(), + route: z.enum(["skill", "slot", "review"]), command: NullableString, - config: NullableString, + reason: NullableString, }); const CatalogRuntimeSchema = strict({ - /** Classification is derived from package resolution, never editorial. */ - classification: z.enum(["pollen-hub", "pollen-review", "custom"]), + status: z.enum(["ready", "review"]), kind: z.enum(["episodic", "perpetual", "scripted"]).nullable(), slot: NullableString, duration_s: NullableNumber, @@ -129,8 +117,9 @@ export type CatalogCoverage = z.infer; const CatalogHardwareSchema = strict({ /** Hardware proof is never inferred from upstream identity or media. */ - status: z.enum(["none", "author-claimed", "maintainer-verified"]), + status: z.enum(["none", "author-claimed"]), target: NullableString, + source_url: NullableUrl, note: NullableString, }); export type CatalogHardware = z.infer; @@ -175,15 +164,12 @@ export const CatalogEntrySchema = strict({ export type CatalogEntry = z.infer; export interface RegistryIndex { - version: "3.0.0"; + version: "4.0.0"; updated_at: string; count: number; entries: CatalogEntry[]; } -/** Evidence is intentionally structural: both local build artifacts and a - * future release-backed reader can provide these fields without changing the - * public catalog contract. */ export interface CatalogSimulationEvidence { status?: CoverageStatus; evidence_key?: string | null; @@ -210,20 +196,12 @@ function nullableSha(value: unknown): string | null { return typeof value === "string" && /^[a-f0-9]{64}$/.test(value) ? value : null; } -function nullableRevision(value: unknown): string | null { - return typeof value === "string" && /^[a-f0-9]{40}$/.test(value) ? value : null; -} - -function httpsOrNull(value: unknown): string | null { - return typeof value === "string" && isHttpsUrl(value) ? value : null; -} - function mediaOrNull(value: unknown): string | null { return typeof value === "string" && isAllowedMediaUrl(value) ? value : null; } -function allowedArtifactOrNull(value: unknown): string | null { - return typeof value === "string" && isAllowedArtifactUrl(value) ? value : null; +function httpsOrNull(value: unknown): string | null { + return typeof value === "string" && isHttpsUrl(value) ? value : null; } function recordValue(value: unknown, key: string): unknown { @@ -233,52 +211,28 @@ function recordValue(value: unknown, key: string): unknown { } function stringArray(value: unknown): string[] | null { - if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) { - return null; - } - return value; -} - -function authorFromBehavior(behavior: Behavior): CatalogEntry["authors"] { - return behavior.authors.map((author) => ({ - name: author.name, - affiliation: author.affiliation ?? null, - github: author.github ?? null, - url: author.url ?? null, - })); + return Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0) + ? value + : null; } function normalizeLicense(value: unknown): string | null { const license = nullableString(value); - if (!license || /^(not provided|not separately specified|unknown)$/i.test(license)) return null; - return license; -} - -function authorMediaFromBehavior(behavior: Behavior): CatalogMedia["author"] { - const items: CatalogMedia["author"] = []; - if (behavior.media.thumbnail_url) items.push({ type: "image", url: behavior.media.thumbnail_url, label: "Author-provided thumbnail" }); - if (behavior.media.loop_url) items.push({ type: "video", url: behavior.media.loop_url, label: "Author-provided loop" }); - if (behavior.media.video_url && behavior.media.video_url !== behavior.media.loop_url) items.push({ type: "video", url: behavior.media.video_url, label: "Author-provided video" }); - return items; + return license && !/^(not provided|not separately specified|unknown)$/i.test(license) ? license : null; } -function authorMediaFromPolicy(policy: PolicyPointer): CatalogMedia["author"] { +function authorMediaFromPolicy(policy: Policy): CatalogMedia["author"] { return (policy.media ?? []).map((item) => ({ type: item.type, url: item.url, label: item.label })); } function registryMedia(evidence?: CatalogSimulationEvidence | null): CatalogMedia["registry"] { - if (!evidence?.loop_url || !evidence.poster_url || !evidence.report_url) return null; - const loop = mediaOrNull(evidence.loop_url); - const poster = mediaOrNull(evidence.poster_url); - const report = mediaOrNull(evidence.report_url); - if (!loop || !poster || !report) return null; - return { loop_url: loop, poster_url: poster, report_url: report }; + const loop = mediaOrNull(evidence?.loop_url); + const poster = mediaOrNull(evidence?.poster_url); + const report = mediaOrNull(evidence?.report_url); + return loop && poster && report ? { loop_url: loop, poster_url: poster, report_url: report } : null; } function coverageReportUrl(evidence?: CatalogSimulationEvidence | null): string | null { - // Coverage report availability is independent from registry media: an - // unsupported/rejected/failed-before-render report legitimately has a - // report with no loop/poster. Do not hide it behind the media gate. return mediaOrNull(evidence?.report_url); } @@ -287,21 +241,16 @@ function coverage( evidence?: CatalogSimulationEvidence | null, ): CatalogCoverage { const media = registryMedia(evidence); - // Fail closed: only explicit passed/failed/not-covered/not-run become - // evidence. Missing status never infers "passed". const rawStatus = evidence?.status; - const status: CatalogCoverage["registry_simulation"]["status"] = - rawStatus === "passed" || rawStatus === "failed" || rawStatus === "not-covered" || rawStatus === "not-run" - ? rawStatus - : media - ? "failed" - : "not-run"; - // Malformed rendered evidence (missing key/inputs/checks) must not become - // positive evidence. Require the identity fields for a passed claim. + const status: CoverageStatus = rawStatus === "passed" || rawStatus === "failed" || rawStatus === "not-covered" || rawStatus === "not-run" + ? rawStatus + : media + ? "failed" + : "not-run"; let finalStatus = status; if (finalStatus === "passed") { const hasIdentity = nullableSha(evidence?.evidence_key) && nullableSha(evidence?.inputs_sha256); - const hasChecks = Array.isArray(evidence?.checks) && (evidence?.checks?.length ?? 0) > 0; + const hasChecks = Array.isArray(evidence?.checks) && evidence.checks.length > 0; if (!hasIdentity || !hasChecks) finalStatus = "failed"; } return { @@ -316,148 +265,26 @@ function coverage( report_url: coverageReportUrl(evidence), loop_url: media?.loop_url ?? null, poster_url: media?.poster_url ?? null, - checks: Array.isArray(evidence?.checks) ? (evidence?.checks as CatalogCoverage["registry_simulation"]["checks"]) : [], + checks: Array.isArray(evidence?.checks) ? evidence.checks : [], reason: nullableString(evidence?.reason), }, }; } -function behaviorSimulationEvidence(behavior: Behavior): CatalogSimulationEvidence | null { - // The report reader is deliberately optional. CI/build code may attach a - // release-backed report later; a descriptor alone never counts as evidence. - return null; -} - -function sourceForBehavior(behavior: Behavior): CatalogSource { - const artifactUrl = allowedArtifactOrNull(behavior.artifacts.onnx.url); - return { - kind: "manual", - repository_url: httpsOrNull(behavior.sources.upstream_repo), - package_url: null, - revision: null, - manifest_sha256: null, - artifact: { - filename: nullableString(behavior.artifacts.onnx.filename), - url: artifactUrl, - sha256: null, - }, - upstream: { - runtime_url: httpsOrNull(behavior.sources.upstream_repo), - training_url: httpsOrNull(behavior.sources.training_code_url), - simulator_url: httpsOrNull(behavior.sources.huggingface_space), - task_id: nullableString(behavior.sources.task_id), - }, - }; -} - -function runtimeForBehavior(behavior: Behavior): CatalogRuntime { - const simulation = behavior.simulation; - const simulationReason = simulation?.runner === "external" - ? simulation.notes ?? simulation.reason - : simulation - ? "A registry recipe exists; its build evidence is reported separately." - : "No registry simulation recipe is authored for this manual entry."; - return { - classification: "custom", - kind: null, - slot: behavior.compatibility.robotd_slot, - duration_s: simulation?.runner === "microduck-standard-v1" ? simulation.duration_s : null, - unwind_s: null, - command_encoding: null, - robot: { - model: behavior.compatibility.robot_model, - hw_rev: null, - servos: null, - }, - contract: { - observation_dim: behavior.contract.observation_dim, - action_dim: behavior.contract.action_dim, - control_frequency_hz: behavior.contract.control_frequency_hz, - action_scale: behavior.contract.action_scale, - decimation: behavior.contract.decimation, - actuator_model: behavior.contract.actuator_model, - }, - compatibility: { - robot_model: behavior.compatibility.robot_model, - accessories_required: [...behavior.compatibility.accessories_required], - terrain: [...behavior.compatibility.terrain], - }, - install: { - route: "manual", - command: null, - config: behavior.deployment.robotd_toml, - }, - unresolved: [simulationReason], - }; +function sourceBase(provider: Policy["source"]["provider"], repo: string): string { + if (provider === "github") return `https://github.com/${repo}`; + if (provider === "huggingface-space") return `https://huggingface.co/spaces/${repo}`; + return `https://huggingface.co/${repo}`; } -/** Convert an existing manually authored descriptor at the public boundary. */ -export function catalogEntryFromBehavior( - behavior: Behavior, - evidence?: CatalogSimulationEvidence | null, -): CatalogEntry { - const authorMedia = authorMediaFromBehavior(behavior); - const registry = registryMedia(evidence); - // Hardware migration: never strengthen a claim. claimed_hardware with an - // attributable upstream publisher source becomes author-claimed (not - // uDuck-verified). verified_hardware without independent registry evidence - // is downgraded. community_experimental carries no hardware claim. - // Audited 2026-09-05: 9 claimed_hardware entries all cite - // pollen-robotics/microduck as upstream with explicit "Verified on physical - // Microduck hardware" summaries and, in most cases, upstream hardware clips. - // They are preserved as publisher claims, never as maintainer-verified. - let hardwareStatus: "none" | "author-claimed" | "maintainer-verified" = "none"; - let hardwareNote: string | null; - if (behavior.verification.status === "claimed_hardware") { - hardwareStatus = "author-claimed"; - hardwareNote = - `Publisher claim from ${behavior.sources.upstream_repo}: ${behavior.verification.summary} ` + - `Not independently verified by uDuck.`; - } else if (behavior.verification.status === "verified_hardware") { - hardwareStatus = "none"; - hardwareNote = - `Descriptor claims independent verification, but no attributable registry evidence is recorded; ` + - `downgraded to none pending maintainer review. Original summary: ${behavior.verification.summary}`; - } else { - hardwareNote = "No independently attributable registry hardware evidence is recorded."; - } - return CatalogEntrySchema.parse({ - id: behavior.id, - name: behavior.name, - version: behavior.version, - description: behavior.description, - details: behavior.details ?? null, - category: behavior.category, - tags: [...behavior.tags], - authors: authorFromBehavior(behavior), - license: normalizeLicense(behavior.license), - curation: { - summary: null, - notes: null, - }, - source: sourceForBehavior(behavior), - runtime: runtimeForBehavior(behavior), - coverage: coverage({ - status: "not-run", - input_shape: null, - output_shape: null, - scope: null, - }, evidence ?? behaviorSimulationEvidence(behavior)), - hardware: { - status: hardwareStatus, - target: nullableString(behavior.verification.hardware_target), - note: hardwareNote, - }, - media: { - author: authorMedia, - registry, - primary: registry ? "registry" : authorMedia.length > 0 ? "author" : "none", - }, - }); +function artifactUrl(source: Policy["source"]): string { + if (source.provider === "github") return `https://raw.githubusercontent.com/${source.repo}/${source.revision}/${source.artifact_path}`; + const prefix = source.provider === "huggingface-space" ? "spaces/" : ""; + return `https://huggingface.co/${prefix}${source.repo}/resolve/${source.revision}/${source.artifact_path}`; } function manifestObject(policy: ResolvedPolicy): Record { - return policy.resolved.manifest; + return policy.resolved.manifest ?? {}; } function manifestString(manifest: Record, key: string): string | null { @@ -468,33 +295,13 @@ function manifestNumber(manifest: Record, key: string): number return nullableNumber(manifest[key]); } -function manifestRobot(manifest: Record): Record { - const robot = manifest.robot; - return robot && typeof robot === "object" && !Array.isArray(robot) - ? robot as Record - : {}; -} - -function manifestTraining(manifest: Record): Record { - const training = manifest.training; - return training && typeof training === "object" && !Array.isArray(training) - ? (training as Record) - : {}; -} - -function trainingRepoUrl(training: Record): string | null { - const repo = training.repo; - if (typeof repo !== "string") return null; - // Only link a clean owner/repo slug; never parse prose into a fake URL. - if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(repo)) return null; - if (repo.startsWith("datasets/") || repo.startsWith("spaces/") || repo.startsWith("models/")) return null; - const url = `https://github.com/${repo}`; - return httpsOrNull(url); +function nestedRecord(manifest: Record, key: string): Record { + const value = manifest[key]; + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } function manifestCommandEncoding(manifest: Record): CatalogRuntime["command_encoding"] { - const command = manifest.command; - const encoding = recordValue(command, "encoding"); + const encoding = recordValue(manifest.command, "encoding"); if (encoding === "constant" || encoding === "phase" || encoding === "posture_flag") return encoding; return encoding == null ? "absent" : null; } @@ -503,88 +310,121 @@ function packageInspection(policy: ResolvedPolicy): CatalogCoverage["package_ins const onnx = policy.resolved.onnx; const input = Array.isArray(onnx.input) ? onnx.input : null; const output = Array.isArray(onnx.output) ? onnx.output : null; - const smoke = onnx.smoke === "passed"; return { - status: input && output && smoke ? "passed" : "failed", + status: input && output && onnx.smoke === "passed" ? "passed" : "failed", input_shape: input, output_shape: output, scope: nullableString(onnx.scope), }; } -/** Convert a resolved schema-2 package into the same public record shape. */ +function authorsForPolicy(policy: ResolvedPolicy, manifest: Record): CatalogEntry["authors"] { + if (policy.curation.authors?.length) { + return policy.curation.authors.map((author) => ({ + name: author.name, + affiliation: author.affiliation ?? null, + github: author.github ?? null, + url: author.url ?? null, + })); + } + const repoOwner = policy.source.repo.split("/", 1)[0] ?? policy.source.repo; + const author = manifestString(manifest, "author") ?? repoOwner; + return [{ name: author, affiliation: null, github: null, url: null }]; +} + +function publicInstallRoute(policy: ResolvedPolicy): CatalogRuntime["install"]["route"] { + if (policy.source.provider !== "huggingface-model" || policy.resolved.policy_set) return "review"; + return policy.resolved.install_route; +} + +function installReviewReason(policy: ResolvedPolicy): string | null { + if (policy.source.provider !== "huggingface-model") { + return "No supported robotctl install route exists for GitHub or Hugging Face Space sources."; + } + if (policy.resolved.policy_set) { + return "Official policy-set artifacts are updated as a set; no per-entry robotctl install command is synthesized."; + } + return nullableString(policy.resolved.install_unresolved?.[0]); +} + +function installCommand(policy: ResolvedPolicy, manifest: Record, route: CatalogRuntime["install"]["route"]): string | null { + if (route === "review" || policy.source.provider !== "huggingface-model" || policy.resolved.policy_set) return null; + const selector = policy.source.artifact_path === "policy.onnx" ? "" : `:${policy.source.artifact_path}`; + const target = `${policy.source.repo}@${policy.source.revision}${selector}`; + if (route === "skill") return `robotctl policy add ${policy.id} ${target}`; + const slot = manifestString(manifest, "slot"); + return slot ? `robotctl policy load ${slot} ${target}` : null; +} + +/** Resolve one authored policy into the one public CatalogEntry shape. */ export function catalogEntryFromPolicy( policy: ResolvedPolicy, evidence?: CatalogSimulationEvidence | null, ): CatalogEntry { const manifest = manifestObject(policy); - const robot = manifestRobot(manifest); - const repo = policy.source.repo; - const revision = nullableRevision(policy.source.revision); - const packageUrl = revision ? `https://huggingface.co/${repo}/tree/${revision}` : null; - const artifactUrl = revision ? `https://huggingface.co/${repo}/resolve/${revision}/policy.onnx` : null; - const owner = repo.split("/", 1)[0] ?? repo; - const author = manifestString(manifest, "author") ?? owner; - const manifestDescription = manifestString(manifest, "description"); - const summary = policy.curation.summary ?? manifestDescription; - const unresolved = [...policy.resolved.unresolved]; - const route = policy.resolved.install_route; - const slot = manifestString(manifest, "slot"); - const installTarget = `${repo}@${revision ?? ""}`.replace(/@$/, ""); - const command = route === "skill" - ? `robotctl policy add ${policy.id} ${installTarget}` - : route === "slot" && slot - ? `robotctl policy load ${slot} ${installTarget}` - : null; + const robot = nestedRecord(manifest, "robot"); + const compatibility = nestedRecord(manifest, "compatibility"); + const training = nestedRecord(manifest, "training"); + const source = policy.source; + const route = publicInstallRoute(policy); + const requirements = policy.curation.requirements; + const publisherHardware = policy.curation.publisher_hardware; const authorMedia = authorMediaFromPolicy(policy); const registry = registryMedia(evidence); - const contractObservation = manifestNumber(manifest, "obs_len"); - const contractAction = manifestNumber(manifest, "action_len"); - const robotControlHz = nullableNumber(robot.control_hz); - const robotModel = nullableString(robot.model); - const training = manifestTraining(manifest); - const trainingUrl = - httpsOrNull(manifestString(manifest, "training_code_url")) ?? trainingRepoUrl(training); - const taskId = - manifestString(manifest, "task_id") ?? - manifestString(manifest, "task") ?? - nullableString(training.task_id); + const description = policy.curation.summary ?? manifestString(manifest, "description") ?? `Policy artifact from ${source.repo}.`; + const robotModel = nullableString(robot.model) ?? requirements?.robot_model ?? null; + const accessories = stringArray(compatibility.accessories_required) ?? (requirements ? requirements.accessories : null); + const terrain = stringArray(compatibility.terrain) ?? (requirements ? requirements.terrain : null); + const trainingRepo = nullableString(training.repo); + const trainingUrl = trainingRepo && /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(trainingRepo) + ? httpsOrNull(`https://github.com/${trainingRepo}`) + : null; + const taskId = manifestString(manifest, "task_id") ?? manifestString(manifest, "task") ?? nullableString(training.task_id); + const packageUrl = `${sourceBase(source.provider, source.repo)}/tree/${source.revision}`; + const artifact = artifactUrl(source); + const unresolved = [...policy.resolved.unresolved, ...(policy.resolved.install_unresolved ?? [])]; + const simulationReason = policy.resolved.simulation.status === "not-covered" + ? policy.resolved.simulation.reason + : null; + if (simulationReason && !unresolved.includes(simulationReason)) unresolved.push(simulationReason); + const catalogEvidence = evidence ?? (simulationReason + ? { status: "not-covered" as const, reason: simulationReason } + : null); return CatalogEntrySchema.parse({ id: policy.id, - name: manifestString(manifest, "name") ?? policy.id, + name: policy.curation.name ?? manifestString(manifest, "name") ?? policy.id, version: manifestString(manifest, "version"), - description: summary ?? "Microduck policy published on Hugging Face.", - details: null, + description, + details: policy.curation.details ?? null, category: policy.curation.category, tags: [...policy.curation.tags], - authors: [{ name: author, affiliation: null, github: null, url: null }], - license: normalizeLicense(policy.resolved.license), + authors: authorsForPolicy(policy, manifest), + license: normalizeLicense(policy.curation.license ?? policy.resolved.license), curation: { summary: policy.curation.summary ?? null, notes: policy.curation.notes ?? null, }, source: { - kind: "pollen-hub", - repository_url: `https://huggingface.co/${repo}`, + kind: source.provider, + repository_url: sourceBase(source.provider, source.repo), package_url: packageUrl, - revision, - manifest_sha256: nullableSha(policy.source.manifest_sha256), + revision: source.revision, + manifest_sha256: source.manifest_sha256, artifact: { - filename: "policy.onnx", - url: artifactUrl, - sha256: nullableSha(policy.source.artifact_sha256), + filename: source.artifact_path.split("/").pop() ?? source.artifact_path, + url: artifact, + sha256: source.artifact_sha256, }, upstream: { - runtime_url: null, training_url: trainingUrl, - simulator_url: null, + simulator_url: source.provider === "huggingface-space" ? sourceBase(source.provider, source.repo) : null, task_id: taskId, }, }, runtime: { - classification: policy.resolved.runtime, + status: policy.resolved.resolution, kind: manifest.kind === "episodic" || manifest.kind === "perpetual" || manifest.kind === "scripted" ? manifest.kind : null, - slot, + slot: manifestString(manifest, "slot"), duration_s: manifestNumber(manifest, "duration_s"), unwind_s: manifestNumber(manifest, "unwind_s"), command_encoding: manifestCommandEncoding(manifest), @@ -594,30 +434,31 @@ export function catalogEntryFromPolicy( servos: nullableString(robot.servos), }, contract: { - observation_dim: contractObservation, - action_dim: contractAction, - control_frequency_hz: robotControlHz, + observation_dim: manifestNumber(manifest, "obs_len"), + action_dim: manifestNumber(manifest, "action_len"), + control_frequency_hz: nullableNumber(robot.control_hz), action_scale: manifestNumber(manifest, "action_scale"), - decimation: null, - actuator_model: null, + decimation: manifestNumber(manifest, "decimation"), + actuator_model: manifestString(manifest, "actuator_model"), }, compatibility: { robot_model: robotModel, - accessories_required: null, - terrain: null, + accessories_required: accessories, + terrain, }, install: { route, - command, - config: null, + command: installCommand(policy, manifest, route), + reason: route === "review" ? installReviewReason(policy) : null, }, unresolved, }, - coverage: coverage(packageInspection(policy), evidence), + coverage: coverage(packageInspection(policy), catalogEvidence), hardware: { - status: "none", - target: robotModel, - note: "No registry hardware verification; upstream manifest and eval metadata are publisher facts.", + status: publisherHardware?.status === "claimed" ? "author-claimed" : "none", + target: publisherHardware?.target ?? null, + source_url: publisherHardware?.status === "claimed" ? publisherHardware.source_url : null, + note: publisherHardware?.note ?? "No independent registry hardware evidence is recorded; upstream media and evaluation remain publisher claims.", }, media: { author: authorMedia, @@ -627,15 +468,11 @@ export function catalogEntryFromPolicy( }); } -export function catalogEntriesFromSources( - behaviors: Behavior[], +export function catalogEntries( policies: ResolvedPolicy[], evidenceById: ReadonlyMap = new Map(), ): CatalogEntry[] { - const entries = [ - ...behaviors.map((behavior) => catalogEntryFromBehavior(behavior, evidenceById.get(behavior.id))), - ...policies.map((policy) => catalogEntryFromPolicy(policy, evidenceById.get(policy.id))), - ]; - return entries.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); + return policies + .map((policy) => catalogEntryFromPolicy(policy, evidenceById.get(policy.id))) + .sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); } - diff --git a/registry/schema/policy.ts b/registry/schema/policy.ts index 18cf582..fa8b7b3 100644 --- a/registry/schema/policy.ts +++ b/registry/schema/policy.ts @@ -1,40 +1,120 @@ -import { z } from 'zod'; -import { BehaviorCategorySchema } from './behavior'; -import { isHttpsUrl } from './allowlist'; - -/** Authored state: immutable upstream pointer and editorial choices only. */ -export const PolicyPointerSchema = z.strictObject({ - id: z.string().max(100).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/), - source: z.strictObject({ - repo: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/), - revision: z.string().regex(/^[a-f0-9]{40}$/), - artifact_sha256: z.string().regex(/^[a-f0-9]{64}$/), - manifest_sha256: z.string().regex(/^[a-f0-9]{64}$/), - }), - curation: z.strictObject({ - category: BehaviorCategorySchema, - tags: z.array(z.string().min(1).max(80)).max(20).default([]), - summary: z.string().max(4000).optional(), - notes: z.string().max(4000).optional(), - }), - media: z.array(z.strictObject({ - type: z.enum(['image', 'video']), - url: z.string().refine(isHttpsUrl), - label: z.string(), - })).max(10).optional(), +import { z } from "zod"; +import { GITHUB_USERNAME_PATTERN, ID_PATTERN, isHttpsUrl } from "./allowlist"; + +const strict = (shape: T) => z.strictObject(shape); +const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/); +const RevisionSchema = z.string().regex(/^[a-f0-9]{40}$/); +const RepositorySchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/); +const RelativePathSchema = z.string().regex( + /^(?!\/)(?!.*(?:^|\/)\.\.?(?:\/|$))[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/, + "Must be a safe relative upstream path", +); +const HttpsUrlSchema = z.string().url().refine(isHttpsUrl, { + message: "Must be a valid https:// URL without embedded credentials", +}); + +export const PolicyProviderSchema = z.enum(["github", "huggingface-model", "huggingface-space"]); +export type PolicyProvider = z.infer; + +export const PolicyCategorySchema = z.enum([ + "locomotion", + "agility-tricks", + "manipulation", + "recovery", + "roller-skate", + "experimental", +]); +export type PolicyCategory = z.infer; + +export const PolicyAuthorSchema = strict({ + name: z.string().min(1).max(200), + affiliation: z.string().min(1).max(200).optional(), + github: z.string().regex(GITHUB_USERNAME_PATTERN).optional(), + url: HttpsUrlSchema.optional(), }); -export type PolicyPointer = z.infer; -export interface ResolvedPolicy extends PolicyPointer { +export type PolicyAuthor = z.infer; + +const PolicySourceSchema = strict({ + provider: PolicyProviderSchema, + repo: RepositorySchema, + revision: RevisionSchema, + artifact_path: RelativePathSchema.refine((value) => value.toLowerCase().endsWith(".onnx"), "Artifact must be an ONNX file"), + artifact_sha256: Sha256Schema, + manifest_path: RelativePathSchema.nullable(), + manifest_sha256: Sha256Schema.nullable(), +}).superRefine((source, context) => { + if ((source.manifest_path === null) !== (source.manifest_sha256 === null)) { + context.addIssue({ + code: "custom", + path: ["manifest_path"], + message: "manifest_path and manifest_sha256 must be both present or both null", + }); + } +}); +export type PolicySource = z.infer; + +const PolicyMediaSchema = z.array(strict({ + type: z.enum(["image", "video"]), + url: HttpsUrlSchema, + label: z.string().min(1).max(240), +})).max(20); + +const PolicyRequirementsSchema = strict({ + robot_model: z.string().min(1).max(120), + accessories: z.array(z.string().min(1).max(120)).max(20), + terrain: z.array(z.string().min(1).max(120)).max(20), +}); + +const PublisherHardwareSchema = strict({ + status: z.enum(["claimed", "not-claimed", "unknown"]), + target: z.string().min(1).max(400).nullable(), + source_url: HttpsUrlSchema.nullable(), + note: z.string().min(1).max(4000).nullable(), +}).superRefine((hardware, context) => { + if (hardware.status === "claimed" && hardware.source_url === null) { + context.addIssue({ + code: "custom", + path: ["source_url"], + message: "Claimed publisher hardware facts require a source URL", + }); + } +}); + +const PolicyCurationSchema = strict({ + category: PolicyCategorySchema, + tags: z.array(z.string().min(1).max(80)).max(20).default([]), + name: z.string().min(2).max(200).optional(), + summary: z.string().min(1).max(4000).optional(), + details: z.string().min(1).max(8000).optional(), + authors: z.array(PolicyAuthorSchema).min(1).max(20).optional(), + license: z.string().min(1).max(200).optional(), + notes: z.string().min(1).max(4000).optional(), + requirements: PolicyRequirementsSchema.optional(), + publisher_hardware: PublisherHardwareSchema.optional(), +}); + +/** The only authored registry format: an immutable source plus curation. */ +export const PolicySchema = strict({ + id: z.string().regex(ID_PATTERN, "Must be a lowercase kebab-case slug").max(100), + source: PolicySourceSchema, + curation: PolicyCurationSchema, + media: PolicyMediaSchema.optional(), +}); +export type Policy = z.infer; + +export interface ResolvedPolicy extends Policy { resolved: { - source: PolicyPointer['source']; - manifest: Record; + source: PolicySource; + manifest: Record | null; + policy_set: boolean; license: string | null; - runtime: 'pollen-hub' | 'pollen-review'; - install_route: 'skill' | 'slot' | 'review'; + resolution: "ready" | "review"; + install_route: "skill" | "slot" | "review"; unresolved: string[]; + install_unresolved: string[]; onnx: { input: unknown[]; output: unknown[]; smoke: string; scope: string }; simulation: - | { status: 'covered'; runner: string; recipe: Record; scope: string } - | { status: 'not-covered'; reason: string }; + | { status: "covered"; recipe: Record; scope: string } + | { status: "not-covered"; reason: string }; }; } diff --git a/research/awesome-microduck-inventory.md b/research/awesome-microduck-inventory.md index fb406b3..3f2071a 100644 --- a/research/awesome-microduck-inventory.md +++ b/research/awesome-microduck-inventory.md @@ -3,7 +3,7 @@ Working inventory for the uDuck Registry. This file records the current curation surface of [`ob1-s/awesome-microduck`](https://github.com/ob1-s/awesome-microduck) and the policy/choreography leads that were found but are not ready for a -uDuck Registry descriptor. It does not change the awesome list or add any of +uDuck Registry entry. It does not change the awesome list or add any of the research leads to the registry. Source snapshot: `ob1-s/awesome-microduck` `main` at @@ -13,7 +13,7 @@ retrieved 2026-08-31. The source README is preserved at ## Curation model -The list is manually curated for signal: a short list of independent projects +The list is maintainer-curated for signal: a short list of independent projects someone can inspect, run, build, or learn from today. Its contribution guide asks for: @@ -41,11 +41,11 @@ rewriting. - [Embodied Agent](https://github.com/mjschock/embodied-agent) — Simulation-first multi-robot agent platform with a MicroDuck MuJoCo/ONNX adapter and semantic skill API. - [Meckie Duck Gateway](https://github.com/rangerchaz/meckie-duck-gateway) — Small HTTP gateway and hardware-free protocol double for experimenting with MicroDuck control from scripts, agents, or home automation. - [MicroDuck MCP](https://github.com/aj-dev-smith/microduck-mcp) — MCP server and CPU MuJoCo simulator exposing MicroDuck intents, sensing, tricks, camera frames, and agent-facing tools. -- [MicroDuck Runtime (legacy)](https://github.com/TommyZihao/microduck_runtime) — Community Raspberry Pi runtime with standing body-pose controls for Z height, pitch, and roll; exploratory and separate from Pollen's current runtime. +- [MicroDuck Runtime (older)](https://github.com/TommyZihao/microduck_runtime) — Community Raspberry Pi runtime with standing body-pose controls for Z height, pitch, and roll; exploratory and separate from Pollen's current runtime. - [OpenCastor — MicroDuck](https://docs.opencastor.com/robots/microduck/) — Third-party OpenCastor integration that discovers MicroDucks, sends intent commands through `robotd`, and composes routines. - [quackd](https://github.com/rokbenko/quackd) — LLM goal-planning layer with a bundled simulator, `.duck` task files, safety rules, and MCP support. - [Strands Robots — MicroDuck](https://strands-labs.github.io/robots/policies/microduck/) — Third-party Python/MuJoCo provider for running Pollen MicroDuck policies through a common simulation and hardware interface. -- [uDuck Registry](https://uduck-registry.pages.dev/) — Community catalog of MicroDuck policy descriptors and artifact links. +- [uDuck Registry](https://uduck-registry.pages.dev/) — Community catalog of MicroDuck policy entries and immutable artifact links. ### Simulation & policy research @@ -59,7 +59,7 @@ rewriting. - [MicroDuck AR](https://huggingface.co/spaces/multimodalart/microduck-ar) — Community WebXR/AR adaptation of the MicroDuck simulator with AR placement and ground-pick interaction; it uses Pollen's policies rather than publishing new weights. - [MicroDuck iPhone Simulator](https://github.com/littlejohntj/microduck-sim) — Native Swift/MuJoCo/RealityKit simulator that runs the released policies on-device and includes AR mode. -- [MicroDuck Jump Playground](https://github.com/Liyucheng1997/318_lab-microduck-simulator) — Fork of the browser simulator with a custom-trained vertical-jump policy and live demo; simulation-only, with no hardware validation. +- [MicroDuck Jump Playground](https://github.com/Liyucheng1997/318_lab-microduck-simulator) — Fork of the browser simulator with a separately trained vertical-jump policy and live demo; simulation-only, with no hardware validation. - [Microquack](https://github.com/lryain/microquack) — Procedural droid-voice engine and WebAssembly experience for MicroDuck, built around a reusable Rust core. ### Hardware & fabrication @@ -78,24 +78,24 @@ Pollen's official MicroDuck software: These are intentionally not repeated in the not-ready queue: -- [Microduck Running](../registry/behaviors/running.json) -- [Flamingo Cycle](../registry/behaviors/flamingo-cycle.json) -- [Rough Walk E](../registry/behaviors/rough-walk-e.json) -- [Rough Walk G](../registry/behaviors/rough-walk-g.json) +- [Microduck Running](../registry/policies/running.json) +- [Flamingo Cycle](../registry/policies/flamingo-cycle.json) +- [Rough Walk E](../registry/policies/rough-walk-e.json) +- [Rough Walk G](../registry/policies/rough-walk-g.json) They are standalone public ONNX exports that meet the current 61-observation, -14-action, 50 Hz descriptor contract, and are listed in the registry as -`community_experimental`. +14-action, 50 Hz contract, and are listed in the registry as experimental +community entries. ## Policy and choreography leads not yet uDuck-ready -“Not uDuck-ready” here means not ready for a current uDuck Registry descriptor; +“Not uDuck-ready” here means not ready for a current uDuck Registry entry; it does not mean the project is uninteresting or should never appear on the awesome list. ### Public policy artifacts with a current registry blocker -- [Step-Up + Head-Brake Recovery](https://github.com/bihaokun/microduck-step-up-policy) ([Hugging Face release](https://huggingface.co/Nupr-Haokun/microduck-step-up-head-brake)) — Strong simulation-only release with two coordinated ONNX policies, a public source snapshot, and a 25 mm step-up evaluation. The current registry schema models one ONNX artifact per descriptor, so the walking/recovery bundle needs a small schema or descriptor-design decision first. +- [Step-Up + Head-Brake Recovery](https://github.com/bihaokun/microduck-step-up-policy) ([Hugging Face release](https://huggingface.co/Nupr-Haokun/microduck-step-up-head-brake)) — Strong simulation-only release with two coordinated ONNX policies, a public source snapshot, and a 25 mm step-up evaluation. The current registry entry models one ONNX artifact, so the walking/recovery bundle needs a separate entry for each artifact first. - [Polite Bow](https://huggingface.co/fffiloni/microduck-polite-bow-b1d864) — Public ONNX and simulation preview; the card reports a passed export/quality gate, but does not state 50 Hz and does not provide an explicit artifact license. - [Backward Moonwalk](https://huggingface.co/fffiloni/microduck-moonwalk-backward-55e6af) — Public ONNX and preview for a backward moonwalk task, but the card marks its quality gate as needing review, leaves semantic matching unverified, does not state 50 Hz, and has no explicit artifact license. - [nottyduck](https://github.com/reachjalil/nottyduck) ([policy Hub](https://huggingface.co/reachjalil/nottyduck-policies)) — Real desk-companion/training-lab project, but the public policy Hub currently contains no downloadable policy artifact beyond its README/scaffold. diff --git a/research/ci-sim-viability.md b/research/ci-sim-viability.md deleted file mode 100644 index 4b49c08..0000000 --- a/research/ci-sim-viability.md +++ /dev/null @@ -1,74 +0,0 @@ -# CI simulation + render check: viability assessment - -**Verdict: viable, built, and validated.** Branch `feat/ci-sim-render`. - -## What was built - -- `simulation/` — headless Microduck policy runtime (Python, MuJoCo + onnxruntime), - hash-pinned upstream assets, explicit registry recipes, measured checks, and - a deterministic 512x512 H.264 render loop + poster generator. -- `.github/workflows/sim-check.yml` — PR-gated workflow: detects changed - descriptors, runs one sim job per behavior, uploads report + render artifacts, - and writes a job summary. Requested runner checks can fail the PR; an explicit - external recipe is reported as unsupported rather than treated as a failure. -- Optional `simulation` descriptor block (JSON Schema + zod) to pin a runner, - robot model, scene, start state, scenario, and requested checks per behavior. - -## Ground truth chain - -The official Pollen simulator (HF Space `microduck-simulator`) runs the exact -stack we need: MuJoCo physics + onnxruntime-web policies at 50 Hz, decimation 4, -61D obs = gyro(3) + projected gravity(3) + joint pos rel(14) + joint vel(14) + -last action(14) + command(13). The canonical reference is -`pollen-robotics/microduck_rl` `scripts/infer_policy.py` (Rust `robotd` on the -robot mirrors the same contract). The Space's MJCF (`robot_allcollisions.xml`) -is byte-identical to the one in `microduck_rl`. - -## Validation performed - -1. **Obs-level**: our port's 61D observation at reset matches upstream - `PolicyInference.get_observations()` exactly (max abs diff 0.0, same scene, - same ONNX). -2. **Trajectory-level**: driving both the unmodified upstream reference script - and our runtime with `BEST_alpha_walking.onnx`, cmd vx=0.25, 8 s: - upstream 0.8376 m total / 0.1011 m last-second; ours 0.8133 m / 0.1040 m - (~3% float-ordering drift). Runtime is a faithful port. -3. **Golden-reference limits**: `max-height-jump` (author-documented 0.628 m/s - launch, 31.67 mm rise) does not launch under the standard profile with any - simple trigger encoding we tried (max 0.208 m/s at the XML's 125 Hz default). - The author's bespoke eval protocol is not recoverable from the descriptor - alone — this is exactly what the `simulation` block is for. Checks are - calibrated to "runs safely under the standard contract", not to reproducing - author setups. -4. **Rendering**: EGL software rendering works headless (this box has no GPU); - GH ubuntu runners support the same via `libegl1`. ffmpeg encodes the loop. - -## Findings that matter - -- The registry contract is fixed at 61 observations (including the unified 13D - command) and 14 actions. The runtime accepts a dynamic batch axis but rejects - artifacts whose feature or action dimensions do not match that contract. -- Deterministic CPU sim under-reports locomotion speed vs hardware claims - (~40-50% of commanded vx for the official walk policy with a step command). - Tracking checks verify direction + a minimum fraction, not equality. -- Policy behavior is highly sensitive to the exact command protocol (sitstand - flag, phase-encoded one-shots, kick windows). The named scenarios encode the - documented upstream semantics (from `constants.js`/`infer_policy.py`) without - making the installation slot select a render recipe. - -## China / restricted-region angle - -Sim-rendered loops are generated in CI and uploaded as workflow artifacts for -review. A maintainer may deliberately promote a reviewed result under -`public/media/registry-sim/`; the site uses it only as a fallback when publisher -media is absent and otherwise shows it as a separate diagnostic. Original -author media remains canonical and preferred where available (mirrored via the -existing `remote-cache`). CI does not publish generated renders automatically. - -## Costs - -- Per-behavior sim job: ~1-2 min on ubuntu-latest (assets cached by lock hash; - 6 s rollout ≈ 300 control steps + 150 renders). Worst case (a PR touching all - descriptors) runs the matrix in parallel. -- No GPU, no HF compute, no upstream permission needed (Apache-2.0 assets, - hash-pinned, attributed). diff --git a/research/registry-direction.md b/research/registry-direction.md deleted file mode 100644 index 7b69823..0000000 --- a/research/registry-direction.md +++ /dev/null @@ -1,41 +0,0 @@ -# Registry direction and reconciliation — 2026-09-05 (final) - -## Responsibility - -Pollen defines packaging, publishing, installation, runtime behavior, and robot safety. Hugging Face hosts weights and model cards. uDuck owns discovery, editorial context, immutable artifact references, and reproducible diagnostic observations. We should not invent a competing runtime contract or claim our simulator is the training environment. - -Read GitHub issue [#17](https://github.com/ob1-s/uduck-registry/issues/17). The useful direction is pointer + curation, manifest ingestion, URL submission, and evidence outside source history. Several implementation claims in earlier discussion need correction: - -- [Pollen's manifest](https://github.com/pollen-robotics/microduck/blob/bc41fb5c9a9b39894669c1e022e375cf83800382/docs/policy-manifest.md) explicitly makes fields optional. Missing fields are not compatibility evidence. Schema 2 by itself proves neither origin nor a baked normalizer. -- A constant encoding describes how the daemon feeds a command, not which command activates a behavior. Flamingo is a concrete counterexample: `[flag, side, 0]`. Do not infer a velocity sweep or an all-zero successful behavior from its `kind`. -- [The robot cheatsheet](https://github.com/pollen-robotics/microduck/blob/bc41fb5c9a9b39894669c1e022e375cf83800382/docs/robot/cheatsheet.md) supports raw local files, manifest-less repositories, explicit files in repositories, `@revision`, and held-pose commands. “Not a schema-2 single-policy package” does not mean “impossible to install with robotctl.” Our automated admission policy is intentionally narrower than upstream's runtime. -- Manifest validity and ONNX shape checks cannot justify automatic behavioral certification or hardware verification. The registry runner uses position-control diagnostics, not the publisher's BAM training environment. -- GitHub-token PRs may leave PR workflows pending approval. [Explicit workflow dispatch](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow) runs CI for the bot branch. -- Native ONNX parsing/inference belongs in a job without write credentials. The PR-writing job only validates inert pointer data. - -Upstream pin reviewed 2026-09-05: `pollen-robotics/microduck@bc41fb5c9a9b39894669c1e022e375cf83800382`. Flamingo command `sudo robotctl policy add flamingo RemiFabre/microduck-flamingo-cycle --hold 5 --command 1,1,0` and the all-zero default for plain episodic skills (“Most skills need none: they are trained on an all-zero command”) are documented at that revision. - -## Final architecture - -- Two internal authored sources: `registry/behaviors/` (legacy/manual) and `registry/policies/*.json` (pointer + curation: stable ID, upstream repo, immutable 40-hex revision, manifest SHA-256, artifact SHA-256, category/tags/summary/notes, author media). Pointers contain no runtime copies, no recipes, no verification claims. -- One public consumer model: `CatalogEntry` (RegistryIndex `version 3.0.0`, `count`, `entries` only). Pages, APIs, search, cards, index generation, and social cards consume it. Existing `/behaviors/` URLs remain stable; Flamingo lives at `/behaviors/flamingo-cycle`. Temporary `/policies` routes from earlier WIP were removed before shipping. -- `.generated/policies/` holds build-local resolved upstream facts from the shared Python resolver (CLI, local registration, issue ingestion, build preparation share one implementation). -- Maintainer-owned recipe layer (`simulation/pointer_recipes.py`) separate from pointer JSON. Flamingo binds repo + revision + manifest SHA + artifact SHA + manifest name; any change makes it not-covered. Generic zero-command coverage is a privilege requiring episodic, finite duration, constant/absent encoding, no twist/head/body prose, complete I/O widths, control_hz 50, explicit finite action_scale, and standing/absent entry pose. No silent `action_scale=1.0`. -- Pointer simulation runs through the existing standardized runner with explicit `command_schedule` validation, source-hash verification before inference, and recipe provenance (owner, pinned upstream docs, command, duration, start, runner, scene, checks, scope/limitations, non-eval-reproduction statement). -- Author media (publisher HTTPS links) separated from registry evidence (pinned runner renders). Publisher eval/hardware claims separated from independent verification. -- Durable evidence is a content-addressed GitHub Release (`registry-evidence`): semantic `evidence_key = sha256("uduck-evidence-v2" + inputs + artifact)`, physical `blob_sha256 = sha256(archive bytes)`, asset filename `.tar.gz`, mutable `index.json` mapping current IDs to keys while retaining history. Archived reports exclude wall-clock timestamps; observation time is index metadata. Same-key/same-blob reruns are idempotent; conflicting content raises. -- Execution identity v2 (`uduck-execution-inputs-v2`) covers only execution-relevant authored state plus runner code, asset lock, dependency pins, and `ubuntu-24.04/python3.12` environment contract. Curation-only edits do not rerun simulation. -- CI build hydration: resolve pinned policies → plan cache from durable index → run runner tests → run only uncached diagnostics → temporary artifact → (main only) publish immutable blobs + update index → hydrate trusted current evidence → build → deploy. PRs never publish. Main pushes serializing the mutable index. Required check `Validate Registry & Build` preserved. -- Cross-build cache with pruning of deleted IDs, partial-publish recovery (blob names make retry safe), and fail-closed hydration validation (traversal, symlinks, size, ID/key/inputs/artifact checks, rendered vs report-only file rules, stale-file clearing). -- Read-only ONNX resolution job → separate write-capable proposal job; explicit dispatch for bot branches; failed checks stay visible; unsupported policies stay discoverable as not-covered; no automatic merge; no hardware certification. - -## Deliberate coverage limits and upstream constraints - -- Package inspection (SHA, ONNX I/O, finite zero-input smoke) is separate from registry simulation and from hardware. A successful ONNX inference is not a behavior test; a registry simulation is not hardware verification; publisher media is not registry evidence. -- Flamingo diagnostic tests stability under the documented `[1,1,0]` 5s hold (`no_fall` plus unilateral-support observations). It does not certify one-foot-task success. No unwind is invented (manifest has no `unwind_s`). -- Generic zero coverage applies only when documented upstream semantics plus complete machine-readable contract make the diagnostic non-invented. Otherwise not-covered. -- No nightly discovery, HF crawling, auto-accept/merge, new BAM simulator, hardware testing, contributor identity verification, general robotctl source support, full legacy migration, CDN, or database. Those are future work. - -## Activation check - -The repository has `default_workflow_permissions: read` and `can_approve_pull_request_reviews: false`. The latter disables bot PR creation; enable it before using the URL bot. Default permissions stay read-only. Main protection requires `Validate Registry & Build`; that name is preserved. No repository settings were changed during implementation. diff --git a/scripts/evidence-identity.ts b/scripts/evidence-identity.ts deleted file mode 100644 index c9facc9..0000000 --- a/scripts/evidence-identity.ts +++ /dev/null @@ -1,81 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { createHash } from 'node:crypto'; - -export const EVIDENCE_IDENTITY_VERSION = 'uduck-execution-inputs-v2'; -export const 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'; - -function canonicalJson(value: unknown): string { - if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'; - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; - const entries = Object.entries(value as Record) - .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) - .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`); - return `{${entries.join(',')}}`; -} - -function runnerFiles(): string[] { - function files(dir: string): string[] { - return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => e.isDirectory() ? (e.name === 'tests' ? [] : files(path.join(dir, e.name))) : e.name.endsWith('.py') ? [path.join(dir, e.name)] : []); - } - return [...files('simulation'), 'simulation/assets.lock.json', 'simulation/requirements.txt'].sort(); -} - -export function runnerDigest(): string { - const hash = createHash('sha256'); - for (const file of runnerFiles()) hash.update(file).update('\0').update(fs.readFileSync(file)).update('\0'); - return hash.digest('hex'); -} - -function fileDigest(file: string): string { - return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); -} - -export function executionDescriptor(id: string): unknown { - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error('Invalid behavior id'); - const policyPath = `registry/policies/${id}.json`; - if (fs.existsSync(policyPath)) { - const data = JSON.parse(fs.readFileSync(policyPath, 'utf8')) as Record; - const source = (data.source ?? {}) as Record; - return { - artifact_sha256: source.artifact_sha256 ?? null, - id: data.id ?? null, - kind: 'policy', - manifest_sha256: source.manifest_sha256 ?? null, - repo: source.repo ?? null, - revision: source.revision ?? null, - }; - } - const data = JSON.parse(fs.readFileSync(`registry/behaviors/${id}.json`, 'utf8')) as Record; - const contract = (data.contract ?? {}) as Record; - const compatibility = (data.compatibility ?? {}) as Record; - const onnx = ((data.artifacts ?? {}) as Record).onnx ?? {}; - return { - artifact_url: onnx.url ?? null, - compatibility: { robot_model: compatibility.robot_model ?? null }, - contract: { - action_dim: contract.action_dim ?? null, - action_scale: contract.action_scale ?? null, - actuator_model: contract.actuator_model ?? null, - control_frequency_hz: contract.control_frequency_hz ?? null, - decimation: contract.decimation ?? null, - observation_dim: contract.observation_dim ?? null, - }, - id: data.id ?? null, - kind: 'manual', - simulation: data.simulation ?? null, - }; -} - -export function evidenceInputsDigest(id: string): string { - if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error('Invalid behavior id'); - const hash = createHash('sha256'); - hash.update(EVIDENCE_IDENTITY_VERSION).update('\0'); - hash.update(canonicalJson(executionDescriptor(id))).update('\0'); - hash.update(runnerDigest()).update('\0'); - hash.update(fileDigest('simulation/assets.lock.json')).update('\0'); - hash.update(fileDigest('simulation/requirements.txt')).update('\0'); - hash.update(EVIDENCE_ENV); - return hash.digest('hex'); -} diff --git a/scripts/evidence_store.py b/scripts/evidence_store.py index 4e37f30..a8b6970 100644 --- a/scripts/evidence_store.py +++ b/scripts/evidence_store.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 """Store registry diagnostics in a content-addressed GitHub Release. -The source tree contains authored descriptors and the executable runner. A +The source tree contains authored policies and the executable runner. A simulation result is generated in a temporary CI workspace, then packaged as one immutable release asset named after its content SHA-256 (``.tar.gz``). ``index.json`` is the only mutable release asset: -it maps the current descriptor ids to semantic evidence keys, each of which -points at an immutable blob, and retains older entries for auditability. +it maps the current entry ids to semantic evidence keys, each of which points +at an immutable blob, and retains older entries for auditability. This module deliberately uses only the Python standard library. The native ONNX/MuJoCo runner is used by the evidence job, which has read-only token @@ -57,6 +57,9 @@ RELEASE_INDEX_NAME = "index.json" 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 +# simulation/evidence.py. EVIDENCE_FORMAT = "uduck-evidence-v2" # Wall-clock fields are useful transiently but must not affect content # addressing: two runs with identical execution inputs archive identical bytes. @@ -120,9 +123,9 @@ def read_index(path: Path) -> dict[str, Any]: for key, entry in entries.items(): if not valid_key(key) or not isinstance(entry, dict): raise ValueError(f"invalid evidence index entry: {key!r}") - for behavior_id, key in current.items(): - if not valid_id(behavior_id) or not valid_key(key): - raise ValueError(f"invalid current evidence mapping: {behavior_id!r} -> {key!r}") + for entry_id, key in current.items(): + if not valid_id(entry_id) or not valid_key(key): + raise ValueError(f"invalid current evidence mapping: {entry_id!r} -> {key!r}") result = empty_index() result.update(value) result["version"] = FORMAT_VERSION @@ -227,12 +230,7 @@ def _result_dirs(results: Path) -> list[Path]: def _policy_sha(report: dict[str, Any]) -> str | None: policy = report.get("policy") if isinstance(policy, dict): - for key in ("sha256", "artifact_sha256"): - value = policy.get(key) - if valid_key(value): - return value - for key in ("artifact_sha256", "policy_sha256"): - value = report.get(key) + value = policy.get("sha256") if valid_key(value): return value return None @@ -240,155 +238,70 @@ def _policy_sha(report: dict[str, Any]) -> str | None: def _report_key(report: dict[str, Any]) -> tuple[str, str]: key = report.get("evidence_key") - if valid_key(key): - return key, "runner" identity = report.get("inputs_sha256") artifact = _policy_sha(report) - if valid_key(identity) and valid_key(artifact): - # Keep the key algorithm owned by the runner. Import lazily so this - # publisher remains usable in a minimal Python job. - sys.path.insert(0, str(SIMULATION_DIR)) - from evidence import evidence_key # type: ignore - return evidence_key(identity, artifact), "derived" - - # Unsupported/rejected reports from older runners did not carry identity - # fields. They can still be made visible in the release, but are marked as - # fallback identities and are never treated as a cache hit by ``plan``. - stable = {k: v for k, v in report.items() if k != "generated_at"} - return sha256_bytes(b"uduck-report-v1\0" + canonical_json(stable)), "report-fallback" - - -def _authored_descriptors() -> dict[str, Path]: - """Return the one descriptor source for every current catalog id.""" + if not (valid_key(key) and valid_key(identity) and valid_key(artifact)): + raise ValueError("every evidence report must carry inputs_sha256, artifact_sha256, and evidence_key") + sys.path.insert(0, str(SIMULATION_DIR)) + from evidence import evidence_key # type: ignore + expected = evidence_key(identity, artifact) + if key != expected: + raise ValueError("evidence_key does not match the report inputs") + return key, "runner" + + +def _authored_policies() -> dict[str, Path]: + """Return the one authored policy source for every current catalog id.""" result: dict[str, Path] = {} - for directory in (ROOT / "registry" / "behaviors", ROOT / "registry" / "policies"): - if not directory.is_dir(): - continue - for path in sorted(directory.glob("*.json")): - value = read_json(path) - behavior_id = value.get("id") if isinstance(value, dict) else None - if not valid_id(behavior_id) or path.stem != behavior_id: - raise ValueError(f"descriptor filename/id mismatch: {path}") - if behavior_id in result: - raise ValueError(f"duplicate authored descriptor id: {behavior_id}") - result[behavior_id] = path + directory = ROOT / "registry" / "policies" + if not directory.is_dir(): + return result + for path in sorted(directory.glob("*.json")): + value = read_json(path) + entry_id = value.get("id") if isinstance(value, dict) else None + if not valid_id(entry_id) or path.stem != entry_id: + raise ValueError(f"policy filename/id mismatch: {path}") + if entry_id in result: + raise ValueError(f"duplicate authored policy id: {entry_id}") + result[entry_id] = path return result -def _descriptor_identity(path: Path, behavior_id: str) -> str: - """Use the exact identity implementation used by ``run_check``. - - There is intentionally no generic fallback. If a new descriptor class is - introduced, its runner must teach ``simulation.evidence.inputs_digest`` how - to represent it before evidence can be cached or hydrated. - """ +def _policy_inputs(entry_id: str) -> str: + """Use the exact identity implementation used by the runner.""" sys.path.insert(0, str(SIMULATION_DIR)) try: from evidence import inputs_digest # type: ignore except ImportError as exc: raise ValueError("simulation.evidence.inputs_digest is unavailable") from exc try: - return inputs_digest(behavior_id) + return inputs_digest(entry_id) except Exception as exc: # noqa: BLE001 - raise ValueError(f"unable to compute canonical evidence identity for {behavior_id}: {exc}") from exc + raise ValueError(f"unable to compute canonical evidence identity for {entry_id}: {exc}") from exc def _validate_report(report: Any, path: Path) -> dict[str, Any]: if not isinstance(report, dict): raise ValueError(f"report must be an object: {path}") - behavior_id = report.get("behavior") - if not valid_id(behavior_id): - raise ValueError(f"report has an unsafe behavior id: {path}") + entry_id = report.get("entry") + if not valid_id(entry_id): + raise ValueError(f"report has an unsafe entry id: {path}") execution = report.get("execution") - if execution not in ("rendered", "unsupported", "rejected", "failed"): + if execution not in ("rendered", "not-covered", "rejected", "failed"): raise ValueError(f"report has unsupported execution status {execution!r}: {path}") return report -def _explicit_artifact_sha(descriptor: dict[str, Any]) -> str | None: - candidates: list[Any] = [descriptor] - for key in ("source", "resolved"): - child = descriptor.get(key) - if isinstance(child, dict): - candidates.append(child) - source = child.get("source") - if isinstance(source, dict): - candidates.append(source) - for candidate in candidates: - for key in ("artifact_sha256", "policy_sha256"): - value = candidate.get(key) if isinstance(candidate, dict) else None - if valid_key(value): - return value - return None - - -def _artifact_url(descriptor: dict[str, Any]) -> str | None: - onnx = descriptor.get("artifacts", {}).get("onnx") if isinstance(descriptor.get("artifacts"), dict) else None - if isinstance(onnx, dict) and isinstance(onnx.get("url"), str): - return onnx["url"] - source = descriptor.get("source") - if isinstance(source, dict) and isinstance(source.get("repo"), str) and valid_git_revision(source.get("revision")): - return f"https://huggingface.co/{source['repo']}/resolve/{source['revision']}/policy.onnx" - resolved = descriptor.get("resolved") - if isinstance(resolved, dict): - return _artifact_url(resolved) - return None - - def normalize_report_for_archive(report: dict[str, Any]) -> dict[str, Any]: """Return the immutable archived report without volatile timestamps.""" return {k: v for k, v in report.items() if k not in VOLATILE_REPORT_FIELDS} -def _augment_report_identity(report: dict[str, Any]) -> dict[str, Any]: - """Fill identity fields for old unsupported/rejected runner reports.""" - behavior_id = report["behavior"] - descriptor_path = _authored_descriptors().get(behavior_id) - if descriptor_path is None: - return report - descriptor = read_json(descriptor_path) - if not isinstance(descriptor, dict): - return report - inputs_sha = _descriptor_identity(descriptor_path, behavior_id) - existing_inputs = report.get("inputs_sha256") - if existing_inputs is not None and existing_inputs != inputs_sha: - raise ValueError(f"report input identity does not match current descriptor: {behavior_id}") - report["inputs_sha256"] = inputs_sha - - artifact_sha = _policy_sha(report) or _explicit_artifact_sha(descriptor) - if artifact_sha: - policy = report.setdefault("policy", {}) - if not isinstance(policy, dict): - raise ValueError(f"report policy field is not an object: {behavior_id}") - existing_artifact = _policy_sha(report) - if existing_artifact is not None and existing_artifact != artifact_sha: - raise ValueError(f"report artifact identity does not match current descriptor: {behavior_id}") - policy["sha256"] = artifact_sha - expected_key = None - sys.path.insert(0, str(SIMULATION_DIR)) - try: - from evidence import evidence_key # type: ignore - expected_key = evidence_key(inputs_sha, artifact_sha) - except ImportError: - expected_key = None - if expected_key is not None: - current_key = report.get("evidence_key") - if current_key is not None and current_key != expected_key: - raise ValueError(f"report evidence key does not match current inputs: {behavior_id}") - report["evidence_key"] = expected_key - return report - - def normalize_report_identity(report: dict[str, Any]) -> dict[str, Any]: - """Canonical identity path used by package, local discovery, and hydration. - - Every evidence-key computation must go through here so a new unsupported - pointer on a PR produces the same key in its temporary index and in the - local-results map. Without this, hydrate cannot find the local result and - tries to download an unpublished Release asset. - """ - validated = _validate_report(report, Path(f"report:{report.get('behavior', '?')}")) - return _augment_report_identity(validated) + """Canonical identity path used by package, local discovery, and hydration.""" + validated = _validate_report(report, Path(f"report:{report.get('entry', '?')}")) + _report_key(validated) + return validated def package(results: Path, out: Path, fragment: Path) -> dict[str, Any]: @@ -402,12 +315,12 @@ def package(results: Path, out: Path, fragment: Path) -> dict[str, Any]: if report_path.stat().st_size > MAX_REPORT_BYTES: raise ValueError(f"report exceeds {MAX_REPORT_BYTES} bytes: {report_path}") report = normalize_report_identity(read_json(report_path)) - behavior_id = report["behavior"] + entry_id = report["entry"] key, identity_source = _report_key(report) # Archive the normalized report so wall-clock timestamps do not break # content addressing. Observation/upload time lives in index metadata. normalized = normalize_report_for_archive(report) - files: list[tuple[str, bytes]] = [(f"{behavior_id}/report.json", canonical_json(normalized) + b"\n")] + files: list[tuple[str, bytes]] = [(f"{entry_id}/report.json", canonical_json(normalized) + b"\n")] present: list[str] = [] for filename in ("loop.mp4", "poster.png"): source = result_dir / filename @@ -415,12 +328,12 @@ def package(results: Path, out: Path, fragment: Path) -> dict[str, Any]: data = source.read_bytes() if len(data) > MAX_EVIDENCE_BYTES: raise ValueError(f"evidence file exceeds size limit: {source}") - files.append((f"{behavior_id}/{filename}", data)) + files.append((f"{entry_id}/{filename}", data)) present.append(filename) if report["execution"] == "rendered" and set(present) != {"loop.mp4", "poster.png"}: raise ValueError(f"rendered report is missing loop.mp4 or poster.png: {result_dir}") - if report["execution"] in ("unsupported", "rejected", "failed") and present not in ([], ["loop.mp4", "poster.png"]): + if report["execution"] in ("not-covered", "rejected", "failed") and present not in ([], ["loop.mp4", "poster.png"]): # Report-only evidence is legitimate; partial media is not. if present: raise ValueError(f"non-rendered report must not carry partial media: {result_dir}") @@ -437,7 +350,7 @@ def package(results: Path, out: Path, fragment: Path) -> dict[str, Any]: artifact_sha = _policy_sha(report) entry = { - "behavior": behavior_id, + "entry": entry_id, "key": key, "asset": asset_name, "asset_sha256": blob_sha, @@ -463,7 +376,7 @@ def package(results: Path, out: Path, fragment: Path) -> dict[str, Any]: # the first observation time, ignore wall-clock reruns. continue entries[key] = entry - current[behavior_id] = key + current[entry_id] = key fragment_value = { "version": FORMAT_VERSION, @@ -498,10 +411,10 @@ def merge(existing: Path, fragment: Path, out: Path) -> dict[str, Any]: # Prune deleted IDs from current while retaining historical blobs in # entries for audit. The evidence plan knows the entire desired catalog. try: - authored_ids = set(_authored_descriptors()) + authored_ids = set(_authored_policies()) except Exception: authored_ids = set(current) - for stale_id in [bid for bid in current if bid not in authored_ids]: + for stale_id in [entry_id for entry_id in current if entry_id not in authored_ids]: del current[stale_id] base["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat() out.parent.mkdir(parents=True, exist_ok=True) @@ -509,87 +422,39 @@ def merge(existing: Path, fragment: Path, out: Path) -> dict[str, Any]: return base -def _descriptor_files() -> list[Path]: - return list(_authored_descriptors().values()) - - -def _descriptor_inputs(path: Path, behavior_id: str) -> str: - if path not in _authored_descriptors().values(): - raise ValueError(f"descriptor is not an authored registry input: {path}") - return _descriptor_identity(path, behavior_id) - - -def _hash_artifact(url: str) -> str: - parsed = urllib.parse.urlsplit(url) - if parsed.scheme != "https" or parsed.username or parsed.password or parsed.hostname not in ("huggingface.co", "raw.githubusercontent.com"): - raise ValueError(f"artifact host is not allowed: {url}") - request = urllib.request.Request(url, headers={"User-Agent": "uduck-registry-evidence"}) - # This uses the same bounded retry behavior as the simulation runner when - # available, while keeping planning independent from MuJoCo imports. - try: - sys.path.insert(0, str(SIMULATION_DIR)) - from http_download import open_download # type: ignore - response = open_download(request, timeout=300) - except ImportError: - response = urllib.request.urlopen(request, timeout=300) - with response: - digest = hashlib.sha256() - size = 0 - while True: - chunk = response.read(1 << 20) - if not chunk: - break - size += len(chunk) - if size > MAX_ONNX_BYTES: - raise ValueError("ONNX artifact exceeds 100 MB sanity bound") - digest.update(chunk) - return digest.hexdigest() +def _policy_files() -> list[Path]: + return list(_authored_policies().values()) def plan(index_path: Path, out: Path) -> dict[str, Any]: - """Plan expensive simulations, reusing a release result when safe. - - A missing index deliberately means ``run`` for every descriptor. On a main - build with an index, descriptors are first compared by runner input - identity. For a legacy descriptor without an authored artifact hash, the - immutable byte hash is downloaded only when that identity is otherwise a - candidate for reuse. - """ + """Plan execution for each authored policy, reusing only exact identities.""" index = read_index(index_path) current = index.get("current", {}) entries = index.get("entries", {}) items: list[dict[str, Any]] = [] - for path in _descriptor_files(): - descriptor = read_json(path) - if not isinstance(descriptor, dict): + for policy_path in _policy_files(): + policy = read_json(policy_path) + if not isinstance(policy, dict): continue - behavior_id = descriptor.get("id") - if not valid_id(behavior_id): + entry_id = policy.get("id") + if not valid_id(entry_id): continue - inputs_sha = _descriptor_inputs(path, behavior_id) - previous_key = current.get(behavior_id) + source = policy.get("source") + artifact_sha = source.get("artifact_sha256") if isinstance(source, dict) else None + if not valid_sha256(artifact_sha): + raise ValueError(f"policy has no valid artifact SHA256: {entry_id}") + inputs_sha = _policy_inputs(entry_id) + previous_key = current.get(entry_id) previous = entries.get(previous_key) if valid_key(previous_key) else None item: dict[str, Any] = { - "behavior": behavior_id, - "descriptor": str(path.relative_to(ROOT)), + "entry": entry_id, + "policy": str(policy_path.relative_to(ROOT)), "inputs_sha256": inputs_sha, + "artifact_sha256": artifact_sha, "status": "run", } - if isinstance(previous, dict) and previous.get("inputs_sha256") == inputs_sha and valid_key(previous_key): - artifact_sha = _explicit_artifact_sha(descriptor) - if artifact_sha is None: - url = _artifact_url(descriptor) - if url: - try: - artifact_sha = _hash_artifact(url) - except Exception as exc: # noqa: BLE001 - # A transient upstream failure must never turn a - # missing verification into a cache hit. Re-run the - # trusted diagnostic, which will report the actual - # download failure if the source remains unavailable. - print(f"[{behavior_id}] unable to verify cached artifact; scheduling a fresh run: {exc}", file=sys.stderr) - if artifact_sha and previous.get("artifact_sha256") == artifact_sha: - item.update({"status": "cached", "evidence_key": previous_key, "artifact_sha256": artifact_sha}) + if isinstance(previous, dict) and previous.get("inputs_sha256") == inputs_sha and previous.get("artifact_sha256") == artifact_sha and valid_key(previous_key): + item.update({"status": "cached", "evidence_key": previous_key}) items.append(item) value = {"version": FORMAT_VERSION, "format": "uduck-evidence-plan-v1", "items": items} @@ -632,7 +497,7 @@ def _local_results(local: Path | None) -> dict[str, Path]: return result -def _extract_archive(data: bytes, behavior_id: str, out: Path) -> dict[str, bytes]: +def _extract_archive(data: bytes, entry_id: str, out: Path) -> dict[str, bytes]: if len(data) > MAX_EVIDENCE_BYTES: raise ValueError("evidence release asset exceeds size limit") files: dict[str, bytes] = {} @@ -641,9 +506,9 @@ def _extract_archive(data: bytes, behavior_id: str, out: Path) -> dict[str, byte name = _safe_relative_name(member.name) if not member.isfile() or member.issym() or member.islnk(): raise ValueError(f"evidence archive contains a non-regular member: {member.name}") - expected_prefix = behavior_id + "/" + expected_prefix = entry_id + "/" if not name.startswith(expected_prefix): - raise ValueError(f"evidence archive member is for a different behavior: {member.name}") + raise ValueError(f"evidence archive member is for a different entry: {member.name}") filename = name[len(expected_prefix):] if filename not in RESULT_FILE_NAMES: raise ValueError(f"unexpected evidence archive member: {member.name}") @@ -661,7 +526,7 @@ def _extract_archive(data: bytes, behavior_id: str, out: Path) -> dict[str, byte def _write_result( files: dict[str, bytes], - behavior_id: str, + entry_id: str, out: Path, expected_key: str, expected_inputs: str | None = None, @@ -669,27 +534,27 @@ def _write_result( ) -> None: report = normalize_report_identity(json.loads(files["report.json"])) actual_key, _ = _report_key(report) - if report.get("evidence_key") != expected_key and actual_key != expected_key: - raise ValueError(f"evidence report key mismatch for {behavior_id}") - if report.get("behavior") != behavior_id: - raise ValueError(f"evidence report behavior mismatch for {behavior_id}") + if report.get("evidence_key") != expected_key or actual_key != expected_key: + raise ValueError(f"evidence report key mismatch for {entry_id}") + if report.get("entry") != entry_id: + raise ValueError(f"evidence report entry mismatch for {entry_id}") if expected_inputs is not None and report.get("inputs_sha256") != expected_inputs: - raise ValueError(f"stale evidence identity for {behavior_id}") + raise ValueError(f"stale evidence identity for {entry_id}") if expected_artifact is not None: actual_artifact = _policy_sha(report) if actual_artifact is not None and actual_artifact != expected_artifact: - raise ValueError(f"evidence artifact mismatch for {behavior_id}") + raise ValueError(f"evidence artifact mismatch for {entry_id}") execution = report.get("execution") if execution == "rendered": if "loop.mp4" not in files or "poster.png" not in files: - raise ValueError(f"rendered result is missing loop/poster for {behavior_id}") - elif execution in ("unsupported", "rejected", "failed"): + raise ValueError(f"rendered result is missing loop/poster for {entry_id}") + elif execution in ("not-covered", "rejected", "failed"): # Report-only evidence is legitimate; partial media is not. if ("loop.mp4" in files) != ("poster.png" in files): - raise ValueError(f"non-rendered result has partial media for {behavior_id}") + raise ValueError(f"non-rendered result has partial media for {entry_id}") else: - raise ValueError(f"unsupported execution status for {behavior_id}: {execution!r}") - destination = out / behavior_id + raise ValueError(f"unsupported execution status for {entry_id}: {execution!r}") + destination = out / entry_id # No stale generated files survive from a previous hydration. if destination.exists(): for stale in sorted(destination.iterdir()): @@ -701,7 +566,7 @@ def _write_result( destination.mkdir(parents=True, exist_ok=True) for filename, content in files.items(): if filename not in RESULT_FILE_NAMES: - raise ValueError(f"unexpected result file for {behavior_id}: {filename}") + raise ValueError(f"unexpected result file for {entry_id}: {filename}") (destination / filename).write_bytes(content) # The report's paths are generated in a disposable runner workspace. The # site consumes stable build paths instead. Reattach the index observation @@ -709,52 +574,46 @@ def _write_result( report["evidence_key"] = expected_key if "loop.mp4" in files and "poster.png" in files: report["media"] = { - "loop_url": f"/media/registry-sim/{behavior_id}/loop.mp4", - "poster_url": f"/media/registry-sim/{behavior_id}/poster.png", + "loop_url": f"/media/registry-sim/{entry_id}/loop.mp4", + "poster_url": f"/media/registry-sim/{entry_id}/poster.png", } (destination / "report.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") -def hydrate(index_path: Path, release_url: str, out: Path, local: Path | None, behavior_ids: list[str]) -> dict[str, Any]: +def hydrate(index_path: Path, release_url: str, out: Path, local: Path | None, entry_ids: list[str]) -> dict[str, Any]: index = read_index(index_path) base = _safe_release_url(release_url) local_map = _local_results(local) current: dict[str, str] = index.get("current", {}) - authored = _authored_descriptors() - requested = set(behavior_ids) if behavior_ids else set(authored) + authored = _authored_policies() + requested = set(entry_ids) if entry_ids else set(authored) unknown_authored = requested - set(authored) if unknown_authored: - raise ValueError("no authored descriptor for: " + ", ".join(sorted(unknown_authored))) + raise ValueError("no authored policy for: " + ", ".join(sorted(unknown_authored))) unknown = requested - set(current) if unknown: raise ValueError("evidence index has no current entries for: " + ", ".join(sorted(unknown))) hydrated: list[dict[str, str]] = [] # Clear the generated target before hydration so stale files cannot survive. out.mkdir(parents=True, exist_ok=True) - for behavior_id in sorted(requested): - if not valid_id(behavior_id): - raise ValueError(f"invalid requested behavior id: {behavior_id!r}") - key = current[behavior_id] + for entry_id in sorted(requested): + if not valid_id(entry_id): + raise ValueError(f"invalid requested entry id: {entry_id!r}") + key = current[entry_id] entry = index["entries"].get(key) - if not isinstance(entry, dict) or entry.get("behavior") != behavior_id: - raise ValueError(f"evidence index entry is missing or mismatched for {behavior_id}") - expected_inputs = _descriptor_identity(authored[behavior_id], behavior_id) + if not isinstance(entry, dict): + raise ValueError(f"evidence index entry is missing or mismatched for {entry_id}") + expected_inputs = _policy_inputs(entry_id) if entry.get("inputs_sha256") != expected_inputs: - raise ValueError(f"stale evidence identity for {behavior_id}") + raise ValueError(f"stale evidence identity for {entry_id}") + if entry.get("entry") != entry_id: + raise ValueError(f"evidence index entry is missing or mismatched for {entry_id}") expected_artifact = entry.get("artifact_sha256") - if expected_artifact is not None and not valid_sha256(expected_artifact): - raise ValueError(f"invalid artifact hash in index for {behavior_id}") - # Authored pointer identity must still match where explicitly known. - try: - authored_doc = read_json(authored[behavior_id]) - if isinstance(authored_doc, dict): - explicit = _explicit_artifact_sha(authored_doc) - if explicit is not None and expected_artifact is not None and explicit != expected_artifact: - raise ValueError(f"index artifact does not match authored pointer for {behavior_id}") - except ValueError: - raise - except Exception: - pass + authored_doc = read_json(authored[entry_id]) + authored_source = authored_doc.get("source") if isinstance(authored_doc, dict) else None + authored_artifact = authored_source.get("artifact_sha256") if isinstance(authored_source, dict) else None + if not valid_sha256(expected_artifact) or expected_artifact != authored_artifact: + raise ValueError(f"index artifact does not match authored policy for {entry_id}") source = local_map.get(key) if source is not None: files: dict[str, bytes] = {} @@ -765,25 +624,21 @@ def hydrate(index_path: Path, release_url: str, out: Path, local: Path | None, b raise ValueError(f"local result exceeds size limit: {path}") files[filename] = path.read_bytes() if "report.json" not in files: - raise ValueError(f"local result has no report for {behavior_id}") - _write_result(files, behavior_id, out, key, expected_inputs, expected_artifact) - hydrated.append({"behavior": behavior_id, "key": key, "source": "local"}) + raise ValueError(f"local result has no report for {entry_id}") + _write_result(files, entry_id, out, key, expected_inputs, expected_artifact) + hydrated.append({"entry": entry_id, "key": key, "source": "local"}) continue asset = entry.get("asset") - blob_sha = entry.get("blob_sha256") or entry.get("asset_sha256") - # Prefer blob-identity filenames; accept legacy key-named assets when - # the blob hash matches, so a partially migrated Release still hydrates. - if not isinstance(asset, str) or not asset.endswith(".tar.gz"): - raise ValueError(f"invalid release asset name for {behavior_id}") - if blob_sha is not None and asset != f"{blob_sha}.tar.gz" and asset != f"{key}.tar.gz": - raise ValueError(f"invalid release asset name for {behavior_id}: {asset}") + blob_sha = entry.get("blob_sha256") + expected_asset_sha = entry.get("asset_sha256") + if not isinstance(asset, str) or not valid_sha256(blob_sha) or expected_asset_sha != blob_sha or asset != f"{blob_sha}.tar.gz": + raise ValueError(f"invalid release asset name for {entry_id}") data = _download(f"{base}/{asset}", MAX_EVIDENCE_BYTES) - expected_asset_sha = entry.get("asset_sha256") or blob_sha if not valid_sha256(expected_asset_sha) or sha256_bytes(data) != expected_asset_sha: - raise ValueError(f"evidence asset hash mismatch for {behavior_id}") - files = _extract_archive(data, behavior_id, out) - _write_result(files, behavior_id, out, key, expected_inputs, expected_artifact) - hydrated.append({"behavior": behavior_id, "key": key, "source": "release"}) + raise ValueError(f"evidence asset hash mismatch for {entry_id}") + files = _extract_archive(data, entry_id, out) + _write_result(files, entry_id, out, key, expected_inputs, expected_artifact) + hydrated.append({"entry": entry_id, "key": key, "source": "release"}) return {"version": FORMAT_VERSION, "hydrated": hydrated} @@ -815,7 +670,7 @@ def _parser() -> argparse.ArgumentParser: hydrate_parser.add_argument("--release-url", required=True) hydrate_parser.add_argument("--out", type=Path, required=True) hydrate_parser.add_argument("--local", type=Path) - hydrate_parser.add_argument("--behavior", action="append", default=[]) + hydrate_parser.add_argument("--entry", action="append", default=[]) return parser @@ -832,7 +687,7 @@ def main(argv: list[str] | None = None) -> int: elif args.command == "fetch-index": fetch_index(args.release_url, args.out, args.allow_missing) elif args.command == "hydrate": - hydrate(args.index, args.release_url, args.out, args.local, args.behavior) + hydrate(args.index, args.release_url, args.out, args.local, args.entry) else: raise ValueError(f"unknown command {args.command}") except Exception as exc: # noqa: BLE001 diff --git a/scripts/generate-registry-index.ts b/scripts/generate-registry-index.ts index 64a687d..dffc5fa 100644 --- a/scripts/generate-registry-index.ts +++ b/scripts/generate-registry-index.ts @@ -1,68 +1,12 @@ import fs from "node:fs"; import path from "node:path"; -import { validateAllBehaviors } from "./validate-registry"; -import { type CatalogEntry, type RegistryIndex } from "../registry/schema/catalog"; +import { validatePolicies } from "./validate-registry"; +import { type RegistryIndex } from "../registry/schema/catalog"; import { getCatalogEntries } from "../src/lib/registry"; const PUBLIC_DIR = path.resolve(process.cwd(), "public"); const REGISTRY_OUT = path.join(PUBLIC_DIR, "registry.json"); -const README_PATH = path.resolve(process.cwd(), "README.md"); const FALLBACK_UPDATED_AT = "1970-01-01T00:00:00.000Z"; -const README_TABLE_START = ""; -const README_TABLE_END = ""; - -function escapeTableCell(value: string): string { - return value.replaceAll("|", "\\|").replace(/\r?\n/g, " "); -} - -function formatLabel(value: string): string { - return value.replaceAll("_", " ").replaceAll("-", " "); -} - -function mediaLabel(behavior: CatalogEntry): string { - const labels = [ - behavior.media.registry?.loop_url && "registry loop", - behavior.media.author.some((item) => item.type === "video") && "author video", - behavior.media.author.some((item) => item.type === "image") && "author image", - ].filter(Boolean); - return labels.length > 0 ? labels.join(" + ") : "—"; -} - -export function renderReadmeCatalog(behaviors: CatalogEntry[]): string { - const rows = behaviors.map((behavior) => { - const authors = behavior.authors.map((author) => author.name).join(", "); - const accessories = behavior.runtime.compatibility.accessories_required == null - ? "unknown" - : behavior.runtime.compatibility.accessories_required.length > 0 - ? behavior.runtime.compatibility.accessories_required.map(formatLabel).join(", ") - : "none"; - - return `| [${escapeTableCell(behavior.name)}](https://uduckmoves.com/behaviors/${behavior.id}) | \`${behavior.id}\` | ${escapeTableCell(formatLabel(behavior.category))} | ${formatLabel(behavior.hardware.status)} | ${escapeTableCell(authors)} | ${escapeTableCell(accessories)} | ${mediaLabel(behavior)} |`; - }); - - return [ - README_TABLE_START, - "", - "| Behavior | ID | Category | Status | Publisher | Setup | Preview |", - "| --- | --- | --- | --- | --- | --- | --- |", - ...rows, - "", - README_TABLE_END, - ].join("\n"); -} - -export function updateReadmeCatalog(behaviors: CatalogEntry[], readmePath = README_PATH): void { - const readme = fs.readFileSync(readmePath, "utf-8"); - const start = readme.indexOf(README_TABLE_START); - const end = readme.indexOf(README_TABLE_END); - if (start === -1 || end === -1 || end < start) { - throw new Error(`README is missing the generated catalog markers: ${README_TABLE_START} / ${README_TABLE_END}`); - } - - const before = readme.slice(0, start); - const after = readme.slice(end + README_TABLE_END.length); - fs.writeFileSync(readmePath, `${before}${renderReadmeCatalog(behaviors)}${after}`, "utf-8"); -} /** * Keep snapshot generation byte-for-byte stable. Release automation may set @@ -98,18 +42,18 @@ export function getDeterministicUpdatedAt( } export function generateRegistryIndex(): RegistryIndex { - const { valid, errors } = validateAllBehaviors(); + const { valid, errors } = validatePolicies(); if (!valid) { throw new Error(`Cannot compile registry due to validation errors:\n${errors.join("\n")}`); } // The app loader and this compiler intentionally share the same boundary so - // the API/site/index cannot drift into separate behavior and policy shapes. + // the API/site/index cannot drift into separate policy shapes. // It also attaches any trusted build evidence already present in the static // media directory. const entries = getCatalogEntries(); const index: RegistryIndex = { - version: "3.0.0", + version: "4.0.0", updated_at: getDeterministicUpdatedAt(), count: entries.length, entries, diff --git a/scripts/new-behavior.ts b/scripts/new-behavior.ts deleted file mode 100644 index 3af44ad..0000000 --- a/scripts/new-behavior.ts +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env tsx -import { ID_PATTERN } from "../registry/schema/allowlist"; -import { BehaviorCategorySchema, type BehaviorCategory } from "../registry/schema/behavior"; - -const SUPPORTED_KEYS = new Set(["id", "name", "category", "author", "description", "license"]); - -export interface ScaffoldOptions { - id: string; - name: string; - category: BehaviorCategory; - author: string; - description: string; - license: string; -} - -function titleFromId(id: string): string { - return id - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -} - -function usage(): string { - return [ - "Usage: pnpm --silent new-behavior id= [name=] [category=] [author=] [description=] [license=]", - "", - "Writes an incomplete custom/legacy draft to stdout. Save it outside registry/behaviors/,", - "then resolve null/TODO values from sources and use pnpm preflight . Prefer pnpm uduck register .", - ].join("\n"); -} - -export function parseScaffoldArgs(values: string[]): ScaffoldOptions { - const parsed = new Map(); - - for (const value of values) { - const separator = value.indexOf("="); - if (separator <= 0) { - throw new Error(`Expected key=value, got '${value}'.\n\n${usage()}`); - } - - const key = value.slice(0, separator); - const rawValue = value.slice(separator + 1).trim(); - if (!SUPPORTED_KEYS.has(key)) { - throw new Error(`Unknown scaffold field '${key}'. Supported fields: ${[...SUPPORTED_KEYS].join(", ")}.`); - } - if (!rawValue) throw new Error(`Scaffold field '${key}' cannot be empty.`); - if (parsed.has(key)) throw new Error(`Scaffold field '${key}' was provided more than once.`); - parsed.set(key, rawValue); - } - - const id = parsed.get("id"); - if (!id) throw new Error(`Missing required field 'id'.\n\n${usage()}`); - if (!ID_PATTERN.test(id)) throw new Error(`Invalid id '${id}': use lowercase kebab-case.`); - - const category = parsed.get("category") ?? "experimental"; - const categoryResult = BehaviorCategorySchema.safeParse(category); - if (!categoryResult.success) { - throw new Error(`Invalid category '${category}'. Use one of: locomotion, agility-tricks, manipulation, recovery, roller-skate, experimental.`); - } - - return { - id, - name: parsed.get("name") ?? titleFromId(id), - category: categoryResult.data, - author: parsed.get("author") ?? "Your Name", - description: parsed.get("description") ?? "TODO: describe what this behavior does.", - license: parsed.get("license") ?? "TODO: confirm the policy license.", - }; -} - -export function createBehaviorScaffold(options: ScaffoldOptions) { - return { - id: options.id, - name: options.name, - version: "0.1.0", - description: options.description, - category: options.category, - tags: [options.category], - authors: [{ name: options.author }], - license: options.license, - verification: { - status: "community_experimental", - summary: "Community behavior; physical deployment evidence has not been reviewed by the registry.", - hardware_target: "TODO: confirm target hardware from upstream evidence", - notes: "TODO: describe evidence, limitations, and simulation or hardware status.", - }, - contract: null, - compatibility: null, - artifacts: null, - media: { - hero_type: "badge", - }, - sources: { - upstream_repo: "https://github.com/your-org/your-policy", - }, - deployment: null, - }; -} - -export function main(argv = process.argv.slice(2)): number { - if (argv.includes("--help") || argv.includes("-h")) { - console.log(usage()); - return 0; - } - - try { - console.log(JSON.stringify(createBehaviorScaffold(parseScaffoldArgs(argv)), null, 2)); - return 0; - } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); - return 1; - } -} - -if (process.argv[1]?.endsWith("new-behavior.ts")) { - process.exitCode = main(); -} diff --git a/scripts/policy/propose.py b/scripts/policy/propose.py index cbbea5e..3d9c82f 100644 --- a/scripts/policy/propose.py +++ b/scripts/policy/propose.py @@ -1,11 +1,11 @@ -"""Trusted publisher: validates inert pointer data; never loads the ONNX.""" +"""Trusted publisher: validates inert policy data; never loads the ONNX.""" import base64 import json import os import re import subprocess from pathlib import Path -from resolve import validate_pointer +from resolve import validate_policy def gh(*args, payload=None, method=None): # `gh api` defaults to GET; `--input` only supplies the body, so every @@ -22,26 +22,23 @@ def gh(*args, payload=None, method=None): repo = os.environ['GH_REPO'] issue = int(os.environ['ISSUE_NUMBER']) submission = json.loads(Path('candidate/submission.json').read_text()) -relative = submission['pointer'] +relative = submission['policy'] if not re.fullmatch(r'registry/policies/[a-z0-9]+(?:-[a-z0-9]+)*\.json', relative): raise ValueError('Invalid candidate path') candidate_file = Path('candidate') / relative if candidate_file.stat().st_size > 65536: - raise ValueError('Pointer exceeds 64 KB') -p = validate_pointer(json.loads(candidate_file.read_text())) + raise ValueError('Policy exceeds 64 KB') +p = validate_policy(json.loads(candidate_file.read_text())) if relative != f"registry/policies/{p['id']}.json": raise ValueError('Candidate filename mismatch') -if (Path('registry/behaviors') / f"{p['id']}.json").exists(): - raise ValueError('Candidate conflicts with existing behavior') for file in Path('registry/policies').glob('*.json'): old = json.loads(file.read_text()) if old['id'] == p['id']: raise ValueError(f"ID already registered at registry/policies/{old['id']}.json") - if old['source']['repo'].lower() == p['source']['repo'].lower(): + if (old['source']['provider'], old['source']['repo'].lower(), old['source']['artifact_path']) == (p['source']['provider'], p['source']['repo'].lower(), p['source']['artifact_path']): raise ValueError( - f"Repository already registered as {old['id']}. To publish a new revision, " - f"open a normal PR updating registry/policies/{old['id']}.json; " - "the URL bot does not create update PRs yet." + f"Immutable source already registered as {old['id']}. To publish a new revision, " + f"open a normal PR updating registry/policies/{old['id']}.json." ) branch = f'bot/policy-{issue}' existing = gh('pr', 'list', '--head', branch, '--state', 'all', '--json', 'number,url') @@ -76,6 +73,9 @@ def blockquoted(value, limit=4000): unresolved = diagnosis.get('unresolved', []) if not isinstance(unresolved, list): unresolved = [] +install_unresolved = diagnosis.get('install_unresolved', []) +if isinstance(install_unresolved, list): + unresolved = [*unresolved, *install_unresolved] review_notes = '\n'.join('- ' + quoted(item) for item in unresolved[:20]) or '- No unresolved package metadata reported.' onnx = diagnosis.get('onnx', {}) if not isinstance(onnx, dict): @@ -110,7 +110,7 @@ def blockquoted(value, limit=4000): - Manifest SHA256: `{p['source']['manifest_sha256']}` - Manifest schema: `{quoted(manifest.get('schema_version', 'unknown'))}` - Kind: `{quoted(manifest.get('kind', 'unknown'))}` -- Runtime assessment: `{quoted(diagnosis.get('runtime', 'needs review'))}` +- Runtime assessment: `{quoted(diagnosis.get('resolution', 'needs review'))}` - License: `{quoted(diagnosis.get('license') or 'not declared')}` - ONNX interface: input `{quoted(onnx.get('input', 'unknown'))}` → output `{quoted(onnx.get('output', 'unknown'))}`; smoke `{quoted(onnx.get('smoke', 'unknown'))}` - Registry simulation: {sim_review} diff --git a/scripts/policy/resolve.py b/scripts/policy/resolve.py index ef63341..cbf2b63 100644 --- a/scripts/policy/resolve.py +++ b/scripts/policy/resolve.py @@ -1,12 +1,14 @@ #!/usr/bin/env python3 -"""Resolve Pollen Hub packages. Never imports publisher code or reads pickle files.""" +"""Resolve immutable upstream policy artifacts without executing publisher code.""" + from __future__ import annotations + import argparse +import copy import hashlib import json import re import sys -import tempfile import urllib.error import urllib.parse import urllib.request @@ -16,47 +18,53 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from simulation.pointer_recipes import recipe_for_policy, recipe_reason +from simulation.execution_recipes import recipe_for_policy, recipe_reason SHA = re.compile(r"^[0-9a-f]{40}$") +SHA256 = re.compile(r"^[0-9a-f]{64}$") SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") REPO = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$") -CATEGORIES = {'locomotion', 'agility-tricks', 'manipulation', 'recovery', 'roller-skate', 'experimental'} +PATH = re.compile(r"^(?!/)(?!.*(?:^|/)\.\.?(?:/|$))[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)*$") +CATEGORIES = {"locomotion", "agility-tricks", "manipulation", "recovery", "roller-skate", "experimental"} +PROVIDERS = {"github", "huggingface-model", "huggingface-space"} +SUPPORTED_SERVO_DECLARATIONS = {"xl330", "14x dynamixel xl330"} + -def digest(data): +def digest(data: bytes) -> str: return hashlib.sha256(data).hexdigest() -class HubRedirect(urllib.request.HTTPRedirectHandler): + +class SafeRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): - u = urllib.parse.urlsplit(newurl) - if u.scheme != 'https' or u.username or u.password or not ( - u.hostname == 'huggingface.co' or (u.hostname or '').endswith(('.huggingface.co', '.hf.co', '.xethub.hf.co')) - ): - raise ValueError('Hub redirected to an unsupported host') + parsed = urllib.parse.urlsplit(newurl) + allowed = parsed.hostname == "huggingface.co" or parsed.hostname == "raw.githubusercontent.com" or (parsed.hostname or "").endswith((".huggingface.co", ".hf.co", ".xethub.hf.co")) + if parsed.scheme != "https" or parsed.username or parsed.password or not allowed: + raise ValueError("upstream redirected to an unsupported host") return super().redirect_request(req, fp, code, msg, headers, newurl) -def fetch(url, limit=2 * 1024 * 1024): - """Fetch Hub bytes with bounded retries for transient upstream failures. - Retries 429/502/503/504 with exponential backoff, honoring a sane - Retry-After (capped at 60s). Permanent failures (e.g. 404) raise at once. - """ +def fetch(url: str, limit: int = 2 * 1024 * 1024) -> bytes: + """Fetch bounded upstream bytes with retries for transient responses.""" + + parsed = urllib.parse.urlsplit(url) + if parsed.scheme != "https" or parsed.username or parsed.password or parsed.hostname not in {"huggingface.co", "api.github.com", "github.com", "raw.githubusercontent.com"}: + raise ValueError(f"unsupported upstream URL: {url}") import time - last = None + last: Exception | None = None for attempt in range(5): try: - with urllib.request.build_opener(HubRedirect()).open( - urllib.request.Request(url, headers={'User-Agent': 'uduck-registry'}), timeout=60 - ) as response: + opener = urllib.request.build_opener(SafeRedirect()) + request = urllib.request.Request(url, headers={"User-Agent": "uduck-registry"}) + with opener.open(request, timeout=120) as response: data = response.read(limit + 1) if len(data) > limit: - raise ValueError(f'Download exceeds {limit} bytes') + raise ValueError(f"download exceeds {limit} bytes") return data except urllib.error.HTTPError as exc: last = exc if exc.code not in (429, 502, 503, 504) or attempt == 4: raise - retry_after = exc.headers.get('Retry-After', '') + retry_after = exc.headers.get("Retry-After", "") delay = min(int(retry_after), 60) if retry_after.isdigit() else 2 ** (attempt + 1) time.sleep(max(1, delay)) except (urllib.error.URLError, TimeoutError) as exc: @@ -67,203 +75,562 @@ def fetch(url, limit=2 * 1024 * 1024): assert last is not None raise last -def parse_url(value): - u = urllib.parse.urlsplit(value) - if u.scheme != 'https' or u.netloc != 'huggingface.co' or u.query or u.fragment: - raise ValueError('Submit https://huggingface.co// (optionally /tree/). Publish raw ONNX with Pollen first; custom sources need manual review.') - parts = u.path.strip('/').split('/') - if len(parts) not in (2, 4) or (len(parts) == 4 and parts[2] != 'tree'): - raise ValueError('Expected a Hub model repository URL, optionally /tree/') - repo = '/'.join(parts[:2]) - if not REPO.fullmatch(repo) or parts[0] in ('datasets', 'spaces', 'models'): - raise ValueError('Expected a Hugging Face model repository') - rev = parts[3] if len(parts) == 4 else 'main' - if not re.fullmatch(r'[A-Za-z0-9_.-]+', rev) or rev in ('.', '..'): - raise ValueError('Unsupported revision') - return repo, rev -def classify(manifest, repo=None, source=None): - """Accept upstream optional fields; missing claims remain unresolved.""" - if not isinstance(manifest, dict) or manifest.get('schema_version') != 2: - raise ValueError('Expected Pollen manifest.json schema_version: 2') - if 'policies' in manifest: - raise ValueError('Multi-policy sets require per-file maintainer review; submit a single-policy package here') - issues = [] - for key, expected in [('obs_len', 61), ('action_len', 14), ('model_api', 1)]: - v = manifest.get(key) - if v is None: - issues.append(f'{key} is not declared') - elif type(v) is not int or v != expected: - raise ValueError(f'Unsupported {key}: {v!r} (registry supports {expected})') - robot = manifest.get('robot', {}) +def parse_source_url(value: str) -> tuple[str, str, str]: + parsed = urllib.parse.urlsplit(value) + if parsed.scheme != "https" or parsed.username or parsed.password or parsed.port or parsed.query or parsed.fragment: + raise ValueError("source URL must be an https URL without query or fragment") + parts = parsed.path.strip("/").split("/") + if parsed.hostname == "huggingface.co": + if len(parts) >= 3 and parts[0] == "spaces": + provider, repo_parts = "huggingface-space", parts[1:3] + offset = 3 + else: + if parts and parts[0] in {"datasets", "spaces"}: + raise ValueError("expected a Hugging Face model repository URL") + provider, repo_parts = "huggingface-model", parts[:2] + offset = 2 + if len(repo_parts) != 2 or not REPO.fullmatch("/".join(repo_parts)): + raise ValueError("expected a Hugging Face owner/repository URL") + revision = "main" + if len(parts) > offset: + if len(parts) != offset + 2 or parts[offset] != "tree" or not re.fullmatch(r"[A-Za-z0-9_.-]+", parts[offset + 1]): + raise ValueError("source URL may optionally include /tree/") + revision = parts[offset + 1] + return provider, "/".join(repo_parts), revision + if parsed.hostname == "github.com": + if len(parts) < 2 or not REPO.fullmatch("/".join(parts[:2])): + raise ValueError("expected a GitHub owner/repository URL") + revision = "main" + if len(parts) > 2: + if len(parts) != 4 or parts[2] != "tree" or not re.fullmatch(r"[A-Za-z0-9_.-]+", parts[3]): + raise ValueError("GitHub source URL may optionally include /tree/") + revision = parts[3] + return "github", "/".join(parts[:2]), revision + raise ValueError("source URL host must be huggingface.co or github.com") + + +def parse_url(value: str) -> tuple[str, str]: + """Parse a model URL for contributor tooling that only needs repo/revision.""" + + provider, repo, revision = parse_source_url(value) + if provider != "huggingface-model": + raise ValueError("this contributor command accepts Hugging Face model URLs") + return repo, revision + + +def parse_artifact_url(value: str) -> tuple[str, str, str, str | None]: + """Parse a repository URL or an exact immutable ONNX file URL.""" + + parsed = urllib.parse.urlsplit(value) + if parsed.scheme != "https" or parsed.username or parsed.password or parsed.port or parsed.query or parsed.fragment: + raise ValueError("source URL must be an https URL without query or fragment") + parts = parsed.path.strip("/").split("/") + if parsed.hostname == "huggingface.co": + if len(parts) >= 3 and parts[0] == "spaces": + provider, repo_parts, offset = "huggingface-space", parts[1:3], 3 + else: + provider, repo_parts, offset = "huggingface-model", parts[:2], 2 + if len(repo_parts) != 2 or not REPO.fullmatch("/".join(repo_parts)): + raise ValueError("expected a Hugging Face owner/repository URL") + elif parsed.hostname == "github.com": + provider, repo_parts, offset = "github", parts[:2], 2 + if len(repo_parts) != 2 or not REPO.fullmatch("/".join(repo_parts)): + raise ValueError("expected a GitHub owner/repository URL") + else: + raise ValueError("source URL host must be huggingface.co or github.com") + + if len(parts) > offset and parts[offset] == "blob": + if len(parts) <= offset + 2 or not re.fullmatch(r"[A-Za-z0-9_.-]+", parts[offset + 1]): + raise ValueError("artifact URL must include /blob//") + artifact_path = "/".join(parts[offset + 2:]) + if not PATH.fullmatch(artifact_path) or not artifact_path.lower().endswith(".onnx"): + raise ValueError("artifact URL must identify a safe relative ONNX path") + return provider, "/".join(repo_parts), parts[offset + 1], artifact_path + provider_from_repo, repo, revision = parse_source_url(value) + return provider_from_repo, repo, revision, None + + +def source_artifact_url(source: dict) -> str: + provider, repo, revision, artifact_path = source["provider"], source["repo"], source["revision"], source["artifact_path"] + if provider == "github": + return f"https://raw.githubusercontent.com/{repo}/{revision}/{artifact_path}" + prefix = "spaces/" if provider == "huggingface-space" else "" + return f"https://huggingface.co/{prefix}{repo}/resolve/{revision}/{artifact_path}" + + +def source_file_url(source: dict, relative_path: str) -> str: + if source["provider"] == "github": + return f"https://raw.githubusercontent.com/{source['repo']}/{source['revision']}/{relative_path}" + prefix = "spaces/" if source["provider"] == "huggingface-space" else "" + return f"https://huggingface.co/{prefix}{source['repo']}/resolve/{source['revision']}/{relative_path}" + + +def _merge_manifest(base: dict, overlay: dict) -> dict: + merged = copy.deepcopy(base) + for key, value in overlay.items(): + if isinstance(merged.get(key), dict) and isinstance(value, dict): + merged[key] = _merge_manifest(merged[key], value) + else: + merged[key] = copy.deepcopy(value) + return merged + + +def select_manifest_for_artifact(manifest: dict, artifact_path: str) -> tuple[dict, bool]: + """Resolve a schema-2 policy-set manifest to one exact artifact entry.""" + + policies = manifest.get("policies") + if policies is None: + return manifest, False + if not isinstance(policies, list) or not policies: + raise ValueError("policy-set manifest policies must be a non-empty array") + matches = [] + for entry in policies: + if not isinstance(entry, dict) or not isinstance(entry.get("file"), str) or not PATH.fullmatch(entry["file"]) or not entry["file"].lower().endswith(".onnx"): + raise ValueError("policy-set manifest contains an invalid policy file") + if entry["file"] == artifact_path: + matches.append(entry) + if len(matches) != 1: + raise ValueError(f"policy-set manifest has no unique entry for artifact {artifact_path!r}") + base = {key: value for key, value in manifest.items() if key != "policies"} + return _merge_manifest(base, matches[0]), True + + +def _manifest_diagnosis(manifest: dict, repo: str, source: dict, policy_set: bool = False) -> dict: + """Classify only explicit package metadata; missing facts stay unresolved.""" + + if not isinstance(manifest, dict) or manifest.get("schema_version") not in (2, 3): + raise ValueError("expected policy manifest schema_version 2 or 3") + issues: list[str] = [] + for key, expected in (("obs_len", 61), ("action_len", 14), ("model_api", 1)): + value = manifest.get(key) + if value is None: + issues.append(f"{key} is not declared") + elif type(value) is not int or value != expected: + raise ValueError(f"unsupported {key}: {value!r} (registry supports {expected})") + robot = manifest.get("robot", {}) if not isinstance(robot, dict): - raise ValueError('robot must be an object') - for key, expected in [('model', 'microduck'), ('hw_rev', 1), ('servos', 'xl330'), ('control_hz', 50)]: - if robot.get(key) is None: - issues.append(f'robot.{key} is not declared') - elif robot[key] != expected or isinstance(robot[key], bool): - raise ValueError(f'Unsupported robot.{key}: {robot[key]!r}') - command = manifest.get('command') or {} + raise ValueError("robot must be an object") + for key, expected in (("model", "microduck"), ("hw_rev", 1), ("control_hz", 50)): + value = robot.get(key) + if value is None: + issues.append(f"robot.{key} is not declared") + elif value != expected or isinstance(value, bool): + raise ValueError(f"unsupported robot.{key}: {value!r}") + servos = robot.get("servos") + if servos is None: + issues.append("robot.servos is not declared") + elif not isinstance(servos, str) or servos.strip().casefold() not in SUPPORTED_SERVO_DECLARATIONS: + raise ValueError(f"unsupported robot.servos: {servos!r}") + if manifest.get("action_scale") is None: + issues.append("action_scale is not declared; installation needs review") + command = manifest.get("command") or {} if not isinstance(command, dict): - raise ValueError('command must be an object') - for key in ('duration_s', 'unwind_s', 'action_scale'): - v = manifest.get(key) - if v is not None and (type(v) not in (int, float) or not 0 < v <= 300): - raise ValueError(f'{key} must be a finite positive number <= 300') - if 'chain' in manifest and type(manifest['chain']) is not bool: - raise ValueError('chain must be boolean') - idle = command.get('idle') - if idle is not None and (not isinstance(idle, list) or len(idle) != 3 or any(type(v) not in (float, int) or not -3 <= v <= 3 for v in idle)): - raise ValueError('command.idle must be three finite numbers in [-3, 3]') - kind = manifest.get('kind') - encoding = command.get('encoding', 'constant') - if kind not in ('episodic', 'perpetual', 'scripted', None): - raise ValueError(f'Unknown kind: {kind!r}') - route = 'review' - if encoding not in ('constant', 'phase', 'posture_flag'): - issues.append(f'Unsupported command encoding: {encoding}') - elif encoding != 'constant' or kind == 'scripted': - issues.append('Daemon-driven policy: use the upstream slot workflow; no generic skill install') - elif kind == 'episodic' and manifest.get('duration_s'): - route = 'skill' - elif kind == 'perpetual' and manifest.get('slot') in ('walk', 'stand'): - route = 'slot' - elif kind == 'perpetual': - issues.append('Held pose requires an explicit command and hold/unwind review') + raise ValueError("command must be an object") + for key in ("duration_s", "unwind_s", "action_scale"): + value = manifest.get(key) + if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 < value <= 300): + raise ValueError(f"{key} must be a finite positive number <= 300") + kind = manifest.get("kind") + if kind not in ("episodic", "perpetual", "scripted", None): + raise ValueError(f"unknown kind: {kind!r}") + encoding = command.get("encoding", "constant") + route = "review" + if encoding not in ("constant", "phase", "posture_flag"): + issues.append(f"unsupported command encoding: {encoding}") + elif encoding != "constant" or kind == "scripted": + issues.append("Daemon-driven policy requires the upstream slot workflow; no generic skill install") + elif kind == "episodic" and manifest.get("duration_s"): + route = "skill" + elif kind == "perpetual" and manifest.get("slot") in ("walk", "stand"): + route = "slot" + elif kind == "perpetual": + issues.append("Held pose requires an explicit command and hold/unwind review") else: - issues.append('Missing kind or episodic duration; install needs review') - # A constant encoding does NOT generally specify the command value. The - # maintainer-owned recipe layer may cover a small set of documented - # defaults or named upstream examples, but that diagnosis is independent - # from install routing and never becomes authored pointer state. + issues.append("Missing kind or episodic duration; install needs review") + recipe = recipe_for_policy(repo, manifest, source) if repo else None - if recipe: - simulation = { - 'status': 'covered', - 'runner': recipe['runner'], - 'recipe': recipe, - 'scope': recipe['provenance']['scope'], - } - else: - simulation = { - 'status': 'not-covered', - 'reason': recipe_reason(repo or '', manifest, source), - } - return {'runtime': 'pollen-hub' if not issues else 'pollen-review', 'install_route': route if not issues else 'review', 'unresolved': issues, 'simulation': simulation} + simulation = {"status": "covered", "recipe": recipe, "scope": recipe["provenance"]["scope"]} if recipe else {"status": "not-covered", "reason": recipe_reason(repo, manifest, source)} + install_unresolved: list[str] = [] + provider = source.get("provider") if isinstance(source, dict) else None + if provider is not None and provider != "huggingface-model": + install_unresolved.append("No supported robotctl install route exists for GitHub or Hugging Face Space sources.") + route = "review" + elif policy_set: + install_unresolved.append("Official policy-set artifacts are updated as a set; no per-entry robotctl install command is synthesized.") + route = "review" + if issues: + route = "review" + return { + "resolution": "ready" if not issues else "review", + "install_route": route, + "unresolved": issues, + "install_unresolved": install_unresolved, + "policy_set": policy_set, + "simulation": simulation, + } -def inspect_onnx(data): - import onnxruntime as ort + +def classify(manifest, repo=None, source=None): + return _manifest_diagnosis(manifest, repo or "", source or {}) + + +def inspect_onnx(data: bytes) -> dict: import numpy as np + import onnxruntime as ort options = ort.SessionOptions() options.intra_op_num_threads = 1 options.inter_op_num_threads = 1 options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL - # Bytes-only loading cannot resolve external tensor files. No custom ops. - session = ort.InferenceSession(data, sess_options=options, providers=['CPUExecutionProvider']) + session = ort.InferenceSession(data, sess_options=options, providers=["CPUExecutionProvider"]) inputs, outputs = session.get_inputs(), session.get_outputs() + def shape_ok(shape, width): return len(shape) == 2 and (shape[0] == 1 or isinstance(shape[0], str) or shape[0] is None) and shape[1] == width - if len(inputs) != 1 or len(outputs) != 1 or inputs[0].type != 'tensor(float)' or outputs[0].type != 'tensor(float)' or not shape_ok(inputs[0].shape, 61) or not shape_ok(outputs[0].shape, 14): - raise ValueError('Expected a float ONNX with one [1,61] input and one [1,14] output') + + if len(inputs) != 1 or len(outputs) != 1 or inputs[0].type != "tensor(float)" or outputs[0].type != "tensor(float)" or not shape_ok(inputs[0].shape, 61) or not shape_ok(outputs[0].shape, 14): + raise ValueError("expected a float ONNX with one [1,61] input and one [1,14] output") result = session.run(None, {inputs[0].name: np.zeros((1, 61), dtype=np.float32)})[0] if result.shape != (1, 14) or not np.isfinite(result).all(): - raise ValueError('ONNX zero-input smoke check returned invalid outputs') - return {'input': inputs[0].shape, 'output': outputs[0].shape, 'smoke': 'passed', 'scope': 'Shape and finite zero-input outputs only; not behavioral or hardware evidence.'} - -def resolve(url, expected=None): - repo, revision = parse_url(url) - metadata = json.loads(fetch(f'https://huggingface.co/api/models/{repo}/revision/{revision}')) - revision = metadata.get('sha') - if not isinstance(revision, str) or not SHA.fullmatch(revision): - raise ValueError('Hub did not return an immutable commit SHA') - files = [s['rfilename'] for s in metadata.get('siblings', [])] - if sorted(f for f in files if f.endswith('.onnx')) != ['policy.onnx'] or 'manifest.json' not in files: - raise ValueError('Expected exactly policy.onnx and manifest.json; publish with Pollen or request custom review') - base = f'https://huggingface.co/{repo}/resolve/{revision}' - raw = fetch(base + '/manifest.json') - manifest = json.loads(raw) - data = fetch(base + '/policy.onnx', 100 * 1024 * 1024) - hashes = {'manifest_sha256': digest(raw), 'artifact_sha256': digest(data)} - if expected and any(expected[k] != v for k, v in hashes.items()): - raise ValueError('Pinned manifest or policy hash mismatch') - diagnosis = classify(manifest, repo, {'revision': revision, **hashes}) - license_name = (metadata.get('cardData') or {}).get('license') - if not isinstance(license_name, str) or not license_name.strip(): - diagnosis['unresolved'].append('Model card does not declare a license; maintainer review required') - return {'source': {'repo': repo, 'revision': revision, **hashes}, 'manifest': manifest, 'license': license_name, 'onnx': inspect_onnx(data), **diagnosis} - -def validate_pointer(p): - if not isinstance(p, dict) or set(p) - {'id', 'source', 'curation', 'media'}: - raise ValueError('Unknown pointer fields') - if not SLUG.fullmatch(p.get('id', '')) or len(p['id']) > 100: - raise ValueError('Invalid policy id') - s = p.get('source', {}) - if set(s) != {'repo', 'revision', 'artifact_sha256', 'manifest_sha256'} or not REPO.fullmatch(s.get('repo', '')) or not SHA.fullmatch(s.get('revision', '')): - raise ValueError('Source requires repo, immutable revision and both hashes') - for key in ('artifact_sha256', 'manifest_sha256'): - if not re.fullmatch(r'[0-9a-f]{64}', s[key]): - raise ValueError('Invalid SHA256') - c = p.get('curation', {}) - if set(c) - {'category', 'tags', 'summary', 'notes'} or c.get('category') not in CATEGORIES: - raise ValueError('Invalid curation') - if not isinstance(c.get('tags', []), list) or len(c.get('tags', [])) > 20 or any(not isinstance(t, str) or not 0 < len(t) <= 80 for t in c.get('tags', [])): - raise ValueError('Invalid tags') - for key in ('summary', 'notes'): - if key in c and (not isinstance(c[key], str) or len(c[key]) > 4000): - raise ValueError('Invalid curation text') - media = p.get('media', []) - if not isinstance(media, list) or len(media) > 10: - raise ValueError('Invalid media') + raise ValueError("ONNX zero-input smoke check returned invalid outputs") + return {"input": inputs[0].shape, "output": outputs[0].shape, "smoke": "passed", "scope": "Shape and finite zero-input outputs only; not behavioral or hardware evidence."} + + +def _metadata(provider: str, repo: str, revision: str) -> dict: + if provider == "github": + endpoint = f"https://api.github.com/repos/{repo}/commits/{revision}" + elif provider == "huggingface-space": + endpoint = f"https://huggingface.co/api/spaces/{repo}/revision/{revision}" + else: + endpoint = f"https://huggingface.co/api/models/{repo}/revision/{revision}" + return json.loads(fetch(endpoint, 8 * 1024 * 1024)) + + +def _source_files(provider: str, repo: str, revision: str) -> list[str]: + """List immutable source files for a provider without reading publisher code.""" + + if provider == "github": + tree = json.loads(fetch(f"https://api.github.com/repos/{repo}/git/trees/{revision}?recursive=1", 16 * 1024 * 1024)) + if not isinstance(tree, dict) or tree.get("truncated") is True: + raise ValueError("GitHub source tree is unavailable or truncated") + items = tree.get("tree", []) + if not isinstance(items, list): + raise ValueError("GitHub source tree is malformed") + return sorted( + item["path"] + for item in items + if isinstance(item, dict) and item.get("type") == "blob" and isinstance(item.get("path"), str) + ) + metadata = _metadata(provider, repo, revision) + siblings = metadata.get("siblings", []) if isinstance(metadata, dict) else [] + return sorted( + item["rfilename"] + for item in siblings + if isinstance(item, dict) and isinstance(item.get("rfilename"), str) + ) + + +def _resolve_revision(provider: str, repo: str, revision: str) -> str: + metadata = _metadata(provider, repo, revision) + resolved = metadata.get("sha") if provider != "github" else metadata.get("sha") + if not isinstance(resolved, str) or not SHA.fullmatch(resolved): + raise ValueError("upstream did not return an immutable commit SHA") + return resolved + + +def resolve_source(source: dict) -> dict: + """Fetch and verify the exact authored source identity.""" + + manifest = None + policy_set = False + if source["manifest_path"] is not None: + manifest_raw = fetch(source_file_url(source, source["manifest_path"]), 2 * 1024 * 1024) + if digest(manifest_raw) != source["manifest_sha256"]: + raise ValueError("pinned manifest hash mismatch") + manifest = json.loads(manifest_raw) + if not isinstance(manifest, dict): + raise ValueError("pinned policy manifest must be an object") + # Resolve the exact policy-set member before downloading its artifact. + # This prevents a bad artifact selector from fetching an unrelated + # large ONNX file and makes per-file manifest semantics authoritative. + manifest, policy_set = select_manifest_for_artifact(manifest, source["artifact_path"]) + + artifact_raw = fetch(source_artifact_url(source), 100 * 1024 * 1024) + if digest(artifact_raw) != source["artifact_sha256"]: + raise ValueError("pinned artifact hash mismatch") + + if manifest is None: + install_unresolved = [] + if source["provider"] != "huggingface-model": + install_unresolved.append("No supported robotctl install route exists for GitHub or Hugging Face Space sources.") + diagnosis = { + "resolution": "review", + "install_route": "review", + "unresolved": ["No machine-readable policy manifest is published with this artifact."], + "install_unresolved": install_unresolved, + "policy_set": False, + "simulation": {"status": "not-covered", "reason": "No machine-readable policy manifest is published with this artifact."}, + } + else: + diagnosis = _manifest_diagnosis(manifest, source["repo"], source, policy_set=policy_set) + license_name = None + try: + metadata = _metadata(source["provider"], source["repo"], source["revision"]) + card_data = metadata.get("cardData") or {} + if isinstance(card_data, dict) and isinstance(card_data.get("license"), str) and card_data["license"].strip(): + license_name = card_data["license"] + except Exception: + if manifest is not None: + diagnosis["unresolved"].append("Upstream license metadata could not be read; maintainer review required") + if manifest is not None and license_name is None: + diagnosis["unresolved"].append("Upstream metadata does not declare a license; maintainer review required") + if diagnosis["unresolved"]: + diagnosis["resolution"] = "review" + diagnosis["install_route"] = "review" + return { + "source": source, + "manifest": manifest, + "license": license_name, + "onnx": inspect_onnx(artifact_raw), + **diagnosis, + } + + +def _discover_source(provider: str, repo: str, revision: str, requested_artifact: str | None = None) -> dict: + immutable = _resolve_revision(provider, repo, revision) + paths = _source_files(provider, repo, immutable) + artifacts = [item for item in paths if item.lower().endswith(".onnx")] + if requested_artifact is not None: + if requested_artifact not in artifacts: + raise ValueError(f"source does not publish the requested ONNX artifact: {requested_artifact}") + artifact_path = requested_artifact + else: + if len(artifacts) != 1: + raise ValueError("source publishes multiple ONNX artifacts; submit an exact /blob//.onnx URL") + artifact_path = artifacts[0] + if not PATH.fullmatch(artifact_path): + raise ValueError("ONNX artifact path is not a safe relative path") + manifest_path = "manifest.json" if "manifest.json" in paths else None + artifact_raw = fetch(source_file_url({"provider": provider, "repo": repo, "revision": immutable}, artifact_path), 100 * 1024 * 1024) + manifest_raw = fetch(source_file_url({"provider": provider, "repo": repo, "revision": immutable}, manifest_path), 2 * 1024 * 1024) if manifest_path else None + return { + "provider": provider, + "repo": repo, + "revision": immutable, + "artifact_path": artifact_path, + "artifact_sha256": digest(artifact_raw), + "manifest_path": manifest_path, + "manifest_sha256": digest(manifest_raw) if manifest_raw else None, + } + + +def resolve(url: str, expected: dict | None = None) -> dict: + provider, repo, revision, requested_artifact = parse_artifact_url(url) + source = expected or _discover_source(provider, repo, revision, requested_artifact) + if expected is not None and (source.get("provider") != provider or source.get("repo") != repo): + raise ValueError("source URL does not match the authored provider or repository") + if expected is not None and requested_artifact is not None and source.get("artifact_path") != requested_artifact: + raise ValueError("source URL does not match the authored artifact path") + if expected is None: + source["revision"] = _resolve_revision(provider, repo, revision) + elif source["revision"] != revision and revision != "main": + raise ValueError("source URL revision does not match the authored revision") + return resolve_source(source) + + +def validate_policy(policy: dict) -> dict: + if not isinstance(policy, dict) or set(policy) - {"id", "source", "curation", "media"}: + raise ValueError("unknown policy fields") + if not isinstance(policy.get("id"), str) or not SLUG.fullmatch(policy["id"]) or len(policy["id"]) > 100: + raise ValueError("invalid policy id") + source = policy.get("source") + required = {"provider", "repo", "revision", "artifact_path", "artifact_sha256", "manifest_path", "manifest_sha256"} + if ( + not isinstance(source, dict) + or set(source) != required + or source.get("provider") not in PROVIDERS + or not isinstance(source.get("repo"), str) + or not REPO.fullmatch(source["repo"]) + or not isinstance(source.get("revision"), str) + or not SHA.fullmatch(source["revision"]) + or not isinstance(source.get("artifact_path"), str) + or not PATH.fullmatch(source["artifact_path"]) + or not source["artifact_path"].lower().endswith(".onnx") + ): + raise ValueError("source requires provider, repository, immutable revision, safe ONNX path and hashes") + if not isinstance(source.get("artifact_sha256"), str) or not SHA256.fullmatch(source["artifact_sha256"]): + raise ValueError("invalid artifact SHA256") + if (source["manifest_path"] is None) != (source["manifest_sha256"] is None): + raise ValueError("manifest_path and manifest_sha256 must be both present or both null") + if source["manifest_path"] is not None and ( + not isinstance(source["manifest_path"], str) + or not PATH.fullmatch(source["manifest_path"]) + or not isinstance(source["manifest_sha256"], str) + or not SHA256.fullmatch(source["manifest_sha256"]) + ): + raise ValueError("invalid manifest path or SHA256") + curation = policy.get("curation") + if not isinstance(curation, dict) or set(curation) - {"category", "tags", "name", "summary", "details", "authors", "license", "notes", "requirements", "publisher_hardware"} or curation.get("category") not in CATEGORIES: + raise ValueError("invalid curation") + tags = curation.get("tags", []) + if not isinstance(tags, list) or len(tags) > 20 or any(not isinstance(tag, str) or not 0 < len(tag) <= 80 for tag in tags): + raise ValueError("invalid tags") + for key, limit in (("name", 200), ("summary", 4000), ("details", 8000), ("license", 200), ("notes", 4000)): + minimum = 2 if key == "name" else 1 + if key in curation and (not isinstance(curation[key], str) or not minimum <= len(curation[key]) <= limit): + raise ValueError(f"invalid curation {key}") + authors = curation.get("authors") + if authors is not None and (not isinstance(authors, list) or not 1 <= len(authors) <= 20): + raise ValueError("invalid authors") + for author in authors or []: + if not isinstance(author, dict) or set(author) - {"name", "affiliation", "github", "url"} or not isinstance(author.get("name"), str) or not 0 < len(author["name"]) <= 200: + raise ValueError("invalid author") + for key, limit in (("affiliation", 200), ("github", 39)): + if key in author and (not isinstance(author[key], str) or not 0 < len(author[key]) <= limit): + raise ValueError("invalid author") + if "url" in author and (not isinstance(author["url"], str) or not author["url"]): + raise ValueError("invalid author") + if "github" in author and not re.fullmatch(r"(?:[A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]{0,37}[A-Za-z0-9])", author["github"]): + raise ValueError("invalid author") + if "url" in author: + parsed = urllib.parse.urlsplit(author["url"]) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password: + raise ValueError("invalid author") + requirements = curation.get("requirements") + if requirements is not None: + if not isinstance(requirements, dict) or set(requirements) != {"robot_model", "accessories", "terrain"}: + raise ValueError("invalid curation requirements") + if not isinstance(requirements["robot_model"], str) or not 0 < len(requirements["robot_model"]) <= 120: + raise ValueError("invalid curation requirements") + for key in ("accessories", "terrain"): + values = requirements[key] + if not isinstance(values, list) or len(values) > 20 or any(not isinstance(item, str) or not 0 < len(item) <= 120 for item in values): + raise ValueError("invalid curation requirements") + publisher_hardware = curation.get("publisher_hardware") + if publisher_hardware is not None: + if not isinstance(publisher_hardware, dict) or set(publisher_hardware) != {"status", "target", "source_url", "note"}: + raise ValueError("invalid publisher hardware facts") + if publisher_hardware["status"] not in ("claimed", "not-claimed", "unknown"): + raise ValueError("invalid publisher hardware facts") + for key, limit in (("target", 400), ("note", 4000)): + value = publisher_hardware[key] + if value is not None and (not isinstance(value, str) or not 0 < len(value) <= limit): + raise ValueError("invalid publisher hardware facts") + source_url = publisher_hardware["source_url"] + if publisher_hardware["status"] == "claimed" and source_url is None: + raise ValueError("claimed publisher hardware facts require source_url") + if source_url is not None: + parsed = urllib.parse.urlsplit(source_url) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password: + raise ValueError("invalid publisher hardware facts") + media = policy.get("media", []) + if not isinstance(media, list) or len(media) > 20: + raise ValueError("invalid media") for item in media: - u = urllib.parse.urlsplit(item.get('url', '')) - if set(item) != {'type', 'url', 'label'} or item['type'] not in ('video', 'image') or u.scheme != 'https' or not u.hostname or u.username or u.password or not isinstance(item['label'], str): - raise ValueError('Invalid author media') - return p + parsed = urllib.parse.urlsplit(item.get("url", "")) if isinstance(item, dict) else None + if not isinstance(item, dict) or set(item) != {"type", "url", "label"} or item["type"] not in ("video", "image") or parsed is None or parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or not isinstance(item["label"], str) or not 0 < len(item["label"]) <= 240: + raise ValueError("invalid author media") + return policy + -def main(): +def _slug(value: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + + +def _logical_source_identity(source: dict) -> str: + return f"{source['provider']}:{source['repo'].lower()}:{source['artifact_path']}" + + +def _bounded_policy_id(candidate: str, identity: str) -> str: + if len(candidate) <= 100: + return candidate + suffix = digest(identity.encode())[:12] + prefix = candidate[: 100 - len(suffix) - 1].rstrip("-") or "policy" + return f"{prefix}-{suffix}" + + +def default_policy_id(source: dict, occupied_ids: set[str] | None = None) -> str: + """Derive a readable ID from the logical artifact, not only its repository.""" + + occupied_ids = occupied_ids or set() + identity = _logical_source_identity(source) + repo_slug = _slug(source["repo"]) + artifact_path = source["artifact_path"] + if artifact_path == "policy.onnx": + candidate = repo_slug + else: + artifact_slug = _slug(artifact_path[:-len(".onnx")]) + candidate = f"{repo_slug}-{artifact_slug}" + candidate = _bounded_policy_id(candidate, identity) + if candidate not in occupied_ids: + return candidate + + suffix = digest(identity.encode())[:12] + prefix = candidate[: 100 - len(suffix) - 1].rstrip("-") or "policy" + fallback = f"{prefix}-{suffix}" + if fallback in occupied_ids: + raise ValueError("unable to derive a unique policy id; maintainer must supply --id") + return fallback + + +def register_policy(url: str, category: str = "experimental", requested_id: str | None = None) -> dict: + """Resolve and write one candidate while preserving logical-source uniqueness.""" + + result = resolve(url) + source = result["source"] + policies_dir = ROOT / "registry/policies" + policies_dir.mkdir(parents=True, exist_ok=True) + occupied_ids: set[str] = set() + + for file in sorted(policies_dir.glob("*.json")): + existing = json.loads(file.read_text()) + existing_id = existing.get("id") + if isinstance(existing_id, str): + occupied_ids.add(existing_id) + existing_source = existing.get("source", {}) + if (existing_source.get("provider"), existing_source.get("repo", "").lower(), existing_source.get("artifact_path")) == ( + source["provider"], source["repo"].lower(), source["artifact_path"] + ): + raise ValueError(f"logical source already registered as {existing['id']}; update that policy in a normal PR") + + policy_id = requested_id or default_policy_id(source, occupied_ids) + if policy_id in occupied_ids: + raise ValueError(f"policy id already registered: {policy_id}; choose another --id") + policy = validate_policy({"id": policy_id, "source": source, "curation": {"category": category, "tags": []}}) + destination = policies_dir / f"{policy_id}.json" + with destination.open("x") as output: + output.write(json.dumps(policy, indent=2) + "\n") + return {"policy": str(destination.relative_to(ROOT)), "diagnosis": result} + + +def main() -> None: parser = argparse.ArgumentParser() - parser.add_argument('command', choices=['resolve', 'register', 'prepare']) - parser.add_argument('url', nargs='?') - parser.add_argument('--id') - parser.add_argument('--category', default='experimental', choices=sorted(CATEGORIES)) + parser.add_argument("command", choices=["resolve", "register", "prepare"]) + parser.add_argument("url", nargs="?") + parser.add_argument("--id") + parser.add_argument("--category", default="experimental", choices=sorted(CATEGORIES)) args = parser.parse_args() - if args.command == 'prepare': - target = ROOT / '.generated/policies' + if args.command == "prepare": + target = ROOT / ".generated/policies" target.mkdir(parents=True, exist_ok=True) - for stale in target.glob('*.json'): + for stale in target.glob("*.json"): stale.unlink() - for file in sorted((ROOT / 'registry/policies').glob('*.json')): - p = validate_pointer(json.loads(file.read_text())) - if file.stem != p['id']: - raise ValueError('Pointer filename must equal its id') - s = p['source'] - result = resolve(f"https://huggingface.co/{s['repo']}/tree/{s['revision']}", s) - (target / file.name).write_text(json.dumps({**p, 'resolved': result}, indent=2) + '\n') + for file in sorted((ROOT / "registry/policies").glob("*.json")): + policy = validate_policy(json.loads(file.read_text())) + if file.stem != policy["id"]: + raise ValueError("policy filename must equal its id") + result = resolve_source(policy["source"]) + (target / file.name).write_text(json.dumps({**policy, "resolved": result}, indent=2) + "\n") return if not args.url: - parser.error('URL is required') - result = resolve(args.url) - if args.command == 'resolve': - print(json.dumps(result, indent=2)) + parser.error("URL is required") + if args.command == "resolve": + print(json.dumps(resolve(args.url), indent=2)) return - policy_id = args.id or re.sub(r'[^a-z0-9]+', '-', result['source']['repo'].lower()).strip('-') - p = validate_pointer({'id': policy_id, 'source': result['source'], 'curation': {'category': args.category, 'tags': []}}) - if (ROOT / 'registry/behaviors' / f'{policy_id}.json').exists(): - raise ValueError('ID already belongs to a legacy behavior; migration requires review') - for file in (ROOT / 'registry/policies').glob('*.json'): - existing = json.loads(file.read_text()) - if existing['source']['repo'].lower() == p['source']['repo'].lower(): - raise ValueError( - f"Repository already registered as {existing['id']}. To publish a new revision, " - f"open a normal PR updating registry/policies/{existing['id']}.json; " - "the URL bot does not create update PRs yet." - ) - destination = ROOT / 'registry/policies' / f'{policy_id}.json' - with destination.open('x') as output: - output.write(json.dumps(p, indent=2) + '\n') - print(json.dumps({'pointer': str(destination.relative_to(ROOT)), 'diagnosis': result}, indent=2)) - -if __name__ == '__main__': + print(json.dumps(register_policy(args.url, args.category, args.id), indent=2)) + + +if __name__ == "__main__": try: main() except Exception as exc: diff --git a/scripts/policy/submission_feedback.py b/scripts/policy/submission_feedback.py index 2ff786c..5ea3a9a 100644 --- a/scripts/policy/submission_feedback.py +++ b/scripts/policy/submission_feedback.py @@ -8,6 +8,6 @@ reason = error.read_text()[:4000] if error.is_file() else 'Package resolution failed before a diagnostic was written. See the workflow run.' # Keep publisher text inert inside a quote and avoid accidental mass mentions. reason = reason.replace('@', '@\u200b') -body = 'The policy URL could not be registered.\n\n' + '\n'.join('> ' + line for line in reason.splitlines()) + '\n\nCheck the URL and package, edit the issue, then close and reopen it to retry. Pollen packages contain `manifest.json` schema 2 and `policy.onnx`. Custom sources can be reviewed manually.' +body = 'The policy URL could not be registered.\n\n' + '\n'.join('> ' + line for line in reason.splitlines()) + '\n\nCheck the URL and package, then edit the issue to retry; reopening it is an alternative. Published packages should contain a pinned ONNX artifact and, when runtime facts are claimed, a machine-readable manifest.' Path('feedback-body.md').write_text(body) subprocess.run(['gh', 'issue', 'comment', issue, '--body-file', 'feedback-body.md'], check=True) diff --git a/scripts/preflight.ts b/scripts/preflight.ts deleted file mode 100644 index b0c6a66..0000000 --- a/scripts/preflight.ts +++ /dev/null @@ -1,148 +0,0 @@ -#!/usr/bin/env tsx -import fs from "node:fs"; -import { BehaviorSchema } from "../registry/schema/behavior"; - -/** - * Draft/preflight checker for behavior descriptors. - * - * Preflight is a lint for descriptor drafts and candidate files: it reports - * which required fields are still unresolved and which present values fail - * the canonical schema. It is advisory — it never writes files and never - * gates anything. The only path into registry/behaviors/ is - * a reviewed, complete descriptor, and `pnpm validate` remains the sole - * gate for published entries. One schema, no "kind of valid" descriptors. - */ - -export interface PreflightFinding { - kind: "unresolved" | "invalid"; - path: string; - reason: string; -} - -export interface PreflightResult { - findings: PreflightFinding[]; - notes: string[]; - complete: boolean; -} - -const ROOT = ""; - -const UNRESOLVED_REASON = - "no value provided; must come from an authoritative source (policy manifest, upstream repo, or direct measurement)"; - -function formatPath(path: readonly PropertyKey[]): string { - if (path.length === 0) return ROOT; - return path.map((segment) => String(segment)).join("."); -} - -/** True when the candidate object actually carries a value at this path. */ -function hasValueAt(raw: unknown, path: readonly PropertyKey[]): boolean { - let current: unknown = raw; - for (const segment of path) { - if (current === null || typeof current !== "object") return false; - current = (current as Record)[segment]; - } - return current !== undefined && current !== null; -} - -/** - * Verification notes describe the hardware-evidence axis only. Registry - * simulation is a separate axis derived from committed evidence; preflight - * never mentions sim results and never lets them touch verification.status. - */ -function verificationNotes(raw: unknown): string[] { - const status = (raw as { verification?: { status?: unknown } } | null)?.verification?.status; - switch (status) { - case "community_experimental": - return ["verification.status = community_experimental — no hardware evidence claimed"]; - case "claimed_hardware": - return ["verification.status = claimed_hardware — hardware claim requires human review"]; - case "verified_hardware": - return ["verification.status = verified_hardware — hardware evidence requires human review"]; - default: - return []; - } -} - -export function preflightDescriptor(raw: unknown): PreflightResult { - const result = BehaviorSchema.safeParse(raw); - if (result.success) { - return { findings: [], notes: verificationNotes(raw), complete: true }; - } - - // Absent required fields are "unresolved"; present-but-wrong values (and - // unrecognized keys) are "invalid". Classification reads the candidate - // object itself, so it stays stable across zod message changes. - const findings: PreflightFinding[] = result.error.issues.map((issue) => { - const pathKey = formatPath(issue.path); - if (hasValueAt(raw, issue.path)) { - return { kind: "invalid", path: pathKey, reason: issue.message }; - } - return { kind: "unresolved", path: pathKey, reason: UNRESOLVED_REASON }; - }); - - findings.sort((a, b) => - a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === "invalid" ? -1 : 1, - ); - - return { findings, notes: verificationNotes(raw), complete: false }; -} - -export function formatPreflight(result: PreflightResult): string { - const lines: string[] = []; - for (const finding of result.findings) { - lines.push(`${finding.kind}: ${finding.path} — ${finding.reason}`); - } - for (const note of result.notes) { - lines.push(`note: ${note}`); - } - const unresolved = result.findings.filter((f) => f.kind === "unresolved").length; - const invalid = result.findings.filter((f) => f.kind === "invalid").length; - lines.push(`status: ${result.complete ? "complete" : "incomplete"} (${unresolved} unresolved, ${invalid} invalid)`); - return lines.join("\n"); -} - -export function preflightFile(filePath: string): PreflightResult { - let raw: unknown; - try { - raw = JSON.parse(fs.readFileSync(filePath, "utf-8")); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - return { - findings: [{ kind: "invalid", path: ROOT, reason: `file is not valid JSON: ${reason}` }], - notes: [], - complete: false, - }; - } - return preflightDescriptor(raw); -} - -export function main(argv = process.argv.slice(2)): number { - if (argv.includes("--help") || argv.includes("-h")) { - console.log( - [ - "Usage: pnpm --silent preflight ", - "", - "Reports unresolved and invalid fields for a descriptor draft or candidate.", - "Exit code 0 only when the file passes the canonical schema in full.", - "Drafts never belong in registry/behaviors/. After review, copy a complete descriptor to registry/behaviors/.json and run:", - "", - " pnpm validate", - ].join("\n"), - ); - return 0; - } - - if (argv.length === 0) { - console.error("Missing argument."); - return 1; - } - - const result = preflightFile(argv[0]!); - console.log(formatPreflight(result)); - return result.complete ? 0 : 1; -} - -if (process.argv[1]?.endsWith("preflight.ts")) { - process.exitCode = main(); -} \ No newline at end of file diff --git a/scripts/uduck.ts b/scripts/uduck.ts index d239cc7..2f99277 100644 --- a/scripts/uduck.ts +++ b/scripts/uduck.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'; const [command, ...args] = process.argv.slice(2); if (!['resolve', 'register', 'prepare'].includes(command ?? '')) { - console.error('Usage: pnpm uduck resolve|register [--category experimental] [--id slug]\n pnpm uduck prepare\nRequires Python with scripts/policy/requirements.txt installed; set UDUCK_PYTHON to its executable.'); + console.error('Usage: pnpm uduck resolve|register [--category experimental] [--id slug]\n pnpm uduck prepare\nRequires Python with scripts/policy/requirements.txt installed; set UDUCK_PYTHON to its executable.'); process.exitCode = 1; } else { const result = spawnSync(process.env.UDUCK_PYTHON ?? 'python3', ['scripts/policy/resolve.py', command!, ...args], { stdio: 'inherit', timeout: 300_000 }); diff --git a/scripts/validate-registry.ts b/scripts/validate-registry.ts index 495beca..5cabf51 100644 --- a/scripts/validate-registry.ts +++ b/scripts/validate-registry.ts @@ -1,91 +1,73 @@ import fs from "node:fs"; -import { PolicyPointerSchema } from "../registry/schema/policy"; import path from "node:path"; -import { BehaviorSchema, type Behavior } from "../registry/schema/behavior"; +import { PolicySchema, type Policy } from "../registry/schema/policy"; -const BEHAVIORS_DIR = path.resolve(process.cwd(), "registry/behaviors"); +const POLICIES_DIR = path.resolve(process.cwd(), "registry/policies"); -export function validateAllBehaviors(): { +export function logicalSourceKey(policy: Policy): string { + return `${policy.source.provider}:${policy.source.repo.toLowerCase()}:${policy.source.artifact_path}`; +} + +export function validatePolicies(directory = POLICIES_DIR): { valid: boolean; - behaviors: Behavior[]; + policies: Policy[]; errors: string[]; } { const errors: string[] = []; - const behaviors: Behavior[] = []; + const policies: Policy[] = []; + const ids = new Set(); + const sources = new Set(); - if (!fs.existsSync(BEHAVIORS_DIR)) { - return { valid: false, behaviors: [], errors: [`Directory not found: ${BEHAVIORS_DIR}`] }; + if (!fs.existsSync(directory)) { + return { valid: false, policies, errors: [`Directory not found: ${directory}`] }; } - const files = fs.readdirSync(BEHAVIORS_DIR).filter((f) => f.endsWith(".json")).sort(); - + const files = fs.readdirSync(directory).filter((file) => file.endsWith(".json")).sort(); if (files.length === 0) { - return { valid: false, behaviors: [], errors: ["No behavior files found in registry/behaviors/"] }; + return { valid: false, policies, errors: [`No policy files found in ${directory}/`] }; } - const idSet = new Set(); - for (const file of files) { - const fullPath = path.join(BEHAVIORS_DIR, file); + const fullPath = path.join(directory, file); try { - const raw = fs.readFileSync(fullPath, "utf-8"); - const parsed = JSON.parse(raw); - - const result = BehaviorSchema.safeParse(parsed); + const result = PolicySchema.safeParse(JSON.parse(fs.readFileSync(fullPath, "utf-8"))); if (!result.success) { errors.push(`Validation failed for ${file}:\n${JSON.stringify(result.error.format(), null, 2)}`); continue; } - const behavior = result.data; - if (idSet.has(behavior.id)) { - errors.push(`Duplicate behavior ID detected: '${behavior.id}' in file ${file}`); - } - idSet.add(behavior.id); + const policy = result.data; + if (ids.has(policy.id)) errors.push(`Duplicate policy ID detected: '${policy.id}'`); + ids.add(policy.id); + if (file !== `${policy.id}.json`) errors.push(`Filename mismatch: file is '${file}' but policy.id requires '${policy.id}.json'`); - const expectedFilename = `${behavior.id}.json`; - if (file !== expectedFilename) { - errors.push(`Filename mismatch: file is '${file}' but behavior.id requires '${expectedFilename}'`); - } - - behaviors.push(behavior); - } catch (err: any) { - errors.push(`Error parsing ${file}: ${err.message}`); + const sourceKey = logicalSourceKey(policy); + if (sources.has(sourceKey)) errors.push(`Duplicate logical source detected: '${policy.source.provider}:${policy.source.repo}/${policy.source.artifact_path}'`); + sources.add(sourceKey); + policies.push(policy); + } catch (error) { + errors.push(`Error parsing ${file}: ${error instanceof Error ? error.message : String(error)}`); } } - const policiesDir = path.resolve("registry/policies"); - const repos = new Set(); - for (const file of fs.existsSync(policiesDir) ? fs.readdirSync(policiesDir).filter(f => f.endsWith('.json')) : []) { - try { - const policy = PolicyPointerSchema.parse(JSON.parse(fs.readFileSync(path.join(policiesDir, file), 'utf8'))); - if (file !== `${policy.id}.json` || idSet.has(policy.id)) throw new Error('Duplicate or mismatched policy ID'); - if (repos.has(policy.source.repo.toLowerCase())) throw new Error('Duplicate Hub repository'); - repos.add(policy.source.repo.toLowerCase()); - idSet.add(policy.id); - } catch (error) { errors.push(`Invalid policy ${file}: ${error}`); } + if (directory === POLICIES_DIR) { + const obsoleteDirectory = path.resolve(process.cwd(), "registry", "behaviors"); + if (fs.existsSync(obsoleteDirectory)) errors.push("registry/policies must be the only authored registry directory"); } - return { - valid: errors.length === 0, - behaviors, - errors, - }; + + return { valid: errors.length === 0, policies, errors }; } if (process.argv[1]?.endsWith("validate-registry.ts")) { - console.log("Validating uDuck Registry entries..."); - const { valid, behaviors, errors } = validateAllBehaviors(); + console.log("Validating uDuck Registry policies..."); + const { valid, policies, errors } = validatePolicies(); if (!valid) { console.error(`\x1b[31mRegistry validation failed with ${errors.length} error(s):\x1b[0m`); - for (const err of errors) { - console.error(err); - } + for (const error of errors) console.error(error); process.exit(1); } - console.log(`\x1b[32mSuccessfully validated ${behaviors.length} registry behavior(s).\x1b[0m`); - for (const b of behaviors) { - console.log(` - [${b.verification.status}] ${b.name} (${b.id})`); - } + console.log(`\x1b[32mSuccessfully validated ${policies.length} authored policy(ies).\x1b[0m`); + for (const policy of policies) console.log(` - ${policy.id} (${policy.source.provider}:${policy.source.repo})`); } diff --git a/simulation/README.md b/simulation/README.md index 62e6b70..91439f0 100644 --- a/simulation/README.md +++ b/simulation/README.md @@ -1,139 +1,54 @@ -# Registry simulation (`simulation/`) - -The registry runner produces a deterministic diagnostic rollout and review -media for policies that explicitly opt into its constrained environment. A -render is not hardware verification and does not reproduce arbitrary publisher -training environments. - -## Recipe model - -Simulation is independent from `compatibility.robotd_slot`: - -```json -"simulation": { - "runner": "microduck-standard-v1", - "scene": "flat-v1", - "start": { "preset": "standing_pose" }, - "scenario": "velocity", - "duration_s": 6, - "checks": ["no_fall", "ends_upright", "velocity_tracking"], - "segments": [ - { "duration_s": 1, "vx": 0, "vy": 0, "wz": 0 }, - { "duration_s": 3, "vx": 0.25, "vy": 0, "wz": 0 }, - { "duration_s": 2, "vx": 0, "vy": 0, "wz": 0 } - ] -} +# Registry execution diagnostics + +`simulation/` is a maintainer-owned, deterministic diagnostic runner. It executes only a concrete `ExecutionSpec` assembled from an authored policy, its resolved upstream manifest, and a reviewed recipe. A render is not hardware verification and does not reproduce an arbitrary publisher training or evaluation environment. + +The data flow is: + +```text +registry/policies/.json + ↓ resolve and verify +resolved manifest + immutable artifact + ↓ maintainer recipe +ExecutionSpec + ↓ preflight, download, MuJoCo rollout +report.json + optional loop.mp4/poster.png + ↓ evidence store +content-addressed Release blob ``` -- `scene` is persistent world geometry. V1 supports only the registry-owned - `flat-v1` scene; a rough-terrain policy rendered there is only a flat-world - diagnostic. -- `model` selects the pinned robot asset variant and must match the behavior's - compatibility model. It defaults to that compatibility model; V1 supports - `microduck-standard` and the official `microduck-rollers` model. -- `start` is the robot state at time zero. V1 supports the raw - `standing_pose` (contact is not implied), `settled_standing`, and a bounded - `airborne_drop` preset. An airborne reset is reported as such and is not - counted as takeoff. -- `scenario` is the command schedule: `velocity`, `standing`, `sitstand`, - `oneshot_phase`, `oneshot_zero`, or `oneshot_trigger`. -- `checks` selects runner-defined assertions. Descriptors cannot provide check - prose or results. - -Before downloading a policy or starting MuJoCo, the runner performs a -deterministic admission check. It verifies the declared contract, model, scene, -start preset, scenario, and command schedule. Velocity schedules must be -explicit, cover the rollout exactly, and stay within the runner's supported -command range. A recipe that does not fit is rejected; command values are -never silently clipped or replaced with a default. - -If the policy requires custom assets, a different observation/action contract, -or a publisher-specific environment, declare that boundary instead of adding -code to the registry runner: - -```json -"simulation": { - "runner": "external", - "reason": "custom_environment", - "notes": "Uses the publisher's obstacle scene." -} -``` - -Having the fixed 61D/14D ONNX contract is not enough for admission: the -command protocol and environment must also be represented. Do not give CI a -convenient but inaccurate command schedule just so the policy can be rendered. -If the policy's command protocol or environment is not supported, use -`external` until it has a matching runner profile. - -Omitting `simulation` is also valid and produces an unsupported/no-recipe CI -report when that descriptor changes. +## ExecutionSpec -## What the report says +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. -Top-level execution is one of `rendered`, `unsupported`, `rejected`, or -`failed`. A rendered report includes exact observations and individual check -outcomes. It never emits a general policy-validation or hardware-validation -claim. +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. -The report also records the preflight status and any runtime-fidelity warnings, -such as a descriptor declaring BAM actuator dynamics while the registry runner -uses its deterministic position-control diagnostic model. That warning does not -turn a render into a reproduction claim. +When no recipe covers an entry, the runner writes a report with `execution: "not-covered"` and a reason. This is visible evidence, not an escape hatch around preflight. Unsupported source environments are not run through a different execution mode. -Baseline numerical-integrity and bounded-drift checks always run. Requested -checks may additionally cover falls, final posture, velocity tracking, -supported takeoff, and bilateral touchdown after takeoff. A failing requested -check fails CI only after the report and media have been produced for review. - -## Usage and outputs +## Usage ```bash -python -m venv .venv && . .venv/bin/activate -pip install -r simulation/requirements.txt # + system: libegl1, ffmpeg -python simulation/run_check.py --behavior alpha-walking --keep-media --out sim-results +python3 -m venv .venv +.venv/bin/pip install -r simulation/requirements.txt +PYTHONPATH=simulation MUJOCO_GL=egl python simulation/run_check.py \ + --entry flamingo-cycle --keep-media --out sim-results ``` -Outputs under `sim-results//`: +Outputs under `sim-results//` are: | File | Meaning | | --- | --- | -| `report.json` | Execution status, recipe, observations, checks, and provenance | -| `loop.mp4` | 512×512 H.264, 30 fps, muted diagnostic rollout | -| `poster.png` | 512×512 midpoint frame with an inset caption bar | - -Exit code 0 means rendered checks passed or the recipe is explicitly -unsupported; 1 means a requested check failed; 2 means preflight rejected the -recipe or execution failed. - -To preview a result matching the current descriptor and executable runner in a local build: - -```bash -python simulation/publish_result.py sim-results/alpha-walking -``` - -Publisher media is never replaced. Published registry renders are used as card -and hero fallbacks when publisher media is absent, and otherwise appear in a -separate **Registry simulation** section on the behavior page. +| `report.json` | Execution status, source, recipe, checks, and identity | +| `loop.mp4` | Registry-owned diagnostic rollout, when requested | +| `poster.png` | Registry-owned diagnostic poster, when requested | -## CI isolation +Exit code 0 means the diagnostic passed or was not-covered; 1 means measured checks failed; 2 means preflight or execution failed. Failed and not-covered reports remain publishable so the catalog can explain the boundary. -- `Sim Check` is a manual utility; `uDuck CI` owns PR and main evidence. This avoids duplicate concurrent downloads. -- Pinned assets use the existing hash-checked Actions cache; transient download failures have bounded backoff. -- The main CI workflow reruns the full legacy catalog before each build. Failed measured checks remain failed in the display; execution errors stop publication. -- Main builds publish matching reports/media into the static site and archive the run in a GitHub Release. PR artifacts remain temporary diagnostics. -- Generated media is ignored by git. No contributor or post-merge media commit is needed. -- Report identity includes descriptor bytes, runner source, requirements, asset lock, and downloaded policy SHA256. The website rejects stale input identities. +## Evidence identity -Fork PRs use read-only permissions, no secrets, and the `pull_request` event. -The runner does not execute contributor Python, install per-policy dependencies, -or accept contributor-provided scenes. +`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. -## Render and runtime standard +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. -- MuJoCo EGL offscreen renderer, square 512×512 H.264 `yuv420p`, 30 fps; -- fixed smoothed chase camera and registry-owned visual stage; -- pinned official Microduck MJCF variant and deterministic CPU rollout; -- 50 Hz control, decimation 4, 61 observations, and 14 actions. +## Runtime boundary -The runtime is a constrained compatibility aid. Publisher footage and external -evaluation remain the source of truth for environments the runner does not own. +Publisher media and evaluation may describe richer scenes, actuator models, command protocols, or hardware. Those claims remain publisher evidence. The registry runner reports only what its own stated scene, contract, recipe, and measured checks establish. diff --git a/simulation/evidence.py b/simulation/evidence.py index 86a2a96..127cc13 100644 --- a/simulation/evidence.py +++ b/simulation/evidence.py @@ -1,49 +1,46 @@ -"""Content identity for diagnostic execution inputs, independent of timestamps. - -Version 2 (uduck-execution-inputs-v2): only execution-relevant authored state -participates. A category/tag/summary change must not consume simulator time. -Mutable display names are not baked into renders (the runner captions by entry -ID), so they are excluded here. Runner code, asset lock, dependency pins, and -an explicit environment contract are all part of the identity. -""" +"""Content identity for one policy's deterministic execution inputs.""" + +from __future__ import annotations + import hashlib import json from pathlib import Path + ROOT = Path(__file__).resolve().parent.parent +IDENTITY_VERSION = "uduck-execution-inputs-v3" +EVIDENCE_VERSION = "uduck-evidence-v3" +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" + -IDENTITY_VERSION = "uduck-execution-inputs-v2" -EVIDENCE_VERSION = "uduck-evidence-v2" -# Bump when CI/runtime assumptions change (runner family, Python, system deps). -# Python packages themselves are pinned in simulation/requirements.txt and are -# hashed separately; this constant covers the surrounding environment. -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" -) +def _canonical_value(value): + if isinstance(value, float) and value.is_integer(): + return int(value) + if isinstance(value, list): + return [_canonical_value(item) for item in value] + if isinstance(value, dict): + return {key: _canonical_value(item) for key, item in value.items()} + return value def canonical_json(value) -> bytes: - return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + return json.dumps(_canonical_value(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") -def _runner_files(): - files = sorted( - (p for p in (ROOT / "simulation").rglob("*.py") if "tests" not in p.parts), - key=lambda p: str(p.relative_to(ROOT)), - ) - files = [*files, ROOT / "simulation/assets.lock.json", ROOT / "simulation/requirements.txt"] - return sorted(files, key=lambda p: str(p.relative_to(ROOT))) +def _runner_files() -> list[Path]: + files = [ + ROOT / "simulation/run_check.py", + ROOT / "simulation/execution.py", + ROOT / "simulation/fetch_assets.py", + ROOT / "simulation/http_download.py", + ] + files.extend(sorted((ROOT / "simulation/microduck_sim").glob("*.py"), key=lambda path: str(path.relative_to(ROOT)))) + return sorted(files, key=lambda path: str(path.relative_to(ROOT))) def runner_digest() -> str: h = hashlib.sha256() - for p in _runner_files(): - h.update(str(p.relative_to(ROOT)).encode() + b"\0" + p.read_bytes() + b"\0") + for path in _runner_files(): + h.update(str(path.relative_to(ROOT)).encode() + b"\0" + path.read_bytes() + b"\0") return h.hexdigest() @@ -51,48 +48,53 @@ def _file_digest(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() -def execution_descriptor(behavior_id: str) -> dict: - """Return only execution-relevant authored state for an entry.""" - policy_path = ROOT / "registry/policies" / f"{behavior_id}.json" - behavior_path = ROOT / "registry/behaviors" / f"{behavior_id}.json" - if policy_path.is_file(): - data = json.loads(policy_path.read_text()) - source = data.get("source", {}) if isinstance(data, dict) else {} - return { - "kind": "policy", - "id": data.get("id"), +def _execution_manifest(manifest: object) -> dict: + if not isinstance(manifest, dict): + return {} + keys = ( + "schema_version", "model_api", "obs_len", "action_len", "action_scale", + "kind", "duration_s", "unwind_s", "entry_pose", "command", "robot", + "decimation", "actuator_model", + ) + return {key: manifest[key] for key in keys if key in manifest} + + +def execution_inputs(entry_id: str) -> dict: + """Return only immutable and execution-relevant state for an entry.""" + + policy_path = ROOT / "registry/policies" / f"{entry_id}.json" + if not policy_path.is_file(): + raise FileNotFoundError(f"authored policy not found: {policy_path}") + policy = json.loads(policy_path.read_text()) + source = policy["source"] + generated_path = ROOT / ".generated/policies" / f"{entry_id}.json" + resolved = json.loads(generated_path.read_text()).get("resolved", {}) if generated_path.is_file() else {} + simulation = resolved.get("simulation") if isinstance(resolved, dict) else None + if not isinstance(simulation, dict): + simulation = {"status": "not-covered", "reason": "Policy resolution is not available."} + execution = { + "id": policy.get("id"), + "source": { + "provider": source.get("provider"), "repo": source.get("repo"), "revision": source.get("revision"), - "manifest_sha256": source.get("manifest_sha256"), + "artifact_path": source.get("artifact_path"), "artifact_sha256": source.get("artifact_sha256"), - } - data = json.loads(behavior_path.read_text()) - contract = data.get("contract", {}) if isinstance(data, dict) else {} - compatibility = data.get("compatibility", {}) if isinstance(data, dict) else {} - simulation = data.get("simulation") if isinstance(data, dict) else None - artifacts = data.get("artifacts", {}) if isinstance(data, dict) else {} - onnx = artifacts.get("onnx", {}) if isinstance(artifacts, dict) else {} - return { - "kind": "manual", - "id": data.get("id"), - "contract": { - "observation_dim": contract.get("observation_dim"), - "action_dim": contract.get("action_dim"), - "control_frequency_hz": contract.get("control_frequency_hz"), - "decimation": contract.get("decimation"), - "actuator_model": contract.get("actuator_model"), - "action_scale": contract.get("action_scale"), + "manifest_path": source.get("manifest_path"), + "manifest_sha256": source.get("manifest_sha256"), }, - "compatibility": { - "robot_model": compatibility.get("robot_model"), + "manifest": _execution_manifest(resolved.get("manifest") if isinstance(resolved, dict) else None), + "simulation": { + "status": simulation.get("status"), + "recipe": simulation.get("recipe") if simulation.get("status") == "covered" else None, + "reason": simulation.get("reason") if simulation.get("status") != "covered" else None, }, - "simulation": simulation, - "artifact_url": onnx.get("url"), } + return execution -def inputs_digest(behavior_id): - execution = execution_descriptor(behavior_id) +def inputs_digest(entry_id: str) -> str: + execution = execution_inputs(entry_id) h = hashlib.sha256() h.update(IDENTITY_VERSION.encode() + b"\0") h.update(canonical_json(execution) + b"\0") @@ -103,9 +105,5 @@ def inputs_digest(behavior_id): return h.hexdigest() -def evidence_key(inputs_sha256, artifact_sha256): - return hashlib.sha256( - EVIDENCE_VERSION.encode() + b"\0" - + inputs_sha256.encode() + b"\0" - + artifact_sha256.encode() - ).hexdigest() +def evidence_key(inputs_sha256: str, artifact_sha256: str) -> str: + return hashlib.sha256(EVIDENCE_VERSION.encode() + b"\0" + inputs_sha256.encode() + b"\0" + artifact_sha256.encode()).hexdigest() diff --git a/simulation/execution.py b/simulation/execution.py new file mode 100644 index 0000000..49087a0 --- /dev/null +++ b/simulation/execution.py @@ -0,0 +1,80 @@ +"""The runner boundary: a concrete execution specification for one policy.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ExecutionSpec: + """All inputs needed by the deterministic registry runner. + + An instance exists only when a maintainer-owned recipe covers the resolved + policy. Uncovered policies never get a partial or synthetic specification. + """ + + entry_id: str + artifact_url: str + artifact_sha256: str + model: str + contract: dict[str, Any] + recipe: dict[str, Any] + source: dict[str, Any] + manifest: dict[str, Any] | None + + +def artifact_url(source: dict[str, Any]) -> str: + provider = source["provider"] + repo = source["repo"] + revision = source["revision"] + artifact_path = source["artifact_path"] + if provider == "github": + return f"https://raw.githubusercontent.com/{repo}/{revision}/{artifact_path}" + prefix = "spaces/" if provider == "huggingface-space" else "" + return f"https://huggingface.co/{prefix}{repo}/resolve/{revision}/{artifact_path}" + + +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``.""" + + simulation = resolved.get("simulation") + if not isinstance(simulation, dict) or simulation.get("status") != "covered": + return None + recipe = simulation.get("recipe") + source = policy.get("source") + manifest = resolved.get("manifest") + if not isinstance(recipe, dict) or not isinstance(source, dict): + return None + if manifest is not None and not isinstance(manifest, dict): + return None + + robot = manifest.get("robot") if isinstance(manifest, dict) else None + if not isinstance(robot, dict): + return None + if manifest.get("obs_len") != 61 or manifest.get("action_len") != 14 or robot.get("model") != "microduck" or robot.get("control_hz") != 50: + return None + action_scale = manifest.get("action_scale") + if isinstance(action_scale, bool) or not isinstance(action_scale, (int, float)): + return None + contract = { + "observation_dim": manifest.get("obs_len"), + "action_dim": manifest.get("action_len"), + "control_frequency_hz": robot.get("control_hz"), + "action_scale": float(action_scale), + "decimation": manifest.get("decimation", 4), + "actuator_model": manifest.get("actuator_model", "Registry deterministic position-control diagnostic runtime"), + } + model = recipe.get("model") + if not isinstance(model, str): + return None + return ExecutionSpec( + entry_id=str(policy["id"]), + artifact_url=artifact_url(source), + artifact_sha256=str(source["artifact_sha256"]), + model=model, + contract=contract, + recipe=recipe, + source=source, + manifest=manifest, + ) diff --git a/simulation/pointer_recipes.py b/simulation/execution_recipes.py similarity index 50% rename from simulation/pointer_recipes.py rename to simulation/execution_recipes.py index 4f03f36..0db5351 100644 --- a/simulation/pointer_recipes.py +++ b/simulation/execution_recipes.py @@ -1,14 +1,9 @@ -"""Maintainer-owned diagnostic recipes for resolved Pollen policies. +"""Maintainer-owned execution recipes for resolved policy artifacts. -The JSON files under ``registry/policies`` are pointers and curation state. -They do not contain executable command schedules. This module is the small, -reviewable bridge between an upstream manifest and the registry's deterministic -MuJoCo runner. A recipe is admitted only when the command semantics are -available from upstream documentation or from an explicit maintainer review. - -The values returned here describe a registry diagnostic. They do not claim to -reproduce the publisher's training/evaluation environment, and they never -establish hardware evidence. +Authored policy files contain immutable upstream identity and curation only. +This small, reviewed layer is the only place where an upstream command example +can become an executable registry recipe. A missing recipe is a visible +``not-covered`` result, never a guessed command or runtime escape hatch. """ from __future__ import annotations @@ -22,53 +17,33 @@ SCENE = "flat-v1" START = {"preset": "settled_standing"} -# This is an upstream operational example, not an inferred value. The -# command is accepted by robotctl for this published policy and is exercised -# against the checked-in manifest fixture used by the recipe tests. FLAMINGO_REPO = "RemiFabre/microduck-flamingo-cycle" FLAMINGO_NAME = "flamingo-cycle" FLAMINGO_SOURCE = { + "provider": "huggingface-model", + "repo": FLAMINGO_REPO, "revision": "6646428394c6997106d2dc07c1588f20f6fea026", + "artifact_path": "policy.onnx", "manifest_sha256": "ac9b9ae16b4f21733990710275bd934c97558c6028e060bd2b34ec1f5341d302", "artifact_sha256": "df77929c39d7695092bdaf810c2075e20a9ba91abd8192b4073d3de593d56904", + "manifest_path": "manifest.json", } FLAMINGO_HOLD_S = 5.0 FLAMINGO_COMMAND = (1.0, 1.0, 0.0) -# Pinned upstream revision reviewed for command semantics (2026-09-05). -# policy-manifest.md: only constant-command episodic policies are generic -# one-shots; phase/posture_flag belong in daemon-driven slots. Single-policy -# repos carry exactly one policy.onnx; robotctl reads duration_s/chain/ -# action_scale/command.idle/unwind_s and refuses on obs_len/action_len/ -# model_api/robot.model/non-constant encoding. Flamingo is a published -# perpetual example with no duration_s, so --hold is required. -# cheatsheet.md: `sudo robotctl policy add flamingo -# RemiFabre/microduck-flamingo-cycle --hold 5 --command 1,1,0`; "--command is -# what the network is fed while it runs. Most skills need none: they are -# trained on an all-zero command and being selected *is* the trigger." UPSTREAM_PIN = "bc41fb5c9a9b39894669c1e022e375cf83800382" 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_SOURCE_URL = UPSTREAM_CHEATSHEET_URL def _duration(value: Any) -> float | None: if isinstance(value, bool) or not isinstance(value, (int, float)): return None value = float(value) - if not isfinite(value) or not 1.0 <= value <= 30.0: - return None - return value + return value if isfinite(value) and 1.0 <= value <= 30.0 else None def _generic_zero_recipe(manifest: dict[str, Any]) -> dict[str, Any] | None: - """Return the documented default recipe for a simple episodic policy. - - Pollen's cheatsheet (pinned in UPSTREAM_CHEATSHEET_URL) specifies that a - plain episodic skill runs on the all-zero command ("Most skills need - none: they are trained on an all-zero command and being selected *is* - the trigger"). This is a privilege, not a default: every precondition - below must hold, otherwise the pointer stays not-covered. - """ + """Cover only the documented, finite, constant zero-command one-shot.""" if manifest.get("kind") != "episodic": return None @@ -76,52 +51,20 @@ def _generic_zero_recipe(manifest: dict[str, Any]) -> dict[str, Any] | None: if command is not None and not isinstance(command, dict): return None command = command or {} - # Absent encoding is treated as constant per upstream manifest docs - # ("absent or `constant`: a fixed twist for the window"). Any other - # encoding is daemon-driven and has no generic recipe. - encoding = command.get("encoding", "constant") - if encoding not in (None, "constant"): + if command.get("encoding", "constant") not in (None, "constant"): return None - if encoding is None: - encoding = "constant" - # Do not interpret prose. Any twist/head/body claim — even "unused - # (zeros)" — requires a named maintainer recipe with a documented numeric - # command. The generic default applies only when no such claim exists. - for key in ("twist", "head", "body"): - if key in command: - return None - # Non-constant custom command semantics must be absent. - for key in ("sit", "stand", "slot", "period_s", "end_phase"): - if key in command: - return None - duration = _duration(manifest.get("duration_s")) - if duration is None: + if any(key in command for key in ("twist", "head", "body", "sit", "stand", "slot", "period_s", "end_phase")): return None - # The runner contract must be fully known: compatible I/O widths, - # control frequency, robot model, and an explicit action scale. Missing - # values stay missing; they never default to a convenient 1.0 here. - if manifest.get("obs_len") != 61 or manifest.get("action_len") != 14: - return None - # 61 inputs and 14 outputs alone do not prove the semantics of those - # channels; the daemon API version must be declared explicitly. - if manifest.get("model_api") != 1: + duration = _duration(manifest.get("duration_s")) + if duration is None or manifest.get("obs_len") != 61 or manifest.get("action_len") != 14 or manifest.get("model_api") != 1: return None robot = manifest.get("robot") - if not isinstance(robot, dict): - return None - if robot.get("model") != "microduck" or robot.get("control_hz") != 50: + if not isinstance(robot, dict) or robot.get("model") != "microduck" or robot.get("control_hz") != 50: return None action_scale = manifest.get("action_scale") - if isinstance(action_scale, bool) or not isinstance(action_scale, (int, float)): + if isinstance(action_scale, bool) or not isinstance(action_scale, (int, float)) or not isfinite(float(action_scale)): return None - action_scale_f = float(action_scale) - if not isfinite(action_scale_f): - return None - # Entry-pose uncertainty would make pass/fail misleading. Accept only a - # documented standing start or an absent claim explicitly accepted as a - # registry diagnostic assumption. - entry_pose = manifest.get("entry_pose") - if entry_pose is not None and entry_pose != "standing": + if manifest.get("entry_pose") not in (None, "standing"): return None return { "runner": RUNNER, @@ -137,8 +80,8 @@ def _generic_zero_recipe(manifest: dict[str, Any]) -> dict[str, Any] | None: "source_url": UPSTREAM_CHEATSHEET_URL, "upstream_pin": UPSTREAM_PIN, "command": [0.0, 0.0, 0.0], - "command_semantics": "upstream documented default for a constant episodic skill with no custom command prose", - "action_scale": action_scale_f, + "command_semantics": "upstream documented default for a constant episodic skill with no publisher-specific command prose", + "action_scale": float(action_scale), "entry_pose_assumption": "settled_standing registry start; manifest entry_pose is standing or absent", "scope": "Registry diagnostic rollout under flat-v1 settled_standing; this does not establish intended-task success or hardware evidence.", }, @@ -146,8 +89,6 @@ def _generic_zero_recipe(manifest: dict[str, Any]) -> dict[str, Any] | None: def _flamingo_recipe() -> dict[str, Any]: - """Build the reviewed Flamingo hold recipe from upstream command semantics.""" - return { "runner": RUNNER, "model": MODEL, @@ -155,9 +96,7 @@ def _flamingo_recipe() -> dict[str, Any]: "start": deepcopy(START), "scenario": "command_schedule", "duration_s": FLAMINGO_HOLD_S, - "segments": [ - {"duration_s": FLAMINGO_HOLD_S, "command": list(FLAMINGO_COMMAND)}, - ], + "segments": [{"duration_s": FLAMINGO_HOLD_S, "command": list(FLAMINGO_COMMAND)}], "checks": ["no_fall"], "provenance": { "owner": "uduck-registry-maintainers", @@ -180,32 +119,18 @@ def _flamingo_recipe() -> dict[str, Any]: } -def recipe_for_policy( - repo: str, - manifest: dict[str, Any], - source: dict[str, Any] | None = None, -) -> dict[str, Any] | None: - """Return a reviewed recipe for ``repo`` and its resolved manifest. - - Repository matching is case-insensitive because Hub owner/repository names - are case-insensitive in URLs, while manifest matching prevents a renamed or - republished artifact from silently inheriting a recipe. - """ +def recipe_for_policy(repo: str, manifest: dict[str, Any], source: dict[str, Any] | None = None) -> dict[str, Any] | None: + """Return a reviewed recipe only for an exact source/manifest match.""" if not isinstance(repo, str) or not isinstance(manifest, dict): return None - if ( - repo.casefold() == FLAMINGO_REPO.casefold() - and manifest.get("name") == FLAMINGO_NAME - and source is not None - and all(source.get(key) == value for key, value in FLAMINGO_SOURCE.items()) - ): + if repo.casefold() == FLAMINGO_REPO.casefold() and manifest.get("name") == FLAMINGO_NAME and source is not None and all(source.get(key) == value for key, value in FLAMINGO_SOURCE.items()): return _flamingo_recipe() return _generic_zero_recipe(manifest) def recipe_reason(repo: str, manifest: dict[str, Any], source: dict[str, Any] | None = None) -> str: - """Explain why a pointer has no registry-owned simulation recipe.""" + """Explain why no registry-owned execution recipe applies.""" if repo.casefold() == FLAMINGO_REPO.casefold(): if manifest.get("name") != FLAMINGO_NAME: @@ -221,13 +146,3 @@ def recipe_reason(repo: str, manifest: dict[str, Any], source: dict[str, Any] | if isinstance(manifest.get("command"), dict) and manifest["command"].get("encoding") not in (None, "constant"): return "The upstream command encoding is daemon-driven and has no registry recipe." return "No maintainer-owned registry recipe covers this manifest." - - -def recipe_descriptor( - repo: str, - manifest: dict[str, Any], - source: dict[str, Any] | None = None, -) -> dict[str, Any] | None: - """Alias with an explicit name for resolver/runner callers.""" - - return recipe_for_policy(repo, manifest, source) diff --git a/simulation/microduck_sim/checks.py b/simulation/microduck_sim/checks.py index 48a6806..471ae21 100644 --- a/simulation/microduck_sim/checks.py +++ b/simulation/microduck_sim/checks.py @@ -35,7 +35,7 @@ def add(name: str, passed: bool, detail: str) -> None: check_results.append({"check": name, "passed": bool(passed), "detail": detail}) # Baseline integrity checks always run and cannot be disabled by a - # descriptor. They say the rollout was numerically usable, nothing more. + # recipe. They say the rollout was numerically usable, nothing more. add("finite_outputs", metrics["all_finite"], f"max |action| = {metrics['max_abs_action']}") add("bounded_drift", metrics["displacement_m"] < MAX_DRIFT_M, diff --git a/simulation/microduck_sim/preflight.py b/simulation/microduck_sim/preflight.py index ceb1292..6fc8e8e 100644 --- a/simulation/microduck_sim/preflight.py +++ b/simulation/microduck_sim/preflight.py @@ -1,46 +1,25 @@ -"""Deterministic admission checks for registry-owned simulation recipes. - -These checks answer whether a descriptor can be represented by the pinned -registry runner. They do not execute the policy or make a claim about its -behavioral success. -""" +"""Admission checks for the concrete registry ExecutionSpec.""" from __future__ import annotations from dataclasses import dataclass from math import isfinite +from typing import TYPE_CHECKING, Any + +from .constants import ACTION_DIM, CONTROL_HZ, DECIMATION, OBSERVATION_DIM, VEL_MAX_ANG, VEL_MAX_X, VEL_MAX_Y, VEL_MIN_X, VEL_MIN_Y -from .constants import ( - ACTION_DIM, - CONTROL_HZ, - DECIMATION, - OBSERVATION_DIM, - VEL_MAX_ANG, - VEL_MAX_X, - VEL_MAX_Y, - VEL_MIN_X, - VEL_MIN_Y, -) +if TYPE_CHECKING: + from ..execution import ExecutionSpec STANDARD_RUNNER = "microduck-standard-v1" SUPPORTED_MODELS = {"microduck-standard", "microduck-rollers"} SUPPORTED_SCENE = "flat-v1" -SUPPORTED_SCENARIOS = { - "velocity", - "command_schedule", - "standing", - "sitstand", - "oneshot_phase", - "oneshot_zero", - "oneshot_trigger", -} +SUPPORTED_SCENARIOS = {"velocity", "command_schedule", "standing", "sitstand", "oneshot_phase", "oneshot_zero", "oneshot_trigger"} SUPPORTED_START_PRESETS = {"standing_pose", "settled_standing", "airborne_drop"} @dataclass(frozen=True) class PreflightResult: - """The static result before any artifact or simulator work begins.""" - errors: tuple[str, ...] = () warnings: tuple[str, ...] = () @@ -50,220 +29,152 @@ def valid(self) -> bool: class SimulationPreflightError(ValueError): - """Raised when a registry recipe cannot be represented by its runner.""" - def __init__(self, result: PreflightResult) -> None: self.result = result - super().__init__( - "simulation preflight rejected the descriptor:\n- " - + "\n- ".join(result.errors) - ) + super().__init__("execution spec rejected by simulation preflight:\n- " + "\n- ".join(result.errors)) -def _is_finite_number(value: object) -> bool: - return ( - isinstance(value, (int, float)) - and not isinstance(value, bool) - and isfinite(value) - ) +def _finite(value: object) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) def _velocity_errors(vx: object, vy: object, wz: object, prefix: str) -> list[str]: errors: list[str] = [] - limits = ( + for axis, value, minimum, maximum in ( ("vx", vx, VEL_MIN_X, VEL_MAX_X), ("vy", vy, VEL_MIN_Y, VEL_MAX_Y), ("wz", wz, -VEL_MAX_ANG, VEL_MAX_ANG), - ) - for axis, value, minimum, maximum in limits: - if not _is_finite_number(value): + ): + if not _finite(value): errors.append(f"{prefix}.{axis} must be a finite number") elif value < minimum or value > maximum: - errors.append( - f"{prefix}.{axis}={value:g} exceeds {STANDARD_RUNNER}'s " - f"supported range [{minimum:g}, {maximum:g}]" - ) + errors.append(f"{prefix}.{axis}={value:g} exceeds the supported range [{minimum:g}, {maximum:g}]") return errors -def preflight_descriptor(descriptor: dict) -> PreflightResult: - """Check a descriptor against the capabilities of ``standard-v1``. - - External/no-recipe descriptors intentionally bypass these checks. They are - reported as unsupported by ``run_check.py`` and must provide their own - publisher-owned environment or media. - """ - - simulation = descriptor.get("simulation") - if simulation is None: - return PreflightResult() - if not isinstance(simulation, dict): - return PreflightResult(errors=("simulation must be an object",)) - if simulation.get("runner") == "external": - return PreflightResult() +def preflight_execution(spec: "ExecutionSpec") -> PreflightResult: + """Check every execution input before artifact download or inference.""" + recipe = spec.recipe errors: list[str] = [] warnings: list[str] = [] - contract = descriptor.get("contract", {}) - if not isinstance(contract, dict): - errors.append("contract must be an object") - contract = {} - compatibility = descriptor.get("compatibility", {}) - if not isinstance(compatibility, dict): - errors.append("compatibility must be an object") - compatibility = {} - runner = simulation.get("runner") - - if runner != STANDARD_RUNNER: - errors.append(f"unsupported simulation runner: {runner!r}") - - model = simulation.get("model", compatibility.get("robot_model")) - if model not in SUPPORTED_MODELS: - errors.append(f"{STANDARD_RUNNER} does not support robot model {model!r}") - if model != compatibility.get("robot_model"): - errors.append( - f"simulation model {model!r} does not match compatibility model " - f"{compatibility.get('robot_model')!r}" - ) - - if simulation.get("scene") != SUPPORTED_SCENE: - errors.append( - f"{STANDARD_RUNNER} supports only scene {SUPPORTED_SCENE!r}; " - f"got {simulation.get('scene')!r}" - ) - - scenario = simulation.get("scenario") + if recipe.get("runner") != STANDARD_RUNNER: + errors.append(f"unsupported execution runner: {recipe.get('runner')!r}") + if spec.model not in SUPPORTED_MODELS: + errors.append(f"{STANDARD_RUNNER} does not support robot model {spec.model!r}") + if recipe.get("model") != spec.model: + errors.append(f"recipe model {recipe.get('model')!r} does not match ExecutionSpec model {spec.model!r}") + if recipe.get("scene") != SUPPORTED_SCENE: + errors.append(f"{STANDARD_RUNNER} supports only scene {SUPPORTED_SCENE!r}; got {recipe.get('scene')!r}") + + scenario = recipe.get("scenario") if scenario not in SUPPORTED_SCENARIOS: - errors.append(f"unsupported simulation scenario: {scenario!r}") - - start = simulation.get("start") + errors.append(f"unsupported execution scenario: {scenario!r}") + start = recipe.get("start") if not isinstance(start, dict): - errors.append("simulation.start must be an object") + errors.append("execution recipe start must be an object") start = {} preset = start.get("preset") if preset not in SUPPORTED_START_PRESETS: - errors.append(f"unsupported simulation start preset: {preset!r}") + errors.append(f"unsupported execution start preset: {preset!r}") elif preset == "airborne_drop": height = start.get("trunk_height_m") - if not _is_finite_number(height): - errors.append("simulation.start.trunk_height_m must be a finite number") - elif height < 0.15 or height > 0.5: - errors.append("simulation.start.trunk_height_m must be between 0.15 and 0.5 m") + if not _finite(height) or not 0.15 <= height <= 0.5: + errors.append("execution start trunk_height_m must be finite and between 0.15 and 0.5 m") if start.get("orientation") not in {"upright", "front", "back", "left", "right"}: - errors.append("simulation.start.orientation is unsupported") + errors.append("execution start orientation is unsupported") velocity = start.get("linear_velocity_mps") - if velocity is not None: - if not isinstance(velocity, (list, tuple)) or len(velocity) != 3: - errors.append("simulation.start.linear_velocity_mps must have three values") - else: - for index, value in enumerate(velocity): - if not _is_finite_number(value) or value < -3 or value > 3: - errors.append( - f"simulation.start.linear_velocity_mps[{index}] must be finite and in [-3, 3]" - ) + if velocity is not None and (not isinstance(velocity, (list, tuple)) or len(velocity) != 3 or any(not _finite(value) or value < -3 or value > 3 for value in velocity)): + errors.append("execution start linear_velocity_mps must contain three finite values in [-3, 3]") - duration = simulation.get("duration_s") - duration_value = duration if _is_finite_number(duration) else None - if duration_value is None: - errors.append("simulation.duration_s must be a finite number") - elif duration_value < 1 or duration_value > 30: - errors.append("simulation.duration_s must be between 1 and 30 seconds") + duration = recipe.get("duration_s") + duration_value = float(duration) if _finite(duration) else None + if duration_value is None or not 1 <= duration_value <= 30: + errors.append("execution duration_s must be finite and between 1 and 30 seconds") + segments = recipe.get("segments") if scenario == "velocity": - segments = simulation.get("segments") if not isinstance(segments, list) or not segments: - errors.append("simulation.segments is required for the velocity scenario") + errors.append("execution segments are required for the velocity scenario") else: - total_duration = 0.0 + total = 0.0 for index, segment in enumerate(segments): - prefix = f"simulation.segments[{index}]" + prefix = f"execution.segments[{index}]" if not isinstance(segment, dict): errors.append(f"{prefix} must be an object") continue segment_duration = segment.get("duration_s") - if _is_finite_number(segment_duration) and segment_duration > 0: - total_duration += float(segment_duration) - else: + if not _finite(segment_duration) or segment_duration <= 0: errors.append(f"{prefix}.duration_s must be a positive finite number") - errors.extend(_velocity_errors( - segment.get("vx"), segment.get("vy"), segment.get("wz"), prefix - )) - if duration_value is not None and abs(total_duration - duration_value) > 1e-9: - errors.append( - f"simulation.segments cover {total_duration:g}s but " - f"simulation.duration_s={duration_value:g}s; the schedule must cover the rollout exactly" - ) + 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") elif scenario == "command_schedule": - segments = simulation.get("segments") if not isinstance(segments, list) or not segments: - errors.append("simulation.segments is required for the command_schedule scenario") + errors.append("execution segments are required for the command_schedule scenario") else: - total_duration = 0.0 + total = 0.0 for index, segment in enumerate(segments): - prefix = f"simulation.segments[{index}]" + prefix = f"execution.segments[{index}]" if not isinstance(segment, dict): errors.append(f"{prefix} must be an object") continue segment_duration = segment.get("duration_s") - if _is_finite_number(segment_duration) and segment_duration > 0: - total_duration += float(segment_duration) - else: + if not _finite(segment_duration) or segment_duration <= 0: errors.append(f"{prefix}.duration_s must be a positive finite number") + else: + total += float(segment_duration) command = segment.get("command") if not isinstance(command, (list, tuple)) or len(command) != 3: errors.append(f"{prefix}.command must have exactly three finite values") else: for axis, value in enumerate(command): - if not _is_finite_number(value): - errors.append(f"{prefix}.command[{axis}] must be a finite number") - elif value < -3 or value > 3: - errors.append( - f"{prefix}.command[{axis}]={value:g} exceeds " - "the command range [-3, 3]" - ) - if duration_value is not None and abs(total_duration - duration_value) > 1e-9: - errors.append( - f"simulation.segments cover {total_duration:g}s but " - f"simulation.duration_s={duration_value:g}s; the schedule must cover the rollout exactly" - ) - elif "segments" in simulation: - errors.append("simulation.segments is only valid with the velocity and command_schedule scenarios") - + 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") + elif "segments" in recipe: + errors.append("execution segments are only valid with velocity and command_schedule scenarios") + + if scenario == "oneshot_phase": + period = recipe.get("period_s") + end_phase = recipe.get("end_phase") + if not _finite(period) or not 0 < period <= 30: + errors.append("oneshot_phase requires an explicit period_s between 0 and 30 seconds") + if not _finite(end_phase) or not 0 < end_phase <= 1: + errors.append("oneshot_phase requires an explicit end_phase in (0, 1]") + elif scenario == "sitstand": + hold = recipe.get("hold_s") + if not _finite(hold) or not _finite(duration_value) or not 0 <= hold <= duration_value: + errors.append("sitstand requires an explicit hold_s within the rollout duration") + elif scenario == "oneshot_trigger": + trigger = recipe.get("trigger_s") + if not _finite(trigger) or not _finite(duration_value) or not 0 <= trigger <= duration_value: + errors.append("oneshot_trigger requires an explicit trigger_s within the rollout duration") + + contract: dict[str, Any] = spec.contract if contract.get("observation_dim") != OBSERVATION_DIM: - errors.append( - f"{STANDARD_RUNNER} expects {OBSERVATION_DIM} observations; " - f"descriptor declares {contract.get('observation_dim')!r}" - ) + errors.append(f"{STANDARD_RUNNER} expects {OBSERVATION_DIM} observations; spec declares {contract.get('observation_dim')!r}") if contract.get("action_dim") != ACTION_DIM: - errors.append( - f"{STANDARD_RUNNER} expects {ACTION_DIM} actions; " - f"descriptor declares {contract.get('action_dim')!r}" - ) + errors.append(f"{STANDARD_RUNNER} expects {ACTION_DIM} actions; spec declares {contract.get('action_dim')!r}") if contract.get("control_frequency_hz") != CONTROL_HZ: - errors.append( - f"{STANDARD_RUNNER} expects {CONTROL_HZ} Hz control; " - f"descriptor declares {contract.get('control_frequency_hz')!r} Hz" - ) + errors.append(f"{STANDARD_RUNNER} expects {CONTROL_HZ} Hz control; spec declares {contract.get('control_frequency_hz')!r} Hz") if contract.get("decimation") != DECIMATION: - errors.append( - f"{STANDARD_RUNNER} expects decimation {DECIMATION}; " - f"descriptor declares {contract.get('decimation')!r}" - ) - - actuator_model = str(contract.get("actuator_model", "")).lower() - if "bam" in actuator_model: - warnings.append( - "descriptor declares BAM actuator dynamics; standard-v1 uses the " - "registry's deterministic position-control diagnostic runtime" - ) - + errors.append(f"{STANDARD_RUNNER} expects decimation {DECIMATION}; spec declares {contract.get('decimation')!r}") + action_scale = contract.get("action_scale") + if not _finite(action_scale): + errors.append("ExecutionSpec requires an explicit finite action_scale") + if "bam" in str(contract.get("actuator_model", "")).lower(): + warnings.append("spec declares BAM actuator dynamics; the registry runtime uses deterministic position control") + if not isinstance(spec.artifact_url, str) or not spec.artifact_url.startswith("https://"): + errors.append("ExecutionSpec artifact_url must be an HTTPS URL") return PreflightResult(tuple(errors), tuple(warnings)) -def require_valid(descriptor: dict) -> PreflightResult: - """Run preflight and raise one readable error before simulation starts.""" - - result = preflight_descriptor(descriptor) +def require_valid(spec: "ExecutionSpec") -> PreflightResult: + result = preflight_execution(spec) if result.errors: raise SimulationPreflightError(result) return result diff --git a/simulation/microduck_sim/scenarios.py b/simulation/microduck_sim/scenarios.py index dff4090..de19ee8 100644 --- a/simulation/microduck_sim/scenarios.py +++ b/simulation/microduck_sim/scenarios.py @@ -1,6 +1,6 @@ """Named scenarios: how the 13D command evolves over a diagnostic rollout. -A scenario is selected explicitly by a descriptor's `simulation` block. +A scenario is selected explicitly by an ExecutionSpec recipe. Compatibility and robotd installation slots are intentionally not inputs. """ @@ -30,13 +30,13 @@ class ScenarioSpec: # oneshot_zero: seconds the zeroed command window lasts (kicks, roulade). duration_s: float = 0.5 # oneshot_trigger: binary launch request followed by the zero command - # (custom one-shot policies such as jumps). + # (publisher-specific one-shot policies such as jumps). trigger_s: float = 0.2 - # Runner-defined checks requested by the descriptor. These are assertions + # Runner-defined checks requested by the recipe. These are assertions # over measured telemetry, not contributor-authored validation claims. checks: list[str] = field(default_factory=list) # command_schedule: list of (duration_s, [twist_x, twist_y, twist_wz]) - # segments. Unlike the legacy oneshot_zero scenario, each command value is + # segments. Unlike a blind one-shot scenario, each command value is # explicit and is retained in the report/provenance for review. command_segments: list = field(default_factory=list) # Alias of the scenario for reports. @@ -47,7 +47,7 @@ def validate_velocity(vx: float, vy: float, wz: float) -> tuple: """Return a velocity command unchanged, rejecting unsupported values. A registry recipe is declarative input, not a user-control stream. Silently - clipping it would make the rendered rollout differ from what the descriptor + clipping it would make the rendered rollout differ from what the recipe says, so out-of-range values are an explicit error. """ limits = ( @@ -66,20 +66,29 @@ def validate_velocity(vx: float, vy: float, wz: float) -> tuple: return float(vx), float(vy), float(wz) -def scenario_from_descriptor(sim_block: dict) -> ScenarioSpec: - """Build a scenario solely from an explicit registry simulation recipe.""" +def scenario_from_recipe(sim_block: dict) -> ScenarioSpec: + """Build a scenario solely from an explicit maintainer-owned recipe.""" if sim_block.get("runner") != "microduck-standard-v1": - raise ValueError("descriptor does not declare a registry simulation recipe") + raise ValueError("recipe does not declare the registry runner") spec = 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"]) - spec.trigger_s = float(sim_block.get("trigger_s", spec.trigger_s)) - spec.period_s = float(sim_block.get("period_s", spec.period_s)) - spec.end_phase = float(sim_block.get("end_phase", spec.end_phase)) - spec.hold_s = float(sim_block.get("hold_s", spec.hold_s)) + if spec.kind == "oneshot_trigger": + if "trigger_s" not in sim_block: + raise ValueError("oneshot_trigger requires an explicit trigger_s") + spec.trigger_s = float(sim_block["trigger_s"]) + if spec.kind == "oneshot_phase": + if "period_s" not in sim_block or "end_phase" not in sim_block: + raise ValueError("oneshot_phase requires explicit period_s and end_phase") + spec.period_s = float(sim_block["period_s"]) + spec.end_phase = float(sim_block["end_phase"]) + if spec.kind == "sitstand": + if "hold_s" not in sim_block: + raise ValueError("sitstand requires an explicit hold_s") + spec.hold_s = float(sim_block["hold_s"]) segments = sim_block.get("segments") if spec.kind == "velocity": if not isinstance(segments, list) or not segments: @@ -124,7 +133,7 @@ def make_command_fn(spec: ScenarioSpec, use_13d: bool) -> Callable[[float], np.n """Return f(t) -> command vector for the scenario. `use_13d` selects the unified 13D command (twist + head + body pose); - otherwise the legacy 3D twist command is produced. + otherwise the compact 3D twist command is produced. """ def wrap(cmd: np.ndarray) -> np.ndarray: if not use_13d: @@ -203,7 +212,7 @@ def zero_fn(t: float) -> np.ndarray: return zero_fn if spec.kind == "oneshot_trigger": - # Custom one-shot policies documented by their authors as a binary + # Publisher-specific one-shot policies documented by their authors as a binary # launch request in twist-vx, followed by the settling command. trigger_s = spec.trigger_s diff --git a/simulation/pointer_runner.py b/simulation/pointer_runner.py deleted file mode 100644 index 1373392..0000000 --- a/simulation/pointer_runner.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Adapt a resolved Pollen pointer to the registry simulation contract. - -This module contains no network access and never executes publisher code. The -resolver/build step supplies the pinned pointer and manifest; a reviewed recipe -from :mod:`pointer_recipes` supplies the command schedule. Keeping this -adapter separate makes it usable by the evidence job and keeps authored pointer -files free of runtime defaults. -""" - -from __future__ import annotations - -from hashlib import sha256 -from typing import Any - -try: # Imported as ``simulation.pointer_runner`` or as a PYTHONPATH script. - from .pointer_recipes import recipe_for_policy -except ImportError: # pragma: no cover - exercised by the script-style runner. - from pointer_recipes import recipe_for_policy - - -def policy_artifact_url(pointer: dict[str, Any]) -> str: - """Return the canonical immutable Hub URL for a single policy artifact.""" - - source = pointer["source"] - return ( - f"https://huggingface.co/{source['repo']}/resolve/" - f"{source['revision']}/policy.onnx" - ) - - -def manifest_digest(raw: bytes) -> str: - return sha256(raw).hexdigest() - - -def artifact_matches(pointer: dict[str, Any], data: bytes) -> bool: - """Check downloaded bytes against the authored artifact identity.""" - - return sha256(data).hexdigest() == pointer["source"]["artifact_sha256"] - - -def simulation_descriptor( - pointer: dict[str, Any], - manifest: dict[str, Any], -) -> dict[str, Any] | None: - """Build the runner descriptor for a covered pointer. - - ``None`` means no maintainer-owned recipe exists *or* the manifest does - not supply every runner input explicitly. The caller must emit a visible - unsupported/not-covered result rather than synthesize a command or fail - the whole registration. In particular there is no silent - ``action_scale = 1.0`` default: Flamingo declares 1.0 explicitly, and any - other policy without an explicit finite action_scale stays not-covered. - """ - - from math import isfinite - - source = pointer["source"] - recipe = recipe_for_policy(source["repo"], manifest, source) - if recipe is None: - return None - robot = manifest.get("robot") - if not isinstance(robot, dict): - return None - # All runner inputs must be known explicitly. Missing or incompatible - # values yield not-covered, never a guessed descriptor that would fail - # preflight and kill package registration. - if manifest.get("obs_len") != 61 or manifest.get("action_len") != 14: - return None - if robot.get("model") != "microduck" or robot.get("control_hz") != 50: - return None - action_scale = manifest.get("action_scale") - if isinstance(action_scale, bool) or not isinstance(action_scale, (int, float)): - return None - action_scale_f = float(action_scale) - if not isfinite(action_scale_f): - return None - return { - "id": pointer["id"], - "name": manifest.get("name") or pointer["id"], - "contract": { - "observation_dim": manifest.get("obs_len"), - "action_dim": manifest.get("action_len"), - "control_frequency_hz": robot.get("control_hz"), - "decimation": 4, - "actuator_model": "Registry deterministic position-control diagnostic runtime", - "action_scale": action_scale_f, - }, - "compatibility": {"robot_model": "microduck-standard"}, - "simulation": recipe, - "artifacts": { - "onnx": { - "filename": "policy.onnx", - "url": policy_artifact_url(pointer), - "expected_sha256": source["artifact_sha256"], - "baked_normalizer": None, - }, - }, - "pointer": pointer, - "manifest": manifest, - } diff --git a/simulation/publish_result.py b/simulation/publish_result.py deleted file mode 100644 index b361e52..0000000 --- a/simulation/publish_result.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -"""Promote one reviewed registry simulation artifact into the static site.""" - -from __future__ import annotations - -import argparse -import json -import re -import shutil -from pathlib import Path -from evidence import inputs_digest, evidence_key - -HERE = Path(__file__).resolve().parent -REPO_ROOT = HERE.parent -ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") - - -def publish(source: Path) -> Path: - source = source.resolve() - report_path = source / "report.json" - loop_path = source / "loop.mp4" - poster_path = source / "poster.png" - for path in (report_path, loop_path, poster_path): - if not path.is_file(): - raise ValueError(f"missing generated artifact: {path}") - - report = json.loads(report_path.read_text()) - behavior_id = report.get("behavior") - if not isinstance(behavior_id, str) or not ID_PATTERN.fullmatch(behavior_id): - raise ValueError("report has no safe behavior id") - if report.get("execution") != "rendered": - raise ValueError("only a completed diagnostic render can be published") - descriptor = REPO_ROOT / "registry" / "behaviors" / f"{behavior_id}.json" - if not descriptor.is_file(): - raise ValueError(f"no registry descriptor for {behavior_id}") - - identity = inputs_digest(behavior_id) - if report.get("inputs_sha256") != identity: - raise ValueError("Evidence does not match the current descriptor and runner") - key = evidence_key(identity, report.get("policy", {}).get("sha256", "")) - if report.get("evidence_key") != key: - raise ValueError("Invalid evidence identity") - target = REPO_ROOT / "public" / "media" / "registry-sim" / behavior_id - target.mkdir(parents=True, exist_ok=True) - shutil.copy2(loop_path, target / "loop.mp4") - shutil.copy2(poster_path, target / "poster.png") - report["media"] = { - "loop_url": f"/media/registry-sim/{behavior_id}/loop.mp4", - "poster_url": f"/media/registry-sim/{behavior_id}/poster.png", - } - (target / "report.json").write_text(json.dumps(report, indent=2) + "\n") - return target - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("source", type=Path, help="sim-results/ directory") - args = parser.parse_args() - try: - target = publish(args.source) - except Exception as exc: # noqa: BLE001 - parser.error(str(exc)) - print(f"published reviewed registry simulation to {target}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/simulation/run_check.py b/simulation/run_check.py index bf3820b..3b8ffbe 100644 --- a/simulation/run_check.py +++ b/simulation/run_check.py @@ -1,21 +1,5 @@ #!/usr/bin/env python3 -"""Run the standardized simulation check for one registry behavior. - -Usage: - python -m run_check --behavior alpha-walking [--out OUT_DIR] [--keep-media] - -Reads `registry/behaviors/.json`, downloads the canonical ONNX (hosts are -restricted to the registry artifact allowlist), executes the descriptor's -explicit registry simulation recipe, runs a -deterministic MuJoCo rollout at the 50 Hz runtime contract, then writes: - - OUT//report.json execution status, exact checks, observations, provenance - OUT//loop.mp4 standardized 512x512 H.264 render loop - OUT//poster.png standardized poster (middle frame + caption bar) - -Exit code 0 = rendered/unsupported, 1 = requested check failed, -2 = preflight rejection or error (could not run at all). -""" +"""Run the deterministic MuJoCo preflight and rollout for one ExecutionSpec.""" from __future__ import annotations @@ -23,327 +7,196 @@ import datetime as _dt import hashlib import json +import math import os import re import sys import tempfile +import urllib.parse import urllib.request from pathlib import Path HERE = Path(__file__).resolve().parent sys.path.insert(0, str(HERE)) - os.environ.setdefault("MUJOCO_GL", "egl") import mujoco # noqa: E402 -from evidence import inputs_digest, evidence_key -from http_download import open_download +from evidence import evidence_key, inputs_digest # noqa: E402 +from execution import ExecutionSpec, artifact_url, execution_spec_from_policy # noqa: E402 +from http_download import open_download # noqa: E402 from microduck_sim import checks, render # noqa: E402 from microduck_sim.preflight import SimulationPreflightError, require_valid # noqa: E402 -from microduck_sim.scenarios import make_command_fn, scenario_from_descriptor # noqa: E402 -from pointer_runner import policy_artifact_url, simulation_descriptor # noqa: E402 -from pointer_recipes import recipe_reason # noqa: E402 from microduck_sim.robot import DuckRuntime, load_model # noqa: E402 +from microduck_sim.scenarios import make_command_fn, scenario_from_recipe # noqa: E402 REPO_ROOT = HERE.parent ALLOWED_HOSTS = ("huggingface.co", "raw.githubusercontent.com") MAX_ONNX_BYTES = 100 * 1024 * 1024 -MAX_MANIFEST_BYTES = 2 * 1024 * 1024 - -def _manifest_url(pointer: dict) -> str: - source = pointer["source"] - return ( - f"https://huggingface.co/{source['repo']}/resolve/" - f"{source['revision']}/manifest.json" - ) +def load_policy_resolution(entry_id: str) -> tuple[dict, dict]: + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", entry_id): + raise ValueError("invalid entry id") + policy_path = REPO_ROOT / "registry" / "policies" / f"{entry_id}.json" + if not policy_path.exists(): + raise SystemExit(f"no authored policy at {policy_path}") + policy = json.loads(policy_path.read_text()) + generated_path = REPO_ROOT / ".generated" / "policies" / f"{entry_id}.json" + if not generated_path.exists(): + return policy, { + "source": policy["source"], + "manifest": None, + "resolution": "review", + "install_route": "review", + "unresolved": ["Policy has not been resolved by the build preparation step."], + "onnx": {}, + "simulation": {"status": "not-covered", "reason": "Policy has not been resolved by the build preparation step."}, + } + generated = json.loads(generated_path.read_text()) + resolved = generated.get("resolved", generated) + if not isinstance(resolved, dict) or resolved.get("source") != policy.get("source"): + raise ValueError(f"stale generated policy resolution: {generated_path}") + return policy, resolved + + +def download_onnx(spec: ExecutionSpec, dest_dir: Path) -> Path: + parsed = urllib.parse.urlsplit(spec.artifact_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" + dest = dest_dir / filename + request = urllib.request.Request(spec.artifact_url, headers={"User-Agent": "uduck-registry-ci"}) + with open_download(request, timeout=300) as response, dest.open("wb") as output: + size = 0 + while True: + chunk = response.read(1 << 20) + if not chunk: + break + size += len(chunk) + if size > MAX_ONNX_BYTES: + 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}") + return dest -def _download_manifest(pointer: dict) -> tuple[dict, bytes]: - """Fetch only the pinned manifest when prepare output is unavailable.""" - - req = urllib.request.Request(_manifest_url(pointer), headers={"User-Agent": "uduck-registry-ci"}) - with open_download(req, timeout=60) as response: - raw = response.read(MAX_MANIFEST_BYTES + 1) - if len(raw) > MAX_MANIFEST_BYTES: - raise ValueError("policy manifest exceeds 2 MB sanity bound") - actual = hashlib.sha256(raw).hexdigest() - expected = pointer["source"]["manifest_sha256"] - if actual != expected: - raise ValueError( - f"policy manifest hash mismatch: expected {expected}, got {actual}" - ) - try: - manifest = json.loads(raw) - except json.JSONDecodeError as exc: - raise ValueError("pinned policy manifest is not valid JSON") from exc - if not isinstance(manifest, dict): - raise ValueError("pinned policy manifest must be an object") - return manifest, raw +def identity_fields(entry_id: str, source: dict) -> dict[str, str]: + inputs = inputs_digest(entry_id) + artifact = source["artifact_sha256"] + return {"inputs_sha256": inputs, "evidence_key": evidence_key(inputs, artifact)} -def load_pointer_descriptor(policy_id: str) -> dict: - """Load a pointer plus prepared/fetched manifest as a runner descriptor. - Generated resolver output is preferred so the evidence job does not fetch - the same manifest twice. A direct pinned fetch remains available for local - runs and verifies the authored manifest hash before a command recipe is - selected. - """ +def write_report(out_dir: Path, entry_id: str, report: dict) -> Path: + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", entry_id): + raise ValueError("invalid entry id") + target = out_dir / entry_id + target.mkdir(parents=True, exist_ok=True) + report_path = target / "report.json" + report_path.write_text(json.dumps(report, indent=2) + "\n") + return report_path - pointer_path = REPO_ROOT / "registry" / "policies" / f"{policy_id}.json" - if not pointer_path.exists(): - raise SystemExit(f"no policy pointer at {pointer_path}") - pointer = json.loads(pointer_path.read_text()) - generated_path = REPO_ROOT / ".generated" / "policies" / f"{policy_id}.json" - manifest = None - if generated_path.exists(): - generated = json.loads(generated_path.read_text()) - resolved = generated.get("resolved", generated) - if not isinstance(resolved, dict): - raise ValueError(f"invalid generated policy resolution: {generated_path}") - resolved_source = resolved.get("source") - if resolved_source is not None and resolved_source != pointer.get("source"): - raise ValueError(f"stale generated policy resolution: {generated_path}") - manifest = resolved.get("manifest") - if not isinstance(manifest, dict): - raise ValueError(f"generated policy has no manifest: {generated_path}") - else: - manifest, _ = _download_manifest(pointer) - descriptor = simulation_descriptor(pointer, manifest) - if descriptor is not None: - return descriptor - # Keep unsupported pointer entries visible and explicit. The caller does - # not download an artifact when no recipe exists. - return { - "id": pointer["id"], - "name": manifest.get("name") or pointer["id"], - "simulation": { - "runner": "external", - "reason": "publisher_only", - "notes": recipe_reason(pointer["source"]["repo"], manifest, pointer["source"]), - }, - "pointer": pointer, - "manifest": manifest, - "artifacts": {"onnx": {"url": policy_artifact_url(pointer), "filename": "policy.onnx"}}, +def not_covered_report(entry_id: str, source: dict, reason: str) -> dict: + report = { + "entry": entry_id, + "execution": "not-covered", + "reason": reason, + "source": source, + "policy": {"url": artifact_url(source), "sha256": source["artifact_sha256"]}, + "media": None, + "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), } + report.update(identity_fields(entry_id, source)) + return report + + +def run(entry_id: str, out_dir: Path, keep_media: bool) -> int: + policy, resolved = load_policy_resolution(entry_id) + spec = execution_spec_from_policy(policy, resolved) + if spec is None: + simulation = resolved.get("simulation") + reason = simulation.get("reason", "No maintainer-owned execution recipe covers this source.") if isinstance(simulation, dict) else "No maintainer-owned execution recipe covers this source." + report_path = write_report(out_dir, entry_id, not_covered_report(entry_id, policy["source"], reason)) + print(f"[{entry_id}] NOT COVERED ({reason}) -> {report_path}") + return 0 - -def load_descriptor(behavior_id: str) -> dict: - if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", behavior_id): - raise ValueError("Invalid behavior id") - path = REPO_ROOT / "registry" / "behaviors" / f"{behavior_id}.json" - if path.exists(): - return json.loads(path.read_text()) - return load_pointer_descriptor(behavior_id) - - -def download_onnx(descriptor: dict, dest_dir: Path) -> Path: - url = descriptor["artifacts"]["onnx"]["url"] - host = re.match(r"https://([^/]+)/", url) - if not host or host.group(1) not in ALLOWED_HOSTS: - raise ValueError(f"artifact host not allowed: {url}") - filename = descriptor["artifacts"]["onnx"].get("filename") or url.rsplit("/", 1)[-1] - dest = dest_dir / filename - if not dest.exists(): - req = urllib.request.Request(url, headers={"User-Agent": "uduck-registry-ci"}) - with open_download(req, timeout=300) as resp, dest.open("wb") as out: - size = 0 - while True: - chunk = resp.read(1 << 20) - if not chunk: - break - size += len(chunk) - if size > MAX_ONNX_BYTES: - raise ValueError("ONNX artifact exceeds 100 MB sanity bound") - out.write(chunk) - return dest - - -def run(behavior_id: str, out_dir: Path, keep_media: bool) -> int: - descriptor = load_descriptor(behavior_id) try: - preflight = require_valid(descriptor) + preflight = require_valid(spec) except SimulationPreflightError as exc: - report = { - "behavior": behavior_id, - "execution": "rejected", - "reason": "simulation_preflight", - "preflight": { - "status": "rejected", - "errors": list(exc.result.errors), - "warnings": list(exc.result.warnings), - }, - "media": None, - "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), - } - report_path = write_report(out_dir, behavior_id, report) - print(f"[{behavior_id}] REJECTED by simulation preflight -> {report_path}", file=sys.stderr) - for error in exc.result.errors: - print(f" ERROR {error}", file=sys.stderr) + report = not_covered_report(entry_id, spec.source, "ExecutionSpec failed preflight.") + report.update({"execution": "rejected", "reason": "execution_preflight", "preflight": {"status": "rejected", "errors": list(exc.result.errors), "warnings": list(exc.result.warnings)}}) + report_path = write_report(out_dir, entry_id, report) + print(f"[{entry_id}] REJECTED by simulation preflight -> {report_path}", file=sys.stderr) return 2 - sim_block = descriptor.get("simulation") - if not sim_block or sim_block.get("runner") == "external": - reason = sim_block.get("reason", "no_registry_recipe") if sim_block else \ - "no_registry_recipe" - report = { - "behavior": behavior_id, - "execution": "unsupported", - "reason": reason, - "notes": sim_block.get("notes") if sim_block else None, - "media": None, - "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), - } - if descriptor.get("pointer"): - report["entry"] = "policy" - report["source"] = descriptor["pointer"]["source"] - report["manifest"] = descriptor["manifest"] - write_report(out_dir, behavior_id, report) - print(f"[{behavior_id}] UNSUPPORTED ({reason})") - return 0 - - contract = descriptor["contract"] - robot_model = descriptor["compatibility"]["robot_model"] - simulation_model = sim_block.get("model", robot_model) - if simulation_model != robot_model: - raise ValueError( - f"simulation model {simulation_model!r} must match compatibility model " - f"{robot_model!r}" - ) - if simulation_model not in ("microduck-standard", "microduck-rollers"): - raise ValueError( - f"microduck-standard-v1 does not support the {simulation_model!r} model" - ) - if sim_block["scene"] != "flat-v1": - raise ValueError(f"unsupported registry scene: {sim_block['scene']}") - spec = scenario_from_descriptor(sim_block) - duration = float(sim_block["duration_s"]) - - with tempfile.TemporaryDirectory(prefix="uduck-sim-") as tmp: - onnx_path = download_onnx(descriptor, Path(tmp)) - onnx_sha = hashlib.sha256(onnx_path.read_bytes()).hexdigest() - expected_onnx_sha = descriptor["artifacts"]["onnx"].get("expected_sha256") - if expected_onnx_sha and onnx_sha != expected_onnx_sha: - raise ValueError( - f"policy artifact hash mismatch: expected {expected_onnx_sha}, got {onnx_sha}" - ) + simulation_model = spec.model + scenario = scenario_from_recipe(spec.recipe) + duration = float(spec.recipe["duration_s"]) + with tempfile.TemporaryDirectory(prefix="uduck-sim-") as temporary: + onnx_path = download_onnx(spec, Path(temporary)) from fetch_assets import fetch asset_variant = "rollers" if simulation_model == "microduck-rollers" else "standard" - mjcf = fetch(variant=asset_variant) - model = load_model(mjcf) - - print(f"[sim] loading runtime for {behavior_id}...", flush=True) - raw_scale = contract.get("action_scale") - if isinstance(raw_scale, bool) or not isinstance(raw_scale, (int, float)): - raise ValueError("runner contract requires an explicit finite action_scale; no silent default is applied") - import math as _math - if not _math.isfinite(float(raw_scale)): - raise ValueError("runner contract requires an explicit finite action_scale") - runtime = DuckRuntime(model, onnx_path, - action_scale=float(raw_scale)) - runtime.prepare_start(sim_block["start"]) - command_fn = make_command_fn(spec, runtime.use_13d) - print(f"[sim] obs_dim={runtime.obs_dim} scenario={spec.name or spec.kind} " - f"duration={duration}s", flush=True) - + model = load_model(fetch(variant=asset_variant)) + print(f"[sim] loading runtime for {entry_id}...", flush=True) + action_scale = spec.contract.get("action_scale") + if isinstance(action_scale, bool) or not isinstance(action_scale, (int, float)) or not math.isfinite(float(action_scale)): + raise ValueError("ExecutionSpec requires an explicit finite action_scale") + 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) renderer = render.LoopRenderer(model) renderer.attach(runtime.data) - def hook(k, sample): - if k % 50 == 0: - print(f"[sim] step {k}/{int(duration * 50)}", flush=True) - renderer.capture(k, sample) + def hook(step, sample): + if step % 50 == 0: + print(f"[sim] step {step}/{int(duration * 50)}", flush=True) + renderer.capture(step, sample) result = runtime.rollout(command_fn, duration, frame_hook=hook) - report = checks.evaluate(result, spec) - - media = None - if keep_media: - # Caption by stable entry ID, not mutable display name, so curation - # edits do not invalidate diagnostic media by design. - caption = f"registry sim {behavior_id} (flat-v1, 50 Hz)" - media = renderer.finalize(out_dir / behavior_id, caption) - - identity = inputs_digest(behavior_id) + 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({ - "inputs_sha256": identity, - "evidence_key": evidence_key(identity, onnx_sha), - "behavior": behavior_id, - "recipe": { - "runner": sim_block["runner"], - "model": simulation_model, - "scene": sim_block["scene"], - "start": sim_block["start"], - "scenario": spec.name or spec.kind, - }, + "entry": entry_id, + "source": spec.source, + "manifest": spec.manifest, + "recipe": spec.recipe, "duration_s": duration, - "policy": { - "url": descriptor["artifacts"]["onnx"]["url"], - "sha256": onnx_sha, - "baked_normalizer": descriptor["artifacts"]["onnx"].get("baked_normalizer"), - }, + "policy": {"url": spec.artifact_url, "sha256": spec.artifact_sha256}, "media": media, - "preflight": { - "status": "passed", - "warnings": list(preflight.warnings), - }, + "preflight": {"status": "passed", "warnings": list(preflight.warnings)}, "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), - "runtime": { - "mjcf": f"{'robot_allcollisions_rollers.xml' if asset_variant == 'rollers' else 'robot_allcollisions.xml'} " - "(pollen-robotics/microduck-simulator, pinned)", - "timestep_s": 0.005, - "decimation": 4, - "control_hz": 50, - "renderer": "mujoco EGL offscreen", - }, + "runtime": {"mjcf": f"{'robot_allcollisions_rollers.xml' if asset_variant == 'rollers' else 'robot_allcollisions.xml'} (pinned registry asset)", "timestep_s": 0.005, "decimation": 4, "control_hz": 50, "renderer": "mujoco EGL offscreen"}, }) - if descriptor.get("pointer"): - report["entry"] = "policy" - report["source"] = descriptor["pointer"]["source"] - report["manifest"] = descriptor["manifest"] - report["recipe"]["provenance"] = sim_block.get("provenance") - report_path = write_report(out_dir, behavior_id, report) - - print(f"[{behavior_id}] RENDERED; CHECKS {report['checks_status'].upper()} -> {report_path}") - for c in report["checks"]: - print(f" {'PASS' if c['passed'] else 'FAIL'} {c['check']}: {c['detail']}") + report.update(identity_fields(entry_id, spec.source)) + report_path = write_report(out_dir, entry_id, report) + print(f"[{entry_id}] RENDERED; CHECKS {report['checks_status'].upper()} -> {report_path}") + for check in report["checks"]: + print(f" {'PASS' if check['passed'] else 'FAIL'} {check['check']}: {check['detail']}") return 0 if report["checks_status"] == "passed" else 1 -def write_report(out_dir: Path, behavior_id: str, report: dict) -> Path: - if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", behavior_id): - raise ValueError("Invalid behavior id") - target = out_dir / behavior_id - target.mkdir(parents=True, exist_ok=True) - report_path = target / "report.json" - report_path.write_text(json.dumps(report, indent=2) + "\n") - return report_path - - def main() -> int: parser = argparse.ArgumentParser() - entry = parser.add_mutually_exclusive_group(required=True) - entry.add_argument("--behavior") - entry.add_argument("--policy") + parser.add_argument("--entry", required=True) parser.add_argument("--out", default=str(REPO_ROOT / "sim-results")) - parser.add_argument("--keep-media", action="store_true", - help="render the loop.mp4 / poster.png (slower)") + parser.add_argument("--keep-media", action="store_true", help="render loop.mp4 / poster.png") args = parser.parse_args() - behavior_id = args.behavior or args.policy try: - return run(behavior_id, Path(args.out), args.keep_media) + return run(args.entry, Path(args.out), args.keep_media) except Exception as exc: # noqa: BLE001 - write_report(Path(args.out), behavior_id, { - "behavior": behavior_id, - "execution": "failed", - "error": str(exc), - "media": None, - "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat(), - }) - print(f"ERROR running sim for {behavior_id}: {exc}", file=sys.stderr) + try: + policy_path = REPO_ROOT / "registry" / "policies" / f"{args.entry}.json" + source = json.loads(policy_path.read_text())["source"] if policy_path.exists() else {"artifact_sha256": "0" * 64} + report = {"entry": args.entry, "execution": "failed", "error": str(exc), "source": source, "policy": {"url": artifact_url(source), "sha256": source["artifact_sha256"]}, "media": None, "generated_at": _dt.datetime.now(_dt.timezone.utc).isoformat()} + if "artifact_sha256" in source: + report.update(identity_fields(args.entry, source)) + write_report(Path(args.out), args.entry, report) + except Exception: + pass + print(f"ERROR running execution for {args.entry}: {exc}", file=sys.stderr) return 2 diff --git a/simulation/tests/fixtures/pre20-evidence-index.json b/simulation/tests/fixtures/pre20-evidence-index.json new file mode 100644 index 0000000..e7f1e8a --- /dev/null +++ b/simulation/tests/fixtures/pre20-evidence-index.json @@ -0,0 +1,73 @@ +{ + "current": { + "alpha-walking": "dafd94b1e963839e4122d4ca9709790e3e026bb6b521882aab39d65d0af9aec9", + "courier": "1022b158c2e977290553fcff13e7607bf0bd4bd9071c9863a08e64f95ccb6c06", + "fall-recovery": "467623bed9f158509ff583b6331d87a4ee49217ef00a75fce3fb73aa17295e10", + "flamingo-cycle": "ba8b47677f7a7ed6bf7e3a4fda1629e6786ab3704b3dfa468bcff06a88b120c4" + }, + "entries": { + "1022b158c2e977290553fcff13e7607bf0bd4bd9071c9863a08e64f95ccb6c06": { + "artifact_sha256": null, + "asset": "7eb9f4ede7043ac02a094ed63e375143877b302903780e6313d71807c5d21c47.tar.gz", + "asset_bytes": 328, + "asset_sha256": "7eb9f4ede7043ac02a094ed63e375143877b302903780e6313d71807c5d21c47", + "behavior": "courier", + "blob_sha256": "7eb9f4ede7043ac02a094ed63e375143877b302903780e6313d71807c5d21c47", + "checks_status": null, + "execution": "unsupported", + "identity_source": "report-fallback", + "inputs_sha256": "6b340e2ba9f7767e20e8b523b41b08d7f273350a461cd082038a9b4ad39282d6", + "key": "1022b158c2e977290553fcff13e7607bf0bd4bd9071c9863a08e64f95ccb6c06", + "observed_at": "2026-09-05T07:18:10.097369+00:00", + "reason": "custom_environment" + }, + "467623bed9f158509ff583b6331d87a4ee49217ef00a75fce3fb73aa17295e10": { + "artifact_sha256": null, + "asset": "9f05187196381b516c853c8b82e90751e4e7fb7dd490a0924607fc67d57aec2e.tar.gz", + "asset_bytes": 333, + "asset_sha256": "9f05187196381b516c853c8b82e90751e4e7fb7dd490a0924607fc67d57aec2e", + "behavior": "fall-recovery", + "blob_sha256": "9f05187196381b516c853c8b82e90751e4e7fb7dd490a0924607fc67d57aec2e", + "checks_status": null, + "execution": "unsupported", + "identity_source": "report-fallback", + "inputs_sha256": "fcf64dc9f45b243a06d0b8ef6a047f0fa95602aa77efe5d1bd6157f778dc2f7b", + "key": "467623bed9f158509ff583b6331d87a4ee49217ef00a75fce3fb73aa17295e10", + "observed_at": "2026-09-05T07:18:10.391282+00:00", + "reason": "custom_environment" + }, + "ba8b47677f7a7ed6bf7e3a4fda1629e6786ab3704b3dfa468bcff06a88b120c4": { + "artifact_sha256": "df77929c39d7695092bdaf810c2075e20a9ba91abd8192b4073d3de593d56904", + "asset": "8ed159955890862e7affe060bb2f83dc39b5d0327ebc12d2c179a96b7e272b63.tar.gz", + "asset_bytes": 374627, + "asset_sha256": "8ed159955890862e7affe060bb2f83dc39b5d0327ebc12d2c179a96b7e272b63", + "behavior": "flamingo-cycle", + "blob_sha256": "8ed159955890862e7affe060bb2f83dc39b5d0327ebc12d2c179a96b7e272b63", + "checks_status": "passed", + "execution": "rendered", + "identity_source": "runner", + "inputs_sha256": "66164992b545aeb641feae7336987f36ec33358720a95ea60eeba5445def3820", + "key": "ba8b47677f7a7ed6bf7e3a4fda1629e6786ab3704b3dfa468bcff06a88b120c4", + "observed_at": "2026-09-05T07:21:41.808253+00:00", + "reason": null + }, + "dafd94b1e963839e4122d4ca9709790e3e026bb6b521882aab39d65d0af9aec9": { + "artifact_sha256": "e36332d383997d51401897734cd3e79cf5038406feddb18b4d57ecfb141daa6c", + "asset": "cd9d9bce111f2a4ab3e87c83135a6ffd3e8cafe0107fd53a79d2a047302c714e.tar.gz", + "asset_bytes": 445040, + "asset_sha256": "cd9d9bce111f2a4ab3e87c83135a6ffd3e8cafe0107fd53a79d2a047302c714e", + "behavior": "alpha-walking", + "blob_sha256": "cd9d9bce111f2a4ab3e87c83135a6ffd3e8cafe0107fd53a79d2a047302c714e", + "checks_status": "passed", + "execution": "rendered", + "identity_source": "runner", + "inputs_sha256": "2667076f85911ea9f3f3e81785d8d9bf21de55e049ada57196d982b39fabea79", + "key": "dafd94b1e963839e4122d4ca9709790e3e026bb6b521882aab39d65d0af9aec9", + "observed_at": "2026-09-05T07:17:44.573462+00:00", + "reason": null + } + }, + "format": "uduck-evidence-v2", + "updated_at": "2026-09-05T08:40:55.560235+00:00", + "version": 2 +} diff --git a/simulation/tests/fixtures/unsupported-manifest.json b/simulation/tests/fixtures/unsupported-manifest.json index 8179300..20bade3 100644 --- a/simulation/tests/fixtures/unsupported-manifest.json +++ b/simulation/tests/fixtures/unsupported-manifest.json @@ -11,7 +11,7 @@ "description": "Spins on command with a daemon-driven twist the registry cannot derive.", "command": { "encoding": "constant", - "twist": ["spin rate follows a custom head-tracked target"], + "twist": ["spin rate follows a publisher-specific head-tracked target"], "head": "tracks a moving target", "body": "leans into the spin", "idle": [0, 0, 0] diff --git a/simulation/tests/test_evidence_identity.py b/simulation/tests/test_evidence_identity.py new file mode 100644 index 0000000..ba0aaa8 --- /dev/null +++ b/simulation/tests/test_evidence_identity.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from evidence import EVIDENCE_VERSION, IDENTITY_VERSION, execution_inputs, inputs_digest # noqa: E402 + + +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_is_entry_scoped(self) -> None: + self.assertNotEqual(inputs_digest("alpha-walking"), inputs_digest("jump")) + + def test_identity_contains_execution_inputs_but_not_curation(self) -> None: + inputs = execution_inputs("alpha-walking") + self.assertIn("source", inputs) + self.assertIn("manifest", inputs) + self.assertIn("simulation", inputs) + self.assertNotIn("curation", inputs) + + +if __name__ == "__main__": + unittest.main() diff --git a/simulation/tests/test_evidence_publication.py b/simulation/tests/test_evidence_publication.py deleted file mode 100644 index 20e2e51..0000000 --- a/simulation/tests/test_evidence_publication.py +++ /dev/null @@ -1,32 +0,0 @@ -import json -import tempfile -import unittest -from pathlib import Path -from unittest.mock import patch -import publish_result -from evidence import evidence_key - -class PublicationTests(unittest.TestCase): - def test_stale_inputs_rejected_and_failed_checks_preserved(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - source = root / 'input'; source.mkdir() - descriptor = root / 'registry/behaviors/test.json' - descriptor.parent.mkdir(parents=True); descriptor.write_text('{}') - for name in ('loop.mp4', 'poster.png'): (source / name).write_bytes(b'media') - report = {'behavior': 'test', 'execution': 'rendered', 'checks_status': 'failed', - 'inputs_sha256': 'old', 'policy': {'sha256': 'a' * 64}, 'evidence_key': 'wrong'} - (source / 'report.json').write_text(json.dumps(report)) - with patch.object(publish_result, 'REPO_ROOT', root), patch.object(publish_result, 'inputs_digest', return_value='current'): - with self.assertRaisesRegex(ValueError, 'does not match'): publish_result.publish(source) - report['inputs_sha256'] = 'current' - report['evidence_key'] = evidence_key('current', 'a' * 64) - (source / 'report.json').write_text(json.dumps(report)) - result = publish_result.publish(source) - self.assertEqual(json.loads((result / 'report.json').read_text())['checks_status'], 'failed') - def test_non_rendered_output_is_never_published(self): - with tempfile.TemporaryDirectory() as directory: - source = Path(directory) - (source / 'report.json').write_text(json.dumps({'behavior': 'test', 'execution': 'unsupported'})) - for name in ('loop.mp4', 'poster.png'): (source / name).write_bytes(b'media') - with self.assertRaisesRegex(ValueError, 'completed diagnostic'): publish_result.publish(source) diff --git a/simulation/tests/test_evidence_store.py b/simulation/tests/test_evidence_store.py index b2cb588..ba422d6 100644 --- a/simulation/tests/test_evidence_store.py +++ b/simulation/tests/test_evidence_store.py @@ -1,64 +1,135 @@ from __future__ import annotations +import hashlib +import io import json import sys +import tarfile import tempfile import unittest from pathlib import Path from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) - import evidence_store +def evidence_key(inputs: str, artifact: str) -> str: + return hashlib.sha256( + b"uduck-evidence-v3\0" + inputs.encode() + b"\0" + artifact.encode() + ).hexdigest() + + +def report(entry: str = "test", inputs: str = "a" * 64, artifact: str = "b" * 64) -> dict: + return { + "entry": entry, + "execution": "not-covered", + "reason": "No maintainer-owned execution recipe covers this source.", + "inputs_sha256": inputs, + "policy": {"url": "https://huggingface.co/o/r/resolve/revision/policy.onnx", "sha256": artifact}, + "evidence_key": evidence_key(inputs, artifact), + "generated_at": "2026-01-01T00:00:00Z", + "media": None, + } + + class EvidenceStoreTests(unittest.TestCase): - def _unsupported_report(self, behavior="test", inputs="a" * 64, artifact="b" * 64): - return { - "behavior": behavior, - "execution": "unsupported", - "reason": "no_registry_recipe", - "inputs_sha256": inputs, - "policy": {"sha256": artifact}, - "evidence_key": self._key(inputs, artifact), - "generated_at": "2026-01-01T00:00:00Z", - } + def test_actual_pre20_index_transitions_to_v3_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") + self.assertIn("behavior", old_index["entries"][old_index["current"]["alpha-walking"]]) + self.assertEqual(old_index["entries"][old_index["current"]["courier"]]["execution"], "unsupported") + + def fragment_entry(entry_id: str, inputs: str, artifact: str) -> tuple[str, dict]: + key = evidence_key(inputs, artifact) + blob = (entry_id[0] * 64) + return key, { + "entry": entry_id, + "key": key, + "asset": f"{blob}.tar.gz", + "asset_sha256": blob, + "blob_sha256": blob, + "inputs_sha256": inputs, + "artifact_sha256": artifact, + "execution": "not-covered", + "identity_source": "runner", + "checks_status": None, + "reason": "No maintainer-owned execution recipe covers this source.", + } + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + old_path = root / "old-index.json" + old_path.write_text(json.dumps(old_index)) + alpha_policy = root / "alpha-walking.json" + courier_policy = root / "courier.json" + alpha_policy.write_text(json.dumps({"id": "alpha-walking", "source": {"artifact_sha256": "a" * 64}})) + courier_policy.write_text(json.dumps({"id": "courier", "source": {"artifact_sha256": "c" * 64}})) + + alpha_key, alpha_entry = fragment_entry("alpha-walking", "1" * 64, "a" * 64) + courier_key, courier_entry = fragment_entry("courier", "2" * 64, "c" * 64) + fragment = root / "fragment.json" + fragment.write_text(json.dumps({ + "version": 2, + "format": "uduck-evidence-v2", + "entries": {alpha_key: alpha_entry, courier_key: courier_entry}, + "current": {"alpha-walking": alpha_key, "courier": courier_key}, + })) + + with patch.object(evidence_store, "ROOT", root), \ + patch.object(evidence_store, "_authored_policies", return_value={ + "alpha-walking": alpha_policy, + "courier": courier_policy, + }), \ + patch.object(evidence_store, "_policy_inputs", side_effect={ + "alpha-walking": "1" * 64, + "courier": "2" * 64, + }.get): + planned = evidence_store.plan(old_path, root / "plan.json") + self.assertEqual({item["status"] for item in planned["items"]}, {"run"}) + + merged = evidence_store.merge(old_path, fragment, root / "merged.json") + self.assertEqual(merged["current"]["alpha-walking"], alpha_key) + self.assertEqual(merged["current"]["courier"], courier_key) + self.assertNotIn("fall-recovery", merged["current"]) + self.assertIn(old_index["current"]["fall-recovery"], merged["entries"]) + self.assertIn(old_index["current"]["alpha-walking"], merged["entries"]) + self.assertIn(old_index["current"]["courier"], merged["entries"]) - @staticmethod - def _key(inputs, artifact): - import hashlib - return hashlib.sha256( - b"uduck-evidence-v2\0" + inputs.encode() + b"\0" + artifact.encode() - ).hexdigest() + with self.assertRaisesRegex(ValueError, "stale evidence identity"): + evidence_store.hydrate( + old_path, + "https://github.com/ob1-s/uduck-registry/releases/download/registry-evidence", + root / "hydrated", + None, + ["alpha-walking"], + ) - def test_package_uses_blob_identity_and_hydrates_local_unsupported(self) -> None: - # Mandatory: new unsupported pointer on PR -> local report -> temporary - # merged index -> hydrate without a Release asset. + def test_package_uses_blob_identity_and_hydrates_local_not_covered(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) results = root / "sim-results" / "test" results.mkdir(parents=True) - (results / "report.json").write_text(json.dumps(self._unsupported_report())) - descriptor = root / "test.json" - descriptor.write_text(json.dumps({"id": "test"})) + (results / "report.json").write_text(json.dumps(report())) + policy = root / "registry" / "policies" / "test.json" + policy.parent.mkdir(parents=True) + policy.write_text(json.dumps({"id": "test", "source": {"artifact_sha256": "b" * 64}})) - with patch.object(evidence_store, "_authored_descriptors", return_value={"test": descriptor}), \ - patch.object(evidence_store, "_descriptor_identity", return_value="a" * 64), \ - patch.object(evidence_store, "_explicit_artifact_sha", return_value="b" * 64): + with patch.object(evidence_store, "ROOT", root), \ + patch.object(evidence_store, "_authored_policies", return_value={"test": policy}), \ + patch.object(evidence_store, "_policy_inputs", return_value="a" * 64): assets = root / "assets" fragment = root / "fragment.json" evidence_store.package(results.parent, assets, fragment) value = json.loads(fragment.read_text()) key = value["current"]["test"] entry = value["entries"][key] - # Blob identity, not semantic key, names the asset. self.assertEqual(entry["asset"], f"{entry['blob_sha256']}.tar.gz") self.assertEqual(entry["asset_sha256"], entry["blob_sha256"]) self.assertEqual(entry["asset_sha256"], evidence_store.sha256_bytes((assets / entry["asset"]).read_bytes())) - # Wall-clock lives in index metadata, not archived bytes. - import tarfile, io - with tarfile.open(assets / entry["asset"], "r:gz") as tar: - archived = json.loads(tar.extractfile("test/report.json").read().decode()) + with tarfile.open(assets / entry["asset"], "r:gz") as archive: + archived = json.loads(archive.extractfile("test/report.json").read().decode()) self.assertNotIn("generated_at", archived) index = root / "index.json" @@ -75,209 +146,114 @@ def test_package_uses_blob_identity_and_hydrates_local_unsupported(self) -> None self.assertTrue((staged / "report.json").is_file()) self.assertEqual(json.loads((staged / "report.json").read_text())["evidence_key"], key) - def test_same_key_same_blob_is_idempotent_conflict_is_detected(self) -> None: + def test_same_key_same_blob_is_idempotent_and_conflicts_are_detected(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) - results = root / "sim-results" / "test" + results = root / "results" / "test" results.mkdir(parents=True) - (results / "report.json").write_text(json.dumps(self._unsupported_report())) - descriptor = root / "test.json" - descriptor.write_text(json.dumps({"id": "test"})) - with patch.object(evidence_store, "_authored_descriptors", return_value={"test": descriptor}), \ - patch.object(evidence_store, "_descriptor_identity", return_value="a" * 64), \ - patch.object(evidence_store, "_explicit_artifact_sha", return_value="b" * 64): - assets = root / "assets" - evidence_store.package(results.parent, assets, root / "f1.json") - # Rerun with different wall clock but same inputs: idempotent. - rep = self._unsupported_report() - rep["generated_at"] = "2026-06-01T00:00:00Z" - (results / "report.json").write_text(json.dumps(rep)) - evidence_store.package(results.parent, assets, root / "f2.json") - v1 = json.loads((root / "f1.json").read_text()) - v2 = json.loads((root / "f2.json").read_text()) - self.assertEqual(v1["current"], v2["current"]) - self.assertEqual( - v1["entries"][v1["current"]["test"]]["blob_sha256"], - v2["entries"][v2["current"]["test"]]["blob_sha256"], - ) + (results / "report.json").write_text(json.dumps(report())) + assets = root / "assets" + first = root / "first.json" + second = root / "second.json" + evidence_store.package(results.parent, assets, first) + original = json.loads((results / "report.json").read_text()) + original["generated_at"] = "2026-06-01T00:00:00Z" + (results / "report.json").write_text(json.dumps(original)) + evidence_store.package(results.parent, assets, second) + one = json.loads(first.read_text()) + two = json.loads(second.read_text()) + self.assertEqual(one["current"], two["current"]) + self.assertEqual( + one["entries"][one["current"]["test"]]["blob_sha256"], + two["entries"][two["current"]["test"]]["blob_sha256"], + ) - def test_merge_prunes_deleted_ids_and_keeps_history(self) -> None: + def test_merge_prunes_deleted_entries_but_keeps_historical_blobs(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) + old_key, keep_key = "a" * 64, "b" * 64 base = { - "version": 2, "format": "uduck-evidence-v2", + "version": 2, + "format": "uduck-evidence-v2", "entries": { - "a" * 64: {"behavior": "old", "key": "a" * 64, "asset": "x.tar.gz", - "asset_sha256": "y" * 64, "blob_sha256": "y" * 64, - "inputs_sha256": "a" * 64}, - "b" * 64: {"behavior": "keep", "key": "b" * 64, "asset": "z.tar.gz", - "asset_sha256": "w" * 64, "blob_sha256": "w" * 64, - "inputs_sha256": "b" * 64}, + old_key: {"entry": "old", "key": old_key, "asset": "x.tar.gz", "asset_sha256": "c" * 64, "blob_sha256": "c" * 64, "inputs_sha256": "d" * 64}, + keep_key: {"entry": "keep", "key": keep_key, "asset": "y.tar.gz", "asset_sha256": "e" * 64, "blob_sha256": "e" * 64, "inputs_sha256": "f" * 64}, }, - "current": {"old": "a" * 64, "keep": "b" * 64}, + "current": {"old": old_key, "keep": keep_key}, } + fragment = {"version": 2, "format": "uduck-evidence-v2", "entries": {}, "current": {}} (root / "base.json").write_text(json.dumps(base)) - (root / "frag.json").write_text(json.dumps( - {"version": 2, "format": "uduck-evidence-v2", "entries": {}, "current": {}})) - with patch.object(evidence_store, "_authored_descriptors", return_value={"keep": Path("x")}): - merged = evidence_store.merge(root / "base.json", root / "frag.json", root / "out.json") + (root / "fragment.json").write_text(json.dumps(fragment)) + with patch.object(evidence_store, "_authored_policies", return_value={"keep": Path("keep.json")}): + merged = evidence_store.merge(root / "base.json", root / "fragment.json", root / "out.json") self.assertNotIn("old", merged["current"]) self.assertIn("keep", merged["current"]) - # Historical blob retained for audit. - self.assertIn("a" * 64, merged["entries"]) + self.assertIn(old_key, merged["entries"]) - - def test_plan_time_base_preserves_cached_entries_when_only_changed_entry_reruns(self) -> None: - # The exact index used to decide cached-vs-run must also be the publish - # merge base. If only "changed" reruns, the already-cached entry must - # survive even though it has no local result in this workflow run. + def test_plan_reuses_only_the_exact_entry_identity_and_artifact(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) - cached_key = "a" * 64 - changed_key = "b" * 64 - base = { - "version": 2, - "format": "uduck-evidence-v2", - "entries": { - cached_key: { - "behavior": "cached", - "key": cached_key, - "asset": f"{'c' * 64}.tar.gz", - "asset_sha256": "c" * 64, - "blob_sha256": "c" * 64, - "inputs_sha256": "d" * 64, - }, - }, - "current": {"cached": cached_key}, - } - fragment = { + policy = root / "registry" / "policies" / "test.json" + policy.parent.mkdir(parents=True) + policy.write_text(json.dumps({"id": "test", "source": {"artifact_sha256": "b" * 64}})) + inputs = "a" * 64 + key = evidence_key(inputs, "b" * 64) + index = { "version": 2, "format": "uduck-evidence-v2", - "entries": { - changed_key: { - "behavior": "changed", - "key": changed_key, - "asset": f"{'e' * 64}.tar.gz", - "asset_sha256": "e" * 64, - "blob_sha256": "e" * 64, - "inputs_sha256": "f" * 64, - }, - }, - "current": {"changed": changed_key}, + "entries": {key: {"entry": "test", "inputs_sha256": inputs, "artifact_sha256": "b" * 64}}, + "current": {"test": key}, } - (root / "plan-time-index.json").write_text(json.dumps(base)) - (root / "local-fragment.json").write_text(json.dumps(fragment)) - with patch.object( - evidence_store, - "_authored_descriptors", - return_value={"cached": Path("cached.json"), "changed": Path("changed.json")}, - ): - merged = evidence_store.merge( - root / "plan-time-index.json", - root / "local-fragment.json", - root / "published-index.json", - ) - self.assertEqual(merged["current"], {"cached": cached_key, "changed": changed_key}) - self.assertIn(cached_key, merged["entries"]) - self.assertIn(changed_key, merged["entries"]) - - def test_revision_validation_distinguishes_git_sha_from_sha256(self) -> None: - self.assertTrue(evidence_store.valid_git_revision("a" * 40)) - self.assertFalse(evidence_store.valid_git_revision("a" * 64)) - self.assertTrue(evidence_store.valid_sha256("b" * 64)) - self.assertFalse(evidence_store.valid_sha256("b" * 40)) + (root / "index.json").write_text(json.dumps(index)) + with patch.object(evidence_store, "ROOT", root), \ + patch.object(evidence_store, "_authored_policies", return_value={"test": policy}), \ + patch.object(evidence_store, "_policy_inputs", return_value=inputs): + planned = evidence_store.plan(root / "index.json", root / "plan.json") + self.assertEqual(planned["items"], [{ + "entry": "test", + "policy": "registry/policies/test.json", + "inputs_sha256": inputs, + "artifact_sha256": "b" * 64, + "status": "cached", + "evidence_key": key, + }]) def test_archive_is_deterministic_across_wall_clock(self) -> None: - files = [ - ("test/report.json", b'{"behavior":"test","execution":"unsupported"}'), - ("test/loop.mp4", b"\x00" * 4096), - ] - # Force different wall-clock times: the old `w:gz` code path stamped - # time.time() into the gzip header, so identical inputs produced - # different blob SHAs ~1s apart. The header-mtime assertion below - # fails that code deterministically, without relying on a sleep. + files = [("test/report.json", b'{"entry":"test","execution":"not-covered"}'), ("test/loop.mp4", b"\x00" * 4096)] with patch("time.time", return_value=1700000000.0): first = evidence_store._archive_bytes(files) with patch("time.time", return_value=1700000005.0): second = evidence_store._archive_bytes(files) self.assertEqual(first, second) self.assertEqual(first[4:8], b"\x00\x00\x00\x00") - self.assertEqual( - evidence_store.sha256_bytes(first), evidence_store.sha256_bytes(second) - ) - - def test_first_publish_round_trips_into_cached_second_plan(self) -> None: - # Guards the durable-cache contract CI depends on: the merged index - # produced by `merge` must be plannable as-is, so a second run with - # unchanged inputs is fully cached instead of starting over. - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - results = root / "sim-results" / "test" - results.mkdir(parents=True) - (results / "report.json").write_text(json.dumps(self._unsupported_report())) - descriptor = root / "test.json" - descriptor.write_text(json.dumps({ - "id": "test", - "source": { - "repo": "o/r", - "revision": "a" * 40, - "artifact_sha256": "b" * 64, - "manifest_sha256": "c" * 64, - }, - })) - with patch.object(evidence_store, "ROOT", root), \ - patch.object(evidence_store, "_authored_descriptors", return_value={"test": descriptor}), \ - patch.object(evidence_store, "_descriptor_identity", return_value="a" * 64), \ - patch.object(evidence_store, "_explicit_artifact_sha", return_value="b" * 64): - assets = root / "assets" - fragment = root / "fragment.json" - evidence_store.package(results.parent, assets, fragment) - # The release asset name is RELEASE_INDEX_NAME: this is what - # `fetch-index` downloads on the next run. - release_index = root / evidence_store.RELEASE_INDEX_NAME - evidence_store.merge(root / "missing-index.json", fragment, release_index) - plan_out = root / "plan.json" - plan = evidence_store.plan(release_index, plan_out) - item = next(i for i in plan["items"] if i["behavior"] == "test") - self.assertEqual(item["status"], "cached") - - def test_workflow_carries_plan_time_index_through_publish_and_validate(self) -> None: - # The plan-time index is a workflow artifact and is the single merge - # base for publish and validate. Neither downstream job re-downloads - # the mutable Release index after the cache decision was made. - repo_root = Path(__file__).resolve().parents[2] - workflow = (repo_root / ".github/workflows/ci.yml").read_text() - evidence = workflow.split("\n evidence:", 1)[1].split("\n publish-evidence:", 1)[0] - publish = workflow.split("\n publish-evidence:", 1)[1].split("\n validate:", 1)[0] - validate = workflow.split("\n validate:", 1)[1] - - self.assertIn("evidence-index.json", evidence) - self.assertIn("--existing ci-evidence/evidence-index.json", publish) - self.assertNotIn("gh release download", publish) - self.assertNotIn("release-index/index.json", publish) - self.assertIn("--out index.json", publish) - self.assertIn('index.json --repo "$GH_REPO" --clobber', publish) - self.assertIn("--existing ci-evidence/evidence-index.json", validate) - self.assertNotIn("fetch-index", validate) - self.assertNotIn("durable-evidence-index.json", validate) + def test_revision_validation_distinguishes_git_sha_from_sha256(self) -> None: + self.assertTrue(evidence_store.valid_git_revision("a" * 40)) + self.assertFalse(evidence_store.valid_git_revision("a" * 64)) + self.assertTrue(evidence_store.valid_sha256("b" * 64)) + self.assertFalse(evidence_store.valid_sha256("b" * 40)) - def test_archive_rejects_path_traversal_and_links(self) -> None: - archive = evidence_store._archive_bytes([("test/report.json", b'{"behavior":"test","execution":"unsupported"}')]) + def test_archive_rejects_path_traversal(self) -> None: + archive = evidence_store._archive_bytes([("test/report.json", json.dumps(report()).encode())]) files = evidence_store._extract_archive(archive, "test", Path("unused")) self.assertIn("report.json", files) - import io - import tarfile - stream = io.BytesIO() - with tarfile.open(fileobj=stream, mode="w:gz") as tar: + with tarfile.open(fileobj=stream, mode="w:gz") as archive_file: info = tarfile.TarInfo("../report.json") info.size = 2 - tar.addfile(info, io.BytesIO(b"{}")) + archive_file.addfile(info, io.BytesIO(b"{}")) with self.assertRaisesRegex(ValueError, "unsafe"): evidence_store._extract_archive(stream.getvalue(), "test", Path("unused")) + def test_workflow_uses_entry_arguments_and_one_plan_index(self) -> None: + workflow = (Path(__file__).resolve().parents[2] / ".github/workflows/ci.yml").read_text() + self.assertIn("--entry", workflow) + self.assertIn("--existing ci-evidence/evidence-index.json", workflow) + self.assertNotIn("gh release download", workflow) + self.assertNotIn("release-index/index.json", workflow) + self.assertNotIn("--behavior", workflow) + if __name__ == "__main__": unittest.main() diff --git a/simulation/tests/test_execution_recipes.py b/simulation/tests/test_execution_recipes.py new file mode 100644 index 0000000..e0171be --- /dev/null +++ b/simulation/tests/test_execution_recipes.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import copy +import json +import unittest +from pathlib import Path + +from execution import execution_spec_from_policy +from execution_recipes import recipe_for_policy, recipe_reason +from microduck_sim.preflight import preflight_execution +from microduck_sim.scenarios import make_command_fn, scenario_from_recipe + +FIXTURE = Path(__file__).parent / "fixtures" / "flamingo-manifest.json" +FLAMINGO_REPO = "RemiFabre/microduck-flamingo-cycle" +FLAMINGO_SOURCE = { + "provider": "huggingface-model", + "repo": FLAMINGO_REPO, + "revision": "6646428394c6997106d2dc07c1588f20f6fea026", + "artifact_path": "policy.onnx", + "manifest_sha256": "ac9b9ae16b4f21733990710275bd934c97558c6028e060bd2b34ec1f5341d302", + "artifact_sha256": "df77929c39d7695092bdaf810c2075e20a9ba91abd8192b4073d3de593d56904", + "manifest_path": "manifest.json", +} + + +class ExecutionRecipeTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.manifest = json.loads(FIXTURE.read_text()) + + def test_flamingo_recipe_uses_documented_command(self) -> None: + recipe = recipe_for_policy(FLAMINGO_REPO, self.manifest, FLAMINGO_SOURCE) + self.assertIsNotNone(recipe) + assert recipe is not None + self.assertEqual(recipe["scenario"], "command_schedule") + self.assertEqual(recipe["duration_s"], 5.0) + self.assertEqual(recipe["segments"], [{"duration_s": 5.0, "command": [1.0, 1.0, 0.0]}]) + self.assertNotIn("unwind_s", recipe) + + def test_flamingo_recipe_is_bound_to_source_and_manifest(self) -> None: + altered = copy.deepcopy(self.manifest) + altered["name"] = "a-different-policy" + self.assertIsNone(recipe_for_policy(FLAMINGO_REPO, altered, FLAMINGO_SOURCE)) + self.assertIn("manifest name", recipe_reason(FLAMINGO_REPO, altered)) + altered_source = dict(FLAMINGO_SOURCE) + altered_source["artifact_sha256"] = "0" * 64 + self.assertIsNone(recipe_for_policy(FLAMINGO_REPO, self.manifest, altered_source)) + + def test_execution_spec_is_concrete_and_preflightable(self) -> None: + recipe = recipe_for_policy(FLAMINGO_REPO, self.manifest, FLAMINGO_SOURCE) + assert recipe is not None + policy = {"id": "flamingo-cycle", "source": FLAMINGO_SOURCE} + resolved = {"manifest": self.manifest, "simulation": {"status": "covered", "recipe": recipe}} + spec = execution_spec_from_policy(policy, resolved) + self.assertIsNotNone(spec) + assert spec is not None + result = preflight_execution(spec) + self.assertTrue(result.valid, result.errors) + scenario = scenario_from_recipe(spec.recipe) + command_fn = make_command_fn(scenario, use_13d=True) + self.assertEqual(command_fn(0.0)[:3].tolist(), [1.0, 1.0, 0.0]) + + def test_generic_zero_recipe_requires_explicit_contract(self) -> None: + manifest = { + "schema_version": 2, + "kind": "episodic", + "duration_s": 4.0, + "command": {"encoding": "constant"}, + "obs_len": 61, + "action_len": 14, + "model_api": 1, + "action_scale": 1.0, + "entry_pose": "standing", + "robot": {"model": "microduck", "hw_rev": 1, "servos": "xl330", "control_hz": 50}, + } + recipe = recipe_for_policy("someone/microduck-bow", manifest) + self.assertIsNotNone(recipe) + missing_scale = copy.deepcopy(manifest) + del missing_scale["action_scale"] + self.assertIsNone(recipe_for_policy("someone/microduck-bow", missing_scale)) + command_prose = copy.deepcopy(manifest) + command_prose["command"]["twist"] = "forward speed" + self.assertIsNone(recipe_for_policy("someone/microduck-bow", command_prose)) + + def test_uncovered_policy_has_no_execution_spec(self) -> None: + policy = {"id": "mystery", "source": {**FLAMINGO_SOURCE, "repo": "someone/mystery"}} + resolved = {"manifest": None, "simulation": {"status": "not-covered", "reason": "No recipe."}} + self.assertIsNone(execution_spec_from_policy(policy, resolved)) + + +if __name__ == "__main__": + unittest.main() diff --git a/simulation/tests/test_pointer_recipes.py b/simulation/tests/test_pointer_recipes.py deleted file mode 100644 index 046c88b..0000000 --- a/simulation/tests/test_pointer_recipes.py +++ /dev/null @@ -1,256 +0,0 @@ -from __future__ import annotations - -import copy -from hashlib import sha256 -import json -import unittest -from pathlib import Path - -from microduck_sim.preflight import preflight_descriptor -from microduck_sim.scenarios import make_command_fn, scenario_from_descriptor -from pointer_recipes import recipe_for_policy, recipe_reason -from pointer_runner import artifact_matches, policy_artifact_url, simulation_descriptor - - -FIXTURE = Path(__file__).parent / "fixtures" / "flamingo-manifest.json" -FLAMINGO_REPO = "RemiFabre/microduck-flamingo-cycle" -FLAMINGO_SOURCE = { - "repo": FLAMINGO_REPO, - "revision": "6646428394c6997106d2dc07c1588f20f6fea026", - "manifest_sha256": "ac9b9ae16b4f21733990710275bd934c97558c6028e060bd2b34ec1f5341d302", - "artifact_sha256": "df77929c39d7695092bdaf810c2075e20a9ba91abd8192b4073d3de593d56904", -} - - -class PointerRecipeTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.manifest = json.loads(FIXTURE.read_text()) - - def test_flamingo_recipe_uses_documented_command_and_no_invented_unwind(self) -> None: - recipe = recipe_for_policy( - FLAMINGO_REPO, - self.manifest, - FLAMINGO_SOURCE, - ) - - self.assertIsNotNone(recipe) - assert recipe is not None - self.assertEqual(recipe["scenario"], "command_schedule") - self.assertEqual(recipe["duration_s"], 5.0) - self.assertEqual(recipe["segments"], [{"duration_s": 5.0, "command": [1.0, 1.0, 0.0]}]) - self.assertEqual(recipe["provenance"]["command"], [1.0, 1.0, 0.0]) - self.assertEqual(recipe["provenance"]["manifest_idle_command"], [0.0, 0.0, 0.0]) - self.assertNotIn("unwind_s", self.manifest) - self.assertNotIn("unwind_s", recipe) - self.assertIn("no unwind", recipe["provenance"]["scope"]) - - def test_flamingo_recipe_is_bound_to_the_manifest_name(self) -> None: - altered = copy.deepcopy(self.manifest) - altered["name"] = "a-different-policy" - self.assertIsNone(recipe_for_policy(FLAMINGO_REPO, altered, FLAMINGO_SOURCE)) - self.assertIn("manifest name", recipe_reason(FLAMINGO_REPO, altered)) - - def test_flamingo_recipe_is_bound_to_the_reviewed_artifact_identity(self) -> None: - altered_source = dict(FLAMINGO_SOURCE) - altered_source["artifact_sha256"] = "0" * 64 - self.assertIsNone(recipe_for_policy(FLAMINGO_REPO, self.manifest, altered_source)) - self.assertIn("pinned revision", recipe_reason(FLAMINGO_REPO, self.manifest, altered_source)) - - def test_flamingo_command_schedule_is_explicit_and_preflightable(self) -> None: - recipe = recipe_for_policy( - FLAMINGO_REPO, - self.manifest, - FLAMINGO_SOURCE, - ) - assert recipe is not None - descriptor = { - "contract": { - "observation_dim": 61, - "action_dim": 14, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 position-control diagnostic law", - }, - "compatibility": {"robot_model": "microduck-standard"}, - "simulation": recipe, - } - result = preflight_descriptor(descriptor) - self.assertTrue(result.valid, result.errors) - spec = scenario_from_descriptor(recipe) - command_fn = make_command_fn(spec, use_13d=True) - self.assertEqual(command_fn(0.0)[:3].tolist(), [1.0, 1.0, 0.0]) - self.assertEqual(command_fn(4.999)[:3].tolist(), [1.0, 1.0, 0.0]) - self.assertEqual(command_fn(5.0)[:3].tolist(), [1.0, 1.0, 0.0]) - - def test_constant_episodic_zero_default_is_explicitly_provenanced(self) -> None: - manifest = { - "schema_version": 2, - "kind": "episodic", - "duration_s": 4.0, - "command": {"encoding": "constant"}, - "obs_len": 61, - "action_len": 14, - "model_api": 1, - "action_scale": 1.0, - "entry_pose": "standing", - "robot": {"model": "microduck", "hw_rev": 1, "servos": "xl330", "control_hz": 50}, - } - recipe = recipe_for_policy("someone/microduck-bow", manifest) - self.assertIsNotNone(recipe) - assert recipe is not None - self.assertEqual(recipe["scenario"], "oneshot_zero") - self.assertEqual(recipe["provenance"]["command"], [0.0, 0.0, 0.0]) - self.assertIn("upstream_pin", recipe["provenance"]) - - def test_generic_zero_requires_complete_runner_contract(self) -> None: - base = { - "schema_version": 2, - "kind": "episodic", - "duration_s": 4.0, - "command": {"encoding": "constant"}, - "obs_len": 61, - "action_len": 14, - "model_api": 1, - "action_scale": 1.0, - "entry_pose": "standing", - "robot": {"model": "microduck", "hw_rev": 1, "servos": "xl330", "control_hz": 50}, - } - import copy as _copy - # Missing action_scale stays not-covered, never defaults to 1.0. - missing_scale = _copy.deepcopy(base) - del missing_scale["action_scale"] - self.assertIsNone(recipe_for_policy("someone/microduck-bow", missing_scale)) - # Custom twist/head/body prose is never interpreted. - for key in ("twist", "head", "body"): - m = _copy.deepcopy(base) - m["command"] = {"encoding": "constant", key: "unused (zeros)"} - self.assertIsNone(recipe_for_policy("someone/microduck-move", m), key) - # Non-standing entry pose stays not-covered. - m = _copy.deepcopy(base) - m["entry_pose"] = "crouching" - self.assertIsNone(recipe_for_policy("someone/microduck-bow", m)) - # Wrong widths stay not-covered. - m = _copy.deepcopy(base) - m["obs_len"] = 60 - self.assertIsNone(recipe_for_policy("someone/microduck-bow", m)) - # Missing duration stays not-covered. - m = _copy.deepcopy(base) - del m["duration_s"] - self.assertIsNone(recipe_for_policy("someone/microduck-bow", m)) - # Missing model_api stays not-covered: 61 inputs and 14 outputs do - # not prove the semantics of those channels. - m = _copy.deepcopy(base) - del m["model_api"] - self.assertIsNone(recipe_for_policy("someone/microduck-bow", m)) - - def test_nonzero_command_prose_is_not_executed_as_a_guess(self) -> None: - manifest = { - "schema_version": 2, - "kind": "episodic", - "duration_s": 4.0, - "command": {"encoding": "constant", "twist": "forward speed"}, - "obs_len": 61, - "action_len": 14, - "model_api": 1, - "action_scale": 1.0, - "entry_pose": "standing", - "robot": {"model": "microduck", "hw_rev": 1, "servos": "xl330", "control_hz": 50}, - } - self.assertIsNone(recipe_for_policy("someone/microduck-move", manifest)) - - def test_command_schedule_rejects_partial_or_out_of_range_values(self) -> None: - recipe = recipe_for_policy( - FLAMINGO_REPO, - self.manifest, - FLAMINGO_SOURCE, - ) - assert recipe is not None - partial = copy.deepcopy(recipe) - partial["duration_s"] = 4.0 - partial_result = preflight_descriptor({ - "contract": {"observation_dim": 61, "action_dim": 14, - "control_frequency_hz": 50, "decimation": 4}, - "compatibility": {"robot_model": "microduck-standard"}, - "simulation": partial, - }) - self.assertFalse(partial_result.valid) - self.assertTrue(any("cover" in error for error in partial_result.errors)) - invalid = copy.deepcopy(recipe) - invalid["segments"][0]["command"][0] = 4.0 - result = preflight_descriptor({ - "contract": {"observation_dim": 61, "action_dim": 14, - "control_frequency_hz": 50, "decimation": 4}, - "compatibility": {"robot_model": "microduck-standard"}, - "simulation": invalid, - }) - self.assertFalse(result.valid) - self.assertTrue(any("command[0]" in error for error in result.errors)) - - def test_pointer_adapter_preserves_source_identity_and_artifact_url(self) -> None: - pointer = {"id": "flamingo-cycle", "source": FLAMINGO_SOURCE} - descriptor = simulation_descriptor(pointer, self.manifest) - self.assertIsNotNone(descriptor) - assert descriptor is not None - self.assertEqual(descriptor["simulation"]["scenario"], "command_schedule") - self.assertEqual( - descriptor["artifacts"]["onnx"]["expected_sha256"], - FLAMINGO_SOURCE["artifact_sha256"], - ) - self.assertEqual(descriptor["artifacts"]["onnx"]["url"], policy_artifact_url(pointer)) - fixture_data = b"fixture artifact" - fixture_pointer = {"source": {"artifact_sha256": sha256(fixture_data).hexdigest()}} - self.assertTrue(artifact_matches(fixture_pointer, fixture_data)) - - def test_unsupported_fixture_stays_registrable_without_recipe(self) -> None: - manifest = json.loads((Path(__file__).parent / "fixtures" / "unsupported-manifest.json").read_text()) - self.assertIsNone(recipe_for_policy("someone/microduck-mystery", manifest)) - pointer = { - "id": "mystery-spin", - "source": { - "repo": "someone/microduck-mystery", - "revision": "b" * 40, - "manifest_sha256": "c" * 64, - "artifact_sha256": "d" * 64, - }, - } - # Incomplete coverage must not kill registration: adapter returns None. - self.assertIsNone(simulation_descriptor(pointer, manifest)) - - def test_generic_eligible_fixture_selects_zero_command_for_documented_reasons(self) -> None: - manifest = json.loads((Path(__file__).parent / "fixtures" / "generic-eligible-manifest.json").read_text()) - recipe = recipe_for_policy("someone/microduck-polite-bow", manifest) - self.assertIsNotNone(recipe) - assert recipe is not None - self.assertEqual(recipe["scenario"], "oneshot_zero") - self.assertEqual(recipe["provenance"]["command"], [0.0, 0.0, 0.0]) - pointer = { - "id": "polite-bow", - "source": { - "repo": "someone/microduck-polite-bow", - "revision": "b" * 40, - "manifest_sha256": "c" * 64, - "artifact_sha256": "d" * 64, - }, - } - descriptor = simulation_descriptor(pointer, manifest) - self.assertIsNotNone(descriptor) - assert descriptor is not None - self.assertEqual(descriptor["contract"]["action_scale"], 0.8) - - def test_incomplete_pointer_returns_not_covered_instead_of_preflight_failure(self) -> None: - manifest = { - "schema_version": 2, "kind": "episodic", "duration_s": 4.0, - "command": {"encoding": "constant"}, - # Missing obs_len/action_len/robot/action_scale: cannot instantiate. - } - pointer = { - "id": "incomplete", - "source": {"repo": "o/r", "revision": "a" * 40, - "manifest_sha256": "b" * 64, "artifact_sha256": "c" * 64}, - } - self.assertIsNone(simulation_descriptor(pointer, manifest)) - - -if __name__ == "__main__": - unittest.main() diff --git a/simulation/tests/test_preflight.py b/simulation/tests/test_preflight.py index 86d7dfe..b1494a9 100644 --- a/simulation/tests/test_preflight.py +++ b/simulation/tests/test_preflight.py @@ -2,79 +2,58 @@ import unittest -from microduck_sim.preflight import preflight_descriptor, require_valid -from microduck_sim.scenarios import make_command_fn, scenario_from_descriptor - - -def descriptor() -> dict: - return { - "contract": { - "observation_dim": 61, - "action_dim": 14, - "control_frequency_hz": 50, - "decimation": 4, - "actuator_model": "Dynamixel XL330 (BAM M6 voltage control law)", - }, - "compatibility": {"robot_model": "microduck-standard"}, - "simulation": { +from execution import ExecutionSpec +from microduck_sim.preflight import preflight_execution, require_valid +from microduck_sim.scenarios import make_command_fn, scenario_from_recipe + + +def spec() -> ExecutionSpec: + return ExecutionSpec( + entry_id="test-move", + artifact_url="https://huggingface.co/o/r/resolve/" + "a" * 40 + "/policy.onnx", + artifact_sha256="b" * 64, + model="microduck-standard", + contract={"observation_dim": 61, "action_dim": 14, "control_frequency_hz": 50, "decimation": 4, "action_scale": 1.0, "actuator_model": "Dynamixel XL330"}, + recipe={ "runner": "microduck-standard-v1", + "model": "microduck-standard", "scene": "flat-v1", "start": {"preset": "standing_pose"}, "scenario": "velocity", "duration_s": 4, "segments": [{"duration_s": 4, "vx": 0.25, "vy": 0, "wz": 0}], }, - } + source={"provider": "huggingface-model", "repo": "o/r", "revision": "a" * 40, "artifact_path": "policy.onnx", "artifact_sha256": "b" * 64}, + manifest=None, + ) class SimulationPreflightTest(unittest.TestCase): def test_accepts_a_complete_supported_recipe(self) -> None: - result = preflight_descriptor(descriptor()) - - self.assertTrue(result.valid) - self.assertEqual(len(result.warnings), 1) - self.assertIn("BAM", result.warnings[0]) + result = preflight_execution(spec()) + self.assertTrue(result.valid, result.errors) def test_rejects_command_outside_the_runtime_range(self) -> None: - candidate = descriptor() - candidate["simulation"]["segments"][0]["vx"] = 2.2 - - result = preflight_descriptor(candidate) - + candidate = spec() + candidate.recipe["segments"][0]["vx"] = 2.2 + result = preflight_execution(candidate) self.assertFalse(result.valid) - self.assertIn("simulation.segments[0].vx=2.2", result.errors[0]) + self.assertIn("vx=2.2", result.errors[0]) - def test_rejects_an_implicit_or_partial_velocity_schedule(self) -> None: - missing = descriptor() - del missing["simulation"]["segments"] - self.assertFalse(preflight_descriptor(missing).valid) - - partial = descriptor() - partial["simulation"]["segments"][0]["duration_s"] = 3 - result = preflight_descriptor(partial) - self.assertFalse(result.valid) - self.assertTrue(any("must cover the rollout exactly" in error for error in result.errors)) - - def test_external_recipe_is_not_admitted_to_the_standard_runner(self) -> None: - candidate = descriptor() - candidate["simulation"] = { - "runner": "external", - "reason": "custom_environment", - } - - result = preflight_descriptor(candidate) - - self.assertTrue(result.valid) - self.assertEqual(result.errors, ()) + def test_rejects_an_implicit_or_partial_schedule(self) -> None: + candidate = spec() + del candidate.recipe["segments"] + self.assertFalse(preflight_execution(candidate).valid) + partial = spec() + partial.recipe["segments"][0]["duration_s"] = 3 + self.assertFalse(preflight_execution(partial).valid) def test_runtime_command_defense_does_not_clip(self) -> None: - candidate = descriptor() - candidate["simulation"]["segments"][0]["vx"] = 0.4 - spec = scenario_from_descriptor(candidate["simulation"]) - + candidate = spec() + candidate.recipe["segments"][0]["vx"] = 0.4 + scenario = scenario_from_recipe(candidate.recipe) with self.assertRaisesRegex(ValueError, "exceeds"): - make_command_fn(spec, use_13d=True)(0) - + make_command_fn(scenario, use_13d=True)(0) with self.assertRaisesRegex(ValueError, "exceeds"): require_valid(candidate) diff --git a/simulation/tests/test_runtime_observations.py b/simulation/tests/test_runtime_observations.py index 440f5de..6caca1f 100644 --- a/simulation/tests/test_runtime_observations.py +++ b/simulation/tests/test_runtime_observations.py @@ -5,7 +5,7 @@ import numpy as np from microduck_sim.robot import RolloutResult, StepSample -from microduck_sim.scenarios import scenario_from_descriptor +from microduck_sim.scenarios import scenario_from_recipe def sample(t: float, left: bool, right: bool, upright_z: float = -1.0) -> StepSample: @@ -85,7 +85,7 @@ def test_scenario_is_selected_without_a_robotd_slot(self) -> None: "duration_s": 4, "checks": ["recover_upright"], } - spec = scenario_from_descriptor(recipe) + spec = scenario_from_recipe(recipe) self.assertEqual(spec.kind, "oneshot_zero") self.assertEqual(spec.checks, ["recover_upright"]) diff --git a/src/app/behaviors/[id]/page.tsx b/src/app/behaviors/[id]/page.tsx index cd792ff..f341fbe 100644 --- a/src/app/behaviors/[id]/page.tsx +++ b/src/app/behaviors/[id]/page.tsx @@ -128,6 +128,7 @@ export default async function BehaviorDetailPage({ params }: Props) {
Status
{hardwareLabel(entry.hardware.status)}
Target
{entry.hardware.target ?? "Unknown"}
+ {entry.hardware.source_url &&
Publisher source
Open source ↗
} {entry.hardware.note &&
Note
{entry.hardware.note}
}
@@ -144,14 +145,13 @@ export default async function BehaviorDetailPage({ params }: Props) {
-

Installation facts come from the package or the manually reviewed record.

+

Installation facts come from the pinned package and maintainer review.

{runtimeKindLabel(entry.runtime.kind)}
-
+
Install route
{install.route ?? "Unknown"}
{install.command &&
Suggested command
{install.command}
} - {install.config &&
Manual configuration
{install.config}
} {artifact?.url && } {artifact?.sha256 &&
Artifact SHA256
{artifact.sha256}
} {entry.source.revision &&
Source revision
{entry.source.revision}
} @@ -173,7 +173,6 @@ export default async function BehaviorDetailPage({ params }: Props) {

{sourceUrl && Repository{sourceUrl.replace(/^https:\/\//, "")}} - {entry.source.upstream.runtime_url && RuntimePollen Microduck} {entry.source.upstream.training_url && Training source{entry.source.upstream.task_id ?? "Open source"}} {entry.source.upstream.simulator_url && SimulatorPublisher simulator}
@@ -183,4 +182,3 @@ export default async function BehaviorDetailPage({ params }: Props) { ); } - diff --git a/src/app/globals.css b/src/app/globals.css index 3f42e91..6fb2875 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -855,55 +855,6 @@ code { .detail-card h2 svg { color: var(--orange); } .detail-card p { margin: 0; color: var(--ink-soft); font-size: 0.85rem; line-height: 1.65; } -.registry-simulation { display: grid; gap: 1.2rem; } -.registry-simulation-secondary { padding: 0; } -.registry-simulation-disclosure { min-width: 0; } -.registry-simulation-summary { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 1.05rem 1.25rem; - cursor: pointer; - list-style: none; -} -.registry-simulation-summary::-webkit-details-marker { display: none; } -.registry-simulation-summary-copy { display: grid; gap: 0.28rem; min-width: 0; } -.registry-simulation-summary-title { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.95rem; font-weight: 800; } -.registry-simulation-summary-title svg { color: var(--orange); } -.registry-simulation-summary-note { color: var(--quiet); font-family: var(--font-mono); font-size: 0.6rem; letter-spacing: 0.04em; } -.registry-simulation-summary-action { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 0.32rem; color: var(--orange); font-family: var(--font-mono); font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } -.registry-simulation-summary-action svg { transition: transform 160ms ease; } -.registry-simulation-disclosure[open] .registry-simulation-summary { border-bottom: 1px solid var(--line); } -.registry-simulation-disclosure[open] .registry-simulation-summary-action svg { transform: rotate(180deg); } -.registry-simulation-content { display: grid; gap: 1.1rem; padding: 1.1rem 1.25rem 1.25rem; } -.registry-simulation-note { margin: 0; color: var(--muted); font-size: 0.78rem; line-height: 1.55; } -.registry-simulation-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } -.registry-simulation-head h2 { margin-bottom: 0.45rem; } -.registry-simulation-head p { max-width: 44rem; } -.registry-simulation-diagnostics { border-top: 1px solid var(--line); } -.registry-simulation-diagnostics-summary { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding-top: 0.9rem; - cursor: pointer; - list-style: none; - color: var(--orange); - font-family: var(--font-mono); - font-size: 0.6rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} -.registry-simulation-diagnostics-summary::-webkit-details-marker { display: none; } -.registry-simulation-diagnostics-summary svg { transition: transform 160ms ease; } -.registry-simulation-diagnostics[open] .registry-simulation-diagnostics-summary svg { transform: rotate(180deg); } -.registry-simulation-diagnostics-content { padding-top: 0.7rem; } -.registry-simulation-figure { margin: 0; } -.registry-simulation-frame { width: min(100%, 34rem); aspect-ratio: 1; background: var(--bg-inset); } -.registry-simulation-grid { display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr); gap: 1.2rem; } .registry-checks { display: grid; align-content: start; gap: 0.55rem; } .registry-check { display: flex; align-items: flex-start; gap: 0.55rem; border-bottom: 1px dashed var(--line); padding: 0.45rem 0 0.65rem; color: var(--ink-soft); } .registry-check:last-child { border-bottom: 0; } @@ -1183,10 +1134,6 @@ code { .behavior-description { font-size: 0.7rem; -webkit-line-clamp: 2; } .behavior-byline { margin-top: 0.45rem; font-size: 0.56rem; } .behavior-footer { min-width: 0; margin-top: -0.4rem; } - .registry-simulation-head { display: grid; } - .registry-simulation-summary { align-items: flex-start; } - .registry-simulation-diagnostics-summary { align-items: flex-start; } - .registry-simulation-grid { grid-template-columns: 1fr; } .share-strip-inner { display: block; } .share-strip-actions { justify-content: flex-start; margin-top: 1.2rem; } .footer-compact { grid-template-columns: 1fr; gap: 0.9rem; padding-block: 2rem 1.1rem; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 5435e90..2b6d94a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,7 +22,7 @@ export default function HomePage() {
Choose a behavior
-

{stats.total} moves · {stats.hardware} maintainer-verified on hardware

+

{stats.total} moves · {stats.hardwareClaims} publisher hardware claims

@@ -43,7 +43,7 @@ export default function HomePage() { {[0, 1].map((copy) => (
{stats.total} moves in the shelf - {stats.hardware} maintainer-verified on hardware + {stats.hardwareClaims} publisher hardware claims {stats.community} experimental open weights your policy here diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index ce28297..1499ac6 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -30,7 +30,6 @@ const categories = [ const hardwareStatuses = [ { id: "all", label: "Any status" }, - { id: "maintainer-verified", label: "Hardware verified" }, { id: "author-claimed", label: "Hardware claimed" }, { id: "none", label: "No hardware evidence" }, ]; diff --git a/src/components/RegistrySimulation.tsx b/src/components/RegistrySimulation.tsx deleted file mode 100644 index c1d9917..0000000 --- a/src/components/RegistrySimulation.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { Activity, CheckCircle2, ChevronDown, CircleX } from "lucide-react"; -import { MediaPreview } from "./MediaPreview"; -import { simulationMedia, type RegistrySimulationResult } from "@/lib/simulation"; - -interface RegistrySimulationProps { - result: RegistrySimulationResult; - title: string; - hasPublisherMedia: boolean; -} - -function observationLabel(value: unknown): string { - if (typeof value === "boolean") return value ? "observed" : "not observed"; - if (typeof value === "number") return String(value); - return "not measured"; -} - -function SimulationFacts({ result }: { result: RegistrySimulationResult }) { - const observationCandidates: Array<[string, unknown]> = [ - ["Initial foot contact", result.observations.initial_foot_contact], - ["Takeoff after support", result.observations.takeoff_after_support], - ["Touchdown after takeoff", result.observations.touchdown_after_takeoff], - ["Maximum trunk height (m)", result.observations.max_trunk_height_m], - ["Final tilt (deg)", result.observations.final_tilt_deg], - ]; - const observations = observationCandidates.filter(([, value]) => value != null); - - return ( -
-
-
Measured checks
{result.checks_status}
- {result.policy &&
Tested artifact SHA256
{result.policy.sha256}
} - - {observations.map(([label, value]) => ( -
{label}
{observationLabel(value)}
- ))} -
-
- {result.checks.map((check) => ( -
- {check.passed - ?
- ))} -
-
- ); -} - -export function RegistrySimulation({ result, title, hasPublisherMedia }: RegistrySimulationProps) { - const simulationDescription = "Registry-owned diagnostic render; it does not validate hardware or reproduce a publisher environment."; - - if (hasPublisherMedia) { - return ( -
-
- - - - Checks {result.checks_status} · {result.recipe.scene} - - Show render - -
-

{simulationDescription}

-
-
- -
-
Generated with {result.recipe.runner}; start: {result.recipe.start.preset}; scenario: {result.recipe.scenario}.
-
- -
-
-
- ); - } - - return ( -
-
-
-

-

Checks {result.checks_status} · diagnostic preview, not hardware verification.

-
- shown above -
-
- - Show diagnostic details - -
- -
-
-
- ); -} diff --git a/src/components/VerificationBadge.tsx b/src/components/VerificationBadge.tsx deleted file mode 100644 index 1c974c9..0000000 --- a/src/components/VerificationBadge.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { AlertCircle, ShieldCheck, Sparkles } from "lucide-react"; -import type { VerificationStatus } from "@registry/schema/behavior"; - -interface VerificationBadgeProps { - status: VerificationStatus; - summary?: string; - showTooltip?: boolean; - size?: "sm" | "md"; -} - -const labels: Record = { - verified_hardware: "Hardware verified", - claimed_hardware: "Hardware claimed", - community_experimental: "Experimental", -}; - -export function VerificationBadge({ - status, - summary, - showTooltip = true, - size = "md", -}: VerificationBadgeProps) { - const Icon = status === "verified_hardware" - ? ShieldCheck - : status === "claimed_hardware" - ? AlertCircle - : Sparkles; - const tone = status === "verified_hardware" - ? "status-hardware" - : status === "claimed_hardware" - ? "status-claimed" - : "status-experimental"; - - return ( - - - ); -} diff --git a/src/components/social/HomeSocialCard.tsx b/src/components/social/HomeSocialCard.tsx index 3c23ed8..fb72bdd 100644 --- a/src/components/social/HomeSocialCard.tsx +++ b/src/components/social/HomeSocialCard.tsx @@ -4,7 +4,7 @@ import type { SocialImageVariant } from "@/lib/social"; interface HomeSocialCardProps { stats: { total: number; - hardware: number; + hardwareClaims: number; }; variant: SocialImageVariant; } @@ -68,7 +68,7 @@ export function HomeSocialCard({ stats, variant }: HomeSocialCardProps) { {stats.total} MOVES - {stats.hardware} HARDWARE VERIFIED + {stats.hardwareClaims} PUBLISHER HARDWARE CLAIMS
diff --git a/src/lib/catalog.ts b/src/lib/catalog.ts index 26ef6cc..73b3377 100644 --- a/src/lib/catalog.ts +++ b/src/lib/catalog.ts @@ -31,7 +31,6 @@ export function primaryMedia(entry: CatalogEntry): CatalogPreviewMedia { } export function hardwareLabel(status: CatalogHardware["status"]): string { - if (status === "maintainer-verified") return "Hardware verified"; if (status === "author-claimed") return "Hardware claimed"; return "No hardware evidence"; } @@ -44,9 +43,7 @@ export function coverageLabel(status: CatalogEntry["coverage"]["registry_simulat } export function runtimeLabel(runtime: CatalogRuntime): string { - if (runtime.classification === "pollen-hub") return "Pollen Hub package"; - if (runtime.classification === "pollen-review") return "Pollen package · review needed"; - return "Manual registry entry"; + return runtime.status === "ready" ? "Runtime ready" : "Runtime review needed"; } export function runtimeKindLabel(kind: CatalogRuntime["kind"]): string { @@ -71,4 +68,3 @@ export function catalogSearchText(entry: CatalogEntry): string { ...(entry.runtime.compatibility.terrain ?? []), ].join(" ").toLowerCase(); } - diff --git a/src/lib/labels.ts b/src/lib/labels.ts index 14f4230..a5ab082 100644 --- a/src/lib/labels.ts +++ b/src/lib/labels.ts @@ -1,6 +1,6 @@ -import type { BehaviorCategory, RobotDSlot } from "@registry/schema/behavior"; +import type { PolicyCategory } from "@registry/schema/policy"; -const categoryLabels: Record = { +const categoryLabels: Record = { locomotion: "Locomotion", "roller-skate": "Roller skating", "agility-tricks": "Agility & tricks", @@ -9,7 +9,7 @@ const categoryLabels: Record = { experimental: "Experimental", }; -const robotdSlotLabels: Record = { +const robotdSlotLabels: Record = { walk: "Walk", stand: "Stand", sitstand: "Sit ↔ stand", @@ -18,10 +18,9 @@ const robotdSlotLabels: Record = { kick_right: "Kick right", ground_pick: "Ground pick", roller: "Roller mode", - custom: "Custom", }; -export function formatCategory(category: BehaviorCategory | string) { +export function formatCategory(category: PolicyCategory | string) { return (categoryLabels as Record)[category] ?? category; } @@ -34,6 +33,6 @@ export function formatAccessory(accessory: string) { return labels[accessory] ?? accessory.replaceAll("_", " "); } -export function formatRobotdSlot(slot: RobotDSlot | string | null) { - return slot ? robotdSlotLabels[slot as RobotDSlot] ?? slot.replaceAll("_", " ") : "Unknown slot"; +export function formatRobotdSlot(slot: string | null) { + return slot ? robotdSlotLabels[slot] ?? slot.replaceAll("_", " ") : "Unknown slot"; } diff --git a/src/lib/policies.ts b/src/lib/policies.ts index 73b5529..2839e3b 100644 --- a/src/lib/policies.ts +++ b/src/lib/policies.ts @@ -1,22 +1,27 @@ import fs from 'node:fs'; import path from 'node:path'; -import { PolicyPointerSchema, type ResolvedPolicy } from '../../registry/schema/policy'; +import { PolicySchema, type ResolvedPolicy } from '../../registry/schema/policy'; -export function getPolicies(): ResolvedPolicy[] { +export function getResolvedPolicies(): ResolvedPolicy[] { const dir = path.resolve('registry/policies'); if (!fs.existsSync(dir)) return []; return fs.readdirSync(dir).filter(f => f.endsWith('.json')).sort().map(file => { - const pointer = PolicyPointerSchema.parse(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))); + const policy = PolicySchema.parse(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))); const cache = path.resolve('.generated/policies', file); if (!fs.existsSync(cache)) throw new Error(`Run pnpm policies:prepare before building: missing ${file}`); const resolved = JSON.parse(fs.readFileSync(cache, 'utf8')) as ResolvedPolicy; - if (Object.entries(pointer.source).some(([key, value]) => resolved.source[key as keyof typeof pointer.source] !== value || resolved.resolved.source[key as keyof typeof pointer.source] !== value)) throw new Error(`Stale policy resolution: ${file}`); - return { ...pointer, resolved: resolved.resolved }; + const sourceKeys = Object.keys(policy.source) as Array; + if (sourceKeys.some((key) => resolved.source[key] !== policy.source[key] || resolved.resolved.source[key] !== policy.source[key])) { + throw new Error(`Stale policy resolution: ${file}`); + } + return { ...policy, resolved: resolved.resolved }; }); } + +export const getPolicies = getResolvedPolicies; export function policyName(p: ResolvedPolicy): string { - return typeof p.resolved.manifest.name === 'string' ? p.resolved.manifest.name : p.id; + return typeof p.resolved.manifest?.name === 'string' ? p.resolved.manifest.name : p.curation.name ?? p.id; } export function policySummary(p: ResolvedPolicy): string { - return p.curation.summary ?? (typeof p.resolved.manifest.description === 'string' ? p.resolved.manifest.description : 'Microduck policy published on Hugging Face.'); + return p.curation.summary ?? (typeof p.resolved.manifest?.description === 'string' ? p.resolved.manifest.description : `Policy artifact from ${p.source.repo}.`); } diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 19fc060..5305932 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -1,66 +1,59 @@ import fs from "node:fs"; import path from "node:path"; -import { BehaviorSchema, type Behavior } from "@registry/schema/behavior"; import { - catalogEntriesFromSources, + catalogEntries, type CatalogEntry, type CatalogSimulationEvidence, } from "@registry/schema/catalog"; -import { getPolicies } from "./policies"; +import type { Policy } from "@registry/schema/policy"; +import { getResolvedPolicies } from "./policies"; -const BEHAVIORS_DIR = path.resolve(process.cwd(), "registry/behaviors"); const REGISTRY_MEDIA_DIR = path.resolve(process.cwd(), "public/media/registry-sim"); -/** Read the manually authored input records. Consumers should use - * getCatalogEntries(), which normalizes these with resolved Hub packages. */ -export function getAllBehaviors(): Behavior[] { - if (!fs.existsSync(BEHAVIORS_DIR)) return []; - - const behaviors: Behavior[] = []; - for (const file of fs.readdirSync(BEHAVIORS_DIR).filter((name) => name.endsWith(".json")).sort()) { - try { - const parsed = BehaviorSchema.safeParse( - JSON.parse(fs.readFileSync(path.join(BEHAVIORS_DIR, file), "utf-8")), - ); - if (parsed.success) { - behaviors.push(parsed.data); - } else { - console.error(`Invalid behavior schema in ${file}:`, parsed.error.format()); - } - } catch (error) { - console.error(`Failed to read ${file}:`, error); - } - } - - return behaviors.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)); +function sameSource(report: unknown, policy: Policy): boolean { + if (!report || typeof report !== "object" || Array.isArray(report)) return false; + const source = report as Record; + return ["provider", "repo", "revision", "artifact_path", "artifact_sha256", "manifest_path", "manifest_sha256"] + .every((key) => source[key] === policy.source[key as keyof Policy["source"]]); } -function readEvidence(id: string): CatalogSimulationEvidence | null { +function readEvidence(id: string, policy: Policy): CatalogSimulationEvidence | null { const directory = path.join(REGISTRY_MEDIA_DIR, id); const reportPath = path.join(directory, "report.json"); if (!fs.existsSync(reportPath)) return null; try { const report = JSON.parse(fs.readFileSync(reportPath, "utf-8")) as Record; + if (report.entry !== id || !sameSource(report.source, policy)) return null; + const reportPolicy = report.policy; + const inputs = report.inputs_sha256; + const artifact = policy.source.artifact_sha256; + if (!reportPolicy || typeof reportPolicy !== "object" || Array.isArray(reportPolicy) + || (reportPolicy as Record).sha256 !== artifact + || typeof inputs !== "string" || !/^[a-f0-9]{64}$/.test(inputs) + || typeof report.evidence_key !== "string" || !/^[a-f0-9]{64}$/.test(report.evidence_key)) return null; + // The Python runner and evidence store are the single canonical identity + // implementation. Hydration verifies the digest and key before this + // website-facing reader sees the report; TypeScript only enforces the + // source binding and report shape here, avoiding a second implementation. const execution = report.execution; - // Fail closed: a rendered report without an explicit checks_status, - // identity, and checks never becomes "passed". let status: CatalogSimulationEvidence["status"]; if (execution === "rendered") { if (report.checks_status !== "passed" && report.checks_status !== "failed") return null; - status = report.checks_status; if (typeof report.evidence_key !== "string" || !/^[a-f0-9]{64}$/.test(report.evidence_key)) return null; if (typeof report.inputs_sha256 !== "string" || !/^[a-f0-9]{64}$/.test(report.inputs_sha256)) return null; const recipe = report.recipe as Record | undefined; if (!recipe || typeof recipe.runner !== "string" || typeof recipe.scenario !== "string") return null; if (!Array.isArray(report.checks) || report.checks.length === 0) return null; - } else if (execution === "unsupported") { + status = report.checks_status; + } else if (execution === "not-covered") { status = "not-covered"; } else if (execution === "rejected" || execution === "failed") { status = "failed"; } else { return null; } + const recipe = report.recipe && typeof report.recipe === "object" && !Array.isArray(report.recipe) ? report.recipe as Record : {}; @@ -75,8 +68,8 @@ function readEvidence(id: string): CatalogSimulationEvidence | null { && typeof (check as Record).detail === "string" )) : []; - // Rendered evidence requires checks; report-only (unsupported) may have none. if (execution === "rendered" && checks.length === 0) return null; + const localLoop = path.join(directory, "loop.mp4"); const localPoster = path.join(directory, "poster.png"); return { @@ -87,16 +80,10 @@ function readEvidence(id: string): CatalogSimulationEvidence | null { scene: typeof recipe.scene === "string" ? recipe.scene : null, scenario: typeof recipe.scenario === "string" ? recipe.scenario : null, report_url: `/media/registry-sim/${id}/report.json`, - loop_url: fs.existsSync(localLoop) - ? `/media/registry-sim/${id}/loop.mp4` - : typeof media.loop_url === "string" ? media.loop_url : null, - poster_url: fs.existsSync(localPoster) - ? `/media/registry-sim/${id}/poster.png` - : typeof media.poster_url === "string" ? media.poster_url : null, + loop_url: fs.existsSync(localLoop) ? `/media/registry-sim/${id}/loop.mp4` : typeof media.loop_url === "string" ? media.loop_url : null, + poster_url: fs.existsSync(localPoster) ? `/media/registry-sim/${id}/poster.png` : typeof media.poster_url === "string" ? media.poster_url : null, checks, - reason: typeof report.reason === "string" - ? report.reason - : typeof report.notes === "string" ? report.notes : null, + reason: typeof report.reason === "string" ? report.reason : typeof report.notes === "string" ? report.notes : null, }; } catch (error) { console.error(`Failed to read registry evidence for ${id}:`, error); @@ -104,16 +91,15 @@ function readEvidence(id: string): CatalogSimulationEvidence | null { } } -/** The single public catalog consumed by pages, APIs, and index generation. */ +/** The only public catalog consumed by pages, APIs, and index generation. */ export function getCatalogEntries(): CatalogEntry[] { - const behaviors = getAllBehaviors(); - const policies = getPolicies(); + const policies = getResolvedPolicies(); const evidence = new Map(); - for (const entry of [...behaviors, ...policies]) { - const result = readEvidence(entry.id); - if (result) evidence.set(entry.id, result); + for (const policy of policies) { + const result = readEvidence(policy.id, policy); + if (result) evidence.set(policy.id, result); } - return catalogEntriesFromSources(behaviors, policies, evidence); + return catalogEntries(policies, evidence); } export function getCatalogEntryById(id: string): CatalogEntry | null { @@ -124,8 +110,7 @@ export function getRegistryStats() { const entries = getCatalogEntries(); return { total: entries.length, - hardware: entries.filter((entry) => entry.hardware.status === "maintainer-verified").length, - community: entries.filter((entry) => entry.runtime.classification === "custom" || entry.category === "experimental").length, + hardwareClaims: entries.filter((entry) => entry.hardware.status === "author-claimed").length, + community: entries.filter((entry) => entry.category === "experimental").length, }; } - diff --git a/src/lib/simulation-results.ts b/src/lib/simulation-results.ts deleted file mode 100644 index 62999f2..0000000 --- a/src/lib/simulation-results.ts +++ /dev/null @@ -1,74 +0,0 @@ -import "server-only"; -import { evidenceInputsDigest } from "../../scripts/evidence-identity"; -import fs from "node:fs"; -import path from "node:path"; -import { z } from "zod"; -import type { Behavior } from "@registry/schema/behavior"; -import type { BehaviorWithSimulation, RegistrySimulationResult } from "./simulation"; - -const CheckResultSchema = z.object({ - check: z.string(), - passed: z.boolean(), - detail: z.string(), -}); - -const RegistrySimulationResultSchema = z.object({ - behavior: z.string(), - inputs_sha256: z.string(), - evidence_key: z.string().regex(/^[a-f0-9]{64}$/), - policy: z.object({ url: z.string(), sha256: z.string().regex(/^[a-f0-9]{64}$/) }), - execution: z.literal("rendered"), - checks_status: z.enum(["passed", "failed"]), - checks: z.array(CheckResultSchema), - observations: z.record(z.string(), z.unknown()), - recipe: z.object({ - runner: z.literal("microduck-standard-v1"), - model: z.enum(["microduck-standard", "microduck-rollers"]).optional(), - scene: z.literal("flat-v1"), - start: z.object({ preset: z.string() }).passthrough(), - scenario: z.string(), - }), - duration_s: z.number(), - generated_at: z.string(), -}); - -const RESULTS_ROOT = path.resolve(process.cwd(), "public/media/registry-sim"); - -export function getRegistrySimulationResult(behavior: Behavior): RegistrySimulationResult | null { - // A checked-in render is only meaningful while the descriptor opts into the - // same registry-owned runner. This also prevents stale media from surviving - // a later reclassification to an external/publisher environment. - if (!behavior.simulation || behavior.simulation.runner !== "microduck-standard-v1") { - return null; - } - - const id = behavior.id; - const resultDir = path.join(RESULTS_ROOT, id); - const reportPath = path.join(resultDir, "report.json"); - const loopPath = path.join(resultDir, "loop.mp4"); - const posterPath = path.join(resultDir, "poster.png"); - if (![reportPath, loopPath, posterPath].every(fs.existsSync)) return null; - - try { - const parsed = RegistrySimulationResultSchema.safeParse( - JSON.parse(fs.readFileSync(reportPath, "utf8")), - ); - if (!parsed.success || parsed.data.behavior !== id || parsed.data.inputs_sha256 !== evidenceInputsDigest(id)) return null; - return { - ...parsed.data, - media: { - loop_url: `/media/registry-sim/${id}/loop.mp4`, - poster_url: `/media/registry-sim/${id}/poster.png`, - }, - } as RegistrySimulationResult; - } catch { - return null; - } -} - -export function withRegistrySimulation(behavior: Behavior): BehaviorWithSimulation { - return { - ...behavior, - registrySimulation: getRegistrySimulationResult(behavior) ?? undefined, - }; -} diff --git a/src/lib/simulation.ts b/src/lib/simulation.ts deleted file mode 100644 index 79de9af..0000000 --- a/src/lib/simulation.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Behavior } from "@registry/schema/behavior"; - -export interface RegistrySimulationCheck { - check: string; - passed: boolean; - detail: string; -} - -export interface RegistrySimulationResult { - behavior: string; - evidence_key?: string; - policy?: { url: string; sha256: string }; - execution: "rendered"; - checks_status: "passed" | "failed"; - checks: RegistrySimulationCheck[]; - observations: Record; - recipe: { - runner: "microduck-standard-v1"; - model?: "microduck-standard" | "microduck-rollers"; - scene: "flat-v1"; - start: { preset: string; [key: string]: unknown }; - scenario: string; - }; - duration_s: number; - generated_at: string; - media: { - loop_url: string; - poster_url: string; - }; -} - -export type BehaviorWithSimulation = Behavior & { - registrySimulation?: RegistrySimulationResult; -}; - -export function hasPublisherMedia(behavior: Behavior): boolean { - return Boolean( - behavior.media.thumbnail_url || behavior.media.loop_url || behavior.media.video_url, - ); -} - -export function simulationMedia(result: RegistrySimulationResult): Behavior["media"] { - return { - thumbnail_url: result.media.poster_url, - loop_url: result.media.loop_url, - video_url: result.media.loop_url, - hero_type: "video", - caption: `Registry simulation — ${result.recipe.scene}, ${result.recipe.scenario}. Diagnostic render only.`, - }; -} - -export function preferredMedia( - behavior: Behavior, - result?: RegistrySimulationResult, -): Behavior["media"] { - if (hasPublisherMedia(behavior) || !result) return behavior.media; - return simulationMedia(result); -} diff --git a/tests/catalog.test.ts b/tests/catalog.test.ts index 4d3f530..43a15c1 100644 --- a/tests/catalog.test.ts +++ b/tests/catalog.test.ts @@ -1,67 +1,72 @@ import fs from "node:fs"; import { describe, expect, it } from "vitest"; -import { - catalogEntriesFromSources, - catalogEntryFromBehavior, - catalogEntryFromPolicy, -} from "../registry/schema/catalog"; -import { BehaviorSchema } from "../registry/schema/behavior"; -import { validateAllBehaviors } from "../scripts/validate-registry"; -import type { ResolvedPolicy } from "../registry/schema/policy"; +import { catalogEntries, catalogEntryFromPolicy } from "../registry/schema/catalog"; +import { PolicySchema, type ResolvedPolicy } from "../registry/schema/policy"; function flamingoPolicy(): ResolvedPolicy { - const pointer = JSON.parse(fs.readFileSync("registry/policies/flamingo-cycle.json", "utf8")); - const generated = JSON.parse(fs.readFileSync(".generated/policies/flamingo-cycle.json", "utf8")); - return { ...pointer, resolved: generated.resolved }; + const policy = PolicySchema.parse(JSON.parse(fs.readFileSync("registry/policies/flamingo-cycle.json", "utf8"))); + return { + ...policy, + resolved: { + source: policy.source, + manifest: { + schema_version: 2, + model_api: 1, + name: "flamingo-cycle", + kind: "perpetual", + obs_len: 61, + action_len: 14, + action_scale: 1, + command: { encoding: "constant", idle: [0, 0, 0] }, + robot: { model: "microduck", hw_rev: 1, servos: "xl330", control_hz: 50 }, + training: { repo: "pollen-robotics/microduck_rl", task_id: "flamingo" }, + }, + license: "apache-2.0", + resolution: "review", + install_route: "review", + unresolved: ["Held pose requires an explicit command and hold/unwind review"], + install_unresolved: [], + policy_set: false, + onnx: { input: [1, 61], output: [1, 14], smoke: "passed", scope: "Shape inspection only." }, + simulation: { + status: "covered", + recipe: { + runner: "microduck-standard-v1", + model: "microduck-standard", + scene: "flat-v1", + start: { preset: "settled_standing" }, + scenario: "command_schedule", + duration_s: 5, + segments: [{ duration_s: 5, command: [1, 1, 0] }], + checks: ["no_fall"], + }, + scope: "Pinned diagnostic only.", + }, + }, + }; } -describe("unified catalog", () => { - it("normalizes Flamingo pointer to one CatalogEntry with exact provenance", () => { +describe("policy catalog boundary", () => { + it("normalizes one resolved policy into the public CatalogEntry shape", () => { const policy = flamingoPolicy(); const entry = catalogEntryFromPolicy(policy, null); expect(entry.id).toBe("flamingo-cycle"); - expect(entry.source.kind).toBe("pollen-hub"); + expect(entry.source.kind).toBe("huggingface-model"); expect(entry.source.revision).toBe(policy.source.revision); - expect(entry.source.artifact?.sha256).toBe(policy.source.artifact_sha256); + expect(entry.source.artifact.sha256).toBe(policy.source.artifact_sha256); expect(entry.source.manifest_sha256).toBe(policy.source.manifest_sha256); expect(entry.coverage.package_inspection.status).toBe("passed"); - expect(entry.coverage.package_inspection.input_shape).toEqual([1, 61]); - expect(entry.coverage.package_inspection.output_shape).toEqual([1, 14]); - // Missing optional metadata stays null, never a guessed default. expect(entry.runtime.compatibility.accessories_required).toBeNull(); expect(entry.runtime.compatibility.terrain).toBeNull(); - expect(entry.runtime.contract.decimation).toBeNull(); - expect(entry.runtime.contract.actuator_model).toBeNull(); - // No hardware verification invented. expect(entry.hardware.status).toBe("none"); - // Author media separate from registry media. + expect(entry.hardware.target).toBeNull(); + expect(entry.hardware.source_url).toBeNull(); expect(entry.media.author.length).toBeGreaterThan(0); - expect(entry.media.registry).toBeNull(); - // Nested training object supported without fake commit URLs. - expect(entry.source.upstream.task_id).toBe("Mjlab-FlamingoCycleHard-Flat-MicroDuck"); - expect(entry.source.upstream.training_url).toBe("https://github.com/pollen-robotics/microduck_rl"); }); - it("normalizes legacy behavior to the same public shape", () => { - const behavior = BehaviorSchema.parse( - JSON.parse(fs.readFileSync("registry/behaviors/alpha-walking.json", "utf8")), - ); - const entry = catalogEntryFromBehavior(behavior, null); - expect(entry.id).toBe("alpha-walking"); - expect(entry.source.kind).toBe("manual"); - // Same shape keys as a policy entry. - const policy = flamingoPolicy(); - const policyEntry = catalogEntryFromPolicy(policy, null); - expect(Object.keys(entry).sort()).toEqual(Object.keys(policyEntry).sort()); - // Attributable Pollen claim preserved as author-claimed, never verified. - expect(entry.hardware.status).toBe("author-claimed"); - expect(entry.hardware.note).toContain("Not independently verified by uDuck"); - }); - - it("preserves report-only evidence without fabricating media", () => { - const policy = flamingoPolicy(); - const evidence = { - status: "not-covered" as const, + it("keeps not-covered evidence visible without fabricating media", () => { + const entry = catalogEntryFromPolicy(flamingoPolicy(), { + status: "not-covered", evidence_key: null, inputs_sha256: null, runner: null, @@ -71,50 +76,104 @@ describe("unified catalog", () => { loop_url: null, poster_url: null, checks: [], - reason: "No maintainer-owned registry recipe covers this manifest.", - }; - const entry = catalogEntryFromPolicy(policy, evidence); + reason: "No maintainer-owned execution recipe covers this source.", + }); expect(entry.coverage.registry_simulation.status).toBe("not-covered"); - expect(entry.coverage.registry_simulation.report_url).toBe( - "/media/registry-sim/flamingo-cycle/report.json", - ); - expect(entry.coverage.registry_simulation.loop_url).toBeNull(); + expect(entry.coverage.registry_simulation.report_url).toBe("/media/registry-sim/flamingo-cycle/report.json"); expect(entry.media.registry).toBeNull(); }); - it("fails closed on malformed rendered evidence", () => { + it("exposes resolver-level not-covered status before a report is hydrated", () => { const policy = flamingoPolicy(); - const malformed = { - status: "passed" as const, + policy.resolved.simulation = { + status: "not-covered", + reason: "No maintainer-owned execution recipe covers this source.", + }; + const entry = catalogEntryFromPolicy(policy, null); + expect(entry.coverage.registry_simulation.status).toBe("not-covered"); + expect(entry.coverage.registry_simulation.reason).toBe("No maintainer-owned execution recipe covers this source."); + expect(entry.media.registry).toBeNull(); + }); + + it("fails closed on malformed passed evidence", () => { + const entry = catalogEntryFromPolicy(flamingoPolicy(), { + status: "passed", evidence_key: null, inputs_sha256: null, runner: "microduck-standard-v1", scene: "flat-v1", scenario: "command_schedule", - 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: [], - reason: null, - }; - const entry = catalogEntryFromPolicy(policy, malformed); + }); expect(entry.coverage.registry_simulation.status).not.toBe("passed"); }); - it("emits one entries collection with version 3.0.0", () => { - const behavior = BehaviorSchema.parse( - JSON.parse(fs.readFileSync("registry/behaviors/alpha-walking.json", "utf8")), - ); - const entries = catalogEntriesFromSources([behavior], [], new Map()); - const index = { version: "3.0.0" as const, updated_at: new Date(0).toISOString(), count: entries.length, entries }; - expect(index.version).toBe("3.0.0"); - expect(index.count).toBe(entries.length); - expect((index as Record).behaviors).toBeUndefined(); - expect((index as Record).policies).toBeUndefined(); + it("keeps publisher hardware and setup facts separate from registry evidence", () => { + const policy = flamingoPolicy(); + policy.curation.requirements = { robot_model: "microduck-standard", accessories: ["70mm_practice_ball"], terrain: ["flat"] }; + policy.curation.publisher_hardware = { + status: "claimed", + target: "Microduck v1", + source_url: "https://github.com/pollen-robotics/microduck", + note: "Publisher claim only.", + }; + const entry = catalogEntryFromPolicy(policy, { + status: "passed", + evidence_key: "a".repeat(64), + inputs_sha256: "b".repeat(64), + runner: "microduck-standard-v1", + scene: "flat-v1", + scenario: "command_schedule", + checks: [{ check: "no_fall", passed: true, detail: "measured" }], + }); + expect(entry.hardware.status).toBe("author-claimed"); + expect(entry.hardware.target).toBe("Microduck v1"); + expect(entry.hardware.source_url).toBe("https://github.com/pollen-robotics/microduck"); + expect(entry.runtime.compatibility.accessories_required).toEqual(["70mm_practice_ball"]); + expect(entry.coverage.registry_simulation.status).toBe("passed"); }); - it("rejects duplicate repository casing", () => { - const { valid } = validateAllBehaviors(); - expect(valid).toBe(true); + it("only synthesizes exact robotctl targets for single-artifact Hugging Face models", () => { + const base = flamingoPolicy(); + base.resolved = { + ...base.resolved, + policy_set: false, + resolution: "ready", + install_route: "skill", + install_unresolved: [], + manifest: { ...base.resolved.manifest, kind: "episodic", duration_s: 1, action_scale: 1, command: { encoding: "constant" } }, + }; + const hf = catalogEntryFromPolicy({ + ...base, + source: { ...base.source, provider: "huggingface-model", artifact_path: "ball_kick_left.onnx" }, + }, null); + expect(hf.runtime.install.route).toBe("skill"); + expect(hf.runtime.install.command).toContain("@6646428394c6997106d2dc07c1588f20f6fea026:ball_kick_left.onnx"); + + for (const provider of ["github", "huggingface-space"] as const) { + const entry = catalogEntryFromPolicy({ + ...base, + source: { ...base.source, provider, artifact_path: "ball_kick_left.onnx" }, + }, null); + expect(entry.runtime.install.route).toBe("review"); + expect(entry.runtime.install.command).toBeNull(); + expect(entry.runtime.install.reason).toContain("No supported robotctl install route"); + } + + const officialSet = catalogEntryFromPolicy({ + ...base, + source: { ...base.source, provider: "huggingface-model", artifact_path: "ball_kick_left.onnx" }, + resolved: { ...base.resolved, policy_set: true }, + }, null); + expect(officialSet.runtime.install.route).toBe("review"); + expect(officialSet.runtime.install.command).toBeNull(); + expect(officialSet.runtime.install.reason).toContain("updated as a set"); + }); + + it("emits one entries collection", () => { + const entries = catalogEntries([flamingoPolicy()]); + const index = { version: "4.0.0", updated_at: new Date(0).toISOString(), count: entries.length, entries }; + expect(index.count).toBe(1); + expect((index as Record).policies).toBeUndefined(); }); }); diff --git a/tests/contributor-tools.test.ts b/tests/contributor-tools.test.ts index 73e5d4c..60f0b8d 100644 --- a/tests/contributor-tools.test.ts +++ b/tests/contributor-tools.test.ts @@ -1,71 +1,15 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import alphaWalkingJson from "../registry/behaviors/alpha-walking.json"; -import { BehaviorSchema } from "../registry/schema/behavior"; -import { catalogEntryFromBehavior } from "../registry/schema/catalog"; -import { renderReadmeCatalog, updateReadmeCatalog } from "../scripts/generate-registry-index"; -import { createBehaviorScaffold, parseScaffoldArgs } from "../scripts/new-behavior"; -const README_TABLE_START = ""; -const README_TABLE_END = ""; - -describe("contributor tooling", () => { - it("creates an intentionally incomplete draft without invented runtime facts", () => { - const options = parseScaffoldArgs([ - "id=moon-walk", - "category=locomotion", - "author=Ada Lovelace", - "description=A small test behavior.", - "license=Apache-2.0", - ]); - const scaffold = createBehaviorScaffold(options); - - expect(options.name).toBe("Moon Walk"); - expect(scaffold).toMatchObject({ - id: "moon-walk", - name: "Moon Walk", - category: "locomotion", - authors: [{ name: "Ada Lovelace" }], - license: "Apache-2.0", - contract: null, - compatibility: null, - artifacts: null, - deployment: null, - }); - expect(BehaviorSchema.safeParse(scaffold).success).toBe(false); - }); - - it("rejects malformed scaffold arguments", () => { - expect(() => parseScaffoldArgs([])).toThrow(/Missing required field 'id'/); - expect(() => parseScaffoldArgs(["id=bad_id"])).toThrow(/lowercase kebab-case/); - expect(() => parseScaffoldArgs(["id=good-id", "category=unknown"])).toThrow(/Invalid category/); - }); - - it("replaces only the generated README section", () => { - const behavior = BehaviorSchema.parse(alphaWalkingJson); - const entry = catalogEntryFromBehavior(behavior, null); - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "uduck-readme-")); - const readmePath = path.join(tempDir, "README.md"); - - try { - fs.writeFileSync( - readmePath, - `Intro\n${README_TABLE_START}\n| old row |\n${README_TABLE_END}\nFooter\n`, - "utf-8", - ); - - updateReadmeCatalog([entry], readmePath); - - const updated = fs.readFileSync(readmePath, "utf-8"); - expect(updated).toContain("Intro\n"); - expect(updated).toContain("Footer\n"); - expect(updated).toContain("| Behavior | ID | Category | Status | Publisher | Setup | Preview |"); - expect(updated).toContain(`[${entry.name}](https://uduckmoves.com/behaviors/${entry.id})`); - expect(updated).not.toContain("old row"); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } +describe("contributor-facing catalog documentation", () => { + it("points to the live generated catalog instead of carrying an empty snapshot", () => { + const readme = fs.readFileSync(path.resolve("README.md"), "utf8"); + const policyCount = fs.readdirSync(path.resolve("registry/policies")).filter((file) => file.endsWith(".json")).length; + expect(policyCount).toBe(18); + expect(readme).toContain("https://uduckmoves.com"); + expect(readme).toContain("https://uduckmoves.com/registry.json"); + expect(readme).not.toContain("BEGIN GENERATED CATALOG TABLE"); + expect(readme).not.toContain("| --- | --- | --- | --- | --- | --- | --- |"); }); }); diff --git a/tests/evidence-identity.test.ts b/tests/evidence-identity.test.ts deleted file mode 100644 index e12be78..0000000 --- a/tests/evidence-identity.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { describe, expect, it } from 'vitest'; -import { evidenceInputsDigest } from '../scripts/evidence-identity'; -import { PolicyPointerSchema } from '../registry/schema/policy'; -describe('provenance boundaries', () => { - it('uses the same full runner identity in publication and display', () => { - const python = execFileSync('python3', ['-c', 'import sys; sys.path.insert(0,"simulation"); from evidence import inputs_digest; print(inputs_digest("alpha-walking"))'], { encoding: 'utf8' }).trim(); - expect(evidenceInputsDigest('alpha-walking')).toBe(python); - expect(evidenceInputsDigest('jump')).not.toBe(python); - }); - it('rejects unpinned pointers and authored verification claims', () => { - const pointer = { id: 'test', source: { repo: 'o/r', revision: 'a'.repeat(40), artifact_sha256: 'b'.repeat(64), manifest_sha256: 'c'.repeat(64) }, curation: { category: 'experimental' } }; - expect(PolicyPointerSchema.safeParse(pointer).success).toBe(true); - expect(PolicyPointerSchema.safeParse({ ...pointer, verification: 'passed' }).success).toBe(false); - expect(PolicyPointerSchema.safeParse({ ...pointer, source: { ...pointer.source, revision: 'main' } }).success).toBe(false); - }); -}); diff --git a/tests/registry.test.ts b/tests/registry.test.ts index 3d53d17..d789d2a 100644 --- a/tests/registry.test.ts +++ b/tests/registry.test.ts @@ -1,53 +1,42 @@ -import { describe, it, expect } from "vitest"; -import { validateAllBehaviors } from "../scripts/validate-registry"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { validatePolicies } from "../scripts/validate-registry"; -describe("uDuck Registry Integrity", () => { - const { valid, behaviors, errors } = validateAllBehaviors(); +describe("uDuck policy registry integrity", () => { + const result = validatePolicies(); - it("should validate all behavior files without schema errors", () => { - if (errors.length > 0) { - console.error(errors); - } - expect(valid).toBe(true); - expect(errors).toHaveLength(0); - expect(behaviors.length).toBeGreaterThan(0); + it("validates every authored policy", () => { + expect(result.valid, result.errors.join("\n")).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.policies).toHaveLength(18); }); - it("should adhere to the strict 61-D observation and 14-action contract", () => { - for (const b of behaviors) { - expect(b.contract.observation_dim).toBe(61); - expect(b.contract.action_dim).toBe(14); - expect(b.contract.control_frequency_hz).toBe(50); - - const { proprioception, twist, head_pose, body_pose } = b.contract.observation_breakdown; - expect(proprioception + twist + head_pose + body_pose).toBe(61); - - const { left_leg, neck_head, right_leg } = b.contract.action_breakdown; - expect(left_leg + neck_head + right_leg).toBe(14); + it("requires immutable artifacts and does not retain the dropped recovery entry", () => { + for (const policy of result.policies) { + expect(policy.source.revision).toMatch(/^[a-f0-9]{40}$/); + expect(policy.source.artifact_sha256).toMatch(/^[a-f0-9]{64}$/); } + expect(result.policies.some((policy) => policy.id === "fall-recovery")).toBe(false); }); - it("should have valid verification status and hardware targets", () => { - const validStatuses = ["verified_hardware", "claimed_hardware", "community_experimental"]; - for (const b of behaviors) { - expect(validStatuses).toContain(b.verification.status); - expect(b.verification.hardware_target.length).toBeGreaterThan(3); - } - }); + it("rejects a revision of one logical source as a second catalog entry", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "uduck-registry-")); + try { + const original = JSON.parse(fs.readFileSync("registry/policies/alpha-walking.json", "utf8")); + fs.writeFileSync(path.join(directory, "alpha-walking.json"), JSON.stringify(original)); + fs.writeFileSync(path.join(directory, "alpha-walking-revision.json"), JSON.stringify({ + ...original, + id: "alpha-walking-revision", + source: { ...original.source, revision: "b".repeat(40) }, + })); - it("should have functional deployment snippets for robotd.toml", () => { - for (const b of behaviors) { - expect(b.deployment.robotd_toml).toContain("[policy]"); + const result = validatePolicies(directory); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.includes("Duplicate logical source detected"))).toBe(true); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); } }); - - it("keeps upstream origin separate from independent hardware verification", () => { - const hwBehaviors = behaviors.filter((b) => b.verification.status === "claimed_hardware"); - expect(hwBehaviors.length).toBeGreaterThanOrEqual(5); - const ids = hwBehaviors.map((b) => b.id); - expect(ids).toContain("alpha-walking"); - expect(ids).toContain("fall-recovery"); - expect(ids).toContain("ground-pick"); - expect(ids).toContain("roller-drive"); - }); }); diff --git a/tests/schema.test.ts b/tests/schema.test.ts index bc8dab6..b7757f7 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -5,107 +5,28 @@ import { ARTIFACT_URL_PATTERN, GITHUB_USERNAME_PATTERN, ID_PATTERN, - ONNX_FILENAME_PATTERN, HTTPS_URL_PATTERN, isAllowedArtifactUrl, isAllowedMediaUrl, isHttpsUrl, } from "../registry/schema/allowlist"; -import { BehaviorSchema } from "../registry/schema/behavior"; - -const jsonSchema = JSON.parse( - fs.readFileSync(path.resolve("registry/schema/behavior.schema.json"), "utf8"), -); +import { PolicySchema } from "../registry/schema/policy"; function fixture() { - return JSON.parse( - fs.readFileSync(path.resolve("registry/behaviors/alpha-walking.json"), "utf8"), - ) as Record; + return JSON.parse(fs.readFileSync(path.resolve("registry/policies/alpha-walking.json"), "utf8")) as Record; } -describe("behavior schema", () => { - it("accepts every checked-in descriptor", () => { - const files = fs - .readdirSync(path.resolve("registry/behaviors")) - .filter((file) => file.endsWith(".json")) - .sort(); - - for (const file of files) { - const raw = JSON.parse( - fs.readFileSync(path.resolve("registry/behaviors", file), "utf8"), - ); - const result = BehaviorSchema.safeParse(raw); - expect(result.success, file).toBe(true); - } - }); - - it("keeps the manual JSON Schema artifact aligned with shared primitives", () => { - expect(jsonSchema.properties.id.$ref).toBe("#/$defs/id"); - expect(jsonSchema.$defs.id.pattern).toBe(ID_PATTERN.source); - expect(jsonSchema.$defs.githubUsername.pattern).toBe(GITHUB_USERNAME_PATTERN.source); - expect(jsonSchema.$defs.onnxFilename.pattern).toBe(ONNX_FILENAME_PATTERN.source); - expect(jsonSchema.$defs.httpsUrl.pattern).toBe(HTTPS_URL_PATTERN.source.replaceAll("\\/", "/")); - expect(jsonSchema.$defs.artifactUrl.pattern).toBe(ARTIFACT_URL_PATTERN.source.replaceAll("\\/", "/")); - - const contract = jsonSchema.properties.contract; - expect(contract.required).toEqual([ - "observation_dim", - "observation_breakdown", - "action_dim", - "action_breakdown", - "control_frequency_hz", - "decimation", - "actuator_model", - "action_scale", - ]); - expect(contract.properties.observation_dim.const).toBe(61); - expect(contract.properties.action_dim.const).toBe(14); - expect(contract.properties.control_frequency_hz.const).toBe(50); - expect(jsonSchema.properties.media.properties.loop_url).toBeDefined(); - - for (const schema of [ - jsonSchema, - jsonSchema.properties.authors.items, - jsonSchema.properties.verification, - contract, - contract.properties.observation_breakdown, - contract.properties.action_breakdown, - jsonSchema.properties.compatibility, - jsonSchema.properties.artifacts, - jsonSchema.properties.artifacts.properties.onnx, - jsonSchema.properties.media, - ...jsonSchema.properties.simulation.oneOf, - jsonSchema.properties.sources, - jsonSchema.properties.deployment, - ]) { - expect(schema.additionalProperties).toBe(false); - } - }); - - it("requires the exact Microduck contract and supported status", () => { - for (const [pathParts, value] of [ - [["contract", "observation_dim"], 60], - [["contract", "action_dim"], 15], - [["contract", "control_frequency_hz"], 49], - [["contract", "observation_breakdown", "twist"], 4], - ] as const) { - const bad = fixture(); - let target = bad; - for (const key of pathParts.slice(0, -1)) target = target[key]; - target[pathParts.at(-1)!] = value; - expect(BehaviorSchema.safeParse(bad).success).toBe(false); - } - - const unsupportedStatus = fixture(); - unsupportedStatus.verification.status = "unknown"; - expect(BehaviorSchema.safeParse(unsupportedStatus).success).toBe(false); - - const missingExplicitContract = fixture(); - delete missingExplicitContract.contract.observation_dim; - expect(BehaviorSchema.safeParse(missingExplicitContract).success).toBe(false); +describe("authored policy schema", () => { + it("accepts every checked-in policy", () => { + const directory = path.resolve("registry/policies"); + const files = fs.readdirSync(directory).filter((file) => file.endsWith(".json")).sort(); + expect(files.length).toBe(18); + for (const file of files) expect(PolicySchema.safeParse(JSON.parse(fs.readFileSync(path.join(directory, file), "utf8"))).success, file).toBe(true); }); - it("uses the same ID, URL, filename, and metadata boundaries", () => { + it("uses shared ID, URL, and path boundaries", () => { + expect(ID_PATTERN.test("alpha-walking")).toBe(true); + expect(GITHUB_USERNAME_PATTERN.test("pollen-robotics")).toBe(true); expect(isHttpsUrl("https://example.com/policy")).toBe(true); expect(isHttpsUrl("http://example.com/policy")).toBe(false); expect(isHttpsUrl("https://user:pass@example.com/policy")).toBe(false); @@ -114,94 +35,36 @@ describe("behavior schema", () => { expect(isAllowedArtifactUrl("https://evil.example/model/policy.onnx")).toBe(false); expect(isAllowedMediaUrl("/media/loops/policy.mp4")).toBe(true); expect(isAllowedMediaUrl("//evil.example/policy.mp4")).toBe(false); - - for (const [field, value] of [ - ["id", "Alpha-Walking"], - ["artifacts.onnx.url", "https://evil.example/policy.onnx"], - ["artifacts.onnx.filename", "../policy.onnx"], - ["authors[0].github", "not_a_github_name"], - ["authors[0].url", "http://example.com/author"], - ["media.thumbnail_url", "//evil.example/image.jpg"], - ] as const) { - const bad = fixture(); - if (field === "id") bad.id = value; - if (field === "artifacts.onnx.url") bad.artifacts.onnx.url = value; - if (field === "artifacts.onnx.filename") bad.artifacts.onnx.filename = value; - if (field === "authors[0].github") bad.authors[0].github = value; - if (field === "authors[0].url") bad.authors[0].url = value; - if (field === "media.thumbnail_url") bad.media.thumbnail_url = value; - expect(BehaviorSchema.safeParse(bad).success, field).toBe(false); - } - - const badNestedKey = fixture(); - badNestedKey.verification.unexpected = true; - expect(BehaviorSchema.safeParse(badNestedKey).success).toBe(false); - - const badCompatibilityKey = fixture(); - badCompatibilityKey.compatibility.unexpected = "value"; - expect(BehaviorSchema.safeParse(badCompatibilityKey).success).toBe(false); + expect(HTTPS_URL_PATTERN.test("https://example.com")).toBe(true); + expect(ARTIFACT_URL_PATTERN.test("https://raw.githubusercontent.com/o/r/a/policy.onnx")).toBe(true); }); - it("accepts the optional simulation block and rejects bad values", () => { - const withSim = fixture(); - withSim.simulation = { - runner: "microduck-standard-v1", - scene: "flat-v1", - start: { preset: "settled_standing", settle_s: 0.2 }, - scenario: "velocity", - duration_s: 8, - checks: ["no_fall", "velocity_tracking"], - segments: [ - { duration_s: 2, vx: 0.2, vy: 0, wz: 0 }, - { duration_s: 1.5, vx: 0.1, vy: 0, wz: 0.5 }, - ], - }; - expect(BehaviorSchema.safeParse(withSim).success).toBe(true); - - const external = fixture(); - external.simulation = { - runner: "external", - reason: "custom_environment", - notes: "Requires the publisher's obstacle scene.", - }; - expect(BehaviorSchema.safeParse(external).success).toBe(true); - - const airborne = fixture(); - airborne.simulation = { - runner: "microduck-standard-v1", - scene: "flat-v1", - start: { - preset: "airborne_drop", - trunk_height_m: 0.2, - orientation: "side", - }, - scenario: "standing", - duration_s: 4, - }; - expect(BehaviorSchema.safeParse(airborne).success).toBe(false); - airborne.simulation.start.orientation = "left"; - expect(BehaviorSchema.safeParse(airborne).success).toBe(true); - airborne.simulation.start.linear_velocity_mps = [0, 0, -4]; - expect(BehaviorSchema.safeParse(airborne).success).toBe(false); - - for (const sim of [ - { ...withSim.simulation, scenario: "teleport" }, - { ...withSim.simulation, duration_s: 0.5 }, - { ...withSim.simulation, duration_s: 60 }, - { ...withSim.simulation, end_phase: 1.5 }, - { ...withSim.simulation, trigger_s: 5.5 }, - { ...withSim.simulation, checks: ["jump_really_high"] }, - { ...withSim.simulation, segments: [{ duration_s: 0, vx: 0, vy: 0, wz: 0 }] }, - { ...withSim.simulation, segments: [{ duration_s: 1, vx: 0, vy: 0, wz: 0, boost: 1 }] }, - ]) { - const bad = fixture(); - bad.simulation = sim; - expect(BehaviorSchema.safeParse(bad).success, JSON.stringify(sim)).toBe(false); - } + it("rejects runtime claims and malformed immutable sources", () => { + const bad = fixture(); + bad.runtime = { runner: "microduck-standard-v1" }; + expect(PolicySchema.safeParse(bad).success).toBe(false); + const unsafePath = fixture(); + unsafePath.source.artifact_path = "../policy.onnx"; + expect(PolicySchema.safeParse(unsafePath).success).toBe(false); + const unsafeRevision = fixture(); + unsafeRevision.source.revision = "main"; + expect(PolicySchema.safeParse(unsafeRevision).success).toBe(false); + const mismatchedManifest = fixture(); + mismatchedManifest.source.manifest_sha256 = null; + expect(PolicySchema.safeParse(mismatchedManifest).success).toBe(false); + }); - const badKey = fixture(); - badKey.simulation = { ...withSim.simulation, warp: true }; - expect(BehaviorSchema.safeParse(badKey).success).toBe(false); + it("requires provenance for claimed publisher hardware", () => { + const claimed = fixture(); + claimed.curation.publisher_hardware.source_url = null; + expect(PolicySchema.safeParse(claimed).success).toBe(false); + claimed.curation.publisher_hardware.source_url = "https://github.com/pollen-robotics/microduck"; + expect(PolicySchema.safeParse(claimed).success).toBe(true); }); + it("rejects unknown nested curation fields", () => { + const bad = fixture(); + bad.curation.unexpected = true; + expect(PolicySchema.safeParse(bad).success).toBe(false); + }); }); diff --git a/tests/simulation-media.test.ts b/tests/simulation-media.test.ts deleted file mode 100644 index 181cd2e..0000000 --- a/tests/simulation-media.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import type { Behavior } from "@registry/schema/behavior"; -import { preferredMedia, type RegistrySimulationResult } from "../src/lib/simulation"; - -const behavior = JSON.parse( - fs.readFileSync(path.resolve("registry/behaviors/alpha-walking.json"), "utf8"), -) as Behavior; - -const result: RegistrySimulationResult = { - behavior: behavior.id, - execution: "rendered", - checks_status: "passed", - checks: [], - observations: {}, - recipe: { - runner: "microduck-standard-v1", - scene: "flat-v1", - start: { preset: "standing_pose" }, - scenario: "velocity", - }, - duration_s: 6, - generated_at: "2026-09-01T00:00:00Z", - media: { - loop_url: "/media/registry-sim/alpha-walking/loop.mp4", - poster_url: "/media/registry-sim/alpha-walking/poster.png", - }, -}; - -describe("registry simulation media selection", () => { - it("never replaces publisher media", () => { - expect(preferredMedia(behavior, result)).toBe(behavior.media); - }); - - it("uses a reviewed registry render when publisher media is absent", () => { - const withoutPublisherMedia: Behavior = { - ...behavior, - media: { hero_type: "badge" }, - }; - expect(preferredMedia(withoutPublisherMedia, result)).toMatchObject({ - loop_url: result.media.loop_url, - thumbnail_url: result.media.poster_url, - hero_type: "video", - }); - }); -}); diff --git a/tests/test_policy_propose.py b/tests/test_policy_propose.py index 458c2f2..71b138a 100644 --- a/tests/test_policy_propose.py +++ b/tests/test_policy_propose.py @@ -15,13 +15,16 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts/policy')) -POINTER = { +POLICY = { 'id': 'test-move', 'source': { + 'provider': 'huggingface-model', 'repo': 'o/r', 'revision': 'a' * 40, + 'artifact_path': 'policy.onnx', 'artifact_sha256': 'b' * 64, - 'manifest_sha256': 'c' * 64, + 'manifest_path': None, + 'manifest_sha256': None, }, 'curation': {'category': 'experimental', 'tags': []}, } @@ -33,13 +36,12 @@ def test_mutating_calls_carry_explicit_methods(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) (root / 'candidate/registry/policies').mkdir(parents=True) - (root / 'candidate/registry/policies/test-move.json').write_text(json.dumps(POINTER)) + (root / 'candidate/registry/policies/test-move.json').write_text(json.dumps(POLICY)) (root / 'candidate/submission.json').write_text(json.dumps({ - 'pointer': 'registry/policies/test-move.json', - 'diagnosis': {'manifest': {}, 'unresolved': []}, + 'policy': 'registry/policies/test-move.json', + 'diagnosis': {'manifest': None, 'unresolved': [], 'simulation': {'status': 'not-covered'}}, })) (root / 'registry/policies').mkdir(parents=True) - (root / 'registry/behaviors').mkdir(parents=True) calls = [] diff --git a/tests/test_policy_resolver.py b/tests/test_policy_resolver.py index 8c12583..54a6922 100644 --- a/tests/test_policy_resolver.py +++ b/tests/test_policy_resolver.py @@ -1,84 +1,338 @@ import copy import json import sys +import tempfile import unittest from pathlib import Path from unittest.mock import patch + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts/policy')) -from resolve import parse_url, classify, validate_pointer, resolve, digest from ingest_issue import parse_issue +from resolve import _discover_source, classify, digest, parse_artifact_url, parse_source_url, parse_url, register_policy, resolve, resolve_source, select_manifest_for_artifact, validate_policy -MANIFEST = {'schema_version': 2, 'model_api': 1, 'obs_len': 61, 'action_len': 14, - 'robot': {'model': 'microduck', 'hw_rev': 1, 'servos': 'xl330', 'control_hz': 50}, - 'kind': 'episodic', 'duration_s': 4, 'command': {'encoding': 'constant'}} + +MANIFEST = { + 'schema_version': 2, + 'model_api': 1, + 'obs_len': 61, + 'action_len': 14, + 'robot': {'model': 'microduck', 'hw_rev': 1, 'servos': 'xl330', 'control_hz': 50}, + 'kind': 'episodic', + 'duration_s': 4, + 'action_scale': 1.0, + 'command': {'encoding': 'constant'}, +} FLAMINGO = json.loads((Path(__file__).resolve().parents[1] / 'simulation/tests/fixtures/flamingo-manifest.json').read_text()) +FLAMINGO_SOURCE = { + 'provider': 'huggingface-model', + 'repo': 'RemiFabre/microduck-flamingo-cycle', + 'revision': '6646428394c6997106d2dc07c1588f20f6fea026', + 'artifact_path': 'policy.onnx', + 'artifact_sha256': 'df77929c39d7695092bdaf810c2075e20a9ba91abd8192b4073d3de593d56904', + 'manifest_path': 'manifest.json', + 'manifest_sha256': 'ac9b9ae16b4f21733990710275bd934c97558c6028e060bd2b34ec1f5341d302', +} +POLLEN_POLICY_SET = { + 'schema_version': 2, + 'model_api': 1, + 'obs_len': 61, + 'action_len': 14, + 'robot': {'model': 'microduck', 'hw_rev': 1, 'servos': 'xl330', 'control_hz': 50}, + 'policies': [ + {'file': 'alpha_walking.onnx', 'kind': 'perpetual'}, + {'file': 'alpha_stand.onnx', 'kind': 'perpetual'}, + {'file': 'roller.onnx', 'kind': 'perpetual', 'mode': 'roller', 'action_scale': 0.8}, + {'file': 'alpha_sitstand.onnx', 'name': 'sitstand', 'kind': 'scripted', 'command': {'encoding': 'posture_flag', 'slot': 'twist.vx', 'sit': 1.0, 'stand': 0.0, 'idle': [0, 0, 0]}, 'ramp_s': 2.0, 'unwind_s': 1.0}, + {'file': 'alpha_ground_pick.onnx', 'name': 'ground_pick', 'kind': 'episodic', 'duration_s': 2.8, 'command': {'encoding': 'phase', 'slots': 'twist.vx,twist.vy', 'period_s': 4.0, 'end_phase': 0.7}}, + {'file': 'roller_crouch.onnx', 'name': 'crouch', 'kind': 'episodic', 'duration_s': 3.5, 'mode': 'roller', 'action_scale': 0.8, 'command': {'encoding': 'phase', 'slots': 'twist.vx,twist.vy', 'period_s': 5.0, 'end_phase': 0.7}}, + {'file': 'roulade.onnx', 'kind': 'episodic', 'duration_s': 1.0, 'chain': True}, + {'file': 'ball_kick_left.onnx', 'name': 'kick_left', 'kind': 'episodic', 'duration_s': 0.5}, + {'file': 'ball_kick_right.onnx', 'name': 'kick_right', 'kind': 'episodic', 'duration_s': 0.5}, + ], +} + + +def policy_fixture() -> dict: + return { + 'id': 'test-move', + 'source': { + 'provider': 'huggingface-model', + 'repo': 'owner/repo', + 'revision': 'a' * 40, + 'artifact_path': 'policy.onnx', + 'artifact_sha256': 'b' * 64, + 'manifest_path': None, + 'manifest_sha256': None, + }, + 'curation': {'category': 'experimental', 'tags': []}, + } + + class ResolverTests(unittest.TestCase): - def test_url_boundary(self): + def test_source_url_boundary(self): + self.assertEqual(parse_source_url('https://huggingface.co/owner/repo/tree/v2'), ('huggingface-model', 'owner/repo', 'v2')) + self.assertEqual(parse_source_url('https://huggingface.co/spaces/owner/repo/tree/v2'), ('huggingface-space', 'owner/repo', 'v2')) + self.assertEqual(parse_source_url('https://github.com/owner/repo'), ('github', 'owner/repo', 'main')) + self.assertEqual(parse_artifact_url('https://huggingface.co/owner/repo/tree/v2'), ('huggingface-model', 'owner/repo', 'v2', None)) self.assertEqual(parse_url('https://huggingface.co/owner/repo/tree/v2'), ('owner/repo', 'v2')) - for url in ['https://huggingface.co.evil.test/a/b', 'https://user@huggingface.co/a/b', 'file:///a/b', 'https://huggingface.co/datasets/repo', 'https://huggingface.co/a/b/resolve/main/policy.onnx', 'https://huggingface.co/a/b?token=x']: - with self.assertRaises(ValueError): parse_url(url) - def test_optional_upstream_fields_are_not_evidence(self): + for url in [ + 'https://huggingface.co.evil.test/a/b', + 'https://user@huggingface.co/a/b', + 'file:///a/b', + 'https://huggingface.co/datasets/repo', + 'https://huggingface.co/a/b/resolve/main/policy.onnx', + 'https://huggingface.co/a/b?token=x', + ]: + with self.assertRaises(ValueError): parse_source_url(url) + self.assertEqual( + parse_artifact_url('https://huggingface.co/owner/repo/blob/' + 'a' * 40 + '/second.onnx'), + ('huggingface-model', 'owner/repo', 'a' * 40, 'second.onnx'), + ) + + def test_missing_manifest_facts_remain_review(self): result = classify({'schema_version': 2}) - self.assertEqual(result['runtime'], 'pollen-review') + self.assertEqual(result['resolution'], 'review') + self.assertEqual(result['install_route'], 'review') self.assertIn('obs_len is not declared', result['unresolved']) - def test_constant_does_not_imply_zero_or_velocity(self): + self.assertEqual(result['simulation']['status'], 'not-covered') + + def test_constant_manifest_gets_no_recipe_without_an_authored_source(self): result = classify(MANIFEST) self.assertEqual(result['install_route'], 'skill') self.assertEqual(result['simulation']['status'], 'not-covered') - m = copy.deepcopy(MANIFEST) - m.update(kind='perpetual', duration_s=None) - m['command'] = {'twist': ['flag', 'side', 'unused'], 'idle': [0, 0, 0]} - self.assertEqual(classify(m)['install_route'], 'review') + + def test_generic_zero_recipe_requires_explicit_source_and_contract(self): + source = {**policy_fixture()['source'], 'repo': 'owner/episodic'} + result = classify(MANIFEST, source['repo'], source) + self.assertEqual(result['simulation']['status'], 'covered') + self.assertEqual(result['simulation']['recipe']['scenario'], 'oneshot_zero') + missing_scale = copy.deepcopy(MANIFEST) + del missing_scale['action_scale'] + self.assertEqual(classify(missing_scale, source['repo'], source)['simulation']['status'], 'not-covered') + + def test_non_hf_model_sources_never_receive_a_robotctl_install_route(self): + source = {**policy_fixture()['source'], 'provider': 'github'} + result = classify(MANIFEST, source['repo'], source) + self.assertEqual(result['resolution'], 'ready') + self.assertEqual(result['install_route'], 'review') + self.assertTrue(result['install_unresolved']) + + def test_policy_set_manifest_selects_exact_per_file_runtime_facts(self): + manifest = { + 'schema_version': 2, + 'model_api': 1, + 'obs_len': 61, + 'action_len': 14, + 'robot': {'model': 'microduck', 'hw_rev': 1, 'servos': 'xl330', 'control_hz': 50}, + 'policies': [ + {'file': 'first.onnx', 'kind': 'perpetual'}, + {'file': 'second.onnx', 'kind': 'episodic', 'duration_s': 2.8, 'command': {'encoding': 'phase', 'period_s': 4.0, 'end_phase': 0.7}}, + ], + } + raw = json.dumps(manifest).encode() + source = { + **policy_fixture()['source'], + 'repo': 'owner/policy-set', + 'revision': 'a' * 40, + 'artifact_path': 'second.onnx', + 'artifact_sha256': digest(b'fake-onnx'), + 'manifest_path': 'manifest.json', + 'manifest_sha256': digest(raw), + } + + def fetch(url, *args): + if url.endswith('/manifest.json'): + return raw + if '/api/models/' in url: + return json.dumps({'sha': 'a' * 40, 'siblings': [], 'cardData': {'license': 'apache-2.0'}}).encode() + return b'fake-onnx' + + with patch('resolve.fetch', fetch), patch('resolve.inspect_onnx', return_value={'smoke': 'passed'}): + result = resolve_source(source) + self.assertTrue(result['policy_set']) + self.assertEqual(result['manifest']['file'], 'second.onnx') + self.assertEqual(result['manifest']['duration_s'], 2.8) + self.assertEqual(result['manifest']['command']['period_s'], 4.0) + self.assertNotIn('policies', result['manifest']) + with patch('resolve.fetch', fetch): + with self.assertRaisesRegex(ValueError, 'no unique entry'): + resolve_source({**source, 'artifact_path': 'missing.onnx'}) + + def test_official_pollen_entries_select_their_exact_policy_set_members(self): + expected = { + 'alpha-walking': ('alpha_walking.onnx', 'perpetual', None, 'constant'), + 'ball-kick-left': ('ball_kick_left.onnx', 'episodic', 0.5, 'constant'), + 'ball-kick-right': ('ball_kick_right.onnx', 'episodic', 0.5, 'constant'), + 'ground-pick': ('alpha_ground_pick.onnx', 'episodic', 2.8, 'phase'), + 'roller-crouch': ('roller_crouch.onnx', 'episodic', 3.5, 'phase'), + 'roller-drive': ('roller.onnx', 'perpetual', None, 'constant'), + 'roulade': ('roulade.onnx', 'episodic', 1.0, 'constant'), + 'sit-stand': ('alpha_sitstand.onnx', 'scripted', None, 'posture_flag'), + } + policies_dir = Path(__file__).resolve().parents[1] / 'registry/policies' + for entry_id, (artifact_path, kind, duration, encoding) in expected.items(): + policy = json.loads((policies_dir / f'{entry_id}.json').read_text()) + source = policy['source'] + self.assertEqual(source['repo'], 'pollen-robotics/microduck-policies') + self.assertEqual(source['revision'], '088524a64e2557dc453256b6071dbb9d23888802') + self.assertEqual(source['artifact_path'], artifact_path) + manifest, policy_set = select_manifest_for_artifact(POLLEN_POLICY_SET, artifact_path) + self.assertTrue(policy_set) + self.assertEqual(manifest['file'], artifact_path) + self.assertEqual(manifest['kind'], kind) + self.assertEqual(manifest.get('duration_s'), duration) + self.assertEqual((manifest.get('command') or {}).get('encoding', 'constant'), encoding) + self.assertNotIn('policies', manifest) + + def test_multi_onnx_discovery_requires_and_honors_exact_artifact(self): + with patch('resolve._resolve_revision', return_value='a' * 40), \ + patch('resolve._source_files', return_value=['first.onnx', 'second.onnx']), \ + patch('resolve.fetch', side_effect=lambda url, *args: b'second' if url.endswith('second.onnx') else b''): + result = _discover_source('huggingface-model', 'owner/policy-set', 'main', 'second.onnx') + self.assertEqual(result['artifact_path'], 'second.onnx') + with self.assertRaisesRegex(ValueError, 'multiple ONNX'): + _discover_source('huggingface-model', 'owner/policy-set', 'main') + + def test_multi_onnx_registration_uses_distinct_artifact_aware_ids(self): + def result_for(artifact_path: str, payload: bytes) -> dict: + return { + "source": { + "provider": "huggingface-model", + "repo": "owner/policy-set", + "revision": "a" * 40, + "artifact_path": artifact_path, + "artifact_sha256": digest(payload), + "manifest_path": None, + "manifest_sha256": None, + } + } + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first_result = result_for("moves/first.onnx", b"first") + second_result = result_for("moves/second.onnx", b"second") + with patch("resolve.ROOT", root), patch("resolve.resolve", side_effect=[first_result, second_result]): + first = register_policy("https://huggingface.co/owner/policy-set/blob/" + "a" * 40 + "/moves/first.onnx") + second = register_policy("https://huggingface.co/owner/policy-set/blob/" + "a" * 40 + "/moves/second.onnx") + + self.assertEqual(first["policy"], "registry/policies/owner-policy-set-moves-first.json") + self.assertEqual(second["policy"], "registry/policies/owner-policy-set-moves-second.json") + self.assertTrue((root / first["policy"]).is_file()) + self.assertTrue((root / second["policy"]).is_file()) + + def test_artifact_aware_id_falls_back_to_source_hash_on_slug_collision(self): + def result_for(artifact_path: str, payload: bytes) -> dict: + return { + "source": { + "provider": "huggingface-model", + "repo": "owner/policy-set", + "revision": "a" * 40, + "artifact_path": artifact_path, + "artifact_sha256": digest(payload), + "manifest_path": None, + "manifest_sha256": None, + } + } + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + first_result = result_for("moves/foo-bar.onnx", b"first") + second_result = result_for("moves/foo/bar.onnx", b"second") + with patch("resolve.ROOT", root), patch("resolve.resolve", side_effect=[first_result, second_result]): + first = register_policy("first") + second = register_policy("second") + + self.assertEqual(first["policy"], "registry/policies/owner-policy-set-moves-foo-bar.json") + self.assertNotEqual(first["policy"], second["policy"]) + self.assertRegex(second["policy"], r"owner-policy-set-moves-foo-bar-[a-f0-9]{12}\.json$") def test_named_recipe_marks_flamingo_simulation_covered(self): - result = classify(FLAMINGO, 'RemiFabre/microduck-flamingo-cycle', { - 'revision': '6646428394c6997106d2dc07c1588f20f6fea026', - 'manifest_sha256': 'ac9b9ae16b4f21733990710275bd934c97558c6028e060bd2b34ec1f5341d302', - 'artifact_sha256': 'df77929c39d7695092bdaf810c2075e20a9ba91abd8192b4073d3de593d56904', - }) + result = classify(FLAMINGO, FLAMINGO_SOURCE['repo'], FLAMINGO_SOURCE) self.assertEqual(result['simulation']['status'], 'covered') - self.assertEqual(result['simulation']['runner'], 'microduck-standard-v1') recipe = result['simulation']['recipe'] + self.assertEqual(recipe['runner'], 'microduck-standard-v1') self.assertEqual(recipe['segments'][0]['command'], [1.0, 1.0, 0.0]) self.assertEqual(recipe['duration_s'], 5.0) self.assertNotIn('unwind_s', recipe) - def test_daemon_encodings_not_generic_skill(self): + + def test_command_protocol_changes_close_generic_coverage(self): + source = {**policy_fixture()['source'], 'repo': 'owner/episodic'} for encoding in ('phase', 'posture_flag'): - m = copy.deepcopy(MANIFEST); m['command']['encoding'] = encoding - self.assertEqual(classify(m)['install_route'], 'review') + manifest = copy.deepcopy(MANIFEST) + manifest['command']['encoding'] = encoding + self.assertEqual(classify(manifest, source['repo'], source)['simulation']['status'], 'not-covered') + manifest = copy.deepcopy(MANIFEST) + manifest['command']['twist'] = ['forward speed'] + self.assertEqual(classify(manifest, source['repo'], source)['simulation']['status'], 'not-covered') + def test_invalid_claims_fail(self): - for key, value in [('obs_len', 60), ('model_api', 2), ('duration_s', float('nan')), ('chain', 1)]: - m = {**MANIFEST, key: value} - with self.assertRaises(ValueError): classify(m) - def test_resolve_uses_same_immutable_revision_and_checks_hashes(self): - raw = json.dumps(MANIFEST).encode(); model = b'fake-onnx'; calls = [] + for key, value in [('obs_len', 60), ('model_api', 2), ('duration_s', float('nan')), ('kind', 'unknown')]: + manifest = copy.deepcopy(MANIFEST) + manifest[key] = value + with self.assertRaises(ValueError): classify(manifest) + + def test_resolve_uses_authored_revision_and_checks_hashes(self): + raw = json.dumps(MANIFEST).encode() + model = b'fake-onnx' + source = { + **policy_fixture()['source'], + 'manifest_path': 'manifest.json', + 'manifest_sha256': digest(raw), + 'artifact_sha256': digest(model), + } + calls = [] + def fetch(url, *args): calls.append(url) if '/api/models/' in url: - return json.dumps({'sha': 'a' * 40, 'siblings': [{'rfilename': 'policy.onnx'}, {'rfilename': 'manifest.json'}], 'cardData': {'license': 'apache-2.0'}}).encode() + return json.dumps({'sha': 'a' * 40, 'siblings': [], 'cardData': {'license': 'apache-2.0'}}).encode() return raw if url.endswith('manifest.json') else model + with patch('resolve.fetch', fetch), patch('resolve.inspect_onnx', return_value={'smoke': 'passed'}): - result = resolve('https://huggingface.co/o/r') + result = resolve('https://huggingface.co/owner/repo', source) self.assertEqual(result['source']['artifact_sha256'], digest(model)) - self.assertTrue(all('/resolve/' + 'a' * 40 in url for url in calls[1:])) + self.assertTrue(all('/resolve/' + 'a' * 40 in url or '/api/models/' in url for url in calls)) with self.assertRaisesRegex(ValueError, 'hash mismatch'): - resolve('https://huggingface.co/o/r', {'artifact_sha256': '0' * 64, 'manifest_sha256': digest(raw)}) + resolve('https://huggingface.co/owner/repo', {**source, 'artifact_sha256': '0' * 64}) + + def test_policy_validation_rejects_runtime_claims_and_unsafe_paths(self): + policy = policy_fixture() + self.assertEqual(validate_policy(policy), policy) + for change in [ + {'id': '../test'}, + {'execution': {'runner': 'x'}}, + {'source': {**policy['source'], 'artifact_path': '../policy.onnx'}}, + {'source': {**policy['source'], 'revision': 'main'}}, + ]: + candidate = copy.deepcopy(policy) + candidate.update(change) + with self.assertRaises(ValueError): validate_policy(candidate) + + def test_claimed_hardware_requires_source_url(self): + policy = policy_fixture() + policy["curation"]["publisher_hardware"] = { + "status": "claimed", + "target": "Microduck v1", + "source_url": None, + "note": "Publisher claim only.", + } + with self.assertRaisesRegex(ValueError, "require source_url"): + validate_policy(policy) + policy["curation"]["publisher_hardware"]["source_url"] = "https://github.com/pollen-robotics/microduck" + self.assertEqual(validate_policy(policy), policy) + def test_issue_input_is_data(self): - self.assertEqual(parse_issue('### Policy URL\n\nhttps://huggingface.co/a/b\n\n### Category\n\nexperimental\n\n### Notes\n\nhello @maintainer\n'), ('https://huggingface.co/a/b', 'experimental', 'hello @maintainer')) + body = '### Policy URL\n\nhttps://huggingface.co/a/b\n\n### Category\n\nexperimental\n\n### Notes\n\nhello @maintainer\n' + self.assertEqual(parse_issue(body), ('https://huggingface.co/a/b', 'experimental', 'hello @maintainer')) self.assertEqual(parse_issue('### Policy URL\n\nhttps://huggingface.co/a/b\n\n### Category\n\nexperimental\n'), ('https://huggingface.co/a/b', 'experimental', '')) with self.assertRaises(ValueError): parse_issue('### Policy URL\n\na\nb') with self.assertRaisesRegex(ValueError, 'Notes exceed'): parse_issue('### Policy URL\n\nhttps://huggingface.co/a/b\n\n### Category\n\nexperimental\n\n### Notes\n\n' + 'x' * 4001) - def test_pointer_rejects_runtime_claims_and_unsafe_paths(self): - p = {'id': 'test', 'source': {'repo': 'o/r', 'revision': 'a'*40, 'artifact_sha256': 'b'*64, 'manifest_sha256': 'c'*64}, 'curation': {'category': 'experimental'}} - self.assertEqual(validate_pointer(p), p) - for change in [{'id': '../test'}, {'verification': {'status': 'verified_hardware'}}, {'simulation': {'checks': ['pass']}}]: - with self.assertRaises(ValueError): validate_pointer({**p, **change}) + def test_fetch_retries_transient_and_honors_retry_after(self): import urllib.error import resolve as resolve_mod calls = {'n': 0} - real_opener = resolve_mod.urllib.request.build_opener class FakeHeaders(dict): def get(self, key, default=''): return super().get(key, default) @@ -88,11 +342,9 @@ def __enter__(self_inner): if calls['n'] == 0: calls['n'] += 1 raise urllib.error.HTTPError('url', 429, 'rate limit', FakeHeaders({'Retry-After': '1'}), None) - import io calls['n'] += 1 - data = b'ok' class Resp: - def read(self_inner2, n=-1): return data + def read(self_inner2, n=-1): return b'ok' def __enter__(self_inner2): return self_inner2 def __exit__(self_inner2, *a): return False return Resp() @@ -100,19 +352,20 @@ def __exit__(self_inner, *a): return False class Opener: def open(self_inner, req, timeout=None): return Ctx() return Opener() - with patch.object(resolve_mod.urllib.request, 'build_opener', fail_once_then_ok), \ - patch('time.sleep', return_value=None) as slept: + with patch.object(resolve_mod.urllib.request, 'build_opener', fail_once_then_ok), patch('time.sleep', return_value=None) as slept: self.assertEqual(resolve_mod.fetch('https://huggingface.co/o/r/resolve/main/manifest.json', limit=10), b'ok') self.assertTrue(slept.called) - # Permanent 404 is not retried indefinitely. + def always_404(*args, **kwargs): class Opener: def open(self_inner, req, timeout=None): raise urllib.error.HTTPError('url', 404, 'missing', FakeHeaders(), None) return Opener() - with patch.object(resolve_mod.urllib.request, 'build_opener', always_404), \ - patch('time.sleep', return_value=None) as slept: + with patch.object(resolve_mod.urllib.request, 'build_opener', always_404), patch('time.sleep', return_value=None) as slept: with self.assertRaises(urllib.error.HTTPError): resolve_mod.fetch('https://huggingface.co/o/r/resolve/main/manifest.json', limit=10) slept.assert_not_called() -if __name__ == '__main__': unittest.main() + + +if __name__ == '__main__': + unittest.main()