From 710cbfc1152d3f7dd2a2dc8638560850fc5ba82f Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Thu, 30 Jul 2026 01:51:37 -0700 Subject: [PATCH 01/13] Add fail-closed VBD and AVBD parity contracts --- .gitignore | 2 + docs/dev_tasks/avbd_solver/README.md | 35 +- docs/dev_tasks/avbd_solver/RESUME.md | 52 +- docs/plans/104-vertex-block-descent-solver.md | 17 + .../avbd-demo-corpus.md | 19 +- .../avbd-paper-coverage-contract.json | 444 ++++++ .../avbd-paper-gap-audit.md | 6 + .../paper-parity-matrix.md | 177 +++ .../vbd-paper-coverage-contract.json | 469 +++++++ .../vbd-paper-gap-audit.md | 63 +- docs/plans/dashboard.md | 13 +- docs/readthedocs/papers.md | 34 +- pixi.toml | 10 + scripts/check_plan104_paper_parity.py | 1204 +++++++++++++++++ tests/test_check_plan104_paper_parity.py | 231 ++++ 15 files changed, 2701 insertions(+), 75 deletions(-) create mode 100644 docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json create mode 100644 docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md create mode 100644 docs/plans/104-vertex-block-descent-solver/vbd-paper-coverage-contract.json create mode 100644 scripts/check_plan104_paper_parity.py create mode 100644 tests/test_check_plan104_paper_parity.py diff --git a/.gitignore b/.gitignore index 319885354c6d0..739c36bfbdba8 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,8 @@ docs/**/results/*.json # JSON dumps in the same folder. docs/plans/104-vertex-block-descent-solver/*.json !docs/plans/104-vertex-block-descent-solver/avbd-*-packet.json +!docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json +!docs/plans/104-vertex-block-descent-solver/vbd-paper-coverage-contract.json # AI tool local state .omc/ diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index 57bc61a834ad2..bafba4fd733ff 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -10,13 +10,24 @@ This folder is the temporary working surface; the durable owner is the plan. [`../../plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md`](../../plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md). - Corpus matrix: [`../../plans/104-vertex-block-descent-solver/avbd-demo-corpus.md`](../../plans/104-vertex-block-descent-solver/avbd-demo-corpus.md). +- Fail-closed VBD/AVBD parity contract: + [`../../plans/104-vertex-block-descent-solver/paper-parity-matrix.md`](../../plans/104-vertex-block-descent-solver/paper-parity-matrix.md), + backed by the machine-checked VBD and AVBD JSON inventories in the same + directory. ## Current Status - **Active, incomplete paper implementation.** No parity is claimed. The headline gates remain **open**: a source-demo/paper CPU win, GPU parity, and a same-hardware paper-number match. -- **Branch state:** no active local AVBD branch; continue from current `main`. +- **Branch state:** active local branch + `feature/vbd-avbd-paper-parity-contract`, based on `main` at `83110ef54ab`; + it has not been pushed. +- **Current packet:** establish the exact official + paper/repository/video/project-page source inventory and fail-closed + validation gate. The contracts contain 88 VBD and 88 AVBD requirements; all + 176 remain incomplete until their recorded correctness, solver-identity, + CPU/CUDA, visual, and comparable-performance predicates pass. - **Recent slices merged to `main`** (see the PLAN-104 progress log and the PRs for detail; per-slice history lives in git, not in this file): - #2991 — source-row coverage + contact-precheck (`f6fecbc5bd5`). @@ -61,18 +72,18 @@ numbers. ## Immediate Next Steps -Pick the next bounded, mutation-verified slice from PLAN-104's -`AVBD Current Next Gaps` and the corpus matrix; keep claims narrow (no CPU-win, -GPU, broad fracture-corpus, or same-hardware paper-number claim unless the -tracked artifacts prove it). Two small deferred items are also ready: +Finish and review the parity-contract packet, then select the highest-leverage +missing method row rather than adding another narrow demo packet. The first +candidate is `avbd.method.quasi_newton_hessian`, because the paper describes +its diagonal geometric-stiffness approximation as part of the core AVBD local +block update and DART currently has no end-to-end implementation. Keep every +dependent figure/demo/performance row partial until source-matched CPU and CUDA +evidence closes it. -1. **Hoist `makeCollisionPairKey`** into a shared `detail` header. It is - currently duplicated verbatim in `dart/simulation/compute/rigid_body_contact_stage.cpp` - and `dart/simulation/world.cpp`, so the contact-stage ignored-pair audit and - `World::setCollisionPairIgnored` can drift out of sync. -2. **Upgrade the Spring / Spring Ratio packets** from legacy `schema_version` 1 - (`LEGACY_IDENTITY_EXEMPT`) to version 2 with a `resolved_solver_identity` so - the packet-identity check covers them. +Two smaller deferred maintenance items remain valid but do not outrank the +missing paper mechanism: hoist the duplicated `makeCollisionPairKey` into a +shared `detail` header, and upgrade the Spring / Spring Ratio packets from +legacy schema version 1 to version 2 with a `resolved_solver_identity`. ## History diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index 98d1ed0c8f23d..335ed1a3f2e6b 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -8,33 +8,48 @@ this file is only the current session pointer. See ## Last Session Summary -Recent AVBD slices merged to `main` (#2991, #3004, #3018, #3022): source-row -coverage and contact-precheck, 2D/3D Spring/Spring Ratio contact-filtering with -inertia-orientation cleanup and refreshed packets, box edge/vertex rigid-contact -feature IDs, and a bounded mutation-verified regression pass (tangent-basis -contract, articulated break→reset→break re-arm, row-inventory replaced-key -cold-start). No parity was claimed; the CPU-win, GPU, and paper-number gates -remain open. +The official VBD and AVBD PDFs, project pages, source repositories, and paper +videos were audited into an exact 176-row coverage contract. VBD now has 88 +canonical rows (methods, limitations, every figure/table/video segment, Gaia +dynamics/cloth configurations, TinyVBD, and every distinct project-page media +surface); AVBD has 88 (methods, every figure/table/video segment, every 2D/3D +source scene, both deployed demos, and every distinct project-page media +surface). No row is complete under the fail-closed rule. A checker and mutation +tests reject source-pin drift, deleted rows, invalid evidence paths, media +timecode drift, incomplete backend/predicate proof, and premature overall +completion. ## Current Branch -`main` — clean, synced with `origin/main`. There is **no** active local AVBD -follow-up branch; the next slice starts fresh from current `main`. +`feature/vbd-avbd-paper-parity-contract`, based on the then-current +`origin/main` at `83110ef54ab`. The branch is local and has not been pushed. ## Immediate Next Step -Pick one bounded, mutation-verified slice from PLAN-104's `AVBD Current Next -Gaps` / the corpus matrix, or one of the two ready deferred items in -[`README.md`](README.md) (hoist the duplicated `makeCollisionPairKey` into a -shared `detail` header; upgrade the Spring/Spring Ratio packets to -`schema_version` 2 with a `resolved_solver_identity`). Verify with the focused -targets and `pixi run lint`; keep claims narrow. +Finish the contract packet with: + +```bash +pixi run check-plan104-paper-parity +pixi run python -m pytest -q tests/test_check_plan104_paper_parity.py +pixi run check-avbd-packets +pixi run lint +``` + +Then implement the first missing core paper mechanism, +`avbd.method.quasi_newton_hessian`, with focused equation/kernel mutation tests +before attempting dependent scenes. Do not promote any dependent row without +source-matched CPU and CUDA solver-identity, physical, visual, and comparable +performance evidence. ## Context That Would Be Lost - **Keep claims narrow.** Do not claim a source-demo/paper CPU win, GPU parity, a broad breakable-wall/fracture corpus, a same-hardware paper-number match, or an all-coefficient friction win unless the tracked artifacts directly prove it. +- The durable completion rule is + [`../../plans/104-vertex-block-descent-solver/paper-parity-matrix.md`](../../plans/104-vertex-block-descent-solver/paper-parity-matrix.md); + the older corpus matrix's `Complete` labels describe narrow historical assets, + not canonical parity rows. - Two profile-build allocator gate failures (`DantzigSolver.ScratchUsesProvidedAllocatorForDantzigWorkBuffers`, `World.BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`) are pre-existing @@ -52,6 +67,7 @@ git status --short --branch pixi run build ``` -Then choose the next slice from PLAN-104 `AVBD Current Next Gaps` and verify with -the relevant focused `test_avbd_*` / `test_world` / `test_variational_integration` -targets plus `pixi run lint`. +Then use the contract row blockers and PLAN-104 `AVBD Current Next Gaps` to +choose the next slice. Verify with the relevant focused `test_avbd_*` / +`test_world` / `test_variational_integration` targets plus the contract checker +and `pixi run lint`. diff --git a/docs/plans/104-vertex-block-descent-solver.md b/docs/plans/104-vertex-block-descent-solver.md index 00d2077261dc7..4bda953d7a943 100644 --- a/docs/plans/104-vertex-block-descent-solver.md +++ b/docs/plans/104-vertex-block-descent-solver.md @@ -47,6 +47,11 @@ [`104-vertex-block-descent-solver/avbd-demo-corpus.md`](104-vertex-block-descent-solver/avbd-demo-corpus.md) owns the row-by-row source-demo, paper-scene, website/video, and benchmark packet tracking needed before any parity claim. +- VBD/AVBD paper-parity contract: + [`104-vertex-block-descent-solver/paper-parity-matrix.md`](104-vertex-block-descent-solver/paper-parity-matrix.md) + owns the fail-closed completion rule and links the machine-checked 88-row VBD + and 88-row AVBD source inventories. Validate them with + `pixi run check-plan104-paper-parity`. ## Current Implementation Evidence @@ -1279,6 +1284,18 @@ AVBD parity additionally requires: Relocated from the dashboard on 2026-07-03; newest first. +On 2026-07-30, the official VBD and AVBD papers, project pages, repositories, +and paper videos were normalized into the fail-closed +[`paper-parity-matrix.md`](104-vertex-block-descent-solver/paper-parity-matrix.md) +and two machine-readable coverage contracts. The gate inventories 88 VBD and +88 AVBD requirements and deliberately records zero complete rows: existing +implementation and packet evidence is partial until source-matched solver +identity, quantitative outcome, CPU/CUDA, visual, comparable-performance, and +current-build predicates all close. `pixi run check-plan104-paper-parity` +rejects source-pin drift, row deletion/reordering, invalid evidence paths, +media-timecode drift, incomplete predicates/backends, and premature aggregate +completion. + The DART-owned VBD CPU+CUDA solver landed on `main` (#2781): per-vertex block kernels, graph coloring, the colored Gauss-Seidel block-descent driver, Stable Neo-Hookean tetrahedra, Chebyshev/Rayleigh diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md index dd4e8801f3f7b..149842b16bfc6 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md @@ -1,12 +1,16 @@ # AVBD Demo And Benchmark Corpus Matrix -This matrix is the durable owner for PLAN-104 AVBD scene and benchmark parity. +This matrix is the detailed inventory of PLAN-104's narrow AVBD scene and +benchmark surfaces. The authoritative cross-family completion rule and +canonical row inventory are +[`paper-parity-matrix.md`](paper-parity-matrix.md) and +[`avbd-paper-coverage-contract.json`](avbd-paper-coverage-contract.json). The active implementation tracker remains [`../../dev_tasks/avbd_solver/`](../../dev_tasks/avbd_solver/), and the method scope remains [`avbd-paper-gap-audit.md`](avbd-paper-gap-audit.md). This file records the -required source-demo, paper, website/video, and performance artifacts so the -dev task can eventually be retired without losing the remaining corpus. +existing source-demo, paper, website/video, and performance artifacts without +promoting narrow packet status into a paper-parity claim. ## Evidence Contract @@ -29,8 +33,10 @@ Status values: next without first creating another internal row family. - `Blocked by foundation`: required row families, coupling, GPU path, or candidate generation are still missing. -- `Complete`: all evidence in the contract above exists and is linked from this - matrix. +- `Complete`: the narrow DART surface or legacy packet met this matrix's local + evidence contract. It does **not** mean the corresponding canonical parity + row is complete; only the machine-checked coverage contract can make that + claim. ## Solver-Identity Relabel (PLAN-091 WP-091.1) @@ -48,7 +54,8 @@ Body, Bridge, and Breakable) timed AVBD point-joint/motor/spring rows while their ordinary contacts ran sequential impulse; and incidental link-link contacts in the chain rows (2D Rod, Rope, Heavy Rope, and Hanging Rope; 3D Rope and Heavy Rope) also ran sequential impulse. A `Complete` status below -covers the row's evidence contract, not an AVBD-contact-solver claim. This +covers only this matrix's historical local artifact contract, not an +AVBD-contact-solver or paper-parity claim. This relabel changes no committed packet bytes and neither closes nor reopens any PLAN-104 completion gate; new AVBD evidence packets must machine-record `resolved_solver_identity` at AVBD packet schema version 2, enforced by diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json new file mode 100644 index 0000000000000..581a0e2d16fa1 --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json @@ -0,0 +1,444 @@ +{ + "schema_version": "dart.avbd_paper_coverage_contract/v1", + "plan_id": "PLAN-104", + "solver_family": "avbd", + "snapshot_date": "2026-07-30", + "overall_status": "incomplete", + "completion_rule": "paper-parity-matrix.md#completion-rule", + "sources": { + "paper": { + "url": "https://graphics.cs.utah.edu/research/projects/avbd/Augmented_VBD-SIGGRAPH25.pdf", + "sha256": "7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6", + "size_bytes": 13803273 + }, + "project_page": { + "url": "https://graphics.cs.utah.edu/research/projects/avbd/", + "sha256": "602525cc0a21b32b71d742ea38be27f68d6672b7c6968397a2ac44c21a5d3aac", + "size_bytes": 10265, + "online_demo2d_url": "https://graphics.cs.utah.edu/research/projects/avbd/avbd_demo2d.html", + "online_demo2d_sha256": "89bd971d6bd0f3b6b79ae1cd3d7a6215d15852dd5f06bd65d7f338a00171e13e", + "online_demo2d_size_bytes": 1599470, + "online_demo3d_url": "https://graphics.cs.utah.edu/research/projects/avbd/avbd_demo3d.html", + "online_demo3d_sha256": "64c10c6d63474b0d9a7e14e7778390baca964ab4f95d76aa5c3adef12ba0cf3d", + "online_demo3d_size_bytes": 1624308 + }, + "project_assets": { + "teaser": { + "url": "https://graphics.cs.utah.edu/research/projects/avbd/teaser.mp4", + "sha256": "ab7e34bdb4dd83198a4f6a38634ae7d878a7f2ecb381f7437b75d9ef41145b24", + "size_bytes": 15620043, + "duration_seconds": 20.733333, + "width": 1920, + "height": 1080, + "frame_rate": "30/1", + "frames": 622 + }, + "contact_comparison": { + "url": "https://graphics.cs.utah.edu/research/projects/avbd/chainmail.mp4", + "sha256": "5a9b6e40aabf49b9341cb00fe1c8f3972a973156394b5edad1492a26dfdddd36", + "size_bytes": 8972724, + "duration_seconds": 6.583333, + "width": 1920, + "height": 1080, + "frame_rate": "60/1", + "frames": 395 + } + }, + "associated_publication": { + "url": "https://graphics.cs.utah.edu/research/projects/avbd/Augmented_VBD-SIGGRAPH25_RTL.pdf", + "sha256": "00b57241602fb8f5428d0b1effa2e084598d796de731a62cd04ea1047dfbe5af", + "size_bytes": 2395194, + "pages": 2, + "role": "Real-Time Live extended abstract whose equations, two figures, and performance annotations are a subset of the canonical AVBD paper and project teaser." + }, + "paper_video": { + "url": "https://www.youtube.com/watch?v=bwJgifqvd5M", + "youtube_id": "bwJgifqvd5M", + "duration_seconds": 279, + "audited_format": "YouTube format 18, 640x360", + "sha256": "2f685b9214c8afd1b35bee54c76c80aaa2d69ac5497cfdef5d229be3292e420d" + }, + "noncanonical_explainer": { + "url": "https://www.youtube.com/watch?v=TzIKbjuSy2A", + "youtube_id": "TzIKbjuSy2A", + "duration_seconds": 379, + "role": "Third-party explainer linked by the project page; it reuses source media and is not an independent parity surface." + }, + "repositories": { + "avbd_demo2d": { + "url": "https://github.com/savant117/avbd-demo2d", + "revision": "74699a11f8586d3ac34474c92b1ef8feb5f587de" + }, + "avbd_demo3d": { + "url": "https://github.com/savant117/avbd-demo3d", + "revision": "7701bd427d55ca5d03ea1fdf331912ded9169f4b" + } + } + }, + "predicate_definitions": { + "artifact_valid": "The evidence schema, source/build identity, hashes, ordering, dimensions, frames, and replay inputs validate.", + "solver_contract_valid": "The intended AVBD solver and backend ran every required step without fallback, invalid state, or an undisclosed cap.", + "physical_outcome_valid": "A scenario-specific quantitative oracle passes for the declared duration and accuracy target.", + "manual_inspected": "A current-build render or plot was actually inspected and records the expected qualitative behavior.", + "media_decoded": "Required image/video media decodes for its declared duration and is bound to the validated run.", + "performance_comparable": "Scene, precision, backend, hardware, compiler, timer boundary, warmup, aggregation, and achieved accuracy are matched.", + "claim_valid": "Every predicate required by the row passes and the resulting claim stays within the demonstrated boundary." + }, + "coverage_groups": [ + { + "id": "method", + "kind": "method", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + { + "id": "avbd.method.augmented_lagrangian_rows", + "status": "partial", + "source_locator": "Section 3.1, Equations 10-14", + "evidence": ["tests/unit/simulation/deformable_vbd/test_avbd_constraint.cpp"], + "blockers": ["The scalar row kernel is narrow CPU foundation; full row-family, scene, and CUDA closure is missing."] + }, + { + "id": "avbd.method.hard_equality_inequality", + "status": "partial", + "source_locator": "Sections 3.1-3.3, hard equality and bounded inequality constraints", + "evidence": ["tests/unit/simulation/deformable_vbd/test_avbd_constraint.cpp"], + "blockers": ["Focused scalar and World rows exist, but every hard row family and both backends are not complete."] + }, + { + "id": "avbd.method.finite_stiffness_ramping", + "status": "partial", + "source_locator": "Section 3.1, stiffness growth and finite-stiffness saturation", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp"], + "blockers": ["Narrow spring/tetrahedral rows exist, but all paper constraints, source scenes, and CUDA parity are incomplete."] + }, + { + "id": "avbd.method.force_bounds", + "status": "partial", + "source_locator": "Section 3.2, bounded force and clamping behavior", + "evidence": ["tests/unit/simulation/deformable_vbd/test_avbd_constraint.cpp"], + "blockers": ["Bounded rows have focused tests, but conflicting-row diagnostics and full scene closure are missing."] + }, + { + "id": "avbd.method.friction_static_dynamic", + "status": "partial", + "source_locator": "Section 3.3, static/dynamic friction and normal-force bounds", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_contact.cpp"], + "blockers": ["Private friction rows exist, but broad AVBD contact activation, paper scenes, and CUDA parity are incomplete."] + }, + { + "id": "avbd.method.alpha_error_regularization", + "status": "partial", + "source_locator": "Section 3.6, Equation 18", + "evidence": ["tests/unit/simulation/deformable_vbd/test_avbd_constraint.cpp"], + "blockers": ["The scalar rule is covered narrowly, but Figure 15 alpha trajectories and both backends are not reproduced."] + }, + { + "id": "avbd.method.gamma_warm_start", + "status": "partial", + "source_locator": "Section 3.7, Equation 19", + "evidence": ["tests/unit/simulation/deformable_vbd/test_avbd_constraint.cpp"], + "blockers": ["Warm-started row state exists, but broad persistence, Figure 15 gamma trajectories, and CUDA parity are missing."] + }, + { + "id": "avbd.method.quasi_newton_hessian", + "status": "missing", + "source_locator": "Section 3.5, Equations 15-16 and diagonal geometric-stiffness approximation", + "evidence": [], + "blockers": ["The paper's always-on quasi-Newton diagonal geometric-stiffness approximation is not implemented end to end."] + }, + { + "id": "avbd.method.rigid_six_dof_blocks", + "status": "partial", + "source_locator": "Section 3, Equations 7-9 and 20-21", + "evidence": ["tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp"], + "blockers": ["A private CPU rigid block exists, but broad constraints, paper scenes, performance, and CUDA parity are incomplete."] + }, + { + "id": "avbd.method.vertex_body_coloring", + "status": "partial", + "source_locator": "Section 4 parallelization and incremental greedy coloring", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_vertex_coloring.cpp"], + "blockers": ["DART has VBD coloring foundation, but AVBD all-constraint dynamic coloring and paper GPU evidence are incomplete."] + }, + { + "id": "avbd.method.parallel_dual_stiffness_pass", + "status": "missing", + "source_locator": "Section 4, parallel dual-variable and stiffness update after each primal iteration", + "evidence": [], + "blockers": ["No complete task-parallel CPU and CUDA dual/stiffness pass spans every AVBD row family."] + }, + { + "id": "avbd.method.contact_persistence", + "status": "partial", + "source_locator": "Sections 3.3-4, persistent contact/friction rows and warm starts", + "evidence": ["dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp"], + "blockers": ["Feature-key and warm-start slices exist, but broad articulated/deformable contacts and GPU candidate generation are incomplete."] + }, + { + "id": "avbd.method.joints_and_attachments", + "status": "partial", + "source_locator": "Sections 1 and 5, ball-socket, attachment, and limited-DOF hard constraints", + "evidence": ["tests/unit/simulation/world/test_vbd_world_solver.cpp"], + "blockers": ["Narrow point-joint and articulated bridges exist, but the full joint corpus and unified paper scenes are incomplete."] + }, + { + "id": "avbd.method.motors", + "status": "partial", + "source_locator": "Paper/source-demo motor constraints", + "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-rigid-revolute-motor-packet.json"], + "blockers": ["Narrow public motor rows exist, but source-wide CPU wins, CUDA parity, and paper-scene integration are incomplete."] + }, + { + "id": "avbd.method.fracture", + "status": "partial", + "source_locator": "Section 5.4 and Figure 13 breakable attachments", + "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json"], + "blockers": ["Narrow break/reset rows exist, but the paper wall, broad fracture lifecycle, solver identity, and CUDA parity are incomplete."] + }, + { + "id": "avbd.method.unified_rigid_soft", + "status": "missing", + "source_locator": "Section 5.4 and Figure 14 unified cloth/articulated solve", + "evidence": [], + "blockers": ["DART lacks one AVBD solve that couples rigid, articulated, contact, and deformable rows as required by Figure 14."] + }, + { + "id": "avbd.method.cpu_solver", + "status": "partial", + "source_locator": "CPU implementation across the paper and reference demos", + "evidence": ["tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp"], + "blockers": ["CPU row foundations and narrow scenes exist, but several reference rows are slower and the paper corpus is incomplete."] + }, + { + "id": "avbd.method.cuda_solver", + "status": "missing", + "source_locator": "Parallel GPU implementation used for the paper results", + "evidence": [], + "blockers": ["DART has a VBD CUDA substrate but no AVBD CUDA row, dual-update, contact, or unified-scene implementation."] + }, + { + "id": "avbd.method.parameter_defaults", + "status": "partial", + "source_locator": "Table 2 and Section 5.6: beta=10, alpha=0.95, gamma=0.99", + "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], + "blockers": ["DART has related controls, but the exact beta/alpha/gamma defaults and all Figure 15 sweeps are not closed."] + }, + { + "id": "avbd.method.baseline_comparisons", + "status": "partial", + "source_locator": "Section 5 comparisons against VBD, XPBD, and sequential impulse", + "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md"], + "blockers": ["Narrow native-demo timings exist, but the paper's matched VBD/XPBD/sequential-impulse accuracy and performance rows are incomplete."] + } + ] + }, + { + "id": "paper_figures", + "kind": "paper_figure", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + {"id": "avbd.paper.fig.01", "status": "missing", "source_locator": "Figure 1, PDF page 1: 110,000-block pile smashed by a sphere", "evidence": [], "blockers": ["The 110K-body scene and 3.5 ms solver/9.8 ms inclusive RTX 4090 rows are missing."]}, + {"id": "avbd.paper.fig.02", "status": "partial", "source_locator": "Figure 2, PDF page 5: three blocks and two springs at 1:10,000 stiffness ratio", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["The exact five-way VBD/substep/AVBD comparison and reference-error oracle are missing."]}, + {"id": "avbd.paper.fig.03", "status": "missing", "source_locator": "Figure 3, PDF page 7: 510,000-block piles smashed by two spheres", "evidence": [], "blockers": ["The 510K-body scene and 10.3 ms solver/17.6 ms inclusive RTX 4090 rows are missing."]}, + {"id": "avbd.paper.fig.04", "status": "partial", "source_locator": "Figure 4, PDF page 7: 10 alternating-stiffness blocks between fixed endpoints", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["The exact reference/AVBD/VBD five-way result and matched error/timing packet are missing."]}, + {"id": "avbd.paper.fig.05", "status": "missing", "source_locator": "Figure 5, PDF page 8: 1,500-vertex flag attached to 20-body deformable pole", "evidence": [], "blockers": ["DART lacks the unified cloth, hard attachments, articulated pole, visual grid, and benchmark."]}, + {"id": "avbd.paper.fig.06", "status": "partial", "source_locator": "Figure 6, PDF page 8: friction-held card tower struck by balls", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-cards-packet.json"], "blockers": ["The narrow 2D card port uses sequential-impulse contacts and does not reproduce the paper VBD/AVBD impact comparison."]}, + {"id": "avbd.paper.fig.07", "status": "partial", "source_locator": "Figure 7, PDF page 9: 50-body pendulum with 50,000:1 tip mass", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-chain-packet.json"], "blockers": ["A narrow AVBD chain exists, but the seven-way baseline grid, exact trajectory, GPU path, and paper timing are missing."]}, + {"id": "avbd.paper.fig.08", "status": "partial", "source_locator": "Figure 8, PDF page 9: pendulum constraint error across frames", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["Current iteration sweeps do not reproduce the matched XPBD/VBD/AVBD constraint-error histories."]}, + {"id": "avbd.paper.fig.09", "status": "missing", "source_locator": "Figure 9, PDF page 9: two heavy balls linked by a 50-body chain", "evidence": [], "blockers": ["No exact four-method/five-row two-heavy-ball chain reproduction exists."]}, + {"id": "avbd.paper.fig.10", "status": "partial", "source_locator": "Figure 10, PDF page 9: high-mass-ratio box stack", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-stack-packet.json"], "blockers": ["The narrow stack port uses sequential-impulse contacts and lacks the paper sequential-impulse/AVBD comparison."]}, + {"id": "avbd.paper.fig.11", "status": "partial", "source_locator": "Figure 11, PDF page 9: friction-coefficient blocks versus sequential impulse", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json"], "blockers": ["The DART sweep does not establish AVBD-contact solver identity, paper trajectory equivalence, GPU parity, or the matched visual pair."]}, + {"id": "avbd.paper.fig.12", "status": "missing", "source_locator": "Figure 12, PDF page 10: heavy ball dropped on chain mail", "evidence": [], "blockers": ["The chain-mail scene, four-method comparison, hard-contact oracle, and performance packet are missing."]}, + {"id": "avbd.paper.fig.13", "status": "partial", "source_locator": "Figure 13, PDF page 10: breakable brick wall hit by three balls", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json"], "blockers": ["The narrow breakable row is not the paper wall and lacks the four-method fracture/outcome comparison."]}, + {"id": "avbd.paper.fig.14", "status": "missing", "source_locator": "Figure 14, PDF page 10: 35K rigid bodies, 72K joints, and 10K-vertex cloth", "evidence": [], "blockers": ["Unified rigid/soft coupling and the 16 ms inclusive performance row are missing."]}, + {"id": "avbd.paper.fig.15", "status": "partial", "source_locator": "Figure 15, PDF page 11: beta, alpha, gamma, and iteration sweeps", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["Only a DART iteration-budget sweep exists; the exact beta/alpha/gamma trajectories and defaults are missing."]} + ] + }, + { + "id": "paper_tables", + "kind": "paper_table", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + { + "id": "avbd.paper.table.01", + "status": "missing", + "source_locator": "Table 1, PDF page 10: sequential impulse, XPBD, VBD, and AVBD large-scene GPU performance", + "evidence": [], + "blockers": ["Neither 110K nor 510K scene has matched accuracy, baseline implementations, or same-RTX-4090 timing."] + }, + { + "id": "avbd.paper.table.02", + "status": "partial", + "source_locator": "Table 2, PDF page 11: beta, alpha, and gamma parameter defaults", + "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md"], + "blockers": ["The formulas are audited, but the exact defaults, public/private configuration identity, and Figure 15 evidence are incomplete."] + } + ] + }, + { + "id": "paper_video", + "kind": "paper_video_segment", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + {"id": "avbd.video.01_110k_block_pile", "status": "missing", "source_locator": "00:00.000-00:31.033 110K-block pile smashed by sphere", "source_seconds": [0, 31.033], "evidence": [], "blockers": ["No full DART scene, validated edit, or matched RTX 4090 packet exists."]}, + {"id": "avbd.video.02_510k_block_pile", "status": "missing", "source_locator": "00:31.033-00:55.633 510K-block piles smashed by two spheres", "source_seconds": [31.033, 55.633], "evidence": [], "blockers": ["No full DART scene, validated edit, or matched RTX 4090 packet exists."]}, + {"id": "avbd.video.03_unified_cloth_rigid", "status": "missing", "source_locator": "00:55.633-01:33.167 cloth plus 35K articulated rigid bodies", "source_seconds": [55.633, 93.167], "evidence": [], "blockers": ["DART lacks unified AVBD rigid/soft coupling and a source-matched edit."]}, + {"id": "avbd.video.04_high_stiffness", "status": "partial", "source_locator": "01:33.167-01:42.933 high-stiffness VBD/substep/AVBD comparison", "source_seconds": [93.167, 102.933], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["The exact five-way source comparison, reference error, and validated edit are missing."]}, + {"id": "avbd.video.05_flag_pole", "status": "missing", "source_locator": "01:42.933-02:01.133 flag attached to articulated pole", "source_seconds": [102.933, 121.133], "evidence": [], "blockers": ["The unified cloth/pole scene and source-matched edit are missing."]}, + {"id": "avbd.video.06_card_tower", "status": "partial", "source_locator": "02:01.133-02:12.900 VBD/AVBD card tower impacts", "source_seconds": [121.133, 132.9], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-cards-packet.json"], "blockers": ["The narrow port uses sequential-impulse contacts and lacks the paper impact comparison/edit."]}, + {"id": "avbd.video.07_pendulum", "status": "partial", "source_locator": "02:12.900-02:37.567 50-body high-ratio pendulum comparison", "source_seconds": [132.9, 157.567], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-chain-packet.json"], "blockers": ["The seven-way source comparison, exact edit, and matched GPU evidence are missing."]}, + {"id": "avbd.video.08_two_heavy_balls", "status": "missing", "source_locator": "02:37.567-02:51.567 two-heavy-ball chain comparison", "source_seconds": [157.567, 171.567], "evidence": [], "blockers": ["No exact four-method source scene or validated DART edit exists."]}, + {"id": "avbd.video.09_box_stack", "status": "partial", "source_locator": "02:51.567-03:02.033 sequential-impulse/AVBD box stack", "source_seconds": [171.567, 182.033], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-stack-packet.json"], "blockers": ["The current port does not run AVBD contacts and lacks the paper comparison/edit."]}, + {"id": "avbd.video.10_friction", "status": "partial", "source_locator": "03:02.033-03:11.800 friction coefficient comparison", "source_seconds": [182.033, 191.8], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json"], "blockers": ["The current sweep lacks AVBD-contact solver identity and the matched source visual edit."]}, + {"id": "avbd.video.11_chain_mail", "status": "missing", "source_locator": "03:11.800-03:19.200 heavy-ball chain-mail comparison", "source_seconds": [191.8, 199.2], "evidence": [], "blockers": ["No chain-mail source scene, four-method outcome, or validated DART edit exists."]}, + {"id": "avbd.video.12_breakable_wall", "status": "partial", "source_locator": "03:19.200-03:59.667 four-method breakable-wall comparison", "source_seconds": [199.2, 239.667], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json"], "blockers": ["The narrow breakable scene is not the source wall and lacks the four-way edit and fracture oracle."]}, + {"id": "avbd.video.13_parameter_sweeps", "status": "partial", "source_locator": "03:59.667-04:32.633 beta, alpha, gamma, and iteration sweeps", "source_seconds": [239.667, 272.633], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["Only a non-source iteration sweep exists; beta/alpha/gamma trajectories and the full edit are missing."]}, + {"id": "avbd.video.14_closing", "status": "missing", "source_locator": "04:32.633-04:39.000 closing card", "source_seconds": [272.633, 279], "evidence": [], "blockers": ["No approved DART replacement closing edit exists."]} + ] + }, + { + "id": "demo2d", + "kind": "reference_scene", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + {"id": "avbd.demo2d.empty", "status": "partial", "source_locator": "avbd-demo2d sceneEmpty, scene index 0", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-empty-baseline-packet.json"], "blockers": ["The shared narrow baseline lacks full CPU/CUDA predicate and matched-source closure."]}, + {"id": "avbd.demo2d.ground", "status": "partial", "source_locator": "avbd-demo2d sceneGround, scene index 1", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-ground-packet.json"], "blockers": ["The narrow static row does not close the required CPU/CUDA and full visual/performance predicate set."]}, + {"id": "avbd.demo2d.dynamic_friction", "status": "partial", "source_locator": "avbd-demo2d sceneDynamicFriction, scene index 2", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-dynamic-friction-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo2d.static_friction", "status": "partial", "source_locator": "avbd-demo2d sceneStaticFriction, scene index 3", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-static-friction-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo2d.pyramid", "status": "partial", "source_locator": "avbd-demo2d scenePyramid, scene index 4", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-pyramid-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo2d.cards", "status": "partial", "source_locator": "avbd-demo2d sceneCards, scene index 5", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-cards-packet.json"], "blockers": ["The port is slower, contacts resolve through sequential impulse, and AVBD/CUDA/source outcome predicates remain open."]}, + {"id": "avbd.demo2d.rope", "status": "partial", "source_locator": "avbd-demo2d sceneRope, scene index 6", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-rope-packet.json"], "blockers": ["The port is slower and lacks full joint/contact solver identity, CUDA, and source-trajectory closure."]}, + {"id": "avbd.demo2d.heavy_rope", "status": "partial", "source_locator": "avbd-demo2d sceneHeavyRope, scene index 7", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-heavy-rope-packet.json"], "blockers": ["The port is slower and lacks full joint/contact solver identity, CUDA, and source-trajectory closure."]}, + {"id": "avbd.demo2d.hanging_rope", "status": "partial", "source_locator": "avbd-demo2d sceneHangingRope, scene index 8", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-hanging-rope-packet.json"], "blockers": ["The port is slower and lacks full joint/contact solver identity, CUDA, and source-trajectory closure."]}, + {"id": "avbd.demo2d.spring", "status": "partial", "source_locator": "avbd-demo2d sceneSpring, scene index 9", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-spring-packet.json"], "blockers": ["The port is slower and lacks schema-v2 identity, CUDA, and full source-trajectory closure."]}, + {"id": "avbd.demo2d.spring_ratio", "status": "partial", "source_locator": "avbd-demo2d sceneSpringsRatio, scene index 10", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-spring-ratio-packet.json"], "blockers": ["The port is slower and lacks schema-v2 identity, CUDA, and full source-trajectory closure."]}, + {"id": "avbd.demo2d.stack", "status": "partial", "source_locator": "avbd-demo2d sceneStack, scene index 11", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-stack-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo2d.stack_ratio", "status": "partial", "source_locator": "avbd-demo2d sceneStackRatio, scene index 12", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-stack-ratio-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo2d.rod", "status": "partial", "source_locator": "avbd-demo2d sceneRod, scene index 13", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-rod-packet.json"], "blockers": ["The port is slower and lacks full joint/contact solver identity, CUDA, and source-trajectory closure."]}, + {"id": "avbd.demo2d.soft_body", "status": "partial", "source_locator": "avbd-demo2d sceneSoftBody, scene index 14", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-soft-body-packet.json"], "blockers": ["The port is slower, contacts use sequential impulse, and unified AVBD/CUDA/source predicates remain open."]}, + {"id": "avbd.demo2d.joint_grid", "status": "partial", "source_locator": "avbd-demo2d sceneJointGrid, scene index 15", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-joint-grid-packet.json"], "blockers": ["The port is slower and lacks full joint/contact solver identity, CUDA, and source-trajectory closure."]}, + {"id": "avbd.demo2d.net", "status": "partial", "source_locator": "avbd-demo2d sceneNet, scene index 16", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-net-packet.json"], "blockers": ["The port is slower, contacts use sequential impulse, and unified AVBD/CUDA/source predicates remain open."]}, + {"id": "avbd.demo2d.motor", "status": "partial", "source_locator": "avbd-demo2d sceneMotor, scene index 17", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-motor-packet.json"], "blockers": ["The port is slower and lacks CUDA, full solver-contract, visual, and source-trajectory closure."]}, + {"id": "avbd.demo2d.fracture", "status": "partial", "source_locator": "avbd-demo2d sceneFracture, scene index 18", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo2d-fracture-packet.json"], "blockers": ["The narrow CPU row wins, but contacts use sequential impulse and CUDA/full source predicates remain open."]} + ] + }, + { + "id": "demo3d", + "kind": "reference_scene", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + {"id": "avbd.demo3d.empty", "status": "partial", "source_locator": "avbd-demo3d sceneEmpty, scene index 0", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-empty-baseline-packet.json"], "blockers": ["The shared narrow baseline lacks full CPU/CUDA predicate and matched-source closure."]}, + {"id": "avbd.demo3d.ground", "status": "partial", "source_locator": "avbd-demo3d sceneGround, scene index 1", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-ground-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo3d.dynamic_friction", "status": "partial", "source_locator": "avbd-demo3d sceneDynamicFriction, scene index 2", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-dynamic-friction-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo3d.static_friction", "status": "partial", "source_locator": "avbd-demo3d sceneStaticFriction, scene index 3", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-static-friction-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo3d.pyramid", "status": "partial", "source_locator": "avbd-demo3d scenePyramid, scene index 4", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-pyramid-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo3d.rope", "status": "partial", "source_locator": "avbd-demo3d sceneRope, scene index 5", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-rope-packet.json"], "blockers": ["The port is slower and lacks full joint/contact solver identity, CUDA, and source-trajectory closure."]}, + {"id": "avbd.demo3d.heavy_rope", "status": "partial", "source_locator": "avbd-demo3d sceneHeavyRope, scene index 6", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-heavy-rope-packet.json"], "blockers": ["The port is slower and lacks full joint/contact solver identity, CUDA, and source-trajectory closure."]}, + {"id": "avbd.demo3d.spring", "status": "partial", "source_locator": "avbd-demo3d sceneSpring, scene index 7", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-spring-packet.json"], "blockers": ["The port is slower and lacks schema-v2 identity, CUDA, and full source-trajectory closure."]}, + {"id": "avbd.demo3d.spring_ratio", "status": "partial", "source_locator": "avbd-demo3d sceneSpringsRatio, scene index 8", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-spring-ratio-packet.json"], "blockers": ["The port is slower and lacks schema-v2 identity, CUDA, and full source-trajectory closure."]}, + {"id": "avbd.demo3d.stack", "status": "partial", "source_locator": "avbd-demo3d sceneStack, scene index 9", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-stack-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo3d.stack_ratio", "status": "partial", "source_locator": "avbd-demo3d sceneStackRatio, scene index 10", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-stack-ratio-packet.json"], "blockers": ["Rigid contacts resolve through sequential impulse; AVBD-contact, CUDA, and full source predicates remain open."]}, + {"id": "avbd.demo3d.soft_body", "status": "partial", "source_locator": "avbd-demo3d sceneSoftBody, scene index 11", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-soft-body-packet.json"], "blockers": ["The port is slower, contacts use sequential impulse, and unified AVBD/CUDA/source predicates remain open."]}, + {"id": "avbd.demo3d.bridge", "status": "partial", "source_locator": "avbd-demo3d sceneBridge, scene index 12", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-bridge-packet.json"], "blockers": ["The narrow CPU row wins, but contacts use sequential impulse and CUDA/full source predicates remain open."]}, + {"id": "avbd.demo3d.breakable", "status": "partial", "source_locator": "avbd-demo3d sceneBreakable, scene index 13", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json"], "blockers": ["The narrow CPU row wins, but contacts use sequential impulse and CUDA/full source predicates remain open."]} + ] + }, + { + "id": "project_page", + "kind": "project_surface", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + { + "id": "avbd.project.teaser", + "status": "missing", + "source_locator": "Project-page teaser.mp4, 00:00.000-00:20.733: 110K/510K block piles, unified rigid-soft scene, chain mail, and breakable wall with embedded timing", + "source_seconds": [0, 20.733333], + "evidence": [], + "blockers": ["No source-matched DART teaser edit, full headline scenes, current-build media, or comparable embedded timing exists."] + }, + { + "id": "avbd.project.online_demo2d", + "status": "partial", + "source_locator": "Official browser-hosted AVBD 2D Demo, backed by all 19 avbd-demo2d source scenes", + "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md"], + "blockers": ["Narrow DART ports exist, but the deployed-demo surface lacks AVBD contact identity, CPU/CUDA closure, all-scene physical oracles, visual replay, and comparable performance."] + }, + { + "id": "avbd.project.online_demo3d", + "status": "partial", + "source_locator": "Official browser-hosted AVBD 3D Demo, backed by all 14 avbd-demo3d source scenes", + "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md"], + "blockers": ["Narrow DART ports exist, but the deployed-demo surface lacks AVBD contact identity, CPU/CUDA closure, all-scene physical oracles, visual replay, and comparable performance."] + }, + { + "id": "avbd.project.contact_comparison", + "status": "missing", + "source_locator": "Project-page chainmail.mp4, 00:00.000-00:06.583: sequential impulse, XPBD, VBD, and AVBD heavy-ball chain-mail comparison", + "source_seconds": [0, 6.583333], + "evidence": [], + "blockers": ["No source-matched DART comparison, quantitative contact oracle, current-build media, or performance packet exists."] + } + ] + } + ] +} diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md index 72f3e6bc81087..5ed916c0fc510 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md @@ -5,6 +5,12 @@ public 2D/3D reference demo repositories. It records the full implementation target for DART's AVBD work. It is a planning artifact; it does not itself claim implemented behavior. +The authoritative, machine-checked parity inventory is +[`avbd-paper-coverage-contract.json`](avbd-paper-coverage-contract.json), and +its fail-closed completion semantics are defined by +[`paper-parity-matrix.md`](paper-parity-matrix.md). This prose audit explains +the method and gaps but cannot promote a contract row. + ## Sources - Chris Giles, Elie Diaz, and Cem Yuksel. "Augmented Vertex Block Descent." diff --git a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md new file mode 100644 index 0000000000000..2732229aefb40 --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md @@ -0,0 +1,177 @@ +# PLAN-104 VBD / AVBD Paper-Parity Matrix + +This document is the durable, fail-closed completion contract for the VBD and +AVBD paper implementations on DART 7 `main`. The machine-readable row +inventories are: + +- [`vbd-paper-coverage-contract.json`](vbd-paper-coverage-contract.json) +- [`avbd-paper-coverage-contract.json`](avbd-paper-coverage-contract.json) + +Run `pixi run check-plan104-paper-parity` to validate their authoritative source +pins, canonical row order, evidence paths, backend requirements, predicates, +video timecodes, and aggregate completion status. The older +[`avbd-demo-corpus.md`](avbd-demo-corpus.md) remains useful as a detailed +inventory of narrow DART surfaces, but its historical `Complete` labels mean +only that a local asset or packet met that document's earlier row contract. +They do not establish AVBD solver identity or paper parity. + +## Current Fail-Closed Snapshot + +Snapshot date: 2026-07-30. No canonical row is complete under the contract +below, so neither solver family has a paper-parity claim. + +| Family / source surface | Rows | Partial | Blocked | Missing | Complete | +| ----------------------- | ------: | ------: | ------: | ------: | -------: | +| VBD method | 17 | 15 | 0 | 2 | 0 | +| VBD limitations | 4 | 4 | 0 | 0 | 0 | +| VBD paper figures | 24 | 13 | 0 | 11 | 0 | +| VBD paper tables | 1 | 1 | 0 | 0 | 0 | +| VBD official video | 19 | 6 | 0 | 13 | 0 | +| VBD Gaia dynamics | 14 | 0 | 0 | 14 | 0 | +| VBD Gaia cloth | 5 | 0 | 0 | 5 | 0 | +| VBD TinyVBD | 1 | 1 | 0 | 0 | 0 | +| VBD project page | 3 | 0 | 0 | 3 | 0 | +| **VBD total** | **88** | **40** | **0** | **48** | **0** | +| AVBD method | 20 | 16 | 0 | 4 | 0 | +| AVBD paper figures | 15 | 9 | 0 | 6 | 0 | +| AVBD paper tables | 2 | 1 | 0 | 1 | 0 | +| AVBD official video | 14 | 7 | 0 | 7 | 0 | +| AVBD demo2d | 19 | 19 | 0 | 0 | 0 | +| AVBD demo3d | 14 | 14 | 0 | 0 | 0 | +| AVBD project page | 4 | 2 | 0 | 2 | 0 | +| **AVBD total** | **88** | **68** | **0** | **20** | **0** | +| **Grand total** | **176** | **108** | **0** | **68** | **0** | + +`Partial` means only that at least one relevant DART artifact exists and that +the row still records an explicit blocker. It is negative evidence against a +complete claim, not permission to generalize from the artifact. + +## Authoritative Sources + +The contracts pin the exact source identity. Changing a pin or silently +dropping a row fails the checker. + +### VBD + +- Paper: _Vertex Block Descent_, official PDF, 20,000,116 bytes, + SHA-256 + `4ba65c4e49e8e8740aca491c2bf466b6eeb6f8824a152c12d1f7800828959132`. +- Official project video: YouTube `2HCgKfKy3W8`, 331 seconds, audited format 18, + SHA-256 + `fe5e669c2260a507735cd77e10926a3969fd78fee8c983b328274c25ac5682af`. +- Official project page: HTML SHA-256 + `2c98c3158d94f2d3d6193381e7f266507f7a722c1ae3c6777786d98ccd84c16f`; + the distinct teaser, teapot GIF, and armadillo GIF media hashes and frame + contracts are pinned in the JSON inventory. +- Gaia: revision `c229692045465a76233f9fba9197fb22bbfb3694`. +- TinyVBD: revision `dcd011a5d945172e247ecced90a6c2c4b4313520`. + +The VBD contract enumerates all 24 paper figures, Table 1, all 19 contiguous +official-video segments, all 14 Gaia dynamics configurations, all 5 Gaia cloth +configurations, the TinyVBD tilted-strand default, and the official project +page's distinct 23.367-second teaser and two stability GIFs. + +### AVBD + +- Paper: _Augmented Vertex Block Descent_, official PDF, 13,803,273 bytes, + SHA-256 + `7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6`. +- Official project video: YouTube `bwJgifqvd5M`, 279 seconds, audited format 18, + SHA-256 + `2f685b9214c8afd1b35bee54c76c80aaa2d69ac5497cfdef5d229be3292e420d`. +- Official project page: HTML SHA-256 + `602525cc0a21b32b71d742ea38be27f68d6672b7c6968397a2ac44c21a5d3aac`; + its distinct teaser and chain-mail comparison hashes and frame contracts are + pinned in the JSON inventory. The self-contained 2D and 3D browser + deployments are pinned there at SHA-256 + `89bd971d6bd0f3b6b79ae1cd3d7a6215d15852dd5f06bd65d7f338a00171e13e` + and + `64c10c6d63474b0d9a7e14e7778390baca964ab4f95d76aa5c3adef12ba0cf3d`. +- `avbd-demo2d`: revision + `74699a11f8586d3ac34474c92b1ef8feb5f587de`. +- `avbd-demo3d`: revision + `7701bd427d55ca5d03ea1fdf331912ded9169f4b`. + +The AVBD contract enumerates all 15 paper figures, both paper tables, all 14 +contiguous official-video segments, all 19 2D source scenes, all 14 3D source +scenes, and four official project-page surfaces: its distinct teaser, both +deployed browser demos, and the chain-mail comparison video. The project page +also links a Two Minute Papers video (`TzIKbjuSy2A`); it reuses the authors' +media and is recorded as a noncanonical explainer rather than a second +independent parity surface. The associated Real-Time Live extended abstract is +pinned too; its equations, two figures, and timing annotations are a subset of +the main paper and project teaser, so it introduces no duplicate row. + +## Completion Rule + +A canonical row may change to `complete` only when all of the following are +true and recorded in the row: + +1. **Source-matched scope.** The DART scene, inputs, parameters, events, + declared duration, and accuracy target match the pinned paper, repository, + project-page, or video surface. A smaller continuation scene stays + `partial`. +2. **Artifact validity.** The evidence schema validates; source and DART build + identities, hashes, ordering, dimensions, frame counts, replay inputs, and + media binding are explicit. +3. **Solver-contract validity.** The intended VBD or AVBD implementation + actually handled every required row and step. Any fallback, invalid state, + sequential-impulse substitution, undisclosed iteration/contact cap, or + unsupported-row skip fails the predicate. +4. **Physical outcome.** A scenario-specific quantitative oracle passes for + the full declared duration. Finite output alone is not a correctness oracle. +5. **CPU and CUDA closure.** Both required backend results are explicitly true. + Backend-neutral setup may be shared, but evidence from one backend cannot + stand in for the other. +6. **Visual closure.** Required images and videos decode, are bound to the + validated current-build run, and receive a recorded manual semantic + inspection. A successful renderer exit or file existence is insufficient. +7. **Comparable performance.** Performance claims match scene, numerical + precision, backend, hardware, compiler, timer boundary, warmup, aggregation, + and achieved accuracy. Unmatched results remain descriptive smoke data. +8. **Current-build closure.** Evidence records the exact DART commit and + build/configuration identity. Any change to solver, collision, pipeline, + reference-scene, benchmark, or inherited base code invalidates affected + closure and requires rerunning and resealing it. +9. **Fail-closed blockers.** Missing tools, unavailable hardware, invalid + artifacts, crashes, fallbacks, caps, and failed or ambiguous visual verdicts + remain first-class blockers. They are never converted into a positive claim. +10. **Review closure.** After the last implementation or evidence fix, two + clean review passes must find no correctness, regression, contract, or + evidence-boundary defect. + +The checker additionally requires every row's exact group predicates and +`cpu`/`cuda` backend results to be boolean `true`, its blocker list to be empty, +and its evidence paths to exist before accepting `complete`. Overall VBD or AVBD +status becomes `complete` only when every canonical row for that family is +complete; full PLAN-104 paper parity requires all 176 rows. + +## Evidence Workflow + +Use the same lifecycle for each bounded implementation packet: + +1. Select one or more explicit contract rows and preserve their source + locators, pins, and blockers. +2. Implement the smallest reusable solver, pipeline, scene, or evidence + capability that closes those rows without weakening DART's long-term + architecture. +3. Add focused equation/kernel tests, scenario-level physical oracles, and + mutation tests that prove the oracle fails when the intended mechanism is + disabled. +4. Capture solver identity, row/iteration/contact counters, fallback state, + build identity, and backend identity in a machine-readable packet. +5. Run source-matched CPU and CUDA correctness, then controlled performance + comparisons at matched achieved accuracy. +6. Follow + [`../../onboarding/agent-sim-verification.md`](../../onboarding/agent-sim-verification.md) + for text-first scene diagnostics, current-build renders, media decoding, and + recorded manual inspection. +7. Update only the rows directly proven by the packet. Keep narrower, + contradictory, or failed evidence in the ledger with its blocker. +8. Rerun `pixi run check-plan104-paper-parity`, the focused tests, the relevant + build/runtime gates, and `pixi run lint`; then perform the two clean review + passes. + +This workflow deliberately separates implementation existence, solver +identity, physical correctness, visual semantics, and performance +comparability. No one predicate substitutes for another. diff --git a/docs/plans/104-vertex-block-descent-solver/vbd-paper-coverage-contract.json b/docs/plans/104-vertex-block-descent-solver/vbd-paper-coverage-contract.json new file mode 100644 index 0000000000000..0938ce5d2aa0c --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/vbd-paper-coverage-contract.json @@ -0,0 +1,469 @@ +{ + "schema_version": "dart.vbd_paper_coverage_contract/v1", + "plan_id": "PLAN-104", + "solver_family": "vbd", + "snapshot_date": "2026-07-30", + "overall_status": "incomplete", + "completion_rule": "paper-parity-matrix.md#completion-rule", + "sources": { + "paper": { + "url": "https://graphics.cs.utah.edu/research/projects/vbd/vbd-siggraph2024.pdf", + "sha256": "4ba65c4e49e8e8740aca491c2bf466b6eeb6f8824a152c12d1f7800828959132", + "size_bytes": 20000116 + }, + "project_page": { + "url": "https://graphics.cs.utah.edu/research/projects/vbd/", + "sha256": "2c98c3158d94f2d3d6193381e7f266507f7a722c1ae3c6777786d98ccd84c16f", + "size_bytes": 7886 + }, + "project_assets": { + "teaser": { + "url": "https://graphics.cs.utah.edu/research/projects/vbd/teaser.mp4", + "sha256": "096533af2e9f116815b551f133da58b5f059c6724da6b80d9041859b715dda65", + "size_bytes": 34479245, + "duration_seconds": 23.366667, + "width": 1920, + "height": 1080, + "frame_rate": "60/1", + "frames": 1402 + }, + "random_teapot_stability": { + "url": "https://graphics.cs.utah.edu/research/projects/vbd/teapot-small.gif", + "sha256": "429101b99fdbab5d8b5b958d03b3f11a5ff34bdcc49a9054f4d4501496c5d94c", + "size_bytes": 3284707, + "duration_seconds": 2.72, + "width": 480, + "height": 270, + "frames": 68 + }, + "flattened_armadillo_stability": { + "url": "https://graphics.cs.utah.edu/research/projects/vbd/armadillo-small.gif", + "sha256": "451ac464a728e6d273ac17350f7b97eced3a3970df8b810309cd90e2f7479c6f", + "size_bytes": 3283913, + "duration_seconds": 3.28, + "width": 200, + "height": 270, + "frames": 164 + } + }, + "paper_video": { + "url": "https://www.youtube.com/watch?v=2HCgKfKy3W8", + "youtube_id": "2HCgKfKy3W8", + "duration_seconds": 331, + "audited_format": "YouTube format 18, 640x360", + "sha256": "fe5e669c2260a507735cd77e10926a3969fd78fee8c983b328274c25ac5682af" + }, + "repositories": { + "gaia": { + "url": "https://github.com/AnkaChan/Gaia", + "revision": "c229692045465a76233f9fba9197fb22bbfb3694" + }, + "tinyvbd": { + "url": "https://github.com/AnkaChan/TinyVBD", + "revision": "dcd011a5d945172e247ecced90a6c2c4b4313520" + } + } + }, + "predicate_definitions": { + "artifact_valid": "The evidence schema, source/build identity, hashes, ordering, dimensions, frames, and replay inputs validate.", + "solver_contract_valid": "The intended VBD solver and backend ran every required step without fallback, invalid state, or an undisclosed cap.", + "physical_outcome_valid": "A scenario-specific quantitative oracle passes for the declared duration and accuracy target.", + "manual_inspected": "A current-build render or plot was actually inspected and records the expected qualitative behavior.", + "media_decoded": "Required image/video media decodes for its declared duration and is bound to the validated run.", + "performance_comparable": "Scene, precision, backend, hardware, compiler, timer boundary, warmup, aggregation, and achieved accuracy are matched.", + "claim_valid": "Every predicate required by the row passes and the resulting claim stays within the demonstrated boundary." + }, + "coverage_groups": [ + { + "id": "method", + "kind": "method", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + { + "id": "vbd.method.variational_backward_euler", + "status": "partial", + "source_locator": "Sections 3.1-3.2, Equations 1-10", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_stepper.cpp"], + "blockers": ["The DART foundation lacks full CPU/CUDA source-equivalent accuracy and performance closure."] + }, + { + "id": "vbd.method.local_vertex_block_newton", + "status": "partial", + "source_locator": "Section 3.2, Equations 5-10", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_vertex_block_kernel.cpp"], + "blockers": ["The local block update is tested narrowly, but full paper-scene CPU/CUDA convergence equivalence is unproven."] + }, + { + "id": "vbd.method.local_spd_solve", + "status": "partial", + "source_locator": "Section 3.2, local 3x3 solve and stability discussion", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_vertex_block_kernel.cpp"], + "blockers": ["DART has a stabilized local solve, but reference-equivalent failure policy and all-scene evidence are incomplete."] + }, + { + "id": "vbd.method.stable_neo_hookean", + "status": "partial", + "source_locator": "Section 3.3 and the paper experiment materials", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_neo_hookean.cpp"], + "blockers": ["The tetrahedral material kernel exists, but paper-scene CPU/CUDA correctness and performance are incomplete."] + }, + { + "id": "vbd.method.rayleigh_damping", + "status": "partial", + "source_locator": "Section 3.4, Equations 11-13", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_acceleration.cpp"], + "blockers": ["Focused damping coverage does not reproduce the paper coefficient sweep on both backends."] + }, + { + "id": "vbd.method.soft_constraints", + "status": "partial", + "source_locator": "Section 3.5, constraint energy formulation", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_attachment.cpp"], + "blockers": ["Narrow attachments exist, but the full constraint family and paper scenes are not closed on CPU and CUDA."] + }, + { + "id": "vbd.method.penalty_contact", + "status": "partial", + "source_locator": "Section 3.6, penetration-potential contact with DCD/CCD", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_contact.cpp"], + "blockers": ["Static-obstacle and self-contact slices exist, but the paper collision corpus and CUDA candidate path are incomplete."] + }, + { + "id": "vbd.method.lagged_friction", + "status": "partial", + "source_locator": "Section 3.6, Equations 14-16", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_contact.cpp"], + "blockers": ["Lagged friction is only partially integrated and lacks the full paper sweeps, self-contact coverage, and CUDA parity."] + }, + { + "id": "vbd.method.adaptive_initialization", + "status": "partial", + "source_locator": "Section 3.7, Equation 17", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_acceleration.cpp"], + "blockers": ["The formula has focused tests, but Figures 5-6 and the full CPU/CUDA trajectory comparison are not reproduced."] + }, + { + "id": "vbd.method.chebyshev_acceleration", + "status": "partial", + "source_locator": "Section 3.8, Equations 18-19", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_acceleration.cpp"], + "blockers": ["The accelerator exists, but source-equivalent collision skip policy and Figure 7/17 performance closure are missing."] + }, + { + "id": "vbd.method.vertex_coloring", + "status": "partial", + "source_locator": "Section 3.9 and Figure 8", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_vertex_coloring.cpp"], + "blockers": ["DART has deterministic coloring tests, but the paper mesh color counts and GPU scheduling evidence are incomplete."] + }, + { + "id": "vbd.method.dynamic_collision_double_buffer", + "status": "partial", + "source_locator": "Section 3.9, collision handling without recoloring", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_parallel_block_descent.cpp"], + "blockers": ["The parallel foundation does not yet reproduce Figure 9 with matched recolored and non-recolored trajectories."] + }, + { + "id": "vbd.method.topology_change", + "status": "missing", + "source_locator": "Section 3.9 and Figure 10 tearing", + "evidence": [], + "blockers": ["DART has no source-equivalent VBD tearing or fracture topology-change implementation and evidence packet."] + }, + { + "id": "vbd.method.gpu_hierarchical_kernel", + "status": "partial", + "source_locator": "Section 4 and Algorithm 1", + "evidence": ["tests/unit/simulation/cuda/test_vbd_block_descent_cuda.cpp"], + "blockers": ["CUDA mass-spring and tetrahedral slices exist, but the full force/contact/friction kernel and paper workloads are incomplete."] + }, + { + "id": "vbd.method.particle_blocks", + "status": "partial", + "source_locator": "Section 6.1 and Figure 21", + "evidence": ["python/examples/demos/scenes/vbd_tilted_strand.py"], + "blockers": ["The TinyVBD-shaped strand is narrow CPU evidence, not full particle CPU/CUDA paper parity."] + }, + { + "id": "vbd.method.rigid_six_dof_blocks", + "status": "partial", + "source_locator": "Section 6.2 and Figures 22-23", + "evidence": ["tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp"], + "blockers": ["A private rigid block foundation exists under AVBD work, but VBD rigid-chain and rigid-contact paper reproductions are missing."] + }, + { + "id": "vbd.method.unified_simulation", + "status": "missing", + "source_locator": "Section 6.3", + "evidence": [], + "blockers": ["DART lacks one VBD solve coupling the paper's heterogeneous rigid, particle, and deformable representations."] + } + ] + }, + { + "id": "limitations", + "kind": "limitation", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + { + "id": "vbd.limit.local_information_propagation", + "status": "partial", + "source_locator": "Section 7, graph-hop information propagation limitation", + "evidence": ["docs/plans/104-vertex-block-descent-solver/vbd-paper-gap-audit.md"], + "blockers": ["The limitation is documented but has no controlled CPU/CUDA scaling reproduction against a global solver."] + }, + { + "id": "vbd.limit.high_stiffness_ratio", + "status": "partial", + "source_locator": "Section 7 and Figure 24", + "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], + "blockers": ["A related AVBD sweep exists, but the exact VBD 100-iteration versus converged limitation row is not reproduced on both backends."] + }, + { + "id": "vbd.limit.penetration_contact", + "status": "partial", + "source_locator": "Section 7, penalty contact is not penetration-free", + "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_contact.cpp"], + "blockers": ["The limitation lacks a quantified paper-shaped penetration/error envelope on CPU and CUDA."] + }, + { + "id": "vbd.limit.codimensional_self_collision", + "status": "partial", + "source_locator": "Section 7, codimensional self-collision limitation", + "evidence": ["docs/plans/104-vertex-block-descent-solver/vbd-paper-gap-audit.md"], + "blockers": ["The limitation is documented, but source-equivalent cloth/self-collision boundary tests and visual evidence are incomplete."] + } + ] + }, + { + "id": "paper_figures", + "kind": "paper_figure", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + {"id": "vbd.paper.fig.01", "status": "missing", "source_locator": "Figure 1, PDF page 1: final 216-ball teapot and 10,368-object box piles", "evidence": [], "blockers": ["Neither headline large-scale scene has a current DART reproduction or matched RTX 4090 packet."]}, + {"id": "vbd.paper.fig.02", "status": "missing", "source_locator": "Figure 2, PDF page 2: 97K-vertex twisting beams with frictional contact", "evidence": [], "blockers": ["No source-shaped twisting-beam scene, quantitative oracle, visual packet, or matched performance row exists."]}, + {"id": "vbd.paper.fig.03", "status": "missing", "source_locator": "Figure 3, PDF page 2: flattened armadillo and randomized teapot recovery", "evidence": [], "blockers": ["The extreme-initialization meshes, trajectories, and recovery oracles are not reproduced."]}, + {"id": "vbd.paper.fig.04", "status": "partial", "source_locator": "Figure 4, PDF page 5: collision coloring bounds", "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_vertex_coloring.cpp"], "blockers": ["Focused coloring tests do not reproduce the paper edge-edge and vertex-triangle collision cases on both backends."]}, + {"id": "vbd.paper.fig.05", "status": "partial", "source_locator": "Figure 5, PDF page 6: four initialization choices versus Newton reference", "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_acceleration.cpp"], "blockers": ["The five-way swinging-tet-pendulum trajectory and Newton comparison are missing."]}, + {"id": "vbd.paper.fig.06", "status": "partial", "source_locator": "Figure 6, PDF page 6: adaptive gravity-factor visualization", "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_acceleration.cpp"], "blockers": ["No source mesh, per-vertex gravity-factor field, rendered comparison, or matched timing exists."]}, + {"id": "vbd.paper.fig.07", "status": "partial", "source_locator": "Figure 7, PDF page 7: accelerated 230K-vertex collision-rich ball", "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_acceleration.cpp"], "blockers": ["The 120-iteration accelerated/non-accelerated/reference scene and performance packet are missing."]}, + {"id": "vbd.paper.fig.08", "status": "partial", "source_locator": "Figure 8, PDF page 7: vertex versus element coloring counts", "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_vertex_coloring.cpp"], "blockers": ["The exact triangle/tetrahedral meshes and reported 3/8/8/76 color comparison are not reproduced."]}, + {"id": "vbd.paper.fig.09", "status": "partial", "source_locator": "Figure 9, PDF page 8: collision handling with and without recoloring", "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_parallel_block_descent.cpp"], "blockers": ["No matched source scene, trajectory-difference oracle, visual pair, or performance comparison exists."]}, + {"id": "vbd.paper.fig.10", "status": "missing", "source_locator": "Figure 10, PDF page 8: tearing cloth with 2,500 vertices and 4,800 triangles", "evidence": [], "blockers": ["DART has no VBD tearing topology implementation or source-equivalent CPU packet."]}, + {"id": "vbd.paper.fig.11", "status": "missing", "source_locator": "Figure 11, PDF page 9: 216 squishy balls in a Utah teapot", "evidence": [], "blockers": ["The 48M-vertex/151M-tet scene and 3.6/3.9-second RTX 4090 row are missing."]}, + {"id": "vbd.paper.fig.12", "status": "missing", "source_locator": "Figure 12, PDF page 9: 10,368 deformable objects and removable platform", "evidence": [], "blockers": ["The 36M-vertex/124M-tet scene, platform event, and 4.2/4.7-second RTX 4090 row are missing."]}, + {"id": "vbd.paper.fig.13", "status": "partial", "source_locator": "Figure 13, PDF page 9: beam visual convergence across stiffness and iteration counts", "evidence": ["python/examples/demos/scenes/vbd_beam.py"], "blockers": ["The 463-vertex tet beam, three stiffness rows, Newton goldens, and reported timing sweep are not matched."]}, + {"id": "vbd.paper.fig.14", "status": "partial", "source_locator": "Figure 14, PDF page 10: incline and octopus friction-coefficient sweeps", "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_contact.cpp"], "blockers": ["No source-equivalent elastic cube/octopus sweep with quantitative and rendered CPU/CUDA evidence exists."]}, + {"id": "vbd.paper.fig.15", "status": "missing", "source_locator": "Figure 15, PDF page 11: extreme Stanford bunny stretch and release", "evidence": [], "blockers": ["The source mesh, pull/release schedule, self-collision outcome, and timing packet are missing."]}, + {"id": "vbd.paper.fig.16", "status": "missing", "source_locator": "Figure 16, PDF page 11: one-iteration armadillo stability test", "evidence": [], "blockers": ["The constrained armadillo scene and one-iteration stability trajectory are not reproduced."]}, + {"id": "vbd.paper.fig.17", "status": "partial", "source_locator": "Figure 17, PDF page 12: GPU convergence versus GD, Block Jacobi, Newton, and quasi-Newton", "evidence": ["tests/benchmark/simulation/bm_vbd_world_solver.cpp"], "blockers": ["The exact soft/stiff armadillo workloads, every baseline, accuracy metric, and matched GPU timing are incomplete."]}, + {"id": "vbd.paper.fig.18", "status": "partial", "source_locator": "Figure 18, PDF page 13: single-thread CPU VBD versus Newton convergence", "evidence": ["tests/benchmark/simulation/bm_vbd_block_descent.cpp"], "blockers": ["No matched armadillo workload or controlled single-thread Newton comparison packet exists."]}, + {"id": "vbd.paper.fig.19", "status": "missing", "source_locator": "Figure 19, PDF page 13: XPBD timestep/collision-frequency comparison", "evidence": [], "blockers": ["The four-way 230K-vertex squishy-ball comparison and source performance claims are missing."]}, + {"id": "vbd.paper.fig.20", "status": "partial", "source_locator": "Figure 20, PDF page 13: 1:2000 elastic-cube mass-ratio collision", "evidence": ["python/examples/demos/scenes/vbd_obstacle_drape.py"], "blockers": ["No source-equivalent two-cube VBD/XPBD outcome and timing comparison exists."]}, + {"id": "vbd.paper.fig.21", "status": "partial", "source_locator": "Figure 21, PDF page 14: 20-particle 1:1000 tilted strand", "evidence": ["python/examples/demos/scenes/vbd_tilted_strand.py"], "blockers": ["The narrow TinyVBD-shaped CPU scene lacks full paper visual, CUDA, Gaia, and matched-accuracy evidence."]}, + {"id": "vbd.paper.fig.22", "status": "missing", "source_locator": "Figure 22, PDF page 14: five 6-DOF rigid bodies in a collision chain", "evidence": [], "blockers": ["No DART VBD rigid-chain paper scene or validated rigid VBD solver identity exists."]}, + {"id": "vbd.paper.fig.23", "status": "missing", "source_locator": "Figure 23, PDF page 14: 60 rigid bodies dropped into a Utah teapot", "evidence": [], "blockers": ["No DART VBD rigid-contact teapot scene, friction outcome, or performance packet exists."]}, + {"id": "vbd.paper.fig.24", "status": "partial", "source_locator": "Figure 24, PDF page 15: 1:10,000 stiffness-ratio VBD limitation", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["The exact VBD 100-iteration and converged comparison is not reproduced on CPU and CUDA."]} + ] + }, + { + "id": "paper_tables", + "kind": "paper_table", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + { + "id": "vbd.paper.table.01", + "status": "partial", + "source_locator": "Table 1, PDF page 12: 13 experiment configurations and average/maximum timings", + "evidence": ["docs/plans/104-vertex-block-descent-solver/vbd-paper-gap-audit.md"], + "blockers": ["No complete matched-scene, matched-accuracy, same-hardware table exists; current smoke numbers are not paper-comparable."] + } + ] + }, + { + "id": "paper_video", + "kind": "paper_video_segment", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + {"id": "vbd.video.01_title", "status": "missing", "source_locator": "00:00.000-00:06.600 title card", "source_seconds": [0, 6.6], "evidence": [], "blockers": ["No approved DART replacement title/edit exists."]}, + {"id": "vbd.video.02_216_squishy_balls", "status": "missing", "source_locator": "00:06.600-00:42.733 216 squishy balls in teapot", "source_seconds": [6.6, 42.733], "evidence": [], "blockers": ["No full DART scene, trajectory, source-matched edit, or comparable timing exists."]}, + {"id": "vbd.video.03_10368_models", "status": "missing", "source_locator": "00:42.733-01:26.800 10,368 mixed models and removable platform", "source_seconds": [42.733, 86.8], "evidence": [], "blockers": ["No full DART scene, platform event, source-matched edit, or comparable timing exists."]}, + {"id": "vbd.video.04_twisting_beams", "status": "missing", "source_locator": "01:26.800-01:48.300 twisting-beam stress test", "source_seconds": [86.8, 108.3], "evidence": [], "blockers": ["No source-shaped DART twisting-beam trajectory or current-build edit exists."]}, + {"id": "vbd.video.05_random_initialization", "status": "missing", "source_locator": "01:48.300-02:02.800 randomized-teapot recovery", "source_seconds": [108.3, 122.8], "evidence": [], "blockers": ["No source mesh, randomized state, recovery oracle, or current-build edit exists."]}, + {"id": "vbd.video.06_flattening_initialization", "status": "missing", "source_locator": "02:02.800-02:22.033 flattened-armadillo recovery", "source_seconds": [122.8, 142.033], "evidence": [], "blockers": ["No source mesh, flattened state, recovery oracle, or current-build edit exists."]}, + {"id": "vbd.video.07_large_residual", "status": "missing", "source_locator": "02:22.033-02:34.933 one-iteration large-residual stability", "source_seconds": [142.033, 154.933], "evidence": [], "blockers": ["No one-iteration armadillo reproduction or validated DART edit exists."]}, + {"id": "vbd.video.08_extreme_stretch", "status": "missing", "source_locator": "02:34.933-02:55.900 extreme bunny stretch/release", "source_seconds": [154.933, 175.9], "evidence": [], "blockers": ["No source-shaped bunny schedule, self-collision outcome, or validated DART edit exists."]}, + {"id": "vbd.video.09_elasticity_convergence", "status": "partial", "source_locator": "02:55.900-03:16.033 elasticity convergence grid", "source_seconds": [175.9, 196.033], "evidence": ["python/examples/demos/scenes/vbd_beam.py"], "blockers": ["The exact three-stiffness/four-iteration visual grid and Newton references are missing."]}, + {"id": "vbd.video.10_incline_friction", "status": "partial", "source_locator": "03:16.033-03:26.967 incline friction-coefficient sweep", "source_seconds": [196.033, 206.967], "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_contact.cpp"], "blockers": ["No source-equivalent elastic-cube sweep or validated current-build edit exists."]}, + {"id": "vbd.video.11_octopus_friction", "status": "partial", "source_locator": "03:26.967-03:42.533 octopus friction/stacking sweep", "source_seconds": [206.967, 222.533], "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_contact.cpp"], "blockers": ["No octopus meshes, source trajectory, or validated current-build edit exists."]}, + {"id": "vbd.video.12_damping", "status": "partial", "source_locator": "03:42.533-03:58.067 damping-coefficient comparison", "source_seconds": [222.533, 238.067], "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_acceleration.cpp"], "blockers": ["No source-shaped damping sweep, energy-decay oracle, or validated current-build edit exists."]}, + {"id": "vbd.video.13_topology_change", "status": "missing", "source_locator": "03:58.067-04:10.367 tearing/topology-change segment", "source_seconds": [238.067, 250.367], "evidence": [], "blockers": ["DART has no VBD tearing implementation or matching edit."]}, + {"id": "vbd.video.14_collision_recoloring", "status": "partial", "source_locator": "04:10.367-04:18.933 collision handling with/without recoloring", "source_seconds": [250.367, 258.933], "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_parallel_block_descent.cpp"], "blockers": ["The source comparison, quantitative difference bound, and validated visual pair are missing."]}, + {"id": "vbd.video.15_xpbd_mass_ratio_collision", "status": "missing", "source_locator": "04:18.933-04:38.167 VBD/XPBD 1:2000 collision", "source_seconds": [258.933, 278.167], "evidence": [], "blockers": ["No matched XPBD baseline, source scene, outcome oracle, or current-build edit exists."]}, + {"id": "vbd.video.16_xpbd_timestep", "status": "missing", "source_locator": "04:38.167-04:51.733 XPBD timestep/collision-frequency comparison", "source_seconds": [278.167, 291.733], "evidence": [], "blockers": ["No four-way matched XPBD/VBD comparison or validated current-build edit exists."]}, + {"id": "vbd.video.17_particle_mass_ratio", "status": "partial", "source_locator": "04:51.733-05:03.633 particle chain with 1:1000 mass ratio", "source_seconds": [291.733, 303.633], "evidence": ["python/examples/demos/scenes/vbd_tilted_strand.py"], "blockers": ["The DART strand is narrow CPU evidence and lacks the exact edit, CUDA path, and source-equivalent outcome."]}, + {"id": "vbd.video.18_rigid_chain", "status": "missing", "source_locator": "05:03.633-05:15.533 five-body rigid chain", "source_seconds": [303.633, 315.533], "evidence": [], "blockers": ["No VBD rigid-chain reproduction or validated current-build edit exists."]}, + {"id": "vbd.video.19_rigid_teapot", "status": "missing", "source_locator": "05:15.533-05:31.000 60 rigid bodies in teapot", "source_seconds": [315.533, 331], "evidence": [], "blockers": ["No VBD rigid-contact teapot reproduction or validated current-build edit exists."]} + ] + }, + { + "id": "gaia_dynamics", + "kind": "reference_scene", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + {"id": "vbd.gaia.dynamics.hybrid_64_experiment", "status": "missing", "source_locator": "S01_Experiment_HybridModelsDrop_sequentiallyAppear/Test_64HybridModels_2steps_40Iters_stiff_Friction", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.hybrid_64", "status": "missing", "source_locator": "S01_HybridModels_32_Drop_sequentiallyAppear/Test_64HybridModels_2steps_40Iters_stiff_Friction", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.drop_two_squishy_balls", "status": "missing", "source_locator": "S02_Drop2SquishyBallLowRes/Test_2Ball_step10_iter10_0.0ColThickness_GPU_newImpl_run02", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.twist_two_beams", "status": "missing", "source_locator": "S03_Twist2Beams/TestThinBeam2Twisting_cpu_5e3_1e5_2e4_iter20_damping1e-06_24s", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.many_hybrid_models", "status": "missing", "source_locator": "S04_HybridModels_Many_Drop_sequential/Test_Many_HybridModels_2steps_80Iters_sequential", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.drop_216_squishy_balls", "status": "missing", "source_locator": "S05_SquishyBalls_Many_DropToTeapot_sequential/Test_216Ball_step5_iter40_largerVDis_squentialRelease", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.friction_cube_mu_0_1", "status": "missing", "source_locator": "S06_TestFriction_2Cube_GPU/TestFriction_2Cube_friction_0.1_Iter10", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.friction_cube_mu_0_5", "status": "missing", "source_locator": "S06_TestFriction_2Cube_GPU/TestFriction_2Cube_friction_0.5_Iter10", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.friction_cube_mu_0_6", "status": "missing", "source_locator": "S06_TestFriction_2Cube_GPU/TestFriction_2Cube_friction_0.6", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.friction_cube_mu_0_9", "status": "missing", "source_locator": "S06_TestFriction_2Cube_GPU/TestFriction_2Cube_friction_0.9", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.damping_beam_0", "status": "missing", "source_locator": "S07_TestDamping_Beam_GPU/Test_Beam_2e-5_0", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.damping_beam_2e_5", "status": "missing", "source_locator": "S07_TestDamping_Beam_GPU/Test_Beam_2e-5_2e-5", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.damping_beam_5e_5", "status": "missing", "source_locator": "S07_TestDamping_Beam_GPU/Test_Beam_5e-5_5e-5", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.dynamics.random_teapot", "status": "missing", "source_locator": "S08_TeapotRandomInitialize/Test_1_teapot_randomInitialization_damping_0", "evidence": [], "blockers": ["No exact DART port or matched Gaia packet exists."]} + ] + }, + { + "id": "gaia_cloth", + "kind": "reference_scene", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + {"id": "vbd.gaia.cloth.twist_c100", "status": "missing", "source_locator": "S01_Twisting/C100_Twist_ContactR0.2_CStif100000.0_bd20_damping3e-05_relaxiation0.42_thick0.01_CDPerIter", "evidence": [], "blockers": ["No exact DART cloth port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.cloth.twist_c50", "status": "missing", "source_locator": "S01_Twisting/C50_Twist_ContactR0.2_CStif100000.0_bd20_damping3e-05_relaxiation0.42", "evidence": [], "blockers": ["No exact DART cloth port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.cloth.twist_c50_thick", "status": "missing", "source_locator": "S01_Twisting/C50_Twist_ContactR0.2_CStif100000.0_bd20_damping3e-05_relaxiation0.42_thick0.01_CDPerIter", "evidence": [], "blockers": ["No exact DART cloth port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.cloth.knot_pulling", "status": "missing", "source_locator": "S02_KnotPulling/Knot_ContactR0.2_CStif1000000.0_bd50_damping1e-07_relaxiation0.42_pull10", "evidence": [], "blockers": ["No exact DART cloth port or matched Gaia packet exists."]}, + {"id": "vbd.gaia.cloth.multilayer_collider", "status": "missing", "source_locator": "S03_MultiLayerClothOnCollider/C70_CR0.3_Cs100000.0_bd10.0_step20_iters15_init2_accel0.0_damp2e-06_l5_fix_VBD_run2", "evidence": [], "blockers": ["No exact DART cloth port or matched Gaia packet exists."]} + ] + }, + { + "id": "tinyvbd", + "kind": "reference_scene", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + { + "id": "vbd.tinyvbd.tilted_strand", + "status": "partial", + "source_locator": "TinyVBD main.cpp default tilted-strand scene", + "evidence": [ + "python/examples/demos/scenes/vbd_tilted_strand.py", + "tests/benchmark/simulation/bm_vbd_world_solver.cpp" + ], + "blockers": ["DART has a narrow CPU reproduction and smoke comparison, but no current validated visual packet, CUDA comparison, or matched full claim."] + } + ] + }, + { + "id": "project_page", + "kind": "project_surface", + "required_backends": ["cpu", "cuda"], + "required_predicates": [ + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid" + ], + "requirements": [ + { + "id": "vbd.project.teaser", + "status": "missing", + "source_locator": "Project-page teaser.mp4, 00:00.000-00:23.367: 216-ball teapot and 10,368-model headline scenes with embedded timing", + "source_seconds": [0, 23.366667], + "evidence": [], + "blockers": ["No source-matched DART teaser edit, full headline scenes, current-build media, or comparable embedded timing exists."] + }, + { + "id": "vbd.project.random_teapot_stability", + "status": "missing", + "source_locator": "Project-page teapot-small.gif, 2.72 seconds: randomized deformable teapot recovery", + "source_seconds": [0, 2.72], + "evidence": [], + "blockers": ["No source mesh, randomized state, quantitative recovery oracle, or source-matched DART GIF exists."] + }, + { + "id": "vbd.project.flattened_armadillo_stability", + "status": "missing", + "source_locator": "Project-page armadillo-small.gif, 3.28 seconds: flattened deformable armadillo recovery", + "source_seconds": [0, 3.28], + "evidence": [], + "blockers": ["No source mesh, flattened state, quantitative recovery oracle, or source-matched DART GIF exists."] + } + ] + } + ] +} diff --git a/docs/plans/104-vertex-block-descent-solver/vbd-paper-gap-audit.md b/docs/plans/104-vertex-block-descent-solver/vbd-paper-gap-audit.md index 3b60141d5010b..74c675ef63403 100644 --- a/docs/plans/104-vertex-block-descent-solver/vbd-paper-gap-audit.md +++ b/docs/plans/104-vertex-block-descent-solver/vbd-paper-gap-audit.md @@ -1,26 +1,34 @@ # VBD Paper / Reference Gap Audit This audit grounds PLAN-104 against the Vertex Block Descent paper and its -reference implementations. It records what the method requires, what the DART -DART 7 simulation world already provides, and what each phase must add. It is a +reference implementations. It records what the method requires, what DART 7's +simulation world already provides, and what each phase must add. It is a planning artifact; it does not itself claim implemented behavior. +The authoritative, machine-checked parity inventory is +[`vbd-paper-coverage-contract.json`](vbd-paper-coverage-contract.json), and its +fail-closed completion semantics are defined by +[`paper-parity-matrix.md`](paper-parity-matrix.md). This prose audit explains +the method and gaps but cannot promote a contract row. + ## Sources -- Chen, A. H., Liu, T., Yang, Y., Kim, T., et al. "Vertex Block Descent." +- Anka He Chen, Ziheng Liu, Yin Yang, and Cem Yuksel. "Vertex Block Descent." _ACM Transactions on Graphics (SIGGRAPH 2024)_. Paper PDF: - Project page: + Project page: Talk: - Reference implementations: - - `Gaia`: (full research framework) - - `TinyVBD`: (minimal reference) + - `Gaia`: at + `c229692045465a76233f9fba9197fb22bbfb3694` (full research framework) + - `TinyVBD`: at + `dcd011a5d945172e247ecced90a6c2c4b4313520` (minimal reference) The formulas and numbers below were verified against the arXiv full text -(arXiv:2403.06321), the project page, and the `Gaia`/`TinyVBD` reference source. -Code references are to those repositories' `main` branch. This is the durable -method audit for PLAN-104; implementation status and remaining work are -summarized in the parent plan and dashboard. +(arXiv:2403.06321), the project page, and the pinned `Gaia`/`TinyVBD` reference +source revisions above. This is the durable method audit for PLAN-104; +implementation status and remaining work are summarized in the parent plan and +dashboard. ## Verified Reference Details (load-bearing) @@ -96,10 +104,11 @@ x_i <- x_i + H_i^{-1} f_i where `N(i)` is the set of elements (springs / tets / contacts) incident to vertex `i`. `H_i` is a single symmetric 3x3 matrix, so each block solve is a -fixed-size SPD solve — no global sparse system. Robustness comes from keeping -`H_i` SPD: the inertia term `(m_i/h^2) I_3` is always SPD, and each element block -is PSD-projected (e.g. clamping indefinite geometric-stiffness terms) so the sum -is SPD. +fixed-size local solve — no global sparse system. In the reference default, +element blocks are **not** PSD-projected. Robustness relies on the SPD inertia +term and the stabilized local 3x3 solve described above; any DART projection or +regularization is an explicitly tested descent-path choice rather than an +unqualified paper-default claim. ### Parallelization by graph coloring @@ -112,21 +121,24 @@ per timestep. ### Acceleration and initialization -VBD reaches good accuracy in few iterations via (a) an **adaptive initial -guess** that blends the inertial prediction with the previous step's -acceleration, and (b) an optional **Chebyshev semi-iterative acceleration** of -the block-descent sweeps. The exact blend rule and Chebyshev parameters are to -be transcribed from the paper during Phase 5. +VBD reaches good accuracy in few iterations via (a) the **adaptive initial +guess** recorded in `Verified Reference Details` and (b) the optional +**Chebyshev semi-iterative acceleration** recorded there. Those equations are +transcribed and focused tests exist; the paper figures and full CPU/CUDA +trajectory closure remain open in the coverage contract. ## Initial Component-by-Component Gap +The table below is the historical pre-#2781 implementation baseline. Use the +coverage contract, not this table, for current status. + | VBD component | DART 7 simulation world today | Phase that closes the gap | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------- | | Variational implicit-Euler objective `G(x)` | Present: `evaluateDeformableObjective` already assembles inertia + spring (+ barrier) energy/gradient | Phase 1 reuses the same objective | | Inertial target `y` with gravity/damping/external accel | Present: `world_step_stage.cpp` inertial-target setup (`dampingScale`, `gravityStep`, `externalAccelerations`) | Phase 1/3 reuse | | Per-vertex force `f_i` (inertia + spring) | Partial: global gradient assembled, not per-vertex blocks | Phase 1 | | Per-vertex SPD Hessian `H_i` (inertia + spring) | Missing: no per-vertex Hessian assembly | Phase 1 | -| PD projection of element Hessian blocks | Missing | Phase 1 (spring), Phase 4 (FEM) | +| Stabilized local Hessian solve without default projection | Present in narrow kernels; reference-equivalent failure policy and full-scene evidence remain open | Coverage-contract method rows | | 3x3 block Newton step | Missing | Phase 1 | | Vertex graph coloring | Missing | Phase 2 | | Block-descent sweep driver | Missing (current solver is global gradient descent) | Phase 3 | @@ -146,11 +158,12 @@ be transcribed from the paper during Phase 5. `evaluateDeformableObjective`). Per-vertex Hessian block `H_aa = k[ n n^T + (1 - L/l)(I - n n^T) ]`, transverse factor clamped to `>= 0` for the SPD local Hessian. -- **Stable Neo-Hookean** (Phase 4): the Smith et al. 2018 energy density used by - the reference, with Lame parameters derived from - `DeformableMaterial.youngsModulus` / `poissonRatio`, plus the per-tet, - per-vertex force and PD-projected Hessian block. Exact constants transcribed - during Phase 4. +- **Stable Neo-Hookean** (Phase 4): the reference energy is + `Psi = (mu/2)(||F||_F^2 - 3) + (lambda/2)(det(F) - a)^2` with + `a = 1 + mu/lambda`, multiplied by rest volume. DART derives the Lamé + parameters from `DeformableMaterial.youngsModulus` / `poissonRatio`; any + stabilized Hessian treatment must stay explicitly distinguished from the + reference's default no-projection path. ## Reference Performance Targets ("beat the reference / paper") diff --git a/docs/plans/dashboard.md b/docs/plans/dashboard.md index 385569b223b24..859f525503dca 100644 --- a/docs/plans/dashboard.md +++ b/docs/plans/dashboard.md @@ -229,16 +229,21 @@ its own line so status updates remain git-history friendly. [`104-vertex-block-descent-solver/avbd-paper-gap-audit.md`](104-vertex-block-descent-solver/avbd-paper-gap-audit.md) toward AVBD hard constraints, bounded contact/friction, finite-stiffness ramping, rigid/articulated blocks, all paper/demo scenes, and CPU/GPU benchmark - parity, or implement the next missing row from - [`104-vertex-block-descent-solver/avbd-demo-corpus.md`](104-vertex-block-descent-solver/avbd-demo-corpus.md). + parity. Select the next missing mechanism or reproduction from the + machine-checked 176-row + [`104-vertex-block-descent-solver/paper-parity-matrix.md`](104-vertex-block-descent-solver/paper-parity-matrix.md); + the older + [`104-vertex-block-descent-solver/avbd-demo-corpus.md`](104-vertex-block-descent-solver/avbd-demo-corpus.md) + remains a narrow artifact inventory rather than the global completion gate. Remaining VBD closeout (self-contact tangential friction, OGC audit, benchmark JSON, paper tetrahedral reproduction, Phase 8b SoA + Gaia-CPU, Phase 9 same-GPU Table 1) stays tracked in the owner plan. History: see the progress log in [`104-vertex-block-descent-solver.md`](104-vertex-block-descent-solver.md). - Gate: VBD progress is not complete until the implementation distinguishes each internal kernel slice from a wired solver, keeps VBD naming - backend-neutral, proves per-vertex force/Hessian correctness, PD Hessian - guarantees, coloring conflict-freedom, and convergence parity with the + backend-neutral, proves per-vertex force/Hessian correctness, an explicit + reference-aligned Hessian stabilization policy, coloring conflict-freedom, + and convergence parity with the existing solver and the reference implementations, adds FEM/acceleration/ contact/friction with focused tests, records CPU and GPU benchmark/profiling JSON that beats the reference and/or paper numbers before any parity claim, diff --git a/docs/readthedocs/papers.md b/docs/readthedocs/papers.md index 11fa9d0a6d65d..97b0b801e7a7a 100644 --- a/docs/readthedocs/papers.md +++ b/docs/readthedocs/papers.md @@ -629,10 +629,13 @@ _ACM Transactions on Graphics (SIGGRAPH 2024)_, 43(4), 2024. DOI: Related public resources: - Paper PDF: -- Project page: +- Project page: - Talk: -- Reference implementations: [AnkaChan/Gaia](https://github.com/AnkaChan/Gaia), - [AnkaChan/TinyVBD](https://github.com/AnkaChan/TinyVBD) +- Reference implementations: + [AnkaChan/Gaia](https://github.com/AnkaChan/Gaia) at + `c229692045465a76233f9fba9197fb22bbfb3694` and + [AnkaChan/TinyVBD](https://github.com/AnkaChan/TinyVBD) at + `dcd011a5d945172e247ecced90a6c2c4b4313520` ```bib @article{Chen2024VBD, @@ -648,11 +651,12 @@ Related public resources: - **Type:** paper · **Topic:** integration/deformable · **Status:** in-progress · **Priority:** high · **Verdict:** adopt - **Where used:** [`PLAN-104`](https://github.com/dartsim/dart/blob/main/docs/plans/104-vertex-block-descent-solver.md) - and its [VBD paper gap audit](https://github.com/dartsim/dart/blob/main/docs/plans/104-vertex-block-descent-solver/vbd-paper-gap-audit.md). + and its [VBD paper gap audit](https://github.com/dartsim/dart/blob/main/docs/plans/104-vertex-block-descent-solver/vbd-paper-gap-audit.md) + and [paper-parity contract](https://github.com/dartsim/dart/blob/main/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md). - **Notes:** Block coordinate descent on the variational implicit-Euler objective for elastodynamics: each vertex is a 3-DOF block updated with one - regularized Newton step using a positive-definite local 3x3 Hessian, with - graph-colored parallel Gauss-Seidel sweeps and adaptive + Newton step using a stabilized local 3x3 solve, with graph-colored parallel + Gauss-Seidel sweeps and adaptive initialization/Chebyshev acceleration. DART targets it as a second deformable inner solver on the same DART 7 deformable ECS components and the same variational objective the current gradient-descent stage already minimizes. @@ -735,9 +739,15 @@ Related public resources: [graphics.cs.utah.edu/research/projects/avbd](https://graphics.cs.utah.edu/research/projects/avbd/) - Paper PDF: [Augmented_VBD-SIGGRAPH25.pdf](https://graphics.cs.utah.edu/research/projects/avbd/Augmented_VBD-SIGGRAPH25.pdf) +- Official project video: + [Augmented Vertex Block Descent](https://www.youtube.com/watch?v=bwJgifqvd5M) +- Associated Real-Time Live extended abstract: + [Crazy Fast Physics! Augmented Vertex Block Descent in Action!](https://graphics.cs.utah.edu/research/projects/avbd/Augmented_VBD-SIGGRAPH25_RTL.pdf) - Reference demo sources: - [avbd-demo2d](https://github.com/savant117/avbd-demo2d) and - [avbd-demo3d](https://github.com/savant117/avbd-demo3d) + [avbd-demo2d](https://github.com/savant117/avbd-demo2d) at + `74699a11f8586d3ac34474c92b1ef8feb5f587de` and + [avbd-demo3d](https://github.com/savant117/avbd-demo3d) at + `7701bd427d55ca5d03ea1fdf331912ded9169f4b` - Online demos: [2D demo](https://graphics.cs.utah.edu/research/projects/avbd/avbd_demo2d.html) and @@ -760,6 +770,8 @@ Related public resources: - **Where used:** [`PLAN-104`](https://github.com/dartsim/dart/blob/main/docs/plans/104-vertex-block-descent-solver.md) and its [AVBD paper gap audit](https://github.com/dartsim/dart/blob/main/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md); + its [paper-parity contract](https://github.com/dartsim/dart/blob/main/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md) + owns the canonical source-surface rows; also informs the PLAN-084 contact-extension survey. - **Notes:** Adopted as the hard-constraint extension of DART's VBD-family solver work: augmented-Lagrangian dual rows, bounded equality/inequality @@ -1355,7 +1367,8 @@ Gazebo / gz-physics / SDFormat. ### `gaia` -Gaia VBD research framework. +Gaia VBD research framework. , pinned for +PLAN-104 parity work at `c229692045465a76233f9fba9197fb22bbfb3694`. - **Type:** engine · **Topic:** comparative-implementation · **Status:** referenced · **Priority:** — · **Verdict:** baseline - **Where used:** VBD correctness and performance comparison under @@ -1365,7 +1378,8 @@ Gaia VBD research framework. ### `tinyvbd` -TinyVBD. +TinyVBD. , pinned for PLAN-104 parity work +at `dcd011a5d945172e247ecced90a6c2c4b4313520`. - **Type:** engine · **Topic:** comparative-implementation · **Status:** referenced · **Priority:** — · **Verdict:** baseline - **Where used:** minimal VBD algorithm comparison under diff --git a/pixi.toml b/pixi.toml index e7857d661e62c..69398ae983ba4 100644 --- a/pixi.toml +++ b/pixi.toml @@ -295,6 +295,10 @@ lint-plan122-allocation-matrix = { cmd = [ "scripts/check_plan122_allocation_matrix.py", ] } lint-avbd-packets = { cmd = ["python", "scripts/check_avbd_packets.py"] } +lint-plan104-paper-parity = { cmd = [ + "python", + "scripts/check_plan104_paper_parity.py", +] } lint-architecture-page = { cmd = [ "python", "scripts/check_architecture_page_lint.py", @@ -333,6 +337,7 @@ lint = { depends-on = [ "lint-lcp-solver-roster", "lint-plan122-allocation-matrix", "lint-avbd-packets", + "lint-plan104-paper-parity", "lint-architecture-page", "sync-ai-commands", ] } @@ -438,6 +443,10 @@ check-plan122-allocation-matrix = { cmd = [ "scripts/check_plan122_allocation_matrix.py", ] } check-avbd-packets = { cmd = ["python", "scripts/check_avbd_packets.py"] } +check-plan104-paper-parity = { cmd = [ + "python", + "scripts/check_plan104_paper_parity.py", +] } check-architecture-page = { cmd = [ "python", "scripts/check_architecture_page_lint.py", @@ -518,6 +527,7 @@ check-lint = { depends-on = [ "check-lcp-solver-roster", "check-plan122-allocation-matrix", "check-avbd-packets", + "check-plan104-paper-parity", "check-architecture-page", "check-ai-commands", "check-ai-infra", diff --git a/scripts/check_plan104_paper_parity.py b/scripts/check_plan104_paper_parity.py new file mode 100644 index 0000000000000..5d3bfe85b9f3a --- /dev/null +++ b/scripts/check_plan104_paper_parity.py @@ -0,0 +1,1204 @@ +#!/usr/bin/env python3 +"""Validate PLAN-104's fail-closed VBD and AVBD paper-parity contracts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import sys +from collections import Counter +from datetime import date +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parent +CONTRACT_DIR = REPO_ROOT / "docs" / "plans" / "104-vertex-block-descent-solver" +MATRIX_PATH = CONTRACT_DIR / "paper-parity-matrix.md" +DEFAULT_CONTRACTS = ( + CONTRACT_DIR / "vbd-paper-coverage-contract.json", + CONTRACT_DIR / "avbd-paper-coverage-contract.json", +) + +ALLOWED_STATUSES = frozenset({"missing", "partial", "blocked", "complete"}) +KNOWN_PREDICATES = ( + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid", +) +METHOD_PREDICATES = ( + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "performance_comparable", + "claim_valid", +) +VISUAL_PREDICATES = ( + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "media_decoded", + "performance_comparable", + "claim_valid", +) +TABLE_PREDICATES = ( + "artifact_valid", + "solver_contract_valid", + "physical_outcome_valid", + "manual_inspected", + "performance_comparable", + "claim_valid", +) +REQUIRED_BACKENDS = ("cpu", "cuda") +MATRIX_LABELS = { + "vbd": { + "method": "VBD method", + "limitations": "VBD limitations", + "paper_figures": "VBD paper figures", + "paper_tables": "VBD paper tables", + "paper_video": "VBD official video", + "gaia_dynamics": "VBD Gaia dynamics", + "gaia_cloth": "VBD Gaia cloth", + "tinyvbd": "VBD TinyVBD", + "project_page": "VBD project page", + }, + "avbd": { + "method": "AVBD method", + "paper_figures": "AVBD paper figures", + "paper_tables": "AVBD paper tables", + "paper_video": "AVBD official video", + "demo2d": "AVBD demo2d", + "demo3d": "AVBD demo3d", + "project_page": "AVBD project page", + }, +} + + +def _numbered_ids(prefix: str, count: int) -> tuple[str, ...]: + return tuple(f"{prefix}.{index:02d}" for index in range(1, count + 1)) + + +VBD_GROUPS = ( + ( + "method", + "method", + METHOD_PREDICATES, + tuple( + f"vbd.method.{name}" + for name in ( + "variational_backward_euler", + "local_vertex_block_newton", + "local_spd_solve", + "stable_neo_hookean", + "rayleigh_damping", + "soft_constraints", + "penalty_contact", + "lagged_friction", + "adaptive_initialization", + "chebyshev_acceleration", + "vertex_coloring", + "dynamic_collision_double_buffer", + "topology_change", + "gpu_hierarchical_kernel", + "particle_blocks", + "rigid_six_dof_blocks", + "unified_simulation", + ) + ), + ), + ( + "limitations", + "limitation", + VISUAL_PREDICATES, + tuple( + f"vbd.limit.{name}" + for name in ( + "local_information_propagation", + "high_stiffness_ratio", + "penetration_contact", + "codimensional_self_collision", + ) + ), + ), + ( + "paper_figures", + "paper_figure", + VISUAL_PREDICATES, + _numbered_ids("vbd.paper.fig", 24), + ), + ("paper_tables", "paper_table", TABLE_PREDICATES, ("vbd.paper.table.01",)), + ( + "paper_video", + "paper_video_segment", + VISUAL_PREDICATES, + tuple( + f"vbd.video.{name}" + for name in ( + "01_title", + "02_216_squishy_balls", + "03_10368_models", + "04_twisting_beams", + "05_random_initialization", + "06_flattening_initialization", + "07_large_residual", + "08_extreme_stretch", + "09_elasticity_convergence", + "10_incline_friction", + "11_octopus_friction", + "12_damping", + "13_topology_change", + "14_collision_recoloring", + "15_xpbd_mass_ratio_collision", + "16_xpbd_timestep", + "17_particle_mass_ratio", + "18_rigid_chain", + "19_rigid_teapot", + ) + ), + ), + ( + "gaia_dynamics", + "reference_scene", + VISUAL_PREDICATES, + tuple( + f"vbd.gaia.dynamics.{name}" + for name in ( + "hybrid_64_experiment", + "hybrid_64", + "drop_two_squishy_balls", + "twist_two_beams", + "many_hybrid_models", + "drop_216_squishy_balls", + "friction_cube_mu_0_1", + "friction_cube_mu_0_5", + "friction_cube_mu_0_6", + "friction_cube_mu_0_9", + "damping_beam_0", + "damping_beam_2e_5", + "damping_beam_5e_5", + "random_teapot", + ) + ), + ), + ( + "gaia_cloth", + "reference_scene", + VISUAL_PREDICATES, + tuple( + f"vbd.gaia.cloth.{name}" + for name in ( + "twist_c100", + "twist_c50", + "twist_c50_thick", + "knot_pulling", + "multilayer_collider", + ) + ), + ), + ( + "tinyvbd", + "reference_scene", + VISUAL_PREDICATES, + ("vbd.tinyvbd.tilted_strand",), + ), + ( + "project_page", + "project_surface", + VISUAL_PREDICATES, + ( + "vbd.project.teaser", + "vbd.project.random_teapot_stability", + "vbd.project.flattened_armadillo_stability", + ), + ), +) + +AVBD_GROUPS = ( + ( + "method", + "method", + METHOD_PREDICATES, + tuple( + f"avbd.method.{name}" + for name in ( + "augmented_lagrangian_rows", + "hard_equality_inequality", + "finite_stiffness_ramping", + "force_bounds", + "friction_static_dynamic", + "alpha_error_regularization", + "gamma_warm_start", + "quasi_newton_hessian", + "rigid_six_dof_blocks", + "vertex_body_coloring", + "parallel_dual_stiffness_pass", + "contact_persistence", + "joints_and_attachments", + "motors", + "fracture", + "unified_rigid_soft", + "cpu_solver", + "cuda_solver", + "parameter_defaults", + "baseline_comparisons", + ) + ), + ), + ( + "paper_figures", + "paper_figure", + VISUAL_PREDICATES, + _numbered_ids("avbd.paper.fig", 15), + ), + ( + "paper_tables", + "paper_table", + TABLE_PREDICATES, + _numbered_ids("avbd.paper.table", 2), + ), + ( + "paper_video", + "paper_video_segment", + VISUAL_PREDICATES, + tuple( + f"avbd.video.{name}" + for name in ( + "01_110k_block_pile", + "02_510k_block_pile", + "03_unified_cloth_rigid", + "04_high_stiffness", + "05_flag_pole", + "06_card_tower", + "07_pendulum", + "08_two_heavy_balls", + "09_box_stack", + "10_friction", + "11_chain_mail", + "12_breakable_wall", + "13_parameter_sweeps", + "14_closing", + ) + ), + ), + ( + "demo2d", + "reference_scene", + VISUAL_PREDICATES, + tuple( + f"avbd.demo2d.{name}" + for name in ( + "empty", + "ground", + "dynamic_friction", + "static_friction", + "pyramid", + "cards", + "rope", + "heavy_rope", + "hanging_rope", + "spring", + "spring_ratio", + "stack", + "stack_ratio", + "rod", + "soft_body", + "joint_grid", + "net", + "motor", + "fracture", + ) + ), + ), + ( + "demo3d", + "reference_scene", + VISUAL_PREDICATES, + tuple( + f"avbd.demo3d.{name}" + for name in ( + "empty", + "ground", + "dynamic_friction", + "static_friction", + "pyramid", + "rope", + "heavy_rope", + "spring", + "spring_ratio", + "stack", + "stack_ratio", + "soft_body", + "bridge", + "breakable", + ) + ), + ), + ( + "project_page", + "project_surface", + VISUAL_PREDICATES, + ( + "avbd.project.teaser", + "avbd.project.online_demo2d", + "avbd.project.online_demo3d", + "avbd.project.contact_comparison", + ), + ), +) + +EXPECTED_GROUPS = {"vbd": VBD_GROUPS, "avbd": AVBD_GROUPS} +EXPECTED_SCHEMAS = { + "vbd": "dart.vbd_paper_coverage_contract/v1", + "avbd": "dart.avbd_paper_coverage_contract/v1", +} +EXPECTED_PREDICATE_DEFINITIONS = { + family: { + "artifact_valid": ( + "The evidence schema, source/build identity, hashes, ordering, " + "dimensions, frames, and replay inputs validate." + ), + "solver_contract_valid": ( + f"The intended {family.upper()} solver and backend ran every " + "required step without fallback, invalid state, or an undisclosed " + "cap." + ), + "physical_outcome_valid": ( + "A scenario-specific quantitative oracle passes for the declared " + "duration and accuracy target." + ), + "manual_inspected": ( + "A current-build render or plot was actually inspected and records " + "the expected qualitative behavior." + ), + "media_decoded": ( + "Required image/video media decodes for its declared duration and " + "is bound to the validated run." + ), + "performance_comparable": ( + "Scene, precision, backend, hardware, compiler, timer boundary, " + "warmup, aggregation, and achieved accuracy are matched." + ), + "claim_valid": ( + "Every predicate required by the row passes and the resulting " + "claim stays within the demonstrated boundary." + ), + } + for family in ("vbd", "avbd") +} +EXPECTED_LOCATOR_DIGESTS = { + "vbd": "0a9746e4b3c5af8f6207d4c210670116dfab89b6b2a34009549183e6ae0774b7", + "avbd": "3830039626580ca628c60cceeda942fd811f174e6b640351bd62d405519d67cd", +} + +EXPECTED_SOURCE_VALUES = { + "vbd": { + ( + "paper", + "url", + ): "https://graphics.cs.utah.edu/research/projects/vbd/vbd-siggraph2024.pdf", + ( + "paper", + "sha256", + ): "4ba65c4e49e8e8740aca491c2bf466b6eeb6f8824a152c12d1f7800828959132", + ("paper", "size_bytes"): 20000116, + ("project_page", "url"): "https://graphics.cs.utah.edu/research/projects/vbd/", + ( + "project_page", + "sha256", + ): "2c98c3158d94f2d3d6193381e7f266507f7a722c1ae3c6777786d98ccd84c16f", + ("project_page", "size_bytes"): 7886, + ( + "project_assets", + "teaser", + "url", + ): "https://graphics.cs.utah.edu/research/projects/vbd/teaser.mp4", + ( + "project_assets", + "teaser", + "sha256", + ): "096533af2e9f116815b551f133da58b5f059c6724da6b80d9041859b715dda65", + ("project_assets", "teaser", "size_bytes"): 34479245, + ("project_assets", "teaser", "duration_seconds"): 23.366667, + ("project_assets", "teaser", "width"): 1920, + ("project_assets", "teaser", "height"): 1080, + ("project_assets", "teaser", "frame_rate"): "60/1", + ("project_assets", "teaser", "frames"): 1402, + ( + "project_assets", + "random_teapot_stability", + "url", + ): "https://graphics.cs.utah.edu/research/projects/vbd/teapot-small.gif", + ( + "project_assets", + "random_teapot_stability", + "sha256", + ): "429101b99fdbab5d8b5b958d03b3f11a5ff34bdcc49a9054f4d4501496c5d94c", + ("project_assets", "random_teapot_stability", "size_bytes"): 3284707, + ("project_assets", "random_teapot_stability", "duration_seconds"): 2.72, + ("project_assets", "random_teapot_stability", "width"): 480, + ("project_assets", "random_teapot_stability", "height"): 270, + ("project_assets", "random_teapot_stability", "frames"): 68, + ( + "project_assets", + "flattened_armadillo_stability", + "url", + ): "https://graphics.cs.utah.edu/research/projects/vbd/armadillo-small.gif", + ( + "project_assets", + "flattened_armadillo_stability", + "sha256", + ): "451ac464a728e6d273ac17350f7b97eced3a3970df8b810309cd90e2f7479c6f", + ("project_assets", "flattened_armadillo_stability", "size_bytes"): 3283913, + ("project_assets", "flattened_armadillo_stability", "duration_seconds"): 3.28, + ("project_assets", "flattened_armadillo_stability", "width"): 200, + ("project_assets", "flattened_armadillo_stability", "height"): 270, + ("project_assets", "flattened_armadillo_stability", "frames"): 164, + ("paper_video", "url"): "https://www.youtube.com/watch?v=2HCgKfKy3W8", + ("paper_video", "youtube_id"): "2HCgKfKy3W8", + ("paper_video", "duration_seconds"): 331, + ("paper_video", "audited_format"): "YouTube format 18, 640x360", + ( + "paper_video", + "sha256", + ): "fe5e669c2260a507735cd77e10926a3969fd78fee8c983b328274c25ac5682af", + ("repositories", "gaia", "url"): "https://github.com/AnkaChan/Gaia", + ( + "repositories", + "gaia", + "revision", + ): "c229692045465a76233f9fba9197fb22bbfb3694", + ("repositories", "tinyvbd", "url"): "https://github.com/AnkaChan/TinyVBD", + ( + "repositories", + "tinyvbd", + "revision", + ): "dcd011a5d945172e247ecced90a6c2c4b4313520", + }, + "avbd": { + ( + "paper", + "url", + ): "https://graphics.cs.utah.edu/research/projects/avbd/Augmented_VBD-SIGGRAPH25.pdf", + ( + "paper", + "sha256", + ): "7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6", + ("paper", "size_bytes"): 13803273, + ("project_page", "url"): "https://graphics.cs.utah.edu/research/projects/avbd/", + ( + "project_page", + "sha256", + ): "602525cc0a21b32b71d742ea38be27f68d6672b7c6968397a2ac44c21a5d3aac", + ("project_page", "size_bytes"): 10265, + ( + "project_page", + "online_demo2d_url", + ): "https://graphics.cs.utah.edu/research/projects/avbd/avbd_demo2d.html", + ( + "project_page", + "online_demo2d_sha256", + ): "89bd971d6bd0f3b6b79ae1cd3d7a6215d15852dd5f06bd65d7f338a00171e13e", + ("project_page", "online_demo2d_size_bytes"): 1599470, + ( + "project_page", + "online_demo3d_url", + ): "https://graphics.cs.utah.edu/research/projects/avbd/avbd_demo3d.html", + ( + "project_page", + "online_demo3d_sha256", + ): "64c10c6d63474b0d9a7e14e7778390baca964ab4f95d76aa5c3adef12ba0cf3d", + ("project_page", "online_demo3d_size_bytes"): 1624308, + ( + "project_assets", + "teaser", + "url", + ): "https://graphics.cs.utah.edu/research/projects/avbd/teaser.mp4", + ( + "project_assets", + "teaser", + "sha256", + ): "ab7e34bdb4dd83198a4f6a38634ae7d878a7f2ecb381f7437b75d9ef41145b24", + ("project_assets", "teaser", "size_bytes"): 15620043, + ("project_assets", "teaser", "duration_seconds"): 20.733333, + ("project_assets", "teaser", "width"): 1920, + ("project_assets", "teaser", "height"): 1080, + ("project_assets", "teaser", "frame_rate"): "30/1", + ("project_assets", "teaser", "frames"): 622, + ( + "project_assets", + "contact_comparison", + "url", + ): "https://graphics.cs.utah.edu/research/projects/avbd/chainmail.mp4", + ( + "project_assets", + "contact_comparison", + "sha256", + ): "5a9b6e40aabf49b9341cb00fe1c8f3972a973156394b5edad1492a26dfdddd36", + ("project_assets", "contact_comparison", "size_bytes"): 8972724, + ("project_assets", "contact_comparison", "duration_seconds"): 6.583333, + ("project_assets", "contact_comparison", "width"): 1920, + ("project_assets", "contact_comparison", "height"): 1080, + ("project_assets", "contact_comparison", "frame_rate"): "60/1", + ("project_assets", "contact_comparison", "frames"): 395, + ( + "associated_publication", + "url", + ): "https://graphics.cs.utah.edu/research/projects/avbd/Augmented_VBD-SIGGRAPH25_RTL.pdf", + ( + "associated_publication", + "sha256", + ): "00b57241602fb8f5428d0b1effa2e084598d796de731a62cd04ea1047dfbe5af", + ("associated_publication", "size_bytes"): 2395194, + ("associated_publication", "pages"): 2, + ( + "associated_publication", + "role", + ): "Real-Time Live extended abstract whose equations, two figures, and performance annotations are a subset of the canonical AVBD paper and project teaser.", + ("paper_video", "url"): "https://www.youtube.com/watch?v=bwJgifqvd5M", + ("paper_video", "youtube_id"): "bwJgifqvd5M", + ("paper_video", "duration_seconds"): 279, + ("paper_video", "audited_format"): "YouTube format 18, 640x360", + ( + "paper_video", + "sha256", + ): "2f685b9214c8afd1b35bee54c76c80aaa2d69ac5497cfdef5d229be3292e420d", + ( + "noncanonical_explainer", + "url", + ): "https://www.youtube.com/watch?v=TzIKbjuSy2A", + ("noncanonical_explainer", "youtube_id"): "TzIKbjuSy2A", + ("noncanonical_explainer", "duration_seconds"): 379, + ( + "noncanonical_explainer", + "role", + ): "Third-party explainer linked by the project page; it reuses source media and is not an independent parity surface.", + ( + "repositories", + "avbd_demo2d", + "url", + ): "https://github.com/savant117/avbd-demo2d", + ( + "repositories", + "avbd_demo2d", + "revision", + ): "74699a11f8586d3ac34474c92b1ef8feb5f587de", + ( + "repositories", + "avbd_demo3d", + "url", + ): "https://github.com/savant117/avbd-demo3d", + ( + "repositories", + "avbd_demo3d", + "revision", + ): "7701bd427d55ca5d03ea1fdf331912ded9169f4b", + }, +} + +EXPECTED_VIDEO_RANGES = { + "vbd": ( + (0.0, 6.6), + (6.6, 42.733), + (42.733, 86.8), + (86.8, 108.3), + (108.3, 122.8), + (122.8, 142.033), + (142.033, 154.933), + (154.933, 175.9), + (175.9, 196.033), + (196.033, 206.967), + (206.967, 222.533), + (222.533, 238.067), + (238.067, 250.367), + (250.367, 258.933), + (258.933, 278.167), + (278.167, 291.733), + (291.733, 303.633), + (303.633, 315.533), + (315.533, 331.0), + ), + "avbd": ( + (0.0, 31.033), + (31.033, 55.633), + (55.633, 93.167), + (93.167, 102.933), + (102.933, 121.133), + (121.133, 132.9), + (132.9, 157.567), + (157.567, 171.567), + (171.567, 182.033), + (182.033, 191.8), + (191.8, 199.2), + (199.2, 239.667), + (239.667, 272.633), + (272.633, 279.0), + ), +} + +EXPECTED_PROJECT_RANGES = { + "vbd": { + "vbd.project.teaser": (0.0, 23.366667), + "vbd.project.random_teapot_stability": (0.0, 2.72), + "vbd.project.flattened_armadillo_stability": (0.0, 3.28), + }, + "avbd": { + "avbd.project.teaser": (0.0, 20.733333), + "avbd.project.contact_comparison": (0.0, 6.583333), + }, +} + + +def _value_at(value: Any, path: tuple[str, ...]) -> Any: + for key in path: + if not isinstance(value, dict) or key not in value: + return None + value = value[key] + return value + + +def _source_locator_digest(groups: list[Any]) -> str: + inventory = [ + [requirement.get("id"), requirement.get("source_locator")] + for group in groups + if isinstance(group, dict) + for requirement in group.get("requirements", ()) + if isinstance(requirement, dict) + ] + serialized = json.dumps(inventory, ensure_ascii=False, separators=(",", ":")) + return hashlib.sha256(f"{serialized}\n".encode()).hexdigest() + + +def _list_of_nonempty_strings(value: Any) -> bool: + return isinstance(value, list) and all( + isinstance(item, str) and bool(item.strip()) for item in value + ) + + +def _result_map_errors( + value: Any, + *, + allowed_keys: tuple[str, ...], + label: str, + require_all_true: bool, +) -> list[str]: + if value is None: + return [f"{label} is required"] if require_all_true else [] + if not isinstance(value, dict): + return [f"{label} must be an object"] + + errors: list[str] = [] + unknown = sorted(set(value) - set(allowed_keys)) + if unknown: + errors.append(f"{label} has unknown keys: {', '.join(unknown)}") + for key, result in value.items(): + if not isinstance(result, bool): + errors.append(f"{label}.{key} must be boolean") + if require_all_true: + if tuple(value) != allowed_keys: + errors.append( + f"{label} must contain exactly, in order: {', '.join(allowed_keys)}" + ) + false_or_missing = [key for key in allowed_keys if value.get(key) is not True] + if false_or_missing: + errors.append(f"{label} must be true for: {', '.join(false_or_missing)}") + return errors + + +def _evidence_path_errors(requirement_id: str, evidence: Any) -> list[str]: + if not _list_of_nonempty_strings(evidence): + return [f"{requirement_id}: evidence must be a list of non-empty paths"] + + errors: list[str] = [] + for raw_path in evidence: + path = Path(raw_path) + if path.is_absolute() or ".." in path.parts: + errors.append( + f"{requirement_id}: evidence path must be repository-relative: " + f"{raw_path}" + ) + continue + if not (REPO_ROOT / path).is_file(): + errors.append(f"{requirement_id}: evidence path does not exist: {raw_path}") + return errors + + +def _requirement_errors( + requirement: Any, + *, + expected_id: str, + required_predicates: tuple[str, ...], +) -> list[str]: + label = expected_id + if not isinstance(requirement, dict): + return [f"{label}: requirement must be an object"] + + errors: list[str] = [] + if requirement.get("id") != expected_id: + errors.append( + f"{label}: expected id {expected_id!r}, got {requirement.get('id')!r}" + ) + source_locator = requirement.get("source_locator") + if not isinstance(source_locator, str) or not source_locator.strip(): + errors.append(f"{label}: source_locator must be a non-empty string") + + status = requirement.get("status") + if status not in ALLOWED_STATUSES: + errors.append(f"{label}: invalid status {status!r}") + + evidence = requirement.get("evidence") + blockers = requirement.get("blockers") + errors.extend(_evidence_path_errors(label, evidence)) + if not _list_of_nonempty_strings(blockers): + errors.append(f"{label}: blockers must be a list of non-empty strings") + + if status == "missing" and evidence: + errors.append(f"{label}: missing rows cannot claim evidence") + if status == "partial" and not evidence: + errors.append(f"{label}: partial rows require evidence") + if status != "complete" and not blockers: + errors.append(f"{label}: incomplete rows require at least one blocker") + if status == "complete" and blockers: + errors.append(f"{label}: complete rows cannot retain blockers") + if status == "complete" and not evidence: + errors.append(f"{label}: complete rows require evidence") + + require_all_true = status == "complete" + predicate_results = requirement.get("predicate_results") + errors.extend( + _result_map_errors( + predicate_results, + allowed_keys=required_predicates, + label=f"{label}: predicate_results", + require_all_true=require_all_true, + ) + ) + errors.extend( + _result_map_errors( + requirement.get("backend_results"), + allowed_keys=REQUIRED_BACKENDS, + label=f"{label}: backend_results", + require_all_true=require_all_true, + ) + ) + if ( + status != "complete" + and isinstance(predicate_results, dict) + and predicate_results.get("claim_valid") is True + ): + errors.append(f"{label}: incomplete rows cannot set claim_valid true") + return errors + + +def _source_errors(contract: dict[str, Any], family: str) -> list[str]: + sources = contract.get("sources") + if not isinstance(sources, dict): + return [f"{family}: sources must be an object"] + + errors: list[str] = [] + for source_path, expected in EXPECTED_SOURCE_VALUES[family].items(): + actual = _value_at(sources, source_path) + if actual != expected: + errors.append( + f"{family}: source {'.'.join(source_path)} must be " + f"{expected!r}, got {actual!r}" + ) + return errors + + +def _video_errors(family: str, groups_by_id: dict[str, dict[str, Any]]) -> list[str]: + group = groups_by_id.get("paper_video") + if not isinstance(group, dict): + return [] + requirements = group.get("requirements") + if not isinstance(requirements, list): + return [] + + expected_ranges = EXPECTED_VIDEO_RANGES[family] + errors: list[str] = [] + for index, (requirement, expected_range) in enumerate( + zip(requirements, expected_ranges, strict=False) + ): + label = ( + requirement.get("id", f"{family}.video[{index}]") + if isinstance(requirement, dict) + else f"{family}.video[{index}]" + ) + source_seconds = ( + requirement.get("source_seconds") if isinstance(requirement, dict) else None + ) + if ( + not isinstance(source_seconds, list) + or len(source_seconds) != 2 + or any( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(value) + for value in source_seconds + ) + ): + errors.append(f"{label}: source_seconds must be two finite numbers") + continue + actual_range = tuple(float(value) for value in source_seconds) + if actual_range != expected_range: + errors.append( + f"{label}: source_seconds must be {list(expected_range)}, " + f"got {source_seconds}" + ) + if actual_range[0] >= actual_range[1]: + errors.append(f"{label}: source_seconds must have positive duration") + if index and actual_range[0] != expected_ranges[index - 1][1]: + errors.append(f"{label}: video inventory is not contiguous") + + expected_duration = float(expected_ranges[-1][1]) + if expected_ranges[0][0] != 0.0: + errors.append(f"{family}: paper-video inventory must start at 0 seconds") + if expected_duration != float( + EXPECTED_SOURCE_VALUES[family][("paper_video", "duration_seconds")] + ): + errors.append(f"{family}: internal checker video duration mismatch") + return errors + + +def _project_surface_errors( + family: str, groups_by_id: dict[str, dict[str, Any]] +) -> list[str]: + group = groups_by_id.get("project_page") + if not isinstance(group, dict): + return [] + requirements = group.get("requirements") + if not isinstance(requirements, list): + return [] + + errors: list[str] = [] + requirements_by_id = { + requirement.get("id"): requirement + for requirement in requirements + if isinstance(requirement, dict) + } + for requirement_id, expected_range in EXPECTED_PROJECT_RANGES[family].items(): + requirement = requirements_by_id.get(requirement_id) + if requirement is None: + continue + source_seconds = requirement.get("source_seconds") + if not isinstance(source_seconds, list) or len(source_seconds) != 2: + errors.append( + f"{requirement_id}: source_seconds must be " f"{list(expected_range)}" + ) + continue + if any( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(value) + for value in source_seconds + ): + errors.append( + f"{requirement_id}: source_seconds must be two finite numbers" + ) + continue + actual_range = tuple(float(value) for value in source_seconds) + if actual_range != expected_range: + errors.append( + f"{requirement_id}: source_seconds must be " + f"{list(expected_range)}, got {source_seconds}" + ) + return errors + + +def validate_contract(contract: Any, *, source_name: str = "") -> list[str]: + if not isinstance(contract, dict): + return [f"{source_name}: contract must be a JSON object"] + + errors: list[str] = [] + family = contract.get("solver_family") + if family not in EXPECTED_GROUPS: + return [ + f"{source_name}: solver_family must be one of " + f"{', '.join(sorted(EXPECTED_GROUPS))}" + ] + + prefix = f"{source_name} ({family})" + if contract.get("schema_version") != EXPECTED_SCHEMAS[family]: + errors.append(f"{prefix}: schema_version must be {EXPECTED_SCHEMAS[family]!r}") + if contract.get("plan_id") != "PLAN-104": + errors.append(f"{prefix}: plan_id must be 'PLAN-104'") + if contract.get("completion_rule") != "paper-parity-matrix.md#completion-rule": + errors.append( + f"{prefix}: completion_rule must target " + "'paper-parity-matrix.md#completion-rule'" + ) + try: + date.fromisoformat(contract.get("snapshot_date", "")) + except TypeError, ValueError: + errors.append(f"{prefix}: snapshot_date must be an ISO calendar date") + + predicate_definitions = contract.get("predicate_definitions") + if not isinstance(predicate_definitions, dict): + errors.append(f"{prefix}: predicate_definitions must be an object") + else: + if tuple(predicate_definitions) != KNOWN_PREDICATES: + errors.append( + f"{prefix}: predicate_definitions must contain exactly, in " + f"order: {', '.join(KNOWN_PREDICATES)}" + ) + for predicate, definition in predicate_definitions.items(): + if not isinstance(definition, str) or not definition.strip(): + errors.append( + f"{prefix}: predicate definition {predicate!r} must be non-empty" + ) + for predicate, expected_definition in EXPECTED_PREDICATE_DEFINITIONS[ + family + ].items(): + actual_definition = predicate_definitions.get(predicate) + if actual_definition != expected_definition: + errors.append( + f"{prefix}: predicate definition {predicate!r} must be " + f"{expected_definition!r}, got {actual_definition!r}" + ) + + errors.extend(_source_errors(contract, family)) + + groups = contract.get("coverage_groups") + if not isinstance(groups, list): + return errors + [f"{prefix}: coverage_groups must be a list"] + locator_digest = _source_locator_digest(groups) + if locator_digest != EXPECTED_LOCATOR_DIGESTS[family]: + errors.append( + f"{prefix}: source-locator inventory digest must be " + f"{EXPECTED_LOCATOR_DIGESTS[family]}, got {locator_digest}" + ) + + expected_groups = EXPECTED_GROUPS[family] + actual_group_ids = [ + group.get("id") if isinstance(group, dict) else None for group in groups + ] + expected_group_ids = [group[0] for group in expected_groups] + if actual_group_ids != expected_group_ids: + errors.append( + f"{prefix}: coverage group ids/order must be {expected_group_ids!r}, " + f"got {actual_group_ids!r}" + ) + + all_ids: list[str] = [] + groups_by_id: dict[str, dict[str, Any]] = {} + for index, expected_group in enumerate(expected_groups): + group_id, expected_kind, expected_predicates, expected_ids = expected_group + if index >= len(groups) or not isinstance(groups[index], dict): + errors.append(f"{prefix}: missing object for group {group_id}") + continue + group = groups[index] + groups_by_id[group_id] = group + label = f"{prefix}: group {group_id}" + if group.get("id") != group_id: + errors.append(f"{label}: id must be {group_id!r}") + if group.get("kind") != expected_kind: + errors.append(f"{label}: kind must be {expected_kind!r}") + if tuple(group.get("required_backends", ())) != REQUIRED_BACKENDS: + errors.append( + f"{label}: required_backends must be " f"{list(REQUIRED_BACKENDS)!r}" + ) + if tuple(group.get("required_predicates", ())) != expected_predicates: + errors.append( + f"{label}: required_predicates must be " + f"{list(expected_predicates)!r}" + ) + + requirements = group.get("requirements") + if not isinstance(requirements, list): + errors.append(f"{label}: requirements must be a list") + continue + actual_ids = [ + requirement.get("id") if isinstance(requirement, dict) else None + for requirement in requirements + ] + if actual_ids != list(expected_ids): + errors.append( + f"{label}: requirement ids/order must be {list(expected_ids)!r}, " + f"got {actual_ids!r}" + ) + for requirement, expected_id in zip(requirements, expected_ids, strict=False): + errors.extend( + _requirement_errors( + requirement, + expected_id=expected_id, + required_predicates=expected_predicates, + ) + ) + all_ids.extend( + requirement_id + for requirement_id in actual_ids + if isinstance(requirement_id, str) + ) + + duplicates = sorted( + requirement_id + for requirement_id, count in Counter(all_ids).items() + if count > 1 + ) + if duplicates: + errors.append(f"{prefix}: duplicate requirement ids: {', '.join(duplicates)}") + + errors.extend(_video_errors(family, groups_by_id)) + errors.extend(_project_surface_errors(family, groups_by_id)) + + statuses = [ + requirement.get("status") + for group in groups + if isinstance(group, dict) + for requirement in group.get("requirements", ()) + if isinstance(requirement, dict) + ] + computed_complete = bool(statuses) and all( + status == "complete" for status in statuses + ) + expected_overall_status = "complete" if computed_complete else "incomplete" + if contract.get("overall_status") != expected_overall_status: + errors.append( + f"{prefix}: overall_status must be {expected_overall_status!r} " + "for the recorded row statuses" + ) + return errors + + +def contract_errors(path: Path) -> list[str]: + if not path.is_file(): + return [f"{path}: contract file not found"] + try: + contract = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + return [f"{path}: invalid JSON ({exc})"] + return validate_contract(contract, source_name=path.name) + + +def contract_summary(path: Path) -> str: + contract = json.loads(path.read_text()) + statuses = Counter( + requirement["status"] + for group in contract["coverage_groups"] + for requirement in group["requirements"] + ) + status_summary = ", ".join( + f"{status}={statuses[status]}" + for status in ("complete", "partial", "blocked", "missing") + if statuses[status] + ) + total = sum(statuses.values()) + return f"{contract['solver_family'].upper()}: {total} rows ({status_summary})" + + +def _matrix_table_rows(text: str) -> dict[str, tuple[int, int, int, int, int]]: + rows: dict[str, tuple[int, int, int, int, int]] = {} + for line in text.splitlines(): + if not line.startswith("|"): + continue + cells = [cell.strip().replace("**", "") for cell in line.split("|")[1:-1]] + if len(cells) != 6: + continue + try: + counts = tuple(int(cell) for cell in cells[1:]) + except ValueError: + continue + rows[cells[0]] = counts + return rows + + +def matrix_errors( + contracts: tuple[dict[str, Any], ...], *, matrix_path: Path = MATRIX_PATH +) -> list[str]: + if not matrix_path.is_file(): + return [f"{matrix_path}: parity matrix not found"] + rows = _matrix_table_rows(matrix_path.read_text()) + expected_rows: dict[str, tuple[int, int, int, int, int]] = {} + grand_counts = Counter() + + for contract in contracts: + family = contract["solver_family"] + family_counts = Counter() + for group in contract["coverage_groups"]: + counts = Counter( + requirement["status"] for requirement in group["requirements"] + ) + label = MATRIX_LABELS[family][group["id"]] + expected_rows[label] = ( + len(group["requirements"]), + counts["partial"], + counts["blocked"], + counts["missing"], + counts["complete"], + ) + family_counts.update(counts) + family_total = sum(family_counts.values()) + expected_rows[f"{family.upper()} total"] = ( + family_total, + family_counts["partial"], + family_counts["blocked"], + family_counts["missing"], + family_counts["complete"], + ) + grand_counts.update(family_counts) + + expected_rows["Grand total"] = ( + sum(grand_counts.values()), + grand_counts["partial"], + grand_counts["blocked"], + grand_counts["missing"], + grand_counts["complete"], + ) + + errors: list[str] = [] + for label, expected in expected_rows.items(): + actual = rows.get(label) + if actual != expected: + errors.append( + f"{matrix_path.name}: table row {label!r} must be {expected}, " + f"got {actual}" + ) + return errors + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--contract", + action="append", + type=Path, + default=None, + help="Explicit coverage contract to validate (repeatable).", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(sys.argv[1:] if argv is None else argv) + paths = tuple(args.contract) if args.contract else DEFAULT_CONTRACTS + all_errors: list[str] = [] + for path in paths: + all_errors.extend(contract_errors(path)) + if not args.contract and not all_errors: + contracts = tuple(json.loads(path.read_text()) for path in paths) + all_errors.extend(matrix_errors(contracts)) + + if all_errors: + for error in all_errors: + print(f"ERROR: {error}") + return 1 + + total = 0 + for path in paths: + print(contract_summary(path)) + contract = json.loads(path.read_text()) + total += sum( + len(group["requirements"]) for group in contract["coverage_groups"] + ) + print(f"Validated {len(paths)} PLAN-104 contract(s), {total} canonical rows") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_plan104_paper_parity.py b/tests/test_check_plan104_paper_parity.py new file mode 100644 index 0000000000000..2b05bfeb269d2 --- /dev/null +++ b/tests/test_check_plan104_paper_parity.py @@ -0,0 +1,231 @@ +"""Tests for scripts/check_plan104_paper_parity.py.""" + +import copy +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "check_plan104_paper_parity.py" +CONTRACT_DIR = ROOT / "docs" / "plans" / "104-vertex-block-descent-solver" +VBD_CONTRACT = CONTRACT_DIR / "vbd-paper-coverage-contract.json" +AVBD_CONTRACT = CONTRACT_DIR / "avbd-paper-coverage-contract.json" + + +def _load_module(): + spec = importlib.util.spec_from_file_location("check_plan104_paper_parity", SCRIPT) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _load_contract(path): + return json.loads(path.read_text()) + + +def _write_contract(tmp_path, contract): + path = tmp_path / f"{contract['solver_family']}-contract.json" + path.write_text(json.dumps(contract, indent=2) + "\n") + return path + + +def _errors_for_mutation(tmp_path, path, mutate): + module = _load_module() + contract = _load_contract(path) + mutate(contract) + return module.contract_errors(_write_contract(tmp_path, contract)) + + +def test_committed_contracts_pass_and_cover_176_rows(capsys): + module = _load_module() + assert module.main([]) == 0 + output = capsys.readouterr().out + assert "VBD: 88 rows" in output + assert "AVBD: 88 rows" in output + assert "176 canonical rows" in output + + +def test_removing_a_canonical_requirement_is_rejected(tmp_path): + errors = _errors_for_mutation( + tmp_path, + VBD_CONTRACT, + lambda contract: contract["coverage_groups"][2]["requirements"].pop(), + ) + assert any("requirement ids/order" in error for error in errors), errors + + +@pytest.mark.parametrize("path", (VBD_CONTRACT, AVBD_CONTRACT)) +def test_every_canonical_requirement_is_guarded_against_deletion(path): + module = _load_module() + baseline = _load_contract(path) + for group_index, group in enumerate(baseline["coverage_groups"]): + for requirement_index, requirement in enumerate(group["requirements"]): + contract = copy.deepcopy(baseline) + contract["coverage_groups"][group_index]["requirements"].pop( + requirement_index + ) + errors = module.validate_contract( + contract, source_name=f"without {requirement['id']}" + ) + assert any("requirement ids/order" in error for error in errors), ( + requirement["id"], + errors, + ) + + +def test_mutating_an_authoritative_source_pin_is_rejected(tmp_path): + def mutate(contract): + contract["sources"]["paper"]["sha256"] = "0" * 64 + + errors = _errors_for_mutation(tmp_path, AVBD_CONTRACT, mutate) + assert any("source paper.sha256" in error for error in errors), errors + + +@pytest.mark.parametrize("path", (VBD_CONTRACT, AVBD_CONTRACT)) +def test_every_authoritative_source_value_is_guarded(path): + module = _load_module() + baseline = _load_contract(path) + family = baseline["solver_family"] + for source_path in module.EXPECTED_SOURCE_VALUES[family]: + contract = copy.deepcopy(baseline) + value = contract["sources"] + for key in source_path[:-1]: + value = value[key] + value[source_path[-1]] = None + errors = module.validate_contract( + contract, source_name=f"mutated {'.'.join(source_path)}" + ) + expected_fragment = f"source {'.'.join(source_path)}" + assert any(expected_fragment in error for error in errors), ( + source_path, + errors, + ) + + +@pytest.mark.parametrize("path", (VBD_CONTRACT, AVBD_CONTRACT)) +def test_every_predicate_definition_is_guarded(path): + module = _load_module() + baseline = _load_contract(path) + for predicate in baseline["predicate_definitions"]: + contract = copy.deepcopy(baseline) + contract["predicate_definitions"][predicate] = "weakened" + errors = module.validate_contract(contract, source_name=f"mutated {predicate}") + assert any( + f"predicate definition {predicate!r} must be" in error for error in errors + ), (predicate, errors) + + +@pytest.mark.parametrize("path", (VBD_CONTRACT, AVBD_CONTRACT)) +def test_every_authoritative_source_locator_is_guarded(path): + module = _load_module() + baseline = _load_contract(path) + for group_index, group in enumerate(baseline["coverage_groups"]): + for requirement_index, requirement in enumerate(group["requirements"]): + contract = copy.deepcopy(baseline) + contract["coverage_groups"][group_index]["requirements"][requirement_index][ + "source_locator" + ] += " drift" + errors = module.validate_contract( + contract, source_name=f"mutated {requirement['id']}" + ) + assert any( + "source-locator inventory digest must be" in error for error in errors + ), (requirement["id"], errors) + + +def test_missing_row_cannot_claim_evidence(tmp_path): + def mutate(contract): + row = contract["coverage_groups"][0]["requirements"][12] + assert row["status"] == "missing" + row["evidence"] = ["docs/plans/104-vertex-block-descent-solver/README.md"] + + errors = _errors_for_mutation(tmp_path, VBD_CONTRACT, mutate) + assert any("missing rows cannot claim evidence" in error for error in errors) + + +def test_partial_row_requires_evidence(tmp_path): + def mutate(contract): + row = contract["coverage_groups"][0]["requirements"][0] + assert row["status"] == "partial" + row["evidence"] = [] + + errors = _errors_for_mutation(tmp_path, AVBD_CONTRACT, mutate) + assert any("partial rows require evidence" in error for error in errors) + + +def test_complete_row_requires_all_predicates_and_backends(tmp_path): + def mutate(contract): + row = contract["coverage_groups"][0]["requirements"][0] + row["status"] = "complete" + row["blockers"] = [] + + errors = _errors_for_mutation(tmp_path, AVBD_CONTRACT, mutate) + assert any("predicate_results is required" in error for error in errors), errors + assert any("backend_results is required" in error for error in errors), errors + + +def test_incomplete_row_cannot_set_claim_valid_true(tmp_path): + def mutate(contract): + row = contract["coverage_groups"][0]["requirements"][0] + row["predicate_results"] = {"claim_valid": True} + + errors = _errors_for_mutation(tmp_path, VBD_CONTRACT, mutate) + assert any("cannot set claim_valid true" in error for error in errors) + + +def test_video_timecode_discontinuity_is_rejected(tmp_path): + def mutate(contract): + contract["coverage_groups"][3]["requirements"][1]["source_seconds"][0] += 1 + + errors = _errors_for_mutation(tmp_path, AVBD_CONTRACT, mutate) + assert any("source_seconds must be" in error for error in errors), errors + + +def test_project_media_duration_drift_is_rejected(tmp_path): + def mutate(contract): + contract["coverage_groups"][-1]["requirements"][0]["source_seconds"][1] = 1 + + errors = _errors_for_mutation(tmp_path, VBD_CONTRACT, mutate) + assert any( + "vbd.project.teaser: source_seconds must be" in error for error in errors + ) + + +def test_nonexistent_evidence_path_is_rejected(tmp_path): + def mutate(contract): + row = contract["coverage_groups"][0]["requirements"][0] + row["evidence"] = ["this/path/does/not/exist.md"] + + errors = _errors_for_mutation(tmp_path, VBD_CONTRACT, mutate) + assert any("evidence path does not exist" in error for error in errors) + + +def test_overall_complete_cannot_be_asserted_while_rows_are_incomplete(tmp_path): + def mutate(contract): + contract["overall_status"] = "complete" + + errors = _errors_for_mutation(tmp_path, AVBD_CONTRACT, mutate) + assert any("overall_status must be 'incomplete'" in error for error in errors) + + +def test_prose_matrix_count_drift_is_rejected(tmp_path): + module = _load_module() + matrix = tmp_path / "paper-parity-matrix.md" + matrix.write_text("| VBD method | 1 | 0 | 0 | 1 | 0 |\n") + contracts = (_load_contract(VBD_CONTRACT), _load_contract(AVBD_CONTRACT)) + errors = module.matrix_errors(contracts, matrix_path=matrix) + assert any("VBD method" in error for error in errors), errors + assert any("Grand total" in error for error in errors), errors + + +def test_contract_mutations_do_not_change_committed_fixture(): + before = _load_contract(VBD_CONTRACT) + mutated = copy.deepcopy(before) + mutated["overall_status"] = "complete" + assert _load_contract(VBD_CONTRACT) == before From a78f688a1785af775bd247ba5dac1e971eaf934e Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Thu, 30 Jul 2026 03:28:54 -0700 Subject: [PATCH 02/13] Implement AVBD quasi-Newton geometric stiffness --- CHANGELOG.md | 6 +- .../detail/deformable_vbd/block_descent.hpp | 6 +- .../finite_stiffness_kernel.hpp | 22 +- .../deformable_vbd/quasi_newton_hessian.hpp | 98 +++++++ .../detail/rigid_avbd/rigid_block_kernel.hpp | 195 +++++++++++-- docs/dev_tasks/avbd_solver/README.md | 106 +++++-- docs/dev_tasks/avbd_solver/RESUME.md | 81 +++--- docs/plans/104-vertex-block-descent-solver.md | 35 ++- .../avbd-paper-coverage-contract.json | 13 +- .../avbd-paper-gap-audit.md | 2 +- .../avbd-quasi-newton-evidence.json | 220 +++++++++++++++ .../paper-parity-matrix.md | 6 +- .../coverage-matrix.md | 2 +- docs/plans/dashboard.md | 20 +- scripts/check_plan104_paper_parity.py | 2 +- .../deformable_vbd/test_avbd_rigid_block.cpp | 263 ++++++++++++++++-- .../test_vbd_finite_stiffness.cpp | 37 +++ tests/unit/simulation/world/test_world.cpp | 40 +++ 18 files changed, 1017 insertions(+), 137 deletions(-) create mode 100644 dart/simulation/detail/deformable_vbd/quasi_newton_hessian.hpp create mode 100644 docs/plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json diff --git a/CHANGELOG.md b/CHANGELOG.md index e18fd349c38bc..c9f6b9144a7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,7 +188,11 @@ compatibility remains on the active DART 6 LTS branch._ line), skipping the general inverse/compose when the child (and optionally parent) joint frame is identity. Guarded by an equivalence regression test. - Added and hardened DART 7 deformable, VBD, AVBD, FEM, IPC/barrier, and - variational solver paths behind the `World` and executor model. + variational solver paths behind the `World` and executor model; AVBD CPU + distance-spring and nonlinear rigid-point rows now use the paper's + non-negative quasi-Newton geometric-stiffness diagonal while contact + remains intentionally Taylor-linearized. + ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) - Added compute-executor and backend-boundary work so CPU threading, optional CUDA experiments, and future accelerator sidecars do not leak into the default public API. diff --git a/dart/simulation/detail/deformable_vbd/block_descent.hpp b/dart/simulation/detail/deformable_vbd/block_descent.hpp index f6db5fe042774..90c527d63bf7b 100644 --- a/dart/simulation/detail/deformable_vbd/block_descent.hpp +++ b/dart/simulation/detail/deformable_vbd/block_descent.hpp @@ -429,8 +429,7 @@ inline BlockDescentStats blockDescentMassSpringAvbdFiniteStiffness( positions[vertex], positions[other], spring.restLength, - *row, - options.clampSpringHessian); + *row); } else { addSpringTerm( block, @@ -573,8 +572,7 @@ inline BlockDescentStats blockDescentMassSpringAvbdRows( positions[vertex], positions[other], spring.restLength, - *row, - options.clampSpringHessian); + *row); } else { addSpringTerm( block, diff --git a/dart/simulation/detail/deformable_vbd/finite_stiffness_kernel.hpp b/dart/simulation/detail/deformable_vbd/finite_stiffness_kernel.hpp index a5423854f37fe..491845d33ca2f 100644 --- a/dart/simulation/detail/deformable_vbd/finite_stiffness_kernel.hpp +++ b/dart/simulation/detail/deformable_vbd/finite_stiffness_kernel.hpp @@ -33,6 +33,7 @@ #include #include +#include #include #include @@ -121,11 +122,24 @@ inline void addAvbdSpringFiniteStiffness( const Eigen::Vector3d& self, const Eigen::Vector3d& other, double restLength, - const AvbdSpringFiniteStiffnessRow& row, - bool clampToPsd = true) + const AvbdSpringFiniteStiffnessRow& row) { - addSpringTerm( - block, row.state.stiffness, restLength, self, other, clampToPsd); + const Eigen::Vector3d delta = other - self; + const double length = delta.norm(); + if (length <= kMinSpringLength || !std::isfinite(length)) { + return; + } + + const Eigen::Vector3d direction = delta / length; + const double constraintValue = length - restLength; + const double forceMagnitude = row.state.stiffness * constraintValue; + + block.force.noalias() += forceMagnitude * direction; + block.hessian.noalias() + += row.state.stiffness * (direction * direction.transpose()); + + block.hessian.diagonal() += avbdQuasiNewtonProjectedDistanceDiagonal( + direction, forceMagnitude / length); } //============================================================================== diff --git a/dart/simulation/detail/deformable_vbd/quasi_newton_hessian.hpp b/dart/simulation/detail/deformable_vbd/quasi_newton_hessian.hpp new file mode 100644 index 0000000000000..25389c9340cef --- /dev/null +++ b/dart/simulation/detail/deformable_vbd/quasi_newton_hessian.hpp @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2011, The DART development contributors + * All rights reserved. + * + * The list of contributors can be found at: + * https://github.com/dartsim/dart/blob/main/LICENSE + * + * This file is provided under the following "BSD-style" License: + * Redistribution and use in source and binary forms, with or + * without modification, are permitted provided that the following + * conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF + * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include + +#include + +#include + +namespace dart::simulation::detail::deformable_vbd { + +/// Return the Section 3.5 column-norm diagonal for +/// `scale * (I - direction * direction^T)`. +/// +/// Distance constraints use this projector repeatedly. Computing its column +/// norms analytically avoids materializing a 3 x 3 matrix in the solver loop: +/// the squared norm of column c is `scale^2 * (1 - direction[c]^2)`. +/// Callers provide a unit direction. Non-finite input is deliberately +/// propagated so the owning block validation can reject the solve fail-closed. +inline Eigen::Vector3d avbdQuasiNewtonProjectedDistanceDiagonal( + const Eigen::Vector3d& direction, double scale) +{ + Eigen::Vector3d diagonal; + const double magnitude = std::abs(scale); + for (Eigen::Index column = 0; column < 3; ++column) { + diagonal[column] + = magnitude + * std::sqrt( + std::max(1.0 - direction[column] * direction[column], 0.0)); + } + return diagonal; +} + +/// Return the AVBD Section 3.5 diagonal approximation of a force-scaled +/// geometric-stiffness matrix. +/// +/// For G = f * d^2 C / dx^2, AVBD replaces G with a diagonal matrix whose +/// c-th entry is the Euclidean norm of column c of G. The entries are therefore +/// non-negative even when the force is compressive or G is non-symmetric. +/// Combined with the rank-one penalty term and inertia, this keeps each local +/// block positive-definite and suitable for LDLT. +/// +/// AVBD's local blocks are fixed-size. Keeping that requirement explicit here +/// prevents a paper-equation helper from introducing hidden solve-loop +/// allocation. +template +inline Eigen::Matrix +avbdQuasiNewtonGeometricDiagonal( + const Eigen::MatrixBase& geometricStiffness) +{ + static_assert( + Derived::RowsAtCompileTime != Eigen::Dynamic + && Derived::ColsAtCompileTime != Eigen::Dynamic, + "AVBD quasi-Newton blocks must be fixed-size"); + static_assert( + Derived::RowsAtCompileTime == Derived::ColsAtCompileTime, + "AVBD geometric stiffness must be square"); + + Eigen::Matrix + diagonal; + for (Eigen::Index column = 0; column < Derived::ColsAtCompileTime; ++column) { + diagonal[column] = geometricStiffness.col(column).norm(); + } + return diagonal; +} + +} // namespace dart::simulation::detail::deformable_vbd diff --git a/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp b/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp index a5bfe259e27d1..8a28b2a0e963f 100644 --- a/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp +++ b/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp @@ -34,6 +34,7 @@ #include #include +#include #include #include @@ -142,6 +143,18 @@ struct AvbdRigidBodyState Eigen::Quaterniond orientation = Eigen::Quaterniond::Identity(); }; +/// Curvature model for a world-point scalar row. +/// +/// General joints and motors recompute their nonlinear world points during the +/// solve and use the AVBD Section 3.5 quasi-Newton geometric term. Contact rows +/// use the paper's Taylor-linearized contact energy, whose second-order term is +/// intentionally discarded. +enum class AvbdRigidPointCurvatureModel : std::uint8_t +{ + QuasiNewton, + TaylorLinearized, +}; + struct AvbdRigidPointAttachmentRow { Eigen::Vector3d localPoint = Eigen::Vector3d::Zero(); @@ -163,6 +176,8 @@ struct AvbdRigidPointPairRow AvbdScalarRowState state; double materialStiffness = std::numeric_limits::infinity(); double previousConstraintValue = 0.0; + AvbdRigidPointCurvatureModel curvatureModel + = AvbdRigidPointCurvatureModel::QuasiNewton; AvbdScalarRowBounds bounds{ -std::numeric_limits::infinity(), std::numeric_limits::infinity()}; @@ -757,6 +772,7 @@ inline AvbdRigidPointPairRow makeAvbdRigidContactNormalRow( row.offset = targetDistance; row.state = state; row.previousConstraintValue = previousConstraintValue; + row.curvatureModel = AvbdRigidPointCurvatureModel::TaylorLinearized; row.bounds = avbdContactNormalBounds(); return row; } @@ -804,6 +820,7 @@ inline AvbdRigidPointPairRow makeAvbdRigidContactFrictionTangentRow( row.offset = -row.axis.dot(stepStartRelativePosition); row.state = state; row.previousConstraintValue = previousConstraintValue; + row.curvatureModel = AvbdRigidPointCurvatureModel::TaylorLinearized; row.bounds = avbdFrictionTangentBounds(forceLimit); return row; } @@ -1564,6 +1581,53 @@ inline Matrix3x6d avbdRigidWorldPointJacobian( state, avbdRigidBodyWorldPoint(state, localPoint)); } +//============================================================================== +/// Return the force-scaled rotational curvature of a rigid world point. +/// +/// With arm r and the actual world force w applied at the point, the negative +/// Jacobian of the generalized torque r x w under DART's left exponential-map +/// update is (r dot w) I - r w^T. This is the rigid-body form of the AVBD +/// geometric-stiffness term before Section 3.5 diagonal lumping. +inline Eigen::Matrix3d avbdRigidWorldPointGeometricStiffness( + const AvbdRigidBodyState& state, + const Eigen::Vector3d& worldPoint, + const Eigen::Vector3d& worldForce) +{ + const Eigen::Vector3d arm = worldPoint - state.position; + return arm.dot(worldForce) * Eigen::Matrix3d::Identity() + - arm * worldForce.transpose(); +} + +//============================================================================== +inline void addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + AvbdRigidBodyBlock& block, + const AvbdRigidBodyState& state, + const Eigen::Vector3d& worldPoint, + const Eigen::Vector3d& worldForce, + AvbdRigidPointCurvatureModel curvatureModel) +{ + if (curvatureModel == AvbdRigidPointCurvatureModel::TaylorLinearized) { + return; + } + + const Eigen::Vector3d arm = worldPoint - state.position; + if (avbdRigidWorldPointIsBodyOrigin(state, worldPoint)) { + return; + } + + const double armForceDot = arm.dot(worldForce); + const double armSquaredNorm = arm.squaredNorm(); + Eigen::Vector3d diagonal; + for (Eigen::Index column = 0; column < 3; ++column) { + const double squaredNorm + = armForceDot * armForceDot + - 2.0 * armForceDot * arm[column] * worldForce[column] + + armSquaredNorm * worldForce[column] * worldForce[column]; + diagonal[column] = std::sqrt(std::max(squaredNorm, 0.0)); + } + block.hessian.diagonal().tail<3>() += diagonal; +} + //============================================================================== inline Vector6d avbdRigidDistanceSpringDirectionAtWorldPoint( const AvbdRigidBodyState& state, @@ -1581,30 +1645,36 @@ inline void addAvbdRigidDistanceSpringHessianAtWorldPoint( const Eigen::Vector3d& axis, double length, double restLength, - double stiffness, - bool clampToPsd) + double stiffness) { if (!axis.allFinite() || length <= kAvbdRigidMinDistanceSpringLength || !std::isfinite(stiffness)) { return; } - double transverse = 1.0 - restLength / length; - if (clampToPsd) { - transverse = std::max(0.0, transverse); - } + const double forceMagnitude = stiffness * (length - restLength); + const Vector6d direction + = avbdRigidWorldPointDirection(state, worldPoint, axis); + block.hessian.noalias() += stiffness * (direction * direction.transpose()); - const Eigen::Matrix3d nnT = axis * axis.transpose(); - const Eigen::Matrix3d pointHessian - = stiffness * (nnT + transverse * (Eigen::Matrix3d::Identity() - nnT)); if (avbdRigidWorldPointIsBodyOrigin(state, worldPoint)) { - block.hessian.topLeftCorner<3, 3>().noalias() += pointHessian; + block.hessian.diagonal().head<3>() + += avbdQuasiNewtonProjectedDistanceDiagonal( + axis, forceMagnitude / length); return; } + const Eigen::Matrix3d constraintHessian + = (Eigen::Matrix3d::Identity() - axis * axis.transpose()) / length; const Matrix3x6d jacobian = avbdRigidWorldPointJacobianAtWorldPoint(state, worldPoint); - block.hessian.noalias() += jacobian.transpose() * pointHessian * jacobian; + Matrix6d geometricStiffness + = forceMagnitude * jacobian.transpose() * constraintHessian * jacobian; + geometricStiffness.bottomRightCorner<3, 3>() + += avbdRigidWorldPointGeometricStiffness( + state, worldPoint, forceMagnitude * axis); + block.hessian.diagonal() + += avbdQuasiNewtonGeometricDiagonal(geometricStiffness); } //============================================================================== @@ -1615,8 +1685,7 @@ inline void addAvbdRigidDistanceSpringHessian( const Eigen::Vector3d& axis, double length, double restLength, - double stiffness, - bool clampToPsd) + double stiffness) { addAvbdRigidDistanceSpringHessianAtWorldPoint( block, @@ -1625,8 +1694,7 @@ inline void addAvbdRigidDistanceSpringHessian( axis, length, restLength, - stiffness, - clampToPsd); + stiffness); } //============================================================================== @@ -1667,6 +1735,12 @@ inline double addAvbdRigidPointAttachment( block.force.noalias() += forceMagnitude * direction; block.hessian.noalias() += row.state.stiffness * (direction * direction.transpose()); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + block, + state, + worldPoint, + forceMagnitude * row.axis, + AvbdRigidPointCurvatureModel::QuasiNewton); return forceMagnitude; } @@ -1724,6 +1798,18 @@ inline double addAvbdRigidPointPair( += row.state.stiffness * (firstDirection * firstDirection.transpose()); blockB.hessian.noalias() += row.state.stiffness * (secondDirection * secondDirection.transpose()); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + blockA, + stateA, + worldPointA, + forceMagnitude * row.axis, + row.curvatureModel); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + blockB, + stateB, + worldPointB, + -forceMagnitude * row.axis, + row.curvatureModel); return forceMagnitude; } @@ -1733,8 +1819,7 @@ inline double addAvbdRigidPointPairDistanceSpring( AvbdRigidBodyBlock& blockB, const AvbdRigidBodyState& stateA, const AvbdRigidBodyState& stateB, - const AvbdRigidPointPairDistanceSpringRow& row, - bool clampToPsd = true) + const AvbdRigidPointPairDistanceSpringRow& row) { const Eigen::Vector3d worldPointA = avbdRigidBodyWorldPoint(stateA, row.localPointA); @@ -1763,17 +1848,15 @@ inline double addAvbdRigidPointPairDistanceSpring( axis, length, row.restLength, - row.state.stiffness, - clampToPsd); + row.state.stiffness); addAvbdRigidDistanceSpringHessianAtWorldPoint( blockB, stateB, worldPointB, - axis, + -axis, length, row.restLength, - row.state.stiffness, - clampToPsd); + row.state.stiffness); return forceMagnitude; } @@ -1864,6 +1947,30 @@ inline Eigen::Vector2d addAvbdRigidPointPairFrictionTangentPair( blockB.hessian.noalias() += second.state.stiffness * (secondDirectionB * secondDirectionB.transpose()); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + blockA, + stateA, + firstWorldPointA, + force.x() * first.axis, + first.curvatureModel); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + blockA, + stateA, + secondWorldPointA, + force.y() * second.axis, + second.curvatureModel); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + blockB, + stateB, + firstWorldPointB, + -force.x() * first.axis, + first.curvatureModel); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + blockB, + stateB, + secondWorldPointB, + -force.y() * second.axis, + second.curvatureModel); return force; } @@ -3823,6 +3930,12 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( block.force.noalias() += forceMagnitude * direction; addAvbdRigidBlockHessianRankOneLowerTriangle( block, direction, row.state.stiffness); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + block, + stateA, + worldPointA, + forceMagnitude * row.axis, + row.curvatureModel); } if (indexedRow.bodyB == body && indexedRow.bodyB != indexedRow.bodyA) { const Vector6d direction @@ -3830,6 +3943,12 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( block.force.noalias() += forceMagnitude * direction; addAvbdRigidBlockHessianRankOneLowerTriangle( block, direction, row.state.stiffness); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + block, + stateB, + worldPointB, + -forceMagnitude * row.axis, + row.curvatureModel); } }; @@ -3866,8 +3985,7 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( axis, length, row.restLength, - row.state.stiffness, - /*clampToPsd=*/true); + row.state.stiffness); } if (indexedRow.bodyB == body) { const Vector6d direction @@ -3878,11 +3996,10 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( block, stateB, worldPointB, - axis, + -axis, length, row.restLength, - row.state.stiffness, - /*clampToPsd=*/true); + row.state.stiffness); } }; @@ -3995,6 +4112,18 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( block, firstDirection, indexedRows.first.state.stiffness); addAvbdRigidBlockHessianRankOneLowerTriangle( block, secondDirection, indexedRows.second.state.stiffness); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + block, + stateA, + firstWorldPointA, + force.x() * indexedRows.first.axis, + indexedRows.first.curvatureModel); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + block, + stateA, + secondWorldPointA, + force.y() * indexedRows.second.axis, + indexedRows.second.curvatureModel); } if (indexedRows.bodyB == body && indexedRows.bodyB != indexedRows.bodyA) { @@ -4008,6 +4137,18 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( block, firstDirection, indexedRows.first.state.stiffness); addAvbdRigidBlockHessianRankOneLowerTriangle( block, secondDirection, indexedRows.second.state.stiffness); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + block, + stateB, + firstWorldPointB, + -force.x() * indexedRows.first.axis, + indexedRows.first.curvatureModel); + addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + block, + stateB, + secondWorldPointB, + -force.y() * indexedRows.second.axis, + indexedRows.second.curvatureModel); } }; diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index bafba4fd733ff..c90cdbfa0c79a 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -22,12 +22,20 @@ This folder is the temporary working surface; the durable owner is the plan. same-hardware paper-number match. - **Branch state:** active local branch `feature/vbd-avbd-paper-parity-contract`, based on `main` at `83110ef54ab`; - it has not been pushed. -- **Current packet:** establish the exact official - paper/repository/video/project-page source inventory and fail-closed - validation gate. The contracts contain 88 VBD and 88 AVBD requirements; all - 176 remain incomplete until their recorded correctness, solver-identity, - CPU/CUDA, visual, and comparable-performance predicates pass. + the fail-closed contract is committed locally at `710cbfc1152`, and nothing + has been pushed. +- **Current packet:** implement the Section 4 parallel dual/stiffness update + pass over the already-promoted CPU row inventories. The contracts contain 88 + VBD and 88 AVBD requirements; all 176 remain incomplete until their recorded + correctness, solver-identity, CPU/CUDA, visual, and comparable-performance + predicates pass. +- **Latest verified local packet:** `avbd.method.quasi_newton_hessian` advanced + from missing to partial. CPU deformable distance springs, rigid distance + springs, point attachments, and nonlinear point-pair joint/motor rows now + use the Section 3.5 column-norm model, while contact explicitly remains + Taylor-linearized. The row is not complete; angular/material families, + source adjudication, source-matched corpus evidence, CUDA, and comparable + achieved-accuracy performance remain open. - **Recent slices merged to `main`** (see the PLAN-104 progress log and the PRs for detail; per-slice history lives in git, not in this file): - #2991 — source-row coverage + contact-precheck (`f6fecbc5bd5`). @@ -37,11 +45,11 @@ This folder is the temporary working surface; the durable owner is the plan. - #3022 — bounded regression coverage: rigid-contact tangent-basis contract, articulated break→reset→break re-arm lifecycle, row-inventory replaced-key cold-start (`65ba05113c6`). -- **Out-of-scope pre-existing gates:** two allocator regressions - (`DantzigSolver.ScratchUsesProvidedAllocatorForDantzigWorkBuffers`, - `World.BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`) are LCP/rigid-IPC - issues, not AVBD. They reproduce only under `DART_BUILD_PROFILE=ON`; hosted CI - is green. Recommended for a separate LCP/IPC allocator fix. +- **Current local gates:** the fresh uncached Release/profile build, 168/168 + selected unit tests, focused AVBD tests, and the relevant + world-base/global/raw allocation tests pass. Two allocator tests previously + recorded as pre-existing failures also pass in this build and are no longer + open blockers. ## Goal @@ -72,19 +80,81 @@ numbers. ## Immediate Next Steps -Finish and review the parity-contract packet, then select the highest-leverage -missing method row rather than adding another narrow demo packet. The first -candidate is `avbd.method.quasi_newton_hessian`, because the paper describes -its diagonal geometric-stiffness approximation as part of the core AVBD local -block update and DART currently has no end-to-end implementation. Keep every -dependent figure/demo/performance row partial until source-matched CPU and CUDA -evidence closes it. +Begin the bounded Section 4 parallel dual/stiffness update packet below after +the verified `avbd.method.quasi_newton_hessian` closeout. Keep every dependent +figure/demo/performance row partial until source-matched CPU and CUDA evidence +closes it. Two smaller deferred maintenance items remain valid but do not outrank the missing paper mechanism: hoist the duplicated `makeCollisionPairKey` into a shared `detail` header, and upgrade the Spring / Spring Ratio packets from legacy schema version 1 to version 2 with a `resolved_solver_identity`. +## Verified Local Packet: Section 3.5 Quasi-Newton Hessian + +- **Value:** replace DART's current rank-one-only or generic PSD-clamped AVBD + row blocks with the paper's non-negative diagonal approximation of the + force-scaled geometric stiffness so the inertia-anchored local block remains + positive-definite. +- **Scope:** add one allocation-free shared column-norm kernel; use it for + deformable finite-stiffness distance springs, rigid distance springs, rigid + point attachments, and nonlinear rigid point-pair rows used by linear + joints and motors. Encode the paper/reference implementation's intentional + Taylor-linearized contact exception in row data so normal and friction + contact rows do not silently acquire a different Hessian model. +- **Non-goals for this packet:** angular log-map curvature, CUDA kernels, + second-order contact curvature, new paper scenes, broad performance claims, + and closing `avbd.method.quasi_newton_hessian` as complete. These remain in + the overall task rather than disappearing from scope. +- **Assumptions and decisions:** the geometric matrix is diagonally lumped by + the Euclidean norm of each column after force scaling, exactly as AVBD + Section 3.5 specifies. The pinned 2D solver applies that rule generically and + the 3D joint source applies it to nonlinear point rows; the pinned 3D spring + source omits its geometric term, so DART's distance-spring implementation + follows the paper without claiming 3D source equivalence. The rank-one term + always uses the current penalty stiffness. Contact remains linearized because + both the paper and reference source explicitly discard its second-order term. +- **Acceptance evidence:** analytic expected-matrix tests for compressed and + oblique deformable springs, off-center rigid attachments and point pairs, + rigid springs with rotational curvature, and explicit contact linearization; + at least one mutation-sensitive test that fails if the geometric diagonal is + removed; matched before/after fixed-joint and spring benchmark runs; focused + post-bake allocation coverage; a text-first solver/scene oracle; and an + assessed headless capture if the behavior-level scene changes visibly. +- **Gates:** `pixi run lint`, focused AVBD unit tests, the relevant DART 7 world + allocation filters, `pixi run build`, parity-contract validation, matched + benchmark comparison, and two clean review passes. +- **Dependencies:** PLAN-104 paper contracts and gap audit, PLAN-122 allocation + contract, the pinned AVBD paper/source revisions, and the existing rigid and + deformable AVBD row inventories. + +The durable evidence is +[`../../plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json`](../../plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json). +The fresh focused result is 115/115 rigid AVBD and 9/9 deformable +finite-stiffness tests, plus all three post-bake allocation policies. The +60-second source Spring oracle remains finite and settles within 0.00018 m of +the expected static length. The current-build fixed-joint mechanism benchmark +improves 1.02%; the 2D/3D Spring mechanisms cost 2.32%/1.90% more. These are +descriptive same-host costs, not paper/reference-performance claims. The +120-frame software render passed pixel integrity and manual semantic review. + +## Active Packet: Section 4 Parallel Dual/Stiffness Update + +- **Value:** match Algorithm 1's additional post-primal pass without + serializing otherwise independent AVBD rows. +- **Scope:** factor one deterministic, allocation-stable CPU pass over the + promoted deformable and rigid row inventories; preserve row order and exactly + match serial dual, bounds, fracture, and stiffness state. +- **Non-goals:** CUDA closure, new row families, changes to public APIs, + nondeterministic reductions, and a paper-speedup claim before matched + achieved-accuracy evidence. +- **Acceptance evidence:** serial/parallel state equivalence for every promoted + row kind, thread-count determinism, warmed-step world-base/global/raw + allocation gates, failure propagation, and matched throughput measurements. +- **Claim boundary:** this can advance + `avbd.method.parallel_dual_stiffness_pass` only to partial until all paper row + families and CUDA share the same contract. + ## History Per-slice history and durable evidence live in the PLAN-104 progress log, the diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index 335ed1a3f2e6b..510b7c659b25d 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -8,66 +8,75 @@ this file is only the current session pointer. See ## Last Session Summary -The official VBD and AVBD PDFs, project pages, source repositories, and paper -videos were audited into an exact 176-row coverage contract. VBD now has 88 -canonical rows (methods, limitations, every figure/table/video segment, Gaia -dynamics/cloth configurations, TinyVBD, and every distinct project-page media -surface); AVBD has 88 (methods, every figure/table/video segment, every 2D/3D -source scene, both deployed demos, and every distinct project-page media -surface). No row is complete under the fail-closed rule. A checker and mutation -tests reject source-pin drift, deleted rows, invalid evidence paths, media -timecode drift, incomplete backend/predicate proof, and premature overall -completion. +The exact 176-row VBD/AVBD paper-parity contract remains fail-closed, with no +complete rows. The latest local packet advanced +`avbd.method.quasi_newton_hessian` from missing to partial: the Section 3.5 +force-scaled column-norm diagonal now covers CPU deformable distance springs, +rigid distance springs, rigid point attachments, and nonlinear point-pair +joint/motor rows. Contact rows encode their intentional Taylor-linearized +zero-curvature policy. Angular/material families, the pinned 3D spring source +divergence, full source-corpus evidence, CUDA, and comparable paper/reference +performance remain open. + +The durable packet is +[`../../plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json`](../../plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json). +It records 115 rigid and 9 deformable focused tests, full 6-DOF +finite-difference coverage, world-base/global/raw allocation closure, a fresh +uncached Release/profile build, 168/168 selected unit tests, exact-HEAD +interleaved mechanism benchmarks, a 60-second Spring text oracle, and an +assessed 120-frame software render. The method row stays partial. ## Current Branch `feature/vbd-avbd-paper-parity-contract`, based on the then-current -`origin/main` at `83110ef54ab`. The branch is local and has not been pushed. +`origin/main` at `83110ef54ab`. The fail-closed contract is committed locally +at `710cbfc1152`; the branch has not been pushed. ## Immediate Next Step -Finish the contract packet with: - -```bash -pixi run check-plan104-paper-parity -pixi run python -m pytest -q tests/test_check_plan104_paper_parity.py -pixi run check-avbd-packets -pixi run lint -``` +Implement `avbd.method.parallel_dual_stiffness_pass` next as a bounded +deterministic CPU packet: -Then implement the first missing core paper mechanism, -`avbd.method.quasi_newton_hessian`, with focused equation/kernel mutation tests -before attempting dependent scenes. Do not promote any dependent row without -source-matched CPU and CUDA solver-identity, physical, visual, and comparable -performance evidence. +- factor the post-primal dual/stiffness update over promoted deformable and + rigid row inventories; +- prove serial/parallel state equivalence, row-order and thread-count + determinism, bounds/fracture behavior, and non-finite failure propagation; +- add warmed-step world-base/global/raw allocation gates and matched throughput + measurements; +- keep the canonical row partial until every paper row family and CUDA use the + same contract. ## Context That Would Be Lost - **Keep claims narrow.** Do not claim a source-demo/paper CPU win, GPU parity, a broad breakable-wall/fracture corpus, a same-hardware paper-number match, or an all-coefficient friction win unless the tracked artifacts directly prove it. +- The pinned 2D source applies Section 3.5 generically and the 3D joint source + applies it to nonlinear point rows, but the pinned 3D Spring source omits the + geometric term. DART follows the paper for distance springs; do not call that + source equivalence without explicit adjudication and evidence. - The durable completion rule is [`../../plans/104-vertex-block-descent-solver/paper-parity-matrix.md`](../../plans/104-vertex-block-descent-solver/paper-parity-matrix.md); the older corpus matrix's `Complete` labels describe narrow historical assets, not canonical parity rows. -- Two profile-build allocator gate failures - (`DantzigSolver.ScratchUsesProvidedAllocatorForDantzigWorkBuffers`, - `World.BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`) are pre-existing - LCP / rigid-IPC issues outside AVBD scope; they reproduce only under - `DART_BUILD_PROFILE=ON` and hosted CI is green. Do not attribute them to AVBD. +- Two allocator tests previously recorded as profile-build failures + (`DantzigSolver.ScratchUsesProvidedAllocatorForDantzigWorkBuffers` and + `World.BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`) both pass in the + current fresh uncached profile build; do not carry the old failure claim + forward without a new reproduction. - PLAN-104's progress log + git history hold the full per-slice record; this folder no longer duplicates it. ## How to Resume ```bash -git checkout main && git pull --ff-only git status --short --branch -# Build if needed (AVBD tests import the compiled extension): -pixi run build +git log --oneline --decorate -5 +pixi run check-plan104-paper-parity +pixi run check-avbd-packets ``` -Then use the contract row blockers and PLAN-104 `AVBD Current Next Gaps` to -choose the next slice. Verify with the relevant focused `test_avbd_*` / -`test_world` / `test_variational_integration` targets plus the contract checker -and `pixi run lint`. +Stay in this exact worktree and branch. Start from PLAN-104's first current gap, +the Section 4 parallel dual/stiffness pass. Verify with focused AVBD and world +allocation tests, matched serial/parallel state evidence, the contract checker, +and `pixi run lint`; do not push or mutate GitHub without explicit approval. diff --git a/docs/plans/104-vertex-block-descent-solver.md b/docs/plans/104-vertex-block-descent-solver.md index 4bda953d7a943..d620c3b3c144f 100644 --- a/docs/plans/104-vertex-block-descent-solver.md +++ b/docs/plans/104-vertex-block-descent-solver.md @@ -988,7 +988,15 @@ They do not cover the AVBD source-demo or paper corpus, and the dashboard rows are narrow CPU public-World evidence only. The next bounded implementation work should prefer one of these gaps, in order: -1. **Articulated multibody AVBD extraction** — the private extractor now +1. **Parallel dual/stiffness update pass** — implement the Section 4 + post-primal row-state update as a deterministic, allocation-stable + task-parallel CPU pass over the already-promoted deformable and rigid row + inventories. Require serial/parallel state equivalence, stable row ordering, + a warmed-step allocation gate, and a matched throughput packet before + expanding the claim. This starts the missing + `avbd.method.parallel_dual_stiffness_pass` row but does not close it until + every row family and CUDA use the same contract. +2. **Articulated multibody AVBD extraction** — the private extractor now classifies free rigid-body endpoints separately from multibody links, and explicitly hard fixed, masked revolute/prismatic, and breakable multibody-link point-joint configs can bridge into the variational @@ -1146,7 +1154,7 @@ implementation work should prefer one of these gaps, in order: break/skip/reset endpoint-shape assertions, and explicit-anchor one-DOF motor reset endpoint/axis-shape assertions are current evidence rather than fresh next targets. -2. **Rigid contact persistence completeness** — broaden narrow-phase endpoint +3. **Rigid contact persistence completeness** — broaden narrow-phase endpoint feature extraction and row identity so box/sphere/cylinder/capsule/plane/mesh contact manifolds persist across realistic rigid stacks and piles, building beyond the current known/unknown shape-frame feature mapping, endpoint-A/B @@ -1191,7 +1199,7 @@ implementation work should prefer one of these gaps, in order: projection when contact normals rotate, then connect that evidence to the paper's rigid stacking/friction scenes. -3. **Paper/source-demo corpus implementation** — the durable +4. **Paper/source-demo corpus implementation** — the durable [`avbd-demo-corpus.md`](104-vertex-block-descent-solver/avbd-demo-corpus.md) matrix now owns the 19 `avbd-demo2d` scenes, 14 `avbd-demo3d` scenes, paper scenes, website/video scenes, and performance packets. The first baseline row @@ -1236,7 +1244,7 @@ implementation work should prefer one of these gaps, in order: finite-stiffness rows, hard multibody distance loop closures, and articulated per-axis compliant point-joint rows are not source-parity substitutes. -4. **GPU row parity plan** — route each landed CPU row family through the +5. **GPU row parity plan** — route each landed CPU row family through the private CUDA boundary only after the shared CUDA substrate and row inventory can preserve warm-started dual/stiffness state deterministically. @@ -1284,6 +1292,25 @@ AVBD parity additionally requires: Relocated from the dashboard on 2026-07-03; newest first. +On 2026-07-30, the bounded AVBD Section 3.5 quasi-Newton packet added a shared +fixed-size column-norm kernel and applied the paper's force-scaled geometric +diagonal to CPU deformable distance springs, rigid distance springs, rigid +point attachments, and nonlinear rigid point-pair joint/motor rows. Contact +normal and friction rows now encode their intentional Taylor-linearized +zero-curvature policy instead of inheriting the nonlinear model accidentally. +Mutation-sensitive analytic and finite-difference tests cover compressed and +off-center cases, and the promoted deformable finite-stiffness path now enters +PLAN-122's world-base/global/raw post-bake allocation gates. +[`avbd-quasi-newton-evidence.json`](104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json) +records the 60-second source Spring text oracle, assessed current-build render, +and exact-HEAD interleaved mechanism-cost benchmark. Specializing the +fixed-size projector and origin-anchor paths reduced a rejected 24-26% Spring +regression to 1.9-2.3%, while the fixed-joint row improved by 1.0%. The +canonical method row remains `partial`: angular/material families, explicit +adjudication of the pinned 3D spring source's omitted geometric term, full +source-corpus closure, achieved-accuracy reference performance, and CUDA parity +are still open. + On 2026-07-30, the official VBD and AVBD papers, project pages, repositories, and paper videos were normalized into the fail-closed [`paper-parity-matrix.md`](104-vertex-block-descent-solver/paper-parity-matrix.md) diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json index 581a0e2d16fa1..a8727626c9e44 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json @@ -148,10 +148,15 @@ }, { "id": "avbd.method.quasi_newton_hessian", - "status": "missing", - "source_locator": "Section 3.5, Equations 15-16 and diagonal geometric-stiffness approximation", - "evidence": [], - "blockers": ["The paper's always-on quasi-Newton diagonal geometric-stiffness approximation is not implemented end to end."] + "status": "partial", + "source_locator": "Section 3.5, Equations 15-17 and diagonal geometric-stiffness approximation", + "evidence": [ + "dart/simulation/detail/deformable_vbd/quasi_newton_hessian.hpp", + "tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp", + "tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp", + "docs/plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json" + ], + "blockers": ["CPU deformable distance-spring, rigid point, and rigid distance-spring rows now use the Section 3.5 column-norm approximation, with contact explicitly Taylor-linearized; a narrow current-build scene oracle, assessed render, allocation gate, and matched before/after mechanism-cost benchmark pass, but remaining row families, the pinned 3D spring source's omission of the paper geometric term, source-matched corpus closure, CUDA, and achieved-accuracy reference performance are still open."] }, { "id": "avbd.method.rigid_six_dof_blocks", diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md index 5ed916c0fc510..3e219e6407b14 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md @@ -282,7 +282,7 @@ full hard-contact/friction solver, and not CPU/GPU parity. | Joint, attachment, motor, fracture, and breakable hard constraints | Scalar hard point-attachment kernel and narrow World attachment wiring started; private fixed-joint row builders now cover all-axis fixed translation/orientation rows plus masked linear/angular axes; named private revolute/prismatic point-joint configs now build arbitrary joint-axis bases, preserve axes/masks through World point-joint input and solve coverage, and initialize/extract from private rigid-body ECS one-DOF joint entities at simulation entry; non-topology multibody-link fixed/revolute/prismatic point-joint entities can now also generate hard private AVBD configs from the simulation-entry current pose while tree-topology joints are skipped; public DART 7 `World` facades now expose free rigid-body revolute/prismatic joints through C++/dartpy with tests and a `sx_rigid_limited_joints` py-demo; public free-rigid-body revolute/prismatic velocity actuators now extract to private bounded AVBD angular/linear motor rows, with a categorized py-demo plus one end-to-end dashboard benchmark row and packet for the revolute path and focused C++/dartpy stepping coverage for the prismatic path; public articulated revolute/prismatic velocity motors and the active break-force armed articulated motor path now also have narrow dashboard benchmark rows; a narrow public free-rigid-body break-force/broken-state lifecycle now marks solved AVBD point joints broken, skips later extraction, and has an `avbd_rigid_breakable_joint` py-demo with reset/re-engagement coverage plus an `avbd_rigid_spherical_breakable_joint` py-demo for spherical anchor-only reset/re-engagement; explicitly hard fixed and masked revolute/prismatic multibody-link point-joint configs now bridge into the variational articulated solve path, breakable hard configs mark the source joint broken from projection load with fixed mark/reset, masked-revolute, and velocity-motor coverage, public one-DOF articulated `Velocity` actuators project as variational coordinate motor targets, private hard revolute/prismatic AVBD point-joint velocity actuators now project one bounded free-axis motor row for articulated endpoints with revolute/prismatic command-update, private fixed-row reset, revolute/prismatic break/reset re-engagement, and tiny positive effort-limit coverage, finite-stiffness private AVBD fixed point-joint configs on articulated endpoints now contribute compliant variational forces through persistent stiffness-ramped rows, and public same-multibody/world-link articulated fixed/revolute/prismatic/spherical facades now feed the current-pose extractor through C++/dartpy with focused spherical linear-only pinned-anchor behavior including explicit link-link/world-link anchors, bounded revolute/prismatic velocity-actuator, same-multibody/world-anchored command-update, same-multibody/world-anchored public one-DOF motor break/skip with focused dartpy stepping coverage for same-multibody/world-link revolute and prismatic explicit-anchor break/skip and reset/re-engagement cases plus non-cardinal same-multibody pair and world-link revolute/prismatic reset regressions plus C++/dartpy non-cardinal off-origin finite-effort cap checks spanning same-multibody/world-link revolute and prismatic facades, same-multibody/world-anchored public one-DOF motor break/reset re-engagement, same-multibody revolute/prismatic and world-anchored prismatic/revolute break/reset py-demo coverage, movable-movable same-multibody motor projection, explicit local/world anchor projection, same-multibody fixed and world-fixed break/reset, same-multibody fixed break/reset py-demo coverage, spherical linear-row break/reset py-demo coverage for world-link and same-multibody endpoints, same-multibody link-link and world-link fixed/spherical save/load rebuilding of the private all-axis and linear rows, same-multibody/world-link revolute/prismatic motor save/load rebuilding of the private hard rows and free-axis motor row, same-multibody/world-link fixed/spherical/revolute/prismatic broken-state save/load/reset persistence, dartpy fixed point-joint break/skip/reset stepping for same-multibody and world-link explicit all-axis anchor rows, and world-anchor coverage; broader public articulated facade coverage beyond those link-link/world-link/anchor/spherical entrypoints and save/load rebuilding, broader persistent private articulated motor coverage beyond those command-update, movable-pair, and one-DOF break/skip/break-reset checks, and broad breakable-constraint coverage remain missing | A5 | | Unified rigid/soft interactions and cloth/articulated-body coupling | Missing | A6 | | Collision candidate generation, contact persistence, and row warm-start mapping | Static half-space row keys include static box face/edge/corner feature IDs and smooth-frame tangent projection, lagged self-contact normal/friction candidate row keys plus self-contact tangent projection started for mass-spring and pure-tet envelopes, and private dynamic/rigid contact feature IDs plus canonical two-endpoint row keys, normal/friction row descriptor helpers, rigid point-pair contact/friction constructors, a private rigid contact-manifold row builder, private World-contact snapshot/solve/writeback extraction plus combined wrapper, initial contact-stage activation with focused dynamic/dynamic and static/dynamic velocity-projection coverage plus mixed-config all-or-nothing fallback and warm-started friction slip-reduction coverage including dynamic-owned, static-owned, kinematic-owned, enabled-peer/disabled-peer, simultaneous multi-contact, static/dynamic box-manifold, dynamic/dynamic box-manifold, stacked static/dynamic plus dynamic/dynamic box-manifold, multi-top stacked box-manifold, default-step dynamic/dynamic, stacked, and multi-top stacked box-manifold schedule paths, and dynamic/dynamic sphere focused regressions, box/sphere/cylinder/capsule endpoint feature persistence, known/unknown shape-frame feature mapping from world contact points including endpoint-A/B explicit compound shape-index coverage that uses narrow-phase shape-local contact points, actual `World::collide()` sphere/cylinder/capsule/plane/mesh primitive-feature coverage including shape-scoped sphere body features, plane face feature persistence, mesh face/edge/vertex feature persistence, deterministic same-feature rigid contact row ordering including actual `World::collide()` sphere/plane contact-point replay, sphere/mesh-face contact-point replay, sphere/mesh-edge contact-point replay, mesh-vertex replay, mesh-face/mesh-edge/mesh-vertex small-pose persistence plus endpoint-order stability, cylinder-cap/plane contact-point replay, capsule-cap/plane contact-point replay, cylinder-side/capsule-side contact-point replay, cylinder-rim contact-point replay, and cylinder/capsule cap/side/rim small-pose persistence plus endpoint-order stability, live sphere/plane friction tangent warm-start mapping, live sphere/plane normal-rotation friction tangent projection evidence, live sphere/plane and box-box manifold endpoint-order row-identity evidence, live endpoint-swapped box-box friction tangent projection evidence, live box-box small-pose row-persistence evidence, live box-box manifold friction warm-start evidence, live stacked-box friction warm-start and endpoint-swapped friction tangent projection evidence, live spanning-top and multi-top box-pile friction row-persistence, contact-order replay, and endpoint-swapped tangent projection evidence plus contact-stage/default-step friction slip-reduction evidence, and live box-box manifold box-face feature evidence, private rigid contact tangent-dual projection across tangent-basis changes, and the first private multibody-link endpoint projection bridge started; broader contact-stage dynamic-contact coverage, broad articulated endpoint mapping, broad feature persistence, and broad row generation remain open | A7 | -| Hessian approximation for hard constraints and stiffness-rescaling friction | Finite-stiffness spring and pure-tet material ramps started; hard-constraint quasi-Newton Hessian and friction stiffness rescaling missing | A8 | +| Hessian approximation for hard constraints and stiffness-rescaling friction | Section 3.5 column-norm geometric-stiffness lumping now covers CPU deformable distance springs, rigid distance springs, rigid point attachments, and nonlinear rigid point-pair joint/motor rows; rigid contact rows explicitly retain the paper's Taylor-linearized zero-curvature model. [`avbd-quasi-newton-evidence.json`](avbd-quasi-newton-evidence.json) records mutation-sensitive analytic tests, post-bake allocation coverage, a 60-second source Spring oracle, an assessed current-build render, and an interleaved exact-HEAD mechanism-cost benchmark. Remaining deformable/material and angular row families, friction stiffness rescaling, full source-corpus closure, achieved-accuracy reference performance, and CUDA parity are open. | A8 | | CPU parallel color sweeps plus deterministic dual update pass | Serial contact-normal dual pass started; parallel dual/update scheduling missing | A9 | | CUDA/GPU AVBD backend for all row families and scene corpus | VBD CUDA mass-spring/tet rollout only | G1-G5 | | DART-owned reproductions of 2D/3D demos and paper/video scenes | First narrow AVBD rigid-constraint py-demo started (`avbd_rigid_fixed_joint_contact` for fixed-joint rows plus ordinary contact), `avbd_rigid_revolute_motor` covers the public free-rigid-body velocity-motor row path, `avbd_articulated_revolute_motor` and `avbd_articulated_prismatic_motor` cover narrow public articulated velocity-motor command-update paths, `avbd_articulated_motor_breakable_joint` covers a narrow same-multibody public articulated revolute motor break/reset lifecycle, `avbd_articulated_prismatic_pair_motor_breakable_joint` covers a narrow same-multibody public articulated prismatic motor break/reset lifecycle, `avbd_articulated_prismatic_motor_breakable_joint` covers a narrow world-anchored public articulated prismatic motor break/reset lifecycle, `avbd_articulated_world_revolute_motor_breakable_joint` covers a narrow world-anchored public articulated revolute motor break/reset lifecycle, `avbd_rigid_breakable_joint` covers the narrow public free-rigid-body break-force lifecycle, `avbd_rigid_spherical_breakable_joint` covers the narrow public free-rigid-body spherical break/reset lifecycle, `avbd_articulated_breakable_joint` covers the narrow public world-link articulated fixed point-joint break/reset lifecycle, `avbd_articulated_fixed_pair_breakable_joint` covers the narrow public same-multibody articulated fixed point-joint break/reset lifecycle, `avbd_articulated_spherical_breakable_joint` covers the narrow public world-link articulated spherical break/reset lifecycle, `avbd_articulated_spherical_pair_breakable_joint` covers the narrow public same-multibody articulated spherical break/reset lifecycle, `avbd_articulated_high_ratio_chain`, `BM_AvbdArticulatedHighRatioChainStep`, and [`avbd-articulated-high-ratio-chain-packet.json`](avbd-articulated-high-ratio-chain-packet.json) cover a narrow five-link 200:1 high mass-ratio articulated-chain smoke, `avbd_paper_scale_high_ratio_chain`, `BM_AvbdPaperScaleHighRatioChainStep`, and [`avbd-paper-scale-high-ratio-chain-packet.json`](avbd-paper-scale-high-ratio-chain-packet.json) add 50-link/50,000:1 visual and CPU benchmark evidence without a same-hardware comparison, `avbd_empty_baseline` provides a shared smoke baseline plus source-row metadata/reference invariant for the 2D/3D source-demo empty rows with a tracked visual/benchmark packet, `avbd_demo2d_ground`, `avbd_demo2d_motor`, `avbd_demo2d_hanging_rope`, `avbd_demo2d_fracture`, `avbd_demo2d_dynamic_friction`, `avbd_demo2d_static_friction`, `avbd_demo2d_pyramid`, `avbd_demo2d_cards`, `avbd_demo2d_stack`, `avbd_demo2d_stack_ratio`, `avbd_demo2d_rod`, `avbd_demo2d_soft_body`, `avbd_demo2d_joint_grid`, `avbd_demo2d_rope`, `avbd_demo2d_net`, `avbd_demo3d_ground`, `avbd_demo3d_dynamic_friction`, `avbd_demo3d_static_friction`, `avbd_demo3d_pyramid`, `avbd_demo3d_rope`, `avbd_demo3d_heavy_rope`, `avbd_demo3d_spring`, `avbd_demo3d_spring_ratio`, `avbd_demo3d_stack`, `avbd_demo3d_stack_ratio`, `avbd_demo3d_soft_body`, `avbd_demo3d_bridge`, and `avbd_demo3d_breakable` now provide the first non-empty source-row ports with focused invariants, dashboard rows, and tracked source timing packets, and `sx_rigid_limited_joints` covers the public one-DOF joint facade path; paper figures, parameter sweeps, website demos, video/headline scenes, broad GPU evidence, benchmark-linked reproductions, and matched source-demo CPU wins remain missing | D1-D6 | diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json b/docs/plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json new file mode 100644 index 0000000000000..a73022814de0e --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json @@ -0,0 +1,220 @@ +{ + "schema_version": "dart.avbd_quasi_newton_evidence/v1", + "plan_id": "PLAN-104", + "contract_row": "avbd.method.quasi_newton_hessian", + "status": "partial", + "recorded_at": "2026-07-30", + "source": { + "paper": "Augmented Vertex Block Descent", + "paper_sha256": "7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6", + "locator": "Section 3.5, Equations 15-17", + "avbd_demo2d_revision": "74699a11f8586d3ac34474c92b1ef8feb5f587de", + "avbd_demo3d_revision": "7701bd427d55ca5d03ea1fdf331912ded9169f4b", + "reference_source_observation": "The pinned 2D solver applies the Section 3.5 column-norm approximation generically, and the pinned 3D joint source applies it to nonlinear point curvature. The pinned 3D spring source stamps only the rank-one term. DART's distance-spring path therefore implements the paper mechanism and is not claimed as 3D source equivalence." + }, + "build_identity": { + "base_commit": "710cbfc1152d3f7dd2a2dc8638560850fc5ba82f", + "build_type": "Release", + "compiler": "GNU 15.2.0", + "profile_enabled": true, + "solver_source_sha256": { + "dart/simulation/detail/deformable_vbd/quasi_newton_hessian.hpp": "acae6fabb2864c2d632e8362b12f58da8a801928d390f6b12a277765d2d81089", + "dart/simulation/detail/deformable_vbd/block_descent.hpp": "72fe53193a08eda485f056ddd3083e7601e37a031c3c7f6b6c379a72a4d4dc7e", + "dart/simulation/detail/deformable_vbd/finite_stiffness_kernel.hpp": "5081bf25a5e0aefb7502f32eebb39c1ac0db422c01632f01de4c70e7486c16fc", + "dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp": "9ca360acabf905f87aefe8179c9e8df8e7564108696703242d64a85025e43416" + } + }, + "implementation_scope": { + "covered_cpu_rows": [ + "deformable finite-stiffness distance springs", + "rigid distance springs", + "rigid point attachments", + "nonlinear rigid point-pair joint and motor rows" + ], + "contact_policy": "Normal and friction contact rows remain Taylor-linearized and intentionally omit second-order point curvature.", + "allocation_policy": "All new local matrices and column-norm diagonals are fixed-size and use no dynamic allocation." + }, + "correctness": { + "focused_tests": [ + "VbdFiniteStiffness.CompressedSpringUsesQuasiNewtonColumnNorms", + "VbdFiniteStiffness.ProjectedQuasiNewtonDiagonalPropagatesNonfiniteInputs", + "AvbdRigidBlock.WorldPointGeometricStiffnessMatchesForceJacobian", + "AvbdRigidBlock.WorldPointQuasiNewtonDiagonalPropagatesNonfiniteForce", + "AvbdRigidBlock.PointAttachmentStampsQuasiNewtonHessian", + "AvbdRigidBlock.NonlinearPointPairStampsQuasiNewtonCurvature", + "AvbdRigidBlock.TaylorLinearizedContactOmitsPointCurvature", + "AvbdRigidBlock.DistanceSpringOriginAnchorUsesQuasiNewtonHessian", + "AvbdRigidBlock.DistanceSpringIncludesRotationalQuasiNewtonCurvature", + "AvbdRigidBlock.DistanceSpringQuasiNewtonDiagonalMatchesFiniteDifference", + "AvbdRigidBlock.PointPairDistanceSpringStepReducesStretch", + "AvbdRigidBlock.RigidRowDriverReducesDistanceSpringStretch" + ], + "focused_test_result": { + "rigid_avbd": "115 passed", + "deformable_finite_stiffness": "9 passed" + }, + "full_validation": { + "build": "fresh uncached Release/profile build passed with dartpy and GUI enabled", + "selected_unit_suite": "168 passed", + "lint": "pixi run lint and pixi run check-lint passed", + "contract_checker": "176 canonical rows validated; VBD partial=40 missing=48; AVBD partial=69 missing=19", + "contract_checker_tests": "21 passed", + "avbd_packet_checker": "52 packets validated", + "plan122_checker": "18 rows validated; 14 closed" + }, + "mutation_sensitivity": "The compressed-spring and off-center rigid expected-matrix tests fail if the geometric diagonal is removed or its force sign, body polarity, or contact policy is changed.", + "source_scene_text_oracle": { + "scene": "avbd_demo3d_spring", + "command": "PYTHONPATH=build/default/cpp/Release/python:python -c <3600-step spring-length oracle>", + "steps": 3600, + "simulated_seconds": 60.0, + "initial_spring_length": 6.0, + "global_min_spring_length": 3.7055829703974634, + "global_max_spring_length": 6.0, + "tail_min_spring_length": 4.793842751652523, + "tail_mean_spring_length": 4.799822330652265, + "tail_max_spring_length": 4.805612232517255, + "expected_static_length": 4.8, + "tail_mean_static_length_error": 0.00017766934773444376, + "max_lateral_drift": 0.0, + "all_finite": true, + "verdict": "pass" + } + }, + "allocation": { + "tests": [ + "World.BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths", + "World.BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap", + "World.BakedAvbdVbdRowsDoNotMallocOnHeap" + ], + "added_scene": "deformable AVBD finite-stiffness rows", + "coverage": [ + "world-base allocator stability", + "global operator-new stability", + "raw malloc-family stability" + ], + "result": "pass" + }, + "performance": { + "claim_boundary": "Descriptive same-host before/after mechanism cost only; this is not a source-reference or paper-number comparison.", + "host": "mark26", + "cpu_pin": 18, + "cpu_reported_mhz": 5300, + "cpu_scaling_enabled": true, + "library": "Google Benchmark 1.9.5", + "baseline_commit": "710cbfc1152d3f7dd2a2dc8638560850fc5ba82f", + "baseline_binary_sha256": "9e885d0d75196a15306b75613432e0b17ee902c2fbc055b3d7699b395b870bc5", + "candidate_binary_sha256": "ed0620c7f8e57a9515997f458c66aa417e070c7a27f10cc715ac523a37aed1bc", + "protocol": "Exact-HEAD baseline and working-tree candidate were run baseline-candidate-baseline-candidate with identical filters, 0.05-second minimum time, five repetitions per run, and aggregate-only JSON.", + "command": "taskset -c 18 --benchmark_filter='BM_AvbdRigidFixedJointStep/32$|BM_AvbdDemo2dSpringStep$|BM_AvbdDemo3dSpringStep$' --benchmark_min_time=0.05s --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out= --benchmark_out_format=json", + "runs": [ + { + "order": 1, + "kind": "baseline", + "json_sha256": "c2bdbb503ab375d4ad96df673b105208beba00aaf17aa771ac6ee08d9bdc6d7a", + "median_real_time_ns": { + "BM_AvbdRigidFixedJointStep/32": 373922.4555743712, + "BM_AvbdDemo2dSpringStep": 6561.910350388325, + "BM_AvbdDemo3dSpringStep": 7935.393019341876 + } + }, + { + "order": 2, + "kind": "candidate", + "json_sha256": "50c43957a04523a699f62aee61de89785961f177f8bb71d0a887a993dffe3b43", + "median_real_time_ns": { + "BM_AvbdRigidFixedJointStep/32": 369968.3770171674, + "BM_AvbdDemo2dSpringStep": 6715.277614827978, + "BM_AvbdDemo3dSpringStep": 8065.158527918522 + } + }, + { + "order": 3, + "kind": "baseline", + "json_sha256": "16c9cbc8d81debdd0f968601cfcee8d159175cfeb186d69292c5cee86b523452", + "median_real_time_ns": { + "BM_AvbdRigidFixedJointStep/32": 373919.47646971536, + "BM_AvbdDemo2dSpringStep": 6568.656915683515, + "BM_AvbdDemo3dSpringStep": 7931.283613344918 + } + }, + { + "order": 4, + "kind": "candidate", + "json_sha256": "37e4edc9a6e345d175b835ab6c1bbfe4c9b01250a5f84bfa9d71598951335926", + "median_real_time_ns": { + "BM_AvbdRigidFixedJointStep/32": 370213.6858900356, + "BM_AvbdDemo2dSpringStep": 6720.304535257326, + "BM_AvbdDemo3dSpringStep": 8103.35487577972 + } + } + ], + "mean_of_paired_medians": { + "BM_AvbdRigidFixedJointStep/32": { + "baseline_real_time_ns": 373920.96602204326, + "candidate_real_time_ns": 370091.0314536015, + "delta_percent": -1.024263124153 + }, + "BM_AvbdDemo2dSpringStep": { + "baseline_real_time_ns": 6565.283633035921, + "candidate_real_time_ns": 6717.791075042653, + "delta_percent": 2.322937599212 + }, + "BM_AvbdDemo3dSpringStep": { + "baseline_real_time_ns": 7933.338316343397, + "candidate_real_time_ns": 8084.256701849121, + "delta_percent": 1.902331395534 + } + }, + "rejected_candidate": { + "reason": "The first generic fixed-matrix implementation was rejected before acceptance because it added avoidable local matrix work.", + "delta_percent": { + "BM_AvbdRigidFixedJointStep/32": 4.286910299, + "BM_AvbdDemo2dSpringStep": 26.243990177, + "BM_AvbdDemo3dSpringStep": 23.898732712 + } + } + }, + "visual": { + "scene": "avbd_demo3d_spring", + "capture_command": "LIBGL_ALWAYS_SOFTWARE=1 MESA_LOADER_DRIVER_OVERRIDE=llvmpipe PYTHONPATH=build/default/cpp/Release/python:python BUILD_TYPE=Release scripts/capture_py_demo.py --scene avbd_demo3d_spring --frames 120 --width 640 --height 480 --view three-quarter --camera-distance 20 --camera-target 0,0,7 --output-dir ", + "frames": 120, + "image_sha256": "1561475b45015991d04ee6c91c64bcd42f516cf37e24652dfbd0b6fd0e4da715", + "manifest_sha256": "5a29498c819200ade38054c7888959f4b873549afeb6cb41353d6373bad04f2e", + "image_verdict_sha256": "13915a06279b735a433b7b828b772784bcae63afb757b0511122e0a993711be9", + "machine_verdict": { + "scope": "pixel-integrity", + "non_blank": true, + "contrast": false, + "contrast_required": false, + "pass": true + }, + "manual_semantic_review": { + "inspected_frames": [ + { + "frame": 1, + "sha256": "d86812dc2c762f0e2c2ca63a5aebc672be4fd0bb6c7d95c7890570dab23326ee" + }, + { + "frame": 60, + "sha256": "6eac03725e5e0537943269709a1ffce661edb2f44d8353c342835841eb20f159" + }, + { + "frame": 120, + "sha256": "1561475b45015991d04ee6c91c64bcd42f516cf37e24652dfbd0b6fd0e4da715" + } + ], + "observations": "The dynamic orange block remains finite, centered directly below the static dark anchor, visibly moves through the sequence, and neither explodes nor penetrates the ground.", + "limitations": "The scene does not draw spring geometry, so attachment and equilibrium are established by the separate text oracle rather than inferred from the image. This is a narrow current-build behavior check, not source visual parity.", + "verdict": "pass" + } + }, + "remaining_blockers": [ + "Angular log-map row curvature is not implemented.", + "Other paper constraint families have not received row-specific Section 3.5 closure.", + "The pinned 3D spring reference omits the paper's geometric term, so source-demo equivalence needs an explicit adjudication and separate evidence.", + "The CUDA AVBD backend is missing.", + "No source-matched achieved-accuracy performance comparison or paper-number comparison is closed.", + "Dependent figures, videos, online demos, and project-page rows remain partial or missing." + ] +} diff --git a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md index 2732229aefb40..f6d39ef7770fa 100644 --- a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md +++ b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md @@ -32,15 +32,15 @@ below, so neither solver family has a paper-parity claim. | VBD TinyVBD | 1 | 1 | 0 | 0 | 0 | | VBD project page | 3 | 0 | 0 | 3 | 0 | | **VBD total** | **88** | **40** | **0** | **48** | **0** | -| AVBD method | 20 | 16 | 0 | 4 | 0 | +| AVBD method | 20 | 17 | 0 | 3 | 0 | | AVBD paper figures | 15 | 9 | 0 | 6 | 0 | | AVBD paper tables | 2 | 1 | 0 | 1 | 0 | | AVBD official video | 14 | 7 | 0 | 7 | 0 | | AVBD demo2d | 19 | 19 | 0 | 0 | 0 | | AVBD demo3d | 14 | 14 | 0 | 0 | 0 | | AVBD project page | 4 | 2 | 0 | 2 | 0 | -| **AVBD total** | **88** | **68** | **0** | **20** | **0** | -| **Grand total** | **176** | **108** | **0** | **68** | **0** | +| **AVBD total** | **88** | **69** | **0** | **19** | **0** | +| **Grand total** | **176** | **109** | **0** | **67** | **0** | `Partial` means only that at least one relevant DART artifact exists and that the row still records an explicit blocker. It is negative evidence against a diff --git a/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md b/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md index d4f981b9169f2..d7389e5203505 100644 --- a/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md +++ b/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md @@ -22,7 +22,7 @@ Status meanings: | R-004 | Closed | Large rigid IPC equality KKT systems | `BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`, `BakedDynamicRigidIpcStepsDoNotAllocateGlobalHeap`, and `BakedRigidIpcLargeEqualityKktStepsDoNotMallocOnHeap` cover a fixed-joint rigid IPC chain whose equality KKT system exceeds the stack solve cap and uses retained dynamic KKT storage after bake. | None for the large rigid IPC equality KKT row. | WP-122.2, PLAN-082, PLAN-083 | | M-001 | Closed | Semi-implicit multibody velocity and force paths | `BakedVelocityActuatedMultibodyStepsDoNotMallocOnHeap` covers first-post-bake raw malloc for k == 1, k >= 2, and velocity-actuated articulated contact scenes; global-heap gates cover semi-implicit force scratch. | None for the semi-implicit multibody velocity/force row. | WP-122.4, PLAN-080 | | M-002 | Closed | Variational multibody integration | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers representative variational scratch, compliant contact, dual state, loop closure, and long articulated point-joint scenes; `BakedVariationalMultibodyStepsDoNotMallocOnHeap` covers the same scenes for first-post-bake raw malloc. | None for currently promoted variational rows; future PLAN-084 rows require world-base/global/raw post-bake gates before promotion. | WP-122.4, PLAN-084 | -| M-003 | Closed | AVBD/VBD rows selected inside DART 7 `World::step()` | Global-heap gates cover representative rigid AVBD rows, deformable AVBD rows, and a VBD Chebyshev self-contact row; `BakedAvbdVbdRowsDoNotMallocOnHeap` covers the same representative rows for first-post-bake raw malloc. | None for currently promoted AVBD/VBD rows; extend with PLAN-104 corpus rows as they are promoted. | WP-122.4, PLAN-104 | +| M-003 | Closed | AVBD/VBD rows selected inside DART 7 `World::step()` | World-base/global-heap gates cover representative rigid AVBD rows, deformable AVBD rows including the Section 3.5 finite-stiffness spring path, and a VBD Chebyshev self-contact row; `BakedAvbdVbdRowsDoNotMallocOnHeap` covers the same representative rows for first-post-bake raw malloc. | None for currently promoted AVBD/VBD rows; extend with PLAN-104 corpus rows as they are promoted. | WP-122.4, PLAN-104 | | M-004 | Open | Unified Newton-barrier multibody rows | Open. PLAN-083 owns the solver direction. | Require world-base/global/raw post-bake gates before any unified Newton-barrier row is promoted into `World::step()`. | WP-122.4, PLAN-083 | | D-001 | Closed | Basic deformable projected-Newton and storage paths | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers particles, springs, FEM, self-contact, static obstacle, CCD, mixed storage, and production-grid global-heap rows; `BakedBasicDeformableRowsDoNotMallocOnHeap` covers dense direct, iterative sparse CG, matrix-free production, obstacle/friction, CCD, and mixed storage rows for first-post-bake raw malloc. | None for current dense direct, iterative sparse CG, matrix-free, obstacle/friction, CCD, and mixed storage rows. | WP-122.3, PLAN-081 | | D-002 | Closed | Scripted deformable boundary | World-base/global gates and `BakedScriptedDeformableBoundaryStepsDoNotMallocOnHeap` cover the first post-bake scripted boundary path; bake primes the retained dense projected-Newton factorization scratch before the measured loop. | None for the scripted deformable boundary row. | WP-122.1, WP-122.3 | diff --git a/docs/plans/dashboard.md b/docs/plans/dashboard.md index 859f525503dca..7f8c749bc5aa4 100644 --- a/docs/plans/dashboard.md +++ b/docs/plans/dashboard.md @@ -218,27 +218,21 @@ its own line so status updates remain git-history friendly. ### PLAN-104: Vertex Block Descent Solver -- Owner doc: - [`104-vertex-block-descent-solver.md`](104-vertex-block-descent-solver.md) +- Owner doc: [PLAN-104](104-vertex-block-descent-solver.md) - Status: Active - Horizon: Now - Dimension: Algorithm extensibility -- Next step: Maintainer direction promotes Augmented VBD (`avbd-2025`) as the - current implementation focus: continue the active +- Next step: Continue the active Augmented VBD (`avbd-2025`) [`../dev_tasks/avbd_solver/`](../dev_tasks/avbd_solver/) tracker with [`104-vertex-block-descent-solver/avbd-paper-gap-audit.md`](104-vertex-block-descent-solver/avbd-paper-gap-audit.md) - toward AVBD hard constraints, bounded contact/friction, finite-stiffness - ramping, rigid/articulated blocks, all paper/demo scenes, and CPU/GPU benchmark - parity. Select the next missing mechanism or reproduction from the - machine-checked 176-row + toward hard constraints, bounded contact/friction, finite-stiffness ramping, + rigid/articulated blocks, all paper/demo scenes, and CPU/GPU benchmark parity. + Select the next missing mechanism or reproduction from the machine-checked + 176-row [`104-vertex-block-descent-solver/paper-parity-matrix.md`](104-vertex-block-descent-solver/paper-parity-matrix.md); - the older - [`104-vertex-block-descent-solver/avbd-demo-corpus.md`](104-vertex-block-descent-solver/avbd-demo-corpus.md) - remains a narrow artifact inventory rather than the global completion gate. Remaining VBD closeout (self-contact tangential friction, OGC audit, benchmark JSON, paper tetrahedral reproduction, Phase 8b SoA + Gaia-CPU, Phase 9 same-GPU - Table 1) stays tracked in the owner plan. History: see the progress log in - [`104-vertex-block-descent-solver.md`](104-vertex-block-descent-solver.md). + Table 1) stays tracked in the owner plan and its progress log. - Gate: VBD progress is not complete until the implementation distinguishes each internal kernel slice from a wired solver, keeps VBD naming backend-neutral, proves per-vertex force/Hessian correctness, an explicit diff --git a/scripts/check_plan104_paper_parity.py b/scripts/check_plan104_paper_parity.py index 5d3bfe85b9f3a..61421b3ab9ddf 100644 --- a/scripts/check_plan104_paper_parity.py +++ b/scripts/check_plan104_paper_parity.py @@ -394,7 +394,7 @@ def _numbered_ids(prefix: str, count: int) -> tuple[str, ...]: } EXPECTED_LOCATOR_DIGESTS = { "vbd": "0a9746e4b3c5af8f6207d4c210670116dfab89b6b2a34009549183e6ae0774b7", - "avbd": "3830039626580ca628c60cceeda942fd811f174e6b640351bd62d405519d67cd", + "avbd": "d64da409fe6b51dd3eee37b4ee2794e44c58baca35813e62092ae6c7e2619776", } EXPECTED_SOURCE_VALUES = { diff --git a/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp b/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp index c704af87c7c32..2a635284d16be 100644 --- a/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp +++ b/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp @@ -208,6 +208,60 @@ TEST(AvbdRigidBlock, RigidStepUpdatesTranslationAndOrientation) EXPECT_NEAR(state.orientation.norm(), 1.0, 1e-15); } +//============================================================================== +TEST(AvbdRigidBlock, WorldPointGeometricStiffnessMatchesForceJacobian) +{ + vbd::AvbdRigidBodyState state; + state.position = Vec3(0.25, -0.5, 1.0); + state.orientation = rotationZ(0.3) * rotationX(-0.2); + const Vec3 localPoint(0.4, -0.7, 0.2); + const Vec3 worldForce(3.0, -2.0, 5.0); + const Vec3 worldPoint = vbd::avbdRigidBodyWorldPoint(state, localPoint); + + const Eigen::Matrix3d analytic = vbd::avbdRigidWorldPointGeometricStiffness( + state, worldPoint, worldForce); + Eigen::Matrix3d numeric; + constexpr double epsilon = 1e-6; + for (Eigen::Index axis = 0; axis < 3; ++axis) { + Vec3 angularStep = Vec3::Zero(); + angularStep[axis] = epsilon; + + vbd::AvbdRigidBodyState plus = state; + plus.orientation + = vbd::avbdRigidOrientationDelta(angularStep) * state.orientation; + vbd::AvbdRigidBodyState minus = state; + minus.orientation + = vbd::avbdRigidOrientationDelta(-angularStep) * state.orientation; + + const Vec3 plusArm + = vbd::avbdRigidBodyWorldPoint(plus, localPoint) - plus.position; + const Vec3 minusArm + = vbd::avbdRigidBodyWorldPoint(minus, localPoint) - minus.position; + numeric.col(axis) + = -(plusArm.cross(worldForce) - minusArm.cross(worldForce)) + / (2.0 * epsilon); + } + + EXPECT_NEAR((analytic - numeric).norm(), 0.0, 1e-8); +} + +//============================================================================== +TEST(AvbdRigidBlock, WorldPointQuasiNewtonDiagonalPropagatesNonfiniteForce) +{ + vbd::AvbdRigidBodyState state; + vbd::AvbdRigidBodyBlock block; + const double nan = std::numeric_limits::quiet_NaN(); + + vbd::addAvbdRigidWorldPointQuasiNewtonGeometricDiagonal( + block, + state, + Vec3::UnitY(), + Vec3(nan, 1.0, 0.0), + vbd::AvbdRigidPointCurvatureModel::QuasiNewton); + + EXPECT_FALSE(block.hessian.allFinite()); +} + //============================================================================== TEST(AvbdRigidBlock, InertiaTermSolvesBackToInertialTarget) { @@ -275,7 +329,7 @@ TEST(AvbdRigidBlock, LowerTriangleInertiaTermMatchesFullForScaledOrientations) } //============================================================================== -TEST(AvbdRigidBlock, PointAttachmentStampsForceTorqueAndPsdHessian) +TEST(AvbdRigidBlock, PointAttachmentStampsQuasiNewtonHessian) { vbd::AvbdRigidBodyState state; @@ -295,11 +349,10 @@ TEST(AvbdRigidBlock, PointAttachmentStampsForceTorqueAndPsdHessian) EXPECT_DOUBLE_EQ(forceMagnitude, 40.0); EXPECT_NEAR((block.force - 40.0 * expectedDirection).norm(), 0.0, 1e-12); - EXPECT_NEAR( - (block.hessian - 40.0 * expectedDirection * expectedDirection.transpose()) - .norm(), - 0.0, - 1e-12); + vbd::Matrix6d expectedHessian + = 40.0 * expectedDirection * expectedDirection.transpose(); + expectedHessian(3, 3) += 40.0; + EXPECT_NEAR((block.hessian - expectedHessian).norm(), 0.0, 1e-12); EXPECT_GE( block.hessian.selfadjointView().eigenvalues().minCoeff(), -1e-12); @@ -407,6 +460,78 @@ TEST(AvbdRigidBlock, PointPairIncludesTorqueDirections) (secondDirection.tail<3>() - Vec3(0.0, 0.0, -1.0)).norm(), 0.0, 1e-12); } +//============================================================================== +TEST(AvbdRigidBlock, NonlinearPointPairStampsQuasiNewtonCurvature) +{ + vbd::AvbdRigidBodyState stateA; + vbd::AvbdRigidBodyState stateB; + stateB.position = Vec3::UnitX(); + + vbd::AvbdRigidPointPairRow row; + row.localPointA = Vec3::UnitY(); + row.axis = Vec3::UnitX(); + row.state.stiffness = 25.0; + + vbd::AvbdRigidBodyBlock blockA; + vbd::AvbdRigidBodyBlock blockB; + const double forceMagnitude = vbd::addAvbdRigidPointPair( + blockA, blockB, stateA, stateB, row, /*alpha=*/0.0); + + const vbd::Vector6d firstDirection + = vbd::avbdRigidPointPairDirectionA(stateA, row); + const vbd::Vector6d secondDirection + = vbd::avbdRigidPointPairDirectionB(stateB, row); + vbd::Matrix6d expectedA = 25.0 * firstDirection * firstDirection.transpose(); + expectedA(3, 3) += 25.0; + const vbd::Matrix6d expectedB + = 25.0 * secondDirection * secondDirection.transpose(); + + EXPECT_DOUBLE_EQ(forceMagnitude, 25.0); + EXPECT_NEAR((blockA.hessian - expectedA).norm(), 0.0, 1e-12); + EXPECT_NEAR((blockB.hessian - expectedB).norm(), 0.0, 1e-12); +} + +//============================================================================== +TEST(AvbdRigidBlock, TaylorLinearizedContactOmitsPointCurvature) +{ + vbd::AvbdRigidBodyState stateA; + vbd::AvbdRigidBodyState stateB; + stateB.position = Vec3::UnitX(); + + vbd::AvbdScalarRowState rowState; + rowState.stiffness = 25.0; + const vbd::AvbdRigidPointPairRow row = vbd::makeAvbdRigidContactNormalRow( + Vec3::UnitY(), + Vec3::Zero(), + Vec3::UnitX(), + /*targetDistance=*/0.0, + rowState); + + vbd::AvbdRigidBodyBlock blockA; + vbd::AvbdRigidBodyBlock blockB; + const double forceMagnitude = vbd::addAvbdRigidPointPair( + blockA, blockB, stateA, stateB, row, /*alpha=*/0.0); + + const vbd::Vector6d firstDirection + = vbd::avbdRigidPointPairDirectionA(stateA, row); + const vbd::Vector6d secondDirection + = vbd::avbdRigidPointPairDirectionB(stateB, row); + + EXPECT_EQ( + row.curvatureModel, vbd::AvbdRigidPointCurvatureModel::TaylorLinearized); + EXPECT_DOUBLE_EQ(forceMagnitude, 25.0); + EXPECT_NEAR( + (blockA.hessian - 25.0 * firstDirection * firstDirection.transpose()) + .norm(), + 0.0, + 1e-12); + EXPECT_NEAR( + (blockB.hessian - 25.0 * secondDirection * secondDirection.transpose()) + .norm(), + 0.0, + 1e-12); +} + //============================================================================== TEST(AvbdRigidBlock, PointPairOriginAnchorDirectionStaysTranslational) { @@ -521,7 +646,7 @@ TEST(AvbdRigidBlock, PointPairDistanceSpringStampsRadialFiniteStiffness) } //============================================================================== -TEST(AvbdRigidBlock, DistanceSpringOriginAnchorHessianStaysTranslational) +TEST(AvbdRigidBlock, DistanceSpringOriginAnchorUsesQuasiNewtonHessian) { vbd::AvbdRigidBodyState state; state.position = Vec3(1.0, -2.0, 0.5); @@ -534,22 +659,15 @@ TEST(AvbdRigidBlock, DistanceSpringOriginAnchorHessianStaysTranslational) vbd::AvbdRigidBodyBlock block; vbd::addAvbdRigidDistanceSpringHessianAtWorldPoint( - block, - state, - state.position, - axis, - length, - restLength, - stiffness, - /*clampToPsd=*/true); + block, state, state.position, axis, length, restLength, stiffness); const Eigen::Matrix3d nnT = axis * axis.transpose(); - const Eigen::Matrix3d pointHessian - = stiffness - * (nnT - + (1.0 - restLength / length) * (Eigen::Matrix3d::Identity() - nnT)); vbd::Matrix6d expectedHessian = vbd::Matrix6d::Zero(); - expectedHessian.topLeftCorner<3, 3>() = pointHessian; + expectedHessian.topLeftCorner<3, 3>() = stiffness * nnT; + const double forceMagnitude = stiffness * (length - restLength); + expectedHessian.diagonal().head<3>() + += (std::abs(forceMagnitude) / length) + * (Vec3::Ones() - axis.cwiseAbs2()).cwiseSqrt(); EXPECT_NEAR((block.hessian - expectedHessian).norm(), 0.0, 1e-12); const double translationAngularNorm @@ -559,6 +677,107 @@ TEST(AvbdRigidBlock, DistanceSpringOriginAnchorHessianStaysTranslational) EXPECT_NEAR(angularNorm, 0.0, 1e-12); } +//============================================================================== +TEST(AvbdRigidBlock, DistanceSpringIncludesRotationalQuasiNewtonCurvature) +{ + vbd::AvbdRigidBodyState stateA; + vbd::AvbdRigidBodyState stateB; + stateB.position = 3.0 * Vec3::UnitY(); + + vbd::AvbdRigidPointPairDistanceSpringRow row; + row.localPointA = Vec3::UnitY(); + row.restLength = 1.0; + row.state.stiffness = 8.0; + + vbd::AvbdRigidBodyBlock blockA; + vbd::AvbdRigidBodyBlock blockB; + const double forceMagnitude = vbd::addAvbdRigidPointPairDistanceSpring( + blockA, blockB, stateA, stateB, row); + + vbd::Matrix6d expectedA = vbd::Matrix6d::Zero(); + expectedA.diagonal() = (vbd::Vector6d() << std::sqrt(32.0), + 8.0, + std::sqrt(32.0), + std::sqrt(160.0), + 0.0, + std::sqrt(160.0)) + .finished(); + vbd::Matrix6d expectedB = vbd::Matrix6d::Zero(); + expectedB.diagonal() + = (vbd::Vector6d() << 4.0, 8.0, 4.0, 0.0, 0.0, 0.0).finished(); + + EXPECT_DOUBLE_EQ(forceMagnitude, 8.0); + EXPECT_NEAR( + (blockA.force.head<3>() - 8.0 * Vec3::UnitY()).norm(), 0.0, 1e-12); + EXPECT_NEAR( + (blockB.force.head<3>() + 8.0 * Vec3::UnitY()).norm(), 0.0, 1e-12); + EXPECT_NEAR((blockA.hessian - expectedA).norm(), 0.0, 1e-12); + EXPECT_NEAR((blockB.hessian - expectedB).norm(), 0.0, 1e-12); +} + +//============================================================================== +TEST(AvbdRigidBlock, DistanceSpringQuasiNewtonDiagonalMatchesFiniteDifference) +{ + vbd::AvbdRigidBodyState state; + state.position = Vec3(0.2, -0.3, 0.4); + state.orientation = rotationZ(0.35) * rotationX(-0.2); + const Vec3 localPoint(0.6, -0.2, 0.5); + const Vec3 target(1.4, 0.8, 1.2); + const Vec3 worldPoint = vbd::avbdRigidBodyWorldPoint(state, localPoint); + const Vec3 relative = target - worldPoint; + const double length = relative.norm(); + const Vec3 axis = relative / length; + const double restLength = 0.7 * length; + constexpr double stiffness = 11.0; + const double forceMagnitude = stiffness * (length - restLength); + const vbd::Vector6d direction + = vbd::avbdRigidDistanceSpringDirectionAtWorldPoint( + state, worldPoint, axis); + + vbd::AvbdRigidBodyBlock block; + vbd::addAvbdRigidDistanceSpringHessianAtWorldPoint( + block, state, worldPoint, axis, length, restLength, stiffness); + + vbd::Matrix6d numericGeometricStiffness; + constexpr double epsilon = 1e-6; + for (Eigen::Index coordinate = 0; coordinate < 6; ++coordinate) { + vbd::AvbdRigidBodyState plus = state; + vbd::AvbdRigidBodyState minus = state; + if (coordinate < 3) { + plus.position[coordinate] += epsilon; + minus.position[coordinate] -= epsilon; + } else { + Vec3 angularStep = Vec3::Zero(); + angularStep[coordinate - 3] = epsilon; + plus.orientation + = vbd::avbdRigidOrientationDelta(angularStep) * state.orientation; + minus.orientation + = vbd::avbdRigidOrientationDelta(-angularStep) * state.orientation; + } + + const Vec3 plusPoint = vbd::avbdRigidBodyWorldPoint(plus, localPoint); + const Vec3 minusPoint = vbd::avbdRigidBodyWorldPoint(minus, localPoint); + const vbd::Vector6d plusDirection + = vbd::avbdRigidDistanceSpringDirectionAtWorldPoint( + plus, plusPoint, (target - plusPoint).normalized()); + const vbd::Vector6d minusDirection + = vbd::avbdRigidDistanceSpringDirectionAtWorldPoint( + minus, minusPoint, (target - minusPoint).normalized()); + + // The direction is the negative constraint gradient. Keep the force + // magnitude fixed here so this isolates f * d^2 C; the rank-one term + // separately accounts for the derivative of f. + numericGeometricStiffness.col(coordinate) + = -forceMagnitude * (plusDirection - minusDirection) / (2.0 * epsilon); + } + + vbd::Matrix6d expected = stiffness * (direction * direction.transpose()); + expected.diagonal() + += vbd::avbdQuasiNewtonGeometricDiagonal(numericGeometricStiffness); + + EXPECT_NEAR((block.hessian - expected).norm(), 0.0, 5e-8); +} + //============================================================================== TEST(AvbdRigidBlock, DistanceSpringOriginAnchorDirectionStaysTranslational) { @@ -755,6 +974,8 @@ TEST(AvbdRigidBlock, ContactNormalPointPairUsesGapOffsetAndBounds) /*targetDistance=*/0.2, rowState); + EXPECT_EQ( + row.curvatureModel, vbd::AvbdRigidPointCurvatureModel::TaylorLinearized); EXPECT_DOUBLE_EQ(row.bounds.lower, 0.0); EXPECT_TRUE(std::isinf(row.bounds.upper)); EXPECT_NEAR( @@ -806,6 +1027,8 @@ TEST(AvbdRigidBlock, ContactFrictionPointPairUsesLaggedRelativeOffset) /*forceLimit=*/5.0, rowState); + EXPECT_EQ( + row.curvatureModel, vbd::AvbdRigidPointCurvatureModel::TaylorLinearized); EXPECT_NEAR(row.axis.norm(), 1.0, 1e-12); EXPECT_NEAR(row.offset, -0.1, 1e-12); EXPECT_DOUBLE_EQ(row.bounds.lower, -5.0); diff --git a/tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp b/tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp index d0c302271f135..e7a71e53ca5be 100644 --- a/tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp +++ b/tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp @@ -36,6 +36,7 @@ #include #include +#include #include namespace vbd = dart::simulation::detail::deformable_vbd; @@ -92,6 +93,42 @@ TEST(VbdFiniteStiffness, SpringConstraintValueIsStretch) -0.25); } +//============================================================================== +TEST(VbdFiniteStiffness, CompressedSpringUsesQuasiNewtonColumnNorms) +{ + const Vec3 self = Vec3::Zero(); + const Vec3 other(0.3, 0.4, 0.0); + const vbd::AvbdSpringFiniteStiffnessRow row + = makeSpringRow(/*stiffness=*/8.0, /*materialStiffness=*/20.0); + + vbd::VertexBlock block; + vbd::addAvbdSpringFiniteStiffness( + block, self, other, /*restLength=*/1.0, row); + + const Vec3 direction(0.6, 0.8, 0.0); + const Vec3 expectedForce = -4.0 * direction; + Eigen::Matrix3d expectedHessian = 8.0 * direction * direction.transpose(); + expectedHessian.diagonal() += Vec3(6.4, 4.8, 8.0); + + EXPECT_NEAR((block.force - expectedForce).norm(), 0.0, 1e-12); + EXPECT_NEAR((block.hessian - expectedHessian).norm(), 0.0, 1e-12); + EXPECT_GT(block.hessian.determinant(), 0.0); +} + +//============================================================================== +TEST(VbdFiniteStiffness, ProjectedQuasiNewtonDiagonalPropagatesNonfiniteInputs) +{ + const double nan = std::numeric_limits::quiet_NaN(); + + const Vec3 nonfiniteDirection = vbd::avbdQuasiNewtonProjectedDistanceDiagonal( + Vec3(nan, 0.0, 1.0), /*scale=*/2.0); + const Vec3 nonfiniteScale + = vbd::avbdQuasiNewtonProjectedDistanceDiagonal(Vec3::UnitX(), nan); + + EXPECT_FALSE(nonfiniteDirection.allFinite()); + EXPECT_FALSE(nonfiniteScale.allFinite()); +} + //============================================================================== TEST(VbdFiniteStiffness, TetMaterialConstraintValueIsStrainNorm) { diff --git a/tests/unit/simulation/world/test_world.cpp b/tests/unit/simulation/world/test_world.cpp index 4663e644089c7..9f04ab4159974 100644 --- a/tests/unit/simulation/world/test_world.cpp +++ b/tests/unit/simulation/world/test_world.cpp @@ -3328,6 +3328,37 @@ void enableAvbdSelfContactFrictionRows(dart::simulation::World& world) } } +void configureDeformableAvbdFiniteStiffnessRowsScene( + dart::simulation::World& world) +{ + namespace sx = dart::simulation; + + world.setGravity(Eigen::Vector3d::Zero()); + world.setTimeStep(0.01); + + sx::DeformableBodyOptions options; + options.positions = {Eigen::Vector3d::Zero(), Eigen::Vector3d(0.8, 0.6, 0.0)}; + options.masses = {1.0, 1.0}; + options.fixedNodes = {0}; + options.edges = {sx::DeformableEdge{0, 1, 0.5}}; + options.edgeStiffness = 1000.0; + world.addDeformableBody("avbd_finite_stiffness_spring", options); + + sx::comps::DeformableVbdConfig cfg; + cfg.enabled = true; + cfg.iterations = 4; + cfg.useAvbdFiniteStiffnessRows = true; + cfg.avbdFiniteStiffnessStart = 1.0; + cfg.avbdBeta = 100.0; + cfg.avbdGamma = 1.0; + cfg.avbdMaxStiffness = 1000.0; + + auto& registry = sx::detail::registryOf(world); + for (const auto entity : registry.view()) { + registry.emplace_or_replace(entity, cfg); + } +} + void configureAvbdSelfContactFrictionRowsScene(dart::simulation::World& world) { namespace sx = dart::simulation; @@ -8358,6 +8389,9 @@ TEST(World, BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths) expectNoWorldBaseAllocatorActivityDuringBakedSteps( "deformable inter-body surface CCD production grid crossing", configureDeformableInterBodySurfaceCcdProductionGridScene); + expectNoWorldBaseAllocatorActivityDuringBakedSteps( + "deformable AVBD finite-stiffness rows", + configureDeformableAvbdFiniteStiffnessRowsScene); expectNoWorldBaseAllocatorActivityDuringBakedSteps( "deformable AVBD self-contact friction rows", configureAvbdSelfContactFrictionRowsScene); @@ -10150,6 +10184,9 @@ TEST(World, BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap) expectNoGlobalHeapAllocationsDuringBakedSteps( "deformable inter-body surface CCD production grid crossing", configureDeformableInterBodySurfaceCcdProductionGridScene); + expectNoGlobalHeapAllocationsDuringBakedSteps( + "deformable AVBD finite-stiffness rows", + configureDeformableAvbdFiniteStiffnessRowsScene); expectNoGlobalHeapAllocationsDuringBakedSteps( "deformable AVBD self-contact friction rows", configureAvbdSelfContactFrictionRowsScene); @@ -10297,6 +10334,9 @@ TEST(World, BakedAvbdVbdRowsDoNotMallocOnHeap) expectNoRawHeapAllocationsDuringFirstPostBakeSteps( "rigid AVBD distance-spring rows", configureRigidAvbdDistanceSpringRowsScene); + expectNoRawHeapAllocationsDuringFirstPostBakeSteps( + "deformable AVBD finite-stiffness rows", + configureDeformableAvbdFiniteStiffnessRowsScene); expectNoRawHeapAllocationsDuringFirstPostBakeSteps( "deformable AVBD self-contact friction rows", configureAvbdSelfContactFrictionRowsScene); From 0b0154573b842c142d31ef9feef4567c5677c4fd Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Thu, 30 Jul 2026 05:13:31 -0700 Subject: [PATCH 03/13] Implement AVBD parallel dual update pass --- CHANGELOG.md | 5 +- .../compute/deformable_dynamics_stage.cpp | 13 +- dart/simulation/compute/parallel_executor.cpp | 206 +++++++- dart/simulation/compute/parallel_executor.hpp | 6 + .../compute/rigid_body_contact_stage.cpp | 8 +- .../detail/deformable_vbd/block_descent.hpp | 468 ++++++++++++------ .../deformable_vbd/parallel_row_update.hpp | 75 +++ .../detail/rigid_avbd/rigid_block_kernel.hpp | 264 ++++++---- .../detail/rigid_avbd/rigid_world_contact.hpp | 6 +- docs/dev_tasks/avbd_solver/README.md | 61 ++- docs/dev_tasks/avbd_solver/RESUME.md | 47 +- docs/plans/104-vertex-block-descent-solver.md | 50 +- .../avbd-paper-coverage-contract.json | 9 +- .../avbd-paper-gap-audit.md | 16 +- .../avbd-parallel-dual-update-evidence.json | 258 ++++++++++ .../paper-parity-matrix.md | 6 +- .../coverage-matrix.md | 2 +- tests/benchmark/simulation/CMakeLists.txt | 5 + .../bm_avbd_parallel_row_update.cpp | 140 ++++++ .../simulation/compute/test_compute_graph.cpp | 157 ++++++ .../deformable_vbd/test_avbd_rigid_block.cpp | 228 +++++++++ .../test_vbd_combined_descent.cpp | 281 +++++++++++ .../test_vbd_finite_stiffness.cpp | 172 +++++++ .../world/test_vbd_world_solver.cpp | 19 +- tests/unit/simulation/world/test_world.cpp | 171 +++++++ 25 files changed, 2294 insertions(+), 379 deletions(-) create mode 100644 dart/simulation/detail/deformable_vbd/parallel_row_update.hpp create mode 100644 docs/plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json create mode 100644 tests/benchmark/simulation/bm_avbd_parallel_row_update.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f6b9144a7d8..247df4458d116 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -191,7 +191,10 @@ compatibility remains on the active DART 6 LTS branch._ variational solver paths behind the `World` and executor model; AVBD CPU distance-spring and nonlinear rigid-point rows now use the paper's non-negative quasi-Newton geometric-stiffness diagonal while contact - remains intentionally Taylor-linearized. + remains intentionally Taylor-linearized. Large promoted AVBD CPU row + inventories now perform the Section 4 post-primal dual/stiffness update over + deterministic, allocation-stable worker ranges while preserving exact serial + row state. ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) - Added compute-executor and backend-boundary work so CPU threading, optional CUDA experiments, and future accelerator sidecars do not leak into the default diff --git a/dart/simulation/compute/deformable_dynamics_stage.cpp b/dart/simulation/compute/deformable_dynamics_stage.cpp index d5918b3287c58..c9c4cb8cf18fe 100644 --- a/dart/simulation/compute/deformable_dynamics_stage.cpp +++ b/dart/simulation/compute/deformable_dynamics_stage.cpp @@ -5169,14 +5169,13 @@ void runVbdDeformableSolve( = hasRequestedAvbdMassSpringRows && vbdScratch.tets.empty() && !hasUnsupportedAvbdFrictionSource && (selfContact == nullptr || useAvbdSelfContactRows) - && executor.getWorkerCount() <= 1u && !options.useChebyshev - && options.rayleighDamping <= 0.0; + && !options.useChebyshev && options.rayleighDamping <= 0.0; const bool canUseAvbdTetMaterialRows = config.useAvbdFiniteStiffnessRows && !config.useAvbdContactNormalRows && !config.useAvbdAttachmentRows && contactPlanes.empty() && vbdScratch.springs.empty() && !vbdScratch.tets.empty() - && !hasUnsupportedAvbdFrictionSource && executor.getWorkerCount() <= 1u - && !options.useChebyshev && options.rayleighDamping <= 0.0; + && !hasUnsupportedAvbdFrictionSource && !options.useChebyshev + && options.rayleighDamping <= 0.0; dvbd::BlockDescentStats result; const auto projectAvbdSelfContactFrictionWarmStarts = @@ -5665,7 +5664,8 @@ void runVbdDeformableSolve( &selfContactOptions, useAvbdSelfContactFrictionRows ? &vbdScratch.avbdSelfContactFrictionRows : nullptr, - useAvbdSelfContactFrictionRows ? &selfContactFrictionOptions : nullptr); + useAvbdSelfContactFrictionRows ? &selfContactFrictionOptions : nullptr, + &executor); for (std::size_t i = 0; i < vbdScratch.avbdContactRows.size(); ++i) { vbdScratch.avbdContactInventory[i].state @@ -5941,7 +5941,8 @@ void runVbdDeformableSolve( useAvbdSelfContactRows ? &selfContactOptions : nullptr, useAvbdSelfContactFrictionRows ? &vbdScratch.avbdSelfContactFrictionRows : nullptr, - useAvbdSelfContactFrictionRows ? &selfContactFrictionOptions : nullptr); + useAvbdSelfContactFrictionRows ? &selfContactFrictionOptions : nullptr, + &executor); for (std::size_t i = 0; i < vbdScratch.avbdTetRows.size(); ++i) { vbdScratch.avbdTetInventory[i].state = vbdScratch.avbdTetRows[i].state; diff --git a/dart/simulation/compute/parallel_executor.cpp b/dart/simulation/compute/parallel_executor.cpp index 9cb9945765cfb..b8fbb519dc65f 100644 --- a/dart/simulation/compute/parallel_executor.cpp +++ b/dart/simulation/compute/parallel_executor.cpp @@ -48,7 +48,12 @@ // clang-format on #include +#include +#include +#include #include +#include +#include #include #include @@ -58,6 +63,169 @@ namespace dart::simulation::compute { class ParallelExecutor::Impl { public: + class ParallelForWorkerPool final + { + public: + explicit ParallelForWorkerPool(std::size_t workerCount) + { + const std::size_t backgroundWorkerCount + = workerCount > 1u ? workerCount - 1u : 0u; + workers.reserve(backgroundWorkerCount); + try { + for (std::size_t worker = 0u; worker < backgroundWorkerCount; + ++worker) { + workers.emplace_back( + [this, chunk = worker + 1u]() { workerLoop(chunk); }); + } + } catch (...) { + { + std::lock_guard lock(mutex); + stopping = true; + ++generation; + } + jobReady.notify_all(); + for (std::thread& worker : workers) { + if (worker.joinable()) { + worker.join(); + } + } + throw; + } + } + + ~ParallelForWorkerPool() + { + { + std::lock_guard lock(mutex); + stopping = true; + ++generation; + } + jobReady.notify_all(); + for (std::thread& worker : workers) { + if (worker.joinable()) { + worker.join(); + } + } + } + + ParallelForWorkerPool(const ParallelForWorkerPool&) = delete; + ParallelForWorkerPool& operator=(const ParallelForWorkerPool&) = delete; + + void run( + std::size_t count, + std::size_t chunkSize, + std::size_t chunkCount, + const ComputeExecutor::ParallelForRange& function) + { + if (workers.empty()) { + function(0u, count); + return; + } + + { + std::lock_guard lock(mutex); + jobFunction = &function; + jobCount = count; + jobChunkSize = chunkSize; + jobChunkCount = chunkCount; + jobException = nullptr; + pendingWorkers = workers.size(); + ++generation; + } + jobReady.notify_all(); + + try { + runChunk(0u, count, chunkSize, chunkCount, function); + } catch (...) { + recordException(std::current_exception()); + } + + std::unique_lock lock(mutex); + jobComplete.wait(lock, [this]() { return pendingWorkers == 0u; }); + jobFunction = nullptr; + jobCount = 0u; + jobChunkSize = 0u; + jobChunkCount = 0u; + std::exception_ptr exception = std::move(jobException); + lock.unlock(); + if (exception != nullptr) { + std::rethrow_exception(exception); + } + } + + private: + static void runChunk( + std::size_t chunk, + std::size_t count, + std::size_t chunkSize, + std::size_t chunkCount, + const ComputeExecutor::ParallelForRange& function) + { + if (chunk >= chunkCount) { + return; + } + const std::size_t begin = chunk * chunkSize; + const std::size_t end = std::min(begin + chunkSize, count); + if (begin < end) { + function(begin, end); + } + } + + void recordException(std::exception_ptr exception) + { + std::lock_guard lock(mutex); + if (jobException == nullptr) { + jobException = std::move(exception); + } + } + + void workerLoop(std::size_t chunk) + { + std::size_t observedGeneration = 0u; + for (;;) { + std::unique_lock lock(mutex); + jobReady.wait(lock, [this, observedGeneration]() { + return stopping || generation != observedGeneration; + }); + if (stopping) { + return; + } + + observedGeneration = generation; + const ComputeExecutor::ParallelForRange* const function = jobFunction; + const std::size_t count = jobCount; + const std::size_t chunkSize = jobChunkSize; + const std::size_t chunkCount = jobChunkCount; + lock.unlock(); + + try { + runChunk(chunk, count, chunkSize, chunkCount, *function); + } catch (...) { + recordException(std::current_exception()); + } + + lock.lock(); + --pendingWorkers; + if (pendingWorkers == 0u) { + jobComplete.notify_one(); + } + } + } + + std::vector workers; + std::mutex mutex; + std::condition_variable jobReady; + std::condition_variable jobComplete; + const ComputeExecutor::ParallelForRange* jobFunction = nullptr; + std::size_t jobCount = 0u; + std::size_t jobChunkSize = 0u; + std::size_t jobChunkCount = 0u; + std::size_t pendingWorkers = 0u; + std::size_t generation = 0u; + std::exception_ptr jobException; + bool stopping = false; + }; + explicit Impl(std::size_t workerCount) { if (workerCount == 0) { @@ -141,6 +309,8 @@ class ParallelExecutor::Impl std::vector cachedNodes; std::vector cachedEdges; bool cacheValid = false; + std::unique_ptr parallelForWorkerPool; + std::atomic_flag parallelForActive = ATOMIC_FLAG_INIT; #if DART_BUILD_PROFILE ComputeExecutionProfiler* activeProfiler = nullptr; #endif @@ -245,22 +415,38 @@ void ParallelExecutor::parallelFor( } const std::size_t safeGrainSize = std::max(1u, grainSize); - const std::size_t chunkCount = (count + safeGrainSize - 1u) / safeGrainSize; - if (chunkCount <= 1u || getWorkerCount() <= 1u) { + const std::size_t requestedChunkCount = 1u + (count - 1u) / safeGrainSize; + const std::size_t workerCount = getWorkerCount(); + const std::size_t targetChunkCount + = std::min(requestedChunkCount, workerCount); + if (targetChunkCount <= 1u) { + function(0u, count); + return; + } + + const std::size_t balancedChunkSize = 1u + (count - 1u) / targetChunkCount; + const std::size_t chunkSize = std::max(safeGrainSize, balancedChunkSize); + const std::size_t chunkCount = 1u + (count - 1u) / chunkSize; + + // The first nontrivial range lazily creates N-1 persistent background + // workers; the caller supplies the Nth lane. Never block a worker waiting for + // that shared pool: a concurrent or recursively nested call runs inline. + if (m_impl->parallelForActive.test_and_set(std::memory_order_acquire)) { function(0u, count); return; } - tf::Taskflow taskflow; - for (std::size_t chunk = 0u; chunk < chunkCount; ++chunk) { - const std::size_t begin = chunk * safeGrainSize; - const std::size_t end = std::min(begin + safeGrainSize, count); - if (begin >= end) { - continue; + try { + if (m_impl->parallelForWorkerPool == nullptr) { + m_impl->parallelForWorkerPool + = std::make_unique(workerCount); } - taskflow.emplace([begin, end, &function]() { function(begin, end); }); + m_impl->parallelForWorkerPool->run(count, chunkSize, chunkCount, function); + } catch (...) { + m_impl->parallelForActive.clear(std::memory_order_release); + throw; } - m_impl->executor->run(taskflow).get(); + m_impl->parallelForActive.clear(std::memory_order_release); } //============================================================================== diff --git a/dart/simulation/compute/parallel_executor.hpp b/dart/simulation/compute/parallel_executor.hpp index 17a60623a647e..5b8617e56e12e 100644 --- a/dart/simulation/compute/parallel_executor.hpp +++ b/dart/simulation/compute/parallel_executor.hpp @@ -41,6 +41,12 @@ namespace dart::simulation::compute { /// Experimental executor that runs ready graph nodes in parallel. +/// +/// `parallelFor()` lazily creates and reuses a persistent range-worker pool. +/// The caller supplies one lane and the pool supplies the remaining lanes. +/// Concurrent or recursively nested range calls fall back to the calling +/// thread while that pool is active, so worker callbacks never deadlock +/// waiting on an outer range. class DART_SIMULATION_API ParallelExecutor final : public ComputeExecutor { public: diff --git a/dart/simulation/compute/rigid_body_contact_stage.cpp b/dart/simulation/compute/rigid_body_contact_stage.cpp index 8c94941bde5fa..480be4d394714 100644 --- a/dart/simulation/compute/rigid_body_contact_stage.cpp +++ b/dart/simulation/compute/rigid_body_contact_stage.cpp @@ -646,7 +646,7 @@ void RigidBodyContactStage::prepare(World& world) } //============================================================================== -void RigidBodyContactStage::execute(World& world, ComputeExecutor& /*executor*/) +void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) { auto& registry = dart::simulation::detail::registryOf(world); @@ -719,7 +719,8 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& /*executor*/) scratch.distanceSpringInventory, timeStep, scratch.solveScratch, - solveOptions); + solveOptions, + &executor); (void)dvbd::markAvbdRigidWorldFracturedPointJoints( registry, scratch.snapshot, solveResult.fracturedJointIndices); if (solveResult.jointLinearRows == 0u && solveResult.jointAngularRows == 0u @@ -859,7 +860,8 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& /*executor*/) scratch.distanceSpringInventory, timeStep, scratch.solveScratch, - solveOptions); + solveOptions, + &executor); (void)dvbd::markAvbdRigidWorldFracturedPointJoints( registry, scratch.snapshot, solveResult.fracturedJointIndices); if (solveResult.normalRows != 0u || solveResult.frictionRows != 0u diff --git a/dart/simulation/detail/deformable_vbd/block_descent.hpp b/dart/simulation/detail/deformable_vbd/block_descent.hpp index 90c527d63bf7b..181c02d3151c2 100644 --- a/dart/simulation/detail/deformable_vbd/block_descent.hpp +++ b/dart/simulation/detail/deformable_vbd/block_descent.hpp @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -496,18 +497,20 @@ inline BlockDescentStats blockDescentMassSpringAvbdFiniteStiffness( //============================================================================== /// Mass-spring block descent with the currently wired AVBD deformable row -/// families combined in one serial solve. This keeps the first World-level AVBD -/// integration honest: contact-normal hard rows, scalar hard attachments, and -/// finite-stiffness spring rows all contribute to the same primal vertex block, -/// then each row family updates its persistent dual/stiffness state after every -/// sweep. The optional friction-tangent rows provide the first bounded row -/// family in the same serial driver. Optional self-contact normal rows share +/// families combined in one solve. Contact-normal hard rows, scalar hard +/// attachments, and finite-stiffness spring rows all contribute to the same +/// primal vertex block, then each row family updates its persistent +/// dual/stiffness state after every sweep. The primal sweep remains in its +/// deterministic graph-color order. When `rowUpdateExecutor` is provided, +/// independent post-sweep row updates use deterministic contiguous ranges; +/// small inventories and friction layouts without complete adjacent tangent +/// pairs retain the exact serial order. Optional self-contact normal rows share /// one scalar row per point-triangle / edge-edge primitive and stamp each /// incident local vertex through the lagged self-contact adjacency. Friction /// tangent rows generated as adjacent pairs use the lagged tangential dual to /// switch between static and dynamic Coulomb modes. Full contact-manifold /// friction persistence, broader self-contact friction envelopes, tetrahedral -/// row mixing, and parallel dual scheduling remain later slices. +/// row mixing, and accelerator scheduling remain later slices. template < typename PositionVector, typename FixedMask, @@ -540,7 +543,8 @@ inline BlockDescentStats blockDescentMassSpringAvbdRows( const SelfContactAdjacency* selfContact = nullptr, const AvbdSelfContactNormalOptions* selfContactOptions = nullptr, SelfContactFrictionRows* selfContactFrictionRows = nullptr, - const AvbdSelfContactFrictionOptions* selfContactFrictionOptions = nullptr) + const AvbdSelfContactFrictionOptions* selfContactFrictionOptions = nullptr, + compute::ComputeExecutor* rowUpdateExecutor = nullptr) { BlockDescentStats stats; const std::size_t vertexCount = positions.size(); @@ -553,6 +557,37 @@ inline BlockDescentStats blockDescentMassSpringAvbdRows( } return false; }; + const bool frictionRowsArePaired = [&]() { + if (frictionRows == nullptr || frictionOptions == nullptr + || frictionRows->empty() || frictionRows->size() % 2u != 0u) { + return false; + } + for (std::size_t i = 0u; i < frictionRows->size(); i += 2u) { + if ((*frictionRows)[i].vertex != (*frictionRows)[i + 1u].vertex) { + return false; + } + } + return true; + }(); + const bool selfContactFrictionRowsArePaired = [&]() { + if (selfContactFrictionRows == nullptr + || selfContactFrictionOptions == nullptr + || selfContactFrictionRows->empty() + || selfContactFrictionRows->size() % 2u != 0u) { + return false; + } + for (std::size_t i = 0u; i < selfContactFrictionRows->size(); i += 2u) { + const AvbdSelfContactFrictionRow& first = (*selfContactFrictionRows)[i]; + const AvbdSelfContactFrictionRow& second + = (*selfContactFrictionRows)[i + 1u]; + if (!avbdSelfContactSameFrictionPrimitive(first, second) + || hasFreeSelfContactFrictionVertex(first) + != hasFreeSelfContactFrictionVertex(second)) { + return false; + } + } + return true; + }(); const auto assemble = [&](std::uint32_t vertex) { VertexBlock block; addInertiaTerm( @@ -689,105 +724,170 @@ inline BlockDescentStats blockDescentMassSpringAvbdRows( } } - for (AvbdHalfSpaceContactRow& row : contactRows) { - if (row.vertex >= vertexCount || fixed[row.vertex] != 0u) { - continue; - } - row.state = updateAvbdHalfSpaceContactNormalRow( - row.state, - positions[row.vertex], - row.plane, - contactOptions, - row.previousConstraintValue, - row.bounds); - } - for (AvbdPointAttachmentRow& row : attachmentRows) { - if (row.vertex >= vertexCount || fixed[row.vertex] != 0u) { - continue; - } - row.state = updateAvbdPointAttachmentRow( - row.state, positions[row.vertex], row, attachmentOptions); - } - for (AvbdSpringFiniteStiffnessRow& row : springRows) { - if (row.spring >= springs.size()) { - continue; - } - const SpringElement& spring = springs[row.spring]; - if (spring.a >= vertexCount || spring.b >= vertexCount) { - continue; - } - if (fixed[spring.a] != 0u && fixed[spring.b] != 0u) { - continue; - } - const double constraintValue = avbdSpringConstraintValue( - positions[spring.a], positions[spring.b], spring.restLength); - row.state = updateAvbdSpringFiniteStiffnessRow( - row.state, constraintValue, row, springOptions); - } + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + contactRows.size(), + [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + AvbdHalfSpaceContactRow& row = contactRows[i]; + if (row.vertex >= vertexCount || fixed[row.vertex] != 0u) { + continue; + } + row.state = updateAvbdHalfSpaceContactNormalRow( + row.state, + positions[row.vertex], + row.plane, + contactOptions, + row.previousConstraintValue, + row.bounds); + } + }); + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + attachmentRows.size(), + [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + AvbdPointAttachmentRow& row = attachmentRows[i]; + if (row.vertex >= vertexCount || fixed[row.vertex] != 0u) { + continue; + } + row.state = updateAvbdPointAttachmentRow( + row.state, positions[row.vertex], row, attachmentOptions); + } + }); + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + springRows.size(), + [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + AvbdSpringFiniteStiffnessRow& row = springRows[i]; + if (row.spring >= springs.size()) { + continue; + } + const SpringElement& spring = springs[row.spring]; + if (spring.a >= vertexCount || spring.b >= vertexCount) { + continue; + } + if (fixed[spring.a] != 0u && fixed[spring.b] != 0u) { + continue; + } + const double constraintValue = avbdSpringConstraintValue( + positions[spring.a], positions[spring.b], spring.restLength); + row.state = updateAvbdSpringFiniteStiffnessRow( + row.state, constraintValue, row, springOptions); + } + }); if (frictionRows != nullptr && frictionOptions != nullptr) { - for (std::size_t i = 0; i < frictionRows->size();) { - AvbdHalfSpaceFrictionRow& row = (*frictionRows)[i]; - if (row.vertex >= vertexCount || fixed[row.vertex] != 0u) { + if (frictionRowsArePaired) { + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + frictionRows->size() / 2u, + [&](std::size_t begin, std::size_t end) { + for (std::size_t pair = begin; pair < end; ++pair) { + const std::size_t firstIndex = 2u * pair; + AvbdHalfSpaceFrictionRow& first = (*frictionRows)[firstIndex]; + if (first.vertex >= vertexCount || fixed[first.vertex] != 0u) { + continue; + } + updateAvbdHalfSpaceFrictionTangentPair( + first, + (*frictionRows)[firstIndex + 1u], + positions[first.vertex], + *frictionOptions); + } + }); + } else { + for (std::size_t i = 0; i < frictionRows->size();) { + AvbdHalfSpaceFrictionRow& row = (*frictionRows)[i]; + if (row.vertex >= vertexCount || fixed[row.vertex] != 0u) { + ++i; + continue; + } + if (i + 1 < frictionRows->size() + && (*frictionRows)[i + 1].vertex == row.vertex + && (*frictionRows)[i + 1].vertex < vertexCount + && fixed[(*frictionRows)[i + 1].vertex] == 0u) { + updateAvbdHalfSpaceFrictionTangentPair( + row, + (*frictionRows)[i + 1], + positions[row.vertex], + *frictionOptions); + i += 2; + continue; + } + row.state = updateAvbdHalfSpaceFrictionTangentRow( + row.state, positions[row.vertex], row, *frictionOptions); ++i; - continue; - } - if (i + 1 < frictionRows->size() - && (*frictionRows)[i + 1].vertex == row.vertex - && (*frictionRows)[i + 1].vertex < vertexCount - && fixed[(*frictionRows)[i + 1].vertex] == 0u) { - updateAvbdHalfSpaceFrictionTangentPair( - row, - (*frictionRows)[i + 1], - positions[row.vertex], - *frictionOptions); - i += 2; - continue; } - row.state = updateAvbdHalfSpaceFrictionTangentRow( - row.state, positions[row.vertex], row, *frictionOptions); - ++i; } } if (selfContactRows != nullptr && selfContactOptions != nullptr) { - for (AvbdSelfContactNormalRow& row : *selfContactRows) { - bool hasFreeVertex = false; - for (const std::uint32_t node : row.nodes) { - if (node < vertexCount && fixed[node] == 0u) { - hasFreeVertex = true; - break; - } - } - if (!hasFreeVertex) { - continue; - } - row.state = updateAvbdSelfContactNormalRow( - row.state, positions, row, *selfContactOptions); - } + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + selfContactRows->size(), + [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + AvbdSelfContactNormalRow& row = (*selfContactRows)[i]; + bool hasFreeVertex = false; + for (const std::uint32_t node : row.nodes) { + if (node < vertexCount && fixed[node] == 0u) { + hasFreeVertex = true; + break; + } + } + if (!hasFreeVertex) { + continue; + } + row.state = updateAvbdSelfContactNormalRow( + row.state, positions, row, *selfContactOptions); + } + }); } if (selfContactFrictionRows != nullptr && selfContactFrictionOptions != nullptr) { - for (std::size_t i = 0; i < selfContactFrictionRows->size();) { - AvbdSelfContactFrictionRow& row = (*selfContactFrictionRows)[i]; - if (!hasFreeSelfContactFrictionVertex(row)) { + if (selfContactFrictionRowsArePaired) { + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + selfContactFrictionRows->size() / 2u, + [&](std::size_t begin, std::size_t end) { + for (std::size_t pair = begin; pair < end; ++pair) { + const std::size_t firstIndex = 2u * pair; + AvbdSelfContactFrictionRow& first + = (*selfContactFrictionRows)[firstIndex]; + if (!hasFreeSelfContactFrictionVertex(first)) { + continue; + } + updateAvbdSelfContactFrictionTangentPair( + first, + (*selfContactFrictionRows)[firstIndex + 1u], + positions, + *selfContactFrictionOptions); + } + }); + } else { + for (std::size_t i = 0; i < selfContactFrictionRows->size();) { + AvbdSelfContactFrictionRow& row = (*selfContactFrictionRows)[i]; + if (!hasFreeSelfContactFrictionVertex(row)) { + ++i; + continue; + } + if (i + 1 < selfContactFrictionRows->size() + && avbdSelfContactSameFrictionPrimitive( + row, (*selfContactFrictionRows)[i + 1]) + && hasFreeSelfContactFrictionVertex( + (*selfContactFrictionRows)[i + 1])) { + updateAvbdSelfContactFrictionTangentPair( + row, + (*selfContactFrictionRows)[i + 1], + positions, + *selfContactFrictionOptions); + i += 2; + continue; + } + row.state = updateAvbdSelfContactFrictionTangentRow( + row.state, positions, row, *selfContactFrictionOptions); ++i; - continue; - } - if (i + 1 < selfContactFrictionRows->size() - && avbdSelfContactSameFrictionPrimitive( - row, (*selfContactFrictionRows)[i + 1]) - && hasFreeSelfContactFrictionVertex( - (*selfContactFrictionRows)[i + 1])) { - updateAvbdSelfContactFrictionTangentPair( - row, - (*selfContactFrictionRows)[i + 1], - positions, - *selfContactFrictionOptions); - i += 2; - continue; } - row.state = updateAvbdSelfContactFrictionTangentRow( - row.state, positions, row, *selfContactFrictionOptions); - ++i; } } if (convergenceSquared > 0.0 && maxDeltaSquared <= convergenceSquared) { @@ -1078,7 +1178,10 @@ inline BlockDescentStats blockDescentTetMesh( /// observed deformation-gradient error after each sweep. Optional AVBD /// self-contact rows let pure-tet scenes share the same hard normal/friction /// row path as the mass-spring envelope; when they are absent, the existing -/// lagged VBD self-contact penalty remains available. +/// lagged VBD self-contact penalty remains available. The primal sweep stays +/// serial and deterministic; an optional `rowUpdateExecutor` dispatches the +/// independent post-sweep material and self-contact row updates over +/// deterministic contiguous ranges. template < typename PositionVector, typename FixedMask, @@ -1103,7 +1206,8 @@ inline BlockDescentStats blockDescentTetMeshAvbdFiniteStiffness( SelfContactRows* selfContactRows = nullptr, const AvbdSelfContactNormalOptions* selfContactOptions = nullptr, SelfContactFrictionRows* selfContactFrictionRows = nullptr, - const AvbdSelfContactFrictionOptions* selfContactFrictionOptions = nullptr) + const AvbdSelfContactFrictionOptions* selfContactFrictionOptions = nullptr, + compute::ComputeExecutor* rowUpdateExecutor = nullptr) { BlockDescentStats stats; const std::size_t vertexCount = positions.size(); @@ -1116,6 +1220,25 @@ inline BlockDescentStats blockDescentTetMeshAvbdFiniteStiffness( } return false; }; + const bool selfContactFrictionRowsArePaired = [&]() { + if (selfContactFrictionRows == nullptr + || selfContactFrictionOptions == nullptr + || selfContactFrictionRows->empty() + || selfContactFrictionRows->size() % 2u != 0u) { + return false; + } + for (std::size_t i = 0u; i < selfContactFrictionRows->size(); i += 2u) { + const AvbdSelfContactFrictionRow& first = (*selfContactFrictionRows)[i]; + const AvbdSelfContactFrictionRow& second + = (*selfContactFrictionRows)[i + 1u]; + if (!avbdSelfContactSameFrictionPrimitive(first, second) + || hasFreeSelfContactFrictionVertex(first) + != hasFreeSelfContactFrictionVertex(second)) { + return false; + } + } + return true; + }(); const auto assemble = [&](std::uint32_t vertex) { VertexBlock block; addInertiaTerm( @@ -1231,70 +1354,103 @@ inline BlockDescentStats blockDescentTetMeshAvbdFiniteStiffness( } } - for (AvbdTetMaterialFiniteStiffnessRow& row : tetRows) { - if (row.tet >= tets.size()) { - continue; - } - const TetMeshElement& tet = tets[row.tet]; - const std::array& vertices = tet.vertices; - if (vertices[0] >= vertexCount || vertices[1] >= vertexCount - || vertices[2] >= vertexCount || vertices[3] >= vertexCount) { - continue; - } - if (fixed[vertices[0]] != 0u && fixed[vertices[1]] != 0u - && fixed[vertices[2]] != 0u && fixed[vertices[3]] != 0u) { - continue; - } - const std::array tetPositions - = {positions[vertices[0]], - positions[vertices[1]], - positions[vertices[2]], - positions[vertices[3]]}; - const double constraintValue - = avbdTetMaterialConstraintValue(tet.rest, tetPositions); - row.state = updateAvbdTetMaterialFiniteStiffnessRow( - row.state, constraintValue, row, avbdOptions); - } - if (selfContactRows != nullptr && selfContactOptions != nullptr) { - for (AvbdSelfContactNormalRow& row : *selfContactRows) { - bool hasFreeVertex = false; - for (const std::uint32_t node : row.nodes) { - if (node < vertexCount && fixed[node] == 0u) { - hasFreeVertex = true; - break; + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + tetRows.size(), + [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + AvbdTetMaterialFiniteStiffnessRow& row = tetRows[i]; + if (row.tet >= tets.size()) { + continue; + } + const TetMeshElement& tet = tets[row.tet]; + const std::array& vertices = tet.vertices; + if (vertices[0] >= vertexCount || vertices[1] >= vertexCount + || vertices[2] >= vertexCount || vertices[3] >= vertexCount) { + continue; + } + if (fixed[vertices[0]] != 0u && fixed[vertices[1]] != 0u + && fixed[vertices[2]] != 0u && fixed[vertices[3]] != 0u) { + continue; + } + const std::array tetPositions + = {positions[vertices[0]], + positions[vertices[1]], + positions[vertices[2]], + positions[vertices[3]]}; + const double constraintValue + = avbdTetMaterialConstraintValue(tet.rest, tetPositions); + row.state = updateAvbdTetMaterialFiniteStiffnessRow( + row.state, constraintValue, row, avbdOptions); } - } - if (!hasFreeVertex) { - continue; - } - row.state = updateAvbdSelfContactNormalRow( - row.state, positions, row, *selfContactOptions); - } + }); + if (selfContactRows != nullptr && selfContactOptions != nullptr) { + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + selfContactRows->size(), + [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + AvbdSelfContactNormalRow& row = (*selfContactRows)[i]; + bool hasFreeVertex = false; + for (const std::uint32_t node : row.nodes) { + if (node < vertexCount && fixed[node] == 0u) { + hasFreeVertex = true; + break; + } + } + if (!hasFreeVertex) { + continue; + } + row.state = updateAvbdSelfContactNormalRow( + row.state, positions, row, *selfContactOptions); + } + }); } if (selfContactFrictionRows != nullptr && selfContactFrictionOptions != nullptr) { - for (std::size_t i = 0; i < selfContactFrictionRows->size();) { - AvbdSelfContactFrictionRow& row = (*selfContactFrictionRows)[i]; - if (!hasFreeSelfContactFrictionVertex(row)) { + if (selfContactFrictionRowsArePaired) { + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + selfContactFrictionRows->size() / 2u, + [&](std::size_t begin, std::size_t end) { + for (std::size_t pair = begin; pair < end; ++pair) { + const std::size_t firstIndex = 2u * pair; + AvbdSelfContactFrictionRow& first + = (*selfContactFrictionRows)[firstIndex]; + if (!hasFreeSelfContactFrictionVertex(first)) { + continue; + } + updateAvbdSelfContactFrictionTangentPair( + first, + (*selfContactFrictionRows)[firstIndex + 1u], + positions, + *selfContactFrictionOptions); + } + }); + } else { + for (std::size_t i = 0; i < selfContactFrictionRows->size();) { + AvbdSelfContactFrictionRow& row = (*selfContactFrictionRows)[i]; + if (!hasFreeSelfContactFrictionVertex(row)) { + ++i; + continue; + } + if (i + 1 < selfContactFrictionRows->size() + && avbdSelfContactSameFrictionPrimitive( + row, (*selfContactFrictionRows)[i + 1]) + && hasFreeSelfContactFrictionVertex( + (*selfContactFrictionRows)[i + 1])) { + updateAvbdSelfContactFrictionTangentPair( + row, + (*selfContactFrictionRows)[i + 1], + positions, + *selfContactFrictionOptions); + i += 2; + continue; + } + row.state = updateAvbdSelfContactFrictionTangentRow( + row.state, positions, row, *selfContactFrictionOptions); ++i; - continue; - } - if (i + 1 < selfContactFrictionRows->size() - && avbdSelfContactSameFrictionPrimitive( - row, (*selfContactFrictionRows)[i + 1]) - && hasFreeSelfContactFrictionVertex( - (*selfContactFrictionRows)[i + 1])) { - updateAvbdSelfContactFrictionTangentPair( - row, - (*selfContactFrictionRows)[i + 1], - positions, - *selfContactFrictionOptions); - i += 2; - continue; } - row.state = updateAvbdSelfContactFrictionTangentRow( - row.state, positions, row, *selfContactFrictionOptions); - ++i; } } if (convergenceSquared > 0.0 && maxDeltaSquared <= convergenceSquared) { diff --git a/dart/simulation/detail/deformable_vbd/parallel_row_update.hpp b/dart/simulation/detail/deformable_vbd/parallel_row_update.hpp new file mode 100644 index 0000000000000..f0544e2ab51e7 --- /dev/null +++ b/dart/simulation/detail/deformable_vbd/parallel_row_update.hpp @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2011, The DART development contributors + * All rights reserved. + * + * The list of contributors can be found at: + * https://github.com/dartsim/dart/blob/main/LICENSE + * + * This file is provided under the following "BSD-style" License: + * Redistribution and use in source and binary forms, with or + * without modification, are permitted provided that the following + * conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF + * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include + +#include + +#include + +namespace dart::simulation::detail::deformable_vbd { + +/// Minimum useful scalar AVBD row inventory for task-parallel dispatch. A +/// matched hard-contact-row benchmark keeps smaller inventories inline because +/// waking the persistent worker pool costs more than the row arithmetic. +inline constexpr std::size_t kAvbdParallelRowUpdateMinCount = 8192u; + +/// Minimum range assigned to one worker after the dispatch cost gate passes. +inline constexpr std::size_t kAvbdParallelRowUpdateMinGrain = 2048u; + +//============================================================================== +/// Visit deterministic contiguous ranges of an AVBD row family. The callback +/// must update only rows in [begin, end); the executor call is synchronous, so +/// all row writes are complete before the next primal sweep begins. +template +inline void forEachAvbdRowUpdateRange( + compute::ComputeExecutor* executor, std::size_t count, Function&& function) +{ + if (count == 0u) { + return; + } + + const std::size_t workerCount + = executor != nullptr ? executor->getWorkerCount() : 0u; + if (workerCount <= 1u || count <= kAvbdParallelRowUpdateMinCount) { + function(0u, count); + return; + } + + const std::size_t balancedGrain = 1u + (count - 1u) / workerCount; + executor->parallelFor( + count, std::max(kAvbdParallelRowUpdateMinGrain, balancedGrain), function); +} + +} // namespace dart::simulation::detail::deformable_vbd diff --git a/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp b/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp index 8a28b2a0e963f..8e7b1c4059759 100644 --- a/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp +++ b/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp @@ -34,6 +34,7 @@ #include #include +#include #include #include @@ -3534,6 +3535,12 @@ inline void avbdRigidRefreshRowIndexLayoutKeys( } //============================================================================== +/// Run the rigid AVBD primal block sweep and persistent row-state update. +/// +/// Body blocks retain deterministic serial order. When `rowUpdateExecutor` is +/// provided, independent attachment, point-pair, distance, angular, and +/// complete adjacent friction-pair updates use deterministic contiguous +/// ranges; incomplete friction layouts preserve the serial fallback. template < typename AttachmentRowVector, typename PointPairRowVector, @@ -3555,7 +3562,8 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( const AvbdRigidPointPairFrictionOptions& frictionOptions, AvbdRigidBodyRowIndexScratch* rowIndexScratch = nullptr, std::span distanceSpringRows = {}, - const AvbdRigidPointPairDistanceSpringOptions& distanceSpringOptions = {}) + const AvbdRigidPointPairDistanceSpringOptions& distanceSpringOptions = {}, + compute::ComputeExecutor* rowUpdateExecutor = nullptr) { AvbdRigidBlockDescentStats stats; const std::size_t bodyCount = states.size(); @@ -4231,118 +4239,156 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( ++stats.bodyUpdates; } - for (AvbdRigidBodyPointAttachmentRow& indexedRow : attachmentRows) { - if (validBody(indexedRow.body)) { - indexedRow.row.state = updateAvbdRigidPointAttachmentRow( - indexedRow.row.state, - states[indexedRow.body], - indexedRow.row, - rowOptions); - } - } - PointPairWorldPointCache pointPairUpdateCache; - for (AvbdRigidBodyPointPairRow& indexedRow : pointPairRows) { - if (validBody(indexedRow.bodyA) && validBody(indexedRow.bodyB)) { - if (avbdRigidRowUsesFiniteMaterial(indexedRow.row.materialStiffness)) { - indexedRow.row.state.lambda = 0.0; - const double maxStiffness = std::min( - indexedRow.row.materialStiffness, rowOptions.maxStiffness); - if (rowOptions.beta >= 0.0 - && indexedRow.row.state.stiffness >= maxStiffness) { - indexedRow.row.state.stiffness = maxStiffness; - continue; + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + attachmentRows.size(), + [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + AvbdRigidBodyPointAttachmentRow& indexedRow = attachmentRows[i]; + if (validBody(indexedRow.body)) { + indexedRow.row.state = updateAvbdRigidPointAttachmentRow( + indexedRow.row.state, + states[indexedRow.body], + indexedRow.row, + rowOptions); + } } - } + }); + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + pointPairRows.size(), + [&](std::size_t begin, std::size_t end) { + PointPairWorldPointCache pointPairUpdateCache; + for (std::size_t i = begin; i < end; ++i) { + AvbdRigidBodyPointPairRow& indexedRow = pointPairRows[i]; + if (!validBody(indexedRow.bodyA) || !validBody(indexedRow.bodyB)) { + continue; + } + if (avbdRigidRowUsesFiniteMaterial( + indexedRow.row.materialStiffness)) { + indexedRow.row.state.lambda = 0.0; + const double maxStiffness = std::min( + indexedRow.row.materialStiffness, rowOptions.maxStiffness); + if (rowOptions.beta >= 0.0 + && indexedRow.row.state.stiffness >= maxStiffness) { + indexedRow.row.state.stiffness = maxStiffness; + continue; + } + } - const PointPairWorldPointCache& points - = pointPairWorldPoints(indexedRow, pointPairUpdateCache); - const double rawConstraintValue - = indexedRow.row.offset - + indexedRow.row.axis.dot( - points.worldPointB - points.worldPointA); - if (avbdRigidRowUsesFiniteMaterial(indexedRow.row.materialStiffness)) { - const double maxStiffness = std::min( - indexedRow.row.materialStiffness, rowOptions.maxStiffness); - indexedRow.row.state.stiffness = updateAvbdFiniteStiffness( - indexedRow.row.state.stiffness, - rawConstraintValue, - rowOptions.beta, - maxStiffness); - } else { - const double constraintValue = regularizeAvbdConstraintValue( - rawConstraintValue, - indexedRow.row.previousConstraintValue, - rowOptions.alpha); - indexedRow.row.state = updateAvbdHardConstraintRow( - indexedRow.row.state, - constraintValue, - rowOptions.beta, - indexedRow.row.bounds, - rowOptions.maxStiffness); - } - } - } - for (AvbdRigidBodyPointPairDistanceSpringRow& indexedRow : - distanceSpringRows) { - if (validBody(indexedRow.bodyA) && validBody(indexedRow.bodyB) - && indexedRow.bodyB != indexedRow.bodyA) { - indexedRow.row.state = updateAvbdRigidPointPairDistanceSpringRow( - indexedRow.row.state, - states[indexedRow.bodyA], - states[indexedRow.bodyB], - indexedRow.row, - distanceSpringOptions); - } - } - AngularPairConstraintCache angularPairUpdateCache; - for (AvbdRigidBodyAngularPairRow& indexedRow : angularPairRows) { - if (validBody(indexedRow.bodyA) && validBody(indexedRow.bodyB)) { - if (avbdRigidRowUsesFiniteMaterial(indexedRow.row.materialStiffness)) { - indexedRow.row.state.lambda = 0.0; - const double maxStiffness = std::min( - indexedRow.row.materialStiffness, rowOptions.maxStiffness); - if (rowOptions.beta >= 0.0 - && indexedRow.row.state.stiffness >= maxStiffness) { - indexedRow.row.state.stiffness = maxStiffness; - continue; + const PointPairWorldPointCache& points + = pointPairWorldPoints(indexedRow, pointPairUpdateCache); + const double rawConstraintValue + = indexedRow.row.offset + + indexedRow.row.axis.dot( + points.worldPointB - points.worldPointA); + if (avbdRigidRowUsesFiniteMaterial( + indexedRow.row.materialStiffness)) { + const double maxStiffness = std::min( + indexedRow.row.materialStiffness, rowOptions.maxStiffness); + indexedRow.row.state.stiffness = updateAvbdFiniteStiffness( + indexedRow.row.state.stiffness, + rawConstraintValue, + rowOptions.beta, + maxStiffness); + } else { + const double constraintValue = regularizeAvbdConstraintValue( + rawConstraintValue, + indexedRow.row.previousConstraintValue, + rowOptions.alpha); + indexedRow.row.state = updateAvbdHardConstraintRow( + indexedRow.row.state, + constraintValue, + rowOptions.beta, + indexedRow.row.bounds, + rowOptions.maxStiffness); + } } - } + }); + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + distanceSpringRows.size(), + [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + AvbdRigidBodyPointPairDistanceSpringRow& indexedRow + = distanceSpringRows[i]; + if (validBody(indexedRow.bodyA) && validBody(indexedRow.bodyB) + && indexedRow.bodyB != indexedRow.bodyA) { + indexedRow.row.state = updateAvbdRigidPointPairDistanceSpringRow( + indexedRow.row.state, + states[indexedRow.bodyA], + states[indexedRow.bodyB], + indexedRow.row, + distanceSpringOptions); + } + } + }); + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + angularPairRows.size(), + [&](std::size_t begin, std::size_t end) { + AngularPairConstraintCache angularPairUpdateCache; + for (std::size_t i = begin; i < end; ++i) { + AvbdRigidBodyAngularPairRow& indexedRow = angularPairRows[i]; + if (!validBody(indexedRow.bodyA) || !validBody(indexedRow.bodyB)) { + continue; + } + if (avbdRigidRowUsesFiniteMaterial( + indexedRow.row.materialStiffness)) { + indexedRow.row.state.lambda = 0.0; + const double maxStiffness = std::min( + indexedRow.row.materialStiffness, rowOptions.maxStiffness); + if (rowOptions.beta >= 0.0 + && indexedRow.row.state.stiffness >= maxStiffness) { + indexedRow.row.state.stiffness = maxStiffness; + continue; + } + } - const double rawConstraintValue - = indexedRow.row.offset - + indexedRow.row.axis.dot(angularPairOrientationError( - indexedRow, angularPairUpdateCache)); - if (avbdRigidRowUsesFiniteMaterial(indexedRow.row.materialStiffness)) { - indexedRow.row.state.stiffness = updateAvbdFiniteStiffness( - indexedRow.row.state.stiffness, - rawConstraintValue, - rowOptions.beta, - std::min( - indexedRow.row.materialStiffness, rowOptions.maxStiffness)); - } else { - const double constraintValue = regularizeAvbdConstraintValue( - rawConstraintValue, - indexedRow.row.previousConstraintValue, - rowOptions.alpha); - indexedRow.row.state = updateAvbdHardConstraintRow( - indexedRow.row.state, - constraintValue, - rowOptions.beta, - indexedRow.row.bounds, - rowOptions.maxStiffness); - } - } - } - for (AvbdRigidBodyPointPairFrictionRows& indexedRows : frictionPairRows) { - if (validBody(indexedRows.bodyA) && validBody(indexedRows.bodyB)) { - updateAvbdRigidPointPairFrictionTangentPair( - indexedRows.first, - indexedRows.second, - states[indexedRows.bodyA], - states[indexedRows.bodyB], - frictionOptions); - } - } + const double rawConstraintValue + = indexedRow.row.offset + + indexedRow.row.axis.dot(angularPairOrientationError( + indexedRow, angularPairUpdateCache)); + if (avbdRigidRowUsesFiniteMaterial( + indexedRow.row.materialStiffness)) { + indexedRow.row.state.stiffness = updateAvbdFiniteStiffness( + indexedRow.row.state.stiffness, + rawConstraintValue, + rowOptions.beta, + std::min( + indexedRow.row.materialStiffness, + rowOptions.maxStiffness)); + } else { + const double constraintValue = regularizeAvbdConstraintValue( + rawConstraintValue, + indexedRow.row.previousConstraintValue, + rowOptions.alpha); + indexedRow.row.state = updateAvbdHardConstraintRow( + indexedRow.row.state, + constraintValue, + rowOptions.beta, + indexedRow.row.bounds, + rowOptions.maxStiffness); + } + } + }); + forEachAvbdRowUpdateRange( + rowUpdateExecutor, + frictionPairRows.size(), + [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + AvbdRigidBodyPointPairFrictionRows& indexedRows + = frictionPairRows[i]; + if (validBody(indexedRows.bodyA) && validBody(indexedRows.bodyB)) { + updateAvbdRigidPointPairFrictionTangentPair( + indexedRows.first, + indexedRows.second, + states[indexedRows.bodyA], + states[indexedRows.bodyB], + frictionOptions); + } + } + }); if (convergenceSquared > 0.0 && maxStepSquared <= convergenceSquared) { break; diff --git a/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp b/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp index e40ce32d90d55..65988c9541d08 100644 --- a/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp +++ b/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp @@ -2271,7 +2271,8 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( AvbdScalarRowInventory& distanceSpringInventory, double timeStep, AvbdRigidWorldContactSolveScratch& scratch, - const AvbdRigidWorldContactSolveOptions& options = {}) + const AvbdRigidWorldContactSolveOptions& options = {}, + compute::ComputeExecutor* rowUpdateExecutor = nullptr) { AvbdRigidWorldContactSolveResult result{ AvbdRigidWorldContactSolveResult::SizeAllocator{ @@ -2433,7 +2434,8 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( options.friction, &scratch.rowIndexScratch, distanceSpringRows, - options.distanceSpring); + options.distanceSpring, + rowUpdateExecutor); for (std::size_t i = 0; i < normalRowCount && i < normalInventory.size(); ++i) { diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index c90cdbfa0c79a..026468af19bf7 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -23,19 +23,19 @@ This folder is the temporary working surface; the durable owner is the plan. - **Branch state:** active local branch `feature/vbd-avbd-paper-parity-contract`, based on `main` at `83110ef54ab`; the fail-closed contract is committed locally at `710cbfc1152`, and nothing - has been pushed. -- **Current packet:** implement the Section 4 parallel dual/stiffness update - pass over the already-promoted CPU row inventories. The contracts contain 88 - VBD and 88 AVBD requirements; all 176 remain incomplete until their recorded - correctness, solver-identity, CPU/CUDA, visual, and comparable-performance - predicates pass. -- **Latest verified local packet:** `avbd.method.quasi_newton_hessian` advanced - from missing to partial. CPU deformable distance springs, rigid distance - springs, point attachments, and nonlinear point-pair joint/motor rows now - use the Section 3.5 column-norm model, while contact explicitly remains - Taylor-linearized. The row is not complete; angular/material families, - source adjudication, source-matched corpus evidence, CUDA, and comparable - achieved-accuracy performance remain open. + has been pushed. The Section 3.5 packet is committed locally at + `a78f688a178`; the Section 4 packet is the current local unpushed closeout. +- **Current packet:** verified Section 4 parallel dual/stiffness update over + the already-promoted deformable and private free-rigid CPU row inventories. + The contracts contain 88 VBD and 88 AVBD requirements; all 176 remain + incomplete until their recorded correctness, solver-identity, CPU/CUDA, + visual, and comparable-performance predicates pass. +- **Latest verified local packet:** + `avbd.method.parallel_dual_stiffness_pass` advanced from missing to partial. + The serial primal order is unchanged; inventories above the measured + 8,192-row gate use deterministic, allocation-stable persistent-worker + ranges. The row is not complete; articulated/unified inventories, CUDA, and + source-matched achieved-accuracy performance remain open. - **Recent slices merged to `main`** (see the PLAN-104 progress log and the PRs for detail; per-slice history lives in git, not in this file): - #2991 — source-row coverage + contact-precheck (`f6fecbc5bd5`). @@ -45,11 +45,16 @@ This folder is the temporary working surface; the durable owner is the plan. - #3022 — bounded regression coverage: rigid-contact tangent-basis contract, articulated break→reset→break re-arm lifecycle, row-inventory replaced-key cold-start (`65ba05113c6`). -- **Current local gates:** the fresh uncached Release/profile build, 168/168 - selected unit tests, focused AVBD tests, and the relevant - world-base/global/raw allocation tests pass. Two allocator tests previously - recorded as pre-existing failures also pass in this build and are no longer - open blockers. +- **Current local gates:** 228/228 focused compute/contact/deformable/rigid + tests and the three new production-World activation/allocation tests pass. + The complete World binary is 431/435; all four failures reproduce with the + same allocation counts and bytes on exact parent `a78f688a178`, so they are + recorded as pre-existing rather than attributed to this packet. The full + simulation label is 77/79 active CTest entries with only the two split + allocation entries failing on those same four assertions; its other two + listed entries are intentionally disabled. The aggregate C++ unit tier is + 168/168. Release/profile build, lint, PLAN-104 parity, AVBD-packet, and + PLAN-122 allocation-matrix checks pass. ## Goal @@ -80,10 +85,9 @@ numbers. ## Immediate Next Steps -Begin the bounded Section 4 parallel dual/stiffness update packet below after -the verified `avbd.method.quasi_newton_hessian` closeout. Keep every dependent -figure/demo/performance row partial until source-matched CPU and CUDA evidence -closes it. +Advance PLAN-104's articulated multibody AVBD extraction gap after the verified +Section 4 closeout. Keep every dependent figure/demo/performance row partial +until source-matched CPU and CUDA evidence closes it. Two smaller deferred maintenance items remain valid but do not outrank the missing paper mechanism: hoist the duplicated `makeCollisionPairKey` into a @@ -138,7 +142,7 @@ improves 1.02%; the 2D/3D Spring mechanisms cost 2.32%/1.90% more. These are descriptive same-host costs, not paper/reference-performance claims. The 120-frame software render passed pixel integrity and manual semantic review. -## Active Packet: Section 4 Parallel Dual/Stiffness Update +## Verified Local Packet: Section 4 Parallel Dual/Stiffness Update - **Value:** match Algorithm 1's additional post-primal pass without serializing otherwise independent AVBD rows. @@ -155,6 +159,17 @@ descriptive same-host costs, not paper/reference-performance claims. The `avbd.method.parallel_dual_stiffness_pass` only to partial until all paper row families and CUDA share the same contract. +The durable evidence is +[`../../plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json`](../../plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json). +Focused compute/deformable/rigid coverage passes 228/228, and the three new +production-World activation/allocation tests pass. The exact-parent interleaved +benchmark keeps 8,192 rows inline and records current 2-/4-worker speedups of +1.66x/2.20x at 16,384 rows, 2.34x/3.77x at 32,768 rows, and 1.78x/3.90x at +65,536 rows. These are descriptive mechanism-throughput results, not +paper/reference-performance claims. No new render is required for this +scheduling-only packet because bitwise serial/parallel row and production +World state equivalence is the stronger behavior oracle. + ## History Per-slice history and durable evidence live in the PLAN-104 progress log, the diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index 510b7c659b25d..8b7890feb4f3c 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -10,41 +10,34 @@ this file is only the current session pointer. See The exact 176-row VBD/AVBD paper-parity contract remains fail-closed, with no complete rows. The latest local packet advanced -`avbd.method.quasi_newton_hessian` from missing to partial: the Section 3.5 -force-scaled column-norm diagonal now covers CPU deformable distance springs, -rigid distance springs, rigid point attachments, and nonlinear point-pair -joint/motor rows. Contact rows encode their intentional Taylor-linearized -zero-curvature policy. Angular/material families, the pinned 3D spring source -divergence, full source-corpus evidence, CUDA, and comparable paper/reference +`avbd.method.parallel_dual_stiffness_pass` from missing to partial: the +Section 4 post-primal update now uses deterministic persistent-worker ranges +for sufficiently large promoted deformable and private free-rigid CPU row +inventories. The serial primal/color order remains unchanged. Articulated and +unified row coverage, CUDA, and comparable source-matched achieved-accuracy performance remain open. The durable packet is -[`../../plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json`](../../plans/104-vertex-block-descent-solver/avbd-quasi-newton-evidence.json). -It records 115 rigid and 9 deformable focused tests, full 6-DOF -finite-difference coverage, world-base/global/raw allocation closure, a fresh -uncached Release/profile build, 168/168 selected unit tests, exact-HEAD -interleaved mechanism benchmarks, a 60-second Spring text oracle, and an -assessed 120-frame software render. The method row stays partial. +[`../../plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json`](../../plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json). +It records bitwise serial/2-worker/4-worker coverage for every promoted row +kind, invalid and non-finite behavior, executor nesting/concurrency/exception +recovery, production-World activation, warmed world-base/global/raw allocation +closure, and the exact-parent interleaved throughput benchmark. The method row +stays partial. ## Current Branch `feature/vbd-avbd-paper-parity-contract`, based on the then-current `origin/main` at `83110ef54ab`. The fail-closed contract is committed locally -at `710cbfc1152`; the branch has not been pushed. +at `710cbfc1152`, and the Section 3.5 packet at `a78f688a178`; the branch has +not been pushed. ## Immediate Next Step -Implement `avbd.method.parallel_dual_stiffness_pass` next as a bounded -deterministic CPU packet: - -- factor the post-primal dual/stiffness update over promoted deformable and - rigid row inventories; -- prove serial/parallel state equivalence, row-order and thread-count - determinism, bounds/fracture behavior, and non-finite failure propagation; -- add warmed-step world-base/global/raw allocation gates and matched throughput - measurements; -- keep the canonical row partial until every paper row family and CUDA use the - same contract. +Advance PLAN-104's articulated multibody AVBD extraction gap. Preserve the +Section 4 claim boundary: the new CPU pass is only partial until articulated +and unified row inventories plus CUDA use the same contract and +source-matched achieved-accuracy evidence closes. ## Context That Would Be Lost @@ -77,6 +70,6 @@ pixi run check-avbd-packets ``` Stay in this exact worktree and branch. Start from PLAN-104's first current gap, -the Section 4 parallel dual/stiffness pass. Verify with focused AVBD and world -allocation tests, matched serial/parallel state evidence, the contract checker, -and `pixi run lint`; do not push or mutate GitHub without explicit approval. +articulated multibody AVBD extraction. Verify with focused AVBD and world +allocation tests, matched solver-state evidence, the contract checker, and +`pixi run lint`; do not push or mutate GitHub without explicit approval. diff --git a/docs/plans/104-vertex-block-descent-solver.md b/docs/plans/104-vertex-block-descent-solver.md index d620c3b3c144f..2b55c4b1ec38f 100644 --- a/docs/plans/104-vertex-block-descent-solver.md +++ b/docs/plans/104-vertex-block-descent-solver.md @@ -89,11 +89,13 @@ driver, a bounded half-space friction-tangent row primitive, and self-contact normal rows for point-triangle / edge-edge primitive directions and AVBD hard-row stamping. Self-contact friction tangent rows reuse lagged point-triangle / edge-edge tangent stencils in the combined mass-spring row -driver, with supported World generation for serial self-contact scenes plus +driver, with supported World generation for self-contact scenes plus pairwise static/dynamic switching and circular-cone projection. The supported mass-spring World envelope carries contact-normal, friction-tangent, self-contact-normal, attachment, and spring -finite-stiffness families in one serial AVBD row solve. The supported +finite-stiffness families in one AVBD row solve. Its primal sweep remains +serial and deterministic, while sufficiently large independent post-primal +dual/stiffness row inventories use deterministic CPU ranges. The supported frictionless pure-tetrahedral World envelope now carries finite-stiffness material rows with a dimensionless Lamé multiplier and separate tet-row diagnostics, while still using the existing lagged VBD self-contact penalty @@ -985,18 +987,14 @@ breakable fixed point-joint, prismatic/revolute/spherical facade, articulated breakable-joint, and high-ratio articulated-chain slices are user-visible but intentionally small. They do not cover the AVBD source-demo or paper corpus, and the dashboard rows -are narrow CPU public-World evidence only. The next bounded -implementation work should prefer one of these gaps, in order: +are narrow CPU public-World evidence only. The Section 4 post-primal +dual/stiffness update is now a verified CPU partial over the promoted +deformable and private free-rigid inventories; it remains open for +articulated/unified rows, CUDA, and source-matched achieved-accuracy +performance. The next bounded implementation work should prefer one of these +remaining gaps, in order: -1. **Parallel dual/stiffness update pass** — implement the Section 4 - post-primal row-state update as a deterministic, allocation-stable - task-parallel CPU pass over the already-promoted deformable and rigid row - inventories. Require serial/parallel state equivalence, stable row ordering, - a warmed-step allocation gate, and a matched throughput packet before - expanding the claim. This starts the missing - `avbd.method.parallel_dual_stiffness_pass` row but does not close it until - every row family and CUDA use the same contract. -2. **Articulated multibody AVBD extraction** — the private extractor now +1. **Articulated multibody AVBD extraction** — the private extractor now classifies free rigid-body endpoints separately from multibody links, and explicitly hard fixed, masked revolute/prismatic, and breakable multibody-link point-joint configs can bridge into the variational @@ -1154,7 +1152,7 @@ implementation work should prefer one of these gaps, in order: break/skip/reset endpoint-shape assertions, and explicit-anchor one-DOF motor reset endpoint/axis-shape assertions are current evidence rather than fresh next targets. -3. **Rigid contact persistence completeness** — broaden narrow-phase endpoint +2. **Rigid contact persistence completeness** — broaden narrow-phase endpoint feature extraction and row identity so box/sphere/cylinder/capsule/plane/mesh contact manifolds persist across realistic rigid stacks and piles, building beyond the current known/unknown shape-frame feature mapping, endpoint-A/B @@ -1199,7 +1197,7 @@ implementation work should prefer one of these gaps, in order: projection when contact normals rotate, then connect that evidence to the paper's rigid stacking/friction scenes. -4. **Paper/source-demo corpus implementation** — the durable +3. **Paper/source-demo corpus implementation** — the durable [`avbd-demo-corpus.md`](104-vertex-block-descent-solver/avbd-demo-corpus.md) matrix now owns the 19 `avbd-demo2d` scenes, 14 `avbd-demo3d` scenes, paper scenes, website/video scenes, and performance packets. The first baseline row @@ -1244,7 +1242,7 @@ implementation work should prefer one of these gaps, in order: finite-stiffness rows, hard multibody distance loop closures, and articulated per-axis compliant point-joint rows are not source-parity substitutes. -5. **GPU row parity plan** — route each landed CPU row family through the +4. **GPU row parity plan** — route each landed CPU row family through the private CUDA boundary only after the shared CUDA substrate and row inventory can preserve warm-started dual/stiffness state deterministically. @@ -1292,6 +1290,26 @@ AVBD parity additionally requires: Relocated from the dashboard on 2026-07-03; newest first. +On 2026-07-30, the bounded AVBD Section 4 packet added a deterministic +post-primal CPU dual/stiffness pass for the currently promoted deformable and +private free-rigid row inventories. The serial primal/color order is unchanged; +inventories at or below 8,192 rows stay inline, while larger independent +contact, attachment, spring, tet-material, self-contact, angular, and paired +friction inventories use allocation-stable persistent-worker ranges. +Bitwise serial/2-worker/4-worker state tests cover every promoted row family, +invalid-row no-ops, and non-finite propagation. Production `World` tests prove +parallel executors no longer disable supported AVBD rows and that a +dispatch-sized self-contact scene adds no warmed world-base, global-`new`, or +raw-malloc allocation above the empty-graph floor. The final pinned +exact-parent B-C-B-C benchmark records current 2-/4-worker speedups of +1.66x/2.20x at 16,384 rows, 2.34x/3.77x at 32,768 rows, and 1.78x/3.90x at +65,536 rows. These are descriptive mechanism-throughput results, not +source-reference or paper-performance claims. +[`avbd-parallel-dual-update-evidence.json`](104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json) +advances `avbd.method.parallel_dual_stiffness_pass` from `missing` to +`partial`; articulated/unified row coverage, CUDA, and source-matched +achieved-accuracy performance remain open. + On 2026-07-30, the bounded AVBD Section 3.5 quasi-Newton packet added a shared fixed-size column-norm kernel and applied the paper's force-scaled geometric diagonal to CPU deformable distance springs, rigid distance springs, rigid diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json index a8727626c9e44..a021861213891 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json @@ -174,10 +174,13 @@ }, { "id": "avbd.method.parallel_dual_stiffness_pass", - "status": "missing", + "status": "partial", "source_locator": "Section 4, parallel dual-variable and stiffness update after each primal iteration", - "evidence": [], - "blockers": ["No complete task-parallel CPU and CUDA dual/stiffness pass spans every AVBD row family."] + "evidence": [ + "docs/plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json", + "tests/benchmark/simulation/bm_avbd_parallel_row_update.cpp" + ], + "blockers": ["The deterministic CPU pass covers the currently promoted deformable and free-rigid row families only; complete articulated/unified row coverage, CUDA parity, and source-matched achieved-accuracy performance remain open."] }, { "id": "avbd.method.contact_persistence", diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md index 3e219e6407b14..3e010faa920cd 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md @@ -264,8 +264,11 @@ The first AVBD implementation slices add: coupling, and paper/demo corpus reproduction are still missing; and - explicit World fallback coverage so unsupported mixed spring-plus-tet, mass-spring self-contact without the self-contact AVBD flag, Chebyshev, - Rayleigh-damped, parallel, and unsupported-row requests keep using the - existing VBD path without reporting partial AVBD row counters. + Rayleigh-damped, and unsupported-row requests keep using the existing VBD + path without reporting partial AVBD row counters. A parallel executor no + longer disables otherwise supported AVBD rows: the deterministic primal + order is unchanged, while sufficiently large independent dual/stiffness row + inventories use the Section 4 post-primal pass. Those are foundation pieces only. They are not a full World AVBD solver, not a full hard-contact/friction solver, and not CPU/GPU parity. @@ -283,7 +286,7 @@ full hard-contact/friction solver, and not CPU/GPU parity. | Unified rigid/soft interactions and cloth/articulated-body coupling | Missing | A6 | | Collision candidate generation, contact persistence, and row warm-start mapping | Static half-space row keys include static box face/edge/corner feature IDs and smooth-frame tangent projection, lagged self-contact normal/friction candidate row keys plus self-contact tangent projection started for mass-spring and pure-tet envelopes, and private dynamic/rigid contact feature IDs plus canonical two-endpoint row keys, normal/friction row descriptor helpers, rigid point-pair contact/friction constructors, a private rigid contact-manifold row builder, private World-contact snapshot/solve/writeback extraction plus combined wrapper, initial contact-stage activation with focused dynamic/dynamic and static/dynamic velocity-projection coverage plus mixed-config all-or-nothing fallback and warm-started friction slip-reduction coverage including dynamic-owned, static-owned, kinematic-owned, enabled-peer/disabled-peer, simultaneous multi-contact, static/dynamic box-manifold, dynamic/dynamic box-manifold, stacked static/dynamic plus dynamic/dynamic box-manifold, multi-top stacked box-manifold, default-step dynamic/dynamic, stacked, and multi-top stacked box-manifold schedule paths, and dynamic/dynamic sphere focused regressions, box/sphere/cylinder/capsule endpoint feature persistence, known/unknown shape-frame feature mapping from world contact points including endpoint-A/B explicit compound shape-index coverage that uses narrow-phase shape-local contact points, actual `World::collide()` sphere/cylinder/capsule/plane/mesh primitive-feature coverage including shape-scoped sphere body features, plane face feature persistence, mesh face/edge/vertex feature persistence, deterministic same-feature rigid contact row ordering including actual `World::collide()` sphere/plane contact-point replay, sphere/mesh-face contact-point replay, sphere/mesh-edge contact-point replay, mesh-vertex replay, mesh-face/mesh-edge/mesh-vertex small-pose persistence plus endpoint-order stability, cylinder-cap/plane contact-point replay, capsule-cap/plane contact-point replay, cylinder-side/capsule-side contact-point replay, cylinder-rim contact-point replay, and cylinder/capsule cap/side/rim small-pose persistence plus endpoint-order stability, live sphere/plane friction tangent warm-start mapping, live sphere/plane normal-rotation friction tangent projection evidence, live sphere/plane and box-box manifold endpoint-order row-identity evidence, live endpoint-swapped box-box friction tangent projection evidence, live box-box small-pose row-persistence evidence, live box-box manifold friction warm-start evidence, live stacked-box friction warm-start and endpoint-swapped friction tangent projection evidence, live spanning-top and multi-top box-pile friction row-persistence, contact-order replay, and endpoint-swapped tangent projection evidence plus contact-stage/default-step friction slip-reduction evidence, and live box-box manifold box-face feature evidence, private rigid contact tangent-dual projection across tangent-basis changes, and the first private multibody-link endpoint projection bridge started; broader contact-stage dynamic-contact coverage, broad articulated endpoint mapping, broad feature persistence, and broad row generation remain open | A7 | | Hessian approximation for hard constraints and stiffness-rescaling friction | Section 3.5 column-norm geometric-stiffness lumping now covers CPU deformable distance springs, rigid distance springs, rigid point attachments, and nonlinear rigid point-pair joint/motor rows; rigid contact rows explicitly retain the paper's Taylor-linearized zero-curvature model. [`avbd-quasi-newton-evidence.json`](avbd-quasi-newton-evidence.json) records mutation-sensitive analytic tests, post-bake allocation coverage, a 60-second source Spring oracle, an assessed current-build render, and an interleaved exact-HEAD mechanism-cost benchmark. Remaining deformable/material and angular row families, friction stiffness rescaling, full source-corpus closure, achieved-accuracy reference performance, and CUDA parity are open. | A8 | -| CPU parallel color sweeps plus deterministic dual update pass | Serial contact-normal dual pass started; parallel dual/update scheduling missing | A9 | +| CPU parallel color sweeps plus deterministic dual update pass | The Section 4 post-primal pass now dispatches deterministic contiguous ranges over the currently promoted CPU deformable contact, attachment, spring, tet-material, self-contact, and paired-friction rows plus private free-rigid attachment, point-pair, distance-spring, angular, and paired-friction rows. Small inventories remain inline behind a measured 8,192-row cost gate; incomplete friction-pair layouts preserve the exact serial fallback. Bitwise serial/2-worker/4-worker state tests, non-finite propagation, production-World activation, warmed world-base/global/raw allocation gates, executor nesting/concurrency/exception recovery, and an interleaved exact-parent throughput packet are recorded in [`avbd-parallel-dual-update-evidence.json`](avbd-parallel-dual-update-evidence.json). Parallel primal color sweeps for the AVBD-specific inventories, full articulated/unified row coverage, CUDA, and source-matched achieved-accuracy performance remain open. | A9 | | CUDA/GPU AVBD backend for all row families and scene corpus | VBD CUDA mass-spring/tet rollout only | G1-G5 | | DART-owned reproductions of 2D/3D demos and paper/video scenes | First narrow AVBD rigid-constraint py-demo started (`avbd_rigid_fixed_joint_contact` for fixed-joint rows plus ordinary contact), `avbd_rigid_revolute_motor` covers the public free-rigid-body velocity-motor row path, `avbd_articulated_revolute_motor` and `avbd_articulated_prismatic_motor` cover narrow public articulated velocity-motor command-update paths, `avbd_articulated_motor_breakable_joint` covers a narrow same-multibody public articulated revolute motor break/reset lifecycle, `avbd_articulated_prismatic_pair_motor_breakable_joint` covers a narrow same-multibody public articulated prismatic motor break/reset lifecycle, `avbd_articulated_prismatic_motor_breakable_joint` covers a narrow world-anchored public articulated prismatic motor break/reset lifecycle, `avbd_articulated_world_revolute_motor_breakable_joint` covers a narrow world-anchored public articulated revolute motor break/reset lifecycle, `avbd_rigid_breakable_joint` covers the narrow public free-rigid-body break-force lifecycle, `avbd_rigid_spherical_breakable_joint` covers the narrow public free-rigid-body spherical break/reset lifecycle, `avbd_articulated_breakable_joint` covers the narrow public world-link articulated fixed point-joint break/reset lifecycle, `avbd_articulated_fixed_pair_breakable_joint` covers the narrow public same-multibody articulated fixed point-joint break/reset lifecycle, `avbd_articulated_spherical_breakable_joint` covers the narrow public world-link articulated spherical break/reset lifecycle, `avbd_articulated_spherical_pair_breakable_joint` covers the narrow public same-multibody articulated spherical break/reset lifecycle, `avbd_articulated_high_ratio_chain`, `BM_AvbdArticulatedHighRatioChainStep`, and [`avbd-articulated-high-ratio-chain-packet.json`](avbd-articulated-high-ratio-chain-packet.json) cover a narrow five-link 200:1 high mass-ratio articulated-chain smoke, `avbd_paper_scale_high_ratio_chain`, `BM_AvbdPaperScaleHighRatioChainStep`, and [`avbd-paper-scale-high-ratio-chain-packet.json`](avbd-paper-scale-high-ratio-chain-packet.json) add 50-link/50,000:1 visual and CPU benchmark evidence without a same-hardware comparison, `avbd_empty_baseline` provides a shared smoke baseline plus source-row metadata/reference invariant for the 2D/3D source-demo empty rows with a tracked visual/benchmark packet, `avbd_demo2d_ground`, `avbd_demo2d_motor`, `avbd_demo2d_hanging_rope`, `avbd_demo2d_fracture`, `avbd_demo2d_dynamic_friction`, `avbd_demo2d_static_friction`, `avbd_demo2d_pyramid`, `avbd_demo2d_cards`, `avbd_demo2d_stack`, `avbd_demo2d_stack_ratio`, `avbd_demo2d_rod`, `avbd_demo2d_soft_body`, `avbd_demo2d_joint_grid`, `avbd_demo2d_rope`, `avbd_demo2d_net`, `avbd_demo3d_ground`, `avbd_demo3d_dynamic_friction`, `avbd_demo3d_static_friction`, `avbd_demo3d_pyramid`, `avbd_demo3d_rope`, `avbd_demo3d_heavy_rope`, `avbd_demo3d_spring`, `avbd_demo3d_spring_ratio`, `avbd_demo3d_stack`, `avbd_demo3d_stack_ratio`, `avbd_demo3d_soft_body`, `avbd_demo3d_bridge`, and `avbd_demo3d_breakable` now provide the first non-empty source-row ports with focused invariants, dashboard rows, and tracked source timing packets, and `sx_rigid_limited_joints` covers the public one-DOF joint facade path; paper figures, parameter sweeps, website demos, video/headline scenes, broad GPU evidence, benchmark-linked reproductions, and matched source-demo CPU wins remain missing | D1-D6 | | Benchmark packets beating reference demos and paper numbers on CPU/GPU | Narrow CPU dashboard rows now track the public free-rigid-body revolute velocity-motor AVBD path, public articulated revolute/prismatic velocity-motor paths, public free-rigid/articulated breakable fixed point-joint paths, the narrow five-link 200:1 high-ratio articulated-chain smoke, the 50-link/50,000:1 paper-scale high-ratio dashboard row, the `avbd-demo2d` Ground, Motor, Hanging Rope, Fracture, Dynamic Friction, Static Friction, Pyramid, Cards, Stack, Stack Ratio, Rod, Soft Body, Joint Grid, Rope, Heavy Rope, and Net source rows, the `avbd-demo3d` Ground, Dynamic Friction, Static Friction, Pyramid, Rope, Heavy Rope, Spring, Spring Ratio, Stack, Stack Ratio, Soft Body, Bridge, and Breakable source rows, and empty source-demo baseline alongside the fixed-joint row; the narrow high-ratio chain smoke, empty source-demo baseline, `avbd-demo2d` Ground/Motor/Hanging Rope/Fracture/Dynamic Friction/Static Friction/Pyramid/Cards/Stack/Stack Ratio/Rod/Soft Body/Joint Grid/Rope/Heavy Rope/Net rows, and `avbd-demo3d` Ground, Dynamic Friction, Static Friction, Pyramid, Rope, Heavy Rope, Spring, Spring Ratio, Stack, Stack Ratio, Soft Body, Bridge, and Breakable rows have tracked packets, with the soft-body and Joint Grid packets configuring the source diagonal ignore-collision pairs through DART's public per-pair filter; the paper-scale high-ratio row now has a visual/benchmark packet but still lacks a same-hardware paper-number comparison; 2D Ground, 2D Fracture, 2D Dynamic Friction, 2D Static Friction, 2D Pyramid, 2D Stack, 2D Stack Ratio, 3D Ground, 3D Dynamic Friction, 3D Static Friction, Pyramid, Stack, Stack Ratio, Soft Body, Bridge, and Breakable are faster than the native source runner on this host but Motor, Hanging Rope, 2D Cards, 2D Rod, 2D Soft Body, 2D Joint Grid, 2D Rope, 2D Heavy Rope, 2D Net, 2D/3D Spring, 2D/3D Spring Ratio, 3D Rope, and 3D Heavy Rope are still slower, so broad CPU/GPU parity and proof against published paper numbers remain missing | P1-P4 | @@ -598,6 +601,13 @@ open. ## Current Next Gap +The bounded Section 4 CPU dual/stiffness packet is now a verified partial +implementation: deterministic persistent-worker ranges cover the promoted +deformable and private free-rigid row families above the measured 8,192-row +cost gate without changing the serial primal sweep. It does not close the +canonical method row because articulated/unified row coverage, CUDA, and +source-matched achieved-accuracy performance are still missing. + After the narrow free-rigid fixed-joint/contact, motor, breakable fixed point-joint public-World benchmark slices, the first non-empty `avbd-demo2d` Ground, Motor, Hanging Rope, Fracture, Dynamic Friction, Static Friction, diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json b/docs/plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json new file mode 100644 index 0000000000000..f671852588a64 --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json @@ -0,0 +1,258 @@ +{ + "schema_version": "dart.avbd_parallel_dual_update_evidence/v1", + "plan_id": "PLAN-104", + "contract_row": "avbd.method.parallel_dual_stiffness_pass", + "status": "partial", + "recorded_at": "2026-07-30", + "source": { + "paper": "Augmented Vertex Block Descent", + "paper_sha256": "7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6", + "locator": "Section 4 and Algorithm 1: parallel dual-variable and stiffness update after each primal iteration", + "claim_boundary": "This packet implements and measures the post-primal CPU mechanism over currently promoted deformable and private free-rigid AVBD row inventories. It is not a full articulated/unified-row, CUDA, source-reference, achieved-accuracy, or paper-performance comparison." + }, + "build_identity": { + "baseline_commit": "a78f688a1785af775bd247ba5dac1e971eaf934e", + "candidate_parent_commit": "a78f688a1785af775bd247ba5dac1e971eaf934e", + "candidate_state": "local working tree; no push", + "build_type": "Release", + "compiler": "GNU 15.2.0", + "profile_enabled": true, + "solver_source_sha256": { + "dart/simulation/compute/parallel_executor.cpp": "67f9b89936437440668c48bdf09e4865673171a40b4ecdaced000cae77254697", + "dart/simulation/compute/parallel_executor.hpp": "82d55ec2dce07f791cdaa5fd5b1571f436e8de8c4c99292d635e2ff0ba90055c", + "dart/simulation/detail/deformable_vbd/parallel_row_update.hpp": "165add5e0dfa1d790f34be6a686e70a05d5adb6f79a4499d887aba26e72bac25", + "dart/simulation/detail/deformable_vbd/block_descent.hpp": "2a51fdfc8e339a50e560dc16e224e42cd45e1288e5ef283c7f1e4d5b16a8c7e0", + "dart/simulation/compute/deformable_dynamics_stage.cpp": "d4efffea03450598986a3c0c7845fde842c2579c9fc74911e83b6b5f33a8b738", + "dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp": "1f1353f7488a124313cd57bf8c2766b249c810e1515bd8efcb581e6510fcdcb9", + "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp": "81622ebb2373fee670f5ccf4b86e4c790f9dedcde8bb285973cea16e842a7068", + "dart/simulation/compute/rigid_body_contact_stage.cpp": "8cd7cf6f1b46cdada289ef5448ff6e59b41ccc92d841e645971c600ef226568f", + "tests/benchmark/simulation/bm_avbd_parallel_row_update.cpp": "668d05eb9bc7dd24fddf9a4d8e48cf048e20406bd3ecd594eee9952a245f33a4" + } + }, + "implementation_scope": { + "ordering_contract": "The AVBD primal vertex/body sweep remains serial and deterministic. Each post-primal family completes synchronously before the next family or primal iteration.", + "range_contract": { + "minimum_parallel_row_count_exclusive": 8192, + "minimum_grain_size": 2048, + "partition": "fixed deterministic contiguous ranges", + "small_inventory_policy": "inline", + "incomplete_friction_pair_policy": "exact serial fallback" + }, + "executor_contract": "ParallelExecutor lazily retains N-1 sleeping range workers while the caller supplies the Nth lane. Concurrent or recursive calls run inline; worker and caller exceptions are collected, rethrown after the synchronous join, and do not poison the pool.", + "covered_deformable_cpu_rows": [ + "half-space contact normal", + "point attachment", + "finite-stiffness distance spring", + "finite-stiffness tetrahedral material", + "self-contact normal", + "complete adjacent half-space friction tangent pairs", + "complete adjacent self-contact friction tangent pairs" + ], + "covered_private_free_rigid_cpu_rows": [ + "point attachment", + "hard and finite-stiffness point pair", + "distance spring", + "hard and finite-stiffness angular pair", + "point-pair friction tangent pairs" + ] + }, + "correctness": { + "focused_tests": [ + "SimulationParallelExecutor.ParallelForSupportsCooperativeNestedRanges", + "SimulationParallelExecutor.ConcurrentParallelForFallsBackWithoutBlockingWorkers", + "SimulationParallelExecutor.WorkerParallelForRecoversAfterException", + "SimulationParallelExecutor.WarmedWorkerParallelForAddsNoHeapAllocationAboveGraphFloor", + "VbdCombinedDescent.AvbdDualUpdateRowsAreBitwiseDeterministicAcrossWorkerCounts", + "VbdCombinedDescent.ParallelDualUpdatePreservesNonfiniteRowFailureBehavior", + "VbdFiniteStiffness.TetAndSelfContactDualUpdatesAreBitwiseDeterministicAcrossWorkers", + "AvbdRigidBlock.RigidDualUpdateRowsAreBitwiseDeterministicAcrossWorkerCounts", + "World.DeformableAvbdRowsRemainActiveWithParallelExecutor", + "World.RigidAvbdRowsRemainActiveWithParallelExecutor", + "World.WarmedParallelAvbdDualUpdatePassIsAllocationStable" + ], + "focused_test_result": { + "compute": "57 passed", + "contact": "22 passed", + "combined_deformable": "23 passed", + "finite_stiffness": "10 passed", + "rigid_avbd": "116 passed", + "focused_total": "228 passed", + "new_world_integration": "3 passed", + "executor_stress": "the nested, concurrent, and worker-exception tests passed 200 consecutive repetitions" + }, + "state_oracle": "Every promoted row family uses 8,193 scalar rows or logical friction pairs and compares serial against 2- and 4-worker state bit-for-bit. Invalid final rows explicitly remain unchanged. The production World activation tests compare sequential and parallel state bit-for-bit.", + "failure_oracle": "A NaN in a dispatched hard-contact row produces the same non-finite row state as serial execution without changing finite peers; a background-worker exception is rethrown and a subsequent range succeeds.", + "full_validation": { + "build": "pixi run build passed for the Release/profile C++ library, dartpy, GUI, examples, and tutorials; pixi run build-simulation-tests rebuilt the affected solver test", + "lint": "pixi run lint passed", + "aggregate_unit": "pixi run test-unit --jobs 8 passed 168 of 168 CTest entries", + "world_binary": "431 of 435 GoogleTests passed; the four failures reproduced identically on exact parent and are enumerated under known_baseline_failures", + "simulation_label": "77 of 79 active CTest entries passed; 2 additional listed entries were disabled, and the only failures were the global-heap and raw-malloc splits containing the four exact-parent assertions", + "contract_checkers": { + "plan104_paper_parity": "176 rows accepted: VBD 40 partial and 48 missing; AVBD 70 partial and 18 missing", + "avbd_packets": "52 packets accepted", + "plan122_allocation_matrix": "18 rows accepted, 14 closed" + }, + "status": "closed for this packet's partial CPU-mechanism claim boundary" + } + }, + "allocation": { + "test": "World.WarmedParallelAvbdDualUpdatePassIsAllocationStable", + "scene": "21 by 31 deformable AVBD self-contact/friction grid", + "dispatch_precondition": "captured self-contact-normal row count is greater than 8,192; the paired friction inventory has the same logical-pair count", + "warmup_steps": 2, + "measured_steps": 2, + "coverage": [ + "World base allocator allocation/deallocation counters remain unchanged", + "global operator-new count and bytes equal the empty parallel-graph floor", + "raw malloc-family count and bytes equal the empty parallel-graph floor" + ], + "coverage_build_policy": "The dispatch-sized scene is skipped under DART_CODECOV because instrumentation makes it prohibitively slow.", + "result": "pass" + }, + "known_baseline_failures": { + "classification": "Reproduced identically in the exact-parent temporary worktree and therefore outside this packet.", + "tests": { + "World.BakedRigidBodyContactStepsDoNotAllocateGlobalHeap": "1 allocation, 320 bytes", + "World.BakedDefaultRigidAndArticulatedContactsDoNotMallocOnHeap": "1 allocation, 320 bytes", + "World.BakedBoxedLcpFallbackContactStepsDoNotAllocateGlobalHeap": "1 allocation, 80 bytes", + "World.BakedBoxedLcpFallbackContactStepsDoNotMallocOnHeap": "29 allocations, 1,376 bytes" + } + }, + "performance": { + "claim_boundary": "Descriptive same-host exact-parent versus candidate mechanism throughput only; this is not a source-reference, achieved-accuracy, scene-level, or paper-number comparison.", + "host": "mark26", + "cpu_affinity": [0, 2, 4, 6], + "cpu_affinity_policy": "four distinct performance cores; simultaneous-multithreading siblings excluded", + "cpu_reported_mhz": 5300, + "cpu_scaling_enabled": true, + "library": "Google Benchmark 1.9.5", + "baseline_commit": "a78f688a1785af775bd247ba5dac1e971eaf934e", + "baseline_binary_sha256": "fe8e4b05de8fdfb4143de86a2eb2d48bc4d49e4f4ea27dd10d4c70d891ed8e4c", + "candidate_binary_sha256": "1a97ee53d30f9851b324055896148197291c60b67946fe251dea1ea788a6c54b", + "benchmark_source_sha256": "668d05eb9bc7dd24fddf9a4d8e48cf048e20406bd3ecd594eee9952a245f33a4", + "protocol": "Exact-parent baseline and working-tree candidate ran baseline-candidate-baseline-candidate with identical P-core affinity, row/worker filters, 0.05-second minimum time, five repetitions, real time, and aggregate-only JSON.", + "command": "taskset -c 0,2,4,6 --benchmark_filter='rows:(8192|16384|32768|65536)' --benchmark_min_time=0.05s --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out= --benchmark_out_format=json", + "runs": [ + { + "order": 1, + "kind": "baseline", + "json_sha256": "7738fc92c359c1b0f11a5ee3332d9ab28495869158cce4dce7b6859af8aa2693", + "median_real_time_ns": { + "rows_8192_workers_0": 11887.557873288626, + "rows_8192_workers_2": 11932.359385159529, + "rows_8192_workers_4": 12539.639449319013, + "rows_16384_workers_0": 25705.976595882141, + "rows_16384_workers_2": 17728.262784457678, + "rows_16384_workers_4": 18589.287471265994, + "rows_32768_workers_0": 73324.714847615949, + "rows_32768_workers_2": 33980.909426824626, + "rows_32768_workers_4": 32657.342325074562, + "rows_65536_workers_0": 149396.56298005636, + "rows_65536_workers_2": 87596.456897746262, + "rows_65536_workers_4": 56897.668041176847 + } + }, + { + "order": 2, + "kind": "candidate", + "json_sha256": "1147bb58997909d9e9135a2c6b152b756f035aa8e1fa0440eb268f6789087a24", + "median_real_time_ns": { + "rows_8192_workers_0": 12029.497925989757, + "rows_8192_workers_2": 12207.765887853542, + "rows_8192_workers_4": 13234.335333325569, + "rows_16384_workers_0": 26261.663654549029, + "rows_16384_workers_2": 16340.909506382555, + "rows_16384_workers_4": 11868.047250050975, + "rows_32768_workers_0": 73214.950729476433, + "rows_32768_workers_2": 31210.667629555846, + "rows_32768_workers_4": 19551.378009601773, + "rows_65536_workers_0": 148424.82153600192, + "rows_65536_workers_2": 84925.856298298051, + "rows_65536_workers_4": 38246.119299767503 + } + }, + { + "order": 3, + "kind": "baseline", + "json_sha256": "17af7635d9432b80823dd5201a43f6e824e0b48cc1009db811d3de841d94f830", + "median_real_time_ns": { + "rows_8192_workers_0": 12063.981261009907, + "rows_8192_workers_2": 12421.624225094092, + "rows_8192_workers_4": 12570.948968263805, + "rows_16384_workers_0": 25734.676365655931, + "rows_16384_workers_2": 18421.12049446152, + "rows_16384_workers_4": 18999.496246061994, + "rows_32768_workers_0": 76081.162869750071, + "rows_32768_workers_2": 34368.453667989088, + "rows_32768_workers_4": 32143.475444625965, + "rows_65536_workers_0": 149332.80121500712, + "rows_65536_workers_2": 85664.888211015466, + "rows_65536_workers_4": 55628.999030514526 + } + }, + { + "order": 4, + "kind": "candidate", + "json_sha256": "bbb8d9e60271423b753a56747c99a9d057653664eae63f67f115882d7db1479b", + "median_real_time_ns": { + "rows_8192_workers_0": 12228.983498368787, + "rows_8192_workers_2": 12382.614298362492, + "rows_8192_workers_4": 12597.814123893089, + "rows_16384_workers_0": 26984.276723470433, + "rows_16384_workers_2": 15723.842127834621, + "rows_16384_workers_4": 12363.319274438021, + "rows_32768_workers_0": 74341.269533702725, + "rows_32768_workers_2": 31895.938535971025, + "rows_32768_workers_4": 19585.75631886597, + "rows_65536_workers_0": 150995.72352702945, + "rows_65536_workers_2": 83463.872355938976, + "rows_65536_workers_4": 38611.650858916299 + } + } + ], + "mean_of_paired_medians": { + "rows_8192_workers_0": {"baseline_ns": 11975.769567149266, "candidate_ns": 12129.240712179271, "delta_percent": 1.281513844847}, + "rows_8192_workers_2": {"baseline_ns": 12176.99180512681, "candidate_ns": 12295.190093108016, "delta_percent": 0.970669027891}, + "rows_8192_workers_4": {"baseline_ns": 12555.294208791409, "candidate_ns": 12916.074728609328, "delta_percent": 2.873532979938}, + "rows_16384_workers_0": {"baseline_ns": 25720.326480769036, "candidate_ns": 26622.97018900973, "delta_percent": 3.509456650621}, + "rows_16384_workers_2": {"baseline_ns": 18074.691639459597, "candidate_ns": 16032.375817108588, "delta_percent": -11.299312116022}, + "rows_16384_workers_4": {"baseline_ns": 18794.391858663992, "candidate_ns": 12115.683262244498, "delta_percent": -35.535646200442}, + "rows_32768_workers_0": {"baseline_ns": 74702.93885868302, "candidate_ns": 73778.11013158958, "delta_percent": -1.238008492334}, + "rows_32768_workers_2": {"baseline_ns": 34174.68154740686, "candidate_ns": 31553.303082763436, "delta_percent": -7.670527846783}, + "rows_32768_workers_4": {"baseline_ns": 32400.408884850265, "candidate_ns": 19568.56716423387, "delta_percent": -39.60394995699}, + "rows_65536_workers_0": {"baseline_ns": 149364.68209753174, "candidate_ns": 149710.2725315157, "delta_percent": 0.231373594568}, + "rows_65536_workers_2": {"baseline_ns": 86630.67255438087, "candidate_ns": 84194.86432711851, "delta_percent": -2.811715706967}, + "rows_65536_workers_4": {"baseline_ns": 56263.33353584568, "candidate_ns": 38428.8850793419, "delta_percent": -31.698172389912} + }, + "candidate_serial_to_parallel_speedup": { + "rows_16384_workers_2": 1.660575481308, + "rows_16384_workers_4": 2.197397341343, + "rows_32768_workers_2": 2.338205605228, + "rows_32768_workers_4": 3.770235680129, + "rows_65536_workers_2": 1.7781401957, + "rows_65536_workers_4": 3.895774551419 + }, + "cost_gate_verdict": "8,192 rows and below remain inline. The worker-count variants at 8,192 execute the same inline row loop; their 0.97-2.87 percent spread is measurement noise, not a parallel-speedup claim." + }, + "visual": { + "required": false, + "reason": "This packet changes only the synchronous scheduling of independent post-primal row updates. Bitwise serial/parallel row state and production World state are stronger semantic oracles than a new render; no scene, geometry, material, camera, or solver equation changed." + }, + "review": { + "passes": 2, + "scope": "worker-pool synchronization and lifetime, deterministic range coverage, exception recovery, row and friction-pair independence, mixed-layout fallback, overflow boundaries, and executor move/destruction behavior", + "resolved_findings": [ + "made partial worker-thread construction exception-safe", + "strengthened the exception-recovery regression so the throw originates on a background worker" + ], + "result": "clean after the resolved findings" + }, + "remaining_blockers": [ + "AVBD-specific primal color sweeps remain serial.", + "Articulated variational and unified soft/rigid row inventories do not yet share this post-primal pass.", + "The CUDA AVBD backend and its dual/stiffness pass are missing.", + "No source-matched achieved-accuracy comparison or paper-number comparison is closed.", + "Dependent paper figures, videos, online demos, and project-page rows remain partial or missing." + ] +} diff --git a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md index f6d39ef7770fa..6adef0b848988 100644 --- a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md +++ b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md @@ -32,15 +32,15 @@ below, so neither solver family has a paper-parity claim. | VBD TinyVBD | 1 | 1 | 0 | 0 | 0 | | VBD project page | 3 | 0 | 0 | 3 | 0 | | **VBD total** | **88** | **40** | **0** | **48** | **0** | -| AVBD method | 20 | 17 | 0 | 3 | 0 | +| AVBD method | 20 | 18 | 0 | 2 | 0 | | AVBD paper figures | 15 | 9 | 0 | 6 | 0 | | AVBD paper tables | 2 | 1 | 0 | 1 | 0 | | AVBD official video | 14 | 7 | 0 | 7 | 0 | | AVBD demo2d | 19 | 19 | 0 | 0 | 0 | | AVBD demo3d | 14 | 14 | 0 | 0 | 0 | | AVBD project page | 4 | 2 | 0 | 2 | 0 | -| **AVBD total** | **88** | **69** | **0** | **19** | **0** | -| **Grand total** | **176** | **109** | **0** | **67** | **0** | +| **AVBD total** | **88** | **70** | **0** | **18** | **0** | +| **Grand total** | **176** | **110** | **0** | **66** | **0** | `Partial` means only that at least one relevant DART artifact exists and that the row still records an explicit blocker. It is negative evidence against a diff --git a/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md b/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md index d7389e5203505..631af365c0c31 100644 --- a/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md +++ b/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md @@ -22,7 +22,7 @@ Status meanings: | R-004 | Closed | Large rigid IPC equality KKT systems | `BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`, `BakedDynamicRigidIpcStepsDoNotAllocateGlobalHeap`, and `BakedRigidIpcLargeEqualityKktStepsDoNotMallocOnHeap` cover a fixed-joint rigid IPC chain whose equality KKT system exceeds the stack solve cap and uses retained dynamic KKT storage after bake. | None for the large rigid IPC equality KKT row. | WP-122.2, PLAN-082, PLAN-083 | | M-001 | Closed | Semi-implicit multibody velocity and force paths | `BakedVelocityActuatedMultibodyStepsDoNotMallocOnHeap` covers first-post-bake raw malloc for k == 1, k >= 2, and velocity-actuated articulated contact scenes; global-heap gates cover semi-implicit force scratch. | None for the semi-implicit multibody velocity/force row. | WP-122.4, PLAN-080 | | M-002 | Closed | Variational multibody integration | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers representative variational scratch, compliant contact, dual state, loop closure, and long articulated point-joint scenes; `BakedVariationalMultibodyStepsDoNotMallocOnHeap` covers the same scenes for first-post-bake raw malloc. | None for currently promoted variational rows; future PLAN-084 rows require world-base/global/raw post-bake gates before promotion. | WP-122.4, PLAN-084 | -| M-003 | Closed | AVBD/VBD rows selected inside DART 7 `World::step()` | World-base/global-heap gates cover representative rigid AVBD rows, deformable AVBD rows including the Section 3.5 finite-stiffness spring path, and a VBD Chebyshev self-contact row; `BakedAvbdVbdRowsDoNotMallocOnHeap` covers the same representative rows for first-post-bake raw malloc. | None for currently promoted AVBD/VBD rows; extend with PLAN-104 corpus rows as they are promoted. | WP-122.4, PLAN-104 | +| M-003 | Closed | AVBD/VBD rows selected inside DART 7 `World::step()` | World-base/global-heap gates cover representative rigid AVBD rows, deformable AVBD rows including the Section 3.5 finite-stiffness spring path, and a VBD Chebyshev self-contact row; `BakedAvbdVbdRowsDoNotMallocOnHeap` covers the same representative rows for first-post-bake raw malloc. `WarmedParallelAvbdDualUpdatePassIsAllocationStable` extends the contract to a dispatch-sized Section 4 AVBD self-contact pass and proves warmed world-base stability plus no global-`new` or raw-malloc cost above the empty-graph floor. | None for currently promoted AVBD/VBD rows; extend with PLAN-104 corpus rows as they are promoted. | WP-122.4, PLAN-104 | | M-004 | Open | Unified Newton-barrier multibody rows | Open. PLAN-083 owns the solver direction. | Require world-base/global/raw post-bake gates before any unified Newton-barrier row is promoted into `World::step()`. | WP-122.4, PLAN-083 | | D-001 | Closed | Basic deformable projected-Newton and storage paths | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers particles, springs, FEM, self-contact, static obstacle, CCD, mixed storage, and production-grid global-heap rows; `BakedBasicDeformableRowsDoNotMallocOnHeap` covers dense direct, iterative sparse CG, matrix-free production, obstacle/friction, CCD, and mixed storage rows for first-post-bake raw malloc. | None for current dense direct, iterative sparse CG, matrix-free, obstacle/friction, CCD, and mixed storage rows. | WP-122.3, PLAN-081 | | D-002 | Closed | Scripted deformable boundary | World-base/global gates and `BakedScriptedDeformableBoundaryStepsDoNotMallocOnHeap` cover the first post-bake scripted boundary path; bake primes the retained dense projected-Newton factorization scratch before the measured loop. | None for the scripted deformable boundary row. | WP-122.1, WP-122.3 | diff --git a/tests/benchmark/simulation/CMakeLists.txt b/tests/benchmark/simulation/CMakeLists.txt index 3eb08b66980f3..76c1d4c0ea5e3 100644 --- a/tests/benchmark/simulation/CMakeLists.txt +++ b/tests/benchmark/simulation/CMakeLists.txt @@ -99,6 +99,11 @@ if(DART_SIMULATION_BUILD_BENCHMARKS) ${CMAKE_CURRENT_SOURCE_DIR}/bm_avbd_rigid_fixed_joint.cpp ) + dart_add_simulation_benchmark( + bm_avbd_parallel_row_update + ${CMAKE_CURRENT_SOURCE_DIR}/bm_avbd_parallel_row_update.cpp + ) + dart_add_simulation_benchmark( bm_ecs_safety ${CMAKE_CURRENT_SOURCE_DIR}/bm_ecs_safety.cpp diff --git a/tests/benchmark/simulation/bm_avbd_parallel_row_update.cpp b/tests/benchmark/simulation/bm_avbd_parallel_row_update.cpp new file mode 100644 index 0000000000000..c367c601eccd4 --- /dev/null +++ b/tests/benchmark/simulation/bm_avbd_parallel_row_update.cpp @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2011, The DART development contributors + * All rights reserved. + * + * The list of contributors can be found at: + * https://github.com/dartsim/dart/blob/main/LICENSE + * + * This file is provided under the following "BSD-style" License: + * Redistribution and use in source and binary forms, with or + * without modification, are permitted provided that the following + * conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF + * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +// Isolates the AVBD Section 4 post-sweep dual/stiffness pass. The zero-worker +// cases are the exact serial reference; the other cases exercise the same +// deterministic chunk policy used by the solver drivers. + +#include +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include + +namespace compute = dart::simulation::compute; +namespace vbd = dart::simulation::detail::deformable_vbd; + +namespace { + +inline constexpr std::size_t kRowUpdateMinCount = 8192u; +inline constexpr std::size_t kRowUpdateMinGrain = 2048u; + +template +void forEachRowUpdateRange( + compute::ComputeExecutor* executor, std::size_t count, Function&& function) +{ + const std::size_t workerCount + = executor != nullptr ? executor->getWorkerCount() : 0u; + if (workerCount <= 1u || count <= kRowUpdateMinCount) { + function(0u, count); + return; + } + + const std::size_t balancedGrain = 1u + (count - 1u) / workerCount; + executor->parallelFor( + count, std::max(kRowUpdateMinGrain, balancedGrain), function); +} + +} // namespace + +//============================================================================== +static void BM_AvbdContactDualUpdatePass(benchmark::State& state) +{ + const std::size_t rowCount = static_cast(state.range(0)); + const std::size_t workerCount = static_cast(state.range(1)); + + std::vector positions(rowCount); + std::vector rows(rowCount); + for (std::size_t rowIndex = 0u; rowIndex < rowCount; ++rowIndex) { + const double offset = 1.0e-4 * static_cast(rowIndex % 17u); + positions[rowIndex] = Eigen::Vector3d(0.01, 0.001 + offset, -0.02); + auto& row = rows[rowIndex]; + row.vertex = static_cast(rowIndex); + row.plane.normal = Eigen::Vector3d::UnitY(); + row.state.lambda = 0.25; + row.state.stiffness = 10.0 + static_cast(rowIndex % 5u); + row.previousConstraintValue = 0.5 * offset; + } + + vbd::AvbdHalfSpaceContactOptions options; + options.alpha = 0.25; + options.beta = 0.5; + options.maxStiffness = 1.0e8; + + std::unique_ptr parallelExecutor; + if (workerCount != 0u) { + parallelExecutor = std::make_unique(workerCount); + } + compute::ComputeExecutor* const executor = parallelExecutor.get(); + const auto updateRows = [&]() { + forEachRowUpdateRange( + executor, rowCount, [&](std::size_t begin, std::size_t end) { + for (std::size_t rowIndex = begin; rowIndex < end; ++rowIndex) { + auto& row = rows[rowIndex]; + row.state = vbd::updateAvbdHalfSpaceContactNormalRow( + row.state, + positions[row.vertex], + row.plane, + options, + row.previousConstraintValue, + row.bounds); + } + }); + }; + + updateRows(); + updateRows(); + for (auto _ : state) { + updateRows(); + benchmark::DoNotOptimize(rows.data()); + benchmark::ClobberMemory(); + } + + state.SetItemsProcessed( + state.iterations() * static_cast(rowCount)); + state.counters["rows"] = static_cast(rowCount); + state.counters["workers"] = static_cast(workerCount); +} +BENCHMARK(BM_AvbdContactDualUpdatePass) + ->ArgsProduct({{256, 4096, 8192, 16384, 32768, 65536}, {0, 2, 4}}) + ->ArgNames({"rows", "workers"}) + ->UseRealTime(); + +BENCHMARK_MAIN(); diff --git a/tests/unit/simulation/compute/test_compute_graph.cpp b/tests/unit/simulation/compute/test_compute_graph.cpp index 655713001f2b8..e7a554d5ef3a9 100644 --- a/tests/unit/simulation/compute/test_compute_graph.cpp +++ b/tests/unit/simulation/compute/test_compute_graph.cpp @@ -47,6 +47,7 @@ #include +#include #include #include #include @@ -841,6 +842,162 @@ TEST(SimulationParallelExecutor, ParallelForCoversRange) } } +//============================================================================== +TEST(SimulationParallelExecutor, ParallelForSupportsCooperativeNestedRanges) +{ + compute::ParallelExecutor executor(4); + executor.setInlineThreshold(0u); + + constexpr std::size_t outerCount = 32u; + constexpr std::size_t innerCount = 7u; + std::array, outerCount * innerCount> visits{}; + + compute::ComputeGraph graph; + graph.addNode("nested_ranges", [&]() { + executor.parallelFor( + outerCount, 4u, [&](std::size_t outerBegin, std::size_t outerEnd) { + for (std::size_t outer = outerBegin; outer < outerEnd; ++outer) { + executor.parallelFor( + innerCount, + 2u, + [&](std::size_t innerBegin, std::size_t innerEnd) { + for (std::size_t inner = innerBegin; inner < innerEnd; + ++inner) { + visits[outer * innerCount + inner].fetch_add( + 1, std::memory_order_relaxed); + } + }); + } + }); + }); + executor.execute(graph); + + for (const auto& visit : visits) { + EXPECT_EQ(visit.load(std::memory_order_relaxed), 1); + } +} + +//============================================================================== +TEST( + SimulationParallelExecutor, + ConcurrentParallelForFallsBackWithoutBlockingWorkers) +{ + compute::ParallelExecutor executor(4); + constexpr std::size_t count = 256u; + std::array, count> firstVisits{}; + std::array, count> secondVisits{}; + std::atomic firstRangeEntered{false}; + std::atomic releaseFirstRange{false}; + + std::thread first([&]() { + executor.parallelFor(count, 64u, [&](std::size_t begin, std::size_t end) { + firstRangeEntered.store(true, std::memory_order_release); + while (!releaseFirstRange.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + for (std::size_t i = begin; i < end; ++i) { + firstVisits[i].fetch_add(1, std::memory_order_relaxed); + } + }); + }); + + while (!firstRangeEntered.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + executor.parallelFor(count, 64u, [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + secondVisits[i].fetch_add(1, std::memory_order_relaxed); + } + }); + releaseFirstRange.store(true, std::memory_order_release); + first.join(); + + for (std::size_t i = 0; i < count; ++i) { + EXPECT_EQ(firstVisits[i].load(std::memory_order_relaxed), 1); + EXPECT_EQ(secondVisits[i].load(std::memory_order_relaxed), 1); + } +} + +//============================================================================== +TEST(SimulationParallelExecutor, WorkerParallelForRecoversAfterException) +{ + compute::ParallelExecutor executor(4); + executor.setInlineThreshold(0u); + std::atomic shouldThrow{true}; + std::array, 256u> visits{}; + + compute::ComputeGraph graph; + graph.addNode("throwing_range", [&]() { + executor.parallelFor( + visits.size(), 64u, [&](std::size_t begin, std::size_t end) { + if (shouldThrow.load(std::memory_order_relaxed) && begin == 64u) { + throw std::runtime_error("parallel range failure"); + } + for (std::size_t i = begin; i < end; ++i) { + visits[i].fetch_add(1, std::memory_order_relaxed); + } + }); + }); + + EXPECT_THROW(executor.execute(graph), std::runtime_error); + shouldThrow.store(false, std::memory_order_relaxed); + for (auto& visit : visits) { + visit.store(0, std::memory_order_relaxed); + } + EXPECT_NO_THROW(executor.execute(graph)); + + for (const auto& visit : visits) { + EXPECT_EQ(visit.load(std::memory_order_relaxed), 1); + } +} + +//============================================================================== +TEST( + SimulationParallelExecutor, + WarmedWorkerParallelForAddsNoHeapAllocationAboveGraphFloor) +{ + compute::ParallelExecutor executor(4); + executor.setInlineThreshold(0u); + + std::atomic runRange{false}; + std::array values{}; + compute::ComputeGraph graph; + graph.addNode("optional_range", [&]() { + if (!runRange.load(std::memory_order_relaxed)) { + return; + } + executor.parallelFor( + values.size(), 64u, [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + values[i] = static_cast(i + 1u); + } + }); + }); + + // Warm both the graph and its persistent range-worker pool before measuring. + runRange.store(true, std::memory_order_relaxed); + executor.execute(graph); + executor.execute(graph); + + runRange.store(false, std::memory_order_relaxed); + ScopedHeapAllocationCounter graphFloorCounter; + executor.execute(graph); + graphFloorCounter.stop(); + const std::size_t graphFloor = graphFloorCounter.allocationCount(); + + runRange.store(true, std::memory_order_relaxed); + ScopedHeapAllocationCounter rangeCounter; + executor.execute(graph); + rangeCounter.stop(); + + EXPECT_EQ(rangeCounter.allocationCount(), graphFloor) + << "a warmed range invoked from an executor worker should reuse its " + "persistent workers and add no allocation above the graph-run floor"; + for (std::size_t i = 0; i < values.size(); ++i) { + EXPECT_EQ(values[i], static_cast(i + 1u)); + } +} + //============================================================================== TEST(ComputeExecutor, DefaultParallelForRunsInlineAndSkipsEmptyRange) { diff --git a/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp b/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp index 2a635284d16be..835c3c83983ea 100644 --- a/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp +++ b/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -51,6 +52,7 @@ #include namespace vbd = dart::simulation::detail::deformable_vbd; +namespace compute = dart::simulation::compute; namespace { @@ -1474,6 +1476,232 @@ TEST(AvbdRigidBlock, RigidRowDriverHonorsConvergenceDisplacement) EXPECT_NEAR(states[0].position.norm(), 0.0, 1e-12); } +//============================================================================== +TEST( + AvbdRigidBlock, + RigidDualUpdateRowsAreBitwiseDeterministicAcrossWorkerCounts) +{ + constexpr std::size_t rowCount = 8193u; + + struct SolveResult + { + std::vector states; + std::vector attachments; + std::vector pointPairs; + std::vector distanceSprings; + std::vector angularPairs; + std::vector frictionPairs; + vbd::AvbdRigidBlockDescentStats stats; + }; + + const auto solve = [](std::size_t workerCount) { + SolveResult result; + result.states.resize(2u); + result.states[1].position = Vec3(0.6, 0.2, 0.1); + result.states[1].orientation = rotationZ(0.08); + std::vector inertialTargets = result.states; + inertialTargets[1].position += Vec3(0.01, -0.005, 0.002); + inertialTargets[1].orientation = rotationZ(0.09); + const std::vector masses = {1.0, 2.0}; + const std::vector inertias{ + Eigen::Matrix3d::Identity(), 1.5 * Eigen::Matrix3d::Identity()}; + const std::vector fixed = {1u, 0u}; + + result.attachments.reserve(rowCount); + result.pointPairs.reserve(rowCount); + result.distanceSprings.reserve(rowCount); + result.angularPairs.reserve(rowCount); + result.frictionPairs.reserve(rowCount); + for (std::size_t i = 0u; i < rowCount; ++i) { + const double index = static_cast(i); + const Vec3 axis = Vec3::Unit(static_cast(i % 3u)); + + vbd::AvbdRigidBodyPointAttachmentRow attachment; + attachment.body = 1u; + attachment.row.localPoint = Vec3(0.01, -0.02, 0.015); + attachment.row.target = vbd::avbdRigidBodyWorldPoint( + result.states[1], attachment.row.localPoint) + + (0.005 + 1e-6 * index) * axis; + attachment.row.axis = axis; + attachment.row.state.stiffness = 5.0 + 0.01 * index; + attachment.row.state.lambda = -0.0005 * index; + result.attachments.push_back(attachment); + + vbd::AvbdRigidBodyPointPairRow pointPair; + pointPair.bodyA = 0u; + pointPair.bodyB = 1u; + pointPair.row.localPointA = Vec3(0.02, 0.01, -0.01); + pointPair.row.localPointB = Vec3(-0.01, 0.015, 0.02); + pointPair.row.axis = axis; + pointPair.row.offset = -0.1 + 1e-5 * index; + pointPair.row.state.stiffness = 4.0 + 0.015 * index; + pointPair.row.state.lambda = 0.00075 * index; + if (i % 2u != 0u) { + pointPair.row.materialStiffness = 40.0 + 0.1 * index; + } + result.pointPairs.push_back(pointPair); + + vbd::AvbdRigidBodyPointPairDistanceSpringRow distanceSpring; + distanceSpring.bodyA = 0u; + distanceSpring.bodyB = 1u; + distanceSpring.row.localPointA = Vec3(0.01, 0.0, 0.0); + distanceSpring.row.localPointB = Vec3(-0.01, 0.0, 0.0); + distanceSpring.row.restLength + = vbd::avbdRigidPointPairDistanceSpringRelativePosition( + result.states[0], result.states[1], distanceSpring.row) + .norm() + - 0.01; + distanceSpring.row.state.stiffness = 3.0 + 0.01 * index; + distanceSpring.row.state.lambda = 100.0 + index; + distanceSpring.row.materialStiffness = 30.0 + 0.1 * index; + result.distanceSprings.push_back(distanceSpring); + + vbd::AvbdRigidBodyAngularPairRow angularPair; + angularPair.bodyA = 0u; + angularPair.bodyB = 1u; + vbd::AvbdScalarRowState angularState; + angularState.stiffness = 6.0 + 0.01 * index; + angularState.lambda = 0.00025 * index; + angularPair.row = vbd::makeAvbdRigidJointAngularRow( + Eigen::Quaterniond::Identity(), axis, angularState); + angularPair.row.offset = 1e-5 * index; + if (i % 2u != 0u) { + angularPair.row.materialStiffness = 50.0 + 0.1 * index; + } + result.angularPairs.push_back(angularPair); + + vbd::AvbdRigidBodyPointPairFrictionRows frictionPair; + frictionPair.bodyA = 0u; + frictionPair.bodyB = 1u; + vbd::AvbdScalarRowState firstState; + firstState.stiffness = 7.0 + 0.01 * index; + firstState.lambda = 0.0005 * index; + vbd::AvbdScalarRowState secondState = firstState; + secondState.lambda = -0.00075 * index; + const Vec3 relative = vbd::avbdRigidPointPairRelativePosition( + result.states[0], result.states[1], pointPair.row); + frictionPair.first = vbd::makeAvbdRigidContactFrictionTangentRow( + pointPair.row.localPointA, + pointPair.row.localPointB, + Vec3::UnitX(), + relative - Vec3(0.004, -0.003, 0.0), + /*forceLimit=*/25.0, + firstState); + frictionPair.second = vbd::makeAvbdRigidContactFrictionTangentRow( + pointPair.row.localPointA, + pointPair.row.localPointB, + Vec3::UnitY(), + relative - Vec3(0.004, -0.003, 0.0), + /*forceLimit=*/25.0, + secondState); + result.frictionPairs.push_back(frictionPair); + } + result.attachments.back().body = 99u; + result.pointPairs.back().bodyB = 99u; + result.distanceSprings.back().bodyB = 99u; + result.angularPairs.back().bodyB = 99u; + result.frictionPairs.back().bodyB = 99u; + + vbd::AvbdRigidBlockDescentOptions options; + options.iterations = 2u; + options.regularization = 1e-12; + vbd::AvbdRigidPointAttachmentOptions rowOptions; + rowOptions.beta = 3.0; + rowOptions.maxStiffness = 1000.0; + vbd::AvbdRigidPointPairFrictionOptions frictionOptions; + frictionOptions.beta = 4.0; + frictionOptions.maxStiffness = 1000.0; + vbd::AvbdRigidPointPairDistanceSpringOptions distanceSpringOptions; + distanceSpringOptions.beta = 2.0; + distanceSpringOptions.maxStiffness = 1000.0; + vbd::AvbdRigidBodyRowIndexScratch rowIndexScratch; + compute::ParallelExecutor executor(std::max(1u, workerCount)); + + result.stats = vbd::blockDescentRigidBodiesAvbdRows( + result.states, + masses, + inertias, + fixed, + inertialTargets, + /*timeStep=*/0.02, + result.attachments, + result.pointPairs, + result.angularPairs, + result.frictionPairs, + options, + rowOptions, + frictionOptions, + &rowIndexScratch, + result.distanceSprings, + distanceSpringOptions, + workerCount == 0u ? nullptr : &executor); + return result; + }; + + const SolveResult serial = solve(0u); + const double lastIndex = static_cast(rowCount - 1u); + EXPECT_EQ(serial.attachments.back().row.state.lambda, -0.0005 * lastIndex); + EXPECT_EQ(serial.pointPairs.back().row.state.lambda, 0.00075 * lastIndex); + EXPECT_EQ(serial.distanceSprings.back().row.state.lambda, 100.0 + lastIndex); + EXPECT_EQ(serial.angularPairs.back().row.state.lambda, 0.00025 * lastIndex); + EXPECT_EQ(serial.frictionPairs.back().first.state.lambda, 0.0005 * lastIndex); + EXPECT_EQ( + serial.frictionPairs.back().second.state.lambda, -0.00075 * lastIndex); + const auto expectSame = [&](const SolveResult& parallel) { + ASSERT_EQ(parallel.states.size(), serial.states.size()); + for (std::size_t i = 0u; i < serial.states.size(); ++i) { + EXPECT_TRUE((parallel.states[i].position.array() + == serial.states[i].position.array()) + .all()) + << "body=" << i << " position"; + EXPECT_TRUE((parallel.states[i].orientation.coeffs().array() + == serial.states[i].orientation.coeffs().array()) + .all()) + << "body=" << i << " orientation"; + } + EXPECT_EQ(parallel.stats.iterations, serial.stats.iterations); + EXPECT_EQ(parallel.stats.bodyUpdates, serial.stats.bodyUpdates); + + const auto expectStates = [](const auto& actual, const auto& expected) { + ASSERT_EQ(actual.size(), expected.size()); + for (std::size_t i = 0u; i < expected.size(); ++i) { + EXPECT_EQ(actual[i].row.state.lambda, expected[i].row.state.lambda) + << "row=" << i; + EXPECT_EQ( + actual[i].row.state.stiffness, expected[i].row.state.stiffness) + << "row=" << i; + } + }; + expectStates(parallel.attachments, serial.attachments); + expectStates(parallel.pointPairs, serial.pointPairs); + expectStates(parallel.distanceSprings, serial.distanceSprings); + expectStates(parallel.angularPairs, serial.angularPairs); + + ASSERT_EQ(parallel.frictionPairs.size(), serial.frictionPairs.size()); + for (std::size_t i = 0u; i < serial.frictionPairs.size(); ++i) { + EXPECT_EQ( + parallel.frictionPairs[i].first.state.lambda, + serial.frictionPairs[i].first.state.lambda) + << "friction pair=" << i << " first lambda"; + EXPECT_EQ( + parallel.frictionPairs[i].first.state.stiffness, + serial.frictionPairs[i].first.state.stiffness) + << "friction pair=" << i << " first stiffness"; + EXPECT_EQ( + parallel.frictionPairs[i].second.state.lambda, + serial.frictionPairs[i].second.state.lambda) + << "friction pair=" << i << " second lambda"; + EXPECT_EQ( + parallel.frictionPairs[i].second.state.stiffness, + serial.frictionPairs[i].second.state.stiffness) + << "friction pair=" << i << " second stiffness"; + } + }; + + expectSame(solve(2u)); + expectSame(solve(4u)); +} + //============================================================================== TEST(AvbdRigidBlock, RigidContactManifoldBuilderCreatesWarmStartedRows) { diff --git a/tests/unit/simulation/deformable_vbd/test_vbd_combined_descent.cpp b/tests/unit/simulation/deformable_vbd/test_vbd_combined_descent.cpp index 0a60304a1673a..e4e2fb13f2c64 100644 --- a/tests/unit/simulation/deformable_vbd/test_vbd_combined_descent.cpp +++ b/tests/unit/simulation/deformable_vbd/test_vbd_combined_descent.cpp @@ -30,6 +30,7 @@ * POSSIBILITY OF SUCH DAMAGE. */ +#include #include #include @@ -40,12 +41,14 @@ #include #include +#include #include #include namespace vbd = dart::simulation::detail::deformable_vbd; namespace common = dart::common; +namespace compute = dart::simulation::compute; namespace { @@ -1169,3 +1172,281 @@ TEST(VbdCombinedDescent, AvbdRowsHonorConvergenceDisplacement) EXPECT_EQ(stats.vertexUpdates, 1u); EXPECT_NEAR(positions[0].norm(), 0.0, 1e-12); } + +//============================================================================== +TEST( + VbdCombinedDescent, + AvbdDualUpdateRowsAreBitwiseDeterministicAcrossWorkerCounts) +{ + constexpr std::size_t scalarRowCount = 8193u; + constexpr std::size_t frictionPairCount = 8193u; + + struct SolveResult + { + std::vector positions; + std::vector contacts; + std::vector attachments; + std::vector springs; + std::vector friction; + std::vector selfContact; + std::vector selfContactFriction; + vbd::BlockDescentStats stats; + }; + + const auto solve = [](std::size_t workerCount) { + SolveResult result; + result.positions + = {Vec3(0.3, 0.3, 0.01), + Vec3(0.0, 0.0, 0.0), + Vec3(1.0, 0.0, 0.0), + Vec3(0.0, 1.0, 0.0)}; + const std::array stepStart{ + result.positions[0], + result.positions[1], + result.positions[2], + result.positions[3]}; + const std::vector masses(result.positions.size(), 1.0); + const std::vector fixed = {0u, 1u, 1u, 1u}; + std::vector inertialTargets = result.positions; + inertialTargets[0] += Vec3(0.02, -0.01, 0.005); + + const std::vector springElements{ + {0u, 1u, (result.positions[0] - result.positions[1]).norm()}}; + const auto coloring + = vbd::colorSprings(result.positions.size(), springElements); + const auto adjacency + = vbd::SpringAdjacency::build(result.positions.size(), springElements); + + result.contacts.reserve(scalarRowCount); + result.attachments.reserve(scalarRowCount); + result.springs.reserve(scalarRowCount); + result.selfContact.reserve(scalarRowCount); + for (std::size_t i = 0u; i < scalarRowCount; ++i) { + const double index = static_cast(i); + + vbd::AvbdHalfSpaceContactRow contact; + contact.vertex = 0u; + contact.plane.normal = Vec3::UnitZ(); + contact.plane.offset = 0.02 + 1e-6 * index; + contact.state.stiffness = 20.0 + 0.01 * index; + contact.state.lambda = 0.001 * index; + result.contacts.push_back(contact); + + vbd::AvbdPointAttachmentRow attachment; + attachment.vertex = 0u; + attachment.target = Vec3(0.32, 0.29, 0.015); + attachment.axis + = vbd::canonicalAvbdAttachmentAxis(static_cast(i % 3u)); + attachment.state.stiffness = 10.0 + 0.02 * index; + attachment.state.lambda = -0.0005 * index; + result.attachments.push_back(attachment); + + vbd::AvbdSpringFiniteStiffnessRow spring; + spring.spring = 0u; + spring.state.stiffness = 5.0 + 0.01 * index; + spring.state.lambda = 1.0 + index; + spring.materialStiffness = 50.0 + 0.1 * index; + result.springs.push_back(spring); + + vbd::AvbdSelfContactNormalRow selfContact; + selfContact.nodes = {0u, 1u, 2u, 3u}; + selfContact.state.stiffness = 15.0 + 0.01 * index; + selfContact.state.lambda = 0.00025 * index; + selfContact.squaredActivationDistance = 4e-4; + result.selfContact.push_back(selfContact); + } + + result.friction.reserve(2u * frictionPairCount); + result.selfContactFriction.reserve(2u * frictionPairCount); + for (std::size_t pair = 0u; pair < frictionPairCount; ++pair) { + const double index = static_cast(pair); + + vbd::AvbdHalfSpaceFrictionRow first; + first.vertex = 0u; + first.stepStartPosition = result.positions[0] - Vec3(0.01, -0.005, 0.0); + first.axis = Vec3::UnitX(); + first.state.stiffness = 12.0 + 0.01 * index; + first.state.lambda = 0.001 * index; + first.bounds = {-25.0, 25.0}; + vbd::AvbdHalfSpaceFrictionRow second = first; + second.axis = Vec3::UnitY(); + second.state.lambda = -0.0015 * index; + result.friction.push_back(first); + result.friction.push_back(second); + + vbd::AvbdSelfContactFrictionRow selfFirst; + selfFirst.nodes = {0u, 1u, 2u, 3u}; + selfFirst.stepStartPositions = stepStart; + selfFirst.stepStartPositions[0] -= Vec3(0.005, 0.004, 0.0); + selfFirst.axis = 0u; + selfFirst.state.stiffness = 8.0 + 0.02 * index; + selfFirst.state.lambda = 0.00075 * index; + selfFirst.bounds = {-20.0, 20.0}; + vbd::AvbdSelfContactFrictionRow selfSecond = selfFirst; + selfSecond.axis = 1u; + selfSecond.state.lambda = -0.00025 * index; + result.selfContactFriction.push_back(selfFirst); + result.selfContactFriction.push_back(selfSecond); + } + result.contacts.back().vertex = 99u; + result.attachments.back().vertex = 99u; + result.springs.back().spring = 99u; + result.selfContact.back().nodes = {99u, 100u, 101u, 102u}; + result.friction[result.friction.size() - 2u].vertex = 99u; + result.friction.back().vertex = 99u; + result.selfContactFriction[result.selfContactFriction.size() - 2u].nodes + = {99u, 100u, 101u, 102u}; + result.selfContactFriction.back().nodes = {99u, 100u, 101u, 102u}; + + vbd::BlockDescentOptions options; + options.iterations = 2u; + options.regularization = 1e-12; + vbd::AvbdHalfSpaceContactOptions contactOptions; + contactOptions.beta = 3.0; + contactOptions.maxStiffness = 1000.0; + vbd::AvbdPointAttachmentOptions attachmentOptions; + attachmentOptions.beta = 4.0; + attachmentOptions.maxStiffness = 1000.0; + vbd::AvbdSpringFiniteStiffnessOptions springOptions; + springOptions.beta = 2.0; + springOptions.maxStiffness = 1000.0; + vbd::AvbdHalfSpaceFrictionOptions frictionOptions; + frictionOptions.beta = 5.0; + frictionOptions.maxStiffness = 1000.0; + vbd::AvbdSelfContactNormalOptions selfContactOptions; + selfContactOptions.beta = 6.0; + selfContactOptions.maxStiffness = 1000.0; + vbd::AvbdSelfContactFrictionOptions selfContactFrictionOptions; + selfContactFrictionOptions.beta = 7.0; + selfContactFrictionOptions.maxStiffness = 1000.0; + + compute::ParallelExecutor executor(std::max(1u, workerCount)); + result.stats = vbd::blockDescentMassSpringAvbdRows( + result.positions, + masses, + fixed, + inertialTargets, + springElements, + /*fallbackSpringStiffness=*/0.0, + /*timeStep=*/0.02, + result.contacts, + result.attachments, + result.springs, + coloring, + adjacency, + options, + contactOptions, + attachmentOptions, + springOptions, + &result.friction, + &frictionOptions, + &result.selfContact, + nullptr, + &selfContactOptions, + &result.selfContactFriction, + &selfContactFrictionOptions, + workerCount == 0u ? nullptr : &executor); + return result; + }; + + const SolveResult serial = solve(0u); + const double lastIndex = static_cast(scalarRowCount - 1u); + EXPECT_EQ(serial.contacts.back().state.lambda, 0.001 * lastIndex); + EXPECT_EQ(serial.attachments.back().state.lambda, -0.0005 * lastIndex); + EXPECT_EQ(serial.springs.back().state.lambda, 1.0 + lastIndex); + EXPECT_EQ(serial.selfContact.back().state.lambda, 0.00025 * lastIndex); + EXPECT_EQ( + serial.friction[serial.friction.size() - 2u].state.lambda, + 0.001 * lastIndex); + EXPECT_EQ(serial.friction.back().state.lambda, -0.0015 * lastIndex); + EXPECT_EQ( + serial.selfContactFriction[serial.selfContactFriction.size() - 2u] + .state.lambda, + 0.00075 * lastIndex); + EXPECT_EQ( + serial.selfContactFriction.back().state.lambda, -0.00025 * lastIndex); + const auto expectSame = [&](const SolveResult& parallel) { + ASSERT_EQ(parallel.positions.size(), serial.positions.size()); + for (std::size_t i = 0u; i < serial.positions.size(); ++i) { + EXPECT_TRUE( + (parallel.positions[i].array() == serial.positions[i].array()).all()) + << "position=" << i; + } + EXPECT_EQ(parallel.stats.iterations, serial.stats.iterations); + EXPECT_EQ(parallel.stats.vertexUpdates, serial.stats.vertexUpdates); + EXPECT_EQ( + parallel.stats.finalResidualNormSquared, + serial.stats.finalResidualNormSquared); + + const auto expectStates = [](const auto& actual, const auto& expected) { + ASSERT_EQ(actual.size(), expected.size()); + for (std::size_t i = 0u; i < expected.size(); ++i) { + EXPECT_EQ(actual[i].state.lambda, expected[i].state.lambda) + << "row=" << i; + EXPECT_EQ(actual[i].state.stiffness, expected[i].state.stiffness) + << "row=" << i; + } + }; + expectStates(parallel.contacts, serial.contacts); + expectStates(parallel.attachments, serial.attachments); + expectStates(parallel.springs, serial.springs); + expectStates(parallel.friction, serial.friction); + expectStates(parallel.selfContact, serial.selfContact); + expectStates(parallel.selfContactFriction, serial.selfContactFriction); + }; + + expectSame(solve(2u)); + expectSame(solve(4u)); +} + +//============================================================================== +TEST(VbdCombinedDescent, ParallelDualUpdatePreservesNonfiniteRowFailureBehavior) +{ + constexpr std::size_t rowCount = 8193u; + constexpr std::size_t nonfiniteRow = 4096u; + std::vector positions(rowCount, Vec3(0.0, 0.01, 0.0)); + positions[nonfiniteRow].y() = std::numeric_limits::quiet_NaN(); + + std::vector serial(rowCount); + for (std::size_t i = 0u; i < rowCount; ++i) { + serial[i].vertex = static_cast(i); + serial[i].state.stiffness = 10.0 + 0.001 * static_cast(i); + serial[i].state.lambda = 0.25; + } + auto parallel = serial; + vbd::AvbdHalfSpaceContactOptions options; + options.beta = 2.0; + options.maxStiffness = 1000.0; + + const auto update = [&](auto& rows, compute::ComputeExecutor* executor) { + vbd::forEachAvbdRowUpdateRange( + executor, rows.size(), [&](std::size_t begin, std::size_t end) { + for (std::size_t i = begin; i < end; ++i) { + auto& row = rows[i]; + row.state = vbd::updateAvbdHalfSpaceContactNormalRow( + row.state, + positions[row.vertex], + row.plane, + options, + row.previousConstraintValue, + row.bounds); + } + }); + }; + + update(serial, nullptr); + compute::ParallelExecutor executor(4u); + update(parallel, &executor); + + for (std::size_t i = 0u; i < rowCount; ++i) { + if (i == nonfiniteRow) { + EXPECT_TRUE(std::isnan(serial[i].state.lambda)); + EXPECT_TRUE(std::isnan(parallel[i].state.lambda)); + } else { + EXPECT_EQ(parallel[i].state.lambda, serial[i].state.lambda) + << "row=" << i; + } + EXPECT_EQ(parallel[i].state.stiffness, serial[i].state.stiffness) + << "row=" << i; + } +} diff --git a/tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp b/tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp index e7a71e53ca5be..d5e193085c8a9 100644 --- a/tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp +++ b/tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp @@ -29,6 +29,7 @@ * POSSIBILITY OF SUCH DAMAGE. */ +#include #include #include #include @@ -40,6 +41,7 @@ #include namespace vbd = dart::simulation::detail::deformable_vbd; +namespace compute = dart::simulation::compute; namespace { @@ -300,3 +302,173 @@ TEST(VbdFiniteStiffness, BlockDescentRowsUpdateEffectiveTetMaterialStiffness) EXPECT_LE(rows[0].state.stiffness, rows[0].materialStiffness); EXPECT_DOUBLE_EQ(rows[0].state.lambda, 0.0); } + +//============================================================================== +TEST( + VbdFiniteStiffness, + TetAndSelfContactDualUpdatesAreBitwiseDeterministicAcrossWorkers) +{ + constexpr std::size_t rowCount = 8193u; + constexpr std::size_t frictionPairCount = 8193u; + + struct SolveResult + { + std::vector positions; + std::vector tetRows; + std::vector selfContactRows; + std::vector frictionRows; + vbd::BlockDescentStats stats; + }; + + const auto solve = [](std::size_t workerCount) { + SolveResult result; + result.positions + = {Vec3(0.3, 0.3, 0.01), + Vec3(0.0, 0.0, 0.0), + Vec3(1.0, 0.0, 0.0), + Vec3(0.0, 1.0, 1.0)}; + const std::vector restPositions + = {Vec3(0.3, 0.3, 0.02), + Vec3(0.0, 0.0, 0.0), + Vec3(1.0, 0.0, 0.0), + Vec3(0.0, 1.0, 1.0)}; + const std::array stepStart{ + result.positions[0], + result.positions[1], + result.positions[2], + result.positions[3]}; + const std::vector masses(result.positions.size(), 1.0); + const std::vector fixed = {0u, 1u, 1u, 1u}; + std::vector inertialTargets = result.positions; + inertialTargets[0] += Vec3(0.01, -0.005, 0.002); + + const std::array vertices{0u, 1u, 2u, 3u}; + const std::vector tets{ + {vertices, + vbd::makeTetRestShape( + {restPositions[0], + restPositions[1], + restPositions[2], + restPositions[3]})}}; + const auto coloring = vbd::colorTetMesh(result.positions.size(), tets); + const auto adjacency + = vbd::TetAdjacency::build(result.positions.size(), tets); + + result.tetRows.reserve(rowCount); + result.selfContactRows.reserve(rowCount); + for (std::size_t i = 0u; i < rowCount; ++i) { + const double index = static_cast(i); + + vbd::AvbdTetMaterialFiniteStiffnessRow tetRow; + tetRow.tet = 0u; + tetRow.state.stiffness = 0.05 + 0.0005 * index; + tetRow.state.lambda = 10.0 + index; + tetRow.materialStiffness = 0.8 + 0.001 * index; + result.tetRows.push_back(tetRow); + + vbd::AvbdSelfContactNormalRow selfContactRow; + selfContactRow.nodes = {0u, 1u, 2u, 3u}; + selfContactRow.state.stiffness = 10.0 + 0.01 * index; + selfContactRow.state.lambda = 0.0005 * index; + selfContactRow.squaredActivationDistance = 4e-4; + result.selfContactRows.push_back(selfContactRow); + } + + result.frictionRows.reserve(2u * frictionPairCount); + for (std::size_t pair = 0u; pair < frictionPairCount; ++pair) { + const double index = static_cast(pair); + vbd::AvbdSelfContactFrictionRow first; + first.nodes = {0u, 1u, 2u, 3u}; + first.stepStartPositions = stepStart; + first.stepStartPositions[0] -= Vec3(0.005, -0.003, 0.0); + first.axis = 0u; + first.state.stiffness = 6.0 + 0.01 * index; + first.state.lambda = 0.001 * index; + first.bounds = {-15.0, 15.0}; + vbd::AvbdSelfContactFrictionRow second = first; + second.axis = 1u; + second.state.lambda = -0.0005 * index; + result.frictionRows.push_back(first); + result.frictionRows.push_back(second); + } + result.tetRows.back().tet = 99u; + result.selfContactRows.back().nodes = {99u, 100u, 101u, 102u}; + result.frictionRows[result.frictionRows.size() - 2u].nodes + = {99u, 100u, 101u, 102u}; + result.frictionRows.back().nodes = {99u, 100u, 101u, 102u}; + + vbd::BlockDescentOptions options; + options.iterations = 2u; + options.regularization = 1e-12; + vbd::AvbdTetMaterialFiniteStiffnessOptions tetOptions; + tetOptions.beta = 0.25; + tetOptions.maxStiffness = 1.0; + vbd::AvbdSelfContactNormalOptions selfContactOptions; + selfContactOptions.beta = 3.0; + selfContactOptions.maxStiffness = 1000.0; + vbd::AvbdSelfContactFrictionOptions frictionOptions; + frictionOptions.beta = 4.0; + frictionOptions.maxStiffness = 1000.0; + + compute::ParallelExecutor executor(std::max(1u, workerCount)); + result.stats = vbd::blockDescentTetMeshAvbdFiniteStiffness( + result.positions, + masses, + fixed, + inertialTargets, + tets, + /*mu=*/500.0, + /*lambda=*/800.0, + /*timeStep=*/0.02, + result.tetRows, + coloring, + adjacency, + options, + tetOptions, + nullptr, + &result.selfContactRows, + &selfContactOptions, + &result.frictionRows, + &frictionOptions, + workerCount == 0u ? nullptr : &executor); + return result; + }; + + const SolveResult serial = solve(0u); + const double lastIndex = static_cast(rowCount - 1u); + EXPECT_EQ(serial.tetRows.back().state.lambda, 10.0 + lastIndex); + EXPECT_EQ(serial.selfContactRows.back().state.lambda, 0.0005 * lastIndex); + EXPECT_EQ( + serial.frictionRows[serial.frictionRows.size() - 2u].state.lambda, + 0.001 * lastIndex); + EXPECT_EQ(serial.frictionRows.back().state.lambda, -0.0005 * lastIndex); + const auto expectSame = [&](const SolveResult& parallel) { + ASSERT_EQ(parallel.positions.size(), serial.positions.size()); + for (std::size_t i = 0u; i < serial.positions.size(); ++i) { + EXPECT_TRUE( + (parallel.positions[i].array() == serial.positions[i].array()).all()) + << "position=" << i; + } + EXPECT_EQ(parallel.stats.iterations, serial.stats.iterations); + EXPECT_EQ(parallel.stats.vertexUpdates, serial.stats.vertexUpdates); + EXPECT_EQ( + parallel.stats.finalResidualNormSquared, + serial.stats.finalResidualNormSquared); + + const auto expectStates = [](const auto& actual, const auto& expected) { + ASSERT_EQ(actual.size(), expected.size()); + for (std::size_t i = 0u; i < expected.size(); ++i) { + EXPECT_EQ(actual[i].state.lambda, expected[i].state.lambda) + << "row=" << i; + EXPECT_EQ(actual[i].state.stiffness, expected[i].state.stiffness) + << "row=" << i; + } + }; + expectStates(parallel.tetRows, serial.tetRows); + expectStates(parallel.selfContactRows, serial.selfContactRows); + expectStates(parallel.frictionRows, serial.frictionRows); + }; + + expectSame(solve(2u)); + expectSame(solve(4u)); +} diff --git a/tests/unit/simulation/world/test_vbd_world_solver.cpp b/tests/unit/simulation/world/test_vbd_world_solver.cpp index 381ebd68fd4b7..196a7de361b36 100644 --- a/tests/unit/simulation/world/test_vbd_world_solver.cpp +++ b/tests/unit/simulation/world/test_vbd_world_solver.cpp @@ -755,9 +755,9 @@ TEST(VbdWorldSolver, AvbdContactNormalRowsSkipInactiveGroundRows) // mass-spring row path supports static contact/friction rows and explicit // self-contact row opt-ins, while the pure tet material row path can coexist // with the existing lagged VBD self-contact penalty. Mixed spring-plus-tet -// topology, Chebyshev, Rayleigh damping, parallel execution, and unsupported -// requested row families must keep using the existing VBD path and report no -// AVBD rows. +// topology, Chebyshev, Rayleigh damping, and unsupported requested row families +// must keep using the existing VBD path and report no AVBD rows. Parallel AVBD +// execution is covered by the dedicated World executor-parity tests. TEST(VbdWorldSolver, AvbdFiniteStiffnessRowsFallbackForUnsupportedEnvelopes) { const auto baseConfig = [] { @@ -804,19 +804,6 @@ TEST(VbdWorldSolver, AvbdFiniteStiffnessRowsFallbackForUnsupportedEnvelopes) const auto stats = run(makeChainOptions(8, 0.5), cfg); expectNoAvbdRows(stats); } - { - sx::World world; - world.setGravity(Eigen::Vector3d(0.0, 0.0, -9.81)); - world.setTimeStep(0.01); - world.addDeformableBody("body", makeChainOptions(8, 0.5)); - enableVbdConfig(world, baseConfig()); - - compute::DeformableDynamicsStage stage; - compute::ParallelExecutor executor(2); - stepOnce(world, stage, executor); - const auto stats = stage.getLastStats(); - expectNoAvbdRows(stats); - } } //============================================================================== diff --git a/tests/unit/simulation/world/test_world.cpp b/tests/unit/simulation/world/test_world.cpp index 9f04ab4159974..be0424dcbf6d9 100644 --- a/tests/unit/simulation/world/test_world.cpp +++ b/tests/unit/simulation/world/test_world.cpp @@ -3409,6 +3409,15 @@ void configureAvbdSelfContactFrictionProductionGridRowsScene( enableAvbdSelfContactFrictionRows(world); } +void configureAvbdSelfContactFrictionParallelGridRowsScene( + dart::simulation::World& world) +{ + configureDeformableSelfContactFrictionGridSceneWithShape( + world, 21, 31, "avbd_self_contact_friction_parallel_grid"); + + enableAvbdSelfContactFrictionRows(world); +} + void configureVbdChebyshevSelfContactGridScene(dart::simulation::World& world) { namespace sx = dart::simulation; @@ -9455,6 +9464,78 @@ TEST(World, RigidAvbdContactRowsAreActive) EXPECT_GT(sphere->getTranslation().z(), 0.49); } +TEST(World, DeformableAvbdRowsRemainActiveWithParallelExecutor) +{ + namespace sx = dart::simulation; + + sx::World sequentialWorld; + sx::World parallelWorld; + configureDeformableAvbdFiniteStiffnessRowsScene(sequentialWorld); + configureDeformableAvbdFiniteStiffnessRowsScene(parallelWorld); + auto sequentialBody + = sequentialWorld.getDeformableBody("avbd_finite_stiffness_spring"); + auto parallelBody + = parallelWorld.getDeformableBody("avbd_finite_stiffness_spring"); + ASSERT_TRUE(sequentialBody.has_value()); + ASSERT_TRUE(parallelBody.has_value()); + + sequentialWorld.enterSimulationMode(); + parallelWorld.enterSimulationMode(); + sx::compute::SequentialExecutor sequentialExecutor; + sx::compute::DeformableDynamicsStage sequentialStage; + sequentialStage.execute(sequentialWorld, sequentialExecutor); + sx::compute::ParallelExecutor executor(4u); + sx::compute::DeformableDynamicsStage parallelStage; + parallelStage.execute(parallelWorld, executor); + + const auto& stats = parallelStage.getLastStats(); + EXPECT_EQ(stats.vbdBodyCount, 1u); + EXPECT_GT(stats.vbdAvbdFiniteStiffnessRows, 0u); + for (std::size_t node = 0u; node < 2u; ++node) { + EXPECT_TRUE((sequentialBody->getPosition(node).array() + == parallelBody->getPosition(node).array()) + .all()) + << "node=" << node << " position"; + EXPECT_TRUE((sequentialBody->getVelocity(node).array() + == parallelBody->getVelocity(node).array()) + .all()) + << "node=" << node << " velocity"; + } +} + +TEST(World, RigidAvbdRowsRemainActiveWithParallelExecutor) +{ + namespace sx = dart::simulation; + + sx::World sequentialWorld; + sx::World parallelWorld; + configureRigidAvbdContactRowsScene(sequentialWorld); + configureRigidAvbdContactRowsScene(parallelWorld); + auto sequentialSphere = sequentialWorld.getRigidBody("rigid_avbd_sphere"); + auto parallelSphere = parallelWorld.getRigidBody("rigid_avbd_sphere"); + ASSERT_TRUE(sequentialSphere.has_value()); + ASSERT_TRUE(parallelSphere.has_value()); + + sequentialWorld.enterSimulationMode(); + parallelWorld.enterSimulationMode(); + ASSERT_FALSE(sequentialWorld.collide().empty()); + ASSERT_FALSE(parallelWorld.collide().empty()); + sequentialWorld.step(); + sx::compute::ParallelExecutor executor(4u); + parallelWorld.step(executor); + + EXPECT_TRUE((sequentialSphere->getTransform().matrix().array() + == parallelSphere->getTransform().matrix().array()) + .all()); + EXPECT_TRUE((sequentialSphere->getLinearVelocity().array() + == parallelSphere->getLinearVelocity().array()) + .all()); + EXPECT_TRUE((sequentialSphere->getAngularVelocity().array() + == parallelSphere->getAngularVelocity().array()) + .all()); + EXPECT_GT(parallelSphere->getLinearVelocity().z(), 0.0); +} + TEST(World, RigidAvbdFixedJointRowsAreActiveWithoutContacts) { namespace sx = dart::simulation; @@ -10355,6 +10436,96 @@ TEST(World, BakedAvbdVbdRowsDoNotMallocOnHeap) #endif } +TEST(World, WarmedParallelAvbdDualUpdatePassIsAllocationStable) +{ +#if defined(DART_CODECOV) + GTEST_SKIP() + << "The dispatch-sized AVBD self-contact allocation scene is too slow " + "under coverage instrumentation."; +#else + namespace sx = dart::simulation; + + CountingMemoryAllocator allocator; + sx::WorldOptions worldOptions; + worldOptions.baseAllocator = &allocator; + sx::World baseAllocatorWorld(worldOptions); + configureAvbdSelfContactFrictionParallelGridRowsScene(baseAllocatorWorld); + auto& registry = sx::detail::registryOf(baseAllocatorWorld); + baseAllocatorWorld.enterSimulationMode(); + sx::compute::ParallelExecutor baseAllocatorExecutor(4u); + baseAllocatorWorld.step(baseAllocatorExecutor); + baseAllocatorWorld.step(baseAllocatorExecutor); + + const auto states + = sx::compute::avbd_replay::captureDeformableAvbdWarmStartReplayState( + registry); + ASSERT_EQ(states.size(), 1u); + ASSERT_GT( + states[0].selfContactRows.size(), + sx::detail::deformable_vbd::kAvbdParallelRowUpdateMinCount); + + const auto allocationsAfterWarmup = allocator.allocationCount; + const auto deallocationsAfterWarmup = allocator.deallocationCount; + const auto alignedAllocationsAfterWarmup = allocator.alignedAllocationCount; + const auto alignedDeallocationsAfterWarmup + = allocator.alignedDeallocationCount; + for (int step = 0; step < 2; ++step) { + baseAllocatorWorld.step(baseAllocatorExecutor); + } + EXPECT_EQ(allocator.allocationCount, allocationsAfterWarmup); + EXPECT_EQ(allocator.deallocationCount, deallocationsAfterWarmup); + EXPECT_EQ(allocator.alignedAllocationCount, alignedAllocationsAfterWarmup); + EXPECT_EQ( + allocator.alignedDeallocationCount, alignedDeallocationsAfterWarmup); + + const auto countGlobalAllocations = [](auto&& configureScene) { + sx::World world; + configureScene(world); + world.enterSimulationMode(); + sx::compute::ParallelExecutor executor(4u); + world.step(executor); + world.step(executor); + + ScopedHeapAllocationCounter heapCounter; + for (int step = 0; step < 2; ++step) { + world.step(executor); + } + heapCounter.stop(); + return HeapAllocationSnapshot{ + heapCounter.allocationCount(), heapCounter.allocationBytes()}; + }; + const auto graphFloor = countGlobalAllocations([](sx::World&) {}); + const auto avbdGlobal = countGlobalAllocations( + configureAvbdSelfContactFrictionParallelGridRowsScene); + EXPECT_EQ(avbdGlobal.allocationCount, graphFloor.allocationCount); + EXPECT_EQ(avbdGlobal.allocationBytes, graphFloor.allocationBytes); + + #if defined(DART_TEST_HAS_RAW_MALLOC_INTERPOSE) + const auto countRawAllocations = [](auto&& configureScene) { + sx::World world; + configureScene(world); + world.enterSimulationMode(); + sx::compute::ParallelExecutor executor(4u); + world.step(executor); + world.step(executor); + + ScopedRawHeapAllocationCounter rawCounter; + for (int step = 0; step < 2; ++step) { + world.step(executor); + } + rawCounter.stop(); + return HeapAllocationSnapshot{ + rawCounter.allocationCount(), rawCounter.allocationBytes()}; + }; + const auto rawGraphFloor = countRawAllocations([](sx::World&) {}); + const auto avbdRaw = countRawAllocations( + configureAvbdSelfContactFrictionParallelGridRowsScene); + EXPECT_EQ(avbdRaw.allocationCount, rawGraphFloor.allocationCount); + EXPECT_EQ(avbdRaw.allocationBytes, rawGraphFloor.allocationBytes); + #endif +#endif +} + // Velocity-actuated multibody joints solve a per-step coupled velocity // constraint (lambda = (J M^-1 J^T)^-1 (target - J v)). This used to allocate // Eigen dynamic temporaries every warmed step (the full mass-matrix inverse, From 761263bbd41190536ce699439ebc64f5776757b6 Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Thu, 30 Jul 2026 06:31:50 -0700 Subject: [PATCH 04/13] Support compliant articulated AVBD joint masks --- CHANGELOG.md | 4 +- .../compute/variational_integration.cpp | 66 +- docs/dev_tasks/avbd_solver/README.md | 79 ++- docs/dev_tasks/avbd_solver/RESUME.md | 62 +- docs/plans/104-vertex-block-descent-solver.md | 44 +- ...d-articulated-compliant-joints-packet.json | 498 +++++++++++++ .../avbd-demo-corpus.md | 3 + .../avbd-paper-coverage-contract.json | 16 +- .../avbd-paper-gap-audit.md | 21 +- python/examples/demos/README.md | 1 + python/examples/demos/registry.py | 4 + .../avbd_articulated_compliant_joints.py | 313 +++++++++ python/tests/integration/test_demos_cycle.py | 65 ++ ...vbd_articulated_compliant_joints_packet.py | 439 ++++++++++++ ...vbd_articulated_compliant_joints_packet.py | 663 ++++++++++++++++++ .../simulation/bm_avbd_rigid_fixed_joint.cpp | 96 +++ .../compute/test_variational_integration.cpp | 220 ++++++ tests/unit/simulation/world/test_world.cpp | 74 ++ 18 files changed, 2603 insertions(+), 65 deletions(-) create mode 100644 docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json create mode 100644 python/examples/demos/scenes/avbd_articulated_compliant_joints.py create mode 100644 python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py create mode 100644 scripts/write_avbd_articulated_compliant_joints_packet.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 247df4458d116..ed36f6e8db950 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -194,7 +194,9 @@ compatibility remains on the active DART 6 LTS branch._ remains intentionally Taylor-linearized. Large promoted AVBD CPU row inventories now perform the Section 4 post-primal dual/stiffness update over deterministic, allocation-stable worker ranges while preserving exact serial - row state. + row state. Passive articulated spherical, revolute, and prismatic point + joints now also apply masked finite-stiffness rows while preserving their + free coordinates. ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) - Added compute-executor and backend-boundary work so CPU threading, optional CUDA experiments, and future accelerator sidecars do not leak into the default diff --git a/dart/simulation/compute/variational_integration.cpp b/dart/simulation/compute/variational_integration.cpp index 2c3aaf736d130..69d8a6fdb6758 100644 --- a/dart/simulation/compute/variational_integration.cpp +++ b/dart/simulation/compute/variational_integration.cpp @@ -30,6 +30,7 @@ #include #include +#include #include #include @@ -607,7 +608,17 @@ void invertJointMatrixInto( return; } - inverse = matrix.inverse(); + DART_SIMULATION_THROW_T_IF( + matrix.rows() != matrix.cols() || matrix.rows() > 6, + InvalidOperationException, + "Variational articulated solve expects a square joint block with at " + "most six rows"); + Matrix6 padded = Matrix6::Identity(); + padded.topLeftCorner(matrix.rows(), matrix.cols()) = matrix; + const Eigen::PartialPivLU decomposition{padded}; + const Matrix6 paddedInverse = decomposition.solve(Matrix6::Identity()); + inverse.resize(matrix.rows(), matrix.cols()); + inverse = paddedInverse.topLeftCorner(matrix.rows(), matrix.cols()); } void subtractProjectedInertiaInto( @@ -627,8 +638,22 @@ void subtractProjectedInertiaInto( return; } - inertia.noalias() - -= forceProjector * inverseJointMatrix * motionProjector.transpose(); + const Eigen::Index dof = forceProjector.cols(); + DART_SIMULATION_THROW_T_IF( + dof > 6 || motionProjector.cols() != dof + || inverseJointMatrix.rows() != dof + || inverseJointMatrix.cols() != dof, + InvalidOperationException, + "Variational articulated solve received inconsistent projected joint " + "blocks"); + Matrix6 paddedForce = Matrix6::Zero(); + Matrix6 paddedInverse = Matrix6::Zero(); + Matrix6 paddedMotion = Matrix6::Zero(); + paddedForce.leftCols(dof) = forceProjector; + paddedInverse.topLeftCorner(dof, dof) = inverseJointMatrix; + paddedMotion.leftCols(dof) = motionProjector; + const Matrix6 projected = paddedForce * paddedInverse; + inertia.noalias() -= projected * paddedMotion.transpose(); } void addProjectedForceInto( @@ -1022,14 +1047,23 @@ void appendAvbdRigidWorldArticulatedPointJointConstraints( const bool hard = isHardAvbdRigidWorldPointJointConfig(config); const std::optional compliantStiffness = avbdRigidWorldCompliantPointJointStiffness(config); + const bool usesFiniteOneDofVelocityMotor + = !hard + && (jointModel.type == comps::JointType::Revolute + || jointModel.type == comps::JointType::Prismatic) + && jointActuation.actuatorType == comps::ActuatorType::Velocity; if (!config.enabled || jointState.broken || !dvbd::isAvbdRigidWorldPointJointType(jointModel.type) || isTopologyMultibodyJoint(registry, jointEntity, jointModel)) { continue; } + // A finite masked constraint and its free-axis velocity motor need + // separate compliant and bounded projection rows. Until that combined + // contract is implemented, keep the pre-existing fail-closed behavior + // instead of silently dropping the configured motor. if (!hard && (compliantScratch == nullptr || !compliantStiffness.has_value() - || jointModel.type != comps::JointType::Fixed)) { + || usesFiniteOneDofVelocityMotor)) { continue; } if (!dvbd::detail::hasValidActiveAvbdRigidJointAxes( @@ -1815,8 +1849,8 @@ void computeResidualInto( residual[seg] = link.subspace.col(0).dot(force) - timeStep * appliedForce[seg]; } else { - residual.segment(seg, n) = link.subspace.transpose() * force - - timeStep * appliedForce.segment(seg, n); + residual.segment(seg, n).noalias() = link.subspace.transpose() * force; + residual.segment(seg, n) -= timeStep * appliedForce.segment(seg, n); } } } @@ -3805,7 +3839,20 @@ VariationalSolveReport integrateMultibodyVariationalImpl( auto normalRhs = anderson.normalRhs.head(m); normalRhs.noalias() = stepMatrix.transpose() * step; auto gamma = anderson.gamma.head(m); - gamma = regularized.ldlt().solve(normalRhs); + constexpr Eigen::Index kStackAndersonDepth = 5; + if (m <= kStackAndersonDepth) { + using AndersonMatrix + = Eigen::Matrix; + using AndersonVector = Eigen::Matrix; + AndersonMatrix padded = AndersonMatrix::Identity(); + padded.topLeftCorner(m, m) = regularized; + AndersonVector paddedRhs = AndersonVector::Zero(); + paddedRhs.head(m) = normalRhs; + const AndersonVector paddedGamma = padded.ldlt().solve(paddedRhs); + gamma = paddedGamma.head(m); + } else { + gamma = regularized.ldlt().solve(normalRhs); + } if (gamma.allFinite()) { anderson.andersonIncrement = step; anderson.andersonIncrement.noalias() += mixMatrix * gamma; @@ -4488,6 +4535,8 @@ void reserveMultibodyVariationalRegistryStorage( compliantScratch->clear(); } } + const bool hasCompliantLoopForce + = compliantScratch != nullptr && !compliantScratch->constraints.empty(); const Eigen::Index loopProjectionRows = constraintRowCount( variationalLoopConstraintSpan(scratch.constraints)); scratch.constraints.clear(); @@ -4505,6 +4554,9 @@ void reserveMultibodyVariationalRegistryStorage( reserveConstraintProjectionScratch( tree, projectionRows, scratch.projection); reserveVariationalAndersonScratch(tree, 5, scratch.anderson); + if (hasCompliantLoopForce) { + reserveContactEvaluationScratch(tree, scratch.contactEvaluation); + } } auto* contactConfig = registry.try_get(entity); diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index 026468af19bf7..24b6acdc1d92e 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -24,18 +24,22 @@ This folder is the temporary working surface; the durable owner is the plan. `feature/vbd-avbd-paper-parity-contract`, based on `main` at `83110ef54ab`; the fail-closed contract is committed locally at `710cbfc1152`, and nothing has been pushed. The Section 3.5 packet is committed locally at - `a78f688a178`; the Section 4 packet is the current local unpushed closeout. -- **Current packet:** verified Section 4 parallel dual/stiffness update over - the already-promoted deformable and private free-rigid CPU row inventories. + `a78f688a178`, and the Section 4 packet at `0b0154573b8`; the articulated + finite-row packet is the current local unpushed closeout. +- **Current packet:** passive public world-link spherical, revolute, and + prismatic point joints now feed their masked finite-stiffness rows into the + CPU variational articulated bridge while preserving their free coordinates. The contracts contain 88 VBD and 88 AVBD requirements; all 176 remain incomplete until their recorded correctness, solver-identity, CPU/CUDA, visual, and comparable-performance predicates pass. -- **Latest verified local packet:** - `avbd.method.parallel_dual_stiffness_pass` advanced from missing to partial. - The serial primal order is unchanged; inventories above the measured - 8,192-row gate use deterministic, allocation-stable persistent-worker - ranges. The row is not complete; articulated/unified inventories, CUDA, and - source-matched achieved-accuracy performance remain open. +- **Latest verified local packet:** focused behavior, dense inverse-mass, + warmed allocation, Python scene, exact-parent mutation, assessed visual, and + candidate-only scale evidence advance + `avbd.method.joints_and_attachments` and + `avbd.method.finite_stiffness_ramping` without completing either row. Finite + one-DOF motor coupling, same-multibody finite pairs, fracture accounting, + unified rows, CUDA, and source-matched achieved-accuracy performance remain + open. - **Recent slices merged to `main`** (see the PLAN-104 progress log and the PRs for detail; per-slice history lives in git, not in this file): - #2991 — source-row coverage + contact-precheck (`f6fecbc5bd5`). @@ -45,16 +49,20 @@ This folder is the temporary working surface; the durable owner is the plan. - #3022 — bounded regression coverage: rigid-contact tangent-basis contract, articulated break→reset→break re-arm lifecycle, row-inventory replaced-key cold-start (`65ba05113c6`). -- **Current local gates:** 228/228 focused compute/contact/deformable/rigid - tests and the three new production-World activation/allocation tests pass. - The complete World binary is 431/435; all four failures reproduce with the - same allocation counts and bytes on exact parent `a78f688a178`, so they are - recorded as pre-existing rather than attributed to this packet. The full - simulation label is 77/79 active CTest entries with only the two split - allocation entries failing on those same four assertions; its other two - listed entries are intentionally disabled. The aggregate C++ unit tier is - 168/168. Release/profile build, lint, PLAN-104 parity, AVBD-packet, and - PLAN-122 allocation-matrix checks pass. +- **Current local gates:** the complete variational-integration binary passes + 181/181 active tests; the three new articulated finite-row post-bake + allocation policies, five packet-writer tests, and focused Python scene + behavior oracle pass. The identical behavior test fails five constrained-row + assertions on exact parent `0b0154573b8`, proving mutation sensitivity. The + assessed 120-frame software capture and pinned 3/12/48-joint candidate-only + benchmark are recorded in the packet. The Release build, aggregate C++ unit + tier (168/168), full Python tier (1644 passed, 20 skipped), PLAN-104 parity, + AVBD-packet, and PLAN-122 allocation-matrix gates pass. The simulation label + passes 77/79 active entries; only the split global-heap and raw-malloc + binaries fail on the same four unrelated allocation signatures previously + reproduced on exact parent `a78f688a178` (320/80 global bytes and 320/1376 + raw bytes), while all three new articulated finite-row gates pass inside + those splits. Two monolithic entries remain intentionally disabled. ## Goal @@ -85,9 +93,10 @@ numbers. ## Immediate Next Steps -Advance PLAN-104's articulated multibody AVBD extraction gap after the verified -Section 4 closeout. Keep every dependent figure/demo/performance row partial -until source-matched CPU and CUDA evidence closes it. +Continue PLAN-104's articulated multibody AVBD extraction with finite +same-multibody endpoint pairs or finite one-DOF motor coupling after this +packet. Keep every dependent figure/demo/performance row partial until +source-matched CPU and CUDA evidence closes it. Two smaller deferred maintenance items remain valid but do not outrank the missing paper mechanism: hoist the duplicated `makeCollisionPairKey` into a @@ -170,6 +179,32 @@ paper/reference-performance claims. No new render is required for this scheduling-only packet because bitwise serial/parallel row and production World state equivalence is the stronger behavior oracle. +## Verified Local Packet: Articulated Finite Masked Rows + +- **Value:** make public passive finite-stiffness spherical, revolute, and + prismatic world-link point joints affect the variational articulated solve + instead of being silently skipped. +- **Scope:** reuse persistent per-axis AVBD ramp state for masked linear and + angular rows on CPU; preserve spherical rotation, revolute hinge rotation, + and prismatic axis translation as free coordinates. +- **Non-goals:** finite one-DOF velocity-motor coupling, same-multibody finite + endpoint pairs, finite-row fracture accounting, unified soft/rigid rows, + CUDA, and a paper/reference speedup claim. +- **Correctness evidence:** the public S/R/P behavior oracle passes on the + candidate and fails five constrained-coordinate assertions on exact parent + `0b0154573b8`; the allocation-free fixed-size 6x6 articulated inverse-mass + path matches a dense nine-DOF solve. +- **Runtime evidence:** warmed world-base, global-`new`, and raw-malloc gates + pass; the Python text oracle and assessed start/middle/end render show the + constrained bodies remain aligned while their free coordinates move. +- **Performance boundary:** the pinned candidate-only benchmark records 71.7 + us for 3 joints, 333.5 us for 12, and 2.404 ms for 48. The parent skipped + these rows, so the packet makes no before/after speedup claim. + +The durable evidence is +[`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json). +Both linked canonical method rows remain partial. + ## History Per-slice history and durable evidence live in the PLAN-104 progress log, the diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index 8b7890feb4f3c..e8c5d27b3ddb8 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -9,41 +9,56 @@ this file is only the current session pointer. See ## Last Session Summary The exact 176-row VBD/AVBD paper-parity contract remains fail-closed, with no -complete rows. The latest local packet advanced -`avbd.method.parallel_dual_stiffness_pass` from missing to partial: the -Section 4 post-primal update now uses deterministic persistent-worker ranges -for sufficiently large promoted deformable and private free-rigid CPU row -inventories. The serial primal/color order remains unchanged. Articulated and -unified row coverage, CUDA, and comparable source-matched achieved-accuracy -performance remain open. +complete rows. The latest local packet extends the CPU articulated variational +bridge from finite fixed rows to passive public world-link spherical, +revolute, and prismatic point joints. Their masked constrained coordinates use +persistent per-axis AVBD stiffness state while spherical rotation, revolute +hinge rotation, and prismatic axis translation remain free. The durable packet is -[`../../plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json`](../../plans/104-vertex-block-descent-solver/avbd-parallel-dual-update-evidence.json). -It records bitwise serial/2-worker/4-worker coverage for every promoted row -kind, invalid and non-finite behavior, executor nesting/concurrency/exception -recovery, production-World activation, warmed world-base/global/raw allocation -closure, and the exact-parent interleaved throughput benchmark. The method row -stays partial. +[`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json). +It records the public S/R/P behavior oracle, a dense nine-DOF inverse-mass +oracle, all three warmed allocation policies, a Python text oracle, assessed +start/middle/end visual evidence, candidate-only 3/12/48-joint scale data, and +an exact-parent mutation run. The identical behavior test fails five +constrained-coordinate assertions on exact parent `0b0154573b8` and passes on +the candidate. Both linked method rows remain partial. + +The Release build, C++ unit tier (168/168), full Python tier (1644 passed, 20 +skipped), PLAN-104 parity, AVBD-packet, and PLAN-122 allocation-matrix gates +pass. The simulation label is 77/79 active entries: its split global-heap and +raw-malloc binaries reproduce the same four unrelated allocation signatures +previously recorded on exact parent `a78f688a178`, while the three new +articulated finite-row allocation policies pass. Two monolithic entries are +intentionally disabled. ## Current Branch `feature/vbd-avbd-paper-parity-contract`, based on the then-current `origin/main` at `83110ef54ab`. The fail-closed contract is committed locally -at `710cbfc1152`, and the Section 3.5 packet at `a78f688a178`; the branch has -not been pushed. +at `710cbfc1152`, the Section 3.5 packet at `a78f688a178`, and the Section 4 +packet at `0b0154573b8`; the articulated finite-row packet is the current +uncommitted closeout. The branch has not been pushed. ## Immediate Next Step -Advance PLAN-104's articulated multibody AVBD extraction gap. Preserve the -Section 4 claim boundary: the new CPU pass is only partial until articulated -and unified row inventories plus CUDA use the same contract and -source-matched achieved-accuracy evidence closes. +After aggregate gates and the local closeout commit, continue articulated +extraction with either finite same-multibody endpoint pairs or finite one-DOF +velocity-motor coupling. Preserve the packet boundary: the current CPU +world-link passive rows do not close unified rows, CUDA, the source/paper +corpus, or achieved-accuracy performance. ## Context That Would Be Lost - **Keep claims narrow.** Do not claim a source-demo/paper CPU win, GPU parity, a broad breakable-wall/fracture corpus, a same-hardware paper-number match, or an all-coefficient friction win unless the tracked artifacts directly prove it. +- The articulated finite-row benchmark is candidate-only. Exact parent skips + the new rows, so a timing ratio would compare different work and is not a + speedup claim. +- Finite public one-DOF `Velocity` rows are deliberately fail-closed: combining + a bounded free-axis motor with finite masked constraints needs a separate row + contract. Passive finite S/R/P rows are the verified scope. - The pinned 2D source applies Section 3.5 generically and the 3D joint source applies it to nonlinear point rows, but the pinned 3D Spring source omits the geometric term. DART follows the paper for distance springs; do not call that @@ -67,9 +82,10 @@ git status --short --branch git log --oneline --decorate -5 pixi run check-plan104-paper-parity pixi run check-avbd-packets +pixi run check-plan122-allocation-matrix ``` -Stay in this exact worktree and branch. Start from PLAN-104's first current gap, -articulated multibody AVBD extraction. Verify with focused AVBD and world -allocation tests, matched solver-state evidence, the contract checker, and -`pixi run lint`; do not push or mutate GitHub without explicit approval. +Stay in this exact worktree and branch. Verify the current packet with focused +AVBD/world allocation/Python writer and scene tests, the parity and allocation +contract checkers, aggregate build/unit gates, and `pixi run lint`; do not push +or mutate GitHub without explicit approval. diff --git a/docs/plans/104-vertex-block-descent-solver.md b/docs/plans/104-vertex-block-descent-solver.md index 2b55c4b1ec38f..f6127484ed3f9 100644 --- a/docs/plans/104-vertex-block-descent-solver.md +++ b/docs/plans/104-vertex-block-descent-solver.md @@ -887,6 +887,15 @@ Finite-stiffness private AVBD fixed point-joint configs on articulated endpoints now also contribute compliant variational forces through persistent per-axis finite-stiffness row state that warm-starts and ramps toward the configured cap across steps. +Passive public world-link spherical, revolute, and prismatic point joints now +use that same persistent finite-stiffness state for their masked linear and +angular row families: spherical rotation, revolute hinge rotation, and +prismatic axis translation remain free. The focused +`avbd_articulated_compliant_joints` py-demo, allocation gates, exact-parent +mutation oracle, and candidate-only 3/12/48-joint benchmark are recorded in +[`avbd-articulated-compliant-joints-packet.json`](104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json). +Finite one-DOF velocity-motor coupling, same-multibody finite pairs, +finite-row fracture accounting, CUDA, and unified soft/rigid rows remain open. Free-rigid AVBD point-joint rows now also support finite linear and angular material stiffness through the public point-joint facade and dartpy properties; those rows use raw finite residuals and ramp row stiffness up to the configured @@ -1008,10 +1017,15 @@ remaining gaps, in order: tiny positive effort-limit coverage including non-cardinal child-/parent-endpoint axis bases, private fixed-row reset, and revolute/prismatic break/reset re-engagement for world-link polarity. - Finite-stiffness - private AVBD fixed point-joint - configs now also contribute compliant variational forces through persistent - stiffness-ramped rows, and non-topology multibody-link + Finite-stiffness private AVBD fixed point-joint configs and passive public + world-link spherical/revolute/prismatic masked rows now contribute compliant + variational forces through persistent stiffness-ramped state while keeping + their paper-defined free coordinates active. The focused behavior oracle + fails on exact parent, all three warmed allocation policies pass, and + `avbd_articulated_compliant_joints` plus + [`avbd-articulated-compliant-joints-packet.json`](104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json) + record visual and candidate-only 3/12/48-joint scale evidence without a + speedup claim. Non-topology multibody-link fixed/revolute/prismatic point-joint entities can now generate hard private configs from the simulation-entry current pose. Public same-multibody and world-link articulated facades now cover fixed, spherical linear-only @@ -1130,7 +1144,9 @@ remaining gaps, in order: movable link-pair, tiny-limit, restored tiny-limit save/load, one-DOF break/reset, spherical reset, and movable-pair plus direct world-link save/load/reset checks remains open. - Extend that bridge to those row families, then broaden fracture + Extend finite extraction to same-multibody endpoint pairs, couple bounded + free-axis velocity motors with finite masked rows, and add finite-row + break-force accounting. Then broaden fracture lifecycle/corpus coverage beyond the narrow hard point-joint threshold and the now-covered private fixed-row/world-fixed reset plus 2D Fracture/3D Breakable source-demo fixed-joint break/reset rows, and public articulated @@ -1290,6 +1306,24 @@ AVBD parity additionally requires: Relocated from the dashboard on 2026-07-03; newest first. +On 2026-07-30, the articulated finite-row packet extended the CPU variational +bridge from finite fixed rows to passive public world-link spherical, +revolute, and prismatic point joints. Their masked constrained coordinates use +persistent per-axis stiffness ramp state while spherical rotation, revolute +hinge rotation, and prismatic axis translation remain free. The public +behavior oracle fails on exact parent `0b0154573b8` and passes on the candidate; +a fixed-size 6x6 articulated inverse-mass solve matches the dense nine-DOF +oracle, and warmed world-base, global-`new`, and raw-malloc gates all pass. The +`avbd_articulated_compliant_joints` py-demo has a text-first free-coordinate +oracle and assessed 120-frame software capture. A pinned candidate-only +benchmark records median CPU time of 71.7 us for 3 joints, 333.5 us for 12, +and 2.404 ms for 48; the parent skipped these rows, so no speedup is claimed. +[`avbd-articulated-compliant-joints-packet.json`](104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json) +keeps `avbd.method.joints_and_attachments` and +`avbd.method.finite_stiffness_ramping` partial: finite one-DOF motor coupling, +same-multibody finite pairs, fracture accounting, unified soft/rigid rows, +CUDA, and source/paper achieved-accuracy performance remain open. + On 2026-07-30, the bounded AVBD Section 4 packet added a deterministic post-primal CPU dual/stiffness pass for the currently promoted deformable and private free-rigid row inventories. The serial primal/color order is unchanged; diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json new file mode 100644 index 0000000000000..4cad2435f9024 --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json @@ -0,0 +1,498 @@ +{ + "benchmark": { + "benchmark": "BM_AvbdArticulatedCompliantJointStep", + "context": { + "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "v1.9.5", + "mhz_per_cpu": 5300, + "num_cpus": 32 + }, + "invariants": { + "angular_stiffness": 1000.0, + "family_instances": [ + 1, + 4, + 16 + ], + "joint_types": [ + "spherical", + "revolute", + "prismatic" + ], + "linear_stiffness": 1000.0, + "start_stiffness": 10.0, + "time_step": 0.002 + }, + "json_sha256": "ffd3a4b5e76be84b90716356bb4a3be1a6d3b6334effa232cd3e74e4a691a496", + "rows": [ + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "compliant_joints": 3.0, + "cpu_time": 71774.63059239613, + "family_index": 0, + "family_instances": 1.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/1_mean", + "per_family_instance_index": 0, + "prismatic_joints": 1.0, + "real_time": 71782.7725970655, + "repetitions": 5, + "revolute_joints": 1.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/1", + "run_type": "aggregate", + "spherical_joints": 1.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "compliant_joints": 3.0, + "cpu_time": 71710.94341290895, + "family_index": 0, + "family_instances": 1.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/1_median", + "per_family_instance_index": 0, + "prismatic_joints": 1.0, + "real_time": 71722.36075973968, + "repetitions": 5, + "revolute_joints": 1.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/1", + "run_type": "aggregate", + "spherical_joints": 1.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "compliant_joints": 0.0, + "cpu_time": 132.45102017831687, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/1_stddev", + "per_family_instance_index": 0, + "prismatic_joints": 0.0, + "real_time": 129.15567137903108, + "repetitions": 5, + "revolute_joints": 0.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/1", + "run_type": "aggregate", + "spherical_joints": 0.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "compliant_joints": 0.0, + "cpu_time": 0.0018453737634749855, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/1_cv", + "per_family_instance_index": 0, + "prismatic_joints": 0.0, + "real_time": 0.0017992572132037013, + "repetitions": 5, + "revolute_joints": 0.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/1", + "run_type": "aggregate", + "spherical_joints": 0.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "compliant_joints": 12.0, + "cpu_time": 333465.8440366972, + "family_index": 0, + "family_instances": 4.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/4_mean", + "per_family_instance_index": 1, + "prismatic_joints": 4.0, + "real_time": 333467.37522435375, + "repetitions": 5, + "revolute_joints": 4.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/4", + "run_type": "aggregate", + "spherical_joints": 4.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "compliant_joints": 12.0, + "cpu_time": 333543.27522935753, + "family_index": 0, + "family_instances": 4.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/4_median", + "per_family_instance_index": 1, + "prismatic_joints": 4.0, + "real_time": 333544.2982235957, + "repetitions": 5, + "revolute_joints": 4.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/4", + "run_type": "aggregate", + "spherical_joints": 4.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "compliant_joints": 0.0, + "cpu_time": 390.96800549048885, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/4_stddev", + "per_family_instance_index": 1, + "prismatic_joints": 0.0, + "real_time": 389.8443675455768, + "repetitions": 5, + "revolute_joints": 0.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/4", + "run_type": "aggregate", + "spherical_joints": 0.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "compliant_joints": 0.0, + "cpu_time": 0.001172437934745316, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/4_cv", + "per_family_instance_index": 1, + "prismatic_joints": 0.0, + "real_time": 0.0011690629923940632, + "repetitions": 5, + "revolute_joints": 0.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/4", + "run_type": "aggregate", + "spherical_joints": 0.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "compliant_joints": 48.0, + "cpu_time": 2410699.096969697, + "family_index": 0, + "family_instances": 16.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/16_mean", + "per_family_instance_index": 2, + "prismatic_joints": 16.0, + "real_time": 2410978.212014236, + "repetitions": 5, + "revolute_joints": 16.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/16", + "run_type": "aggregate", + "spherical_joints": 16.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "compliant_joints": 48.0, + "cpu_time": 2403628.999999995, + "family_index": 0, + "family_instances": 16.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/16_median", + "per_family_instance_index": 2, + "prismatic_joints": 16.0, + "real_time": 2403645.393452748, + "repetitions": 5, + "revolute_joints": 16.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/16", + "run_type": "aggregate", + "spherical_joints": 16.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "compliant_joints": 0.0, + "cpu_time": 16237.220725305564, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/16_stddev", + "per_family_instance_index": 2, + "prismatic_joints": 0.0, + "real_time": 16555.881931020635, + "repetitions": 5, + "revolute_joints": 0.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/16", + "run_type": "aggregate", + "spherical_joints": 0.0, + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "compliant_joints": 0.0, + "cpu_time": 0.00673548214528977, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantJointStep/16_cv", + "per_family_instance_index": 2, + "prismatic_joints": 0.0, + "real_time": 0.006866873308319586, + "repetitions": 5, + "revolute_joints": 0.0, + "run_name": "BM_AvbdArticulatedCompliantJointStep/16", + "run_type": "aggregate", + "spherical_joints": 0.0, + "threads": 1, + "time_unit": "ns" + } + ], + "scale_data": [ + { + "benchmark": "BM_AvbdArticulatedCompliantJointStep/1", + "compliant_joints": 3, + "cpu_time_per_joint_ns": 23903.647804302982, + "cpu_time_per_step_ns": 71710.94341290895, + "family_instances": 1, + "real_time_per_step_ns": 71722.36075973968, + "time_unit": "ns" + }, + { + "benchmark": "BM_AvbdArticulatedCompliantJointStep/4", + "compliant_joints": 12, + "cpu_time_per_joint_ns": 27795.272935779794, + "cpu_time_per_step_ns": 333543.27522935753, + "family_instances": 4, + "real_time_per_step_ns": 333544.2982235957, + "time_unit": "ns" + }, + { + "benchmark": "BM_AvbdArticulatedCompliantJointStep/16", + "compliant_joints": 48, + "cpu_time_per_joint_ns": 50075.60416666656, + "cpu_time_per_step_ns": 2403628.999999995, + "family_instances": 16, + "real_time_per_step_ns": 2403645.393452748, + "time_unit": "ns" + } + ] + }, + "correctness": { + "allocation_tests": [ + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotGrowWorldBaseAllocator", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotAllocateGlobalHeap", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotMallocOnHeap" + ], + "cpp_behavior_test": "VariationalIntegration.AvbdCompliantPublicArticulatedJointMasksResistOnlyConstrainedMotion", + "cpp_multi_dof_inverse_test": "VariationalIntegration.ArticulatedInverseMassMultiDofMatchesDenseSolve", + "exact_parent_mutation": { + "command": "./build/default/cpp/Release/bin/test_variational_integration --gtest_filter=VariationalIntegration.AvbdCompliantPublicArticulatedJointMasksResistOnlyConstrainedMotion", + "exit_code": 1, + "failed_assertions": [ + { + "actual": 0.10099999250024566, + "coordinate": "spherical constrained translation", + "upper_bound": 0.05049999625012283 + }, + { + "actual": 0.10099999730419329, + "coordinate": "revolute constrained translation", + "upper_bound": 0.050499998652096645 + }, + { + "actual": 0.08076857228987704, + "coordinate": "revolute constrained axis tilt", + "upper_bound": 0.04038428614493852 + }, + { + "actual": 0.10099999247697403, + "coordinate": "prismatic constrained transverse translation", + "upper_bound": 0.05049999623848701 + }, + { + "actual": 0.0673331824015065, + "coordinate": "prismatic constrained rotation", + "upper_bound": 0.03366659120075325 + } + ], + "file": "exact_parent_mutation.json", + "interpretation": "The exact parent skips passive finite spherical, revolute, and prismatic articulated rows, so each constrained coordinate matches the free rollout and the candidate oracle fails.", + "mutation": "Apply the candidate behavior test to the exact parent without applying the solver implementation.", + "observed_result": "fail", + "parent_commit": "0b0154573b842c142d31ef9feef4567c5677c4fd", + "sha256": "e9b8ea90558c53e4129e840aca12447f9760564d9f5fd666bd2eb3a801191d2b", + "test": "VariationalIntegration.AvbdCompliantPublicArticulatedJointMasksResistOnlyConstrainedMotion" + }, + "python_scene_test": "test_avbd_articulated_compliant_joints_demo_preserves_free_coordinates" + }, + "packet": "avbd_articulated_compliant_joints", + "performance_claim_boundary": "Candidate-only finite mechanism cost and scaling. The exact parent intentionally skipped these non-Fixed finite rows, so this packet makes no before/after speedup claim.", + "remaining_gates": [ + "finite articulated one-DOF velocity-motor coupling", + "finite-row break-force accounting and fracture lifecycle", + "same-multibody finite articulated endpoint pairs", + "unified soft/rigid row solve", + "CUDA AVBD row parity and same-hardware benchmark packets", + "source-demo, paper/site/video scene, and achieved-accuracy performance closure" + ], + "reproduction": { + "benchmark_command": "taskset -c 18 build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint --benchmark_filter='BM_AvbdArticulatedCompliantJointStep/(1|4|16)$' --benchmark_min_time=0.05s --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out= --benchmark_out_format=json", + "capture_command": "LIBGL_ALWAYS_SOFTWARE=1 MESA_LOADER_DRIVER_OVERRIDE=llvmpipe PYTHONPATH=build/default/cpp/Release/python:python BUILD_TYPE=Release python scripts/capture_py_demo.py --scene avbd_articulated_compliant_joints --frames 120 --width 960 --height 540 --view three-quarter --camera-distance 8 --camera-target 0,0,1.2 --output-dir ", + "image_verdict_command": "pixi run image-verdict -- /avbd_articulated_compliant_joints.png --out /image_verdict.json --meta scene=avbd_articulated_compliant_joints --meta view=three-quarter" + }, + "resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": false, + "recorded_from": "contact-free py-demo and articulated compliant-joint benchmark row family", + "rigid_contact_solver": "none", + "rigid_point_joint_solver": "avbd" + }, + "scene": "avbd_articulated_compliant_joints", + "scene_invariants": { + "angular_stiffness": 1000.0, + "free_coordinates": { + "prismatic": "translation along the joint axis", + "revolute": "rotation about the joint axis", + "spherical": "rotation" + }, + "joint_types": [ + "spherical", + "revolute", + "prismatic" + ], + "linear_stiffness": 1000.0, + "start_stiffness": 10.0, + "time_step": 0.002 + }, + "schema_version": 2, + "target": { + "complete_paper_reproduction": false, + "contract_rows": [ + "avbd.method.joints_and_attachments", + "avbd.method.finite_stiffness_ramping" + ], + "scope": "passive finite-stiffness articulated spherical, revolute, and prismatic masked rows on CPU" + }, + "visual_capture": { + "frames": { + "count": 120, + "directory": "png_frames", + "first_frame": { + "file": "png_frames/frame_000001.png", + "sha256": "83cd44d35728e5e7b56170ce5441d05aba4277e5671d96f25d6240899f8d2310" + }, + "last_frame": { + "file": "png_frames/frame_000120.png", + "sha256": "cb7ec695bd6e2829a58a18e03b62252f9869c330a69647835a431e29aff0363c" + } + }, + "image_verdict": { + "checks": { + "contrast": { + "bright_pixels": 562, + "dark_pixels": 0, + "mid_pixels": 125375, + "p05": 145.446, + "p95": 171.017, + "pass": false, + "reasons": [ + "dark pixels 0 < 664", + "bright pixels 562 < 664", + "luminance spread 25.6 < 90.0" + ], + "region": { + "x0": 384, + "x1": 931, + "y0": 243, + "y1": 486 + }, + "sample_count": 132921, + "spread": 25.571, + "thresholds": { + "bright_luminance_gt": 190.0, + "dark_luminance_lt": 55.0, + "mid_luminance_max": 170.0, + "mid_luminance_min": 70.0, + "min_bright_pixels": 664, + "min_dark_pixels": 664, + "min_mid_pixels": 332, + "min_spread": 90.0 + } + }, + "non_blank": { + "nonzero_pixels": 518400, + "pass": true, + "reasons": [], + "total_pixels": 518400, + "unique_colors_seen": 2 + } + }, + "file": "image_verdict.json", + "machine_scope": "pixel-integrity", + "metadata": { + "scene": "avbd_articulated_compliant_joints", + "view": "three-quarter" + }, + "pass": true, + "sha256": "61f9140a592b3f06f2bf465e7edd0860089c4a0207b9cf66d91d4ec8b1bb8ce7" + }, + "manifest_sha256": "520194e1f6a156399f53cd60dba831ac38ffb63536359aca8a316742d2d44089", + "scene": "avbd_articulated_compliant_joints", + "screenshot": { + "file": "avbd_articulated_compliant_joints.png", + "height": 540, + "sha256": "cb7ec695bd6e2829a58a18e03b62252f9869c330a69647835a431e29aff0363c", + "width": 960 + }, + "semantic_review": { + "file": "visual_review.json", + "inspected_frames": [ + { + "file": "png_frames/frame_000001.png", + "role": "start", + "sha256": "83cd44d35728e5e7b56170ce5441d05aba4277e5671d96f25d6240899f8d2310" + }, + { + "file": "png_frames/frame_000060.png", + "role": "middle", + "sha256": "95f96cbc4fbfc054801fcddfd0ed179cabe8677745f44c383006ae7f2b649509" + }, + { + "file": "png_frames/frame_000120.png", + "role": "end", + "sha256": "cb7ec695bd6e2829a58a18e03b62252f9869c330a69647835a431e29aff0363c" + } + ], + "limitations": "The images corroborate scene identity, finite motion, free-coordinate motion, and gross mask behavior. They do not quantify constraint residuals, prove stiffness-ramp state, or distinguish the fixed-size inverse-mass implementation; the focused C++ and Python text oracles decide those claims.", + "observations": "Across the start, middle, and end frames, all three bodies remain finite and fully visible. The blue spherical body and orange revolute bar visibly rotate while remaining near their anchor markers; the orange bar stays centered and coaxial with the vertical hinge guide. The green prismatic body advances along the gray horizontal rail without visible transverse or rotational escape.", + "reviewer_capability": "native image input at original detail", + "sha256": "35a53d9aef1034215d86ac91b58332c61f3da9e908c86dca34b1fca50f630d31", + "verdict": "pass" + }, + "show_ui": false, + "ui_ready": { + "dropped_warmup_frames": 0, + "required": false + } + } +} diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md index 149842b16bfc6..51810c737ef37 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md @@ -163,6 +163,9 @@ paper/source-demo corpus: | `avbd_articulated_spherical_pair_breakable_joint` py-demo | Public same-multibody articulated spherical break/reset and weak re-arm breakage while orientation stays free | Partial | | `avbd-articulated-spherical-pair-breakable-joint-packet.json` | Visual/benchmark packet for the public same-multibody articulated spherical point-joint break/reset row | Partial | | `avbd-breakable-joint-scale-packet.json` | Benchmark-only scale packet for five public fixed/spherical breakable point-joint rows over 1/8/32 joints | Partial | +| `avbd_articulated_compliant_joints` py-demo | Public passive finite-stiffness world-link spherical/revolute/prismatic rows with visible constrained and free coordinates | Partial | +| `BM_AvbdArticulatedCompliantJointStep` dashboard row | Candidate-only CPU cost/scaling row over 3/12/48 passive finite articulated point joints | Partial | +| `avbd-articulated-compliant-joints-packet.json` | Mutation-sensitive correctness, allocation, visual, solver-identity, and candidate-only scale evidence for the finite rows | Partial | | `avbd_articulated_high_ratio_chain` py-demo | Five-link articulated variational-chain smoke with a 200:1 heavy tip | Partial | | `BM_AvbdArticulatedHighRatioChainStep` dashboard row | Narrow CPU dashboard row for the five-link 200:1 articulated high-ratio chain smoke | Partial | | `avbd_paper_scale_high_ratio_chain` py-demo | 50-link articulated variational-chain smoke with a 50,000:1 heavy tip | Partial | diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json index a021861213891..56e60b7b3cd7f 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json @@ -115,8 +115,12 @@ "id": "avbd.method.finite_stiffness_ramping", "status": "partial", "source_locator": "Section 3.1, stiffness growth and finite-stiffness saturation", - "evidence": ["tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp"], - "blockers": ["Narrow spring/tetrahedral rows exist, but all paper constraints, source scenes, and CUDA parity are incomplete."] + "evidence": [ + "tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp", + "tests/unit/simulation/compute/test_variational_integration.cpp", + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json" + ], + "blockers": ["CPU finite-stiffness spring/tetrahedral rows and passive world-link articulated fixed/spherical/revolute/prismatic rows exist, but finite one-DOF motor coupling, same-multibody finite pairs, fracture accounting, the full paper/source corpus, and CUDA parity are incomplete."] }, { "id": "avbd.method.force_bounds", @@ -193,8 +197,12 @@ "id": "avbd.method.joints_and_attachments", "status": "partial", "source_locator": "Sections 1 and 5, ball-socket, attachment, and limited-DOF hard constraints", - "evidence": ["tests/unit/simulation/world/test_vbd_world_solver.cpp"], - "blockers": ["Narrow point-joint and articulated bridges exist, but the full joint corpus and unified paper scenes are incomplete."] + "evidence": [ + "tests/unit/simulation/world/test_vbd_world_solver.cpp", + "tests/unit/simulation/compute/test_variational_integration.cpp", + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json" + ], + "blockers": ["Hard point-joint bridges and passive finite world-link spherical/revolute/prismatic masked rows exist on CPU, but finite motor coupling, same-multibody finite pairs, the full joint corpus, unified paper scenes, and CUDA parity are incomplete."] }, { "id": "avbd.method.motors", diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md index 3e010faa920cd..f24ad1c907e8a 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md @@ -579,7 +579,10 @@ open. rescaling/approximate Hessian options. 6. **A5 - joints, motors, and fracture:** Implement ball/revolute/limited DOF joints, motor torque rows, attachment rows, maximum-force fracture, and - breakage persistence. + breakage persistence. CPU hard point-joint bridges and passive finite + world-link fixed/spherical/revolute/prismatic masked rows are partial; + finite motor coupling, same-multibody finite pairs, broad fracture + accounting/corpus coverage, and CUDA remain open. 7. **A6 - unified rigid/soft coupling:** Couple rigid rows and deformable VBD rows in one AVBD solve so soft cloth/elastic bodies interact with articulated rigid chains and stacks. @@ -623,7 +626,17 @@ bridge into the variational projection path, including narrow hard bounded revolute/prismatic velocity-motor rows with revolute/prismatic command-update coverage, private fixed-row reset, revolute/prismatic break/reset re-engagement, and persistent -stiffness-ramped compliant fixed rows. Non-topology +stiffness-ramped compliant fixed rows. Passive public world-link spherical, +revolute, and prismatic rows now also apply their finite linear/angular masks +through that persistent ramp state while preserving spherical rotation, +revolute hinge rotation, and prismatic axis translation. The focused behavior +oracle fails on exact parent and passes on the candidate, the three post-bake +allocation policies pass, and +[`avbd-articulated-compliant-joints-packet.json`](avbd-articulated-compliant-joints-packet.json) +records solver identity, assessed capture, and candidate-only 3/12/48-joint +scale data without a speedup claim. Finite one-DOF velocity-motor coupling, +same-multibody finite pairs, and finite-row break-force accounting remain +missing. Non-topology multibody-link fixed/revolute/prismatic point-joint entities can also generate hard private configs from simulation-entry current poses, and public same-multibody/world-link articulated @@ -752,7 +765,9 @@ records validated benchmark JSON plus finite replay plot data, and renders the timing/stability sweep. No same-hardware paper-number comparison or GPU parity exists yet. DART still cannot reproduce the paper's full hard-constrained articulated chains, same-hardware numbers, or -cloth-plus-articulated-rigid coupling scenes. +cloth-plus-articulated-rigid coupling scenes. The next articulated mechanism +slice should close finite one-DOF motor coupling or same-multibody finite pairs +before treating the A5 joint-family surface as broad. The next implementation slice should either optimize the measured `avbd_demo2d_ground`, `avbd_demo2d_motor`, `avbd_demo2d_hanging_rope`, `avbd_demo2d_rod`, diff --git a/python/examples/demos/README.md b/python/examples/demos/README.md index dc919cfb28b76..18cdd5715ae60 100644 --- a/python/examples/demos/README.md +++ b/python/examples/demos/README.md @@ -1409,6 +1409,7 @@ constraint/contact/motor signal instead of relying on screenshots alone. | `avbd_articulated_prismatic_pair_motor_breakable_joint` | A weak same-multibody prismatic motor releasing and re-engaging an articulated carriage | Public articulated linear motor break/reset lifecycle through the bridge | | `avbd_articulated_prismatic_motor_breakable_joint` | A weak world-anchored prismatic motor releasing and re-engaging an articulated carriage | Public articulated linear motor break/reset lifecycle through the bridge | | `avbd_articulated_world_revolute_motor_breakable_joint` | A weak world-anchored revolute motor releasing and re-engaging an articulated floating link | Public world-anchored articulated motor break/reset lifecycle through the bridge | +| `avbd_articulated_compliant_joints` | Finite-stiffness spherical, revolute, and prismatic articulated joint masks | Public passive finite rows preserve each joint family's free coordinates | | `avbd_articulated_high_ratio_chain` | A five-link variational chain swinging with a 200:1 heavy tip | Narrow articulated high mass-ratio smoke for the AVBD paper gap | | `avbd_paper_scale_high_ratio_chain` | A 50-link variational chain swinging with a 50,000:1 heavy tip | Paper-scale articulated high mass-ratio visual smoke for the AVBD paper gap | | `avbd_rigid_breakable_joint` | A weak fixed joint releasing and re-engaging a rigid payload | Public break-force threshold, reset, and broken-state path | diff --git a/python/examples/demos/registry.py b/python/examples/demos/registry.py index 24db3372c400f..3a3ee94a53242 100644 --- a/python/examples/demos/registry.py +++ b/python/examples/demos/registry.py @@ -48,6 +48,9 @@ from .scenes.avbd_articulated_breakable_joint import ( SCENE as AVBD_ARTICULATED_BREAKABLE_JOINT, ) +from .scenes.avbd_articulated_compliant_joints import ( + SCENE as AVBD_ARTICULATED_COMPLIANT_JOINTS, +) from .scenes.avbd_articulated_fixed_pair_breakable_joint import ( SCENE as AVBD_ARTICULATED_FIXED_PAIR_BREAKABLE_JOINT, ) @@ -308,6 +311,7 @@ def make_demo_scenes() -> list[PythonDemoScene]: AVBD_ARTICULATED_PRISMATIC_PAIR_MOTOR_BREAKABLE_JOINT, AVBD_ARTICULATED_PRISMATIC_MOTOR_BREAKABLE_JOINT, AVBD_ARTICULATED_WORLD_REVOLUTE_MOTOR_BREAKABLE_JOINT, + AVBD_ARTICULATED_COMPLIANT_JOINTS, AVBD_ARTICULATED_HIGH_RATIO_CHAIN, AVBD_PAPER_SCALE_HIGH_RATIO_CHAIN, AVBD_RIGID_BREAKABLE_JOINT, diff --git a/python/examples/demos/scenes/avbd_articulated_compliant_joints.py b/python/examples/demos/scenes/avbd_articulated_compliant_joints.py new file mode 100644 index 0000000000000..0c4f14b0cfc26 --- /dev/null +++ b/python/examples/demos/scenes/avbd_articulated_compliant_joints.py @@ -0,0 +1,313 @@ +"""Finite-stiffness AVBD articulated joint-mask showcase.""" + +from __future__ import annotations + +from collections import deque + +import numpy as np + +import dartpy as dart +import dartpy as sx + +from .._world_bridge import WorldRenderBridge +from ..runner import PythonDemoScene, ScenePanel, SceneSetup + +_TIME_STEP = 0.002 +_START_STIFFNESS = 10.0 +_LINEAR_STIFFNESS = 1000.0 +_ANGULAR_STIFFNESS = 1000.0 +_AUTO_RESET_SECONDS = 1.2 +_ANCHORS = { + "spherical": np.array([-2.2, 0.0, 1.2]), + "revolute": np.array([0.0, 0.0, 1.2]), + "prismatic": np.array([2.2, 0.0, 1.2]), +} +_INITIAL_VELOCITIES = { + "spherical": np.array([0.8, 0.0, 0.0, 0.0, 0.0, 1.2]), + "revolute": np.array([0.5, 0.3, 0.0, 0.5, 0.3, 1.0]), + "prismatic": np.array([1.0, 0.5, 0.0, 0.0, 0.4, 0.6]), +} + + +def _translation(position: np.ndarray) -> np.ndarray: + transform = np.eye(4) + transform[:3, 3] = position + return transform + + +def _rotation_angle(rotation: np.ndarray) -> float: + cosine = 0.5 * (float(np.trace(rotation)) - 1.0) + return float(np.arccos(np.clip(cosine, -1.0, 1.0))) + + +def _configure_finite_joint(joint: sx.Joint) -> sx.Joint: + policy = joint.constraint_projection_policy + policy.start_stiffness = _START_STIFFNESS + policy.linear_stiffness = _LINEAR_STIFFNESS + policy.angular_stiffness = _ANGULAR_STIFFNESS + joint.constraint_projection_policy = policy + return joint + + +def build() -> SceneSetup: + world = sx.World(time_step=_TIME_STEP, gravity=(0.0, 0.0, 0.0)) + world.multibody_options = sx.MultibodyOptions( + integration_family=sx.MultibodyIntegrationFamily.VARIATIONAL + ) + + robot = world.add_multibody("avbd_articulated_compliant_joints") + base = robot.add_link("avbd_articulated_compliant_base") + bodies: dict[str, sx.Link] = {} + joints: dict[str, sx.Joint] = {} + types = { + "spherical": sx.JointType.SPHERICAL, + "revolute": sx.JointType.REVOLUTE, + "prismatic": sx.JointType.PRISMATIC, + } + axes = { + "spherical": (0.0, 0.0, 1.0), + "revolute": (0.0, 0.0, 1.0), + "prismatic": (1.0, 0.0, 0.0), + } + + for name in ("spherical", "revolute", "prismatic"): + body = robot.add_link( + f"avbd_articulated_compliant_{name}_body", + parent=base, + joint=sx.JointSpec( + name=f"avbd_articulated_compliant_{name}_floating", + type=sx.JointType.FLOATING, + ), + ) + body.mass = 2.0 + body.inertia = ((0.2, 0.0, 0.0), (0.0, 0.25, 0.0), (0.0, 0.0, 0.3)) + body.parent_joint.position = [*_ANCHORS[name], 0.0, 0.0, 0.0] + body.parent_joint.velocity = _INITIAL_VELOCITIES[name].tolist() + + joint = world.add_joint( + body, + sx.JointSpec( + name=f"avbd_articulated_compliant_{name}_joint", + type=types[name], + axis=axes[name], + parent_anchor=_ANCHORS[name].tolist(), + child_anchor=(0.0, 0.0, 0.0), + ), + ) + joint.actuator_type = sx.ActuatorType.PASSIVE + bodies[name] = body + joints[name] = _configure_finite_joint(joint) + + world.enter_simulation_mode() + + bridge = WorldRenderBridge( + world, name="avbd_articulated_compliant_joints_render" + ) + body_shapes = { + "spherical": dart.BoxShape(np.array([0.72, 0.30, 0.22])), + "revolute": dart.BoxShape(np.array([0.90, 0.20, 0.20])), + "prismatic": dart.BoxShape(np.array([0.54, 0.34, 0.24])), + } + colors = { + "spherical": (0.25, 0.66, 0.92), + "revolute": (0.95, 0.61, 0.16), + "prismatic": (0.34, 0.73, 0.39), + } + for name, body in bodies.items(): + bridge.add_link_visual( + body, + body_shapes[name], + colors[name], + name=f"avbd_articulated_compliant_{name}_visual", + ) + + for name, anchor in _ANCHORS.items(): + marker = dart.SimpleFrame( + dart.gui.world_render_frame(), + f"avbd_articulated_compliant_{name}_anchor", + _translation(anchor), + ) + marker.set_shape(dart.SphereShape(0.075)) + marker.create_visual_aspect().set_color([0.82, 0.82, 0.78]) + bridge.render_world.add_simple_frame(marker) + + hinge_axis = dart.SimpleFrame( + dart.gui.world_render_frame(), + "avbd_articulated_compliant_hinge_axis", + _translation(_ANCHORS["revolute"]), + ) + hinge_axis.set_shape(dart.CylinderShape(0.025, 1.0)) + hinge_axis.create_visual_aspect().set_color([0.92, 0.38, 0.18]) + bridge.render_world.add_simple_frame(hinge_axis) + + slider_rail = dart.SimpleFrame( + dart.gui.world_render_frame(), + "avbd_articulated_compliant_slider_rail", + _translation(_ANCHORS["prismatic"]), + ) + slider_rail.set_shape(dart.BoxShape(np.array([2.4, 0.035, 0.035]))) + slider_rail.create_visual_aspect().set_color([0.72, 0.76, 0.82]) + bridge.render_world.add_simple_frame(slider_rail) + + initial_rotations = { + name: np.asarray(body.rotation, dtype=float).reshape(3, 3).copy() + for name, body in bodies.items() + } + replay_state = {"enabled": True, "last_reset": 0.0, "cycles": 0} + + def reset_joints() -> None: + for name, body in bodies.items(): + body.parent_joint.position = [*_ANCHORS[name], 0.0, 0.0, 0.0] + body.parent_joint.velocity = _INITIAL_VELOCITIES[name].tolist() + replay_state["last_reset"] = float(world.time) + replay_state["cycles"] = int(replay_state["cycles"]) + 1 + bridge.sync() + + def metrics() -> dict[str, float]: + spherical_rotation = np.asarray( + bodies["spherical"].rotation, dtype=float + ).reshape(3, 3) + revolute_rotation = np.asarray( + bodies["revolute"].rotation, dtype=float + ).reshape(3, 3) + prismatic_rotation = np.asarray( + bodies["prismatic"].rotation, dtype=float + ).reshape(3, 3) + socket_displacement = ( + np.asarray(bodies["spherical"].translation, dtype=float).reshape(3) + - _ANCHORS["spherical"] + ) + hinge_displacement = ( + np.asarray(bodies["revolute"].translation, dtype=float).reshape(3) + - _ANCHORS["revolute"] + ) + slider_displacement = ( + np.asarray(bodies["prismatic"].translation, dtype=float).reshape(3) + - _ANCHORS["prismatic"] + ) + return { + "socket_anchor_error": float(np.linalg.norm(socket_displacement)), + "socket_free_rotation": _rotation_angle( + spherical_rotation @ initial_rotations["spherical"].T + ), + "hinge_anchor_error": float(np.linalg.norm(hinge_displacement)), + "hinge_axis_tilt": float( + np.linalg.norm( + revolute_rotation @ np.array([0.0, 0.0, 1.0]) + - np.array([0.0, 0.0, 1.0]) + ) + ), + "hinge_free_rotation": float( + abs(np.arctan2(revolute_rotation[1, 0], revolute_rotation[0, 0])) + ), + "slider_axis_travel": float(slider_displacement[0]), + "slider_transverse_error": float( + np.linalg.norm(slider_displacement[1:]) + ), + "slider_rotation_error": _rotation_angle( + prismatic_rotation @ initial_rotations["prismatic"].T + ), + } + + def replay_capture_state() -> dict[str, object]: + return dict(replay_state) + + def replay_restore_state(state: dict[str, object]) -> None: + replay_state["enabled"] = bool(state.get("enabled", True)) + replay_state["last_reset"] = float(state.get("last_reset", 0.0)) + replay_state["cycles"] = int(state.get("cycles", 0)) + bridge.sync() + + def pre_step() -> None: + if ( + bool(replay_state["enabled"]) + and world.time - float(replay_state["last_reset"]) + >= _AUTO_RESET_SECONDS + ): + reset_joints() + bridge.pre_step() + + bridge.sync() + + socket_history: deque[float] = deque(maxlen=160) + hinge_history: deque[float] = deque(maxlen=160) + slider_history: deque[float] = deque(maxlen=160) + + def build_panel(builder: object, context: object) -> None: + values = metrics() + socket_history.append(values["socket_free_rotation"]) + hinge_history.append(values["hinge_free_rotation"]) + slider_history.append(values["slider_axis_travel"]) + + builder.text("solver: AVBD articulated finite-stiffness joint rows") + builder.text( + "rows: spherical translation; revolute translation/tilt; " + "prismatic transverse/rotation" + ) + builder.text(f"start stiffness: {_START_STIFFNESS:.1f}") + builder.text(f"linear cap: {_LINEAR_STIFFNESS:.1f}") + builder.text(f"angular cap: {_ANGULAR_STIFFNESS:.1f}") + builder.text(f"socket anchor error: {values['socket_anchor_error']:.4f} m") + builder.text( + f"socket free rotation: {values['socket_free_rotation']:.3f} rad" + ) + builder.text(f"hinge anchor error: {values['hinge_anchor_error']:.4f} m") + builder.text(f"hinge axis tilt: {values['hinge_axis_tilt']:.4f}") + builder.text( + f"hinge free rotation: {values['hinge_free_rotation']:.3f} rad" + ) + builder.text(f"slider travel: {values['slider_axis_travel']:.3f} m") + builder.text( + f"slider transverse error: {values['slider_transverse_error']:.4f} m" + ) + builder.text( + f"slider rotation error: {values['slider_rotation_error']:.4f} rad" + ) + builder.text(f"replay cycles: {int(replay_state['cycles'])}") + if builder.button("Reset joint trio"): + reset_joints() + changed, enabled = builder.checkbox( + "Loop replay", + bool(replay_state["enabled"]), + ) + if changed: + replay_state["enabled"] = bool(enabled) + replay_state["last_reset"] = float(world.time) + builder.plot_lines("Socket free rotation", list(socket_history)) + builder.plot_lines("Hinge free rotation", list(hinge_history)) + builder.plot_lines("Slider axis travel", list(slider_history)) + builder.separator() + bridge.build_control_panel(builder, context) + + return SceneSetup( + world=bridge.render_world, + pre_step=pre_step, + force_drag=bridge.force_drag, + panels=[ScenePanel("AVBD Articulated Compliant Joints", build_panel)], + info={ + "sx_world": world, + "base": base, + "bodies": bodies, + "joints": joints, + "anchors": {name: anchor.copy() for name, anchor in _ANCHORS.items()}, + "start_stiffness": _START_STIFFNESS, + "linear_stiffness": _LINEAR_STIFFNESS, + "angular_stiffness": _ANGULAR_STIFFNESS, + "auto_reset_seconds": _AUTO_RESET_SECONDS, + "metrics": metrics, + "reset_joints": reset_joints, + "replay_state": replay_state, + "replay_capture_state": replay_capture_state, + "replay_restore_state": replay_restore_state, + }, + ) + + +SCENE = PythonDemoScene( + id="avbd_articulated_compliant_joints", + title="AVBD Articulated Compliant Joints (sx)", + category="AVBD Rigid Constraints (sx)", + summary="Finite-stiffness spherical, revolute, and prismatic articulated " + "rows resist only their constrained coordinates.", + build=build, +) diff --git a/python/tests/integration/test_demos_cycle.py b/python/tests/integration/test_demos_cycle.py index be2917ae8d370..0f769782a9b6b 100644 --- a/python/tests/integration/test_demos_cycle.py +++ b/python/tests/integration/test_demos_cycle.py @@ -935,6 +935,7 @@ def test_world_scenes_use_solver_focused_categories() -> None: "avbd_articulated_prismatic_pair_motor_breakable_joint", "avbd_articulated_prismatic_motor_breakable_joint", "avbd_articulated_world_revolute_motor_breakable_joint", + "avbd_articulated_compliant_joints", "avbd_articulated_high_ratio_chain", "avbd_rigid_breakable_joint", "avbd_rigid_spherical_breakable_joint", @@ -11852,6 +11853,70 @@ def step(n: int) -> None: ) +def test_avbd_articulated_compliant_joints_demo_preserves_free_coordinates() -> None: + import numpy as np + + sx = _require_simulation_experimental_symbols("World", "MultibodyOptions") + + from examples.demos.scenes.avbd_articulated_compliant_joints import build + + setup = build() + sx_world = setup.info["sx_world"] + bodies = setup.info["bodies"] + joints = setup.info["joints"] + metrics = setup.info["metrics"] + + assert ( + sx_world.multibody_options.integration_family + == sx.MultibodyIntegrationFamily.VARIATIONAL + ) + assert set(joints) == {"spherical", "revolute", "prismatic"} + assert joints["spherical"].type == sx.JointType.SPHERICAL + assert joints["revolute"].type == sx.JointType.REVOLUTE + assert joints["prismatic"].type == sx.JointType.PRISMATIC + assert all(joint.actuator_type == sx.ActuatorType.PASSIVE for joint in joints.values()) + assert all( + joint.constraint_projection_policy.start_stiffness + == pytest.approx(setup.info["start_stiffness"]) + for joint in joints.values() + ) + assert all( + joint.constraint_projection_policy.linear_stiffness + == pytest.approx(setup.info["linear_stiffness"]) + for joint in joints.values() + ) + assert all( + joint.constraint_projection_policy.angular_stiffness + == pytest.approx(setup.info["angular_stiffness"]) + for joint in joints.values() + ) + + setup.info["replay_state"]["enabled"] = False + for _ in range(100): + assert setup.pre_step is not None + setup.pre_step() + setup.world.step() + + values = metrics() + assert all(np.isfinite(value) for value in values.values()) + assert values["socket_anchor_error"] < 0.08 + assert values["socket_free_rotation"] > 0.15 + assert values["hinge_anchor_error"] < 0.06 + assert values["hinge_axis_tilt"] < 0.04 + assert values["hinge_free_rotation"] > 0.12 + assert values["slider_axis_travel"] > 0.15 + assert values["slider_transverse_error"] < 0.06 + assert values["slider_rotation_error"] < 0.05 + + setup.info["reset_joints"]() + reset_values = metrics() + assert all(abs(value) < 1.0e-12 for value in reset_values.values()) + assert all( + np.all(np.isfinite(np.asarray(body.transform, dtype=float))) + for body in bodies.values() + ) + + def test_avbd_articulated_high_ratio_chain_demo_swings_and_resets() -> None: import numpy as np diff --git a/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py b/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py new file mode 100644 index 0000000000000..b2bb46c2b3163 --- /dev/null +++ b/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py @@ -0,0 +1,439 @@ +from __future__ import annotations + +import importlib.util +import json +import struct +import sys +import zlib +from hashlib import sha256 +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ( + ROOT / "scripts" / "write_avbd_articulated_compliant_joints_packet.py" +) + + +def _load_packet_module(): + spec = importlib.util.spec_from_file_location( + "write_avbd_articulated_compliant_joints_packet", + SCRIPT, + ) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _png_chunk(kind: bytes, payload: bytes) -> bytes: + checksum = zlib.crc32(kind + payload) & 0xFFFFFFFF + return ( + struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", checksum) + ) + + +def _write_png(path: Path, width: int = 4, height: int = 3) -> None: + rows = [b"\x00" + (b"\x10\x20\x30" * width) for _ in range(height)] + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + _png_chunk(b"IDAT", zlib.compress(b"".join(rows), 6)) + + _png_chunk(b"IEND", b"") + ) + + +def _write_capture_manifest( + tmp_path: Path, + *, + scene: str = "avbd_articulated_compliant_joints", +) -> Path: + capture = tmp_path / "capture" + frames = capture / "png_frames" + frames.mkdir(parents=True) + screenshot = capture / "avbd_articulated_compliant_joints.png" + _write_png(screenshot) + _write_png(frames / "frame_000001.png") + _write_png(frames / "frame_000002.png") + manifest = { + "artifacts": { + "events": None, + "frames": str(frames), + "screenshot": str(screenshot), + }, + "force_drag": None, + "scene": scene, + "schema_version": 1, + "show_ui": False, + "switch_frame": None, + "switch_scene": None, + "ui_ready": { + "dropped_warmup_frames": 0, + "required": False, + }, + } + path = capture / "manifest.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + return path + + +def _sha256(path: Path) -> str: + return sha256(path.read_bytes()).hexdigest() + + +def _write_image_verdict(tmp_path: Path) -> Path: + path = tmp_path / "capture" / "image_verdict.json" + path.write_text( + json.dumps( + { + "checks": { + "contrast": {"pass": False}, + "non_blank": {"pass": True}, + }, + "image": { + "height": 3, + "path": "avbd_articulated_compliant_joints.png", + "width": 4, + }, + "machine_scope": "pixel-integrity", + "metadata": { + "scene": "avbd_articulated_compliant_joints", + "view": "three-quarter", + }, + "pass": True, + "schema_version": "dart.image_verdict/v1", + } + ), + encoding="utf-8", + ) + return path + + +def _write_visual_review(tmp_path: Path) -> Path: + capture = tmp_path / "capture" + frames = capture / "png_frames" + selected = { + "start": frames / "frame_000001.png", + "middle": frames / "frame_000001.png", + "end": frames / "frame_000002.png", + } + path = capture / "visual_review.json" + path.write_text( + json.dumps( + { + "inspected_frames": [ + { + "file": f"png_frames/{frame.name}", + "role": role, + "sha256": _sha256(frame), + } + for role, frame in selected.items() + ], + "limitations": "Images corroborate bounded motion but do not measure row residuals.", + "observations": "All three bodies remain finite and aligned with their visible guides.", + "reviewer_capability": "native image input", + "scene": "avbd_articulated_compliant_joints", + "schema_version": "dart.visual_semantic_review/v1", + "verdict": "pass", + } + ), + encoding="utf-8", + ) + return path + + +def _write_benchmark_json( + tmp_path: Path, + *, + missing_arg: int | None = None, + bad_compliant_count_arg: int | None = None, +) -> Path: + rows = [] + for arg in (1, 4, 16): + if arg == missing_arg: + continue + compliant_joints = 3 * arg + if arg == bad_compliant_count_arg: + compliant_joints += 1 + rows.append( + { + "aggregate_name": "median", + "compliant_joints": float(compliant_joints), + "cpu_time": float(1000 * arg), + "family_instances": float(arg), + "iterations": 10, + "name": f"BM_AvbdArticulatedCompliantJointStep/{arg}_median", + "prismatic_joints": float(arg), + "real_time": float(1100 * arg), + "revolute_joints": float(arg), + "run_name": f"BM_AvbdArticulatedCompliantJointStep/{arg}", + "run_type": "aggregate", + "spherical_joints": float(arg), + "time_unit": "ns", + } + ) + benchmark = { + "benchmarks": rows, + "context": { + "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "v1.9.5", + "mhz_per_cpu": 5300, + "num_cpus": 32, + }, + } + path = tmp_path / "benchmark.json" + path.write_text(json.dumps(benchmark), encoding="utf-8") + return path + + +def _write_exact_parent_mutation( + tmp_path: Path, + *, + parent_commit: str = "0b0154573b842c142d31ef9feef4567c5677c4fd", +) -> Path: + path = tmp_path / "exact_parent_mutation.json" + path.write_text( + json.dumps( + { + "schema_version": "dart.exact_parent_mutation/v1", + "parent_commit": parent_commit, + "test": ( + "VariationalIntegration." + "AvbdCompliantPublicArticulatedJointMasks" + "ResistOnlyConstrainedMotion" + ), + "mutation": ( + "Apply the candidate behavior test to the exact parent " + "without the solver implementation." + ), + "command": ( + "./test_variational_integration " + "--gtest_filter=VariationalIntegration." + "AvbdCompliantPublicArticulatedJointMasks" + "ResistOnlyConstrainedMotion" + ), + "exit_code": 1, + "expected_result": "fail", + "observed_result": "fail", + "failed_assertions": [ + { + "coordinate": coordinate, + "actual": 1.0, + "upper_bound": 0.5, + } + for coordinate in ( + "spherical constrained translation", + "revolute constrained translation", + "revolute constrained axis tilt", + "prismatic constrained transverse translation", + "prismatic constrained rotation", + ) + ], + "interpretation": ( + "The exact parent skips the finite articulated rows." + ), + } + ), + encoding="utf-8", + ) + return path + + +def test_avbd_articulated_compliant_joints_packet_records_evidence( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest(tmp_path) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path) + exact_parent_mutation = _write_exact_parent_mutation(tmp_path) + output = tmp_path / "packet.json" + + assert ( + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + "--output", + str(output), + ] + ) + == 0 + ) + + packet = json.loads(output.read_text()) + assert packet["schema_version"] == 2 + assert packet["scene"] == "avbd_articulated_compliant_joints" + assert packet["resolved_solver_identity"] == { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": ( + "contact-free py-demo and articulated compliant-joint " + "benchmark row family" + ), + "rigid_contact_solver": "none", + "rigid_point_joint_solver": "avbd", + } + assert packet["scene_invariants"]["joint_types"] == [ + "spherical", + "revolute", + "prismatic", + ] + assert packet["visual_capture"]["frames"]["count"] == 2 + assert packet["benchmark"]["benchmark"] == ( + "BM_AvbdArticulatedCompliantJointStep" + ) + assert [row["compliant_joints"] for row in packet["benchmark"]["scale_data"]] == [ + 3, + 12, + 48, + ] + assert packet["benchmark"]["scale_data"][0]["cpu_time_per_joint_ns"] == ( + pytest.approx(1000.0 / 3.0) + ) + assert packet["correctness"]["exact_parent_mutation"]["parent_commit"] == ( + "0b0154573b842c142d31ef9feef4567c5677c4fd" + ) + assert packet["correctness"]["exact_parent_mutation"]["observed_result"] == ( + "fail" + ) + assert "finite articulated one-DOF velocity-motor coupling" in packet[ + "remaining_gates" + ] + + +def test_avbd_articulated_compliant_joints_packet_rejects_wrong_scene( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest( + tmp_path, scene="avbd_articulated_high_ratio_chain" + ) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path) + exact_parent_mutation = _write_exact_parent_mutation(tmp_path) + + with pytest.raises( + SystemExit, + match="capture scene must be avbd_articulated_compliant_joints", + ): + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + ] + ) + + +def test_avbd_articulated_compliant_joints_packet_rejects_missing_scale_row( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest(tmp_path) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path, missing_arg=4) + exact_parent_mutation = _write_exact_parent_mutation(tmp_path) + + with pytest.raises( + SystemExit, + match="missing compliant-joint scale rows: 4", + ): + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + ] + ) + + +def test_avbd_articulated_compliant_joints_packet_rejects_wrong_counter( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest(tmp_path) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path, bad_compliant_count_arg=16) + exact_parent_mutation = _write_exact_parent_mutation(tmp_path) + + with pytest.raises( + SystemExit, + match="expected compliant_joints=48, got 49", + ): + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + ] + ) + + +def test_avbd_articulated_compliant_joints_packet_rejects_wrong_parent( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest(tmp_path) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path) + exact_parent_mutation = _write_exact_parent_mutation( + tmp_path, parent_commit="deadbeef" + ) + + with pytest.raises( + SystemExit, + match="exact-parent mutation parent_commit must be 0b0154573b8", + ): + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + ] + ) diff --git a/scripts/write_avbd_articulated_compliant_joints_packet.py b/scripts/write_avbd_articulated_compliant_joints_packet.py new file mode 100644 index 0000000000000..8924df62ed79c --- /dev/null +++ b/scripts/write_avbd_articulated_compliant_joints_packet.py @@ -0,0 +1,663 @@ +#!/usr/bin/env python3 +"""Write a validated AVBD articulated compliant-joint evidence packet.""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from avbd_packet_schema import ( # noqa: E402 + AVBD_PACKET_SCHEMA_VERSION, + make_resolved_solver_identity, +) +from write_avbd_demo3d_static_friction_packet import ( # noqa: E402 + _artifact_label, + _artifact_path, + _canonical_name, + _load_json, + _png_dimensions, + _row_name, + _sha256, +) + +DEFAULT_OUTPUT = Path( + "docs/plans/104-vertex-block-descent-solver/" + "avbd-articulated-compliant-joints-packet.json" +) +SCENE_ID = "avbd_articulated_compliant_joints" +BENCHMARK_NAME = "BM_AvbdArticulatedCompliantJointStep" +BENCHMARK_ARGS = (1, 4, 16) +EXACT_PARENT_COMMIT = "0b0154573b842c142d31ef9feef4567c5677c4fd" +BEHAVIOR_TEST = ( + "VariationalIntegration." + "AvbdCompliantPublicArticulatedJointMasksResistOnlyConstrainedMotion" +) +TIME_STEP = 0.002 +START_STIFFNESS = 10.0 +LINEAR_STIFFNESS = 1000.0 +ANGULAR_STIFFNESS = 1000.0 +RESOLVED_SOLVER_IDENTITY = make_resolved_solver_identity( + resolved_rigid_contact_family=None, + rigid_point_joint_solver="avbd", + avbd_rigid_contact_config_emplaced=False, + recorded_from=( + "contact-free py-demo and articulated compliant-joint benchmark row family" + ), +) + + +class AvbdArticulatedCompliantJointsPacketError(RuntimeError): + pass + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--capture-manifest", type=Path, required=True) + parser.add_argument("--image-verdict-json", type=Path, required=True) + parser.add_argument("--visual-review-json", type=Path, required=True) + parser.add_argument("--benchmark-json", type=Path, required=True) + parser.add_argument("--exact-parent-mutation-json", type=Path, required=True) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + return parser.parse_args(argv) + + +def _capture_artifact_path( + manifest_dir: Path, + value: object, + key: str, +) -> Path: + if isinstance(value, str) and value: + path = Path(value) + if path.is_absolute(): + return path + repo_relative = SCRIPT_DIR.parent / path + if repo_relative.exists(): + return repo_relative + return _artifact_path(manifest_dir, value, key) + + +def _validate_capture( + manifest_path: Path, +) -> tuple[dict[str, Any], Path, dict[str, Path]]: + manifest = _load_json(manifest_path) + if manifest.get("schema_version") != 1: + raise AvbdArticulatedCompliantJointsPacketError( + "capture manifest schema_version must be 1" + ) + if manifest.get("scene") != SCENE_ID: + raise AvbdArticulatedCompliantJointsPacketError( + f"capture scene must be {SCENE_ID}" + ) + if manifest.get("switch_scene") is not None: + raise AvbdArticulatedCompliantJointsPacketError( + "compliant-joint capture must not switch scenes" + ) + if manifest.get("force_drag") is not None: + raise AvbdArticulatedCompliantJointsPacketError( + "compliant-joint capture must not force-drag" + ) + + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict): + raise AvbdArticulatedCompliantJointsPacketError( + "capture manifest missing artifacts" + ) + manifest_dir = manifest_path.parent + screenshot = _capture_artifact_path( + manifest_dir, artifacts.get("screenshot"), "screenshot" + ) + frames_dir = _capture_artifact_path(manifest_dir, artifacts.get("frames"), "frames") + if not screenshot.is_file(): + raise AvbdArticulatedCompliantJointsPacketError( + f"{screenshot}: screenshot not found" + ) + if not frames_dir.is_dir(): + raise AvbdArticulatedCompliantJointsPacketError( + f"{frames_dir}: frame directory not found" + ) + + frame_paths = sorted(frames_dir.glob("frame_*.png")) + if not frame_paths: + raise AvbdArticulatedCompliantJointsPacketError( + f"{frames_dir}: no PNG frames found" + ) + width, height = _png_dimensions(screenshot) + if _png_dimensions(frame_paths[0]) != (width, height): + raise AvbdArticulatedCompliantJointsPacketError( + "first frame dimensions do not match screenshot" + ) + if _png_dimensions(frame_paths[-1]) != (width, height): + raise AvbdArticulatedCompliantJointsPacketError( + "last frame dimensions do not match screenshot" + ) + ui_ready = manifest.get("ui_ready") + if not isinstance(ui_ready, dict): + raise AvbdArticulatedCompliantJointsPacketError( + "capture manifest missing ui_ready" + ) + + middle_frame = frame_paths[(len(frame_paths) - 1) // 2] + return ( + { + "manifest_sha256": _sha256(manifest_path), + "scene": SCENE_ID, + "show_ui": bool(manifest.get("show_ui")), + "ui_ready": ui_ready, + "screenshot": { + "file": _artifact_label(manifest_dir, screenshot), + "sha256": _sha256(screenshot), + "width": width, + "height": height, + }, + "frames": { + "directory": _artifact_label(manifest_dir, frames_dir), + "count": len(frame_paths), + "first_frame": { + "file": _artifact_label(manifest_dir, frame_paths[0]), + "sha256": _sha256(frame_paths[0]), + }, + "last_frame": { + "file": _artifact_label(manifest_dir, frame_paths[-1]), + "sha256": _sha256(frame_paths[-1]), + }, + }, + }, + screenshot, + { + "start": frame_paths[0], + "middle": middle_frame, + "end": frame_paths[-1], + }, + ) + + +def _validate_image_verdict( + verdict_path: Path, + screenshot: Path, +) -> dict[str, Any]: + verdict = _load_json(verdict_path) + if verdict.get("schema_version") != "dart.image_verdict/v1": + raise AvbdArticulatedCompliantJointsPacketError( + "image verdict schema_version must be dart.image_verdict/v1" + ) + if verdict.get("pass") is not True: + raise AvbdArticulatedCompliantJointsPacketError("image verdict must pass") + checks = verdict.get("checks") + if not isinstance(checks, dict): + raise AvbdArticulatedCompliantJointsPacketError("image verdict missing checks") + non_blank = checks.get("non_blank") + if not isinstance(non_blank, dict) or non_blank.get("pass") is not True: + raise AvbdArticulatedCompliantJointsPacketError( + "image verdict non_blank check must pass" + ) + image = verdict.get("image") + if not isinstance(image, dict): + raise AvbdArticulatedCompliantJointsPacketError( + "image verdict missing image metadata" + ) + if ( + image.get("width") != _png_dimensions(screenshot)[0] + or image.get("height") != _png_dimensions(screenshot)[1] + ): + raise AvbdArticulatedCompliantJointsPacketError( + "image verdict dimensions do not match capture screenshot" + ) + return { + "file": verdict_path.name, + "sha256": _sha256(verdict_path), + "machine_scope": verdict.get("machine_scope"), + "metadata": verdict.get("metadata"), + "checks": checks, + "pass": True, + } + + +def _validate_visual_review( + review_path: Path, + manifest_path: Path, + inspected_frames: dict[str, Path], +) -> dict[str, Any]: + review = _load_json(review_path) + if review.get("schema_version") != "dart.visual_semantic_review/v1": + raise AvbdArticulatedCompliantJointsPacketError( + "visual review schema_version must be " "dart.visual_semantic_review/v1" + ) + if review.get("scene") != SCENE_ID: + raise AvbdArticulatedCompliantJointsPacketError( + f"visual review scene must be {SCENE_ID}" + ) + if review.get("verdict") != "pass": + raise AvbdArticulatedCompliantJointsPacketError( + "visual semantic review must pass" + ) + observations = review.get("observations") + limitations = review.get("limitations") + if not isinstance(observations, str) or not observations.strip(): + raise AvbdArticulatedCompliantJointsPacketError( + "visual review observations must be non-empty" + ) + if not isinstance(limitations, str) or not limitations.strip(): + raise AvbdArticulatedCompliantJointsPacketError( + "visual review limitations must be non-empty" + ) + + entries = review.get("inspected_frames") + if not isinstance(entries, list): + raise AvbdArticulatedCompliantJointsPacketError( + "visual review inspected_frames must be a list" + ) + by_role = { + entry.get("role"): entry + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("role"), str) + } + manifest_dir = manifest_path.parent + validated_frames = [] + for role, frame_path in inspected_frames.items(): + entry = by_role.get(role) + if entry is None: + raise AvbdArticulatedCompliantJointsPacketError( + f"visual review missing {role} frame" + ) + expected_file = _artifact_label(manifest_dir, frame_path) + if entry.get("file") != expected_file: + raise AvbdArticulatedCompliantJointsPacketError( + f"visual review {role} frame must be {expected_file}" + ) + expected_sha = _sha256(frame_path) + if entry.get("sha256") != expected_sha: + raise AvbdArticulatedCompliantJointsPacketError( + f"visual review {role} frame sha256 mismatch" + ) + validated_frames.append( + { + "role": role, + "file": expected_file, + "sha256": expected_sha, + } + ) + return { + "file": review_path.name, + "sha256": _sha256(review_path), + "reviewer_capability": review.get("reviewer_capability"), + "inspected_frames": validated_frames, + "observations": observations, + "limitations": limitations, + "verdict": "pass", + } + + +def _finite_counter(row: dict[str, Any], key: str) -> float: + value = row.get(key) + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise AvbdArticulatedCompliantJointsPacketError( + f"{BENCHMARK_NAME}: missing {key}" + ) + value = float(value) + if not math.isfinite(value): + raise AvbdArticulatedCompliantJointsPacketError( + f"{BENCHMARK_NAME}: non-finite {key}" + ) + return value + + +def _require_counter(row: dict[str, Any], key: str, expected: float) -> None: + value = _finite_counter(row, key) + if not math.isclose(value, expected, rel_tol=0.0, abs_tol=1e-15): + raise AvbdArticulatedCompliantJointsPacketError( + f"{BENCHMARK_NAME}: expected {key}={expected:g}, got {value:g}" + ) + + +def _benchmark_arg(row: dict[str, Any]) -> int | None: + identity = _canonical_name(_row_name(row)) + prefix = f"{BENCHMARK_NAME}/" + if not identity.startswith(prefix): + return None + arg_text = identity[len(prefix) :] + try: + arg = int(arg_text) + except ValueError as exc: + raise AvbdArticulatedCompliantJointsPacketError( + f"{BENCHMARK_NAME}: non-integer benchmark argument {arg_text!r}" + ) from exc + if arg not in BENCHMARK_ARGS: + raise AvbdArticulatedCompliantJointsPacketError( + f"{BENCHMARK_NAME}: unexpected family_instances argument {arg}" + ) + return arg + + +def _is_representative(row: dict[str, Any]) -> bool: + run_type = row.get("run_type", "iteration") + aggregate_name = row.get("aggregate_name") + return run_type == "iteration" or aggregate_name in ("mean", "median") + + +def _timing_row(rows: list[dict[str, Any]]) -> dict[str, Any]: + median_rows = [row for row in rows if row.get("aggregate_name") == "median"] + if median_rows: + return median_rows[0] + mean_rows = [row for row in rows if row.get("aggregate_name") == "mean"] + if mean_rows: + return mean_rows[0] + return rows[0] + + +def _validate_benchmark(benchmark_json: Path) -> dict[str, Any]: + data = _load_json(benchmark_json) + rows = data.get("benchmarks") + if not isinstance(rows, list): + raise AvbdArticulatedCompliantJointsPacketError( + "benchmark JSON missing benchmarks list" + ) + + representative: dict[int, list[dict[str, Any]]] = { + arg: [] for arg in BENCHMARK_ARGS + } + packet_rows: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + arg = _benchmark_arg(row) + if arg is None: + continue + for key in ("real_time", "cpu_time"): + _finite_counter(row, key) + packet_rows.append(row) + if not _is_representative(row): + continue + _require_counter(row, "family_instances", float(arg)) + _require_counter(row, "spherical_joints", float(arg)) + _require_counter(row, "revolute_joints", float(arg)) + _require_counter(row, "prismatic_joints", float(arg)) + _require_counter(row, "compliant_joints", float(3 * arg)) + representative[arg].append(row) + + missing = [arg for arg in BENCHMARK_ARGS if not representative[arg]] + if missing: + raise AvbdArticulatedCompliantJointsPacketError( + "benchmark JSON missing compliant-joint scale rows: " + + ", ".join(str(arg) for arg in missing) + ) + + scale_data = [] + for arg in BENCHMARK_ARGS: + row = _timing_row(representative[arg]) + compliant_joints = 3 * arg + cpu_time = _finite_counter(row, "cpu_time") + real_time = _finite_counter(row, "real_time") + scale_data.append( + { + "benchmark": f"{BENCHMARK_NAME}/{arg}", + "family_instances": arg, + "compliant_joints": compliant_joints, + "cpu_time_per_step_ns": cpu_time, + "real_time_per_step_ns": real_time, + "cpu_time_per_joint_ns": cpu_time / compliant_joints, + "time_unit": row.get("time_unit", "ns"), + } + ) + + context = data.get("context", {}) + if not isinstance(context, dict): + context = {} + return { + "json_sha256": _sha256(benchmark_json), + "benchmark": BENCHMARK_NAME, + "context": { + key: context[key] + for key in ( + "executable", + "num_cpus", + "mhz_per_cpu", + "library_version", + "library_build_type", + "json_schema_version", + ) + if key in context + }, + "rows": packet_rows, + "scale_data": scale_data, + "invariants": { + "family_instances": list(BENCHMARK_ARGS), + "joint_types": ["spherical", "revolute", "prismatic"], + "time_step": TIME_STEP, + "start_stiffness": START_STIFFNESS, + "linear_stiffness": LINEAR_STIFFNESS, + "angular_stiffness": ANGULAR_STIFFNESS, + }, + } + + +def _validate_exact_parent_mutation(path: Path) -> dict[str, Any]: + evidence = _load_json(path) + if evidence.get("schema_version") != "dart.exact_parent_mutation/v1": + raise AvbdArticulatedCompliantJointsPacketError( + "exact-parent mutation schema_version must be " + "dart.exact_parent_mutation/v1" + ) + if evidence.get("parent_commit") != EXACT_PARENT_COMMIT: + raise AvbdArticulatedCompliantJointsPacketError( + f"exact-parent mutation parent_commit must be {EXACT_PARENT_COMMIT}" + ) + if evidence.get("test") != BEHAVIOR_TEST: + raise AvbdArticulatedCompliantJointsPacketError( + f"exact-parent mutation test must be {BEHAVIOR_TEST}" + ) + if evidence.get("exit_code") != 1: + raise AvbdArticulatedCompliantJointsPacketError( + "exact-parent mutation exit_code must be 1" + ) + if ( + evidence.get("expected_result") != "fail" + or evidence.get("observed_result") != "fail" + ): + raise AvbdArticulatedCompliantJointsPacketError( + "exact-parent mutation must record expected and observed failure" + ) + + assertions = evidence.get("failed_assertions") + if not isinstance(assertions, list) or len(assertions) != 5: + raise AvbdArticulatedCompliantJointsPacketError( + "exact-parent mutation must record five constrained-row failures" + ) + validated_assertions = [] + for assertion in assertions: + if not isinstance(assertion, dict): + raise AvbdArticulatedCompliantJointsPacketError( + "exact-parent mutation assertions must be objects" + ) + coordinate = assertion.get("coordinate") + actual = assertion.get("actual") + upper_bound = assertion.get("upper_bound") + if not isinstance(coordinate, str) or not coordinate.strip(): + raise AvbdArticulatedCompliantJointsPacketError( + "exact-parent mutation assertion coordinate must be non-empty" + ) + if ( + not isinstance(actual, (int, float)) + or isinstance(actual, bool) + or not math.isfinite(float(actual)) + or not isinstance(upper_bound, (int, float)) + or isinstance(upper_bound, bool) + or not math.isfinite(float(upper_bound)) + or float(actual) <= float(upper_bound) + ): + raise AvbdArticulatedCompliantJointsPacketError( + "exact-parent mutation assertions must record finite " + "actual values above their upper bounds" + ) + validated_assertions.append( + { + "coordinate": coordinate, + "actual": float(actual), + "upper_bound": float(upper_bound), + } + ) + + mutation = evidence.get("mutation") + command = evidence.get("command") + interpretation = evidence.get("interpretation") + for label, value in ( + ("mutation", mutation), + ("command", command), + ("interpretation", interpretation), + ): + if not isinstance(value, str) or not value.strip(): + raise AvbdArticulatedCompliantJointsPacketError( + f"exact-parent mutation {label} must be non-empty" + ) + return { + "file": path.name, + "sha256": _sha256(path), + "parent_commit": EXACT_PARENT_COMMIT, + "test": BEHAVIOR_TEST, + "mutation": mutation, + "command": command, + "exit_code": 1, + "observed_result": "fail", + "failed_assertions": validated_assertions, + "interpretation": interpretation, + } + + +def make_packet( + capture_manifest: Path, + image_verdict_json: Path, + visual_review_json: Path, + benchmark_json: Path, + exact_parent_mutation_json: Path, +) -> dict[str, Any]: + visual_capture, screenshot, inspected_frames = _validate_capture(capture_manifest) + visual_capture["image_verdict"] = _validate_image_verdict( + image_verdict_json, screenshot + ) + visual_capture["semantic_review"] = _validate_visual_review( + visual_review_json, capture_manifest, inspected_frames + ) + return { + "schema_version": AVBD_PACKET_SCHEMA_VERSION, + "resolved_solver_identity": RESOLVED_SOLVER_IDENTITY, + "packet": "avbd_articulated_compliant_joints", + "scene": SCENE_ID, + "target": { + "contract_rows": [ + "avbd.method.joints_and_attachments", + "avbd.method.finite_stiffness_ramping", + ], + "scope": ( + "passive finite-stiffness articulated spherical, revolute, " + "and prismatic masked rows on CPU" + ), + "complete_paper_reproduction": False, + }, + "scene_invariants": { + "joint_types": ["spherical", "revolute", "prismatic"], + "free_coordinates": { + "spherical": "rotation", + "revolute": "rotation about the joint axis", + "prismatic": "translation along the joint axis", + }, + "time_step": TIME_STEP, + "start_stiffness": START_STIFFNESS, + "linear_stiffness": LINEAR_STIFFNESS, + "angular_stiffness": ANGULAR_STIFFNESS, + }, + "correctness": { + "cpp_behavior_test": BEHAVIOR_TEST, + "cpp_multi_dof_inverse_test": ( + "VariationalIntegration." + "ArticulatedInverseMassMultiDofMatchesDenseSolve" + ), + "python_scene_test": ( + "test_avbd_articulated_compliant_joints_demo_" + "preserves_free_coordinates" + ), + "allocation_tests": [ + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotGrowWorldBaseAllocator", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotAllocateGlobalHeap", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotMallocOnHeap", + ], + "exact_parent_mutation": _validate_exact_parent_mutation( + exact_parent_mutation_json + ), + }, + "visual_capture": visual_capture, + "benchmark": _validate_benchmark(benchmark_json), + "performance_claim_boundary": ( + "Candidate-only finite mechanism cost and scaling. The exact " + "parent intentionally skipped these non-Fixed finite rows, so " + "this packet makes no before/after speedup claim." + ), + "reproduction": { + "capture_command": ( + "LIBGL_ALWAYS_SOFTWARE=1 MESA_LOADER_DRIVER_OVERRIDE=llvmpipe " + "PYTHONPATH=build/default/cpp/Release/python:python " + "BUILD_TYPE=Release python scripts/capture_py_demo.py " + "--scene avbd_articulated_compliant_joints --frames 120 " + "--width 960 --height 540 --view three-quarter " + "--camera-distance 8 --camera-target 0,0,1.2 " + "--output-dir " + ), + "image_verdict_command": ( + "pixi run image-verdict -- /" + "avbd_articulated_compliant_joints.png " + "--out /image_verdict.json " + "--meta scene=avbd_articulated_compliant_joints " + "--meta view=three-quarter" + ), + "benchmark_command": ( + "taskset -c 18 " + "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint " + "--benchmark_filter='BM_AvbdArticulatedCompliantJointStep/" + "(1|4|16)$' --benchmark_min_time=0.05s " + "--benchmark_repetitions=5 " + "--benchmark_report_aggregates_only=true " + "--benchmark_out= " + "--benchmark_out_format=json" + ), + }, + "remaining_gates": [ + "finite articulated one-DOF velocity-motor coupling", + "finite-row break-force accounting and fracture lifecycle", + "same-multibody finite articulated endpoint pairs", + "unified soft/rigid row solve", + "CUDA AVBD row parity and same-hardware benchmark packets", + "source-demo, paper/site/video scene, and achieved-accuracy performance closure", + ], + } + + +def write_packet(path: Path, packet: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(packet, indent=2, sort_keys=True) + "\n") + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + packet = make_packet( + args.capture_manifest, + args.image_verdict_json, + args.visual_review_json, + args.benchmark_json, + args.exact_parent_mutation_json, + ) + except AvbdArticulatedCompliantJointsPacketError as exc: + raise SystemExit(str(exc)) from exc + write_packet(args.output, packet) + print(f"Wrote AVBD articulated compliant-joints packet: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp index 746a4478a2db4..c1d95277515e5 100644 --- a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp +++ b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp @@ -52,6 +52,7 @@ #include #include +#include #include #include #include @@ -2687,6 +2688,82 @@ std::unique_ptr makeArticulatedSphericalPairBreakableJointWorld( return world; } +std::unique_ptr makeArticulatedCompliantJointWorld( + std::size_t familyCount) +{ + constexpr double kStartStiffness = 10.0; + constexpr double kLinearStiffness = 1000.0; + constexpr double kAngularStiffness = 1000.0; + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.timeStep = 0.002; + auto world = std::make_unique(options); + + sx::MultibodyOptions multibodyOptions; + multibodyOptions.integrationFamily + = sx::MultibodyIntegrationFamily::Variational; + world->setMultibodyOptions(multibodyOptions); + + auto robot = world->addMultibody("articulated_compliant_joint_robot"); + auto base = robot.addLink("articulated_compliant_joint_base"); + + std::vector links; + std::vector joints; + links.reserve(3u * familyCount); + joints.reserve(3u * familyCount); + const std::array jointTypes{ + sx::JointType::Spherical, + sx::JointType::Revolute, + sx::JointType::Prismatic}; + for (std::size_t family = 0; family < jointTypes.size(); ++family) { + for (std::size_t i = 0; i < familyCount; ++i) { + const std::size_t flatIndex = family * familyCount + i; + const Eigen::Vector3d anchor( + 0.35 * static_cast(i + 1), + 0.2 * static_cast(family), + 0.04 * static_cast(i % 2)); + + sx::JointSpec floatingSpec; + floatingSpec.name + = "articulated_compliant_float_" + std::to_string(flatIndex); + floatingSpec.type = sx::JointType::Floating; + auto link = robot.addLink( + "articulated_compliant_link_" + std::to_string(flatIndex), + base, + floatingSpec); + link.setMass(1.0 + 0.1 * static_cast(i % 3)); + link.setInertia(Eigen::Vector3d(0.15, 0.2, 0.25).asDiagonal()); + Eigen::VectorXd pose = Eigen::VectorXd::Zero(6); + pose.head<3>() = anchor; + link.getParentJoint().setPosition(pose); + link.getParentJoint().setVelocity( + (Eigen::VectorXd(6) << 0.35, -0.25, 0.2, 0.4, -0.3, 0.5).finished()); + + sx::Joint joint = addPointJoint( + *world, + "articulated_compliant_joint_" + std::to_string(flatIndex), + link, + jointTypes[family], + family == 2u ? Eigen::Vector3d::UnitX() : Eigen::Vector3d::UnitZ(), + anchor, + Eigen::Vector3d::Zero()); + auto policy = joint.getConstraintProjectionPolicy(); + policy.startStiffness = kStartStiffness; + policy.linearStiffness = kLinearStiffness; + policy.angularStiffness = kAngularStiffness; + joint.setConstraintProjectionPolicy(policy); + + links.push_back(link); + joints.push_back(joint); + } + } + + benchmark::DoNotOptimize(links.data()); + benchmark::DoNotOptimize(joints.data()); + return world; +} + constexpr std::size_t kArticulatedHighRatioChainLinks = 5; constexpr double kArticulatedHighRatioLinkLength = 0.45; constexpr double kArticulatedHighRatioLightMass = 1.0; @@ -3808,6 +3885,25 @@ BENCHMARK(BM_AvbdArticulatedSphericalPairBreakableJointStep) ->Arg(8) ->Arg(32); +//============================================================================== +static void BM_AvbdArticulatedCompliantJointStep(benchmark::State& state) +{ + const auto familyCount = static_cast(state.range(0)); + auto world = makeArticulatedCompliantJointWorld(familyCount); + world->enterSimulationMode(); + + for (auto _ : state) { + world->step(); + benchmark::ClobberMemory(); + } + state.counters["family_instances"] = static_cast(familyCount); + state.counters["spherical_joints"] = static_cast(familyCount); + state.counters["revolute_joints"] = static_cast(familyCount); + state.counters["prismatic_joints"] = static_cast(familyCount); + state.counters["compliant_joints"] = static_cast(3u * familyCount); +} +BENCHMARK(BM_AvbdArticulatedCompliantJointStep)->Arg(1)->Arg(4)->Arg(16); + //============================================================================== static void BM_AvbdArticulatedHighRatioChainStep(benchmark::State& state) { diff --git a/tests/unit/simulation/compute/test_variational_integration.cpp b/tests/unit/simulation/compute/test_variational_integration.cpp index 21f1633d751b3..5f261cdde8b49 100644 --- a/tests/unit/simulation/compute/test_variational_integration.cpp +++ b/tests/unit/simulation/compute/test_variational_integration.cpp @@ -590,6 +590,69 @@ TEST(VariationalIntegration, ArticulatedInverseMassMatchesDenseSolve) } } +// The allocation-free fixed-size joint-block solve must preserve the dense +// inverse-mass result for the largest public joint block (Floating, six DOFs) +// and a multi-DOF manifold child (Spherical, three DOFs). +TEST(VariationalIntegration, ArticulatedInverseMassMultiDofMatchesDenseSolve) +{ + sx::World world; + world.setGravity(Eigen::Vector3d::Zero()); + auto robot = world.addMultibody("multi_dof"); + auto base = robot.addLink("base"); + + sx::JointSpec floatingSpec; + floatingSpec.name = "floating"; + floatingSpec.type = sx::JointType::Floating; + auto body = robot.addLink("body", base, floatingSpec); + body.setMass(2.3); + body.setInertia(Eigen::Vector3d(0.17, 0.23, 0.31).asDiagonal()); + + sx::JointSpec sphericalSpec; + sphericalSpec.name = "spherical"; + sphericalSpec.type = sx::JointType::Spherical; + sphericalSpec.transformFromParent.translation() + = Eigen::Vector3d(0.4, -0.2, 0.3); + auto child = robot.addLink("child", body, sphericalSpec); + child.setMass(1.7); + child.setInertia(Eigen::Vector3d(0.11, 0.19, 0.29).asDiagonal()); + + world.enterSimulationMode(); + body.getParentJoint().setPosition( + (Eigen::VectorXd(6) << 0.2, -0.3, 0.4, 0.1, -0.2, 0.3).finished()); + child.getParentJoint().setPosition( + (Eigen::VectorXd(3) << 0.3, -0.4, 0.2).finished()); + world.updateKinematics(); + + auto& registry = dart::simulation::detail::registryOf(world); + const auto& structure = structureOf(world); + const Eigen::MatrixXd massMatrix + = sxc::computeMultibodyDynamicsTerms( + registry, structure, world.getGravity()) + .massMatrix; + ASSERT_EQ(massMatrix.rows(), 9); + const Eigen::LDLT dense(massMatrix); + + std::vector rhs; + rhs.reserve(10); + for (Eigen::Index i = 0; i < 9; ++i) { + Eigen::VectorXd basis = Eigen::VectorXd::Zero(9); + basis[i] = 1.0; + rhs.push_back(std::move(basis)); + } + rhs.push_back( + (Eigen::VectorXd(9) << 0.7, -1.3, 0.9, 0.2, -0.6, 1.1, -0.4, 0.8, -0.5) + .finished()); + + for (const auto& b : rhs) { + const Eigen::VectorXd abi + = sxc::computeMultibodyInverseMassProduct(registry, structure, b); + const Eigen::VectorXd denseSolve = dense.solve(b); + EXPECT_TRUE(abi.isApprox(denseSolve, 1e-9)) + << "b=" << b.transpose() << " abi=" << abi.transpose() + << " dense=" << denseSolve.transpose(); + } +} + // The variational integrator is deterministic: two identical rollouts through // the public World::step() path produce bit-identical final state. TEST(VariationalIntegration, DeterministicAcrossRuns) @@ -2769,6 +2832,163 @@ TEST( EXPECT_LT(stiffAngular.maxYaw, 0.7 * softAngular.maxYaw); } +// PLAN-104 AVBD articulated finite-stiffness bridge: passive public spherical, +// revolute, and prismatic point joints must feed their masked finite rows into +// the variational solve. Each joint resists only its constrained row family and +// leaves the paper-defined free coordinate responsive. +TEST( + VariationalIntegration, + AvbdCompliantPublicArticulatedJointMasksResistOnlyConstrainedMotion) +{ + struct Motion + { + double maxTranslation = 0.0; + double maxAxisTranslation = 0.0; + double maxOrthogonalTranslation = 0.0; + double maxRotation = 0.0; + double maxAxisTilt = 0.0; + double maxAxisRotation = 0.0; + }; + + const auto rollout = [](sx::JointType type, + const Eigen::Vector3d& axis, + const Eigen::Vector3d& force, + const Eigen::Vector3d& torque, + bool compliant) { + sx::World world; + world.setGravity(Eigen::Vector3d::Zero()); + world.setMultibodyOptions( + {.integrationFamily = sx::MultibodyIntegrationFamily::Variational}); + world.setTimeStep(0.002); + + auto robot = world.addMultibody("masked_compliant_joint"); + auto base = robot.addLink("base"); + sx::JointSpec floatingSpec; + floatingSpec.name = "floating"; + floatingSpec.type = sx::JointType::Floating; + auto body = robot.addLink("body", base, floatingSpec); + body.setMass(2.0); + body.setInertia(Eigen::Vector3d(0.2, 0.25, 0.3).asDiagonal()); + + if (compliant) { + sx::Joint joint + = world.addJoint(body, makeJointSpec("finite_joint", type, axis)); + joint.setActuatorType(sx::ActuatorType::Passive); + joint.setConstraintProjectionPolicy( + makeConstraintProjectionPolicy(1000.0, 1000.0, 1000.0)); + } + + world.enterSimulationMode(); + + Motion result; + for (int k = 0; k < 100; ++k) { + body.applyForce(force); + if (torque.squaredNorm() > 0.0) { + const Eigen::Vector3d torqueDirection = torque.normalized(); + const Eigen::Vector3d reference + = std::abs(torqueDirection.dot(Eigen::Vector3d::UnitX())) < 0.9 + ? Eigen::Vector3d::UnitX() + : Eigen::Vector3d::UnitY(); + const Eigen::Vector3d lever + = 0.25 * torqueDirection.cross(reference).normalized(); + const Eigen::Vector3d coupleForce + = torque.cross(lever) / (2.0 * lever.squaredNorm()); + const Eigen::Vector3d center = body.getWorldTransform().translation(); + body.applyForce( + coupleForce, + center + lever, + /*forceInWorldFrame=*/true, + /*pointInWorldFrame=*/true); + body.applyForce( + -coupleForce, + center - lever, + /*forceInWorldFrame=*/true, + /*pointInWorldFrame=*/true); + } + world.step(); + + const Eigen::Isometry3d transform = body.getWorldTransform(); + const Eigen::Vector3d translation = transform.translation(); + const double axisTranslation = translation.dot(axis); + result.maxTranslation + = std::max(result.maxTranslation, translation.norm()); + result.maxAxisTranslation + = std::max(result.maxAxisTranslation, std::abs(axisTranslation)); + result.maxOrthogonalTranslation = std::max( + result.maxOrthogonalTranslation, + (translation - axisTranslation * axis).norm()); + result.maxRotation = std::max( + result.maxRotation, Eigen::AngleAxisd(transform.linear()).angle()); + result.maxAxisTilt = std::max( + result.maxAxisTilt, (transform.linear() * axis - axis).norm()); + result.maxAxisRotation = std::max( + result.maxAxisRotation, + std::abs(signedRotationAroundAxis(transform.linear(), axis))); + } + return result; + }; + + const Eigen::Vector3d zAxis = Eigen::Vector3d::UnitZ(); + const Motion freeSocket = rollout( + sx::JointType::Spherical, + zAxis, + 10.0 * Eigen::Vector3d::UnitX(), + Eigen::Vector3d::UnitZ(), + /*compliant=*/false); + const Motion compliantSocket = rollout( + sx::JointType::Spherical, + zAxis, + 10.0 * Eigen::Vector3d::UnitX(), + Eigen::Vector3d::UnitZ(), + /*compliant=*/true); + ASSERT_GT(freeSocket.maxTranslation, 5e-2); + ASSERT_GT(freeSocket.maxAxisRotation, 2e-2); + EXPECT_LT(compliantSocket.maxTranslation, 0.5 * freeSocket.maxTranslation); + EXPECT_GT(compliantSocket.maxAxisRotation, 0.5 * freeSocket.maxAxisRotation); + + const Motion freeHinge = rollout( + sx::JointType::Revolute, + zAxis, + 10.0 * Eigen::Vector3d::UnitX(), + 0.8 * Eigen::Vector3d::UnitX() + 0.6 * Eigen::Vector3d::UnitZ(), + /*compliant=*/false); + const Motion compliantHinge = rollout( + sx::JointType::Revolute, + zAxis, + 10.0 * Eigen::Vector3d::UnitX(), + 0.8 * Eigen::Vector3d::UnitX() + 0.6 * Eigen::Vector3d::UnitZ(), + /*compliant=*/true); + ASSERT_GT(freeHinge.maxTranslation, 5e-2); + ASSERT_GT(freeHinge.maxAxisTilt, 2e-2); + ASSERT_GT(freeHinge.maxAxisRotation, 1e-2); + EXPECT_LT(compliantHinge.maxTranslation, 0.5 * freeHinge.maxTranslation); + EXPECT_LT(compliantHinge.maxAxisTilt, 0.5 * freeHinge.maxAxisTilt); + EXPECT_GT(compliantHinge.maxAxisRotation, 0.5 * freeHinge.maxAxisRotation); + + const Eigen::Vector3d xAxis = Eigen::Vector3d::UnitX(); + const Motion freeSlider = rollout( + sx::JointType::Prismatic, + xAxis, + 10.0 * Eigen::Vector3d::UnitX() + 10.0 * Eigen::Vector3d::UnitY(), + Eigen::Vector3d::UnitZ(), + /*compliant=*/false); + const Motion compliantSlider = rollout( + sx::JointType::Prismatic, + xAxis, + 10.0 * Eigen::Vector3d::UnitX() + 10.0 * Eigen::Vector3d::UnitY(), + Eigen::Vector3d::UnitZ(), + /*compliant=*/true); + ASSERT_GT(freeSlider.maxAxisTranslation, 5e-2); + ASSERT_GT(freeSlider.maxOrthogonalTranslation, 5e-2); + ASSERT_GT(freeSlider.maxRotation, 2e-2); + EXPECT_GT( + compliantSlider.maxAxisTranslation, 0.5 * freeSlider.maxAxisTranslation); + EXPECT_LT( + compliantSlider.maxOrthogonalTranslation, + 0.5 * freeSlider.maxOrthogonalTranslation); + EXPECT_LT(compliantSlider.maxRotation, 0.5 * freeSlider.maxRotation); +} + // Topology joints are the multibody tree itself, not external AVBD point-joint // rows. Even if a private config is attached accidentally, the articulated // bridge must not duplicate the tree joint as a rigid closure. diff --git a/tests/unit/simulation/world/test_world.cpp b/tests/unit/simulation/world/test_world.cpp index be0424dcbf6d9..531b31cf1d17c 100644 --- a/tests/unit/simulation/world/test_world.cpp +++ b/tests/unit/simulation/world/test_world.cpp @@ -6866,6 +6866,51 @@ void configureLongVariationalArticulatedPointJointScene( parent, makeJointSpec("tip_world_anchor", sx::JointType::Fixed)); } +void configureFiniteVariationalArticulatedPointJointScene( + dart::simulation::World& world) +{ + namespace sx = dart::simulation; + + world.setMultibodyOptions({sx::MultibodyIntegrationFamily::Variational}); + world.setGravity(Eigen::Vector3d(1.0, -2.0, 3.0)); + world.setTimeStep(1.0e-3); + + struct JointCase + { + std::string_view name; + sx::JointType type; + Eigen::Vector3d axis; + }; + const std::array cases{{ + {"socket", sx::JointType::Spherical, Eigen::Vector3d::UnitZ()}, + {"hinge", sx::JointType::Revolute, Eigen::Vector3d::UnitY()}, + {"slider", sx::JointType::Prismatic, Eigen::Vector3d::UnitX()}, + }}; + + for (const JointCase& jointCase : cases) { + auto robot + = world.addMultibody(std::string(jointCase.name) + "_finite_robot"); + auto base = robot.addLink("base"); + sx::JointSpec floatingSpec; + floatingSpec.name = std::string(jointCase.name) + "_floating"; + floatingSpec.type = sx::JointType::Floating; + auto body = robot.addLink("body", base, floatingSpec); + body.setMass(1.0); + body.setInertia(Eigen::Vector3d(0.1, 0.2, 0.3).asDiagonal()); + body.getParentJoint().setVelocity(Eigen::VectorXd::Constant(6, 0.1)); + + auto joint = world.addJoint( + body, + makeJointSpec( + std::string(jointCase.name) + "_finite_joint", + jointCase.type, + jointCase.axis)); + joint.setActuatorType(sx::ActuatorType::Passive); + joint.setConstraintProjectionPolicy( + makeConstraintProjectionPolicy(10.0, 200.0, 400.0)); + } +} + void configureCompliantVariationalContactSliderScene( dart::simulation::World& world) { @@ -7454,6 +7499,15 @@ TEST(World, VariationalArticulatedPointJointLinkIndexScratchUsesWorldAllocator) EXPECT_EQ(allocator.alignedDeallocationCount, alignedDeallocationsAfterBake); } +TEST( + World, + BakedVariationalCompliantArticulatedPointJointRowsDoNotGrowWorldBaseAllocator) +{ + expectNoWorldBaseAllocatorActivityDuringBakedSteps( + "multibody variational compliant articulated point-joint rows", + configureFiniteVariationalArticulatedPointJointScene); +} + TEST(World, VariationalLoopClosureRegistryStorageRebuildsAfterClear) { namespace common = dart::common; @@ -10375,6 +10429,26 @@ TEST(World, BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap) }); } +TEST( + World, + BakedVariationalCompliantArticulatedPointJointRowsDoNotAllocateGlobalHeap) +{ + expectNoGlobalHeapAllocationsDuringBakedSteps( + "multibody variational compliant articulated point-joint rows", + configureFiniteVariationalArticulatedPointJointScene); +} + +TEST(World, BakedVariationalCompliantArticulatedPointJointRowsDoNotMallocOnHeap) +{ +#if !defined(DART_TEST_HAS_RAW_MALLOC_INTERPOSE) + GTEST_SKIP() << "raw malloc interposer unavailable on this platform/build"; +#else + expectNoRawHeapAllocationsDuringFirstPostBakeSteps( + "multibody variational compliant articulated point-joint rows", + configureFiniteVariationalArticulatedPointJointScene); +#endif +} + TEST(World, BakedVariationalMultibodyStepsDoNotMallocOnHeap) { #if !defined(DART_TEST_HAS_RAW_MALLOC_INTERPOSE) From 9ebd9b895b17e982bd1ed9287287d589b029409f Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Thu, 30 Jul 2026 07:35:42 -0700 Subject: [PATCH 05/13] Support finite articulated AVBD motors --- CHANGELOG.md | 6 +- .../compute/variational_integration.cpp | 146 ++-- docs/dev_tasks/avbd_solver/README.md | 85 ++- docs/dev_tasks/avbd_solver/RESUME.md | 57 +- docs/plans/104-vertex-block-descent-solver.md | 36 +- ...d-articulated-compliant-motors-packet.json | 467 ++++++++++++ .../avbd-demo-corpus.md | 3 + .../avbd-paper-coverage-contract.json | 15 +- .../avbd-paper-gap-audit.md | 18 +- python/examples/demos/README.md | 1 + python/examples/demos/registry.py | 4 + .../avbd_articulated_compliant_motors.py | 364 ++++++++++ python/tests/integration/test_demos_cycle.py | 72 ++ ...vbd_articulated_compliant_motors_packet.py | 483 +++++++++++++ ...vbd_articulated_compliant_motors_packet.py | 679 ++++++++++++++++++ .../simulation/bm_avbd_rigid_fixed_joint.cpp | 104 +++ .../compute/test_variational_integration.cpp | 284 ++++++++ tests/unit/simulation/world/test_world.cpp | 45 ++ 18 files changed, 2732 insertions(+), 137 deletions(-) create mode 100644 docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json create mode 100644 python/examples/demos/scenes/avbd_articulated_compliant_motors.py create mode 100644 python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py create mode 100644 scripts/write_avbd_articulated_compliant_motors_packet.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ed36f6e8db950..0fd58303091c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -195,8 +195,10 @@ compatibility remains on the active DART 6 LTS branch._ inventories now perform the Section 4 post-primal dual/stiffness update over deterministic, allocation-stable worker ranges while preserving exact serial row state. Passive articulated spherical, revolute, and prismatic point - joints now also apply masked finite-stiffness rows while preserving their - free coordinates. + joints now also apply masked finite-stiffness rows for world-link and + same-multibody movable-link endpoints while preserving their free + coordinates. Finite articulated revolute and prismatic velocity motors now + add bounded free-coordinate motor rows alongside those compliant masks. ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) - Added compute-executor and backend-boundary work so CPU threading, optional CUDA experiments, and future accelerator sidecars do not leak into the default diff --git a/dart/simulation/compute/variational_integration.cpp b/dart/simulation/compute/variational_integration.cpp index 69d8a6fdb6758..af674c202ae0c 100644 --- a/dart/simulation/compute/variational_integration.cpp +++ b/dart/simulation/compute/variational_integration.cpp @@ -1047,23 +1047,13 @@ void appendAvbdRigidWorldArticulatedPointJointConstraints( const bool hard = isHardAvbdRigidWorldPointJointConfig(config); const std::optional compliantStiffness = avbdRigidWorldCompliantPointJointStiffness(config); - const bool usesFiniteOneDofVelocityMotor - = !hard - && (jointModel.type == comps::JointType::Revolute - || jointModel.type == comps::JointType::Prismatic) - && jointActuation.actuatorType == comps::ActuatorType::Velocity; if (!config.enabled || jointState.broken || !dvbd::isAvbdRigidWorldPointJointType(jointModel.type) || isTopologyMultibodyJoint(registry, jointEntity, jointModel)) { continue; } - // A finite masked constraint and its free-axis velocity motor need - // separate compliant and bounded projection rows. Until that combined - // contract is implemented, keep the pre-existing fail-closed behavior - // instead of silently dropping the configured motor. if (!hard - && (compliantScratch == nullptr || !compliantStiffness.has_value() - || usesFiniteOneDofVelocityMotor)) { + && (compliantScratch == nullptr || !compliantStiffness.has_value())) { continue; } if (!dvbd::detail::hasValidActiveAvbdRigidJointAxes( @@ -1133,6 +1123,71 @@ void appendAvbdRigidWorldArticulatedPointJointConstraints( .linear(); } const Eigen::Matrix3d axisFrame = worldRotationA; + constraint.linearAxes = axisFrame * config.linearAxes; + constraint.angularAxes = axisFrame * config.angularAxes; + constraint.linearAxisMask = config.linearAxisMask; + constraint.angularAxisMask = config.angularAxisMask; + + const auto configureVelocityMotorRow = + [&](VariationalLoopConstraint& motorConstraint) { + if (jointModel.type == comps::JointType::Prismatic + && jointActuation.actuatorType == comps::ActuatorType::Velocity + && jointActuation.commandVelocity.size() == 1 + && jointActuation.commandVelocity.allFinite()) { + const double maxForce + = dvbd::avbdRigidWorldSymmetricEffortLimit(jointModel); + const std::optional freeAxis + = singleInactiveVariationalLoopAxis(config.linearAxisMask); + if (maxForce > 0.0 && !std::isnan(maxForce) + && freeAxis.has_value()) { + motorConstraint.linearMotor = true; + motorConstraint.linearMotorAxis + = variationalLoopAxis(motorConstraint.linearAxes, *freeAxis); + const Eigen::Vector3d pointA + = (worldA ? Eigen::Isometry3d::Identity() + : dvbd::avbdRigidWorldContactFrameWorldTransform( + registry, jointModel.parentLink)) + * motorConstraint.pointA; + const Eigen::Vector3d pointB + = (worldB ? Eigen::Isometry3d::Identity() + : dvbd::avbdRigidWorldContactFrameWorldTransform( + registry, jointModel.childLink)) + * motorConstraint.pointB; + const Eigen::Vector3d positionResidual = pointA - pointB; + motorConstraint.linearMotorReferencePosition + = -motorConstraint.linearMotorAxis.dot(positionResidual); + motorConstraint.linearMotorTargetSpeed + = jointActuation.commandVelocity[0]; + motorConstraint.linearMotorMaxForce = maxForce; + } + } + if (jointModel.type == comps::JointType::Revolute + && jointActuation.actuatorType == comps::ActuatorType::Velocity + && jointActuation.commandVelocity.size() == 1 + && jointActuation.commandVelocity.allFinite()) { + const double maxTorque + = dvbd::avbdRigidWorldSymmetricEffortLimit(jointModel); + const std::optional freeAxis + = singleInactiveVariationalLoopAxis(config.angularAxisMask); + if (maxTorque > 0.0 && !std::isnan(maxTorque) + && freeAxis.has_value()) { + motorConstraint.angularMotor = true; + motorConstraint.angularMotorAxis + = variationalLoopAxis(motorConstraint.angularAxes, *freeAxis); + const Eigen::Matrix3d rotA + = worldRotationA * motorConstraint.rotationA; + const Eigen::Matrix3d rotB + = worldRotationB * motorConstraint.rotationB; + const Eigen::Vector3d angularResidual + = rotB * rotationLog3(rotB.transpose() * rotA); + motorConstraint.angularMotorReferencePosition + = -motorConstraint.angularMotorAxis.dot(angularResidual); + motorConstraint.angularMotorTargetSpeed + = jointActuation.commandVelocity[0]; + motorConstraint.angularMotorMaxTorque = maxTorque; + } + } + }; if (!hard) { VariationalCompliantLoopConstraint compliantConstraint{ @@ -1185,64 +1240,23 @@ void appendAvbdRigidWorldArticulatedPointJointConstraints( *compliantStiffness}); } compliantScratch->constraints.push_back(std::move(compliantConstraint)); - continue; - } - constraint.linearAxes = axisFrame * config.linearAxes; - constraint.angularAxes = axisFrame * config.angularAxes; - constraint.linearAxisMask = config.linearAxisMask; - constraint.angularAxisMask = config.angularAxisMask; - if (jointModel.type == comps::JointType::Prismatic - && jointActuation.actuatorType == comps::ActuatorType::Velocity - && jointActuation.commandVelocity.size() == 1 - && jointActuation.commandVelocity.allFinite()) { - const double maxForce - = dvbd::avbdRigidWorldSymmetricEffortLimit(jointModel); - const std::optional freeAxis - = singleInactiveVariationalLoopAxis(config.linearAxisMask); - if (maxForce > 0.0 && !std::isnan(maxForce) && freeAxis.has_value()) { - constraint.linearMotor = true; - constraint.linearMotorAxis - = variationalLoopAxis(constraint.linearAxes, *freeAxis); - const Eigen::Vector3d pointA - = (worldA ? Eigen::Isometry3d::Identity() - : dvbd::avbdRigidWorldContactFrameWorldTransform( - registry, jointModel.parentLink)) - * constraint.pointA; - const Eigen::Vector3d pointB - = (worldB ? Eigen::Isometry3d::Identity() - : dvbd::avbdRigidWorldContactFrameWorldTransform( - registry, jointModel.childLink)) - * constraint.pointB; - const Eigen::Vector3d positionResidual = pointA - pointB; - constraint.linearMotorReferencePosition - = -constraint.linearMotorAxis.dot(positionResidual); - constraint.linearMotorTargetSpeed = jointActuation.commandVelocity[0]; - constraint.linearMotorMaxForce = maxForce; - } - } - if (jointModel.type == comps::JointType::Revolute - && jointActuation.actuatorType == comps::ActuatorType::Velocity - && jointActuation.commandVelocity.size() == 1 - && jointActuation.commandVelocity.allFinite()) { - const double maxTorque - = dvbd::avbdRigidWorldSymmetricEffortLimit(jointModel); - const std::optional freeAxis - = singleInactiveVariationalLoopAxis(config.angularAxisMask); - if (maxTorque > 0.0 && !std::isnan(maxTorque) && freeAxis.has_value()) { - constraint.angularMotor = true; - constraint.angularMotorAxis - = variationalLoopAxis(constraint.angularAxes, *freeAxis); - const Eigen::Matrix3d rotA = worldRotationA * constraint.rotationA; - const Eigen::Matrix3d rotB = worldRotationB * constraint.rotationB; - const Eigen::Vector3d angularResidual - = rotB * rotationLog3(rotB.transpose() * rotA); - constraint.angularMotorReferencePosition - = -constraint.angularMotorAxis.dot(angularResidual); - constraint.angularMotorTargetSpeed = jointActuation.commandVelocity[0]; - constraint.angularMotorMaxTorque = maxTorque; + // The constrained coordinates stay in the finite-stiffness AVBD force, + // while the paper's free coordinate reuses the bounded projection row + // already used by hard one-DOF joints. Keep the hard masks empty so this + // companion row cannot silently make a finite coordinate rigid. + VariationalLoopConstraint motorConstraint = constraint; + motorConstraint.linearAxisMask = 0u; + motorConstraint.angularAxisMask = 0u; + configureVelocityMotorRow(motorConstraint); + motorConstraint.rigid = motorConstraint.angularMotor; + if (motorConstraint.linearMotor || motorConstraint.angularMotor) { + constraints.push_back(motorConstraint); } + continue; } + + configureVelocityMotorRow(constraint); constraint.sourceJoint = jointEntity; constraint.breakForce = jointModel.breakForce; constraints.push_back(constraint); diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index 24b6acdc1d92e..d9580761236bb 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -25,21 +25,23 @@ This folder is the temporary working surface; the durable owner is the plan. the fail-closed contract is committed locally at `710cbfc1152`, and nothing has been pushed. The Section 3.5 packet is committed locally at `a78f688a178`, and the Section 4 packet at `0b0154573b8`; the articulated - finite-row packet is the current local unpushed closeout. -- **Current packet:** passive public world-link spherical, revolute, and - prismatic point joints now feed their masked finite-stiffness rows into the - CPU variational articulated bridge while preserving their free coordinates. + finite-row packet at `761263bbd41`; the articulated finite-motor packet is + the current local unpushed closeout. +- **Current packet:** passive public spherical, revolute, and prismatic point + joints now support same-multibody movable-link endpoints in their masked + finite-stiffness rows. Finite public revolute/prismatic `Velocity` joints + additionally project one bounded motor-only row on the free coordinate while + their constrained coordinates remain compliant. The contracts contain 88 VBD and 88 AVBD requirements; all 176 remain incomplete until their recorded correctness, solver-identity, CPU/CUDA, visual, and comparable-performance predicates pass. -- **Latest verified local packet:** focused behavior, dense inverse-mass, - warmed allocation, Python scene, exact-parent mutation, assessed visual, and +- **Latest verified local packet:** focused passive/motor behavior, warmed + allocation, Python scene, exact-parent mutation, docked assessed visual, and candidate-only scale evidence advance `avbd.method.joints_and_attachments` and - `avbd.method.finite_stiffness_ramping` without completing either row. Finite - one-DOF motor coupling, same-multibody finite pairs, fracture accounting, - unified rows, CUDA, and source-matched achieved-accuracy performance remain - open. + `avbd.method.finite_stiffness_ramping` without completing either row. + Finite-row fracture accounting, unified rows, CUDA, and source-matched + achieved-accuracy performance remain open. - **Recent slices merged to `main`** (see the PLAN-104 progress log and the PRs for detail; per-slice history lives in git, not in this file): - #2991 — source-row coverage + contact-precheck (`f6fecbc5bd5`). @@ -49,20 +51,21 @@ This folder is the temporary working surface; the durable owner is the plan. - #3022 — bounded regression coverage: rigid-contact tangent-basis contract, articulated break→reset→break re-arm lifecycle, row-inventory replaced-key cold-start (`65ba05113c6`). -- **Current local gates:** the complete variational-integration binary passes - 181/181 active tests; the three new articulated finite-row post-bake - allocation policies, five packet-writer tests, and focused Python scene - behavior oracle pass. The identical behavior test fails five constrained-row - assertions on exact parent `0b0154573b8`, proving mutation sensitivity. The - assessed 120-frame software capture and pinned 3/12/48-joint candidate-only - benchmark are recorded in the packet. The Release build, aggregate C++ unit - tier (168/168), full Python tier (1644 passed, 20 skipped), PLAN-104 parity, - AVBD-packet, and PLAN-122 allocation-matrix gates pass. The simulation label +- **Current local gates:** the focused passive and bounded-motor C++ oracles, + all three articulated finite-row post-bake allocation policies, six + packet-writer tests, and the focused Python command/reversal oracle pass. + The motor oracle fails both driven coordinates on exact parent + `761263bbd41`, proving mutation sensitivity. The assessed 119-frame + docking-build capture and pinned 2/8/32-motor candidate-only benchmark are + recorded in the packet. The Release build, complete variational binary + (183/183), aggregate C++ unit tier (168/168), full Python tier (1651 passed, + 20 skipped), PLAN-104 parity contract (176 rows), AVBD packets (54), and + PLAN-122 allocation matrix (18 rows, 14 closed) pass. The simulation label passes 77/79 active entries; only the split global-heap and raw-malloc - binaries fail on the same four unrelated allocation signatures previously - reproduced on exact parent `a78f688a178` (320/80 global bytes and 320/1376 - raw bytes), while all three new articulated finite-row gates pass inside - those splits. Two monolithic entries remain intentionally disabled. + binaries fail on the same four unrelated signatures previously reproduced + on exact parent (320/80 global bytes and 320/1376 raw bytes), while all three + new finite articulated gates pass inside those splits. Two monolithic + entries remain intentionally disabled. ## Goal @@ -93,10 +96,10 @@ numbers. ## Immediate Next Steps -Continue PLAN-104's articulated multibody AVBD extraction with finite -same-multibody endpoint pairs or finite one-DOF motor coupling after this -packet. Keep every dependent figure/demo/performance row partial until -source-matched CPU and CUDA evidence closes it. +Continue PLAN-104's articulated multibody AVBD extraction with finite-row load +accounting and fracture lifecycle after this packet. Keep every dependent +figure/demo/performance row partial until source-matched CPU and CUDA evidence +closes it. Two smaller deferred maintenance items remain valid but do not outrank the missing paper mechanism: hoist the duplicated `makeCollisionPairKey` into a @@ -205,6 +208,34 @@ The durable evidence is [`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json). Both linked canonical method rows remain partial. +## Verified Local Packet: Articulated Finite Movable-Pair Motors + +- **Value:** make passive finite masked rows work between two movable links of + one multibody and make finite revolute/prismatic `Velocity` joints drive + their free coordinate instead of being silently passive. +- **Scope:** retain compliant constrained coordinates and add one bounded + motor-only projection row for each finite revolute/prismatic free coordinate, + using the hard-motor target, Jacobian, and effort-bound semantics. +- **Non-goals:** finite-row break-force/load accounting, unified soft/rigid + rows, CUDA, and a paper/reference speedup claim. The motor-only row + intentionally does not attach a `sourceJoint` until load accounting is + complete. +- **Correctness evidence:** non-cardinal, off-origin same-multibody passive and + motor oracles cover spherical/revolute/prismatic masks plus strong/tiny + revolute/prismatic effort limits. The motor oracle fails both driven + coordinates on exact parent `761263bbd41`. +- **Runtime evidence:** warmed world-base, global-`new`, and raw-malloc gates + pass; the Python text oracle validates forward/reversed commands and bounded + residuals; the docking-build start/middle/end render shows both movable-pair + mechanisms and their live metrics. +- **Performance boundary:** the pinned candidate-only benchmark records 104.2 + us for 2 motors, 579.8 us for 8, and 5.803 ms for 32. The parent skipped + these rows, so the packet makes no before/after speedup claim. + +The durable evidence is +[`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json). +The linked method rows remain partial. + ## History Per-slice history and durable evidence live in the PLAN-104 progress log, the diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index e8c5d27b3ddb8..04c721cea1876 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -10,55 +10,58 @@ this file is only the current session pointer. See The exact 176-row VBD/AVBD paper-parity contract remains fail-closed, with no complete rows. The latest local packet extends the CPU articulated variational -bridge from finite fixed rows to passive public world-link spherical, -revolute, and prismatic point joints. Their masked constrained coordinates use -persistent per-axis AVBD stiffness state while spherical rotation, revolute -hinge rotation, and prismatic axis translation remain free. +bridge from passive finite world-link rows to same-multibody movable-link +spherical/revolute/prismatic pairs and bounded finite revolute/prismatic +`Velocity` motors. Their masked constrained coordinates use persistent +per-axis AVBD stiffness state while the free coordinate uses a motor-only +projection row with the existing hard-motor target/Jacobian/effort bounds. The durable packet is -[`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json). -It records the public S/R/P behavior oracle, a dense nine-DOF inverse-mass -oracle, all three warmed allocation policies, a Python text oracle, assessed -start/middle/end visual evidence, candidate-only 3/12/48-joint scale data, and -an exact-parent mutation run. The identical behavior test fails five -constrained-coordinate assertions on exact parent `0b0154573b8` and passes on -the candidate. Both linked method rows remain partial. +[`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json). +It records passive and motor same-multibody C++ oracles, all three warmed +allocation policies, a Python command/reversal oracle, assessed docked +start/middle/end visual evidence, candidate-only 2/8/32-motor scale data, and +an exact-parent mutation run. The motor behavior test fails both driven +coordinates on exact parent `761263bbd41` and passes on the candidate. The +linked method rows remain partial. -The Release build, C++ unit tier (168/168), full Python tier (1644 passed, 20 -skipped), PLAN-104 parity, AVBD-packet, and PLAN-122 allocation-matrix gates -pass. The simulation label is 77/79 active entries: its split global-heap and -raw-malloc binaries reproduce the same four unrelated allocation signatures -previously recorded on exact parent `a78f688a178`, while the three new -articulated finite-row allocation policies pass. Two monolithic entries are -intentionally disabled. +The Release build, complete variational binary (183/183), aggregate C++ unit +tier (168/168), full Python tier (1651 passed, 20 skipped), six packet-writer +tests, PLAN-104 parity contract (176 rows), all 54 AVBD packets, and PLAN-122 +allocation matrix (18 rows, 14 closed) pass. The simulation label passes 77/79 +active entries: its split global-heap and raw-malloc binaries reproduce the +same four unrelated 320/80-byte and 320/1376-byte signatures recorded on the +exact parent, while every new finite articulated allocation gate passes. Two +monolithic entries remain intentionally disabled. ## Current Branch `feature/vbd-avbd-paper-parity-contract`, based on the then-current `origin/main` at `83110ef54ab`. The fail-closed contract is committed locally at `710cbfc1152`, the Section 3.5 packet at `a78f688a178`, and the Section 4 -packet at `0b0154573b8`; the articulated finite-row packet is the current +packet at `0b0154573b8`; the articulated finite-row packet is committed at +`761263bbd41`, and the articulated finite-motor packet is the current uncommitted closeout. The branch has not been pushed. ## Immediate Next Step After aggregate gates and the local closeout commit, continue articulated -extraction with either finite same-multibody endpoint pairs or finite one-DOF -velocity-motor coupling. Preserve the packet boundary: the current CPU -world-link passive rows do not close unified rows, CUDA, the source/paper -corpus, or achieved-accuracy performance. +extraction with finite-row load accounting and fracture lifecycle. Preserve +the packet boundary: the current CPU finite rows do not close unified rows, +CUDA, the source/paper corpus, or achieved-accuracy performance. ## Context That Would Be Lost - **Keep claims narrow.** Do not claim a source-demo/paper CPU win, GPU parity, a broad breakable-wall/fracture corpus, a same-hardware paper-number match, or an all-coefficient friction win unless the tracked artifacts directly prove it. -- The articulated finite-row benchmark is candidate-only. Exact parent skips +- The articulated finite-motor benchmark is candidate-only. Exact parent skips the new rows, so a timing ratio would compare different work and is not a speedup claim. -- Finite public one-DOF `Velocity` rows are deliberately fail-closed: combining - a bounded free-axis motor with finite masked constraints needs a separate row - contract. Passive finite S/R/P rows are the verified scope. +- Finite public one-DOF `Velocity` rows now use a separate bounded motor-only + row beside their compliant masks. Do not attach break-force source state to + that row until finite compliant and motor loads have a complete accounting + contract; doing so now would be partial and misleading. - The pinned 2D source applies Section 3.5 generically and the 3D joint source applies it to nonlinear point rows, but the pinned 3D Spring source omits the geometric term. DART follows the paper for distance springs; do not call that diff --git a/docs/plans/104-vertex-block-descent-solver.md b/docs/plans/104-vertex-block-descent-solver.md index f6127484ed3f9..3c29157c4d33a 100644 --- a/docs/plans/104-vertex-block-descent-solver.md +++ b/docs/plans/104-vertex-block-descent-solver.md @@ -890,12 +890,20 @@ warm-starts and ramps toward the configured cap across steps. Passive public world-link spherical, revolute, and prismatic point joints now use that same persistent finite-stiffness state for their masked linear and angular row families: spherical rotation, revolute hinge rotation, and -prismatic axis translation remain free. The focused +prismatic axis translation remain free. The same masked finite rows now also +support same-multibody movable-link endpoint pairs. Finite public revolute and +prismatic `Velocity` joints additionally project a bounded motor-only row on +their free coordinate while their constrained coordinates remain compliant; +the motor-only row intentionally carries no break-force source because finite +row load accounting is not yet complete. The focused `avbd_articulated_compliant_joints` py-demo, allocation gates, exact-parent mutation oracle, and candidate-only 3/12/48-joint benchmark are recorded in [`avbd-articulated-compliant-joints-packet.json`](104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json). -Finite one-DOF velocity-motor coupling, same-multibody finite pairs, -finite-row fracture accounting, CUDA, and unified soft/rigid rows remain open. +The `avbd_articulated_compliant_motors` py-demo, allocation gates, +exact-parent mutation oracle, docked assessed capture, and candidate-only +2/8/32-motor benchmark are recorded in +[`avbd-articulated-compliant-motors-packet.json`](104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json). +Finite-row fracture accounting, CUDA, and unified soft/rigid rows remain open. Free-rigid AVBD point-joint rows now also support finite linear and angular material stiffness through the public point-joint facade and dartpy properties; those rows use raw finite residuals and ramp row stiffness up to the configured @@ -1306,6 +1314,25 @@ AVBD parity additionally requires: Relocated from the dashboard on 2026-07-03; newest first. +On 2026-07-30, the articulated finite-motor packet closed two adjacent CPU +extraction gaps: passive finite spherical/revolute/prismatic masks now support +same-multibody movable-link pairs, and finite revolute/prismatic `Velocity` +joints add one bounded motor-only projection row on the free coordinate while +their constrained coordinates remain compliant. Non-cardinal off-origin C++ +oracles cover strong and tiny effort limits and fail both driven coordinates +on exact parent `761263bbd4`; passive same-multibody behavior and all three +warmed allocation policies pass. The +`avbd_articulated_compliant_motors` py-demo has a text-first command/reversal +oracle and an assessed 119-frame docking-build capture. A pinned +candidate-only benchmark records median CPU time of 104.2 us for 2 motors, +579.8 us for 8, and 5.803 ms for 32; the parent skipped this mechanism, so no +speedup is claimed. +[`avbd-articulated-compliant-motors-packet.json`](104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json) +keeps `avbd.method.joints_and_attachments`, +`avbd.method.finite_stiffness_ramping`, and `avbd.method.motors` partial: +finite-row fracture accounting, unified soft/rigid rows, CUDA, and +source/paper achieved-accuracy performance remain open. + On 2026-07-30, the articulated finite-row packet extended the CPU variational bridge from finite fixed rows to passive public world-link spherical, revolute, and prismatic point joints. Their masked constrained coordinates use @@ -1322,7 +1349,8 @@ and 2.404 ms for 48; the parent skipped these rows, so no speedup is claimed. keeps `avbd.method.joints_and_attachments` and `avbd.method.finite_stiffness_ramping` partial: finite one-DOF motor coupling, same-multibody finite pairs, fracture accounting, unified soft/rigid rows, -CUDA, and source/paper achieved-accuracy performance remain open. +CUDA, and source/paper achieved-accuracy performance were left open by that +packet; the adjacent finite-motor packet above closes the first two only. On 2026-07-30, the bounded AVBD Section 4 packet added a deterministic post-primal CPU dual/stiffness pass for the currently promoted deformable and diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json new file mode 100644 index 0000000000000..effb314a1ef36 --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json @@ -0,0 +1,467 @@ +{ + "benchmark": { + "benchmark": "BM_AvbdArticulatedCompliantMotorStep", + "context": { + "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "v1.9.5", + "mhz_per_cpu": 5300, + "num_cpus": 32 + }, + "invariants": { + "angular_stiffness": 2000.0, + "effort_limit": 800.0, + "endpoint_topology": "same-multibody movable-link pairs", + "family_instances": [ + 1, + 4, + 16 + ], + "linear_stiffness": 2000.0, + "motor_types": [ + "revolute", + "prismatic" + ], + "start_stiffness": 20.0, + "time_step": 0.005 + }, + "json_sha256": "34003ea6f225159524df8a4d369eb6400c2995498e39f16cfc2cd5b4a2c45ed7", + "rows": [ + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "compliant_motors": 2.0, + "cpu_time": 104183.77681818184, + "family_index": 0, + "family_instances": 1.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/1_mean", + "per_family_instance_index": 0, + "prismatic_motors": 1.0, + "real_time": 104189.82909785965, + "repetitions": 5, + "revolute_motors": 1.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/1", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "compliant_motors": 2.0, + "cpu_time": 104168.97386363638, + "family_index": 0, + "family_instances": 1.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/1_median", + "per_family_instance_index": 0, + "prismatic_motors": 1.0, + "real_time": 104169.62273170197, + "repetitions": 5, + "revolute_motors": 1.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/1", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "compliant_motors": 0.0, + "cpu_time": 163.18712504882166, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/1_stddev", + "per_family_instance_index": 0, + "prismatic_motors": 0.0, + "real_time": 169.38460490222585, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/1", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "compliant_motors": 0.0, + "cpu_time": 0.001566339117592277, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/1_cv", + "per_family_instance_index": 0, + "prismatic_motors": 0.0, + "real_time": 0.0016257307106544191, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/1", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "compliant_motors": 8.0, + "cpu_time": 580115.688, + "family_index": 0, + "family_instances": 4.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/4_mean", + "per_family_instance_index": 1, + "prismatic_motors": 4.0, + "real_time": 580145.1700390318, + "repetitions": 5, + "revolute_motors": 4.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/4", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "compliant_motors": 8.0, + "cpu_time": 579802.5200000001, + "family_index": 0, + "family_instances": 4.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/4_median", + "per_family_instance_index": 1, + "prismatic_motors": 4.0, + "real_time": 579805.5601189844, + "repetitions": 5, + "revolute_motors": 4.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/4", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "compliant_motors": 0.0, + "cpu_time": 572.5337579209764, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/4_stddev", + "per_family_instance_index": 1, + "prismatic_motors": 0.0, + "real_time": 602.4515200455039, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/4", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "compliant_motors": 0.0, + "cpu_time": 0.0009869303136669809, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/4_cv", + "per_family_instance_index": 1, + "prismatic_motors": 0.0, + "real_time": 0.0010384496004766728, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/4", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "compliant_motors": 32.0, + "cpu_time": 5807382.020000002, + "family_index": 0, + "family_instances": 16.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/16_mean", + "per_family_instance_index": 2, + "prismatic_motors": 16.0, + "real_time": 5807446.700637229, + "repetitions": 5, + "revolute_motors": 16.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/16", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "compliant_motors": 32.0, + "cpu_time": 5802735.800000014, + "family_index": 0, + "family_instances": 16.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/16_median", + "per_family_instance_index": 2, + "prismatic_motors": 16.0, + "real_time": 5802819.001837634, + "repetitions": 5, + "revolute_motors": 16.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/16", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "compliant_motors": 0.0, + "cpu_time": 12542.13476630824, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/16_stddev", + "per_family_instance_index": 2, + "prismatic_motors": 0.0, + "real_time": 12576.956752947022, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/16", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "compliant_motors": 0.0, + "cpu_time": 0.002159688259376509, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantMotorStep/16_cv", + "per_family_instance_index": 2, + "prismatic_motors": 0.0, + "real_time": 0.0021656602981077726, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantMotorStep/16", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + } + ], + "scale_data": [ + { + "benchmark": "BM_AvbdArticulatedCompliantMotorStep/1", + "compliant_motors": 2, + "cpu_time_per_motor_ns": 52084.48693181819, + "cpu_time_per_step_ns": 104168.97386363638, + "family_instances": 1, + "real_time_per_step_ns": 104169.62273170197, + "time_unit": "ns" + }, + { + "benchmark": "BM_AvbdArticulatedCompliantMotorStep/4", + "compliant_motors": 8, + "cpu_time_per_motor_ns": 72475.31500000002, + "cpu_time_per_step_ns": 579802.5200000001, + "family_instances": 4, + "real_time_per_step_ns": 579805.5601189844, + "time_unit": "ns" + }, + { + "benchmark": "BM_AvbdArticulatedCompliantMotorStep/16", + "compliant_motors": 32, + "cpu_time_per_motor_ns": 181335.49375000043, + "cpu_time_per_step_ns": 5802735.800000014, + "family_instances": 16, + "real_time_per_step_ns": 5802819.001837634, + "time_unit": "ns" + } + ] + }, + "correctness": { + "allocation_tests": [ + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotGrowWorldBaseAllocator", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotAllocateGlobalHeap", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotMallocOnHeap" + ], + "cpp_behavior_test": "VariationalIntegration.AvbdCompliantPublicArticulatedOneDofMotorsDriveMovableLinkPairs", + "cpp_passive_pair_test": "VariationalIntegration.AvbdCompliantPublicArticulatedJointMasksSupportMovableLinkPairs", + "exact_parent_mutation": { + "command": "build/default/cpp/Release/bin/test_variational_integration --gtest_filter=VariationalIntegration.AvbdCompliantPublicArticulatedOneDofMotorsDriveMovableLinkPairs", + "exit_code": 1, + "failed_assertions": [ + { + "actual": 0.0, + "coordinate": "strong finite revolute motor angle", + "expected": 0.04 + }, + { + "actual": 1.2113526306256194e-17, + "coordinate": "strong finite prismatic motor travel", + "expected": 0.03 + } + ], + "file": "avbd-articulated-compliant-motors-exact-parent-mutation.json", + "interpretation": "At the exact parent, both finite masked one-DOF joints remain passive because the assembly path deliberately skips velocity-motor projection for non-hard revolute and prismatic policies. The candidate must add the bounded free-coordinate motor row while preserving compliant constrained coordinates.", + "mutation": "Run the new motor behavior oracle against exact parent 761263bbd41190536ce699439ebc64f5776757b6 with only the test present; production retains the finite velocity-motor skip.", + "observed_result": "fail", + "parent_commit": "761263bbd41190536ce699439ebc64f5776757b6", + "sha256": "b85078e62c9c8f9437c6a6998b78576d1e9ea66d06c3744473f85fc5de73cb0d", + "test": "VariationalIntegration.AvbdCompliantPublicArticulatedOneDofMotorsDriveMovableLinkPairs" + }, + "python_scene_test": "test_avbd_articulated_compliant_motors_demo_drives_free_coordinates" + }, + "packet": "avbd_articulated_compliant_motors", + "performance_claim_boundary": "Candidate-only finite mechanism cost and scaling. The exact parent intentionally skipped these non-Fixed finite rows, so this packet makes no before/after speedup claim.", + "remaining_gates": [ + "finite-row break-force accounting and fracture lifecycle", + "unified soft/rigid row solve", + "CUDA AVBD row parity and same-hardware benchmark packets", + "source-demo, paper/site/video scene, and achieved-accuracy performance closure" + ], + "reproduction": { + "benchmark_command": "taskset -c 18 build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint --benchmark_filter='BM_AvbdArticulatedCompliantMotorStep/(1|4|16)$' --benchmark_min_time=0.05s --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out= --benchmark_out_format=json", + "capture_command": "LIBGL_ALWAYS_SOFTWARE=1 MESA_LOADER_DRIVER_OVERRIDE=llvmpipe PYTHONPATH=build/default/cpp/Release-docking/python:python BUILD_TYPE=Release-docking python scripts/capture_py_demo.py --scene avbd_articulated_compliant_motors --frames 120 --width 960 --height 540 --show-ui --view three-quarter --camera-distance 7 --camera-target 0,0,1.05 --output-dir ", + "image_verdict_command": "pixi run image-verdict -- /avbd_articulated_compliant_motors.png --out /image_verdict.json --meta scene=avbd_articulated_compliant_motors --meta view=three-quarter" + }, + "resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": false, + "recorded_from": "contact-free py-demo and articulated compliant-motor benchmark row family", + "rigid_contact_solver": "none", + "rigid_point_joint_solver": "avbd" + }, + "scene": "avbd_articulated_compliant_motors", + "scene_invariants": { + "angular_stiffness": 2000.0, + "effort_limit": 800.0, + "endpoint_topology": "same-multibody movable-link pairs", + "free_coordinates": { + "prismatic": "translation along the joint axis", + "revolute": "rotation about the joint axis" + }, + "linear_stiffness": 2000.0, + "motor_types": [ + "revolute", + "prismatic" + ], + "start_stiffness": 20.0, + "time_step": 0.005 + }, + "schema_version": 2, + "target": { + "complete_paper_reproduction": false, + "contract_rows": [ + "avbd.method.joints_and_attachments", + "avbd.method.finite_stiffness_ramping", + "avbd.method.motors" + ], + "scope": "bounded finite-stiffness articulated revolute and prismatic velocity motors between movable links of one multibody on CPU" + }, + "visual_capture": { + "frames": { + "count": 119, + "directory": "png_frames", + "first_frame": { + "file": "png_frames/frame_000001.png", + "sha256": "5742c18adbbe4db6a9ef06e49fccf49e0dc7027aa51e96374d9cafb6c13598ad" + }, + "last_frame": { + "file": "png_frames/frame_000119.png", + "sha256": "213e45b42f7c6c7183ca8f01cbb74879a73f22355f4a27280337a2d6057925e1" + } + }, + "image_verdict": { + "checks": { + "contrast": { + "bright_pixels": 755, + "dark_pixels": 98667, + "mid_pixels": 29488, + "p05": 23.936, + "p95": 161.374, + "pass": true, + "reasons": [], + "region": { + "x0": 384, + "x1": 931, + "y0": 243, + "y1": 486 + }, + "sample_count": 132921, + "spread": 137.438, + "thresholds": { + "bright_luminance_gt": 190.0, + "dark_luminance_lt": 55.0, + "mid_luminance_max": 170.0, + "mid_luminance_min": 70.0, + "min_bright_pixels": 664, + "min_dark_pixels": 664, + "min_mid_pixels": 332, + "min_spread": 90.0 + } + }, + "non_blank": { + "nonzero_pixels": 518400, + "pass": true, + "reasons": [], + "total_pixels": 518400, + "unique_colors_seen": 2 + } + }, + "file": "image_verdict.json", + "machine_scope": "pixel-integrity", + "metadata": { + "scene": "avbd_articulated_compliant_motors", + "view": "three-quarter" + }, + "pass": true, + "sha256": "3720ec072a2117c3c55953009f539518f241b797fd1921aef751a2da802195b8" + }, + "manifest_sha256": "e1740954fea3a7556d5cea9e1bfa3dbcee27a387921d78ebdfc90f1a5fbc8bcd", + "scene": "avbd_articulated_compliant_motors", + "screenshot": { + "file": "avbd_articulated_compliant_motors.png", + "height": 540, + "sha256": "213e45b42f7c6c7183ca8f01cbb74879a73f22355f4a27280337a2d6057925e1", + "width": 960 + }, + "semantic_review": { + "file": "visual_review.json", + "inspected_frames": [ + { + "file": "png_frames/frame_000001.png", + "role": "start", + "sha256": "5742c18adbbe4db6a9ef06e49fccf49e0dc7027aa51e96374d9cafb6c13598ad" + }, + { + "file": "png_frames/frame_000060.png", + "role": "middle", + "sha256": "1ce43a5c8070da33e4c50d7a20869720b4be5a67e1f89fb036a852b6f0b2905a" + }, + { + "file": "png_frames/frame_000119.png", + "role": "end", + "sha256": "213e45b42f7c6c7183ca8f01cbb74879a73f22355f4a27280337a2d6057925e1" + } + ], + "limitations": "The images corroborate scene identity, finite motion, movable-link endpoints, command reversal, and gross mask behavior. They do not quantify constraint residuals, prove effort saturation, or distinguish the bounded motor-only projection row; the focused C++ and Python text oracles decide those claims.", + "observations": "Across the start, middle, and end frames, both same-multibody movable-link pairs remain finite, separated, and fully visible. The blue and gold revolute pair stays joined at the marked hinge while the live hinge angle changes and the command direction reverses. The green and purple prismatic pair stays aligned with its rail and joint marker without visible transverse or rotational escape. The docked AVBD panel and replay controls remain visible throughout, with zero contacts reported.", + "reviewer_capability": "native image input at original detail", + "sha256": "f072525ef5de13c00abcc0bfdff2f6143cd6dc9e9942f152fd6277f114030147", + "verdict": "pass" + }, + "show_ui": true, + "ui_ready": { + "dropped_warmup_frames": 1, + "required": true + } + } +} diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md index 51810c737ef37..98b50cc975c51 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md @@ -166,6 +166,9 @@ paper/source-demo corpus: | `avbd_articulated_compliant_joints` py-demo | Public passive finite-stiffness world-link spherical/revolute/prismatic rows with visible constrained and free coordinates | Partial | | `BM_AvbdArticulatedCompliantJointStep` dashboard row | Candidate-only CPU cost/scaling row over 3/12/48 passive finite articulated point joints | Partial | | `avbd-articulated-compliant-joints-packet.json` | Mutation-sensitive correctness, allocation, visual, solver-identity, and candidate-only scale evidence for the finite rows | Partial | +| `avbd_articulated_compliant_motors` py-demo | Public bounded finite-stiffness revolute/prismatic motors between same-multibody movable-link pairs | Partial | +| `BM_AvbdArticulatedCompliantMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 bounded finite articulated motors | Partial | +| `avbd-articulated-compliant-motors-packet.json` | Mutation-sensitive motor/pair correctness, allocation, docked visual, solver-identity, and candidate-only scale evidence | Partial | | `avbd_articulated_high_ratio_chain` py-demo | Five-link articulated variational-chain smoke with a 200:1 heavy tip | Partial | | `BM_AvbdArticulatedHighRatioChainStep` dashboard row | Narrow CPU dashboard row for the five-link 200:1 articulated high-ratio chain smoke | Partial | | `avbd_paper_scale_high_ratio_chain` py-demo | 50-link articulated variational-chain smoke with a 50,000:1 heavy tip | Partial | diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json index 56e60b7b3cd7f..03fd824600f68 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json @@ -118,9 +118,10 @@ "evidence": [ "tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp", "tests/unit/simulation/compute/test_variational_integration.cpp", - "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json" + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json", + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json" ], - "blockers": ["CPU finite-stiffness spring/tetrahedral rows and passive world-link articulated fixed/spherical/revolute/prismatic rows exist, but finite one-DOF motor coupling, same-multibody finite pairs, fracture accounting, the full paper/source corpus, and CUDA parity are incomplete."] + "blockers": ["CPU finite-stiffness spring/tetrahedral rows, passive world-link articulated fixed/spherical/revolute/prismatic rows, same-multibody passive spherical/revolute/prismatic rows, and bounded same-multibody finite revolute/prismatic motors exist, but fracture accounting, the full paper/source corpus, unified rows, and CUDA parity are incomplete."] }, { "id": "avbd.method.force_bounds", @@ -200,15 +201,19 @@ "evidence": [ "tests/unit/simulation/world/test_vbd_world_solver.cpp", "tests/unit/simulation/compute/test_variational_integration.cpp", - "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json" + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json", + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json" ], - "blockers": ["Hard point-joint bridges and passive finite world-link spherical/revolute/prismatic masked rows exist on CPU, but finite motor coupling, same-multibody finite pairs, the full joint corpus, unified paper scenes, and CUDA parity are incomplete."] + "blockers": ["Hard point-joint bridges, passive finite world-link and same-multibody spherical/revolute/prismatic masked rows, and bounded same-multibody finite revolute/prismatic motors exist on CPU, but finite-row fracture accounting, the full joint corpus, unified paper scenes, and CUDA parity are incomplete."] }, { "id": "avbd.method.motors", "status": "partial", "source_locator": "Paper/source-demo motor constraints", - "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-rigid-revolute-motor-packet.json"], + "evidence": [ + "docs/plans/104-vertex-block-descent-solver/avbd-rigid-revolute-motor-packet.json", + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json" + ], "blockers": ["Narrow public motor rows exist, but source-wide CPU wins, CUDA parity, and paper-scene integration are incomplete."] }, { diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md index f24ad1c907e8a..b0ae9892b8746 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md @@ -580,9 +580,9 @@ open. 6. **A5 - joints, motors, and fracture:** Implement ball/revolute/limited DOF joints, motor torque rows, attachment rows, maximum-force fracture, and breakage persistence. CPU hard point-joint bridges and passive finite - world-link fixed/spherical/revolute/prismatic masked rows are partial; - finite motor coupling, same-multibody finite pairs, broad fracture - accounting/corpus coverage, and CUDA remain open. + world-link/same-multibody fixed/spherical/revolute/prismatic masked rows plus + bounded same-multibody finite revolute/prismatic motors are partial; broad + finite-row fracture accounting/corpus coverage and CUDA remain open. 7. **A6 - unified rigid/soft coupling:** Couple rigid rows and deformable VBD rows in one AVBD solve so soft cloth/elastic bodies interact with articulated rigid chains and stacks. @@ -635,7 +635,13 @@ allocation policies pass, and [`avbd-articulated-compliant-joints-packet.json`](avbd-articulated-compliant-joints-packet.json) records solver identity, assessed capture, and candidate-only 3/12/48-joint scale data without a speedup claim. Finite one-DOF velocity-motor coupling, -same-multibody finite pairs, and finite-row break-force accounting remain +same-multibody finite pairs, and finite-row break-force accounting were the +next three gaps from that packet. The adjacent +[`avbd-articulated-compliant-motors-packet.json`](avbd-articulated-compliant-motors-packet.json) +now records mutation-sensitive same-multibody passive S/R/P coverage, bounded +finite revolute/prismatic motor rows, all three warmed allocation policies, +an assessed docked capture, and candidate-only 2/8/32-motor scale data. The +first two gaps are closed on CPU; finite-row break-force accounting remains missing. Non-topology multibody-link fixed/revolute/prismatic point-joint entities can also generate hard private configs from simulation-entry current @@ -766,8 +772,8 @@ renders the timing/stability sweep. No same-hardware paper-number comparison or GPU parity exists yet. DART still cannot reproduce the paper's full hard-constrained articulated chains, same-hardware numbers, or cloth-plus-articulated-rigid coupling scenes. The next articulated mechanism -slice should close finite one-DOF motor coupling or same-multibody finite pairs -before treating the A5 joint-family surface as broad. +slice should close finite-row load accounting and fracture lifecycle before +treating the A5 joint-family surface as broad. The next implementation slice should either optimize the measured `avbd_demo2d_ground`, `avbd_demo2d_motor`, `avbd_demo2d_hanging_rope`, `avbd_demo2d_rod`, diff --git a/python/examples/demos/README.md b/python/examples/demos/README.md index 18cdd5715ae60..52598ffba68a2 100644 --- a/python/examples/demos/README.md +++ b/python/examples/demos/README.md @@ -1410,6 +1410,7 @@ constraint/contact/motor signal instead of relying on screenshots alone. | `avbd_articulated_prismatic_motor_breakable_joint` | A weak world-anchored prismatic motor releasing and re-engaging an articulated carriage | Public articulated linear motor break/reset lifecycle through the bridge | | `avbd_articulated_world_revolute_motor_breakable_joint` | A weak world-anchored revolute motor releasing and re-engaging an articulated floating link | Public world-anchored articulated motor break/reset lifecycle through the bridge | | `avbd_articulated_compliant_joints` | Finite-stiffness spherical, revolute, and prismatic articulated joint masks | Public passive finite rows preserve each joint family's free coordinates | +| `avbd_articulated_compliant_motors` | Finite-stiffness revolute and prismatic motors between movable articulated link pairs | Public finite masked rows share free coordinates with bounded motor rows | | `avbd_articulated_high_ratio_chain` | A five-link variational chain swinging with a 200:1 heavy tip | Narrow articulated high mass-ratio smoke for the AVBD paper gap | | `avbd_paper_scale_high_ratio_chain` | A 50-link variational chain swinging with a 50,000:1 heavy tip | Paper-scale articulated high mass-ratio visual smoke for the AVBD paper gap | | `avbd_rigid_breakable_joint` | A weak fixed joint releasing and re-engaging a rigid payload | Public break-force threshold, reset, and broken-state path | diff --git a/python/examples/demos/registry.py b/python/examples/demos/registry.py index 3a3ee94a53242..8000da8ba853f 100644 --- a/python/examples/demos/registry.py +++ b/python/examples/demos/registry.py @@ -51,6 +51,9 @@ from .scenes.avbd_articulated_compliant_joints import ( SCENE as AVBD_ARTICULATED_COMPLIANT_JOINTS, ) +from .scenes.avbd_articulated_compliant_motors import ( + SCENE as AVBD_ARTICULATED_COMPLIANT_MOTORS, +) from .scenes.avbd_articulated_fixed_pair_breakable_joint import ( SCENE as AVBD_ARTICULATED_FIXED_PAIR_BREAKABLE_JOINT, ) @@ -312,6 +315,7 @@ def make_demo_scenes() -> list[PythonDemoScene]: AVBD_ARTICULATED_PRISMATIC_MOTOR_BREAKABLE_JOINT, AVBD_ARTICULATED_WORLD_REVOLUTE_MOTOR_BREAKABLE_JOINT, AVBD_ARTICULATED_COMPLIANT_JOINTS, + AVBD_ARTICULATED_COMPLIANT_MOTORS, AVBD_ARTICULATED_HIGH_RATIO_CHAIN, AVBD_PAPER_SCALE_HIGH_RATIO_CHAIN, AVBD_RIGID_BREAKABLE_JOINT, diff --git a/python/examples/demos/scenes/avbd_articulated_compliant_motors.py b/python/examples/demos/scenes/avbd_articulated_compliant_motors.py new file mode 100644 index 0000000000000..42057ef9e92c1 --- /dev/null +++ b/python/examples/demos/scenes/avbd_articulated_compliant_motors.py @@ -0,0 +1,364 @@ +"""Finite-stiffness AVBD articulated movable-pair motor showcase.""" + +from __future__ import annotations + +from collections import deque + +import numpy as np + +import dartpy as dart +import dartpy as sx + +from .._world_bridge import WorldRenderBridge +from ..runner import PythonDemoScene, ScenePanel, SceneSetup + +_TIME_STEP = 0.005 +_START_STIFFNESS = 20.0 +_LINEAR_STIFFNESS = 2000.0 +_ANGULAR_STIFFNESS = 2000.0 +_HINGE_SPEED = 0.8 +_SLIDER_SPEED = 0.45 +_MAX_EFFORT = 800.0 +_COMMAND_SWITCH_SECONDS = 0.30 +_AUTO_RESET_SECONDS = 1.20 +_HINGE_CENTER = np.array([-1.55, 0.0, 1.05]) +_SLIDER_CENTER = np.array([1.55, 0.0, 1.05]) +_PARENT_ANCHOR = np.array([0.45, 0.0, 0.0]) +_CHILD_ANCHOR = np.array([-0.45, 0.0, 0.0]) + + +def _translation(position: np.ndarray) -> np.ndarray: + transform = np.eye(4) + transform[:3, 3] = position + return transform + + +def _rotation_angle(rotation: np.ndarray) -> float: + cosine = 0.5 * (float(np.trace(rotation)) - 1.0) + return float(np.arccos(np.clip(cosine, -1.0, 1.0))) + + +def _link_rotation(link: sx.Link) -> np.ndarray: + return np.asarray(link.rotation, dtype=float).reshape(3, 3) + + +def _link_translation(link: sx.Link) -> np.ndarray: + return np.asarray(link.translation, dtype=float).reshape(3) + + +def _world_point(link: sx.Link, local_point: np.ndarray) -> np.ndarray: + return _link_rotation(link) @ local_point + _link_translation(link) + + +def _configure_finite_motor( + joint: sx.Joint, target_speed: float +) -> sx.Joint: + joint.actuator_type = sx.ActuatorType.VELOCITY + joint.command_velocity = [target_speed] + joint.set_effort_limits([-_MAX_EFFORT], [_MAX_EFFORT]) + policy = joint.constraint_projection_policy + policy.start_stiffness = _START_STIFFNESS + policy.linear_stiffness = _LINEAR_STIFFNESS + policy.angular_stiffness = _ANGULAR_STIFFNESS + joint.constraint_projection_policy = policy + return joint + + +def build() -> SceneSetup: + world = sx.World(time_step=_TIME_STEP, gravity=(0.0, 0.0, 0.0)) + world.multibody_options = sx.MultibodyOptions( + integration_family=sx.MultibodyIntegrationFamily.VARIATIONAL + ) + + robot = world.add_multibody("avbd_articulated_compliant_motors") + base = robot.add_link("avbd_articulated_compliant_motor_base") + + initial_positions = { + "hinge_parent": _HINGE_CENTER - _PARENT_ANCHOR, + "hinge_child": _HINGE_CENTER - _CHILD_ANCHOR, + "slider_parent": _SLIDER_CENTER - _PARENT_ANCHOR, + "slider_child": _SLIDER_CENTER - _CHILD_ANCHOR, + } + links: dict[str, sx.Link] = {} + for name, position in initial_positions.items(): + link = robot.add_link( + f"avbd_articulated_compliant_{name}", + parent=base, + joint=sx.JointSpec( + name=f"avbd_articulated_compliant_{name}_floating", + type=sx.JointType.FLOATING, + ), + ) + link.mass = 1.0 if name.endswith("parent") else 1.2 + link.inertia = ((0.16, 0.0, 0.0), (0.0, 0.22, 0.0), (0.0, 0.0, 0.28)) + link.parent_joint.position = [*position, 0.0, 0.0, 0.0] + links[name] = link + + hinge = _configure_finite_motor( + world.add_joint( + links["hinge_parent"], + links["hinge_child"], + sx.JointSpec( + name="avbd_articulated_compliant_hinge_motor", + type=sx.JointType.REVOLUTE, + axis=(0.0, 0.0, 1.0), + parent_anchor=_PARENT_ANCHOR.tolist(), + child_anchor=_CHILD_ANCHOR.tolist(), + ), + ), + _HINGE_SPEED, + ) + slider = _configure_finite_motor( + world.add_joint( + links["slider_parent"], + links["slider_child"], + sx.JointSpec( + name="avbd_articulated_compliant_slider_motor", + type=sx.JointType.PRISMATIC, + axis=(1.0, 0.0, 0.0), + parent_anchor=_PARENT_ANCHOR.tolist(), + child_anchor=_CHILD_ANCHOR.tolist(), + ), + ), + _SLIDER_SPEED, + ) + joints = {"hinge": hinge, "slider": slider} + + world.enter_simulation_mode() + + bridge = WorldRenderBridge( + world, name="avbd_articulated_compliant_motors_render" + ) + colors = { + "hinge_parent": (0.22, 0.52, 0.84), + "hinge_child": (0.95, 0.57, 0.16), + "slider_parent": (0.28, 0.70, 0.46), + "slider_child": (0.73, 0.42, 0.86), + } + shapes = { + "hinge_parent": dart.BoxShape(np.array([0.82, 0.18, 0.18])), + "hinge_child": dart.BoxShape(np.array([0.82, 0.18, 0.18])), + "slider_parent": dart.BoxShape(np.array([0.58, 0.28, 0.22])), + "slider_child": dart.BoxShape(np.array([0.58, 0.28, 0.22])), + } + for name, link in links.items(): + bridge.add_link_visual( + link, + shapes[name], + colors[name], + name=f"avbd_articulated_compliant_{name}_visual", + ) + + for name, center in ( + ("hinge", _HINGE_CENTER), + ("slider", _SLIDER_CENTER), + ): + marker = dart.SimpleFrame( + dart.gui.world_render_frame(), + f"avbd_articulated_compliant_{name}_anchor", + _translation(center), + ) + marker.set_shape(dart.SphereShape(0.075)) + marker.create_visual_aspect().set_color([0.86, 0.86, 0.80]) + bridge.render_world.add_simple_frame(marker) + + hinge_axis = dart.SimpleFrame( + dart.gui.world_render_frame(), + "avbd_articulated_compliant_motor_hinge_axis", + _translation(_HINGE_CENTER), + ) + hinge_axis.set_shape(dart.CylinderShape(0.025, 0.75)) + hinge_axis.create_visual_aspect().set_color([0.92, 0.38, 0.18]) + bridge.render_world.add_simple_frame(hinge_axis) + + slider_rail = dart.SimpleFrame( + dart.gui.world_render_frame(), + "avbd_articulated_compliant_motor_slider_rail", + _translation(_SLIDER_CENTER), + ) + slider_rail.set_shape(dart.BoxShape(np.array([2.6, 0.035, 0.035]))) + slider_rail.create_visual_aspect().set_color([0.74, 0.78, 0.84]) + bridge.render_world.add_simple_frame(slider_rail) + + replay_state = { + "enabled": True, + "last_reset": 0.0, + "direction": 1.0, + "cycles": 0, + } + + def metrics() -> dict[str, float]: + hinge_parent_rotation = _link_rotation(links["hinge_parent"]) + hinge_child_rotation = _link_rotation(links["hinge_child"]) + hinge_relative_rotation = ( + hinge_parent_rotation.T @ hinge_child_rotation + ) + hinge_anchor_delta = _world_point( + links["hinge_child"], _CHILD_ANCHOR + ) - _world_point(links["hinge_parent"], _PARENT_ANCHOR) + + slider_parent_rotation = _link_rotation(links["slider_parent"]) + slider_child_rotation = _link_rotation(links["slider_child"]) + slider_relative_rotation = ( + slider_parent_rotation.T @ slider_child_rotation + ) + slider_axis_world = slider_parent_rotation @ np.array([1.0, 0.0, 0.0]) + slider_anchor_delta = _world_point( + links["slider_child"], _CHILD_ANCHOR + ) - _world_point(links["slider_parent"], _PARENT_ANCHOR) + slider_travel = float(slider_anchor_delta @ slider_axis_world) + + return { + "hinge_angle": float( + np.arctan2( + hinge_relative_rotation[1, 0], + hinge_relative_rotation[0, 0], + ) + ), + "hinge_anchor_error": float(np.linalg.norm(hinge_anchor_delta)), + "hinge_axis_tilt": float( + np.linalg.norm( + hinge_parent_rotation @ np.array([0.0, 0.0, 1.0]) + - hinge_child_rotation @ np.array([0.0, 0.0, 1.0]) + ) + ), + "slider_travel": slider_travel, + "slider_transverse_error": float( + np.linalg.norm( + slider_anchor_delta - slider_travel * slider_axis_world + ) + ), + "slider_rotation_error": _rotation_angle( + slider_relative_rotation + ), + } + + def apply_commands() -> None: + direction = float(replay_state["direction"]) + hinge.command_velocity = [direction * _HINGE_SPEED] + slider.command_velocity = [direction * _SLIDER_SPEED] + + def reset_pairs() -> None: + for name, link in links.items(): + link.parent_joint.position = [ + *initial_positions[name], + 0.0, + 0.0, + 0.0, + ] + link.parent_joint.velocity = [0.0] * 6 + replay_state["last_reset"] = float(world.time) + replay_state["direction"] = 1.0 + replay_state["cycles"] = int(replay_state["cycles"]) + 1 + apply_commands() + bridge.sync() + + def replay_capture_state() -> dict[str, object]: + return dict(replay_state) + + def replay_restore_state(state: dict[str, object]) -> None: + replay_state["enabled"] = bool(state.get("enabled", True)) + replay_state["last_reset"] = float(state.get("last_reset", 0.0)) + replay_state["direction"] = float(state.get("direction", 1.0)) + replay_state["cycles"] = int(state.get("cycles", 0)) + apply_commands() + bridge.sync() + + def pre_step() -> None: + elapsed = float(world.time) - float(replay_state["last_reset"]) + if bool(replay_state["enabled"]): + switch_index = int(elapsed / _COMMAND_SWITCH_SECONDS) + replay_state["direction"] = 1.0 if switch_index % 2 == 0 else -1.0 + if elapsed >= _AUTO_RESET_SECONDS: + reset_pairs() + apply_commands() + bridge.pre_step() + + bridge.sync() + + hinge_history: deque[float] = deque(maxlen=160) + slider_history: deque[float] = deque(maxlen=160) + residual_history: deque[float] = deque(maxlen=160) + + def build_panel(builder: object, context: object) -> None: + values = metrics() + hinge_history.append(values["hinge_angle"]) + slider_history.append(values["slider_travel"]) + residual_history.append( + max( + values["hinge_anchor_error"], + values["slider_transverse_error"], + ) + ) + + builder.text("solver: AVBD finite masked rows + bounded motor rows") + builder.text("endpoints: two movable same-multibody link pairs") + builder.text(f"start stiffness: {_START_STIFFNESS:.1f}") + builder.text(f"linear/angular cap: {_LINEAR_STIFFNESS:.1f}") + builder.text(f"effort cap: {_MAX_EFFORT:.1f}") + builder.text(f"command direction: {int(replay_state['direction']):+d}") + builder.text(f"hinge angle: {values['hinge_angle']:.3f} rad") + builder.text( + f"hinge anchor error: {values['hinge_anchor_error']:.4f} m" + ) + builder.text(f"hinge axis tilt: {values['hinge_axis_tilt']:.4f}") + builder.text(f"slider travel: {values['slider_travel']:.3f} m") + builder.text( + "slider transverse error: " + f"{values['slider_transverse_error']:.4f} m" + ) + builder.text( + f"slider rotation error: {values['slider_rotation_error']:.4f} rad" + ) + builder.text(f"replay cycles: {int(replay_state['cycles'])}") + if builder.button("Reset motor pairs"): + reset_pairs() + changed, enabled = builder.checkbox( + "Loop replay", + bool(replay_state["enabled"]), + ) + if changed: + replay_state["enabled"] = bool(enabled) + replay_state["last_reset"] = float(world.time) + builder.plot_lines("Hinge angle", list(hinge_history)) + builder.plot_lines("Slider travel", list(slider_history)) + builder.plot_lines("Max constrained residual", list(residual_history)) + builder.separator() + bridge.build_control_panel(builder, context) + + return SceneSetup( + world=bridge.render_world, + pre_step=pre_step, + force_drag=bridge.force_drag, + panels=[ScenePanel("AVBD Articulated Compliant Motors", build_panel)], + info={ + "sx_world": world, + "base": base, + "links": links, + "joints": joints, + "metrics": metrics, + "reset_pairs": reset_pairs, + "replay_state": replay_state, + "replay_capture_state": replay_capture_state, + "replay_restore_state": replay_restore_state, + "time_step": _TIME_STEP, + "start_stiffness": _START_STIFFNESS, + "linear_stiffness": _LINEAR_STIFFNESS, + "angular_stiffness": _ANGULAR_STIFFNESS, + "hinge_speed": _HINGE_SPEED, + "slider_speed": _SLIDER_SPEED, + "max_effort": _MAX_EFFORT, + "command_switch_seconds": _COMMAND_SWITCH_SECONDS, + "auto_reset_seconds": _AUTO_RESET_SECONDS, + }, + ) + + +SCENE = PythonDemoScene( + id="avbd_articulated_compliant_motors", + title="AVBD Articulated Compliant Motors (sx)", + category="AVBD Rigid Constraints (sx)", + summary="Finite-stiffness same-multibody revolute and prismatic pairs " + "share their free coordinates with bounded velocity-motor rows.", + build=build, +) diff --git a/python/tests/integration/test_demos_cycle.py b/python/tests/integration/test_demos_cycle.py index 0f769782a9b6b..f0fb8c73c7ab6 100644 --- a/python/tests/integration/test_demos_cycle.py +++ b/python/tests/integration/test_demos_cycle.py @@ -936,6 +936,7 @@ def test_world_scenes_use_solver_focused_categories() -> None: "avbd_articulated_prismatic_motor_breakable_joint", "avbd_articulated_world_revolute_motor_breakable_joint", "avbd_articulated_compliant_joints", + "avbd_articulated_compliant_motors", "avbd_articulated_high_ratio_chain", "avbd_rigid_breakable_joint", "avbd_rigid_spherical_breakable_joint", @@ -11917,6 +11918,77 @@ def test_avbd_articulated_compliant_joints_demo_preserves_free_coordinates() -> ) +def test_avbd_articulated_compliant_motors_demo_drives_free_coordinates() -> None: + import numpy as np + + sx = _require_simulation_experimental_symbols("World", "MultibodyOptions") + + from examples.demos.scenes.avbd_articulated_compliant_motors import build + + setup = build() + sx_world = setup.info["sx_world"] + links = setup.info["links"] + joints = setup.info["joints"] + metrics = setup.info["metrics"] + + assert ( + sx_world.multibody_options.integration_family + == sx.MultibodyIntegrationFamily.VARIATIONAL + ) + assert set(joints) == {"hinge", "slider"} + assert joints["hinge"].type == sx.JointType.REVOLUTE + assert joints["slider"].type == sx.JointType.PRISMATIC + assert all( + joint.actuator_type == sx.ActuatorType.VELOCITY + for joint in joints.values() + ) + assert all( + joint.constraint_projection_policy.start_stiffness + == pytest.approx(setup.info["start_stiffness"]) + for joint in joints.values() + ) + assert all( + joint.constraint_projection_policy.linear_stiffness + == pytest.approx(setup.info["linear_stiffness"]) + for joint in joints.values() + ) + assert all( + joint.constraint_projection_policy.angular_stiffness + == pytest.approx(setup.info["angular_stiffness"]) + for joint in joints.values() + ) + + setup.info["replay_state"]["enabled"] = False + steps = 80 + for _ in range(steps): + assert setup.pre_step is not None + setup.pre_step() + setup.world.step() + + values = metrics() + assert all(np.isfinite(value) for value in values.values()) + assert values["hinge_angle"] == pytest.approx( + setup.info["hinge_speed"] * setup.info["time_step"] * steps, + abs=2.0e-2, + ) + assert values["hinge_anchor_error"] < 1.0e-2 + assert values["hinge_axis_tilt"] < 1.0e-2 + assert values["slider_travel"] == pytest.approx( + setup.info["slider_speed"] * setup.info["time_step"] * steps, + abs=2.0e-2, + ) + assert values["slider_transverse_error"] < 1.0e-2 + assert values["slider_rotation_error"] < 1.0e-2 + + setup.info["reset_pairs"]() + reset_values = metrics() + assert all(abs(value) < 1.0e-12 for value in reset_values.values()) + assert all( + np.all(np.isfinite(np.asarray(link.transform, dtype=float))) + for link in links.values() + ) + + def test_avbd_articulated_high_ratio_chain_demo_swings_and_resets() -> None: import numpy as np diff --git a/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py b/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py new file mode 100644 index 0000000000000..62bc1bb8b8b3e --- /dev/null +++ b/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +import importlib.util +import json +import struct +import sys +import zlib +from hashlib import sha256 +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ( + ROOT / "scripts" / "write_avbd_articulated_compliant_motors_packet.py" +) + + +def _load_packet_module(): + spec = importlib.util.spec_from_file_location( + "write_avbd_articulated_compliant_motors_packet", + SCRIPT, + ) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _png_chunk(kind: bytes, payload: bytes) -> bytes: + checksum = zlib.crc32(kind + payload) & 0xFFFFFFFF + return ( + struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", checksum) + ) + + +def _write_png(path: Path, width: int = 4, height: int = 3) -> None: + rows = [b"\x00" + (b"\x10\x20\x30" * width) for _ in range(height)] + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + _png_chunk(b"IDAT", zlib.compress(b"".join(rows), 6)) + + _png_chunk(b"IEND", b"") + ) + + +def _write_capture_manifest( + tmp_path: Path, + *, + scene: str = "avbd_articulated_compliant_motors", + docked_workspace: bool = True, +) -> Path: + capture = tmp_path / "capture" + frames = capture / "png_frames" + frames.mkdir(parents=True) + screenshot = capture / "avbd_articulated_compliant_motors.png" + _write_png(screenshot) + _write_png(frames / "frame_000001.png") + _write_png(frames / "frame_000002.png") + manifest = { + "artifacts": { + "events": None, + "frames": str(frames), + "screenshot": str(screenshot), + }, + "force_drag": None, + "scene": scene, + "schema_version": 1, + "show_ui": True, + "switch_frame": None, + "switch_scene": None, + "ui_ready": { + "dropped_warmup_frames": 0, + "required": True, + }, + "visual_evidence": { + "first_frame": {"docked_workspace": docked_workspace}, + "screenshot": {"docked_workspace": docked_workspace}, + }, + } + path = capture / "manifest.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + return path + + +def _sha256(path: Path) -> str: + return sha256(path.read_bytes()).hexdigest() + + +def _write_image_verdict(tmp_path: Path) -> Path: + path = tmp_path / "capture" / "image_verdict.json" + path.write_text( + json.dumps( + { + "checks": { + "contrast": {"pass": False}, + "non_blank": {"pass": True}, + }, + "image": { + "height": 3, + "path": "avbd_articulated_compliant_motors.png", + "width": 4, + }, + "machine_scope": "pixel-integrity", + "metadata": { + "scene": "avbd_articulated_compliant_motors", + "view": "three-quarter", + }, + "pass": True, + "schema_version": "dart.image_verdict/v1", + } + ), + encoding="utf-8", + ) + return path + + +def _write_visual_review(tmp_path: Path) -> Path: + capture = tmp_path / "capture" + frames = capture / "png_frames" + selected = { + "start": frames / "frame_000001.png", + "middle": frames / "frame_000001.png", + "end": frames / "frame_000002.png", + } + path = capture / "visual_review.json" + path.write_text( + json.dumps( + { + "inspected_frames": [ + { + "file": f"png_frames/{frame.name}", + "role": role, + "sha256": _sha256(frame), + } + for role, frame in selected.items() + ], + "limitations": "Images corroborate bounded motion but do not measure row residuals.", + "observations": "All three bodies remain finite and aligned with their visible guides.", + "reviewer_capability": "native image input", + "scene": "avbd_articulated_compliant_motors", + "schema_version": "dart.visual_semantic_review/v1", + "verdict": "pass", + } + ), + encoding="utf-8", + ) + return path + + +def _write_benchmark_json( + tmp_path: Path, + *, + missing_arg: int | None = None, + bad_compliant_count_arg: int | None = None, +) -> Path: + rows = [] + for arg in (1, 4, 16): + if arg == missing_arg: + continue + compliant_motors = 2 * arg + if arg == bad_compliant_count_arg: + compliant_motors += 1 + rows.append( + { + "aggregate_name": "median", + "compliant_motors": float(compliant_motors), + "cpu_time": float(1000 * arg), + "family_instances": float(arg), + "iterations": 10, + "name": f"BM_AvbdArticulatedCompliantMotorStep/{arg}_median", + "prismatic_motors": float(arg), + "real_time": float(1100 * arg), + "revolute_motors": float(arg), + "run_name": f"BM_AvbdArticulatedCompliantMotorStep/{arg}", + "run_type": "aggregate", + "time_unit": "ns", + } + ) + benchmark = { + "benchmarks": rows, + "context": { + "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "v1.9.5", + "mhz_per_cpu": 5300, + "num_cpus": 32, + }, + } + path = tmp_path / "benchmark.json" + path.write_text(json.dumps(benchmark), encoding="utf-8") + return path + + +def _write_exact_parent_mutation( + tmp_path: Path, + *, + parent_commit: str = "761263bbd41190536ce699439ebc64f5776757b6", +) -> Path: + path = tmp_path / "exact_parent_mutation.json" + path.write_text( + json.dumps( + { + "schema_version": "dart.exact_parent_mutation/v1", + "parent_commit": parent_commit, + "test": ( + "VariationalIntegration." + "AvbdCompliantPublicArticulatedOneDofMotors" + "DriveMovableLinkPairs" + ), + "mutation": ( + "Apply the candidate behavior test to the exact parent " + "without the solver implementation." + ), + "command": ( + "./test_variational_integration " + "--gtest_filter=VariationalIntegration." + "AvbdCompliantPublicArticulatedOneDofMotors" + "DriveMovableLinkPairs" + ), + "exit_code": 1, + "expected_result": "fail", + "observed_result": "fail", + "failed_assertions": [ + { + "coordinate": coordinate, + "actual": 0.0, + "expected": expected, + } + for coordinate, expected in ( + ("strong finite revolute motor angle", 0.04), + ("strong finite prismatic motor travel", 0.03), + ) + ], + "interpretation": ( + "The exact parent skips the finite articulated rows." + ), + } + ), + encoding="utf-8", + ) + return path + + +def test_avbd_articulated_compliant_motors_packet_records_evidence( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest(tmp_path) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path) + exact_parent_mutation = _write_exact_parent_mutation(tmp_path) + output = tmp_path / "packet.json" + + assert ( + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + "--output", + str(output), + ] + ) + == 0 + ) + + packet = json.loads(output.read_text()) + assert packet["schema_version"] == 2 + assert packet["scene"] == "avbd_articulated_compliant_motors" + assert packet["resolved_solver_identity"] == { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": ( + "contact-free py-demo and articulated compliant-motor " + "benchmark row family" + ), + "rigid_contact_solver": "none", + "rigid_point_joint_solver": "avbd", + } + assert packet["scene_invariants"]["motor_types"] == ["revolute", "prismatic"] + assert packet["scene_invariants"]["endpoint_topology"] == ( + "same-multibody movable-link pairs" + ) + assert packet["target"]["contract_rows"] == [ + "avbd.method.joints_and_attachments", + "avbd.method.finite_stiffness_ramping", + "avbd.method.motors", + ] + assert packet["visual_capture"]["frames"]["count"] == 2 + assert packet["benchmark"]["benchmark"] == ( + "BM_AvbdArticulatedCompliantMotorStep" + ) + assert [row["compliant_motors"] for row in packet["benchmark"]["scale_data"]] == [ + 2, + 8, + 32, + ] + assert packet["benchmark"]["scale_data"][0]["cpu_time_per_motor_ns"] == ( + pytest.approx(1000.0 / 2.0) + ) + assert packet["correctness"]["exact_parent_mutation"]["parent_commit"] == ( + "761263bbd41190536ce699439ebc64f5776757b6" + ) + assert packet["correctness"]["exact_parent_mutation"]["observed_result"] == ( + "fail" + ) + assert "finite articulated one-DOF velocity-motor coupling" not in packet[ + "remaining_gates" + ] + assert "same-multibody finite articulated endpoint pairs" not in packet[ + "remaining_gates" + ] + assert ( + "finite-row break-force accounting and fracture lifecycle" + in packet["remaining_gates"] + ) + + +def test_avbd_articulated_compliant_motors_packet_rejects_wrong_scene( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest( + tmp_path, scene="avbd_articulated_high_ratio_chain" + ) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path) + exact_parent_mutation = _write_exact_parent_mutation(tmp_path) + + with pytest.raises( + SystemExit, + match="capture scene must be avbd_articulated_compliant_motors", + ): + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + ] + ) + + +def test_avbd_articulated_compliant_motors_packet_rejects_undocked_capture( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest( + tmp_path, docked_workspace=False + ) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path) + exact_parent_mutation = _write_exact_parent_mutation(tmp_path) + + with pytest.raises( + SystemExit, + match="capture must verify the docked workspace", + ): + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + ] + ) + + +def test_avbd_articulated_compliant_motors_packet_rejects_missing_scale_row( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest(tmp_path) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path, missing_arg=4) + exact_parent_mutation = _write_exact_parent_mutation(tmp_path) + + with pytest.raises( + SystemExit, + match="missing compliant-motor scale rows: 4", + ): + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + ] + ) + + +def test_avbd_articulated_compliant_motors_packet_rejects_wrong_counter( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest(tmp_path) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path, bad_compliant_count_arg=16) + exact_parent_mutation = _write_exact_parent_mutation(tmp_path) + + with pytest.raises( + SystemExit, + match="expected compliant_motors=32, got 33", + ): + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + ] + ) + + +def test_avbd_articulated_compliant_motors_packet_rejects_wrong_parent( + tmp_path: Path, +) -> None: + module = _load_packet_module() + capture_manifest = _write_capture_manifest(tmp_path) + image_verdict = _write_image_verdict(tmp_path) + visual_review = _write_visual_review(tmp_path) + benchmark_json = _write_benchmark_json(tmp_path) + exact_parent_mutation = _write_exact_parent_mutation( + tmp_path, parent_commit="deadbeef" + ) + + with pytest.raises( + SystemExit, + match="exact-parent mutation parent_commit must be 761263bbd4", + ): + module.main( + [ + "--capture-manifest", + str(capture_manifest), + "--image-verdict-json", + str(image_verdict), + "--visual-review-json", + str(visual_review), + "--benchmark-json", + str(benchmark_json), + "--exact-parent-mutation-json", + str(exact_parent_mutation), + ] + ) diff --git a/scripts/write_avbd_articulated_compliant_motors_packet.py b/scripts/write_avbd_articulated_compliant_motors_packet.py new file mode 100644 index 0000000000000..e145b903f63dd --- /dev/null +++ b/scripts/write_avbd_articulated_compliant_motors_packet.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +"""Write a validated AVBD articulated compliant-motor evidence packet.""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from avbd_packet_schema import ( # noqa: E402 + AVBD_PACKET_SCHEMA_VERSION, + make_resolved_solver_identity, +) +from write_avbd_demo3d_static_friction_packet import ( # noqa: E402 + _artifact_label, + _artifact_path, + _canonical_name, + _load_json, + _png_dimensions, + _row_name, + _sha256, +) + +DEFAULT_OUTPUT = Path( + "docs/plans/104-vertex-block-descent-solver/" + "avbd-articulated-compliant-motors-packet.json" +) +SCENE_ID = "avbd_articulated_compliant_motors" +BENCHMARK_NAME = "BM_AvbdArticulatedCompliantMotorStep" +BENCHMARK_ARGS = (1, 4, 16) +EXACT_PARENT_COMMIT = "761263bbd41190536ce699439ebc64f5776757b6" +BEHAVIOR_TEST = ( + "VariationalIntegration." + "AvbdCompliantPublicArticulatedOneDofMotorsDriveMovableLinkPairs" +) +PASSIVE_PAIR_TEST = ( + "VariationalIntegration." + "AvbdCompliantPublicArticulatedJointMasksSupportMovableLinkPairs" +) +TIME_STEP = 0.005 +START_STIFFNESS = 20.0 +LINEAR_STIFFNESS = 2000.0 +ANGULAR_STIFFNESS = 2000.0 +EFFORT_LIMIT = 800.0 +RESOLVED_SOLVER_IDENTITY = make_resolved_solver_identity( + resolved_rigid_contact_family=None, + rigid_point_joint_solver="avbd", + avbd_rigid_contact_config_emplaced=False, + recorded_from=( + "contact-free py-demo and articulated compliant-motor benchmark row family" + ), +) + + +class AvbdArticulatedCompliantMotorsPacketError(RuntimeError): + pass + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--capture-manifest", type=Path, required=True) + parser.add_argument("--image-verdict-json", type=Path, required=True) + parser.add_argument("--visual-review-json", type=Path, required=True) + parser.add_argument("--benchmark-json", type=Path, required=True) + parser.add_argument("--exact-parent-mutation-json", type=Path, required=True) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + return parser.parse_args(argv) + + +def _capture_artifact_path( + manifest_dir: Path, + value: object, + key: str, +) -> Path: + if isinstance(value, str) and value: + path = Path(value) + if path.is_absolute(): + return path + repo_relative = SCRIPT_DIR.parent / path + if repo_relative.exists(): + return repo_relative + return _artifact_path(manifest_dir, value, key) + + +def _validate_capture( + manifest_path: Path, +) -> tuple[dict[str, Any], Path, dict[str, Path]]: + manifest = _load_json(manifest_path) + if manifest.get("schema_version") != 1: + raise AvbdArticulatedCompliantMotorsPacketError( + "capture manifest schema_version must be 1" + ) + if manifest.get("scene") != SCENE_ID: + raise AvbdArticulatedCompliantMotorsPacketError( + f"capture scene must be {SCENE_ID}" + ) + if manifest.get("switch_scene") is not None: + raise AvbdArticulatedCompliantMotorsPacketError( + "compliant-motor capture must not switch scenes" + ) + if manifest.get("force_drag") is not None: + raise AvbdArticulatedCompliantMotorsPacketError( + "compliant-motor capture must not force-drag" + ) + + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict): + raise AvbdArticulatedCompliantMotorsPacketError( + "capture manifest missing artifacts" + ) + manifest_dir = manifest_path.parent + screenshot = _capture_artifact_path( + manifest_dir, artifacts.get("screenshot"), "screenshot" + ) + frames_dir = _capture_artifact_path(manifest_dir, artifacts.get("frames"), "frames") + if not screenshot.is_file(): + raise AvbdArticulatedCompliantMotorsPacketError( + f"{screenshot}: screenshot not found" + ) + if not frames_dir.is_dir(): + raise AvbdArticulatedCompliantMotorsPacketError( + f"{frames_dir}: frame directory not found" + ) + + frame_paths = sorted(frames_dir.glob("frame_*.png")) + if not frame_paths: + raise AvbdArticulatedCompliantMotorsPacketError( + f"{frames_dir}: no PNG frames found" + ) + width, height = _png_dimensions(screenshot) + if _png_dimensions(frame_paths[0]) != (width, height): + raise AvbdArticulatedCompliantMotorsPacketError( + "first frame dimensions do not match screenshot" + ) + if _png_dimensions(frame_paths[-1]) != (width, height): + raise AvbdArticulatedCompliantMotorsPacketError( + "last frame dimensions do not match screenshot" + ) + ui_ready = manifest.get("ui_ready") + if not isinstance(ui_ready, dict): + raise AvbdArticulatedCompliantMotorsPacketError( + "capture manifest missing ui_ready" + ) + if manifest.get("show_ui") is not True or ui_ready.get("required") is not True: + raise AvbdArticulatedCompliantMotorsPacketError( + "compliant-motor capture must show the required docked workspace" + ) + visual_evidence = manifest.get("visual_evidence") + if not isinstance(visual_evidence, dict) or any( + not isinstance(visual_evidence.get(role), dict) + or visual_evidence[role].get("docked_workspace") is not True + for role in ("first_frame", "screenshot") + ): + raise AvbdArticulatedCompliantMotorsPacketError( + "compliant-motor capture must verify the docked workspace" + ) + + middle_frame = frame_paths[(len(frame_paths) - 1) // 2] + return ( + { + "manifest_sha256": _sha256(manifest_path), + "scene": SCENE_ID, + "show_ui": bool(manifest.get("show_ui")), + "ui_ready": ui_ready, + "screenshot": { + "file": _artifact_label(manifest_dir, screenshot), + "sha256": _sha256(screenshot), + "width": width, + "height": height, + }, + "frames": { + "directory": _artifact_label(manifest_dir, frames_dir), + "count": len(frame_paths), + "first_frame": { + "file": _artifact_label(manifest_dir, frame_paths[0]), + "sha256": _sha256(frame_paths[0]), + }, + "last_frame": { + "file": _artifact_label(manifest_dir, frame_paths[-1]), + "sha256": _sha256(frame_paths[-1]), + }, + }, + }, + screenshot, + { + "start": frame_paths[0], + "middle": middle_frame, + "end": frame_paths[-1], + }, + ) + + +def _validate_image_verdict( + verdict_path: Path, + screenshot: Path, +) -> dict[str, Any]: + verdict = _load_json(verdict_path) + if verdict.get("schema_version") != "dart.image_verdict/v1": + raise AvbdArticulatedCompliantMotorsPacketError( + "image verdict schema_version must be dart.image_verdict/v1" + ) + if verdict.get("pass") is not True: + raise AvbdArticulatedCompliantMotorsPacketError("image verdict must pass") + checks = verdict.get("checks") + if not isinstance(checks, dict): + raise AvbdArticulatedCompliantMotorsPacketError("image verdict missing checks") + non_blank = checks.get("non_blank") + if not isinstance(non_blank, dict) or non_blank.get("pass") is not True: + raise AvbdArticulatedCompliantMotorsPacketError( + "image verdict non_blank check must pass" + ) + image = verdict.get("image") + if not isinstance(image, dict): + raise AvbdArticulatedCompliantMotorsPacketError( + "image verdict missing image metadata" + ) + if ( + image.get("width") != _png_dimensions(screenshot)[0] + or image.get("height") != _png_dimensions(screenshot)[1] + ): + raise AvbdArticulatedCompliantMotorsPacketError( + "image verdict dimensions do not match capture screenshot" + ) + return { + "file": verdict_path.name, + "sha256": _sha256(verdict_path), + "machine_scope": verdict.get("machine_scope"), + "metadata": verdict.get("metadata"), + "checks": checks, + "pass": True, + } + + +def _validate_visual_review( + review_path: Path, + manifest_path: Path, + inspected_frames: dict[str, Path], +) -> dict[str, Any]: + review = _load_json(review_path) + if review.get("schema_version") != "dart.visual_semantic_review/v1": + raise AvbdArticulatedCompliantMotorsPacketError( + "visual review schema_version must be " "dart.visual_semantic_review/v1" + ) + if review.get("scene") != SCENE_ID: + raise AvbdArticulatedCompliantMotorsPacketError( + f"visual review scene must be {SCENE_ID}" + ) + if review.get("verdict") != "pass": + raise AvbdArticulatedCompliantMotorsPacketError( + "visual semantic review must pass" + ) + observations = review.get("observations") + limitations = review.get("limitations") + if not isinstance(observations, str) or not observations.strip(): + raise AvbdArticulatedCompliantMotorsPacketError( + "visual review observations must be non-empty" + ) + if not isinstance(limitations, str) or not limitations.strip(): + raise AvbdArticulatedCompliantMotorsPacketError( + "visual review limitations must be non-empty" + ) + + entries = review.get("inspected_frames") + if not isinstance(entries, list): + raise AvbdArticulatedCompliantMotorsPacketError( + "visual review inspected_frames must be a list" + ) + by_role = { + entry.get("role"): entry + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("role"), str) + } + manifest_dir = manifest_path.parent + validated_frames = [] + for role, frame_path in inspected_frames.items(): + entry = by_role.get(role) + if entry is None: + raise AvbdArticulatedCompliantMotorsPacketError( + f"visual review missing {role} frame" + ) + expected_file = _artifact_label(manifest_dir, frame_path) + if entry.get("file") != expected_file: + raise AvbdArticulatedCompliantMotorsPacketError( + f"visual review {role} frame must be {expected_file}" + ) + expected_sha = _sha256(frame_path) + if entry.get("sha256") != expected_sha: + raise AvbdArticulatedCompliantMotorsPacketError( + f"visual review {role} frame sha256 mismatch" + ) + validated_frames.append( + { + "role": role, + "file": expected_file, + "sha256": expected_sha, + } + ) + return { + "file": review_path.name, + "sha256": _sha256(review_path), + "reviewer_capability": review.get("reviewer_capability"), + "inspected_frames": validated_frames, + "observations": observations, + "limitations": limitations, + "verdict": "pass", + } + + +def _finite_counter(row: dict[str, Any], key: str) -> float: + value = row.get(key) + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise AvbdArticulatedCompliantMotorsPacketError( + f"{BENCHMARK_NAME}: missing {key}" + ) + value = float(value) + if not math.isfinite(value): + raise AvbdArticulatedCompliantMotorsPacketError( + f"{BENCHMARK_NAME}: non-finite {key}" + ) + return value + + +def _require_counter(row: dict[str, Any], key: str, expected: float) -> None: + value = _finite_counter(row, key) + if not math.isclose(value, expected, rel_tol=0.0, abs_tol=1e-15): + raise AvbdArticulatedCompliantMotorsPacketError( + f"{BENCHMARK_NAME}: expected {key}={expected:g}, got {value:g}" + ) + + +def _benchmark_arg(row: dict[str, Any]) -> int | None: + identity = _canonical_name(_row_name(row)) + prefix = f"{BENCHMARK_NAME}/" + if not identity.startswith(prefix): + return None + arg_text = identity[len(prefix) :] + try: + arg = int(arg_text) + except ValueError as exc: + raise AvbdArticulatedCompliantMotorsPacketError( + f"{BENCHMARK_NAME}: non-integer benchmark argument {arg_text!r}" + ) from exc + if arg not in BENCHMARK_ARGS: + raise AvbdArticulatedCompliantMotorsPacketError( + f"{BENCHMARK_NAME}: unexpected family_instances argument {arg}" + ) + return arg + + +def _is_representative(row: dict[str, Any]) -> bool: + run_type = row.get("run_type", "iteration") + aggregate_name = row.get("aggregate_name") + return run_type == "iteration" or aggregate_name in ("mean", "median") + + +def _timing_row(rows: list[dict[str, Any]]) -> dict[str, Any]: + median_rows = [row for row in rows if row.get("aggregate_name") == "median"] + if median_rows: + return median_rows[0] + mean_rows = [row for row in rows if row.get("aggregate_name") == "mean"] + if mean_rows: + return mean_rows[0] + return rows[0] + + +def _validate_benchmark(benchmark_json: Path) -> dict[str, Any]: + data = _load_json(benchmark_json) + rows = data.get("benchmarks") + if not isinstance(rows, list): + raise AvbdArticulatedCompliantMotorsPacketError( + "benchmark JSON missing benchmarks list" + ) + + representative: dict[int, list[dict[str, Any]]] = { + arg: [] for arg in BENCHMARK_ARGS + } + packet_rows: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + arg = _benchmark_arg(row) + if arg is None: + continue + for key in ("real_time", "cpu_time"): + _finite_counter(row, key) + packet_rows.append(row) + if not _is_representative(row): + continue + _require_counter(row, "family_instances", float(arg)) + _require_counter(row, "revolute_motors", float(arg)) + _require_counter(row, "prismatic_motors", float(arg)) + _require_counter(row, "compliant_motors", float(2 * arg)) + representative[arg].append(row) + + missing = [arg for arg in BENCHMARK_ARGS if not representative[arg]] + if missing: + raise AvbdArticulatedCompliantMotorsPacketError( + "benchmark JSON missing compliant-motor scale rows: " + + ", ".join(str(arg) for arg in missing) + ) + + scale_data = [] + for arg in BENCHMARK_ARGS: + row = _timing_row(representative[arg]) + compliant_motors = 2 * arg + cpu_time = _finite_counter(row, "cpu_time") + real_time = _finite_counter(row, "real_time") + scale_data.append( + { + "benchmark": f"{BENCHMARK_NAME}/{arg}", + "family_instances": arg, + "compliant_motors": compliant_motors, + "cpu_time_per_step_ns": cpu_time, + "real_time_per_step_ns": real_time, + "cpu_time_per_motor_ns": cpu_time / compliant_motors, + "time_unit": row.get("time_unit", "ns"), + } + ) + + context = data.get("context", {}) + if not isinstance(context, dict): + context = {} + return { + "json_sha256": _sha256(benchmark_json), + "benchmark": BENCHMARK_NAME, + "context": { + key: context[key] + for key in ( + "executable", + "num_cpus", + "mhz_per_cpu", + "library_version", + "library_build_type", + "json_schema_version", + ) + if key in context + }, + "rows": packet_rows, + "scale_data": scale_data, + "invariants": { + "family_instances": list(BENCHMARK_ARGS), + "motor_types": ["revolute", "prismatic"], + "endpoint_topology": "same-multibody movable-link pairs", + "time_step": TIME_STEP, + "start_stiffness": START_STIFFNESS, + "linear_stiffness": LINEAR_STIFFNESS, + "angular_stiffness": ANGULAR_STIFFNESS, + "effort_limit": EFFORT_LIMIT, + }, + } + + +def _validate_exact_parent_mutation(path: Path) -> dict[str, Any]: + evidence = _load_json(path) + if evidence.get("schema_version") != "dart.exact_parent_mutation/v1": + raise AvbdArticulatedCompliantMotorsPacketError( + "exact-parent mutation schema_version must be " + "dart.exact_parent_mutation/v1" + ) + if evidence.get("parent_commit") != EXACT_PARENT_COMMIT: + raise AvbdArticulatedCompliantMotorsPacketError( + f"exact-parent mutation parent_commit must be {EXACT_PARENT_COMMIT}" + ) + if evidence.get("test") != BEHAVIOR_TEST: + raise AvbdArticulatedCompliantMotorsPacketError( + f"exact-parent mutation test must be {BEHAVIOR_TEST}" + ) + if evidence.get("exit_code") != 1: + raise AvbdArticulatedCompliantMotorsPacketError( + "exact-parent mutation exit_code must be 1" + ) + if ( + evidence.get("expected_result") != "fail" + or evidence.get("observed_result") != "fail" + ): + raise AvbdArticulatedCompliantMotorsPacketError( + "exact-parent mutation must record expected and observed failure" + ) + + assertions = evidence.get("failed_assertions") + if not isinstance(assertions, list) or len(assertions) != 2: + raise AvbdArticulatedCompliantMotorsPacketError( + "exact-parent mutation must record both strong-motor failures" + ) + validated_assertions = [] + for assertion in assertions: + if not isinstance(assertion, dict): + raise AvbdArticulatedCompliantMotorsPacketError( + "exact-parent mutation assertions must be objects" + ) + coordinate = assertion.get("coordinate") + actual = assertion.get("actual") + expected = assertion.get("expected") + if not isinstance(coordinate, str) or not coordinate.strip(): + raise AvbdArticulatedCompliantMotorsPacketError( + "exact-parent mutation assertion coordinate must be non-empty" + ) + if ( + not isinstance(actual, (int, float)) + or isinstance(actual, bool) + or not math.isfinite(float(actual)) + or not isinstance(expected, (int, float)) + or isinstance(expected, bool) + or not math.isfinite(float(expected)) + or float(expected) <= 0.0 + or abs(float(actual)) >= 0.1 * float(expected) + ): + raise AvbdArticulatedCompliantMotorsPacketError( + "exact-parent mutation assertions must record finite near-zero " + "actual values against positive driven expectations" + ) + validated_assertions.append( + { + "coordinate": coordinate, + "actual": float(actual), + "expected": float(expected), + } + ) + + mutation = evidence.get("mutation") + command = evidence.get("command") + interpretation = evidence.get("interpretation") + for label, value in ( + ("mutation", mutation), + ("command", command), + ("interpretation", interpretation), + ): + if not isinstance(value, str) or not value.strip(): + raise AvbdArticulatedCompliantMotorsPacketError( + f"exact-parent mutation {label} must be non-empty" + ) + return { + "file": path.name, + "sha256": _sha256(path), + "parent_commit": EXACT_PARENT_COMMIT, + "test": BEHAVIOR_TEST, + "mutation": mutation, + "command": command, + "exit_code": 1, + "observed_result": "fail", + "failed_assertions": validated_assertions, + "interpretation": interpretation, + } + + +def make_packet( + capture_manifest: Path, + image_verdict_json: Path, + visual_review_json: Path, + benchmark_json: Path, + exact_parent_mutation_json: Path, +) -> dict[str, Any]: + visual_capture, screenshot, inspected_frames = _validate_capture(capture_manifest) + visual_capture["image_verdict"] = _validate_image_verdict( + image_verdict_json, screenshot + ) + visual_capture["semantic_review"] = _validate_visual_review( + visual_review_json, capture_manifest, inspected_frames + ) + return { + "schema_version": AVBD_PACKET_SCHEMA_VERSION, + "resolved_solver_identity": RESOLVED_SOLVER_IDENTITY, + "packet": "avbd_articulated_compliant_motors", + "scene": SCENE_ID, + "target": { + "contract_rows": [ + "avbd.method.joints_and_attachments", + "avbd.method.finite_stiffness_ramping", + "avbd.method.motors", + ], + "scope": ( + "bounded finite-stiffness articulated revolute and prismatic " + "velocity motors between movable links of one multibody on CPU" + ), + "complete_paper_reproduction": False, + }, + "scene_invariants": { + "motor_types": ["revolute", "prismatic"], + "endpoint_topology": "same-multibody movable-link pairs", + "free_coordinates": { + "revolute": "rotation about the joint axis", + "prismatic": "translation along the joint axis", + }, + "time_step": TIME_STEP, + "start_stiffness": START_STIFFNESS, + "linear_stiffness": LINEAR_STIFFNESS, + "angular_stiffness": ANGULAR_STIFFNESS, + "effort_limit": EFFORT_LIMIT, + }, + "correctness": { + "cpp_behavior_test": BEHAVIOR_TEST, + "cpp_passive_pair_test": PASSIVE_PAIR_TEST, + "python_scene_test": ( + "test_avbd_articulated_compliant_motors_demo_" "drives_free_coordinates" + ), + "allocation_tests": [ + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotGrowWorldBaseAllocator", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotAllocateGlobalHeap", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotMallocOnHeap", + ], + "exact_parent_mutation": _validate_exact_parent_mutation( + exact_parent_mutation_json + ), + }, + "visual_capture": visual_capture, + "benchmark": _validate_benchmark(benchmark_json), + "performance_claim_boundary": ( + "Candidate-only finite mechanism cost and scaling. The exact " + "parent intentionally skipped these non-Fixed finite rows, so " + "this packet makes no before/after speedup claim." + ), + "reproduction": { + "capture_command": ( + "LIBGL_ALWAYS_SOFTWARE=1 MESA_LOADER_DRIVER_OVERRIDE=llvmpipe " + "PYTHONPATH=build/default/cpp/Release-docking/python:python " + "BUILD_TYPE=Release-docking python scripts/capture_py_demo.py " + "--scene avbd_articulated_compliant_motors --frames 120 " + "--width 960 --height 540 --show-ui --view three-quarter " + "--camera-distance 7 --camera-target 0,0,1.05 " + "--output-dir " + ), + "image_verdict_command": ( + "pixi run image-verdict -- /" + "avbd_articulated_compliant_motors.png " + "--out /image_verdict.json " + "--meta scene=avbd_articulated_compliant_motors " + "--meta view=three-quarter" + ), + "benchmark_command": ( + "taskset -c 18 " + "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint " + "--benchmark_filter='BM_AvbdArticulatedCompliantMotorStep/" + "(1|4|16)$' --benchmark_min_time=0.05s " + "--benchmark_repetitions=5 " + "--benchmark_report_aggregates_only=true " + "--benchmark_out= " + "--benchmark_out_format=json" + ), + }, + "remaining_gates": [ + "finite-row break-force accounting and fracture lifecycle", + "unified soft/rigid row solve", + "CUDA AVBD row parity and same-hardware benchmark packets", + "source-demo, paper/site/video scene, and achieved-accuracy performance closure", + ], + } + + +def write_packet(path: Path, packet: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(packet, indent=2, sort_keys=True) + "\n") + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + packet = make_packet( + args.capture_manifest, + args.image_verdict_json, + args.visual_review_json, + args.benchmark_json, + args.exact_parent_mutation_json, + ) + except AvbdArticulatedCompliantMotorsPacketError as exc: + raise SystemExit(str(exc)) from exc + write_packet(args.output, packet) + print(f"Wrote AVBD articulated compliant-motors packet: {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp index c1d95277515e5..c7d487cc25e21 100644 --- a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp +++ b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp @@ -2764,6 +2764,92 @@ std::unique_ptr makeArticulatedCompliantJointWorld( return world; } +std::unique_ptr makeArticulatedCompliantMotorWorld( + std::size_t familyCount) +{ + constexpr double kStartStiffness = 20.0; + constexpr double kLinearStiffness = 2000.0; + constexpr double kAngularStiffness = 2000.0; + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.timeStep = 0.005; + auto world = std::make_unique(options); + + sx::MultibodyOptions multibodyOptions; + multibodyOptions.integrationFamily + = sx::MultibodyIntegrationFamily::Variational; + world->setMultibodyOptions(multibodyOptions); + + auto robot = world->addMultibody("articulated_compliant_motor_robot"); + auto base = robot.addLink("articulated_compliant_motor_base"); + + std::vector links; + std::vector joints; + links.reserve(4u * familyCount); + joints.reserve(2u * familyCount); + const std::array jointTypes{ + sx::JointType::Revolute, sx::JointType::Prismatic}; + const std::array axes{ + Eigen::Vector3d(1.0, 2.0, 3.0).normalized(), + Eigen::Vector3d(1.0, -2.0, 0.5).normalized()}; + for (std::size_t family = 0; family < jointTypes.size(); ++family) { + for (std::size_t i = 0; i < familyCount; ++i) { + const std::size_t flatIndex = family * familyCount + i; + const Eigen::Vector3d anchor( + 0.35 * static_cast(i + 1), + 0.25 * static_cast(family), + 0.04 * static_cast(i % 2)); + + const auto addFloatingLink = [&](std::string_view role, double mass) { + sx::JointSpec floatingSpec; + floatingSpec.name = "articulated_compliant_motor_" + std::string(role) + + "_float_" + std::to_string(flatIndex); + floatingSpec.type = sx::JointType::Floating; + auto link = robot.addLink( + "articulated_compliant_motor_" + std::string(role) + "_link_" + + std::to_string(flatIndex), + base, + floatingSpec); + link.setMass(mass); + link.setInertia(Eigen::Vector3d(0.15, 0.2, 0.25).asDiagonal()); + Eigen::VectorXd pose = Eigen::VectorXd::Zero(6); + pose.head<3>() = anchor; + link.getParentJoint().setPosition(pose); + links.push_back(link); + return link; + }; + auto parent = addFloatingLink("parent", 1.0); + auto child = addFloatingLink("child", 1.2); + + sx::Joint joint = addPointJoint( + *world, + "articulated_compliant_motor_" + std::to_string(flatIndex), + parent, + child, + jointTypes[family], + axes[family]); + joint.setActuatorType(sx::ActuatorType::Velocity); + joint.setCommandVelocity( + Eigen::VectorXd::Constant( + 1, 0.3 + 0.03 * static_cast(i % 4))); + joint.setEffortLimits( + Eigen::VectorXd::Constant(1, -800.0), + Eigen::VectorXd::Constant(1, 800.0)); + auto policy = joint.getConstraintProjectionPolicy(); + policy.startStiffness = kStartStiffness; + policy.linearStiffness = kLinearStiffness; + policy.angularStiffness = kAngularStiffness; + joint.setConstraintProjectionPolicy(policy); + joints.push_back(joint); + } + } + + benchmark::DoNotOptimize(links.data()); + benchmark::DoNotOptimize(joints.data()); + return world; +} + constexpr std::size_t kArticulatedHighRatioChainLinks = 5; constexpr double kArticulatedHighRatioLinkLength = 0.45; constexpr double kArticulatedHighRatioLightMass = 1.0; @@ -3904,6 +3990,24 @@ static void BM_AvbdArticulatedCompliantJointStep(benchmark::State& state) } BENCHMARK(BM_AvbdArticulatedCompliantJointStep)->Arg(1)->Arg(4)->Arg(16); +//============================================================================== +static void BM_AvbdArticulatedCompliantMotorStep(benchmark::State& state) +{ + const auto familyCount = static_cast(state.range(0)); + auto world = makeArticulatedCompliantMotorWorld(familyCount); + world->enterSimulationMode(); + + for (auto _ : state) { + world->step(); + benchmark::ClobberMemory(); + } + state.counters["family_instances"] = static_cast(familyCount); + state.counters["revolute_motors"] = static_cast(familyCount); + state.counters["prismatic_motors"] = static_cast(familyCount); + state.counters["compliant_motors"] = static_cast(2u * familyCount); +} +BENCHMARK(BM_AvbdArticulatedCompliantMotorStep)->Arg(1)->Arg(4)->Arg(16); + //============================================================================== static void BM_AvbdArticulatedHighRatioChainStep(benchmark::State& state) { diff --git a/tests/unit/simulation/compute/test_variational_integration.cpp b/tests/unit/simulation/compute/test_variational_integration.cpp index 5f261cdde8b49..e71325a144553 100644 --- a/tests/unit/simulation/compute/test_variational_integration.cpp +++ b/tests/unit/simulation/compute/test_variational_integration.cpp @@ -3689,6 +3689,290 @@ FloatingLinkPair addFloatingLinkPair(sx::World& world) } // namespace +// PLAN-104 AVBD articulated finite-stiffness bridge: compliant masked rows +// must apply equal-and-opposite forces between two movable links of the same +// multibody, not only between a link and the world. Each public joint family +// still leaves only its paper-defined coordinate free. +TEST( + VariationalIntegration, + AvbdCompliantPublicArticulatedJointMasksSupportMovableLinkPairs) +{ + struct Motion + { + double maxTranslation = 0.0; + double maxAxisTranslation = 0.0; + double maxOrthogonalTranslation = 0.0; + double maxRotation = 0.0; + double maxAxisTilt = 0.0; + double maxAxisRotation = 0.0; + }; + + const auto rollout = [](sx::JointType type, + const Eigen::Vector3d& axis, + const Eigen::Vector3d& force, + const Eigen::Vector3d& torque, + bool compliant) { + sx::World world; + world.setGravity(Eigen::Vector3d::Zero()); + world.setMultibodyOptions( + {.integrationFamily = sx::MultibodyIntegrationFamily::Variational}); + world.setTimeStep(0.002); + + FloatingLinkPair pair = addFloatingLinkPair(world); + if (compliant) { + sx::Joint joint = world.addJoint( + pair.parent, pair.child, makeJointSpec("finite_pair", type, axis)); + joint.setActuatorType(sx::ActuatorType::Passive); + joint.setConstraintProjectionPolicy( + makeConstraintProjectionPolicy(1000.0, 1000.0, 1000.0)); + } + + const auto applyWorldTorque = [](sx::Link& link, + const Eigen::Vector3d& worldTorque) { + if (worldTorque.squaredNorm() == 0.0) { + return; + } + const Eigen::Vector3d torqueDirection = worldTorque.normalized(); + const Eigen::Vector3d reference + = std::abs(torqueDirection.dot(Eigen::Vector3d::UnitX())) < 0.9 + ? Eigen::Vector3d::UnitX() + : Eigen::Vector3d::UnitY(); + const Eigen::Vector3d lever + = 0.25 * torqueDirection.cross(reference).normalized(); + const Eigen::Vector3d coupleForce + = worldTorque.cross(lever) / (2.0 * lever.squaredNorm()); + const Eigen::Vector3d center = link.getWorldTransform().translation(); + link.applyForce( + coupleForce, + center + lever, + /*forceInWorldFrame=*/true, + /*pointInWorldFrame=*/true); + link.applyForce( + -coupleForce, + center - lever, + /*forceInWorldFrame=*/true, + /*pointInWorldFrame=*/true); + }; + + world.enterSimulationMode(); + + Motion result; + for (int k = 0; k < 100; ++k) { + pair.parent.applyForce(-force); + pair.child.applyForce(force); + applyWorldTorque(pair.parent, -torque); + applyWorldTorque(pair.child, torque); + world.step(); + + const Eigen::Isometry3d parentTransform = pair.parent.getWorldTransform(); + const Eigen::Isometry3d childTransform = pair.child.getWorldTransform(); + const Eigen::Vector3d axisWorld = parentTransform.linear() * axis; + const Eigen::Vector3d translation + = childTransform.translation() - parentTransform.translation(); + const double axisTranslation = translation.dot(axisWorld); + const Eigen::Matrix3d relativeRotation + = parentTransform.linear().transpose() * childTransform.linear(); + result.maxTranslation + = std::max(result.maxTranslation, translation.norm()); + result.maxAxisTranslation + = std::max(result.maxAxisTranslation, std::abs(axisTranslation)); + result.maxOrthogonalTranslation = std::max( + result.maxOrthogonalTranslation, + (translation - axisTranslation * axisWorld).norm()); + result.maxRotation = std::max( + result.maxRotation, Eigen::AngleAxisd(relativeRotation).angle()); + result.maxAxisTilt = std::max( + result.maxAxisTilt, + (parentTransform.linear() * axis - childTransform.linear() * axis) + .norm()); + result.maxAxisRotation = std::max( + result.maxAxisRotation, + std::abs(signedRotationAroundAxis(relativeRotation, axis))); + } + return result; + }; + + const Eigen::Vector3d zAxis = Eigen::Vector3d::UnitZ(); + const Motion freeSocket = rollout( + sx::JointType::Spherical, + zAxis, + 10.0 * Eigen::Vector3d::UnitX(), + Eigen::Vector3d::UnitZ(), + /*compliant=*/false); + const Motion compliantSocket = rollout( + sx::JointType::Spherical, + zAxis, + 10.0 * Eigen::Vector3d::UnitX(), + Eigen::Vector3d::UnitZ(), + /*compliant=*/true); + ASSERT_GT(freeSocket.maxTranslation, 5e-2); + ASSERT_GT(freeSocket.maxAxisRotation, 2e-2); + EXPECT_LT(compliantSocket.maxTranslation, 0.5 * freeSocket.maxTranslation); + EXPECT_GT(compliantSocket.maxAxisRotation, 0.5 * freeSocket.maxAxisRotation); + + const Motion freeHinge = rollout( + sx::JointType::Revolute, + zAxis, + 10.0 * Eigen::Vector3d::UnitX(), + 0.8 * Eigen::Vector3d::UnitX() + 0.6 * Eigen::Vector3d::UnitZ(), + /*compliant=*/false); + const Motion compliantHinge = rollout( + sx::JointType::Revolute, + zAxis, + 10.0 * Eigen::Vector3d::UnitX(), + 0.8 * Eigen::Vector3d::UnitX() + 0.6 * Eigen::Vector3d::UnitZ(), + /*compliant=*/true); + ASSERT_GT(freeHinge.maxTranslation, 5e-2); + ASSERT_GT(freeHinge.maxAxisTilt, 2e-2); + ASSERT_GT(freeHinge.maxAxisRotation, 1e-2); + EXPECT_LT(compliantHinge.maxTranslation, 0.5 * freeHinge.maxTranslation); + EXPECT_LT(compliantHinge.maxAxisTilt, 0.5 * freeHinge.maxAxisTilt); + EXPECT_GT(compliantHinge.maxAxisRotation, 0.5 * freeHinge.maxAxisRotation); + + const Eigen::Vector3d xAxis = Eigen::Vector3d::UnitX(); + const Motion freeSlider = rollout( + sx::JointType::Prismatic, + xAxis, + 10.0 * Eigen::Vector3d::UnitX() + 10.0 * Eigen::Vector3d::UnitY(), + Eigen::Vector3d::UnitZ(), + /*compliant=*/false); + const Motion compliantSlider = rollout( + sx::JointType::Prismatic, + xAxis, + 10.0 * Eigen::Vector3d::UnitX() + 10.0 * Eigen::Vector3d::UnitY(), + Eigen::Vector3d::UnitZ(), + /*compliant=*/true); + ASSERT_GT(freeSlider.maxAxisTranslation, 5e-2); + ASSERT_GT(freeSlider.maxOrthogonalTranslation, 5e-2); + ASSERT_GT(freeSlider.maxRotation, 2e-2); + EXPECT_GT( + compliantSlider.maxAxisTranslation, 0.5 * freeSlider.maxAxisTranslation); + EXPECT_LT( + compliantSlider.maxOrthogonalTranslation, + 0.5 * freeSlider.maxOrthogonalTranslation); + EXPECT_LT(compliantSlider.maxRotation, 0.5 * freeSlider.maxRotation); +} + +// PLAN-104 AVBD articulated finite-stiffness bridge: a public one-DOF +// velocity actuator needs a bounded motor-only projection row alongside its +// compliant masked rows. This covers non-cardinal, off-origin revolute and +// prismatic pairs and proves that the configured effort cap remains active. +TEST( + VariationalIntegration, + AvbdCompliantPublicArticulatedOneDofMotorsDriveMovableLinkPairs) +{ + struct Motion + { + double freePosition = 0.0; + double maxLinearResidual = 0.0; + double maxAngularResidual = 0.0; + }; + + const double dt = 0.005; + constexpr int steps = 20; + const auto rollout = [dt]( + sx::JointType type, + const Eigen::Vector3d& axis, + double targetSpeed, + double effortLimit) { + sx::World world; + world.setGravity(Eigen::Vector3d::Zero()); + world.setMultibodyOptions( + {.integrationFamily = sx::MultibodyIntegrationFamily::Variational}); + world.setTimeStep(dt); + + FloatingLinkPair pair = addFloatingLinkPair(world); + const Eigen::Vector3d parentAnchor(0.2, 0.1, -0.05); + const Eigen::Vector3d childAnchor(-0.1, 0.1, -0.05); + Eigen::VectorXd childPose = Eigen::VectorXd::Zero(6); + childPose.head<3>() = parentAnchor - childAnchor; + pair.child.getParentJoint().setPosition(childPose); + + sx::Joint joint = world.addJoint( + pair.parent, + pair.child, + makeJointSpec("finite_motor", type, axis, parentAnchor, childAnchor)); + joint.setActuatorType(sx::ActuatorType::Velocity); + joint.setCommandVelocity(Eigen::VectorXd::Constant(1, targetSpeed)); + joint.setEffortLimits( + Eigen::VectorXd::Constant(1, -effortLimit), + Eigen::VectorXd::Constant(1, effortLimit)); + joint.setConstraintProjectionPolicy( + makeConstraintProjectionPolicy(2000.0, 2000.0, 2000.0)); + + world.enterSimulationMode(); + + Motion result; + for (int k = 0; k < steps; ++k) { + world.step(); + + const Eigen::Isometry3d parentTransform = pair.parent.getWorldTransform(); + const Eigen::Isometry3d childTransform = pair.child.getWorldTransform(); + const Eigen::Vector3d axisWorld = parentTransform.linear() * axis; + const Eigen::Vector3d relativeAnchor + = childTransform * childAnchor - parentTransform * parentAnchor; + const Eigen::Matrix3d relativeRotation + = parentTransform.linear().transpose() * childTransform.linear(); + + if (type == sx::JointType::Revolute) { + result.freePosition = signedRotationAroundAxis(relativeRotation, axis); + result.maxLinearResidual + = std::max(result.maxLinearResidual, relativeAnchor.norm()); + result.maxAngularResidual = std::max( + result.maxAngularResidual, + (parentTransform.linear() * axis - childTransform.linear() * axis) + .norm()); + } else { + result.freePosition = relativeAnchor.dot(axisWorld); + result.maxLinearResidual = std::max( + result.maxLinearResidual, + (relativeAnchor - result.freePosition * axisWorld).norm()); + result.maxAngularResidual = std::max( + result.maxAngularResidual, + (relativeRotation - Eigen::Matrix3d::Identity()).norm()); + } + } + return result; + }; + + const Eigen::Vector3d hingeAxis = Eigen::Vector3d(1.0, 2.0, 3.0).normalized(); + const double hingeSpeed = 0.4; + const Motion strongHinge = rollout( + sx::JointType::Revolute, hingeAxis, hingeSpeed, /*effortLimit=*/1000.0); + const Motion weakHinge = rollout( + sx::JointType::Revolute, hingeAxis, hingeSpeed, /*effortLimit=*/1e-9); + EXPECT_NEAR(strongHinge.freePosition, hingeSpeed * dt * steps, 2e-3); + EXPECT_LT( + std::abs(weakHinge.freePosition), + 0.01 * hingeSpeed * dt * static_cast(steps)); + EXPECT_LT(strongHinge.maxLinearResidual, 2e-3); + EXPECT_LT(strongHinge.maxAngularResidual, 2e-3); + EXPECT_LT(weakHinge.maxLinearResidual, 1e-6); + EXPECT_LT(weakHinge.maxAngularResidual, 1e-6); + + const Eigen::Vector3d sliderAxis + = Eigen::Vector3d(1.0, -2.0, 0.5).normalized(); + const double sliderSpeed = 0.3; + const Motion strongSlider = rollout( + sx::JointType::Prismatic, + sliderAxis, + sliderSpeed, + /*effortLimit=*/1000.0); + const Motion weakSlider = rollout( + sx::JointType::Prismatic, + sliderAxis, + sliderSpeed, + /*effortLimit=*/1e-9); + EXPECT_NEAR(strongSlider.freePosition, sliderSpeed * dt * steps, 2e-3); + EXPECT_LT( + std::abs(weakSlider.freePosition), + 0.01 * sliderSpeed * dt * static_cast(steps)); + EXPECT_LT(strongSlider.maxLinearResidual, 2e-3); + EXPECT_LT(strongSlider.maxAngularResidual, 2e-3); + EXPECT_LT(weakSlider.maxLinearResidual, 1e-6); + EXPECT_LT(weakSlider.maxAngularResidual, 1e-6); +} + // PLAN-104 AVBD articulated bridge: simulation-entry current-pose extraction // now covers non-topology private point-joint entities whose endpoint is a // multibody link. The generated hard fixed config captures the link's design diff --git a/tests/unit/simulation/world/test_world.cpp b/tests/unit/simulation/world/test_world.cpp index 531b31cf1d17c..9264d1f0f908c 100644 --- a/tests/unit/simulation/world/test_world.cpp +++ b/tests/unit/simulation/world/test_world.cpp @@ -6909,6 +6909,51 @@ void configureFiniteVariationalArticulatedPointJointScene( joint.setConstraintProjectionPolicy( makeConstraintProjectionPolicy(10.0, 200.0, 400.0)); } + + const std::array motorCases{{ + {"pair_hinge", + sx::JointType::Revolute, + Eigen::Vector3d(1.0, 2.0, 3.0).normalized()}, + {"pair_slider", + sx::JointType::Prismatic, + Eigen::Vector3d(1.0, -2.0, 0.5).normalized()}, + }}; + for (const JointCase& jointCase : motorCases) { + auto robot + = world.addMultibody(std::string(jointCase.name) + "_finite_robot"); + auto base = robot.addLink("base"); + + const auto addFloatingLink = [&](std::string_view suffix, double mass) { + sx::JointSpec floatingSpec; + floatingSpec.name + = std::string(jointCase.name) + "_" + std::string(suffix) + "_float"; + floatingSpec.type = sx::JointType::Floating; + auto link = robot.addLink( + std::string(jointCase.name) + "_" + std::string(suffix), + base, + floatingSpec); + link.setMass(mass); + link.setInertia(Eigen::Vector3d(0.12, 0.18, 0.24).asDiagonal()); + return link; + }; + auto parent = addFloatingLink("parent", 1.2); + auto child = addFloatingLink("child", 0.9); + + auto joint = world.addJoint( + parent, + child, + makeJointSpec( + std::string(jointCase.name) + "_finite_motor", + jointCase.type, + jointCase.axis)); + joint.setActuatorType(sx::ActuatorType::Velocity); + joint.setCommandVelocity(Eigen::VectorXd::Constant(1, 0.3)); + joint.setEffortLimits( + Eigen::VectorXd::Constant(1, -200.0), + Eigen::VectorXd::Constant(1, 200.0)); + joint.setConstraintProjectionPolicy( + makeConstraintProjectionPolicy(10.0, 200.0, 400.0)); + } } void configureCompliantVariationalContactSliderScene( From 131981788fa731b96e0591dbb22575dde6a157fa Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Thu, 30 Jul 2026 08:40:26 -0700 Subject: [PATCH 06/13] Account for finite articulated AVBD fracture loads --- CHANGELOG.md | 4 +- dart/simulation/comps/joint.hpp | 4 +- .../compute/variational_integration.cpp | 205 ++++- .../compute/variational_integration.hpp | 2 +- dart/simulation/multibody/joint.hpp | 5 +- docs/dev_tasks/avbd_solver/README.md | 96 ++- docs/dev_tasks/avbd_solver/RESUME.md | 65 +- docs/plans/104-vertex-block-descent-solver.md | 51 +- ...articulated-compliant-fracture-packet.json | 591 ++++++++++++++ .../avbd-demo-corpus.md | 5 +- .../avbd-paper-coverage-contract.json | 22 +- .../avbd-paper-gap-audit.md | 33 +- python/examples/demos/README.md | 103 +-- python/examples/demos/registry.py | 4 + ...d_articulated_compliant_breakable_motor.py | 414 ++++++++++ python/tests/integration/test_demos_cycle.py | 64 ++ ...d_articulated_compliant_fracture_packet.py | 430 ++++++++++ ...d_articulated_compliant_fracture_packet.py | 757 ++++++++++++++++++ .../simulation/bm_avbd_rigid_fixed_joint.cpp | 28 +- .../compute/test_variational_integration.cpp | 248 +++++- 20 files changed, 2945 insertions(+), 186 deletions(-) create mode 100644 docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json create mode 100644 python/examples/demos/scenes/avbd_articulated_compliant_breakable_motor.py create mode 100644 python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py create mode 100644 scripts/write_avbd_articulated_compliant_fracture_packet.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd58303091c5..8a082d0bc6fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -198,7 +198,9 @@ compatibility remains on the active DART 6 LTS branch._ joints now also apply masked finite-stiffness rows for world-link and same-multibody movable-link endpoints while preserving their free coordinates. Finite articulated revolute and prismatic velocity motors now - add bounded free-coordinate motor rows alongside those compliant masks. + add bounded free-coordinate motor rows alongside those compliant masks, and + finite plus motor row loads now share physical break-force accounting with + reset, re-arm, and binary persistence. ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) - Added compute-executor and backend-boundary work so CPU threading, optional CUDA experiments, and future accelerator sidecars do not leak into the default diff --git a/dart/simulation/comps/joint.hpp b/dart/simulation/comps/joint.hpp index 0d25665884ca3..66998936e708a 100644 --- a/dart/simulation/comps/joint.hpp +++ b/dart/simulation/comps/joint.hpp @@ -227,8 +227,8 @@ struct JointModel /// (per generalized coordinate). JointVector coulombFriction; - /// Maximum AVBD constraint force before the joint is marked broken. A value - /// of 0 disables automatic breakage. + /// Maximum physical AVBD row-load norm before the joint is marked broken. A + /// value of 0 disables automatic breakage. double breakForce = 0.0; JointLimits limits; diff --git a/dart/simulation/compute/variational_integration.cpp b/dart/simulation/compute/variational_integration.cpp index af674c202ae0c..16232b7c4b79d 100644 --- a/dart/simulation/compute/variational_integration.cpp +++ b/dart/simulation/compute/variational_integration.cpp @@ -901,6 +901,15 @@ struct VariationalCompliantLoopConstraint std::uint8_t angularAxisMask = dvbd::kAvbdRigidJointAllAxesMask; AxisRowVector linearRows; AxisRowVector angularRows; + entt::entity sourceJoint = entt::null; + double breakForce = 0.0; + double projectedLoadSquared = 0.0; +}; + +struct VariationalCompliantLoopProjectionBinding +{ + std::size_t constraintIndex = 0; + std::size_t compliantConstraintIndex = 0; }; struct VariationalCompliantLoopScratch @@ -913,11 +922,17 @@ struct VariationalCompliantLoopScratch = dart::common::StlAllocator; using DescriptorVector = std::vector; + using ProjectionBindingAllocator + = dart::common::StlAllocator; + using ProjectionBindingVector = std::vector< + VariationalCompliantLoopProjectionBinding, + ProjectionBindingAllocator>; explicit VariationalCompliantLoopScratch( dart::common::MemoryAllocator& allocator) : memoryAllocator(&allocator), constraints(ConstraintAllocator{allocator}), + projectionBindings(ProjectionBindingAllocator{allocator}), linearInventory(allocator), angularInventory(allocator), linearDescriptors(DescriptorAllocator{allocator}), @@ -926,9 +941,15 @@ struct VariationalCompliantLoopScratch { } - void clear() + void beginAssembly() { constraints.clear(); + projectionBindings.clear(); + } + + void clear() + { + beginAssembly(); linearDescriptors.clear(); angularDescriptors.clear(); linearInventory.records().clear(); @@ -937,6 +958,7 @@ struct VariationalCompliantLoopScratch dart::common::MemoryAllocator* memoryAllocator = nullptr; ConstraintVector constraints; + ProjectionBindingVector projectionBindings; dvbd::AvbdScalarRowInventory linearInventory; dvbd::AvbdScalarRowInventory angularInventory; DescriptorVector linearDescriptors; @@ -1205,6 +1227,8 @@ void appendAvbdRigidWorldArticulatedPointJointConstraints( compliantConstraint.angularAxes = axisFrame * config.angularAxes; compliantConstraint.linearAxisMask = config.linearAxisMask; compliantConstraint.angularAxisMask = config.angularAxisMask; + compliantConstraint.sourceJoint = jointEntity; + compliantConstraint.breakForce = jointModel.breakForce; compliantConstraint.linearRows.reserve(linearRows); for (std::uint8_t axis = 0; axis < 3u; ++axis) { if (!dvbd::detail::avbdRigidJointAxisEnabled( @@ -1239,6 +1263,8 @@ void appendAvbdRigidWorldArticulatedPointJointConstraints( axis), *compliantStiffness}); } + const std::size_t compliantConstraintIndex + = compliantScratch->constraints.size(); compliantScratch->constraints.push_back(std::move(compliantConstraint)); // The constrained coordinates stay in the finite-stiffness AVBD force, @@ -1251,7 +1277,13 @@ void appendAvbdRigidWorldArticulatedPointJointConstraints( configureVelocityMotorRow(motorConstraint); motorConstraint.rigid = motorConstraint.angularMotor; if (motorConstraint.linearMotor || motorConstraint.angularMotor) { + motorConstraint.sourceJoint = jointEntity; + motorConstraint.breakForce = jointModel.breakForce; + const std::size_t constraintIndex = constraints.size(); constraints.push_back(motorConstraint); + compliantScratch->projectionBindings.push_back( + VariationalCompliantLoopProjectionBinding{ + constraintIndex, compliantConstraintIndex}); } continue; } @@ -1266,30 +1298,107 @@ void appendAvbdRigidWorldArticulatedPointJointConstraints( void markBrokenAvbdVariationalLoopConstraints( detail::WorldRegistry& registry, std::span constraints, - const Eigen::VectorXd& lambda) + const Eigen::VectorXd& lambda, + double timeStep, + VariationalCompliantLoopScratch* compliantScratch) { + if (!(timeStep > 0.0) || !std::isfinite(timeStep)) { + return; + } + + const double inverseTimeStepSquared = 1.0 / (timeStep * timeStep); Eigen::Index row = 0; - for (const auto& constraint : constraints) { + std::size_t projectionBindingCursor = 0; + for (std::size_t constraintIndex = 0; constraintIndex < constraints.size(); + ++constraintIndex) { + const auto& constraint = constraints[constraintIndex]; const Eigen::Index rows = variationalLoopConstraintRowCount(constraint); if (rows <= 0) { continue; } - if (constraint.sourceJoint != entt::null && constraint.breakForce > 0.0 - && std::isfinite(constraint.breakForce) && row + rows <= lambda.size() - && registry.all_of(constraint.sourceJoint)) { - const double load = lambda.segment(row, rows).norm(); - auto& jointState - = registry.get(constraint.sourceJoint); - if (load >= constraint.breakForce) { + const bool hasProjectionBinding + = compliantScratch != nullptr + && projectionBindingCursor + < compliantScratch->projectionBindings.size() + && compliantScratch->projectionBindings[projectionBindingCursor] + .constraintIndex + == constraintIndex; + if (row + rows <= lambda.size()) { + const double loadSquared = lambda.segment(row, rows).squaredNorm() + * inverseTimeStepSquared + * inverseTimeStepSquared; + if (hasProjectionBinding) { + const std::size_t compliantConstraintIndex + = compliantScratch->projectionBindings[projectionBindingCursor] + .compliantConstraintIndex; + if (compliantConstraintIndex < compliantScratch->constraints.size()) { + compliantScratch->constraints[compliantConstraintIndex] + .projectedLoadSquared += loadSquared; + } + } else if ( + constraint.sourceJoint != entt::null && constraint.breakForce > 0.0 + && std::isfinite(constraint.breakForce) + && registry.all_of(constraint.sourceJoint) + && std::sqrt(loadSquared) >= constraint.breakForce) { + auto& jointState + = registry.get(constraint.sourceJoint); jointState.broken = true; } } + if (hasProjectionBinding) { + ++projectionBindingCursor; + } row += rows; } } +void resetVariationalCompliantLoopInventoryRange( + dvbd::AvbdScalarRowInventory& inventory, std::size_t begin, std::size_t end) +{ + end = std::min(end, inventory.size()); + for (std::size_t index = begin; index < end; ++index) { + inventory[index].state.lambda = 0.0; + inventory[index].state.stiffness = 0.0; + } +} + +void markBrokenAvbdVariationalCompliantLoopConstraint( + detail::WorldRegistry& registry, + const VariationalCompliantLoopConstraint& constraint, + double loadSquared, + VariationalCompliantLoopScratch& scratch, + std::size_t linearBegin, + std::size_t linearEnd, + std::size_t angularBegin, + std::size_t angularEnd) +{ + if (constraint.sourceJoint == entt::null || !(constraint.breakForce > 0.0) + || !std::isfinite(constraint.breakForce) + || !registry.all_of(constraint.sourceJoint) + || std::sqrt(loadSquared) < constraint.breakForce) { + return; + } + + auto& jointState = registry.get(constraint.sourceJoint); + jointState.broken = true; + resetVariationalCompliantLoopInventoryRange( + scratch.linearInventory, linearBegin, linearEnd); + resetVariationalCompliantLoopInventoryRange( + scratch.angularInventory, angularBegin, angularEnd); +} + +void accumulateVariationalCompliantLoopLoadSquared( + double force, double& loadSquared) +{ + if (!std::isfinite(force)) { + loadSquared = std::numeric_limits::infinity(); + return; + } + loadSquared += force * force; +} + constexpr double kVariationalCompliantLoopStiffnessGrowthBeta = 1000.0; void syncVariationalCompliantLoopConstraintRows( @@ -1337,7 +1446,7 @@ VarTree& buildVarTreeIntoScratch( const comps::MultibodyStructure& structure); void updateVariationalCompliantLoopConstraintRows( - const detail::WorldRegistry& registry, + detail::WorldRegistry& registry, const comps::MultibodyStructure& structure, MultibodyVariationalTreeScratch& treeScratch, VariationalCompliantLoopScratch& scratch) @@ -1389,6 +1498,9 @@ void updateVariationalCompliantLoopConstraintRows( std::size_t linearCursor = 0; std::size_t angularCursor = 0; for (const VariationalCompliantLoopConstraint& constraint : constraints) { + const std::size_t linearBegin = linearCursor; + const std::size_t angularBegin = angularCursor; + double loadSquared = constraint.projectedLoadSquared; const Eigen::Vector3d pointA = worldPoint(constraint.linkA, constraint.pointA); const Eigen::Vector3d pointB @@ -1400,31 +1512,48 @@ void updateVariationalCompliantLoopConstraintRows( } const Eigen::Vector3d rowAxis = variationalLoopAxis(constraint.linearAxes, row.axis); - updateStiffness( - scratch.linearInventory[linearCursor++], - rowAxis.dot(positionResidual)); + const double constraintValue = rowAxis.dot(positionResidual); + if (row.stiffness > 0.0 && std::isfinite(row.stiffness)) { + accumulateVariationalCompliantLoopLoadSquared( + row.stiffness * constraintValue, loadSquared); + } + updateStiffness(scratch.linearInventory[linearCursor++], constraintValue); } - if (!constraint.rigid) { - angularCursor += constraint.angularRows.size(); - continue; - } - const Eigen::Matrix3d rotA - = worldRotation(constraint.linkA, constraint.rotationA); - const Eigen::Matrix3d rotB - = worldRotation(constraint.linkB, constraint.rotationB); - const Eigen::Vector3d angularResidual - = rotB * rotationLog3(rotB.transpose() * rotA); - for (const auto& row : constraint.angularRows) { - if (angularCursor >= scratch.angularInventory.size()) { - return; + if (constraint.rigid) { + const Eigen::Matrix3d rotA + = worldRotation(constraint.linkA, constraint.rotationA); + const Eigen::Matrix3d rotB + = worldRotation(constraint.linkB, constraint.rotationB); + const Eigen::Vector3d angularResidual + = rotB * rotationLog3(rotB.transpose() * rotA); + for (const auto& row : constraint.angularRows) { + if (angularCursor >= scratch.angularInventory.size()) { + return; + } + const Eigen::Vector3d rowAxis + = variationalLoopAxis(constraint.angularAxes, row.axis); + const double constraintValue = rowAxis.dot(angularResidual); + if (row.stiffness > 0.0 && std::isfinite(row.stiffness)) { + accumulateVariationalCompliantLoopLoadSquared( + row.stiffness * constraintValue, loadSquared); + } + updateStiffness( + scratch.angularInventory[angularCursor++], constraintValue); } - const Eigen::Vector3d rowAxis - = variationalLoopAxis(constraint.angularAxes, row.axis); - updateStiffness( - scratch.angularInventory[angularCursor++], - rowAxis.dot(angularResidual)); + } else { + angularCursor += constraint.angularRows.size(); } + + markBrokenAvbdVariationalCompliantLoopConstraint( + registry, + constraint, + loadSquared, + scratch, + linearBegin, + linearCursor, + angularBegin, + angularCursor); } } @@ -3457,7 +3586,7 @@ VariationalSolveReport integrateMultibodyVariationalImpl( std::span constraints, std::size_t andersonDepth, const VariationalContactHook& contactHook, - const VariationalCompliantLoopScratch* compliantLoopScratch, + VariationalCompliantLoopScratch* compliantLoopScratch, const VariationalGroundContact* fastGroundContact, const VariationalGroundContactSolver* groundContactSolver, VariationalContactEvaluationScratch* contactScratch, @@ -4107,7 +4236,11 @@ VariationalSolveReport integrateMultibodyVariationalImpl( if (projectionLambdaInitialized && projectionScratchStorage.projectionLambda.size() > 0) { markBrokenAvbdVariationalLoopConstraints( - registry, constraints, projectionScratchStorage.projectionLambda); + registry, + constraints, + projectionScratchStorage.projectionLambda, + timeStep, + compliantLoopScratch); } if (treeConfigurationChanged) { updateVarTreeConfigurationInto(registry, tree, position); @@ -4538,7 +4671,7 @@ void reserveMultibodyVariationalRegistryStorage( if (hasPointJointConfigs) { compliantScratch = &getOrCreateVariationalCompliantLoopScratch( registry, entity, allocator); - compliantScratch->constraints.clear(); + compliantScratch->beginAssembly(); } appendAvbdRigidWorldArticulatedPointJointConstraints( registry, entity, scratch.constraints, compliantScratch); @@ -4704,7 +4837,7 @@ void MultibodyVariationalIntegrationStage::execute( if (hasPointJointConfigs) { compliantScratch = &getOrCreateVariationalCompliantLoopScratch( registry, entity, worldFreeAllocator); - compliantScratch->constraints.clear(); + compliantScratch->beginAssembly(); } else { auto* existingScratch = registry.try_get(entity); diff --git a/dart/simulation/compute/variational_integration.hpp b/dart/simulation/compute/variational_integration.hpp index 26f356a02a4c1..568722efb28a5 100644 --- a/dart/simulation/compute/variational_integration.hpp +++ b/dart/simulation/compute/variational_integration.hpp @@ -147,7 +147,7 @@ struct VariationalLoopConstraint double angularMotorMaxTorque = std::numeric_limits::infinity(); ///< motor row torque bound. entt::entity sourceJoint = entt::null; ///< private AVBD source joint. - double breakForce = 0.0; ///< >0 marks sourceJoint broken. + double breakForce = 0.0; ///< Physical row-load norm; >0 arms fracture. }; /// **EXPERIMENTAL SPIKE (PLAN-084 contact-roadmap gate 2).** Kinematics of the diff --git a/dart/simulation/multibody/joint.hpp b/dart/simulation/multibody/joint.hpp index 87267986d3bb3..5465a493f293b 100644 --- a/dart/simulation/multibody/joint.hpp +++ b/dart/simulation/multibody/joint.hpp @@ -363,8 +363,9 @@ class DART_SIMULATION_API Joint /// Set the point-joint break-force threshold. /// /// A finite non-negative value is required. The default value, 0, disables - /// automatic breakage. When projected point-joint rows accumulate a - /// constraint load at or above this threshold, the joint is marked broken and + /// automatic breakage. The threshold is the L2 norm of the joint's physical + /// row forces/torques, including finite-stiffness and bounded motor rows. + /// When that load reaches the threshold, the joint is marked broken and /// excluded from later point-joint extraction until resetBreakage(). void setBreakForce(double breakForce); diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index d9580761236bb..146e99c241869 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -25,22 +25,25 @@ This folder is the temporary working surface; the durable owner is the plan. the fail-closed contract is committed locally at `710cbfc1152`, and nothing has been pushed. The Section 3.5 packet is committed locally at `a78f688a178`, and the Section 4 packet at `0b0154573b8`; the articulated - finite-row packet at `761263bbd41`; the articulated finite-motor packet is - the current local unpushed closeout. -- **Current packet:** passive public spherical, revolute, and prismatic point - joints now support same-multibody movable-link endpoints in their masked - finite-stiffness rows. Finite public revolute/prismatic `Velocity` joints - additionally project one bounded motor-only row on the free coordinate while - their constrained coordinates remain compliant. + finite-row packet at `761263bbd41`; the articulated finite-motor packet at + `9ebd9b895b1`; the articulated finite-fracture packet is the current local + unpushed closeout. +- **Current packet:** same-multibody finite-stiffness articulated point joints + now account for accepted finite-row force and bounded motor projection load + in public force/torque units. All rows owned by a joint contribute to one L2 + threshold; fracture clears finite row state, later steps skip the broken + joint, reset re-engages it, and weak re-arm can break it again. The contracts contain 88 VBD and 88 AVBD requirements; all 176 remain incomplete until their recorded correctness, solver-identity, CPU/CUDA, visual, and comparable-performance predicates pass. -- **Latest verified local packet:** focused passive/motor behavior, warmed - allocation, Python scene, exact-parent mutation, docked assessed visual, and - candidate-only scale evidence advance - `avbd.method.joints_and_attachments` and - `avbd.method.finite_stiffness_ramping` without completing either row. - Finite-row fracture accounting, unified rows, CUDA, and source-matched +- **Latest verified local packet:** focused finite-only, motor-only + two-timestep, combined-load, break/reset/re-arm, and save/load behavior; + warmed allocation; Python scene; exact-parent mutation; docked assessed + visual; and candidate-only scale evidence advance + `avbd.method.joints_and_attachments`, + `avbd.method.finite_stiffness_ramping`, `avbd.method.motors`, and + `avbd.method.fracture` without completing any row. The paper wall, broad + joint/fracture corpus, unified rows, CUDA, and source-matched achieved-accuracy performance remain open. - **Recent slices merged to `main`** (see the PLAN-104 progress log and the PRs for detail; per-slice history lives in git, not in this file): @@ -51,21 +54,22 @@ This folder is the temporary working surface; the durable owner is the plan. - #3022 — bounded regression coverage: rigid-contact tangent-basis contract, articulated break→reset→break re-arm lifecycle, row-inventory replaced-key cold-start (`65ba05113c6`). -- **Current local gates:** the focused passive and bounded-motor C++ oracles, - all three articulated finite-row post-bake allocation policies, six - packet-writer tests, and the focused Python command/reversal oracle pass. - The motor oracle fails both driven coordinates on exact parent - `761263bbd41`, proving mutation sensitivity. The assessed 119-frame - docking-build capture and pinned 2/8/32-motor candidate-only benchmark are - recorded in the packet. The Release build, complete variational binary - (183/183), aggregate C++ unit tier (168/168), full Python tier (1651 passed, - 20 skipped), PLAN-104 parity contract (176 rows), AVBD packets (54), and - PLAN-122 allocation matrix (18 rows, 14 closed) pass. The simulation label - passes 77/79 active entries; only the split global-heap and raw-malloc - binaries fail on the same four unrelated signatures previously reproduced - on exact parent (320/80 global bytes and 320/1376 raw bytes), while all three - new finite articulated gates pass inside those splits. Two monolithic - entries remain intentionally disabled. +- **Current local gates:** the focused finite-only, bounded-motor, + combined-load, break/reset/re-arm, and save/load C++ oracles; all 55 AVBD + breakage tests; all three articulated finite-row post-bake allocation + policies; six packet-writer tests; and the focused Python lifecycle oracle + pass. The two mutation-sensitive C++ tests fail in five required cases on + exact parent `9ebd9b895b1`. The assessed 149-frame docking-build capture and + pinned 2/8/32-motor candidate-only benchmark are recorded in the packet. + The Release build, complete variational binary (186/186), aggregate C++ unit + tier (168/168), full Python tier (1658 passed, 20 skipped), PLAN-104 parity + contract (176 rows), AVBD packets (55), and PLAN-122 allocation matrix + (18 rows, 14 closed) pass. The simulation label passes 77/79 active entries; + only the split global-heap and raw-malloc binaries fail on the same four + unrelated signatures previously reproduced on exact parent (320/80 global + bytes and 320/1376 raw bytes), while all three new finite articulated gates + pass inside those splits. Two monolithic entries remain intentionally + disabled. ## Goal @@ -96,10 +100,10 @@ numbers. ## Immediate Next Steps -Continue PLAN-104's articulated multibody AVBD extraction with finite-row load -accounting and fracture lifecycle after this packet. Keep every dependent -figure/demo/performance row partial until source-matched CPU and CUDA evidence -closes it. +Continue PLAN-104 with the paper wall/broader fracture and joint corpus, +unified rigid/soft rows, or another highest-priority missing paper mechanism. +Keep every dependent figure/demo/performance row partial until source-matched +CPU and CUDA evidence closes it. Two smaller deferred maintenance items remain valid but do not outrank the missing paper mechanism: hoist the duplicated `makeCollisionPairKey` into a @@ -236,6 +240,34 @@ The durable evidence is [`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json). The linked method rows remain partial. +## Verified Local Packet: Articulated Finite Load And Fracture + +- **Value:** give finite articulated rows the same physical break-force + contract as hard and bounded motor rows, so public thresholds are stable + across timesteps and one joint fractures from its complete row load. +- **Scope:** evaluate accepted finite load as stiffness times residual, convert + position-projection lambda to force/torque with `1 / dt^2`, aggregate all + finite and motor rows by L2 norm, clear finite row state on fracture, and + preserve broken/reset policy through simulation-mode save/load. +- **Correctness evidence:** finite-only, motor-only at 5 ms and 10 ms, and + combined-load threshold oracles; break/skip/reset/re-arm and binary + round-trip lifecycle coverage; all existing AVBD breakage regressions; and + all three warmed finite articulated allocation policies. +- **Mutation evidence:** both load/lifecycle tests fail in the five required + cases on exact parent `9ebd9b895b1` and pass on the candidate. +- **Runtime evidence:** the + `avbd_articulated_compliant_breakable_motor` text oracle and assessed docked + capture show weak break, strong reset/intact, and weak re-arm/break phases. +- **Performance boundary:** the pinned candidate-only benchmark records 103.2 + us for 2 breakable motors, 585.0 us for 8, and 6.633 ms for 32. The parent + omitted this load accounting, so the packet makes no speedup claim. +- **Non-goals:** the paper wall, broad fracture/joint corpus, unified + rigid/soft rows, CUDA, and source/paper achieved-accuracy performance. + +The durable evidence is +[`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json). +All linked canonical method rows remain partial. + ## History Per-slice history and durable evidence live in the PLAN-104 progress log, the diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index 04c721cea1876..6829426b33e0c 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -9,30 +9,31 @@ this file is only the current session pointer. See ## Last Session Summary The exact 176-row VBD/AVBD paper-parity contract remains fail-closed, with no -complete rows. The latest local packet extends the CPU articulated variational -bridge from passive finite world-link rows to same-multibody movable-link -spherical/revolute/prismatic pairs and bounded finite revolute/prismatic -`Velocity` motors. Their masked constrained coordinates use persistent -per-axis AVBD stiffness state while the free coordinate uses a motor-only -projection row with the existing hard-motor target/Jacobian/effort bounds. +complete rows. The latest local packet closes the narrow CPU break-load gap for +same-multibody finite articulated point joints. Accepted finite-row forces and +bounded motor projection loads now share public force/torque units, aggregate +into one per-joint L2 threshold, clear finite row state on fracture, skip while +broken, and re-engage after reset. The durable packet is -[`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json). -It records passive and motor same-multibody C++ oracles, all three warmed -allocation policies, a Python command/reversal oracle, assessed docked -start/middle/end visual evidence, candidate-only 2/8/32-motor scale data, and -an exact-parent mutation run. The motor behavior test fails both driven -coordinates on exact parent `761263bbd41` and passes on the candidate. The -linked method rows remain partial. +[`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json). +It records finite-only, motor-only at two timesteps, combined-load, +break/reset/re-arm, and save/load C++ oracles; all three warmed allocation +policies; a Python lifecycle oracle; assessed docked start/middle/end visual +evidence; candidate-only 2/8/32-motor scale data; and an exact-parent mutation +run. Both load/lifecycle tests fail in five required cases on exact parent +`9ebd9b895b1` and pass on the candidate. The linked method rows remain partial. -The Release build, complete variational binary (183/183), aggregate C++ unit -tier (168/168), full Python tier (1651 passed, 20 skipped), six packet-writer -tests, PLAN-104 parity contract (176 rows), all 54 AVBD packets, and PLAN-122 -allocation matrix (18 rows, 14 closed) pass. The simulation label passes 77/79 -active entries: its split global-heap and raw-malloc binaries reproduce the -same four unrelated 320/80-byte and 320/1376-byte signatures recorded on the -exact parent, while every new finite articulated allocation gate passes. Two -monolithic entries remain intentionally disabled. +The focused load/lifecycle/serialization C++ oracles, all existing AVBD +breakage tests, all three warmed allocation policies, the Python scene oracle, +six packet-writer tests, assessed 149-frame docking capture, and pinned +candidate-only benchmark pass. The Release build, complete variational binary +(186/186), aggregate C++ unit tier (168/168), full Python tier (1658 passed, +20 skipped), PLAN-104 parity contract (176 rows), AVBD packets (55), and +PLAN-122 allocation matrix (18 rows, 14 closed) also pass. The simulation label +passes 77/79 active entries; only the split global-heap and raw-malloc binaries +retain the four unrelated exact-parent-reproduced allocation signatures, while +all three new finite articulated gates pass inside those splits. ## Current Branch @@ -40,15 +41,16 @@ monolithic entries remain intentionally disabled. `origin/main` at `83110ef54ab`. The fail-closed contract is committed locally at `710cbfc1152`, the Section 3.5 packet at `a78f688a178`, and the Section 4 packet at `0b0154573b8`; the articulated finite-row packet is committed at -`761263bbd41`, and the articulated finite-motor packet is the current -uncommitted closeout. The branch has not been pushed. +`761263bbd41`, and the articulated finite-motor packet at `9ebd9b895b1`; the +articulated finite-fracture packet follows as the current local unpushed +closeout. The branch has not been pushed. ## Immediate Next Step -After aggregate gates and the local closeout commit, continue articulated -extraction with finite-row load accounting and fracture lifecycle. Preserve -the packet boundary: the current CPU finite rows do not close unified rows, -CUDA, the source/paper corpus, or achieved-accuracy performance. +Broaden the paper wall/fracture and joint corpus, unified rigid/soft rows, or +another highest-priority missing paper mechanism. Preserve the packet +boundary: this narrow CPU load contract does not close those rows, CUDA, the +source/paper corpus, or achieved-accuracy performance. ## Context That Would Be Lost @@ -58,10 +60,11 @@ CUDA, the source/paper corpus, or achieved-accuracy performance. - The articulated finite-motor benchmark is candidate-only. Exact parent skips the new rows, so a timing ratio would compare different work and is not a speedup claim. -- Finite public one-DOF `Velocity` rows now use a separate bounded motor-only - row beside their compliant masks. Do not attach break-force source state to - that row until finite compliant and motor loads have a complete accounting - contract; doing so now would be partial and misleading. +- Finite public one-DOF `Velocity` rows use a bounded motor-only projection row + beside their compliant masks. Its `dt^2` position-level load must remain + associated with the owning compliant constraint and converted to physical + units before comparison; do not regress to an isolated motor threshold or + compare raw projection lambda to public break force. - The pinned 2D source applies Section 3.5 generically and the 3D joint source applies it to nonlinear point rows, but the pinned 3D Spring source omits the geometric term. DART follows the paper for distance springs; do not call that diff --git a/docs/plans/104-vertex-block-descent-solver.md b/docs/plans/104-vertex-block-descent-solver.md index 3c29157c4d33a..6cd53bdcd8a52 100644 --- a/docs/plans/104-vertex-block-descent-solver.md +++ b/docs/plans/104-vertex-block-descent-solver.md @@ -893,9 +893,14 @@ angular row families: spherical rotation, revolute hinge rotation, and prismatic axis translation remain free. The same masked finite rows now also support same-multibody movable-link endpoint pairs. Finite public revolute and prismatic `Velocity` joints additionally project a bounded motor-only row on -their free coordinate while their constrained coordinates remain compliant; -the motor-only row intentionally carries no break-force source because finite -row load accounting is not yet complete. The focused +their free coordinate while their constrained coordinates remain compliant. +Accepted finite-row forces and position-level hard/motor projection loads now +share one physical-unit break-force contract: projection loads are divided by +`dt^2`, every active row is aggregated into the owning public joint's L2 load, +and fracture clears finite lambda/stiffness state before later-step extraction +skips the broken joint. Focused load, timestep-invariance, combined-load, +break/reset/re-arm, and simulation-mode save/load regressions cover that +lifecycle. The focused `avbd_articulated_compliant_joints` py-demo, allocation gates, exact-parent mutation oracle, and candidate-only 3/12/48-joint benchmark are recorded in [`avbd-articulated-compliant-joints-packet.json`](104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json). @@ -903,7 +908,13 @@ The `avbd_articulated_compliant_motors` py-demo, allocation gates, exact-parent mutation oracle, docked assessed capture, and candidate-only 2/8/32-motor benchmark are recorded in [`avbd-articulated-compliant-motors-packet.json`](104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json). -Finite-row fracture accounting, CUDA, and unified soft/rigid rows remain open. +The `avbd_articulated_compliant_breakable_motor` py-demo, exact-parent +load/lifecycle mutation, serialization and allocation gates, assessed +break/reset/re-arm capture, and candidate-only 2/8/32-motor benchmark are +recorded in +[`avbd-articulated-compliant-fracture-packet.json`](104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json). +The paper wall, broad fracture corpus, CUDA, and unified soft/rigid rows remain +open. Free-rigid AVBD point-joint rows now also support finite linear and angular material stiffness through the public point-joint facade and dartpy properties; those rows use raw finite residuals and ramp row stiffness up to the configured @@ -1152,12 +1163,12 @@ remaining gaps, in order: movable link-pair, tiny-limit, restored tiny-limit save/load, one-DOF break/reset, spherical reset, and movable-pair plus direct world-link save/load/reset checks remains open. - Extend finite extraction to same-multibody endpoint pairs, couple bounded - free-axis velocity motors with finite masked rows, and add finite-row - break-force accounting. Then broaden fracture - lifecycle/corpus coverage beyond the narrow hard point-joint threshold and - the now-covered private fixed-row/world-fixed reset plus 2D Fracture/3D - Breakable source-demo fixed-joint break/reset rows, and public articulated + Same-multibody finite endpoint pairs, bounded free-axis velocity motors, and + physical finite-plus-motor break-force accounting are now covered narrowly. + Broaden fracture lifecycle/corpus coverage beyond that articulated contract, + the narrow hard point-joint threshold, the private fixed-row/world-fixed + reset, and the 2D Fracture/3D Breakable source-demo fixed-joint rows; also + broaden public articulated World facade coverage beyond the new same-multibody link-link, world-link, explicit-anchor, and spherical linear-only point-joint entrypoints plus same-multibody link-link @@ -1314,6 +1325,26 @@ AVBD parity additionally requires: Relocated from the dashboard on 2026-07-03; newest first. +On 2026-07-30, the articulated finite-fracture packet closed the narrow CPU +load-accounting gap left by the finite-motor slice. Accepted finite-row forces +use the same stiffness/residual values as the variational solve, while hard and +bounded-motor position-projection loads are converted to public force/torque +units by dividing by `dt^2`; all rows owned by one joint contribute to one L2 +break load. Focused C++ oracles cover finite-only, motor-only at two timesteps, +combined finite-plus-motor thresholds, skip/reset/re-arm, and simulation-mode +save/load. Both mutation-sensitive tests fail in five required cases on exact +parent `9ebd9b895b1`; all 55 AVBD breakage tests and all three warmed +finite-row allocation policies pass on the candidate. The +`avbd_articulated_compliant_breakable_motor` text oracle and assessed +149-frame docking capture show weak break, strong reset/intact, and weak +re-arm/break phases. A pinned candidate-only benchmark records median CPU time +of 103.2 us for 2 breakable motors, 585.0 us for 8, and 6.633 ms for 32; no +parent speedup is claimed because the parent omitted the new load accounting. +[`avbd-articulated-compliant-fracture-packet.json`](104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json) +keeps the four linked method rows partial: the paper wall, broad fracture and +joint corpus, unified rigid/soft rows, CUDA, and source/paper achieved-accuracy +performance remain open. + On 2026-07-30, the articulated finite-motor packet closed two adjacent CPU extraction gaps: passive finite spherical/revolute/prismatic masks now support same-multibody movable-link pairs, and finite revolute/prismatic `Velocity` diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json new file mode 100644 index 0000000000000..8c924f6087673 --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json @@ -0,0 +1,591 @@ +{ + "benchmark": { + "benchmark": "BM_AvbdArticulatedCompliantBreakableMotorStep", + "context": { + "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "v1.9.5", + "mhz_per_cpu": 5300, + "num_cpus": 32 + }, + "invariants": { + "break_force": 1000000000000.0, + "breakable": true, + "endpoint_topology": "same-multibody movable-link pairs", + "family_instances": [ + 1, + 4, + 16 + ], + "motor_types": [ + "revolute", + "prismatic" + ], + "time_step": 0.005 + }, + "json_sha256": "e0ec14fa8309b537f3e29333d520b0768e7ceb14eb06ba12f0624fe1c5bf14ef", + "rows": [ + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "breakable_motors": 2.0, + "cpu_time": 104680.47797001156, + "family_index": 0, + "family_instances": 1.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/1_mean", + "per_family_instance_index": 0, + "prismatic_motors": 1.0, + "real_time": 104683.55271102692, + "repetitions": 5, + "revolute_motors": 1.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/1", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "breakable_motors": 2.0, + "cpu_time": 103192.47866205306, + "family_index": 0, + "family_instances": 1.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/1_median", + "per_family_instance_index": 0, + "prismatic_motors": 1.0, + "real_time": 103192.3010588689, + "repetitions": 5, + "revolute_motors": 1.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/1", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "breakable_motors": 0.0, + "cpu_time": 3967.754250407611, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/1_stddev", + "per_family_instance_index": 0, + "prismatic_motors": 0.0, + "real_time": 3973.4648026748278, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/1", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "breakable_motors": 0.0, + "cpu_time": 0.03790347854109223, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/1_cv", + "per_family_instance_index": 0, + "prismatic_motors": 0.0, + "real_time": 0.0379569158647429, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/1", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "breakable_motors": 8.0, + "cpu_time": 585850.9421875004, + "family_index": 0, + "family_instances": 4.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/4_mean", + "per_family_instance_index": 1, + "prismatic_motors": 4.0, + "real_time": 585880.695234664, + "repetitions": 5, + "revolute_motors": 4.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/4", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "breakable_motors": 8.0, + "cpu_time": 585012.8593750002, + "family_index": 0, + "family_instances": 4.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/4_median", + "per_family_instance_index": 1, + "prismatic_motors": 4.0, + "real_time": 585012.8436577506, + "repetitions": 5, + "revolute_motors": 4.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/4", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "breakable_motors": 0.0, + "cpu_time": 3602.87932594175, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/4_stddev", + "per_family_instance_index": 1, + "prismatic_motors": 0.0, + "real_time": 3573.74469830174, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/4", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "breakable_motors": 0.0, + "cpu_time": 0.006149822534191053, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/4_cv", + "per_family_instance_index": 1, + "prismatic_motors": 0.0, + "real_time": 0.006099782306140572, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/4", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "breakable_motors": 32.0, + "cpu_time": 6649086.720000001, + "family_index": 0, + "family_instances": 16.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/16_mean", + "per_family_instance_index": 2, + "prismatic_motors": 16.0, + "real_time": 6650178.079726175, + "repetitions": 5, + "revolute_motors": 16.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/16", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "breakable_motors": 32.0, + "cpu_time": 6633084.8, + "family_index": 0, + "family_instances": 16.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/16_median", + "per_family_instance_index": 2, + "prismatic_motors": 16.0, + "real_time": 6633091.499679722, + "repetitions": 5, + "revolute_motors": 16.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/16", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "breakable_motors": 0.0, + "cpu_time": 65218.89335385543, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/16_stddev", + "per_family_instance_index": 2, + "prismatic_motors": 0.0, + "real_time": 65473.14611885314, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/16", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "breakable_motors": 0.0, + "cpu_time": 0.00980869946509818, + "family_index": 0, + "family_instances": 0.0, + "iterations": 5, + "name": "BM_AvbdArticulatedCompliantBreakableMotorStep/16_cv", + "per_family_instance_index": 2, + "prismatic_motors": 0.0, + "real_time": 0.009845322235573732, + "repetitions": 5, + "revolute_motors": 0.0, + "run_name": "BM_AvbdArticulatedCompliantBreakableMotorStep/16", + "run_type": "aggregate", + "threads": 1, + "time_unit": "ns" + } + ], + "scale_data": [ + { + "benchmark": "BM_AvbdArticulatedCompliantBreakableMotorStep/1", + "breakable_motors": 2, + "cpu_time_per_motor_ns": 51596.23933102653, + "cpu_time_per_step_ns": 103192.47866205306, + "family_instances": 1, + "real_time_per_step_ns": 103192.3010588689, + "time_unit": "ns" + }, + { + "benchmark": "BM_AvbdArticulatedCompliantBreakableMotorStep/4", + "breakable_motors": 8, + "cpu_time_per_motor_ns": 73126.60742187503, + "cpu_time_per_step_ns": 585012.8593750002, + "family_instances": 4, + "real_time_per_step_ns": 585012.8436577506, + "time_unit": "ns" + }, + { + "benchmark": "BM_AvbdArticulatedCompliantBreakableMotorStep/16", + "breakable_motors": 32, + "cpu_time_per_motor_ns": 207283.9, + "cpu_time_per_step_ns": 6633084.8, + "family_instances": 16, + "real_time_per_step_ns": 6633091.499679722, + "time_unit": "ns" + } + ] + }, + "claim_boundary": "This closes the narrow CPU articulated finite-row load-accounting gap. It does not reproduce the paper wall, close the broad fracture corpus, unify rigid and soft rows, provide CUDA parity, or establish a paper/reference performance win.", + "correctness": { + "allocation_tests": [ + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotGrowWorldBaseAllocator", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotAllocateGlobalHeap", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotMallocOnHeap" + ], + "cpp_lifecycle_test": "VariationalIntegration.AvbdCompliantPublicArticulatedJointBreakResetRearmsFiniteRows", + "cpp_load_test": "VariationalIntegration.AvbdCompliantPublicArticulatedJointAggregatesFiniteAndMotorBreakLoads", + "cpp_serialization_test": "VariationalIntegration.AvbdCompliantPublicArticulatedFiniteBreakageSurvivesSaveLoadAndReset", + "exact_parent_mutation": { + "command": "build/default/cpp/Release/bin/test_variational_integration --gtest_filter='VariationalIntegration.AvbdCompliantPublicArticulatedJointAggregatesFiniteAndMotorBreakLoads:VariationalIntegration.AvbdCompliantPublicArticulatedJointBreakResetRearmsFiniteRows'", + "exit_code": 1, + "failed_assertions": [ + { + "actual": false, + "case": "finite_only", + "expected": true, + "threshold": 0.8 + }, + { + "actual": false, + "case": "motor_dt_0.005", + "expected": true, + "threshold": 1.8 + }, + { + "actual": false, + "case": "motor_dt_0.01", + "expected": true, + "threshold": 1.8 + }, + { + "actual": false, + "case": "combined_load", + "expected": true, + "threshold": 2.1 + }, + { + "actual": false, + "case": "reset_lifecycle", + "expected": true, + "threshold": 2.1 + } + ], + "file": "avbd-articulated-compliant-fracture-exact-parent.json", + "interpretation": "The exact parent neither fractures from finite accepted row forces nor associates bounded finite-motor projection load with the public joint, so finite-only, motor-only at both timesteps, combined-load, and reset/re-arm lifecycle assertions all fail.", + "mutation": "Build production at the exact parent commit with only the candidate finite-load and fracture-lifecycle tests applied.", + "observed_result": "fail", + "parent_commit": "9ebd9b895b17e982bd1ed9287287d589b029409f", + "sha256": "b6984f1d10a652469e09488f7032692e078d338791e124e2bd55a74504b3b517", + "tests": [ + "VariationalIntegration.AvbdCompliantPublicArticulatedJointAggregatesFiniteAndMotorBreakLoads", + "VariationalIntegration.AvbdCompliantPublicArticulatedJointBreakResetRearmsFiniteRows" + ] + }, + "python_scene_test": "test_avbd_articulated_compliant_breakable_motor_demo_rearms_finite_rows" + }, + "load_contract": { + "finite_rows": "accepted stiffness times accepted residual", + "fracture_action": "mark the source joint broken and clear its finite row lambda/stiffness state before later-step extraction skips it", + "hard_and_motor_projection_rows": "position-level projection lambda divided by time_step squared", + "joint_aggregation": "L2 norm across finite and projection rows", + "public_threshold_units": "force or torque" + }, + "packet": "avbd_articulated_compliant_fracture", + "performance_claim_boundary": "Candidate-only finite breakable-mechanism cost and scaling. The exact parent did not account for these finite or bounded-motor loads, so this packet makes no before/after speedup claim.", + "reproduction": { + "benchmark_command": "taskset -c 18 build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint --benchmark_filter='BM_AvbdArticulatedCompliantBreakableMotorStep/(1|4|16)$' --benchmark_min_time=0.05s --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out= --benchmark_out_format=json", + "capture_command": "LIBGL_ALWAYS_SOFTWARE=1 MESA_LOADER_DRIVER_OVERRIDE=llvmpipe PYTHONPATH=build/default/cpp/Release-docking/python:python BUILD_TYPE=Release-docking python scripts/capture_py_demo.py --scene avbd_articulated_compliant_breakable_motor --frames 150 --width 960 --height 540 --show-ui --view three-quarter --camera-distance 5 --camera-target 0,0,1 --output-dir ", + "image_verdict_command": "pixi run image-verdict -- /avbd_articulated_compliant_breakable_motor.png --out /image_verdict.json --meta scene=avbd_articulated_compliant_breakable_motor --meta view=three-quarter" + }, + "resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": false, + "recorded_from": "contact-free py-demo, focused World tests, and articulated finite-load benchmark row family", + "rigid_contact_solver": "none", + "rigid_point_joint_solver": "avbd" + }, + "scene": "avbd_articulated_compliant_breakable_motor", + "schema_version": 2, + "target": { + "complete_paper_reproduction": false, + "contract_rows": [ + "avbd.method.joints_and_attachments", + "avbd.method.finite_stiffness_ramping", + "avbd.method.motors", + "avbd.method.fracture" + ], + "scope": "physical break-force accounting across finite masked rows and bounded motor projection rows for same-multibody articulated point joints on CPU" + }, + "visual_capture": { + "frames": { + "count": 149, + "directory": "png_frames", + "first_frame": { + "file": "png_frames/frame_000001.png", + "sha256": "4f053c497293c3c4a5bd57f4e7cb10c145f7e815fef7eb6912748f3c72175975" + }, + "last_frame": { + "file": "png_frames/frame_000149.png", + "sha256": "8723d275bef35376bda361e7722fed1ef14c0a4ad1975dfea86051b82d331bc9" + } + }, + "image_verdict": { + "checks": { + "contrast": { + "bright_pixels": 1051, + "dark_pixels": 98371, + "mid_pixels": 28782, + "p05": 23.936, + "p95": 157.761, + "pass": true, + "reasons": [], + "region": { + "x0": 384, + "x1": 931, + "y0": 243, + "y1": 486 + }, + "sample_count": 132921, + "spread": 133.825, + "thresholds": { + "bright_luminance_gt": 190.0, + "dark_luminance_lt": 55.0, + "mid_luminance_max": 170.0, + "mid_luminance_min": 70.0, + "min_bright_pixels": 664, + "min_dark_pixels": 664, + "min_mid_pixels": 332, + "min_spread": 90.0 + } + }, + "non_blank": { + "nonzero_pixels": 518400, + "pass": true, + "reasons": [], + "total_pixels": 518400, + "unique_colors_seen": 2 + } + }, + "file": "image_verdict.json", + "machine_scope": "pixel-integrity", + "metadata": { + "scene": "avbd_articulated_compliant_breakable_motor", + "view": "three-quarter" + }, + "pass": true, + "sha256": "cdf3404cdd0311d0dc0124ed560984c883aa0f68e0a03c57e49bb7f9744aa0cc" + }, + "manifest_sha256": "e829939530d29b24b660dd855fa31210dfd07a56ff305c9bd845a4727eb76e98", + "scene": "avbd_articulated_compliant_breakable_motor", + "scene_metrics": { + "event_count": 150, + "latest_frame": 150, + "latest_metrics": { + "angular_stiffness": 100.0, + "axis_travel": 0.3512673896350404, + "break_force": 9.0, + "broken": 1.0, + "constraint": "finite_and_motor_physical_load_fracture_lifecycle", + "executor": "World.step default", + "history": { + "cycles": 0.0, + "max_transverse_residual": 0.1821538634946589, + "samples": 151.0, + "saw_broken": 1.0, + "saw_intact": 1.0, + "transitions": 3.0 + }, + "joint_name": "avbd_articulated_compliant_breakable_slider", + "linear_stiffness": 100.0, + "max_effort": 6.0, + "phase": "weak re-arm: break", + "prestrain": 0.08, + "row": "avbd_articulated_compliant_breakable_motor", + "solver": "avbd_variational_articulated", + "start_stiffness": 100.0, + "status": "broken", + "strong_break_force": 100.0, + "time_step_ms": 5.0, + "transverse_residual": 0.10368056156434158, + "weak_break_force": 9.0, + "world_time": 0.7500000000000006 + }, + "metric_key_counts": { + "angular_stiffness": 150, + "axis_travel": 150, + "break_force": 150, + "broken": 150, + "constraint": 150, + "executor": 150, + "history": 150, + "joint_name": 150, + "linear_stiffness": 150, + "max_effort": 150, + "phase": 150, + "prestrain": 150, + "row": 150, + "solver": 150, + "start_stiffness": 150, + "status": 150, + "strong_break_force": 150, + "time_step_ms": 150, + "transverse_residual": 150, + "weak_break_force": 150, + "world_time": 150 + }, + "numeric_ranges": { + "angular_stiffness": { + "max": 100.0, + "min": 100.0 + }, + "axis_travel": { + "max": 0.3512673896350404, + "min": 8.804580602117529e-05 + }, + "break_force": { + "max": 100.0, + "min": 9.0 + }, + "broken": { + "max": 1.0, + "min": 0.0 + }, + "linear_stiffness": { + "max": 100.0, + "min": 100.0 + }, + "max_effort": { + "max": 6.0, + "min": 6.0 + }, + "prestrain": { + "max": 0.08, + "min": 0.08 + }, + "start_stiffness": { + "max": 100.0, + "min": 100.0 + }, + "strong_break_force": { + "max": 100.0, + "min": 100.0 + }, + "time_step_ms": { + "max": 5.0, + "min": 5.0 + }, + "transverse_residual": { + "max": 0.1821538634946589, + "min": 0.018957483996866337 + }, + "weak_break_force": { + "max": 9.0, + "min": 9.0 + }, + "world_time": { + "max": 0.7500000000000006, + "min": 0.005 + } + } + }, + "scene_metrics_events": { + "file": "scene_metrics.jsonl", + "sha256": "d931df25287fb49874890d94ea516d649756fbe3dcb015bf32cb75380c081738" + }, + "screenshot": { + "file": "avbd_articulated_compliant_breakable_motor.png", + "height": 540, + "sha256": "8723d275bef35376bda361e7722fed1ef14c0a4ad1975dfea86051b82d331bc9", + "width": 960 + }, + "semantic_review": { + "file": "visual_review.json", + "inspected_frames": [ + { + "file": "png_frames/frame_000001.png", + "role": "start", + "sha256": "4f053c497293c3c4a5bd57f4e7cb10c145f7e815fef7eb6912748f3c72175975" + }, + { + "file": "png_frames/frame_000075.png", + "role": "middle", + "sha256": "79ecb97e2ea7fbf48871f4add852a60acc7410cc5131799ac086745596f34edd" + }, + { + "file": "png_frames/frame_000149.png", + "role": "end", + "sha256": "8723d275bef35376bda361e7722fed1ef14c0a4ad1975dfea86051b82d331bc9" + } + ], + "limitations": "The images corroborate phase identity, break/reset/re-arm state, visible release, and scene stability. They do not quantify individual finite-row or motor-row physical loads, establish their L2 aggregation, or prove timestep-unit conversion; the focused C++ load and lifecycle oracles decide those claims.", + "observations": "The docked panel and mechanism remain legible in all three frames. The start frame reports the weak threshold and broken state. The midpoint reports the 100 N strong-reset threshold and intact state while the blue and gold movable links remain aligned with the rail. The end reports weak re-arm and broken state; the links have visibly separated transverse to the rail and the connector is red. No contacts are present, so the observed lifecycle belongs to the articulated finite-row and bounded-motor path.", + "reviewer_capability": "native image input at original detail", + "sha256": "2e31961b14c975eb93d061e55bb7d07c0d85bb861451d2f7f21a73385c4b324d", + "verdict": "pass" + }, + "show_ui": true, + "ui_ready": { + "dropped_warmup_frames": 1, + "required": true + } + } +} diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md index 98b50cc975c51..764230faf578a 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md @@ -169,6 +169,9 @@ paper/source-demo corpus: | `avbd_articulated_compliant_motors` py-demo | Public bounded finite-stiffness revolute/prismatic motors between same-multibody movable-link pairs | Partial | | `BM_AvbdArticulatedCompliantMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 bounded finite articulated motors | Partial | | `avbd-articulated-compliant-motors-packet.json` | Mutation-sensitive motor/pair correctness, allocation, docked visual, solver-identity, and candidate-only scale evidence | Partial | +| `avbd_articulated_compliant_breakable_motor` py-demo | Finite-plus-motor physical-load fracture, strong reset, and weak re-arm lifecycle for one same-multibody prismatic pair | Partial | +| `BM_AvbdArticulatedCompliantBreakableMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 finite articulated motors with armed break thresholds | Partial | +| `avbd-articulated-compliant-fracture-packet.json` | Mutation-sensitive load/lifecycle/serialization, allocation, docked visual, solver-identity, and scale evidence | Partial | | `avbd_articulated_high_ratio_chain` py-demo | Five-link articulated variational-chain smoke with a 200:1 heavy tip | Partial | | `BM_AvbdArticulatedHighRatioChainStep` dashboard row | Narrow CPU dashboard row for the five-link 200:1 articulated high-ratio chain smoke | Partial | | `avbd_paper_scale_high_ratio_chain` py-demo | 50-link articulated variational-chain smoke with a 50,000:1 heavy tip | Partial | @@ -288,7 +291,7 @@ paper/source-demo corpus: | Box stack | Rigid stack visual and benchmark packet | Narrow source-row ports now cover Pyramid, Cards, Stack, and Stack Ratio; no paper/site/video box-stack packet exists | Partial | Broad rigid contact candidate generation | | Friction coefficient comparison | Parameter sweep with visual/JSON output | Narrow friction switching tests exist, and `BM_AvbdDemo2dFrictionCoefficientSweep`, [`avbd-friction-coefficient-sweep-packet.json`](avbd-friction-coefficient-sweep-packet.json), and [`avbd-friction-coefficient-sweep-plot.svg`](avbd-friction-coefficient-sweep-plot.svg) now provide benchmark/plot evidence over maximum Coulomb friction values 0, 0.5, 1, 2.5, and 5 for the source-shaped Dynamic Friction scene | Partial | Source/reference sweep timing, per-coefficient visual capture, and GPU parity | | Chain mail impact | Cloth/contact impact visual and benchmark packet | None | Missing | Unified soft/rigid AVBD coupling | -| Breakable wall | Fracture visual and benchmark packet | Narrow free-rigid and articulated breakable-joint py-demos plus movable-pair C++ reset tests and dashboard benchmark rows exist | Partial | Broad fracture corpus | +| Breakable wall | Fracture visual and benchmark packet | Narrow free-rigid and articulated breakable-joint py-demos plus physical finite-plus-motor load accounting, reset/re-arm C++ tests, and dashboard benchmark rows exist | Partial | Paper-wall geometry, four-method comparison, broad fracture corpus, and CUDA | | Large cloth plus articulated-rigid coupling | Coupled cloth/articulated rigid visual and benchmark packet | None | Missing | Unified soft/rigid coupling and GPU path | | `beta` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | | `alpha` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json index 03fd824600f68..25595f0368a3c 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json @@ -119,9 +119,10 @@ "tests/unit/simulation/deformable_vbd/test_vbd_finite_stiffness.cpp", "tests/unit/simulation/compute/test_variational_integration.cpp", "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json", - "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json" + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json", + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json" ], - "blockers": ["CPU finite-stiffness spring/tetrahedral rows, passive world-link articulated fixed/spherical/revolute/prismatic rows, same-multibody passive spherical/revolute/prismatic rows, and bounded same-multibody finite revolute/prismatic motors exist, but fracture accounting, the full paper/source corpus, unified rows, and CUDA parity are incomplete."] + "blockers": ["CPU finite-stiffness spring/tetrahedral rows, passive world-link and same-multibody articulated masks, bounded same-multibody finite motors, and physical finite-plus-motor fracture accounting exist, but the full paper/source corpus, broad finite-row fracture coverage, unified rows, and CUDA parity are incomplete."] }, { "id": "avbd.method.force_bounds", @@ -202,9 +203,10 @@ "tests/unit/simulation/world/test_vbd_world_solver.cpp", "tests/unit/simulation/compute/test_variational_integration.cpp", "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json", - "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json" + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json", + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json" ], - "blockers": ["Hard point-joint bridges, passive finite world-link and same-multibody spherical/revolute/prismatic masked rows, and bounded same-multibody finite revolute/prismatic motors exist on CPU, but finite-row fracture accounting, the full joint corpus, unified paper scenes, and CUDA parity are incomplete."] + "blockers": ["Hard point-joint bridges, passive finite world-link and same-multibody masks, bounded same-multibody finite motors, and physical finite-plus-motor break/reset accounting exist on CPU, but the full joint corpus, unified paper scenes, broad fracture coverage, and CUDA parity are incomplete."] }, { "id": "avbd.method.motors", @@ -212,16 +214,20 @@ "source_locator": "Paper/source-demo motor constraints", "evidence": [ "docs/plans/104-vertex-block-descent-solver/avbd-rigid-revolute-motor-packet.json", - "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json" + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json", + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json" ], - "blockers": ["Narrow public motor rows exist, but source-wide CPU wins, CUDA parity, and paper-scene integration are incomplete."] + "blockers": ["Narrow public hard and finite motor rows plus physical break-load accounting exist, but source-wide CPU wins, CUDA parity, and paper-scene integration are incomplete."] }, { "id": "avbd.method.fracture", "status": "partial", "source_locator": "Section 5.4 and Figure 13 breakable attachments", - "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json"], - "blockers": ["Narrow break/reset rows exist, but the paper wall, broad fracture lifecycle, solver identity, and CUDA parity are incomplete."] + "evidence": [ + "docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", + "docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json" + ], + "blockers": ["Hard and finite articulated break/reset rows now have narrow CPU and solver-identity evidence, but the paper wall, broad fracture lifecycle and row corpus, achieved-accuracy performance, and CUDA parity are incomplete."] }, { "id": "avbd.method.unified_rigid_soft", diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md index b0ae9892b8746..98f2881df2f8a 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md @@ -466,6 +466,14 @@ reset for all three spherical paths. now validates the five public fixed/spherical breakable point-joint benchmark rows over 1, 8, and 32 joints. Broad fracture-constraint, breakable-wall, GPU parity, and paper-number gates remain open. +[`avbd-articulated-compliant-fracture-packet.json`](avbd-articulated-compliant-fracture-packet.json) +adds the first finite articulated fracture contract: accepted finite-row forces +and bounded motor projection loads are converted to physical units, aggregated +per public joint, and drive break/reset/re-arm plus save/load behavior. Its +exact-parent mutation, allocation gates, docked lifecycle capture, and +candidate-only 2/8/32-motor benchmark close the prior load-accounting gap +without claiming the paper wall, broad fracture corpus, CUDA parity, or a +paper/reference performance win. Note: `avbd_demo2d_net`, `BM_AvbdDemo2dNetStep`, and `avbd-demo2d-net-packet.json` now cover the source Net row shape/count, @@ -581,8 +589,9 @@ open. joints, motor torque rows, attachment rows, maximum-force fracture, and breakage persistence. CPU hard point-joint bridges and passive finite world-link/same-multibody fixed/spherical/revolute/prismatic masked rows plus - bounded same-multibody finite revolute/prismatic motors are partial; broad - finite-row fracture accounting/corpus coverage and CUDA remain open. + bounded same-multibody finite revolute/prismatic motors now include narrow + physical finite-plus-motor load accounting and break/reset persistence; + broad fracture-corpus coverage and CUDA remain open. 7. **A6 - unified rigid/soft coupling:** Couple rigid rows and deformable VBD rows in one AVBD solve so soft cloth/elastic bodies interact with articulated rigid chains and stacks. @@ -641,8 +650,14 @@ next three gaps from that packet. The adjacent now records mutation-sensitive same-multibody passive S/R/P coverage, bounded finite revolute/prismatic motor rows, all three warmed allocation policies, an assessed docked capture, and candidate-only 2/8/32-motor scale data. The -first two gaps are closed on CPU; finite-row break-force accounting remains -missing. Non-topology +first two gaps are closed on CPU. +[`avbd-articulated-compliant-fracture-packet.json`](avbd-articulated-compliant-fracture-packet.json) +closes the third narrowly with physical accepted finite-row and bounded-motor +load aggregation, timestep-unit invariance, break/reset/re-arm and save/load +oracles, exact-parent mutation evidence, allocation gates, an assessed docked +capture, and candidate-only scale data. The paper wall, broad joint/fracture +corpus, unified rows, CUDA, and comparable paper/reference performance remain +open. Non-topology multibody-link fixed/revolute/prismatic point-joint entities can also generate hard private configs from simulation-entry current poses, and public same-multibody/world-link articulated @@ -739,8 +754,9 @@ coverage, but DART still lacks broader persistent motor lifecycle coverage beyond the one-DOF, direct/private movable-pair lifecycle, and generated current-pose plus public/private movable-pair tiny-limit and save/load/reset checks, -broad fracture lifecycle coverage beyond the now-covered 2D Fracture and 3D -Breakable source-demo fixed-joint break/reset rows, and broader +broad fracture lifecycle coverage beyond the now-covered finite articulated +load/reset contract and 2D Fracture/3D Breakable source-demo fixed-joint +break/reset rows, and broader public articulated World facade coverage beyond those linear-only spherical/link-link/world-link entrypoints and fixed/spherical save/load rebuilding. @@ -772,8 +788,9 @@ renders the timing/stability sweep. No same-hardware paper-number comparison or GPU parity exists yet. DART still cannot reproduce the paper's full hard-constrained articulated chains, same-hardware numbers, or cloth-plus-articulated-rigid coupling scenes. The next articulated mechanism -slice should close finite-row load accounting and fracture lifecycle before -treating the A5 joint-family surface as broad. +slice should broaden the now-covered finite-row load/fracture lifecycle into +the paper wall and remaining joint/attachment corpus before treating the A5 +joint-family surface as broad. The next implementation slice should either optimize the measured `avbd_demo2d_ground`, `avbd_demo2d_motor`, `avbd_demo2d_hanging_rope`, `avbd_demo2d_rod`, diff --git a/python/examples/demos/README.md b/python/examples/demos/README.md index 52598ffba68a2..aae86dfdedb80 100644 --- a/python/examples/demos/README.md +++ b/python/examples/demos/README.md @@ -1368,57 +1368,58 @@ free-rigid rows are also related evidence from the numbered rigid workflow. They expose scene-owned capture metrics so docked captures preserve the constraint/contact/motor signal instead of relying on screenshots alone. -| Scene id | Shows | AVBD capability exercised | -| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| `avbd_empty_baseline` | An empty variational-integrator World stepping with render-only axes | Source-demo corpus baseline with 2D/3D empty-row revision/default metadata | -| `avbd_demo2d_ground` | The `avbd-demo2d` Ground source scene as its static 2D ground slab | Source-demo static ground row with matched source geometry and metadata | -| `avbd_demo2d_motor` | The `avbd-demo2d` Motor source scene as a pinned rigid bar with a velocity motor | First non-empty source-demo row using public AVBD revolute motor rows | -| `avbd_demo2d_dynamic_friction` | The `avbd-demo2d` Dynamic Friction source scene as sliding boxes over a ground slab | Source-demo rigid friction row with matched source geometry and metadata | -| `avbd_demo2d_static_friction` | The `avbd-demo2d` Static Friction source scene as rotated 2D boxes on an inclined slab | Source-demo inclined-slab friction row with matched source geometry/metadata | -| `avbd_demo2d_pyramid` | The `avbd-demo2d` Pyramid source scene as a triangular pile of 2D rigid boxes | Source-demo rigid stacking row with matched source geometry and metadata | -| `avbd_demo2d_cards` | The `avbd-demo2d` Cards source scene as a thin-card rigid contact tower | Source-demo card-tower contact row with matched source geometry and metadata | -| `avbd_demo2d_stack` | The `avbd-demo2d` Stack source scene as falling 2D boxes over a ground slab | Source-demo rigid stacking row with matched source geometry and metadata | -| `avbd_demo2d_stack_ratio` | The `avbd-demo2d` Stack Ratio source scene as geometric-size 2D rigid boxes | Source-demo size-ratio stacking row with matched source geometry and metadata | -| `avbd_demo2d_rod` | The `avbd-demo2d` Rod source scene as a horizontal all-axis fixed-joint chain | Source-demo rod row with matched hard constraints, geometry, and metadata | -| `avbd_demo2d_soft_body` | The `avbd-demo2d` Soft Body source scene as two finite-stiffness box lattices | Source-demo soft-body row with finite all-axis joint stiffness and metadata | -| `avbd_demo2d_joint_grid` | The `avbd-demo2d` Joint Grid source scene as a 25x25 fixed-joint box lattice | Source-demo large joint-grid row with matched hard constraints and metadata | -| `avbd_demo2d_rope` | The `avbd-demo2d` Rope source scene as a horizontal point-jointed rigid chain | Source-demo rope row with matched anchors, geometry, and metadata | -| `avbd_demo2d_heavy_rope` | The `avbd-demo2d` Heavy Rope source scene with a large endpoint block | Source-demo high-ratio rope row with matched anchors, geometry, and metadata | -| `avbd_demo2d_hanging_rope` | The `avbd-demo2d` Hanging Rope source scene as vertical point-jointed links | Source-demo long rope row with matched anchors, geometry, and metadata | -| `avbd_demo2d_spring` | The `avbd-demo2d` Spring source scene as one radial rigid distance spring | Source-demo finite-stiffness spring row with matched anchors and metadata | -| `avbd_demo2d_spring_ratio` | The `avbd-demo2d` Spring Ratio source scene as alternating-stiffness radial springs | Source-demo stiffness-ratio row with matched anchors, geometry, and metadata | -| `avbd_demo2d_net` | The `avbd-demo2d` Net source scene as endpoint-pinned links under falling rigid blocks | Source-demo net row with matched anchors, geometry, and metadata | -| `avbd_demo2d_fracture` | The `avbd-demo2d` Fracture source scene as falling 2D boxes over weak fixed joints | Source-demo breakable fixed-joint row with public AVBD break thresholds | -| `avbd_demo3d_ground` | The `avbd-demo3d` Ground source scene as a falling rigid box over a static ground box | Source-demo rigid contact row with matched source geometry and metadata | -| `avbd_demo3d_dynamic_friction` | The `avbd-demo3d` Dynamic Friction source scene as sliding rigid boxes over ground | Source-demo rigid friction row with matched source geometry and metadata | -| `avbd_demo3d_static_friction` | The `avbd-demo3d` Static Friction source scene as rigid boxes on an inclined ramp | Source-demo inclined-ramp friction row with matched source geometry/metadata | -| `avbd_demo3d_pyramid` | The `avbd-demo3d` Pyramid source scene as a triangular pile of rigid boxes | Source-demo rigid stacking row with matched source geometry and metadata | -| `avbd_demo3d_rope` | The `avbd-demo3d` Rope source scene as anchored rigid links with linear-only point joints | Source-demo rope row with matched anchors, geometry, and metadata | -| `avbd_demo3d_heavy_rope` | The `avbd-demo3d` Heavy Rope source scene with a large endpoint block | Source-demo high-ratio rope row with matched anchors, geometry, and metadata | -| `avbd_demo3d_stack` | The `avbd-demo3d` Stack source scene as ten rigid boxes over a static ground box | Source-demo vertical stacking row with matched source geometry and metadata | -| `avbd_demo3d_stack_ratio` | The `avbd-demo3d` Stack Ratio source scene as a four-box geometric size tower | Source-demo size-ratio stacking row with matched source geometry and metadata | -| `avbd_demo3d_soft_body` | The `avbd-demo3d` Soft Body source scene as three finite-stiffness box lattices | Source-demo 3D soft-body row with finite all-axis joint stiffness and metadata | -| `avbd_demo3d_bridge` | The `avbd-demo3d` Bridge source scene with paired point-jointed planks and load boxes | Source-demo bridge row with matched anchors, geometry, and metadata | -| `avbd_demo3d_breakable` | The `avbd-demo3d` Breakable source scene as rigid boxes, contact shapes, and weak joints | Source-demo breakable fixed-joint row with public AVBD break thresholds | -| `avbd_rigid_fixed_joint_contact` | A fixed rigid payload sliding against static contact | Public fixed-joint rows plus ordinary contact | -| `avbd_rigid_revolute_motor` | A bounded revolute motor driving a free hinge | Public velocity actuator mapped to AVBD motor rows | -| `avbd_rigid_prismatic_motor` | A bounded prismatic motor driving a free rigid slider | Public linear velocity actuator mapped to AVBD motor rows | -| `avbd_articulated_revolute_motor` | A bounded revolute motor reversing an articulated floating link | Public articulated velocity motor updates through the bridge | -| `avbd_articulated_prismatic_motor` | A bounded prismatic motor reversing an articulated floating carriage | Public articulated linear motor updates through the bridge | -| `avbd_articulated_motor_breakable_joint` | A weak articulated revolute motor releasing and re-engaging an articulated floating link | Public articulated motor break/reset lifecycle through the bridge | -| `avbd_articulated_prismatic_pair_motor_breakable_joint` | A weak same-multibody prismatic motor releasing and re-engaging an articulated carriage | Public articulated linear motor break/reset lifecycle through the bridge | -| `avbd_articulated_prismatic_motor_breakable_joint` | A weak world-anchored prismatic motor releasing and re-engaging an articulated carriage | Public articulated linear motor break/reset lifecycle through the bridge | -| `avbd_articulated_world_revolute_motor_breakable_joint` | A weak world-anchored revolute motor releasing and re-engaging an articulated floating link | Public world-anchored articulated motor break/reset lifecycle through the bridge | -| `avbd_articulated_compliant_joints` | Finite-stiffness spherical, revolute, and prismatic articulated joint masks | Public passive finite rows preserve each joint family's free coordinates | -| `avbd_articulated_compliant_motors` | Finite-stiffness revolute and prismatic motors between movable articulated link pairs | Public finite masked rows share free coordinates with bounded motor rows | -| `avbd_articulated_high_ratio_chain` | A five-link variational chain swinging with a 200:1 heavy tip | Narrow articulated high mass-ratio smoke for the AVBD paper gap | -| `avbd_paper_scale_high_ratio_chain` | A 50-link variational chain swinging with a 50,000:1 heavy tip | Paper-scale articulated high mass-ratio visual smoke for the AVBD paper gap | -| `avbd_rigid_breakable_joint` | A weak fixed joint releasing and re-engaging a rigid payload | Public break-force threshold, reset, and broken-state path | -| `avbd_rigid_spherical_breakable_joint` | A weak spherical point joint releasing and re-engaging only a rigid payload anchor | Public free-rigid spherical break/reset while orientation stays free | -| `avbd_articulated_breakable_joint` | A world-anchored floating link released and reset through a public articulated fixed joint | Public articulated world-link break/reset path through the variational bridge | -| `avbd_articulated_fixed_pair_breakable_joint` | A same-multibody fixed joint releasing and restoring a captured relative pose | Public articulated link-link fixed break/reset through the variational bridge | -| `avbd_articulated_spherical_breakable_joint` | A world-anchored floating socket that releases and resets only its anchor | Public articulated spherical break/reset with orientation left free | -| `avbd_articulated_spherical_pair_breakable_joint` | A same-multibody floating socket that releases and resets only its anchor | Public articulated link-link spherical break/reset with orientation left free | +| Scene id | Shows | AVBD capability exercised | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `avbd_empty_baseline` | An empty variational-integrator World stepping with render-only axes | Source-demo corpus baseline with 2D/3D empty-row revision/default metadata | +| `avbd_demo2d_ground` | The `avbd-demo2d` Ground source scene as its static 2D ground slab | Source-demo static ground row with matched source geometry and metadata | +| `avbd_demo2d_motor` | The `avbd-demo2d` Motor source scene as a pinned rigid bar with a velocity motor | First non-empty source-demo row using public AVBD revolute motor rows | +| `avbd_demo2d_dynamic_friction` | The `avbd-demo2d` Dynamic Friction source scene as sliding boxes over a ground slab | Source-demo rigid friction row with matched source geometry and metadata | +| `avbd_demo2d_static_friction` | The `avbd-demo2d` Static Friction source scene as rotated 2D boxes on an inclined slab | Source-demo inclined-slab friction row with matched source geometry/metadata | +| `avbd_demo2d_pyramid` | The `avbd-demo2d` Pyramid source scene as a triangular pile of 2D rigid boxes | Source-demo rigid stacking row with matched source geometry and metadata | +| `avbd_demo2d_cards` | The `avbd-demo2d` Cards source scene as a thin-card rigid contact tower | Source-demo card-tower contact row with matched source geometry and metadata | +| `avbd_demo2d_stack` | The `avbd-demo2d` Stack source scene as falling 2D boxes over a ground slab | Source-demo rigid stacking row with matched source geometry and metadata | +| `avbd_demo2d_stack_ratio` | The `avbd-demo2d` Stack Ratio source scene as geometric-size 2D rigid boxes | Source-demo size-ratio stacking row with matched source geometry and metadata | +| `avbd_demo2d_rod` | The `avbd-demo2d` Rod source scene as a horizontal all-axis fixed-joint chain | Source-demo rod row with matched hard constraints, geometry, and metadata | +| `avbd_demo2d_soft_body` | The `avbd-demo2d` Soft Body source scene as two finite-stiffness box lattices | Source-demo soft-body row with finite all-axis joint stiffness and metadata | +| `avbd_demo2d_joint_grid` | The `avbd-demo2d` Joint Grid source scene as a 25x25 fixed-joint box lattice | Source-demo large joint-grid row with matched hard constraints and metadata | +| `avbd_demo2d_rope` | The `avbd-demo2d` Rope source scene as a horizontal point-jointed rigid chain | Source-demo rope row with matched anchors, geometry, and metadata | +| `avbd_demo2d_heavy_rope` | The `avbd-demo2d` Heavy Rope source scene with a large endpoint block | Source-demo high-ratio rope row with matched anchors, geometry, and metadata | +| `avbd_demo2d_hanging_rope` | The `avbd-demo2d` Hanging Rope source scene as vertical point-jointed links | Source-demo long rope row with matched anchors, geometry, and metadata | +| `avbd_demo2d_spring` | The `avbd-demo2d` Spring source scene as one radial rigid distance spring | Source-demo finite-stiffness spring row with matched anchors and metadata | +| `avbd_demo2d_spring_ratio` | The `avbd-demo2d` Spring Ratio source scene as alternating-stiffness radial springs | Source-demo stiffness-ratio row with matched anchors, geometry, and metadata | +| `avbd_demo2d_net` | The `avbd-demo2d` Net source scene as endpoint-pinned links under falling rigid blocks | Source-demo net row with matched anchors, geometry, and metadata | +| `avbd_demo2d_fracture` | The `avbd-demo2d` Fracture source scene as falling 2D boxes over weak fixed joints | Source-demo breakable fixed-joint row with public AVBD break thresholds | +| `avbd_demo3d_ground` | The `avbd-demo3d` Ground source scene as a falling rigid box over a static ground box | Source-demo rigid contact row with matched source geometry and metadata | +| `avbd_demo3d_dynamic_friction` | The `avbd-demo3d` Dynamic Friction source scene as sliding rigid boxes over ground | Source-demo rigid friction row with matched source geometry and metadata | +| `avbd_demo3d_static_friction` | The `avbd-demo3d` Static Friction source scene as rigid boxes on an inclined ramp | Source-demo inclined-ramp friction row with matched source geometry/metadata | +| `avbd_demo3d_pyramid` | The `avbd-demo3d` Pyramid source scene as a triangular pile of rigid boxes | Source-demo rigid stacking row with matched source geometry and metadata | +| `avbd_demo3d_rope` | The `avbd-demo3d` Rope source scene as anchored rigid links with linear-only point joints | Source-demo rope row with matched anchors, geometry, and metadata | +| `avbd_demo3d_heavy_rope` | The `avbd-demo3d` Heavy Rope source scene with a large endpoint block | Source-demo high-ratio rope row with matched anchors, geometry, and metadata | +| `avbd_demo3d_stack` | The `avbd-demo3d` Stack source scene as ten rigid boxes over a static ground box | Source-demo vertical stacking row with matched source geometry and metadata | +| `avbd_demo3d_stack_ratio` | The `avbd-demo3d` Stack Ratio source scene as a four-box geometric size tower | Source-demo size-ratio stacking row with matched source geometry and metadata | +| `avbd_demo3d_soft_body` | The `avbd-demo3d` Soft Body source scene as three finite-stiffness box lattices | Source-demo 3D soft-body row with finite all-axis joint stiffness and metadata | +| `avbd_demo3d_bridge` | The `avbd-demo3d` Bridge source scene with paired point-jointed planks and load boxes | Source-demo bridge row with matched anchors, geometry, and metadata | +| `avbd_demo3d_breakable` | The `avbd-demo3d` Breakable source scene as rigid boxes, contact shapes, and weak joints | Source-demo breakable fixed-joint row with public AVBD break thresholds | +| `avbd_rigid_fixed_joint_contact` | A fixed rigid payload sliding against static contact | Public fixed-joint rows plus ordinary contact | +| `avbd_rigid_revolute_motor` | A bounded revolute motor driving a free hinge | Public velocity actuator mapped to AVBD motor rows | +| `avbd_rigid_prismatic_motor` | A bounded prismatic motor driving a free rigid slider | Public linear velocity actuator mapped to AVBD motor rows | +| `avbd_articulated_revolute_motor` | A bounded revolute motor reversing an articulated floating link | Public articulated velocity motor updates through the bridge | +| `avbd_articulated_prismatic_motor` | A bounded prismatic motor reversing an articulated floating carriage | Public articulated linear motor updates through the bridge | +| `avbd_articulated_motor_breakable_joint` | A weak articulated revolute motor releasing and re-engaging an articulated floating link | Public articulated motor break/reset lifecycle through the bridge | +| `avbd_articulated_prismatic_pair_motor_breakable_joint` | A weak same-multibody prismatic motor releasing and re-engaging an articulated carriage | Public articulated linear motor break/reset lifecycle through the bridge | +| `avbd_articulated_prismatic_motor_breakable_joint` | A weak world-anchored prismatic motor releasing and re-engaging an articulated carriage | Public articulated linear motor break/reset lifecycle through the bridge | +| `avbd_articulated_world_revolute_motor_breakable_joint` | A weak world-anchored revolute motor releasing and re-engaging an articulated floating link | Public world-anchored articulated motor break/reset lifecycle through the bridge | +| `avbd_articulated_compliant_breakable_motor` | A finite-stiffness same-multibody prismatic motor breaking, resetting, and re-arming | Combined finite and bounded-motor physical load drives the public break threshold | +| `avbd_articulated_compliant_joints` | Finite-stiffness spherical, revolute, and prismatic articulated joint masks | Public passive finite rows preserve each joint family's free coordinates | +| `avbd_articulated_compliant_motors` | Finite-stiffness revolute and prismatic motors between movable articulated link pairs | Public finite masked rows share free coordinates with bounded motor rows | +| `avbd_articulated_high_ratio_chain` | A five-link variational chain swinging with a 200:1 heavy tip | Narrow articulated high mass-ratio smoke for the AVBD paper gap | +| `avbd_paper_scale_high_ratio_chain` | A 50-link variational chain swinging with a 50,000:1 heavy tip | Paper-scale articulated high mass-ratio visual smoke for the AVBD paper gap | +| `avbd_rigid_breakable_joint` | A weak fixed joint releasing and re-engaging a rigid payload | Public break-force threshold, reset, and broken-state path | +| `avbd_rigid_spherical_breakable_joint` | A weak spherical point joint releasing and re-engaging only a rigid payload anchor | Public free-rigid spherical break/reset while orientation stays free | +| `avbd_articulated_breakable_joint` | A world-anchored floating link released and reset through a public articulated fixed joint | Public articulated world-link break/reset path through the variational bridge | +| `avbd_articulated_fixed_pair_breakable_joint` | A same-multibody fixed joint releasing and restoring a captured relative pose | Public articulated link-link fixed break/reset through the variational bridge | +| `avbd_articulated_spherical_breakable_joint` | A world-anchored floating socket that releases and resets only its anchor | Public articulated spherical break/reset with orientation left free | +| `avbd_articulated_spherical_pair_breakable_joint` | A same-multibody floating socket that releases and resets only its anchor | Public articulated link-link spherical break/reset with orientation left free | This is an early AVBD rigid-row showcase, not a paper-complete reproduction. The remaining AVBD corpus still needs the full 2D/3D reference demos, paper diff --git a/python/examples/demos/registry.py b/python/examples/demos/registry.py index 8000da8ba853f..42fdba90eb9c6 100644 --- a/python/examples/demos/registry.py +++ b/python/examples/demos/registry.py @@ -48,6 +48,9 @@ from .scenes.avbd_articulated_breakable_joint import ( SCENE as AVBD_ARTICULATED_BREAKABLE_JOINT, ) +from .scenes.avbd_articulated_compliant_breakable_motor import ( + SCENE as AVBD_ARTICULATED_COMPLIANT_BREAKABLE_MOTOR, +) from .scenes.avbd_articulated_compliant_joints import ( SCENE as AVBD_ARTICULATED_COMPLIANT_JOINTS, ) @@ -314,6 +317,7 @@ def make_demo_scenes() -> list[PythonDemoScene]: AVBD_ARTICULATED_PRISMATIC_PAIR_MOTOR_BREAKABLE_JOINT, AVBD_ARTICULATED_PRISMATIC_MOTOR_BREAKABLE_JOINT, AVBD_ARTICULATED_WORLD_REVOLUTE_MOTOR_BREAKABLE_JOINT, + AVBD_ARTICULATED_COMPLIANT_BREAKABLE_MOTOR, AVBD_ARTICULATED_COMPLIANT_JOINTS, AVBD_ARTICULATED_COMPLIANT_MOTORS, AVBD_ARTICULATED_HIGH_RATIO_CHAIN, diff --git a/python/examples/demos/scenes/avbd_articulated_compliant_breakable_motor.py b/python/examples/demos/scenes/avbd_articulated_compliant_breakable_motor.py new file mode 100644 index 0000000000000..0d2fe039b33ff --- /dev/null +++ b/python/examples/demos/scenes/avbd_articulated_compliant_breakable_motor.py @@ -0,0 +1,414 @@ +"""Finite-stiffness AVBD articulated break/load/reset lifecycle showcase.""" + +from __future__ import annotations + +from collections import deque + +import numpy as np + +import dartpy as dart +import dartpy as sx + +from .._world_bridge import WorldRenderBridge +from ..runner import CAPTURE_METRICS_INFO_KEY, PythonDemoScene, ScenePanel, SceneSetup + +_TIME_STEP = 0.005 +_START_STIFFNESS = 100.0 +_LINEAR_STIFFNESS = 100.0 +_ANGULAR_STIFFNESS = 100.0 +_TARGET_SPEED = 0.8 +_MAX_EFFORT = 6.0 +_WEAK_BREAK_FORCE = 9.0 +_STRONG_BREAK_FORCE = 100.0 +_PRESTRAIN = 0.08 +_LATERAL_FORCE = 200.0 +_PHASE_SECONDS = 0.25 +_CYCLE_SECONDS = 3.0 * _PHASE_SECONDS +_CENTER = np.array([0.0, 0.0, 1.0]) +_PARENT_ANCHOR = np.array([0.45, 0.0, 0.0]) +_CHILD_ANCHOR = np.array([-0.45, 0.0, 0.0]) +_AXIS = np.array([1.0, 0.0, 0.0]) +_PHASE_LABELS = ( + "weak: break", + "strong reset: intact", + "weak re-arm: break", +) + + +def _transform(position: np.ndarray) -> np.ndarray: + transform = np.eye(4) + transform[:3, 3] = position + return transform + + +def _normalized_or(vector: np.ndarray, fallback: np.ndarray) -> np.ndarray: + norm = float(np.linalg.norm(vector)) + if norm < 1.0e-12: + return fallback + return vector / norm + + +def _connector_transform(start: np.ndarray, end: np.ndarray) -> np.ndarray: + x_axis = _normalized_or(end - start, np.array([1.0, 0.0, 0.0])) + reference = np.array([0.0, 0.0, 1.0]) + if abs(float(reference @ x_axis)) > 0.95: + reference = np.array([0.0, 1.0, 0.0]) + y_axis = _normalized_or(np.cross(reference, x_axis), np.array([0.0, 1.0, 0.0])) + z_axis = np.cross(x_axis, y_axis) + transform = _transform(0.5 * (start + end)) + transform[:3, 0] = x_axis + transform[:3, 1] = y_axis + transform[:3, 2] = z_axis + return transform + + +def _link_rotation(link: sx.Link) -> np.ndarray: + return np.asarray(link.rotation, dtype=float).reshape(3, 3) + + +def _link_translation(link: sx.Link) -> np.ndarray: + return np.asarray(link.translation, dtype=float).reshape(3) + + +def _world_point(link: sx.Link, local_point: np.ndarray) -> np.ndarray: + return _link_rotation(link) @ local_point + _link_translation(link) + + +def build() -> SceneSetup: + world = sx.World(time_step=_TIME_STEP, gravity=(0.0, 0.0, 0.0)) + world.multibody_options = sx.MultibodyOptions( + integration_family=sx.MultibodyIntegrationFamily.VARIATIONAL + ) + + robot = world.add_multibody("avbd_articulated_compliant_breakable_motor") + base = robot.add_link("avbd_articulated_compliant_breakable_base") + parent_position = _CENTER - _PARENT_ANCHOR + child_position = _CENTER - _CHILD_ANCHOR + + def add_floating_link(name: str, position: np.ndarray, mass: float) -> sx.Link: + link = robot.add_link( + name, + parent=base, + joint=sx.JointSpec( + name=f"{name}_floating", + type=sx.JointType.FLOATING, + ), + ) + link.mass = mass + link.inertia = ((0.16, 0.0, 0.0), (0.0, 0.22, 0.0), (0.0, 0.0, 0.28)) + link.parent_joint.position = [*position, 0.0, 0.0, 0.0] + return link + + parent = add_floating_link( + "avbd_articulated_compliant_breakable_parent", + parent_position, + 100.0, + ) + child = add_floating_link( + "avbd_articulated_compliant_breakable_child", + child_position, + 120.0, + ) + + joint = world.add_joint( + parent, + child, + sx.JointSpec( + name="avbd_articulated_compliant_breakable_slider", + type=sx.JointType.PRISMATIC, + axis=_AXIS.tolist(), + parent_anchor=_PARENT_ANCHOR.tolist(), + child_anchor=_CHILD_ANCHOR.tolist(), + ), + ) + joint.actuator_type = sx.ActuatorType.VELOCITY + joint.command_velocity = [_TARGET_SPEED] + joint.set_effort_limits([-_MAX_EFFORT], [_MAX_EFFORT]) + policy = joint.constraint_projection_policy + policy.start_stiffness = _START_STIFFNESS + policy.linear_stiffness = _LINEAR_STIFFNESS + policy.angular_stiffness = _ANGULAR_STIFFNESS + joint.constraint_projection_policy = policy + joint.break_force = _WEAK_BREAK_FORCE + + world.enter_simulation_mode() + + bridge = WorldRenderBridge( + world, name="avbd_articulated_compliant_breakable_motor_render" + ) + bridge.add_link_visual( + parent, + dart.BoxShape(np.array([0.82, 0.20, 0.20])), + (0.22, 0.52, 0.84), + name="avbd_articulated_compliant_breakable_parent_visual", + ) + bridge.add_link_visual( + child, + dart.BoxShape(np.array([0.82, 0.20, 0.20])), + (0.95, 0.57, 0.16), + name="avbd_articulated_compliant_breakable_child_visual", + ) + + rail = dart.SimpleFrame( + dart.gui.world_render_frame(), + "avbd_articulated_compliant_breakable_rail", + _transform(_CENTER), + ) + rail.set_shape(dart.BoxShape(np.array([2.6, 0.035, 0.035]))) + rail_visual = rail.create_visual_aspect() + rail_visual.set_color([0.74, 0.78, 0.84]) + bridge.render_world.add_simple_frame(rail) + + anchor_marker = dart.SimpleFrame( + dart.gui.world_render_frame(), + "avbd_articulated_compliant_breakable_anchor", + _transform(_CENTER), + ) + anchor_marker.set_shape(dart.SphereShape(0.075)) + anchor_marker.create_visual_aspect().set_color([0.86, 0.86, 0.80]) + bridge.render_world.add_simple_frame(anchor_marker) + + connector = dart.SimpleFrame( + dart.gui.world_render_frame(), + "avbd_articulated_compliant_breakable_connector", + _connector_transform(_CENTER, _CENTER), + ) + connector.set_shape(dart.BoxShape(np.array([0.02, 0.055, 0.055]))) + connector_visual = connector.create_visual_aspect() + connector_visual.set_color([0.35, 0.82, 0.45]) + bridge.render_world.add_simple_frame(connector) + + replay_state: dict[str, object] = { + "enabled": True, + "cycle_start": 0.0, + "phase_index": 0, + "cycles": 0, + "transitions": 0, + } + + def reset_loaded_pose(break_force: float, phase_index: int) -> None: + parent.parent_joint.position = [*parent_position, 0.0, 0.0, 0.0] + child.parent_joint.position = [ + *(child_position + _PRESTRAIN * np.array([0.0, 1.0, 0.0])), + 0.0, + 0.0, + 0.0, + ] + parent.parent_joint.velocity = [0.0] * 6 + child.parent_joint.velocity = [0.0] * 6 + joint.command_velocity = [_TARGET_SPEED] + joint.break_force = float(break_force) + joint.reset_breakage() + replay_state["phase_index"] = int(phase_index) + replay_state["transitions"] = int(replay_state["transitions"]) + 1 + world.update_kinematics() + bridge.sync() + + def reset_strong_joint() -> None: + reset_loaded_pose(_STRONG_BREAK_FORCE, 1) + + def rearm_weak_joint() -> None: + reset_loaded_pose(_WEAK_BREAK_FORCE, 2) + + def start_weak_cycle() -> None: + reset_loaded_pose(_WEAK_BREAK_FORCE, 0) + + def anchor_delta() -> np.ndarray: + return _world_point(child, _CHILD_ANCHOR) - _world_point( + parent, _PARENT_ANCHOR + ) + + def sample_metrics() -> dict[str, float | str]: + delta = anchor_delta() + axis_travel = float(delta @ _AXIS) + transverse = delta - axis_travel * _AXIS + phase_index = int(replay_state["phase_index"]) + return { + "axis_travel": axis_travel, + "transverse_residual": float(np.linalg.norm(transverse)), + "broken": 1.0 if joint.is_broken else 0.0, + "break_force": float(joint.break_force), + "world_time": float(world.time), + "phase": _PHASE_LABELS[phase_index], + "cycles": float(replay_state["cycles"]), + "transitions": float(replay_state["transitions"]), + } + + transverse_history: deque[float] = deque(maxlen=180) + travel_history: deque[float] = deque(maxlen=180) + broken_history: deque[float] = deque(maxlen=180) + _last_metrics: dict[str, float | str] = {} + + def sync_visuals() -> None: + start = _world_point(parent, _PARENT_ANCHOR) + end = _world_point(child, _CHILD_ANCHOR) + distance = max(float(np.linalg.norm(end - start)), 0.02) + connector.set_transform(_connector_transform(start, end)) + connector.set_shape(dart.BoxShape(np.array([distance, 0.055, 0.055]))) + if joint.is_broken: + connector_visual.set_color([0.95, 0.18, 0.13]) + elif int(replay_state["phase_index"]) == 1: + connector_visual.set_color([0.25, 0.86, 0.42]) + else: + connector_visual.set_color([0.94, 0.72, 0.18]) + + def record_metrics() -> dict[str, float | str]: + _last_metrics.clear() + _last_metrics.update(sample_metrics()) + transverse_history.append(float(_last_metrics["transverse_residual"])) + travel_history.append(float(_last_metrics["axis_travel"])) + broken_history.append(float(_last_metrics["broken"])) + return _last_metrics + + def capture_metrics() -> dict[str, object]: + if not _last_metrics: + record_metrics() + broken_values = list(broken_history) + transverse_values = list(transverse_history) + return { + "row": "avbd_articulated_compliant_breakable_motor", + "solver": "avbd_variational_articulated", + "executor": "World.step default", + "constraint": "finite_and_motor_physical_load_fracture_lifecycle", + "time_step_ms": _TIME_STEP * 1000.0, + "world_time": float(world.time), + "joint_name": str(joint.name), + "phase": str(_last_metrics["phase"]), + "status": "broken" if joint.is_broken else "intact", + "break_force": float(joint.break_force), + "weak_break_force": _WEAK_BREAK_FORCE, + "strong_break_force": _STRONG_BREAK_FORCE, + "start_stiffness": _START_STIFFNESS, + "linear_stiffness": _LINEAR_STIFFNESS, + "angular_stiffness": _ANGULAR_STIFFNESS, + "max_effort": _MAX_EFFORT, + "prestrain": _PRESTRAIN, + "axis_travel": float(_last_metrics["axis_travel"]), + "transverse_residual": float(_last_metrics["transverse_residual"]), + "broken": float(_last_metrics["broken"]), + "history": { + "samples": float(len(broken_values)), + "saw_broken": max(broken_values, default=0.0), + "saw_intact": 1.0 - min(broken_values, default=0.0), + "max_transverse_residual": max(transverse_values, default=0.0), + "cycles": float(replay_state["cycles"]), + "transitions": float(replay_state["transitions"]), + }, + } + + def replay_capture_state() -> dict[str, object]: + return dict(replay_state) + + def replay_restore_state(state: dict[str, object]) -> None: + replay_state.update(state) + joint.command_velocity = [_TARGET_SPEED] + bridge.sync() + record_metrics() + sync_visuals() + + def pre_step() -> None: + if bool(replay_state["enabled"]): + elapsed = float(world.time) - float(replay_state["cycle_start"]) + if elapsed >= _CYCLE_SECONDS: + replay_state["cycle_start"] = float(world.time) + replay_state["cycles"] = int(replay_state["cycles"]) + 1 + start_weak_cycle() + elapsed = 0.0 + phase_index = min(int(elapsed / _PHASE_SECONDS), 2) + if phase_index != int(replay_state["phase_index"]): + if phase_index == 1: + reset_strong_joint() + elif phase_index == 2: + rearm_weak_joint() + + parent.apply_force((0.0, -_LATERAL_FORCE, 0.0)) + child.apply_force((0.0, _LATERAL_FORCE, 0.0)) + joint.command_velocity = [_TARGET_SPEED] + bridge.pre_step() + record_metrics() + sync_visuals() + + start_weak_cycle() + record_metrics() + sync_visuals() + + def build_panel(builder: object, context: object) -> None: + metrics = _last_metrics or record_metrics() + builder.text("solver: AVBD finite rows + bounded motor load") + builder.text(f"phase: {metrics['phase']}") + builder.text(f"state: {'broken' if joint.is_broken else 'intact'}") + builder.text(f"break threshold: {joint.break_force:.1f} N") + builder.text(f"finite stiffness: {_LINEAR_STIFFNESS:.1f} N/m") + builder.text(f"motor effort cap: {_MAX_EFFORT:.1f} N") + builder.text(f"prestrain: {_PRESTRAIN:.3f} m") + builder.text( + "transverse residual: " + f"{float(metrics['transverse_residual']):.4f} m" + ) + builder.text(f"axis travel: {float(metrics['axis_travel']):.4f} m") + builder.text(f"replay cycles: {int(replay_state['cycles'])}") + if builder.button("Reset strong"): + reset_strong_joint() + if builder.button("Re-arm weak"): + rearm_weak_joint() + changed, enabled = builder.checkbox( + "Loop fracture lifecycle", + bool(replay_state["enabled"]), + ) + if changed: + replay_state["enabled"] = bool(enabled) + replay_state["cycle_start"] = float(world.time) + builder.plot_lines("Transverse residual", list(transverse_history)) + builder.plot_lines("Slider travel", list(travel_history)) + builder.plot_lines("Broken", list(broken_history)) + builder.separator() + bridge.build_control_panel(builder, context) + + return SceneSetup( + world=bridge.render_world, + pre_step=pre_step, + force_drag=bridge.force_drag, + panels=[ + ScenePanel( + "AVBD Articulated Compliant Breakable Motor", + build_panel, + ) + ], + info={ + "sx_world": world, + "base": base, + "parent": parent, + "child": child, + "joint": joint, + "axis": _AXIS.copy(), + "metrics": sample_metrics, + "replay_state": replay_state, + "replay_capture_state": replay_capture_state, + "replay_restore_state": replay_restore_state, + "reset_loaded_pose": reset_loaded_pose, + "reset_strong_joint": reset_strong_joint, + "rearm_weak_joint": rearm_weak_joint, + "start_weak_cycle": start_weak_cycle, + "time_step": _TIME_STEP, + "start_stiffness": _START_STIFFNESS, + "linear_stiffness": _LINEAR_STIFFNESS, + "angular_stiffness": _ANGULAR_STIFFNESS, + "target_speed": _TARGET_SPEED, + "max_effort": _MAX_EFFORT, + "weak_break_force": _WEAK_BREAK_FORCE, + "strong_break_force": _STRONG_BREAK_FORCE, + "prestrain": _PRESTRAIN, + "phase_seconds": _PHASE_SECONDS, + CAPTURE_METRICS_INFO_KEY: capture_metrics, + }, + ) + + +SCENE = PythonDemoScene( + id="avbd_articulated_compliant_breakable_motor", + title="AVBD Articulated Compliant Breakable Motor (sx)", + category="AVBD Rigid Constraints (sx)", + summary="A finite same-multibody prismatic motor breaks from its combined " + "physical row load, resets strong, and fractures again when re-armed.", + build=build, +) diff --git a/python/tests/integration/test_demos_cycle.py b/python/tests/integration/test_demos_cycle.py index f0fb8c73c7ab6..137299ad523ee 100644 --- a/python/tests/integration/test_demos_cycle.py +++ b/python/tests/integration/test_demos_cycle.py @@ -935,6 +935,7 @@ def test_world_scenes_use_solver_focused_categories() -> None: "avbd_articulated_prismatic_pair_motor_breakable_joint", "avbd_articulated_prismatic_motor_breakable_joint", "avbd_articulated_world_revolute_motor_breakable_joint", + "avbd_articulated_compliant_breakable_motor", "avbd_articulated_compliant_joints", "avbd_articulated_compliant_motors", "avbd_articulated_high_ratio_chain", @@ -11989,6 +11990,69 @@ def test_avbd_articulated_compliant_motors_demo_drives_free_coordinates() -> Non ) +def test_avbd_articulated_compliant_breakable_motor_demo_rearms_finite_rows() -> None: + import numpy as np + + sx = _require_simulation_experimental_symbols("World", "MultibodyOptions") + + from examples.demos.scenes.avbd_articulated_compliant_breakable_motor import ( + build, + ) + + setup = build() + sx_world = setup.info["sx_world"] + joint = setup.info["joint"] + metrics = setup.info["metrics"] + + assert ( + sx_world.multibody_options.integration_family + == sx.MultibodyIntegrationFamily.VARIATIONAL + ) + assert joint.type == sx.JointType.PRISMATIC + assert joint.actuator_type == sx.ActuatorType.VELOCITY + assert joint.constraint_projection_policy.start_stiffness == pytest.approx( + setup.info["start_stiffness"] + ) + assert joint.constraint_projection_policy.linear_stiffness == pytest.approx( + setup.info["linear_stiffness"] + ) + assert joint.constraint_projection_policy.angular_stiffness == pytest.approx( + setup.info["angular_stiffness"] + ) + assert joint.effort_lower_limits[0] == pytest.approx(-setup.info["max_effort"]) + assert joint.effort_upper_limits[0] == pytest.approx(setup.info["max_effort"]) + + setup.info["replay_state"]["enabled"] = False + assert not joint.is_broken + assert setup.pre_step is not None + setup.pre_step() + setup.world.step() + assert joint.is_broken + weak_metrics = metrics() + assert np.isfinite(float(weak_metrics["transverse_residual"])) + assert float(weak_metrics["transverse_residual"]) > 0.05 + + setup.info["reset_strong_joint"]() + assert not joint.is_broken + for _ in range(8): + setup.pre_step() + setup.world.step() + assert not joint.is_broken + + setup.info["rearm_weak_joint"]() + assert not joint.is_broken + setup.pre_step() + setup.world.step() + assert joint.is_broken + + capture_metrics = setup.info[CAPTURE_METRICS_INFO_KEY]() + assert capture_metrics["constraint"] == ( + "finite_and_motor_physical_load_fracture_lifecycle" + ) + assert capture_metrics["history"]["saw_broken"] == pytest.approx(1.0) + assert capture_metrics["history"]["saw_intact"] == pytest.approx(1.0) + + def test_avbd_articulated_high_ratio_chain_demo_swings_and_resets() -> None: import numpy as np diff --git a/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py b/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py new file mode 100644 index 0000000000000..dc66cfe7d50f4 --- /dev/null +++ b/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +import importlib.util +import json +import struct +import sys +import zlib +from hashlib import sha256 +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ( + ROOT / "scripts" / "write_avbd_articulated_compliant_fracture_packet.py" +) + + +def _load_packet_module(): + spec = importlib.util.spec_from_file_location( + "write_avbd_articulated_compliant_fracture_packet", + SCRIPT, + ) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _png_chunk(kind: bytes, payload: bytes) -> bytes: + checksum = zlib.crc32(kind + payload) & 0xFFFFFFFF + return ( + struct.pack(">I", len(payload)) + + kind + + payload + + struct.pack(">I", checksum) + ) + + +def _write_png(path: Path, width: int = 4, height: int = 3) -> None: + rows = [b"\x00" + (b"\x10\x20\x30" * width) for _ in range(height)] + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + _png_chunk( + b"IHDR", + struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0), + ) + + _png_chunk(b"IDAT", zlib.compress(b"".join(rows), 6)) + + _png_chunk(b"IEND", b"") + ) + + +def _scene_metrics(*, saw_intact: float = 1.0) -> dict[str, object]: + metrics = { + "angular_stiffness": 100.0, + "constraint": "finite_and_motor_physical_load_fracture_lifecycle", + "history": { + "cycles": 0.0, + "max_transverse_residual": 0.18, + "samples": 100.0, + "saw_broken": 1.0, + "saw_intact": saw_intact, + "transitions": 3.0, + }, + "linear_stiffness": 100.0, + "max_effort": 6.0, + "phase": "weak re-arm: break", + "prestrain": 0.08, + "start_stiffness": 100.0, + "strong_break_force": 100.0, + "time_step_ms": 5.0, + "weak_break_force": 9.0, + } + latest = { + "event": "scene_capture_metrics", + "frame": 100, + "metrics": metrics, + "scene": "avbd_articulated_compliant_breakable_motor", + } + return { + "event_count": 100, + "first": latest, + "latest": latest, + "metric_key_counts": {key: 100 for key in metrics}, + "numeric_ranges": {}, + } + + +def _write_capture_manifest( + tmp_path: Path, + *, + docked_workspace: bool = True, + saw_intact: float = 1.0, +) -> Path: + capture = tmp_path / "capture" + frames = capture / "png_frames" + frames.mkdir(parents=True, exist_ok=True) + screenshot = capture / "avbd_articulated_compliant_breakable_motor.png" + _write_png(screenshot) + for index in range(1, 101): + _write_png(frames / f"frame_{index:06d}.png") + metrics_events = capture / "scene_metrics.jsonl" + metrics_events.write_text( + json.dumps(_scene_metrics(saw_intact=saw_intact)["latest"]) + "\n", + encoding="utf-8", + ) + manifest = { + "artifacts": { + "events": None, + "frames": str(frames), + "scene_metrics_events": str(metrics_events), + "screenshot": str(screenshot), + }, + "force_drag": None, + "scene": "avbd_articulated_compliant_breakable_motor", + "scene_metrics": _scene_metrics(saw_intact=saw_intact), + "schema_version": 1, + "show_ui": True, + "switch_frame": None, + "switch_scene": None, + "ui_ready": { + "dropped_warmup_frames": 1, + "required": True, + }, + "visual_evidence": { + "first_frame": {"docked_workspace": docked_workspace}, + "screenshot": {"docked_workspace": docked_workspace}, + }, + } + path = capture / "manifest.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + return path + + +def _sha256(path: Path) -> str: + return sha256(path.read_bytes()).hexdigest() + + +def _write_image_verdict(tmp_path: Path) -> Path: + path = tmp_path / "capture" / "image_verdict.json" + path.write_text( + json.dumps( + { + "checks": { + "contrast": {"pass": True}, + "non_blank": {"pass": True}, + }, + "image": { + "height": 3, + "path": "avbd_articulated_compliant_breakable_motor.png", + "width": 4, + }, + "machine_scope": "pixel-integrity", + "metadata": { + "scene": "avbd_articulated_compliant_breakable_motor", + "view": "three-quarter", + }, + "pass": True, + "schema_version": "dart.image_verdict/v1", + } + ), + encoding="utf-8", + ) + return path + + +def _write_visual_review(tmp_path: Path) -> Path: + capture = tmp_path / "capture" + frames = capture / "png_frames" + selected = { + "start": frames / "frame_000001.png", + "middle": frames / "frame_000050.png", + "end": frames / "frame_000100.png", + } + path = capture / "visual_review.json" + path.write_text( + json.dumps( + { + "inspected_frames": [ + { + "file": f"png_frames/{frame.name}", + "role": role, + "sha256": _sha256(frame), + } + for role, frame in selected.items() + ], + "limitations": ( + "Images corroborate the lifecycle but do not measure " + "individual physical row loads." + ), + "observations": ( + "The connector is broken, intact after reset, and broken " + "again in the three inspected phases." + ), + "reviewer_capability": "native image input", + "scene": "avbd_articulated_compliant_breakable_motor", + "schema_version": "dart.visual_semantic_review/v1", + "verdict": "pass", + } + ), + encoding="utf-8", + ) + return path + + +def _write_benchmark_json( + tmp_path: Path, + *, + missing_arg: int | None = None, +) -> Path: + rows = [] + for arg in (1, 4, 16): + if arg == missing_arg: + continue + rows.append( + { + "aggregate_name": "median", + "breakable_motors": float(2 * arg), + "cpu_time": float(1000 * arg), + "family_instances": float(arg), + "iterations": 10, + "name": ( + "BM_AvbdArticulatedCompliantBreakableMotorStep/" + f"{arg}_median" + ), + "prismatic_motors": float(arg), + "real_time": float(1100 * arg), + "revolute_motors": float(arg), + "run_name": ( + "BM_AvbdArticulatedCompliantBreakableMotorStep/" + f"{arg}" + ), + "run_type": "aggregate", + "time_unit": "ns", + } + ) + benchmark = { + "benchmarks": rows, + "context": { + "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "v1.9.5", + "mhz_per_cpu": 5300, + "num_cpus": 32, + }, + } + path = tmp_path / "benchmark.json" + path.write_text(json.dumps(benchmark), encoding="utf-8") + return path + + +def _write_exact_parent_mutation( + tmp_path: Path, + *, + parent_commit: str = "9ebd9b895b17e982bd1ed9287287d589b029409f", + omit_case: str | None = None, +) -> Path: + cases = ( + "finite_only", + "motor_dt_0.005", + "motor_dt_0.01", + "combined_load", + "reset_lifecycle", + ) + path = tmp_path / "exact_parent_mutation.json" + path.write_text( + json.dumps( + { + "schema_version": "dart.exact_parent_mutation/v1", + "parent_commit": parent_commit, + "tests": [ + "VariationalIntegration." + "AvbdCompliantPublicArticulatedJointAggregatesFiniteAndMotor" + "BreakLoads", + "VariationalIntegration." + "AvbdCompliantPublicArticulatedJointBreakResetRearmsFiniteRows", + ], + "mutation": ( + "Apply the candidate finite-load tests to the exact parent " + "without the load-accounting implementation." + ), + "command": ( + "./test_variational_integration --gtest_filter=" + "VariationalIntegration.AvbdCompliantPublicArticulated*" + ), + "exit_code": 1, + "expected_result": "fail", + "observed_result": "fail", + "failed_assertions": [ + { + "actual": False, + "case": case, + "expected": True, + } + for case in cases + if case != omit_case + ], + "interpretation": ( + "The exact parent does not fracture from finite or " + "finite-plus-motor physical loads." + ), + } + ), + encoding="utf-8", + ) + return path + + +def _write_valid_inputs(tmp_path: Path) -> dict[str, Path]: + return { + "capture_manifest": _write_capture_manifest(tmp_path), + "image_verdict_json": _write_image_verdict(tmp_path), + "visual_review_json": _write_visual_review(tmp_path), + "benchmark_json": _write_benchmark_json(tmp_path), + "exact_parent_mutation_json": _write_exact_parent_mutation(tmp_path), + } + + +def test_packet_records_finite_load_fracture_evidence(tmp_path: Path) -> None: + module = _load_packet_module() + inputs = _write_valid_inputs(tmp_path) + output = tmp_path / "packet.json" + + assert ( + module.main( + [ + "--capture-manifest", + str(inputs["capture_manifest"]), + "--image-verdict-json", + str(inputs["image_verdict_json"]), + "--visual-review-json", + str(inputs["visual_review_json"]), + "--benchmark-json", + str(inputs["benchmark_json"]), + "--exact-parent-mutation-json", + str(inputs["exact_parent_mutation_json"]), + "--output", + str(output), + ] + ) + == 0 + ) + + packet = json.loads(output.read_text(encoding="utf-8")) + assert packet["schema_version"] == 2 + assert packet["resolved_solver_identity"]["rigid_contact_solver"] == "none" + assert packet["resolved_solver_identity"]["rigid_point_joint_solver"] == "avbd" + assert packet["load_contract"]["joint_aggregation"] == ( + "L2 norm across finite and projection rows" + ) + assert packet["visual_capture"]["scene_metrics"]["event_count"] == 100 + assert len(packet["benchmark"]["scale_data"]) == 3 + assert packet["target"]["complete_paper_reproduction"] is False + + +def test_packet_rejects_capture_without_intact_reset_phase( + tmp_path: Path, +) -> None: + module = _load_packet_module() + inputs = _write_valid_inputs(tmp_path) + inputs["capture_manifest"] = _write_capture_manifest( + tmp_path, + saw_intact=0.0, + ) + + with pytest.raises( + module.AvbdArticulatedCompliantFracturePacketError, + match="saw_intact", + ): + module.make_packet(**inputs) + + +def test_packet_rejects_missing_benchmark_scale(tmp_path: Path) -> None: + module = _load_packet_module() + inputs = _write_valid_inputs(tmp_path) + inputs["benchmark_json"] = _write_benchmark_json(tmp_path, missing_arg=4) + + with pytest.raises( + module.AvbdArticulatedCompliantFracturePacketError, + match="missing breakable finite-motor scale rows", + ): + module.make_packet(**inputs) + + +def test_packet_rejects_wrong_exact_parent(tmp_path: Path) -> None: + module = _load_packet_module() + inputs = _write_valid_inputs(tmp_path) + inputs["exact_parent_mutation_json"] = _write_exact_parent_mutation( + tmp_path, + parent_commit="deadbeef", + ) + + with pytest.raises( + module.AvbdArticulatedCompliantFracturePacketError, + match="parent_commit", + ): + module.make_packet(**inputs) + + +def test_packet_rejects_incomplete_exact_parent_failures(tmp_path: Path) -> None: + module = _load_packet_module() + inputs = _write_valid_inputs(tmp_path) + inputs["exact_parent_mutation_json"] = _write_exact_parent_mutation( + tmp_path, + omit_case="combined_load", + ) + + with pytest.raises( + module.AvbdArticulatedCompliantFracturePacketError, + match="combined_load", + ): + module.make_packet(**inputs) + + +def test_packet_rejects_undocked_visual_capture(tmp_path: Path) -> None: + module = _load_packet_module() + inputs = _write_valid_inputs(tmp_path) + inputs["capture_manifest"] = _write_capture_manifest( + tmp_path, + docked_workspace=False, + ) + + with pytest.raises( + module.AvbdArticulatedCompliantFracturePacketError, + match="docked workspace", + ): + module.make_packet(**inputs) diff --git a/scripts/write_avbd_articulated_compliant_fracture_packet.py b/scripts/write_avbd_articulated_compliant_fracture_packet.py new file mode 100644 index 0000000000000..d09909fc2e71c --- /dev/null +++ b/scripts/write_avbd_articulated_compliant_fracture_packet.py @@ -0,0 +1,757 @@ +#!/usr/bin/env python3 +"""Write a validated AVBD articulated finite-load fracture evidence packet.""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from avbd_packet_schema import ( # noqa: E402 + AVBD_PACKET_SCHEMA_VERSION, + make_resolved_solver_identity, +) +from write_avbd_demo3d_static_friction_packet import ( # noqa: E402 + _artifact_label, + _artifact_path, + _canonical_name, + _load_json, + _png_dimensions, + _row_name, + _sha256, +) + +DEFAULT_OUTPUT = Path( + "docs/plans/104-vertex-block-descent-solver/" + "avbd-articulated-compliant-fracture-packet.json" +) +SCENE_ID = "avbd_articulated_compliant_breakable_motor" +BENCHMARK_NAME = "BM_AvbdArticulatedCompliantBreakableMotorStep" +BENCHMARK_ARGS = (1, 4, 16) +EXACT_PARENT_COMMIT = "9ebd9b895b17e982bd1ed9287287d589b029409f" +LOAD_TEST = ( + "VariationalIntegration." + "AvbdCompliantPublicArticulatedJointAggregatesFiniteAndMotorBreakLoads" +) +LIFECYCLE_TEST = ( + "VariationalIntegration." + "AvbdCompliantPublicArticulatedJointBreakResetRearmsFiniteRows" +) +SERIALIZATION_TEST = ( + "VariationalIntegration." + "AvbdCompliantPublicArticulatedFiniteBreakageSurvivesSaveLoadAndReset" +) +TIME_STEP = 0.005 +START_STIFFNESS = 100.0 +LINEAR_STIFFNESS = 100.0 +ANGULAR_STIFFNESS = 100.0 +EFFORT_LIMIT = 6.0 +WEAK_BREAK_FORCE = 9.0 +STRONG_BREAK_FORCE = 100.0 +PRESTRAIN = 0.08 +RESOLVED_SOLVER_IDENTITY = make_resolved_solver_identity( + resolved_rigid_contact_family=None, + rigid_point_joint_solver="avbd", + avbd_rigid_contact_config_emplaced=False, + recorded_from=( + "contact-free py-demo, focused World tests, and articulated " + "finite-load benchmark row family" + ), +) + + +class AvbdArticulatedCompliantFracturePacketError(RuntimeError): + pass + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--capture-manifest", type=Path, required=True) + parser.add_argument("--image-verdict-json", type=Path, required=True) + parser.add_argument("--visual-review-json", type=Path, required=True) + parser.add_argument("--benchmark-json", type=Path, required=True) + parser.add_argument("--exact-parent-mutation-json", type=Path, required=True) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + return parser.parse_args(argv) + + +def _capture_artifact_path( + manifest_dir: Path, + value: object, + key: str, +) -> Path: + if isinstance(value, str) and value: + path = Path(value) + if path.is_absolute(): + return path + repo_relative = SCRIPT_DIR.parent / path + if repo_relative.exists(): + return repo_relative + return _artifact_path(manifest_dir, value, key) + + +def _finite_number(value: object, label: str) -> float: + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(float(value)) + ): + raise AvbdArticulatedCompliantFracturePacketError( + f"{label} must be a finite number" + ) + return float(value) + + +def _require_close(value: object, expected: float, label: str) -> None: + actual = _finite_number(value, label) + if not math.isclose(actual, expected, rel_tol=0.0, abs_tol=1e-12): + raise AvbdArticulatedCompliantFracturePacketError( + f"{label} must be {expected:g}, got {actual:g}" + ) + + +def _validate_scene_metrics(manifest: dict[str, Any]) -> dict[str, Any]: + summary = manifest.get("scene_metrics") + if not isinstance(summary, dict): + raise AvbdArticulatedCompliantFracturePacketError( + "capture manifest missing scene_metrics" + ) + event_count = summary.get("event_count") + if ( + not isinstance(event_count, int) + or isinstance(event_count, bool) + or event_count < 100 + ): + raise AvbdArticulatedCompliantFracturePacketError( + "capture must record at least 100 scene metric events" + ) + latest = summary.get("latest") + if not isinstance(latest, dict): + raise AvbdArticulatedCompliantFracturePacketError( + "capture scene_metrics missing latest event" + ) + metrics = latest.get("metrics") + if not isinstance(metrics, dict): + raise AvbdArticulatedCompliantFracturePacketError( + "capture latest scene metric payload must be an object" + ) + if metrics.get("constraint") != "finite_and_motor_physical_load_fracture_lifecycle": + raise AvbdArticulatedCompliantFracturePacketError( + "capture metrics identify the wrong fracture lifecycle" + ) + if metrics.get("phase") not in { + "weak: break", + "strong reset: intact", + "weak re-arm: break", + }: + raise AvbdArticulatedCompliantFracturePacketError( + "capture metrics contain an unknown lifecycle phase" + ) + for key, expected in ( + ("time_step_ms", TIME_STEP * 1000.0), + ("start_stiffness", START_STIFFNESS), + ("linear_stiffness", LINEAR_STIFFNESS), + ("angular_stiffness", ANGULAR_STIFFNESS), + ("max_effort", EFFORT_LIMIT), + ("weak_break_force", WEAK_BREAK_FORCE), + ("strong_break_force", STRONG_BREAK_FORCE), + ("prestrain", PRESTRAIN), + ): + _require_close(metrics.get(key), expected, f"capture metrics {key}") + + history = metrics.get("history") + if not isinstance(history, dict): + raise AvbdArticulatedCompliantFracturePacketError( + "capture metrics missing lifecycle history" + ) + _require_close(history.get("saw_broken"), 1.0, "history saw_broken") + _require_close(history.get("saw_intact"), 1.0, "history saw_intact") + transitions = _finite_number(history.get("transitions"), "history transitions") + if transitions < 3.0: + raise AvbdArticulatedCompliantFracturePacketError( + "capture must include weak break, strong reset, and weak re-arm" + ) + max_residual = _finite_number( + history.get("max_transverse_residual"), + "history max_transverse_residual", + ) + if max_residual <= PRESTRAIN: + raise AvbdArticulatedCompliantFracturePacketError( + "capture must show released transverse drift beyond the preload" + ) + return { + "event_count": event_count, + "latest_frame": latest.get("frame"), + "latest_metrics": metrics, + "metric_key_counts": summary.get("metric_key_counts"), + "numeric_ranges": summary.get("numeric_ranges"), + } + + +def _validate_capture( + manifest_path: Path, +) -> tuple[dict[str, Any], Path, dict[str, Path]]: + manifest = _load_json(manifest_path) + if manifest.get("schema_version") != 1: + raise AvbdArticulatedCompliantFracturePacketError( + "capture manifest schema_version must be 1" + ) + if manifest.get("scene") != SCENE_ID: + raise AvbdArticulatedCompliantFracturePacketError( + f"capture scene must be {SCENE_ID}" + ) + if manifest.get("switch_scene") is not None: + raise AvbdArticulatedCompliantFracturePacketError( + "fracture capture must not switch scenes" + ) + if manifest.get("force_drag") is not None: + raise AvbdArticulatedCompliantFracturePacketError( + "fracture capture must not force-drag" + ) + + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict): + raise AvbdArticulatedCompliantFracturePacketError( + "capture manifest missing artifacts" + ) + manifest_dir = manifest_path.parent + screenshot = _capture_artifact_path( + manifest_dir, artifacts.get("screenshot"), "screenshot" + ) + frames_dir = _capture_artifact_path(manifest_dir, artifacts.get("frames"), "frames") + metrics_events = _capture_artifact_path( + manifest_dir, + artifacts.get("scene_metrics_events"), + "scene_metrics_events", + ) + if not screenshot.is_file(): + raise AvbdArticulatedCompliantFracturePacketError( + f"{screenshot}: screenshot not found" + ) + if not frames_dir.is_dir(): + raise AvbdArticulatedCompliantFracturePacketError( + f"{frames_dir}: frame directory not found" + ) + if not metrics_events.is_file(): + raise AvbdArticulatedCompliantFracturePacketError( + f"{metrics_events}: scene metrics event log not found" + ) + + frame_paths = sorted(frames_dir.glob("frame_*.png")) + if len(frame_paths) < 100: + raise AvbdArticulatedCompliantFracturePacketError( + "fracture capture must include at least 100 PNG frames" + ) + width, height = _png_dimensions(screenshot) + if _png_dimensions(frame_paths[0]) != (width, height): + raise AvbdArticulatedCompliantFracturePacketError( + "first frame dimensions do not match screenshot" + ) + if _png_dimensions(frame_paths[-1]) != (width, height): + raise AvbdArticulatedCompliantFracturePacketError( + "last frame dimensions do not match screenshot" + ) + ui_ready = manifest.get("ui_ready") + if not isinstance(ui_ready, dict): + raise AvbdArticulatedCompliantFracturePacketError( + "capture manifest missing ui_ready" + ) + if manifest.get("show_ui") is not True or ui_ready.get("required") is not True: + raise AvbdArticulatedCompliantFracturePacketError( + "fracture capture must show the required docked workspace" + ) + visual_evidence = manifest.get("visual_evidence") + if not isinstance(visual_evidence, dict) or any( + not isinstance(visual_evidence.get(role), dict) + or visual_evidence[role].get("docked_workspace") is not True + for role in ("first_frame", "screenshot") + ): + raise AvbdArticulatedCompliantFracturePacketError( + "fracture capture must verify the docked workspace" + ) + + middle_frame = frame_paths[(len(frame_paths) - 1) // 2] + return ( + { + "manifest_sha256": _sha256(manifest_path), + "scene": SCENE_ID, + "show_ui": True, + "ui_ready": ui_ready, + "screenshot": { + "file": _artifact_label(manifest_dir, screenshot), + "sha256": _sha256(screenshot), + "width": width, + "height": height, + }, + "frames": { + "directory": _artifact_label(manifest_dir, frames_dir), + "count": len(frame_paths), + "first_frame": { + "file": _artifact_label(manifest_dir, frame_paths[0]), + "sha256": _sha256(frame_paths[0]), + }, + "last_frame": { + "file": _artifact_label(manifest_dir, frame_paths[-1]), + "sha256": _sha256(frame_paths[-1]), + }, + }, + "scene_metrics_events": { + "file": _artifact_label(manifest_dir, metrics_events), + "sha256": _sha256(metrics_events), + }, + "scene_metrics": _validate_scene_metrics(manifest), + }, + screenshot, + { + "start": frame_paths[0], + "middle": middle_frame, + "end": frame_paths[-1], + }, + ) + + +def _validate_image_verdict( + verdict_path: Path, + screenshot: Path, +) -> dict[str, Any]: + verdict = _load_json(verdict_path) + if verdict.get("schema_version") != "dart.image_verdict/v1": + raise AvbdArticulatedCompliantFracturePacketError( + "image verdict schema_version must be dart.image_verdict/v1" + ) + if verdict.get("pass") is not True: + raise AvbdArticulatedCompliantFracturePacketError("image verdict must pass") + checks = verdict.get("checks") + if not isinstance(checks, dict): + raise AvbdArticulatedCompliantFracturePacketError( + "image verdict missing checks" + ) + non_blank = checks.get("non_blank") + if not isinstance(non_blank, dict) or non_blank.get("pass") is not True: + raise AvbdArticulatedCompliantFracturePacketError( + "image verdict non_blank check must pass" + ) + image = verdict.get("image") + if not isinstance(image, dict): + raise AvbdArticulatedCompliantFracturePacketError( + "image verdict missing image metadata" + ) + width, height = _png_dimensions(screenshot) + if image.get("width") != width or image.get("height") != height: + raise AvbdArticulatedCompliantFracturePacketError( + "image verdict dimensions do not match capture screenshot" + ) + return { + "file": verdict_path.name, + "sha256": _sha256(verdict_path), + "machine_scope": verdict.get("machine_scope"), + "metadata": verdict.get("metadata"), + "checks": checks, + "pass": True, + } + + +def _validate_visual_review( + review_path: Path, + manifest_path: Path, + inspected_frames: dict[str, Path], +) -> dict[str, Any]: + review = _load_json(review_path) + if review.get("schema_version") != "dart.visual_semantic_review/v1": + raise AvbdArticulatedCompliantFracturePacketError( + "visual review schema_version must be dart.visual_semantic_review/v1" + ) + if review.get("scene") != SCENE_ID: + raise AvbdArticulatedCompliantFracturePacketError( + f"visual review scene must be {SCENE_ID}" + ) + if review.get("verdict") != "pass": + raise AvbdArticulatedCompliantFracturePacketError( + "visual semantic review must pass" + ) + for key in ("observations", "limitations"): + value = review.get(key) + if not isinstance(value, str) or not value.strip(): + raise AvbdArticulatedCompliantFracturePacketError( + f"visual review {key} must be non-empty" + ) + + entries = review.get("inspected_frames") + if not isinstance(entries, list): + raise AvbdArticulatedCompliantFracturePacketError( + "visual review inspected_frames must be a list" + ) + by_role = { + entry.get("role"): entry + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("role"), str) + } + manifest_dir = manifest_path.parent + validated_frames = [] + for role, frame_path in inspected_frames.items(): + entry = by_role.get(role) + if entry is None: + raise AvbdArticulatedCompliantFracturePacketError( + f"visual review missing {role} frame" + ) + expected_file = _artifact_label(manifest_dir, frame_path) + expected_sha = _sha256(frame_path) + if entry.get("file") != expected_file: + raise AvbdArticulatedCompliantFracturePacketError( + f"visual review {role} frame must be {expected_file}" + ) + if entry.get("sha256") != expected_sha: + raise AvbdArticulatedCompliantFracturePacketError( + f"visual review {role} frame sha256 mismatch" + ) + validated_frames.append( + { + "role": role, + "file": expected_file, + "sha256": expected_sha, + } + ) + return { + "file": review_path.name, + "sha256": _sha256(review_path), + "reviewer_capability": review.get("reviewer_capability"), + "inspected_frames": validated_frames, + "observations": review["observations"], + "limitations": review["limitations"], + "verdict": "pass", + } + + +def _benchmark_arg(row: dict[str, Any]) -> int | None: + identity = _canonical_name(_row_name(row)) + prefix = f"{BENCHMARK_NAME}/" + if not identity.startswith(prefix): + return None + arg_text = identity[len(prefix) :] + try: + arg = int(arg_text) + except ValueError as exc: + raise AvbdArticulatedCompliantFracturePacketError( + f"{BENCHMARK_NAME}: non-integer benchmark argument {arg_text!r}" + ) from exc + if arg not in BENCHMARK_ARGS: + raise AvbdArticulatedCompliantFracturePacketError( + f"{BENCHMARK_NAME}: unexpected family_instances argument {arg}" + ) + return arg + + +def _is_representative(row: dict[str, Any]) -> bool: + run_type = row.get("run_type", "iteration") + aggregate_name = row.get("aggregate_name") + return run_type == "iteration" or aggregate_name in ("mean", "median") + + +def _timing_row(rows: list[dict[str, Any]]) -> dict[str, Any]: + median_rows = [row for row in rows if row.get("aggregate_name") == "median"] + if median_rows: + return median_rows[0] + mean_rows = [row for row in rows if row.get("aggregate_name") == "mean"] + if mean_rows: + return mean_rows[0] + return rows[0] + + +def _validate_benchmark(benchmark_json: Path) -> dict[str, Any]: + data = _load_json(benchmark_json) + rows = data.get("benchmarks") + if not isinstance(rows, list): + raise AvbdArticulatedCompliantFracturePacketError( + "benchmark JSON missing benchmarks list" + ) + + representative: dict[int, list[dict[str, Any]]] = { + arg: [] for arg in BENCHMARK_ARGS + } + packet_rows: list[dict[str, Any]] = [] + for row in rows: + if not isinstance(row, dict): + continue + arg = _benchmark_arg(row) + if arg is None: + continue + _finite_number(row.get("real_time"), f"{BENCHMARK_NAME} real_time") + _finite_number(row.get("cpu_time"), f"{BENCHMARK_NAME} cpu_time") + packet_rows.append(row) + if not _is_representative(row): + continue + for key, expected in ( + ("family_instances", float(arg)), + ("revolute_motors", float(arg)), + ("prismatic_motors", float(arg)), + ("breakable_motors", float(2 * arg)), + ): + _require_close(row.get(key), expected, f"{BENCHMARK_NAME} {key}") + representative[arg].append(row) + + missing = [arg for arg in BENCHMARK_ARGS if not representative[arg]] + if missing: + raise AvbdArticulatedCompliantFracturePacketError( + "benchmark JSON missing breakable finite-motor scale rows: " + + ", ".join(str(arg) for arg in missing) + ) + + scale_data = [] + for arg in BENCHMARK_ARGS: + row = _timing_row(representative[arg]) + motor_count = 2 * arg + cpu_time = _finite_number( + row.get("cpu_time"), f"{BENCHMARK_NAME}/{arg} cpu_time" + ) + real_time = _finite_number( + row.get("real_time"), f"{BENCHMARK_NAME}/{arg} real_time" + ) + scale_data.append( + { + "benchmark": f"{BENCHMARK_NAME}/{arg}", + "family_instances": arg, + "breakable_motors": motor_count, + "cpu_time_per_step_ns": cpu_time, + "real_time_per_step_ns": real_time, + "cpu_time_per_motor_ns": cpu_time / motor_count, + "time_unit": row.get("time_unit", "ns"), + } + ) + + context = data.get("context", {}) + if not isinstance(context, dict): + context = {} + return { + "json_sha256": _sha256(benchmark_json), + "benchmark": BENCHMARK_NAME, + "context": { + key: context[key] + for key in ( + "executable", + "num_cpus", + "mhz_per_cpu", + "library_version", + "library_build_type", + "json_schema_version", + ) + if key in context + }, + "rows": packet_rows, + "scale_data": scale_data, + "invariants": { + "family_instances": list(BENCHMARK_ARGS), + "motor_types": ["revolute", "prismatic"], + "endpoint_topology": "same-multibody movable-link pairs", + "breakable": True, + "break_force": 1.0e12, + "time_step": TIME_STEP, + }, + } + + +def _validate_exact_parent_mutation(path: Path) -> dict[str, Any]: + evidence = _load_json(path) + if evidence.get("schema_version") != "dart.exact_parent_mutation/v1": + raise AvbdArticulatedCompliantFracturePacketError( + "exact-parent mutation schema_version must be " + "dart.exact_parent_mutation/v1" + ) + if evidence.get("parent_commit") != EXACT_PARENT_COMMIT: + raise AvbdArticulatedCompliantFracturePacketError( + f"exact-parent mutation parent_commit must be {EXACT_PARENT_COMMIT}" + ) + if evidence.get("tests") != [LOAD_TEST, LIFECYCLE_TEST]: + raise AvbdArticulatedCompliantFracturePacketError( + "exact-parent mutation must run the load and lifecycle tests" + ) + if evidence.get("exit_code") != 1: + raise AvbdArticulatedCompliantFracturePacketError( + "exact-parent mutation exit_code must be 1" + ) + if ( + evidence.get("expected_result") != "fail" + or evidence.get("observed_result") != "fail" + ): + raise AvbdArticulatedCompliantFracturePacketError( + "exact-parent mutation must record expected and observed failure" + ) + + assertions = evidence.get("failed_assertions") + if not isinstance(assertions, list): + raise AvbdArticulatedCompliantFracturePacketError( + "exact-parent mutation failed_assertions must be a list" + ) + required_cases = { + "finite_only", + "motor_dt_0.005", + "motor_dt_0.01", + "combined_load", + "reset_lifecycle", + } + observed_cases = { + assertion.get("case") + for assertion in assertions + if isinstance(assertion, dict) + and assertion.get("expected") is True + and assertion.get("actual") is False + } + missing_cases = sorted(required_cases - observed_cases) + if missing_cases: + raise AvbdArticulatedCompliantFracturePacketError( + "exact-parent mutation missing failed cases: " + ", ".join(missing_cases) + ) + + for key in ("mutation", "command", "interpretation"): + value = evidence.get(key) + if not isinstance(value, str) or not value.strip(): + raise AvbdArticulatedCompliantFracturePacketError( + f"exact-parent mutation {key} must be non-empty" + ) + return { + "file": path.name, + "sha256": _sha256(path), + "parent_commit": EXACT_PARENT_COMMIT, + "tests": [LOAD_TEST, LIFECYCLE_TEST], + "mutation": evidence["mutation"], + "command": evidence["command"], + "exit_code": 1, + "observed_result": "fail", + "failed_assertions": assertions, + "interpretation": evidence["interpretation"], + } + + +def make_packet( + capture_manifest: Path, + image_verdict_json: Path, + visual_review_json: Path, + benchmark_json: Path, + exact_parent_mutation_json: Path, +) -> dict[str, Any]: + visual_capture, screenshot, inspected_frames = _validate_capture(capture_manifest) + visual_capture["image_verdict"] = _validate_image_verdict( + image_verdict_json, screenshot + ) + visual_capture["semantic_review"] = _validate_visual_review( + visual_review_json, capture_manifest, inspected_frames + ) + return { + "schema_version": AVBD_PACKET_SCHEMA_VERSION, + "resolved_solver_identity": RESOLVED_SOLVER_IDENTITY, + "packet": "avbd_articulated_compliant_fracture", + "scene": SCENE_ID, + "target": { + "contract_rows": [ + "avbd.method.joints_and_attachments", + "avbd.method.finite_stiffness_ramping", + "avbd.method.motors", + "avbd.method.fracture", + ], + "scope": ( + "physical break-force accounting across finite masked rows " + "and bounded motor projection rows for same-multibody " + "articulated point joints on CPU" + ), + "complete_paper_reproduction": False, + }, + "load_contract": { + "finite_rows": "accepted stiffness times accepted residual", + "hard_and_motor_projection_rows": ( + "position-level projection lambda divided by time_step squared" + ), + "joint_aggregation": "L2 norm across finite and projection rows", + "public_threshold_units": "force or torque", + "fracture_action": ( + "mark the source joint broken and clear its finite row " + "lambda/stiffness state before later-step extraction skips it" + ), + }, + "correctness": { + "cpp_load_test": LOAD_TEST, + "cpp_lifecycle_test": LIFECYCLE_TEST, + "cpp_serialization_test": SERIALIZATION_TEST, + "python_scene_test": ( + "test_avbd_articulated_compliant_breakable_motor_demo_" + "rearms_finite_rows" + ), + "allocation_tests": [ + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotGrowWorldBaseAllocator", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotAllocateGlobalHeap", + "World.BakedVariationalCompliantArticulatedPointJointRowsDoNotMallocOnHeap", + ], + "exact_parent_mutation": _validate_exact_parent_mutation( + exact_parent_mutation_json + ), + }, + "visual_capture": visual_capture, + "benchmark": _validate_benchmark(benchmark_json), + "performance_claim_boundary": ( + "Candidate-only finite breakable-mechanism cost and scaling. The " + "exact parent did not account for these finite or bounded-motor " + "loads, so this packet makes no before/after speedup claim." + ), + "claim_boundary": ( + "This closes the narrow CPU articulated finite-row load-accounting " + "gap. It does not reproduce the paper wall, close the broad " + "fracture corpus, unify rigid and soft rows, provide CUDA parity, " + "or establish a paper/reference performance win." + ), + "reproduction": { + "capture_command": ( + "LIBGL_ALWAYS_SOFTWARE=1 " + "MESA_LOADER_DRIVER_OVERRIDE=llvmpipe " + "PYTHONPATH=build/default/cpp/Release-docking/python:python " + "BUILD_TYPE=Release-docking python scripts/capture_py_demo.py " + f"--scene {SCENE_ID} --frames 150 --width 960 --height 540 " + "--show-ui --view three-quarter --camera-distance 5 " + "--camera-target 0,0,1 --output-dir " + ), + "image_verdict_command": ( + "pixi run image-verdict -- " + f"/{SCENE_ID}.png " + "--out /image_verdict.json " + f"--meta scene={SCENE_ID} --meta view=three-quarter" + ), + "benchmark_command": ( + "taskset -c 18 " + "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint " + "--benchmark_filter=" + f"'{BENCHMARK_NAME}/(1|4|16)$' " + "--benchmark_min_time=0.05s --benchmark_repetitions=5 " + "--benchmark_report_aggregates_only=true " + "--benchmark_out= " + "--benchmark_out_format=json" + ), + }, + } + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + packet = make_packet( + args.capture_manifest, + args.image_verdict_json, + args.visual_review_json, + args.benchmark_json, + args.exact_parent_mutation_json, + ) + except (OSError, ValueError, AvbdArticulatedCompliantFracturePacketError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(packet, indent=2, sort_keys=True) + "\n") + print(f"Wrote {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp index c7d487cc25e21..8f83c5a7e0e1e 100644 --- a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp +++ b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp @@ -2765,7 +2765,7 @@ std::unique_ptr makeArticulatedCompliantJointWorld( } std::unique_ptr makeArticulatedCompliantMotorWorld( - std::size_t familyCount) + std::size_t familyCount, bool breakable = false) { constexpr double kStartStiffness = 20.0; constexpr double kLinearStiffness = 2000.0; @@ -2841,6 +2841,9 @@ std::unique_ptr makeArticulatedCompliantMotorWorld( policy.linearStiffness = kLinearStiffness; policy.angularStiffness = kAngularStiffness; joint.setConstraintProjectionPolicy(policy); + if (breakable) { + joint.setBreakForce(1.0e12); + } joints.push_back(joint); } } @@ -4008,6 +4011,29 @@ static void BM_AvbdArticulatedCompliantMotorStep(benchmark::State& state) } BENCHMARK(BM_AvbdArticulatedCompliantMotorStep)->Arg(1)->Arg(4)->Arg(16); +//============================================================================== +static void BM_AvbdArticulatedCompliantBreakableMotorStep( + benchmark::State& state) +{ + const auto familyCount = static_cast(state.range(0)); + auto world + = makeArticulatedCompliantMotorWorld(familyCount, /*breakable=*/true); + world->enterSimulationMode(); + + for (auto _ : state) { + world->step(); + benchmark::ClobberMemory(); + } + state.counters["family_instances"] = static_cast(familyCount); + state.counters["revolute_motors"] = static_cast(familyCount); + state.counters["prismatic_motors"] = static_cast(familyCount); + state.counters["breakable_motors"] = static_cast(2u * familyCount); +} +BENCHMARK(BM_AvbdArticulatedCompliantBreakableMotorStep) + ->Arg(1) + ->Arg(4) + ->Arg(16); + //============================================================================== static void BM_AvbdArticulatedHighRatioChainStep(benchmark::State& state) { diff --git a/tests/unit/simulation/compute/test_variational_integration.cpp b/tests/unit/simulation/compute/test_variational_integration.cpp index e71325a144553..3f9f9f7737fd8 100644 --- a/tests/unit/simulation/compute/test_variational_integration.cpp +++ b/tests/unit/simulation/compute/test_variational_integration.cpp @@ -3973,6 +3973,246 @@ TEST( EXPECT_LT(weakSlider.maxAngularResidual, 1e-6); } +// PLAN-104 AVBD articulated finite-stiffness bridge: break-force accounting +// must use physical row loads, then combine the compliant constrained rows and +// the bounded free-coordinate motor row belonging to one public joint. The +// deliberately massive endpoints keep the imposed 0.1 m transverse residual +// effectively fixed: k=10 N/m contributes 1 N while the saturated prismatic +// motor contributes 2 N, so neither crosses 2.1 N alone but their L2 norm does. +TEST( + VariationalIntegration, + AvbdCompliantPublicArticulatedJointAggregatesFiniteAndMotorBreakLoads) +{ + const auto breaks = + [](bool finiteLoad, bool motorLoad, double breakForce, double timeStep) { + sx::World world; + world.setGravity(Eigen::Vector3d::Zero()); + world.setMultibodyOptions( + {.integrationFamily = sx::MultibodyIntegrationFamily::Variational}); + world.setTimeStep(timeStep); + + FloatingLinkPair pair = addFloatingLinkPair(world); + constexpr double kMass = 1.0e12; + pair.parent.setMass(kMass); + pair.parent.setInertia(kMass * Eigen::Matrix3d::Identity()); + pair.child.setMass(kMass); + pair.child.setInertia(kMass * Eigen::Matrix3d::Identity()); + + sx::Joint joint = world.addJoint( + pair.parent, + pair.child, + makeJointSpec( + "finite_break_load", + sx::JointType::Prismatic, + Eigen::Vector3d::UnitX())); + joint.setConstraintProjectionPolicy( + makeConstraintProjectionPolicy(10.0, 10.0, 10.0)); + if (motorLoad) { + joint.setActuatorType(sx::ActuatorType::Velocity); + joint.setCommandVelocity(Eigen::VectorXd::Constant(1, 1.0)); + joint.setEffortLimits( + Eigen::VectorXd::Constant(1, -2.0), + Eigen::VectorXd::Constant(1, 2.0)); + } + joint.setBreakForce(breakForce); + + world.enterSimulationMode(); + auto& registry = dart::simulation::detail::registryOf(world); + const entt::entity jointEntity + = sx::detail::toRegistryEntity(joint.getEntity()); + auto& config + = registry.get(jointEntity); + if (finiteLoad) { + config.localAnchorB += 0.1 * Eigen::Vector3d::UnitY(); + } + + world.step(); + return joint.isBroken(); + }; + + EXPECT_TRUE(breaks( + /*finiteLoad=*/true, + /*motorLoad=*/false, + /*breakForce=*/0.8, + /*timeStep=*/0.005)); + EXPECT_FALSE(breaks( + /*finiteLoad=*/true, + /*motorLoad=*/false, + /*breakForce=*/1.2, + /*timeStep=*/0.005)); + + // The motor projection bound is effort * dt^2. Break-force accounting must + // divide the projected load by dt^2 so the public threshold stays in force + // units and produces the same verdict at different timesteps. + EXPECT_TRUE(breaks( + /*finiteLoad=*/false, + /*motorLoad=*/true, + /*breakForce=*/1.8, + /*timeStep=*/0.005)); + EXPECT_TRUE(breaks( + /*finiteLoad=*/false, + /*motorLoad=*/true, + /*breakForce=*/1.8, + /*timeStep=*/0.01)); + EXPECT_FALSE(breaks( + /*finiteLoad=*/false, + /*motorLoad=*/true, + /*breakForce=*/2.2, + /*timeStep=*/0.005)); + + EXPECT_TRUE(breaks( + /*finiteLoad=*/true, + /*motorLoad=*/true, + /*breakForce=*/2.1, + /*timeStep=*/0.005)); +} + +// PLAN-104 AVBD articulated finite-stiffness bridge: a finite joint that +// fractures from its combined compliant/motor load must remain skipped until +// the public reset, re-engage under a high threshold, and fracture again when +// re-armed with the original weak threshold. +TEST( + VariationalIntegration, + AvbdCompliantPublicArticulatedJointBreakResetRearmsFiniteRows) +{ + sx::World world; + world.setGravity(Eigen::Vector3d::Zero()); + world.setMultibodyOptions( + {.integrationFamily = sx::MultibodyIntegrationFamily::Variational}); + world.setTimeStep(0.005); + + FloatingLinkPair pair = addFloatingLinkPair(world); + constexpr double kMass = 1.0e12; + pair.parent.setMass(kMass); + pair.parent.setInertia(kMass * Eigen::Matrix3d::Identity()); + pair.child.setMass(kMass); + pair.child.setInertia(kMass * Eigen::Matrix3d::Identity()); + + sx::Joint joint = world.addJoint( + pair.parent, + pair.child, + makeJointSpec( + "finite_break_reset", + sx::JointType::Prismatic, + Eigen::Vector3d::UnitX())); + joint.setConstraintProjectionPolicy( + makeConstraintProjectionPolicy(10.0, 10.0, 10.0)); + joint.setActuatorType(sx::ActuatorType::Velocity); + joint.setCommandVelocity(Eigen::VectorXd::Constant(1, 1.0)); + joint.setEffortLimits( + Eigen::VectorXd::Constant(1, -2.0), Eigen::VectorXd::Constant(1, 2.0)); + joint.setBreakForce(2.1); + + world.enterSimulationMode(); + auto& registry = dart::simulation::detail::registryOf(world); + const entt::entity jointEntity + = sx::detail::toRegistryEntity(joint.getEntity()); + auto& config + = registry.get(jointEntity); + config.localAnchorB += 0.1 * Eigen::Vector3d::UnitY(); + + world.step(); + ASSERT_TRUE(joint.isBroken()); + + world.step(); + EXPECT_TRUE(joint.isBroken()); + + joint.setBreakForce(100.0); + joint.resetBreakage(); + world.step(); + EXPECT_FALSE(joint.isBroken()); + + joint.setBreakForce(2.1); + world.step(); + EXPECT_TRUE(joint.isBroken()); +} + +// PLAN-104 AVBD articulated finite-stiffness bridge: finite projection policy, +// private row geometry, and broken-state bookkeeping must survive a +// simulation-mode binary round trip. The restored joint stays broken until the +// public reset, then its finite rows re-engage and can fracture a second time. +TEST( + VariationalIntegration, + AvbdCompliantPublicArticulatedFiniteBreakageSurvivesSaveLoadAndReset) +{ + sx::World world; + world.setGravity(Eigen::Vector3d::Zero()); + world.setMultibodyOptions( + {.integrationFamily = sx::MultibodyIntegrationFamily::Variational}); + world.setTimeStep(0.005); + + FloatingLinkPair pair = addFloatingLinkPair(world); + constexpr double kMass = 1.0e12; + pair.parent.setMass(kMass); + pair.parent.setInertia(kMass * Eigen::Matrix3d::Identity()); + pair.child.setMass(kMass); + pair.child.setInertia(kMass * Eigen::Matrix3d::Identity()); + + sx::Joint joint = world.addJoint( + pair.parent, + pair.child, + makeJointSpec( + "serialized_finite_break", + sx::JointType::Prismatic, + Eigen::Vector3d::UnitX())); + joint.setConstraintProjectionPolicy( + makeConstraintProjectionPolicy(10.0, 10.0, 10.0)); + joint.setBreakForce(0.8); + + world.enterSimulationMode(); + auto& registry = dart::simulation::detail::registryOf(world); + const entt::entity jointEntity + = sx::detail::toRegistryEntity(joint.getEntity()); + auto& config + = registry.get(jointEntity); + config.localAnchorB += 0.1 * Eigen::Vector3d::UnitY(); + const Eigen::Vector3d savedLocalAnchorB = config.localAnchorB; + + world.step(); + ASSERT_TRUE(joint.isBroken()); + + std::stringstream data; + world.saveBinary(data); + + sx::World restored; + restored.loadBinary(data); + restored.setMultibodyOptions( + {.integrationFamily = sx::MultibodyIntegrationFamily::Variational}); + + auto restoredJoint = restored.getJoint("serialized_finite_break"); + ASSERT_TRUE(restoredJoint.has_value()); + ASSERT_TRUE(restoredJoint->isBroken()); + EXPECT_EQ(restoredJoint->getType(), sx::JointType::Prismatic); + EXPECT_DOUBLE_EQ(restoredJoint->getBreakForce(), 0.8); + const sx::JointConstraintProjectionPolicy restoredPolicy + = restoredJoint->getConstraintProjectionPolicy(); + EXPECT_DOUBLE_EQ(restoredPolicy.startStiffness, 10.0); + EXPECT_DOUBLE_EQ(restoredPolicy.linearStiffness, 10.0); + EXPECT_DOUBLE_EQ(restoredPolicy.angularStiffness, 10.0); + + auto& restoredRegistry = dart::simulation::detail::registryOf(restored); + const entt::entity restoredJointEntity + = sx::detail::toRegistryEntity(restoredJoint->getEntity()); + ASSERT_TRUE(restoredRegistry.all_of( + restoredJointEntity)); + const auto& restoredConfig + = restoredRegistry.get( + restoredJointEntity); + EXPECT_LT((restoredConfig.localAnchorB - savedLocalAnchorB).norm(), 1e-12); + + restored.step(); + EXPECT_TRUE(restoredJoint->isBroken()); + + restoredJoint->setBreakForce(100.0); + restoredJoint->resetBreakage(); + restored.step(); + EXPECT_FALSE(restoredJoint->isBroken()); + + restoredJoint->setBreakForce(0.8); + restored.step(); + EXPECT_TRUE(restoredJoint->isBroken()); +} + // PLAN-104 AVBD articulated bridge: simulation-entry current-pose extraction // now covers non-topology private point-joint entities whose endpoint is a // multibody link. The generated hard fixed config captures the link's design @@ -8891,7 +9131,10 @@ TEST( joint.setEffortLimits( Eigen::VectorXd::Constant(1, -850.0), Eigen::VectorXd::Constant(1, 950.0)); - joint.setBreakForce(13.0); + // This test exercises persistence rather than fracture, so retain a finite + // non-default threshold that stays above every commanded physical row load. + constexpr double breakForce = 1.0e6; + joint.setBreakForce(breakForce); std::stringstream data; world.saveBinary(data); @@ -8922,7 +9165,7 @@ TEST( ASSERT_EQ(restoredJoint->getEffortUpperLimits().size(), 1); EXPECT_DOUBLE_EQ(restoredJoint->getEffortLowerLimits()[0], -850.0); EXPECT_DOUBLE_EQ(restoredJoint->getEffortUpperLimits()[0], 950.0); - EXPECT_DOUBLE_EQ(restoredJoint->getBreakForce(), 13.0); + EXPECT_DOUBLE_EQ(restoredJoint->getBreakForce(), breakForce); EXPECT_FALSE(restoredJoint->isBroken()); auto& registry = dart::simulation::detail::registryOf(restored); @@ -8965,6 +9208,7 @@ TEST( EXPECT_LT(maxAnchorResidual, 1e-6); EXPECT_LT(maxHingeAxisTilt, 1e-6); EXPECT_LT((rotation - expectedRotation).norm(), 1e-6); + EXPECT_FALSE(restoredJoint->isBroken()); } // PLAN-104 AVBD articulated bridge: world-anchored revolute facades expose the From 646b447d6cc76209e88f241740402268a8912364 Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Thu, 30 Jul 2026 17:26:58 -0700 Subject: [PATCH 07/13] Add public AVBD solver and Figure 13 evidence --- CHANGELOG.md | 9 + dart/gui/view_quality.cpp | 9 +- dart/gui/view_quality.hpp | 2 +- dart/simulation/comps/rigid_body.hpp | 10 +- .../detail/deformable_avbd_replay_state.hpp | 31 + .../compute/detail/world_step_stages.hpp | 27 +- .../compute/rigid_body_contact_stage.cpp | 248 ++- .../detail/rigid_avbd/rigid_block_kernel.hpp | 46 +- .../detail/rigid_avbd/rigid_world_contact.hpp | 19 +- .../simulation/detail/world_step_schedule.hpp | 6 +- dart/simulation/io/binary_io.hpp | 5 +- dart/simulation/world.cpp | 340 +++- dart/simulation/world.hpp | 37 +- dart/simulation/world_options.hpp | 33 +- docs/design/dart7_architecture_assessment.md | 25 +- docs/design/simulation_cpp_api.md | 19 +- docs/design/simulation_python_api.md | 10 + docs/design/simulation_solver_architecture.md | 15 +- docs/dev_tasks/avbd_solver/README.md | 130 +- docs/dev_tasks/avbd_solver/RESUME.md | 104 +- docs/onboarding/agent-sim-verification.md | 6 +- docs/plans/104-vertex-block-descent-solver.md | 99 +- ...articulated-compliant-fracture-packet.json | 3 +- ...d-articulated-compliant-joints-packet.json | 3 +- ...d-articulated-compliant-motors-packet.json | 3 +- .../avbd-breakable-joint-scale-packet.json | 3 +- .../avbd-breakable-motor-scale-packet.json | 3 +- .../avbd-demo-corpus.md | 406 +++-- ...vbd-friction-coefficient-sweep-packet.json | 3 +- .../avbd-paper-breakable-wall-packet.json | 777 +++++++++ .../avbd-paper-coverage-contract.json | 4 +- .../avbd-paper-gap-audit.md | 64 +- ...ale-high-ratio-iteration-sweep-packet.json | 3 +- .../paper-parity-matrix.md | 6 + .../coverage-matrix.md | 40 +- docs/readthedocs/architecture.md | 4 + python/dartpy/_view_quality.py | 4 +- python/dartpy/simulation/module_compute.cpp | 38 + python/dartpy/simulation/module_core.cpp | 3 +- python/dartpy/simulation/module_frames.cpp | 13 + python/dartpy/simulation/module_world.cpp | 17 + python/examples/demos/README.md | 1 + python/examples/demos/registry.py | 2 + .../demos/scenes/avbd_paper_breakable_wall.py | 747 +++++++++ python/stubs/dartpy/simulation.pyi | 56 +- python/tests/integration/test_demos_cycle.py | 202 +++ python/tests/unit/simulation/test_world.py | 49 +- python/tests/unit/test_capture_py_demo.py | 14 + ...d_articulated_compliant_fracture_packet.py | 6 +- ...vbd_articulated_compliant_joints_packet.py | 3 +- ...vbd_articulated_compliant_motors_packet.py | 3 +- ...write_avbd_breakable_joint_scale_packet.py | 3 +- ...write_avbd_breakable_motor_scale_packet.py | 3 +- ..._avbd_friction_coefficient_sweep_packet.py | 3 +- ..._write_avbd_paper_breakable_wall_packet.py | 594 +++++++ ...scale_high_ratio_iteration_sweep_packet.py | 3 +- scripts/avbd_packet_schema.py | 123 +- scripts/benchmark_display_names.py | 3 + scripts/capture_py_demo.py | 1 + scripts/check_avbd_packets.py | 10 +- .../run_performance_dashboard_benchmarks.py | 1 + ...te_avbd_friction_coefficient_sweep_plot.py | 4 +- .../write_avbd_paper_breakable_wall_packet.py | 1424 +++++++++++++++++ .../simulation/bm_avbd_rigid_fixed_joint.cpp | 365 +++++ tests/test_avbd_packet_schema.py | 54 + tests/test_check_avbd_packets.py | 24 +- tests/unit/gui/test_gui_debug_visuals.cpp | 40 + .../simulation/world/test_serialization.cpp | 130 +- tests/unit/simulation/world/test_world.cpp | 374 ++++- .../test_world_resolved_configuration.cpp | 257 ++- .../world/test_world_step_schedule.cpp | 15 + 71 files changed, 6585 insertions(+), 556 deletions(-) create mode 100644 docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json create mode 100644 python/examples/demos/scenes/avbd_paper_breakable_wall.py create mode 100644 python/tests/unit/test_write_avbd_paper_breakable_wall_packet.py create mode 100644 scripts/write_avbd_paper_breakable_wall_packet.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a082d0bc6fae..082780ca7e434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -202,6 +202,15 @@ compatibility remains on the active DART 6 LTS branch._ finite plus motor row loads now share physical break-force accounting with reset, re-arm, and binary persistence. ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) +- Added an explicit experimental AVBD rigid-body family to the DART 7 + `World` C++ and dartpy facades, including typed rigid-constraint options, + binary/replay persistence, fail-closed configuration, and a + publication-shaped breakable-wall demo with quantitative, allocation, + performance, and assessed visual evidence. Claim-tied view assessment now + scopes ambiguity to an explicitly named subject while still treating other + renderables as possible occluders, so dense debris scenes cannot reject a + clear subject view because of an unrelated overlapping pair. + ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) - Added compute-executor and backend-boundary work so CPU threading, optional CUDA experiments, and future accelerator sidecars do not leak into the default public API. diff --git a/dart/gui/view_quality.cpp b/dart/gui/view_quality.cpp index 0c10afb6f3a68..8526722a10eea 100644 --- a/dart/gui/view_quality.cpp +++ b/dart/gui/view_quality.cpp @@ -282,8 +282,11 @@ ViewQualityReport assessView( report.occlusionFraction = occlusionFraction(descriptors, focusSet, assessAll, samples, basis.eye); - // Ambiguity: distinct bodies whose screen boxes pile up while separated in - // depth suggest a degenerate view axis (e.g. a stack seen from above). + // Ambiguity: distinct subject bodies whose screen boxes pile up while + // separated in depth suggest a degenerate view axis (e.g. a stack seen from + // above). An explicit focus scopes this check just like framing does; + // unrelated debris can occlude the subject, but it must not make an + // otherwise clear claim-tied view ambiguous. struct AmbiguityBox { ScreenBox box; @@ -292,7 +295,7 @@ ViewQualityReport assessView( }; std::vector boxes; for (const RenderableDescriptor& descriptor : descriptors) { - if (!descriptor.material.visible) { + if (!isFocus(descriptor)) { continue; } const std::vector corners diff --git a/dart/gui/view_quality.hpp b/dart/gui/view_quality.hpp index e71604b456492..9eb74c9811f4b 100644 --- a/dart/gui/view_quality.hpp +++ b/dart/gui/view_quality.hpp @@ -77,7 +77,7 @@ struct ViewQualityReport /// via `pickNearestRenderable` rays from the eye to the focus corners and /// centroid (excluding focus hits, counting a hit only when it is meaningfully /// closer than the sample), and ambiguity as the worst pairwise IoU of the -/// viewport-clipped screen boxes of fully-in-front bodies, gated by a +/// viewport-clipped screen boxes of fully-in-front subject bodies, gated by a /// depth-gap test so overlapping-but-coplanar bodies do not register. DART_GUI_API ViewQualityReport assessView( const std::vector& descriptors, diff --git a/dart/simulation/comps/rigid_body.hpp b/dart/simulation/comps/rigid_body.hpp index f29513d0d808f..456509f58f217 100644 --- a/dart/simulation/comps/rigid_body.hpp +++ b/dart/simulation/comps/rigid_body.hpp @@ -88,13 +88,13 @@ struct KinematicBodyStepTrace Transform endTransform; }; -/// Internal opt-in configuration for the first AVBD rigid contact World slice. +/// Internal per-body configuration for AVBD rigid contact. /// /// This component is intentionally not surfaced through the public `World` -/// facade. When every rigid contact in the contact stage has at least one body -/// with an enabled config, supported free rigid-body contacts route through the -/// private 6-DOF AVBD point-pair row projection. Unsupported envelopes fall -/// back to the default sequential-impulse path. +/// facade. `RigidBodySolver::Avbd` selects AVBD globally and uses these values +/// only as internal per-body refinements. Compatibility tests may also attach +/// enabled configs while another public family is selected; only that private +/// compatibility path may fall back to sequential impulse. struct RigidAvbdContactConfig { bool enabled = true; diff --git a/dart/simulation/compute/detail/deformable_avbd_replay_state.hpp b/dart/simulation/compute/detail/deformable_avbd_replay_state.hpp index 7f9968d0ad021..49d142d9bbb6c 100644 --- a/dart/simulation/compute/detail/deformable_avbd_replay_state.hpp +++ b/dart/simulation/compute/detail/deformable_avbd_replay_state.hpp @@ -82,6 +82,37 @@ struct DeformableAvbdWarmStartReplayState RowVector tetRows; }; +/// Replay snapshot of the rigid AVBD warm-start continuation state. +/// +/// The live inventories belong to `RigidBodyContactStage`; replay preserves +/// only the row records whose lambda/stiffness values affect the next solve. +struct RigidAvbdWarmStartReplayState +{ + using RowAllocator = dart::common::StlAllocator; + using RowVector = std::vector; + + RigidAvbdWarmStartReplayState() = default; + + explicit RigidAvbdWarmStartReplayState( + dart::common::MemoryAllocator& allocator) + : normalRows(RowAllocator{allocator}), + frictionRows(RowAllocator{allocator}), + jointLinearRows(RowAllocator{allocator}), + jointAngularRows(RowAllocator{allocator}), + motorRows(RowAllocator{allocator}), + distanceSpringRows(RowAllocator{allocator}) + { + // Empty. + } + + RowVector normalRows; + RowVector frictionRows; + RowVector jointLinearRows; + RowVector jointAngularRows; + RowVector motorRows; + RowVector distanceSpringRows; +}; + using DeformableAvbdWarmStartReplayStates = std::vector; using AllocatedDeformableAvbdWarmStartReplayStates = std::vector< diff --git a/dart/simulation/compute/detail/world_step_stages.hpp b/dart/simulation/compute/detail/world_step_stages.hpp index 7f6cd2f8eb31c..d2d780c202450 100644 --- a/dart/simulation/compute/detail/world_step_stages.hpp +++ b/dart/simulation/compute/detail/world_step_stages.hpp @@ -41,6 +41,7 @@ #include namespace dart::common { +class MemoryAllocator; class MemoryManager; } // namespace dart::common @@ -48,6 +49,10 @@ namespace dart::simulation::compute { class WorldKinematicsGraph; +namespace avbd_replay { +struct RigidAvbdWarmStartReplayState; +} // namespace avbd_replay + /// Summary of the most recent deformable solver execution. struct DeformableSolverStats { @@ -378,17 +383,21 @@ class DART_SIMULATION_API RigidBodyPositionStage final : public WorldStepStage /// Resolves contacts between free rigid bodies. Static bodies (non-positive /// mass) act as immovable. /// -/// Two solver paths are available, selected per-World via -/// `WorldOptions::contactSolverMethod`: +/// The selected `WorldOptions::rigidBodySolver` family owns the top-level +/// contact formulation. `RigidBodySolver::Avbd` routes every supported active +/// free-rigid contact through AVBD. Under the default +/// `RigidBodySolver::SequentialImpulse` family, two contact-policy paths are +/// available via `WorldOptions::contactSolverMethod`: /// - `SequentialImpulse` (default): the long-standing sequential normal + /// friction impulse solve with positional correction. /// - `BoxedLcp`: an opt-in boxed-LCP normal/friction solve via the pivoting /// Dantzig solver. Articulated-link contacts are handled by the unified /// constraint/contact path, not by this free-rigid stage. /// -/// Internal PLAN-104 AVBD work can also opt specific rigid bodies into the -/// private `RigidAvbdContactConfig` row projection without exposing AVBD row -/// storage or solver registries through the facade. +/// Internal PLAN-104 compatibility tests may still opt specific rigid bodies +/// into the private `RigidAvbdContactConfig` row projection while the public +/// family remains sequential impulse. That compatibility path does not expose +/// AVBD row storage or solver registries through the facade. class DART_SIMULATION_API RigidBodyContactStage final : public WorldStepStage { public: @@ -402,9 +411,17 @@ class DART_SIMULATION_API RigidBodyContactStage final : public WorldStepStage void prepare(World& world) override; void execute(World& world, ComputeExecutor& executor) override; + void setIterations(std::size_t iterations) noexcept; [[nodiscard]] std::size_t getIterations() const noexcept; private: + friend class ::dart::simulation::World; + + [[nodiscard]] avbd_replay::RigidAvbdWarmStartReplayState + captureAvbdWarmStartReplayState(common::MemoryAllocator& allocator) const; + void restoreAvbdWarmStartReplayState( + const avbd_replay::RigidAvbdWarmStartReplayState& replayState); + struct AvbdScratch; struct ContactScratch; struct AvbdScratchDeleter diff --git a/dart/simulation/compute/rigid_body_contact_stage.cpp b/dart/simulation/compute/rigid_body_contact_stage.cpp index 480be4d394714..22622125ebae6 100644 --- a/dart/simulation/compute/rigid_body_contact_stage.cpp +++ b/dart/simulation/compute/rigid_body_contact_stage.cpp @@ -38,6 +38,7 @@ #include "dart/simulation/comps/joint.hpp" #include "dart/simulation/comps/rigid_body.hpp" #include "dart/simulation/compute/compute_executor.hpp" +#include "dart/simulation/compute/detail/deformable_avbd_replay_state.hpp" #include "dart/simulation/compute/detail/stage_scratch.hpp" #include "dart/simulation/compute/detail/world_step_stages.hpp" #include "dart/simulation/compute/rigid_body_constraint.hpp" @@ -91,7 +92,9 @@ bool mayHaveRigidAvbdContactConfigs(const detail::WorldRegistry& registry) //============================================================================== std::optional rigidAvbdContactStageConfig( - const detail::WorldRegistry& registry, std::span contacts) + const detail::WorldRegistry& registry, + std::span contacts, + bool selectAllContacts) { std::optional merged; for (const Contact& contact : contacts) { @@ -99,7 +102,7 @@ std::optional rigidAvbdContactStageConfig( registry, detail::toRegistryEntity(contact.bodyA.getEntity())); const auto* configB = enabledRigidAvbdContactConfig( registry, detail::toRegistryEntity(contact.bodyB.getEntity())); - if (configA == nullptr && configB == nullptr) { + if (!selectAllContacts && configA == nullptr && configB == nullptr) { return std::nullopt; } @@ -126,8 +129,71 @@ std::optional rigidAvbdContactStageConfig( } } + if (selectAllContacts && !merged.has_value()) { + merged.emplace(); + } return merged; } + +//============================================================================== +dvbd::AvbdRigidWorldContactSolveOptions rigidAvbdWorldSolveOptions( + std::size_t iterations, + const comps::RigidAvbdContactConfig* contactConfig = nullptr, + double contactMaxStiffness = std::numeric_limits::infinity()) +{ + dvbd::AvbdRigidWorldContactSolveOptions options; + options.descent.iterations = iterations; + options.descent.regularization = 1e-12; + + if (contactConfig != nullptr) { + options.hasContactFamilyOverride = true; + options.contactWarmStart.alpha = contactConfig->alpha; + options.contactWarmStart.gamma = contactConfig->gamma; + options.contactWarmStart.maxStiffness = contactMaxStiffness; + options.contactRow.alpha = contactConfig->alpha; + options.contactRow.beta = contactConfig->beta; + options.contactRow.maxStiffness = contactMaxStiffness; + options.friction.alpha = contactConfig->alpha; + options.friction.beta = contactConfig->beta; + options.friction.maxStiffness = contactMaxStiffness; + } + return options; +} + +//============================================================================== +std::optional countProjectableRigidAvbdContacts( + const detail::WorldRegistry& registry, std::span contacts) +{ + std::size_t count = 0u; + for (const Contact& contact : contacts) { + if (contact.depth <= 0.0) { + continue; + } + if (!std::isfinite(contact.depth) || !contact.point.allFinite() + || !contact.normal.allFinite() || contact.normal.squaredNorm() <= 0.0) { + return std::nullopt; + } + + const entt::entity entityA + = detail::toRegistryEntity(contact.bodyA.getEntity()); + const entt::entity entityB + = detail::toRegistryEntity(contact.bodyB.getEntity()); + if (entityA == entt::null || entityB == entt::null || entityA == entityB) { + return std::nullopt; + } + + const auto bodyA = dvbd::avbdRigidWorldProjectableBody(registry, entityA); + const auto bodyB = dvbd::avbdRigidWorldProjectableBody(registry, entityB); + if (!bodyA || !bodyB) { + return std::nullopt; + } + if (!bodyA.isStatic || !bodyB.isStatic) { + ++count; + } + } + return count; +} + struct RigidContactCandidate { entt::entity entity = entt::null; @@ -213,25 +279,25 @@ void captureRigidContactForces( } //============================================================================== -// The boxed-LCP solver returns its impulses in a stacked snapshot (normal rows +// The boxed-LCP solver returns its impulses in a stacked buffer (normal rows // [0, n) then two friction rows per contact) rather than on the constraints, so // copy them back onto the constraints to share the capture path above. void writeBoxedLcpImpulsesIntoConstraints( std::span constraints, - const detail::BoxedLcpContactSnapshot& snapshot) + std::span impulses) { - const Eigen::Index n = static_cast(constraints.size()); - if (n == 0 || snapshot.f.size() < n) { + const std::size_t n = constraints.size(); + if (n == 0u || impulses.size() < n) { return; } - const bool hasFrictionRows = snapshot.f.size() == 3 * n; - for (Eigen::Index i = 0; i < n; ++i) { - RigidBodyContactConstraint& constraint - = constraints[static_cast(i)]; - constraint.normalImpulse = snapshot.f[i]; + const bool hasFrictionRows = n <= std::numeric_limits::max() / 3u + && impulses.size() == 3u * n; + for (std::size_t i = 0; i < n; ++i) { + RigidBodyContactConstraint& constraint = constraints[i]; + constraint.normalImpulse = impulses[i]; if (hasFrictionRows) { - constraint.tangentImpulse1 = snapshot.f[n + 2 * i]; - constraint.tangentImpulse2 = snapshot.f[n + 2 * i + 1]; + constraint.tangentImpulse1 = impulses[n + 2u * i]; + constraint.tangentImpulse2 = impulses[n + 2u * i + 1u]; } else { constraint.tangentImpulse1 = 0.0; constraint.tangentImpulse2 = 0.0; @@ -566,6 +632,8 @@ void RigidBodyContactStage::prepare(World& world) constraints.clear(); const auto& registry = dart::simulation::detail::registryOf(world); + const bool useAvbdFamily + = world.getRigidBodySolver() == RigidBodySolver::Avbd; const bool skipContactQuery = shouldSkipRigidBodyContactQuery(world); std::size_t contactCapacity = 0u; if (!skipContactQuery) { @@ -583,6 +651,7 @@ void RigidBodyContactStage::prepare(World& world) : std::numeric_limits::max(); contactCapacity = contactConstraintCapacity; constraints.reserve(contactCapacity); + detail::storageOf(world).lastContactForces.reserve(contactCapacity); // Warm the collision-query cache and its contact buffer at bake time so // baked steps reuse the reserved capacity instead of growing the world // allocator. BoxedLcp additionally warms the frame arena for its per-step @@ -615,7 +684,8 @@ void RigidBodyContactStage::prepare(World& world) const std::size_t distanceSpringCapacity = distanceSpringStorage != nullptr ? distanceSpringStorage->size() : 0u; const std::size_t avbdContactCapacity - = contactCapacity != 0u && mayHaveRigidAvbdContactConfigs(registry) + = contactCapacity != 0u + && (useAvbdFamily || mayHaveRigidAvbdContactConfigs(registry)) ? contactCapacity : 0u; if (avbdContactCapacity == 0u && jointCapacity == 0u @@ -649,6 +719,8 @@ void RigidBodyContactStage::prepare(World& world) void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) { auto& registry = dart::simulation::detail::registryOf(world); + const bool useAvbdFamily + = world.getRigidBodySolver() == RigidBodySolver::Avbd; const auto projectAvbdRigidPointJoints = [&]() { const bool hasPointJointConfigs @@ -705,9 +777,8 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) dvbd::predictAvbdRigidWorldContactInertialTargets( registry, scratch.snapshot, timeStep); - dvbd::AvbdRigidWorldContactSolveOptions solveOptions; - solveOptions.descent.iterations = m_iterations; - solveOptions.descent.regularization = 1e-12; + const dvbd::AvbdRigidWorldContactSolveOptions solveOptions + = rigidAvbdWorldSolveOptions(m_iterations); const dvbd::AvbdRigidWorldContactSolveResult solveResult = dvbd::solveAvbdRigidWorldContactSnapshot( scratch.snapshot, @@ -748,7 +819,7 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) } const bool mayUseAvbdContactDetails - = mayHaveRigidAvbdContactConfigs(registry); + = useAvbdFamily || mayHaveRigidAvbdContactConfigs(registry); const auto queriedContacts = world.queryContacts( CollisionQueryOptions{}, /*includeShapeContactDetails=*/mayUseAvbdContactDetails); @@ -770,16 +841,16 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) return; } - // Internal AVBD rigid contact path (PLAN-104 AVBD): when every active contact - // has at least one body carrying the private opt-in config, assemble the - // contact manifold into 6-DOF point-pair rows, solve against the - // velocity-predicted inertial target, then project the solved displacement - // back into velocities. The standard rigid position stage still advances - // poses, so default pipeline ordering is unchanged. Unsupported envelopes - // fall through to the sequential-impulse path below. - const auto avbdConfig = mayUseAvbdContactDetails - ? rigidAvbdContactStageConfig(registry, contacts) - : std::optional{}; + // AVBD rigid contact path (PLAN-104): the public AVBD family selects every + // supported active free-rigid contact. The compatibility path under another + // family still requires every active contact to carry a private opt-in. + // Both assemble 6-DOF point-pair rows, solve against velocity-predicted + // inertial targets, and project the displacement back into velocities. The + // standard rigid position stage then advances poses. + const auto avbdConfig + = mayUseAvbdContactDetails + ? rigidAvbdContactStageConfig(registry, contacts, useAvbdFamily) + : std::optional{}; if (avbdConfig) { if (m_avbdScratch == nullptr) { m_avbdScratch = AvbdScratchPtr( @@ -798,6 +869,24 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) scratch.snapshot, scratch.buildScratch, contactOptions); + const auto expectedPublicContactCount + = useAvbdFamily ? countProjectableRigidAvbdContacts(registry, contacts) + : std::optional{}; + DART_SIMULATION_THROW_T_IF( + useAvbdFamily && !expectedPublicContactCount.has_value(), + NotImplementedException, + "The AVBD rigid-body solver encountered an active contact envelope it " + "cannot project; sequential-impulse fallback is disabled"); + const bool allContactsCovered + = useAvbdFamily ? scratch.snapshot.contacts.size() + == expectedPublicContactCount.value() + : scratch.snapshot.contacts.size() == contacts.size(); + DART_SIMULATION_THROW_T_IF( + useAvbdFamily && !allContactsCovered, + NotImplementedException, + "The AVBD rigid-body solver could not assemble every active contact; " + "sequential-impulse fallback is disabled"); + std::size_t appendedJoints = 0u; std::size_t appendedDistanceSprings = 0u; if (dvbd::mayHaveAvbdRigidWorldPointJointConfigs(registry)) { @@ -827,27 +916,16 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) scratch.distanceSprings.clear(); } - if (scratch.snapshot.contacts.size() == contacts.size() + if (allContactsCovered && (!scratch.snapshot.contacts.empty() || appendedJoints != 0u || appendedDistanceSprings != 0u)) { const double timeStep = world.getTimeStep(); dvbd::predictAvbdRigidWorldContactInertialTargets( registry, scratch.snapshot, timeStep); - dvbd::AvbdRigidWorldContactSolveOptions solveOptions; - solveOptions.warmStart.alpha = avbdConfig->alpha; - solveOptions.warmStart.gamma = avbdConfig->gamma; - solveOptions.warmStart.maxStiffness = contactOptions.maxStiffness; - solveOptions.row.alpha = avbdConfig->alpha; - solveOptions.row.beta = avbdConfig->beta; - solveOptions.row.maxStiffness = contactOptions.maxStiffness; - solveOptions.friction.alpha = avbdConfig->alpha; - solveOptions.friction.beta = avbdConfig->beta; - solveOptions.friction.maxStiffness = contactOptions.maxStiffness; - solveOptions.distanceSpring.beta = avbdConfig->beta; - solveOptions.distanceSpring.maxStiffness = contactOptions.maxStiffness; - solveOptions.descent.iterations = m_iterations; - solveOptions.descent.regularization = 1e-12; + const dvbd::AvbdRigidWorldContactSolveOptions solveOptions + = rigidAvbdWorldSolveOptions( + m_iterations, &*avbdConfig, contactOptions.maxStiffness); const dvbd::AvbdRigidWorldContactSolveResult solveResult = dvbd::solveAvbdRigidWorldContactSnapshot( @@ -876,11 +954,28 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) return; } } + + DART_SIMULATION_THROW_T_IF( + useAvbdFamily, + InvalidOperationException, + "The AVBD rigid-body solver assembled active constraints but " + "produced no AVBD rows; sequential-impulse fallback is disabled"); + } else if ( + useAvbdFamily && allContactsCovered + && expectedPublicContactCount.value() == 0u) { + m_avbdScratch->clear(); + return; } m_avbdScratch->clear(); } + DART_SIMULATION_THROW_T_IF( + useAvbdFamily, + InvalidOperationException, + "The AVBD rigid-body solver did not resolve the active rigid contacts; " + "sequential-impulse fallback is disabled"); + if (projectAvbdRigidPointJoints()) { // Keep the AVBD joint projection active while ordinary contacts continue // through the selected non-AVBD contact solver below. @@ -895,14 +990,14 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) if (world.getContactSolverMethod() == ContactSolverMethod::BoxedLcp) { detail::BoxedLcpContactScratch frameScratch( world.getMemoryManager().getFrameAllocator()); - // solveBoxedLcpContacts applies the same velocity impulses as - // applyBoxedLcpContacts but also leaves the solved impulses in the - // snapshot, which the force capture needs. - const detail::BoxedLcpContactSnapshot& snapshot - = detail::solveBoxedLcpContacts( - registry, contacts, world.getTimeStep(), frameScratch); + // Keep the step on the allocation-free apply path. The solved impulse + // buffer remains available in frame scratch for force capture; requesting + // the full differentiable snapshot here would allocate its Eigen matrices + // on every step. + detail::applyBoxedLcpContacts( + registry, contacts, world.getTimeStep(), frameScratch); writeBoxedLcpImpulsesIntoConstraints( - frameScratch.problem.constraints, snapshot); + frameScratch.problem.constraints, frameScratch.systemF); captureRigidContactForces( registry, world, frameScratch.problem.constraints, world.getTimeStep()); resolveRigidBodyContactPositions(registry, contacts, world.getTimeStep()); @@ -1021,4 +1116,57 @@ std::size_t RigidBodyContactStage::getIterations() const noexcept return m_iterations; } +//============================================================================== +avbd_replay::RigidAvbdWarmStartReplayState +RigidBodyContactStage::captureAvbdWarmStartReplayState( + common::MemoryAllocator& allocator) const +{ + avbd_replay::RigidAvbdWarmStartReplayState state(allocator); + if (m_avbdScratch == nullptr) { + return state; + } + + const auto copyRecords = [](auto& destination, const auto& inventory) { + destination.assign(inventory.records().begin(), inventory.records().end()); + }; + copyRecords(state.normalRows, m_avbdScratch->normalInventory); + copyRecords(state.frictionRows, m_avbdScratch->frictionInventory); + copyRecords(state.jointLinearRows, m_avbdScratch->jointLinearInventory); + copyRecords(state.jointAngularRows, m_avbdScratch->jointAngularInventory); + copyRecords(state.motorRows, m_avbdScratch->motorInventory); + copyRecords(state.distanceSpringRows, m_avbdScratch->distanceSpringInventory); + return state; +} + +//============================================================================== +void RigidBodyContactStage::restoreAvbdWarmStartReplayState( + const avbd_replay::RigidAvbdWarmStartReplayState& replayState) +{ + if (m_avbdScratch == nullptr) { + m_avbdScratch = AvbdScratchPtr( + createAvbdScratch(m_memoryManager), + AvbdScratchDeleter{m_memoryManager}); + } + + m_avbdScratch->clear(); + const auto restoreRecords = [](auto& inventory, const auto& records) { + inventory.records().assign(records.begin(), records.end()); + }; + restoreRecords(m_avbdScratch->normalInventory, replayState.normalRows); + restoreRecords(m_avbdScratch->frictionInventory, replayState.frictionRows); + restoreRecords( + m_avbdScratch->jointLinearInventory, replayState.jointLinearRows); + restoreRecords( + m_avbdScratch->jointAngularInventory, replayState.jointAngularRows); + restoreRecords(m_avbdScratch->motorInventory, replayState.motorRows); + restoreRecords( + m_avbdScratch->distanceSpringInventory, replayState.distanceSpringRows); +} + +//============================================================================== +void RigidBodyContactStage::setIterations(std::size_t iterations) noexcept +{ + m_iterations = std::max(1, iterations); +} + } // namespace dart::simulation::compute diff --git a/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp b/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp index 8e7b1c4059759..b6e43eaffe551 100644 --- a/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp +++ b/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp @@ -206,6 +206,13 @@ struct AvbdRigidAngularPairRow std::numeric_limits::infinity()}; }; +struct AvbdRigidPointAttachmentOptions +{ + double alpha = 0.0; + double beta = 1.0; + double maxStiffness = std::numeric_limits::infinity(); +}; + struct AvbdRigidBodyPointAttachmentRow { std::uint32_t body = 0; @@ -217,6 +224,8 @@ struct AvbdRigidBodyPointPairRow std::uint32_t bodyA = 0; std::uint32_t bodyB = 0; AvbdRigidPointPairRow row; + bool hasSolveOptionsOverride = false; + AvbdRigidPointAttachmentOptions solveOptionsOverride; }; struct AvbdRigidBodyPointPairDistanceSpringRow @@ -313,13 +322,6 @@ struct AvbdRigidContactManifoldPoint std::uint32_t row = 0; }; -struct AvbdRigidPointAttachmentOptions -{ - double alpha = 0.0; - double beta = 1.0; - double maxStiffness = std::numeric_limits::infinity(); -}; - struct AvbdRigidPointPairFrictionOptions { double alpha = 0.0; @@ -347,6 +349,14 @@ struct AvbdRigidBlockDescentStats std::size_t bodyUpdates = 0; }; +//============================================================================== +inline const AvbdRigidPointAttachmentOptions& avbdRigidPointPairSolveOptions( + const AvbdRigidBodyPointPairRow& row, + const AvbdRigidPointAttachmentOptions& fallback) noexcept +{ + return row.hasSolveOptionsOverride ? row.solveOptionsOverride : fallback; +} + struct AvbdRigidBodyRowIndexScratch { using SizeAllocator = ::dart::common::StlAllocator; @@ -3914,6 +3924,8 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( const AvbdRigidBodyPointPairRow& indexedRow, PointPairWorldPointCache& cache) { const AvbdRigidPointPairRow& row = indexedRow.row; + const AvbdRigidPointAttachmentOptions& effectiveRowOptions + = avbdRigidPointPairSolveOptions(indexedRow, rowOptions); const AvbdRigidBodyState& stateA = states[indexedRow.bodyA]; const AvbdRigidBodyState& stateB = states[indexedRow.bodyB]; const PointPairWorldPointCache& points @@ -3928,7 +3940,7 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( : regularizeAvbdConstraintValue( rawConstraintValue, row.previousConstraintValue, - rowOptions.alpha); + effectiveRowOptions.alpha); const double forceMagnitude = avbdRigidScalarRowForce( row.state, constraintValue, row.bounds, row.materialStiffness); @@ -4264,12 +4276,15 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( if (!validBody(indexedRow.bodyA) || !validBody(indexedRow.bodyB)) { continue; } + const AvbdRigidPointAttachmentOptions& effectiveRowOptions + = avbdRigidPointPairSolveOptions(indexedRow, rowOptions); if (avbdRigidRowUsesFiniteMaterial( indexedRow.row.materialStiffness)) { indexedRow.row.state.lambda = 0.0; const double maxStiffness = std::min( - indexedRow.row.materialStiffness, rowOptions.maxStiffness); - if (rowOptions.beta >= 0.0 + indexedRow.row.materialStiffness, + effectiveRowOptions.maxStiffness); + if (effectiveRowOptions.beta >= 0.0 && indexedRow.row.state.stiffness >= maxStiffness) { indexedRow.row.state.stiffness = maxStiffness; continue; @@ -4285,23 +4300,24 @@ inline AvbdRigidBlockDescentStats blockDescentRigidBodiesAvbdRows( if (avbdRigidRowUsesFiniteMaterial( indexedRow.row.materialStiffness)) { const double maxStiffness = std::min( - indexedRow.row.materialStiffness, rowOptions.maxStiffness); + indexedRow.row.materialStiffness, + effectiveRowOptions.maxStiffness); indexedRow.row.state.stiffness = updateAvbdFiniteStiffness( indexedRow.row.state.stiffness, rawConstraintValue, - rowOptions.beta, + effectiveRowOptions.beta, maxStiffness); } else { const double constraintValue = regularizeAvbdConstraintValue( rawConstraintValue, indexedRow.row.previousConstraintValue, - rowOptions.alpha); + effectiveRowOptions.alpha); indexedRow.row.state = updateAvbdHardConstraintRow( indexedRow.row.state, constraintValue, - rowOptions.beta, + effectiveRowOptions.beta, indexedRow.row.bounds, - rowOptions.maxStiffness); + effectiveRowOptions.maxStiffness); } } }); diff --git a/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp b/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp index 65988c9541d08..542687bbb3aab 100644 --- a/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp +++ b/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp @@ -300,10 +300,16 @@ inline void clearAvbdRigidWorldContactSnapshot( struct AvbdRigidWorldContactSolveOptions { + // Constraint-family parameters stay independent of whether contact rows are + // present. Contact stages may opt into the contact-family override below + // without retuning disconnected joint, motor, or spring rows. AvbdRowWarmStartOptions warmStart; AvbdRigidPointAttachmentOptions row; - AvbdRigidPointPairFrictionOptions friction; AvbdRigidPointPairDistanceSpringOptions distanceSpring; + bool hasContactFamilyOverride = false; + AvbdRowWarmStartOptions contactWarmStart; + AvbdRigidPointAttachmentOptions contactRow; + AvbdRigidPointPairFrictionOptions friction; AvbdRigidBlockDescentOptions descent; }; @@ -2282,6 +2288,9 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( } scratch.clear(); + const AvbdRowWarmStartOptions& contactWarmStart + = options.hasContactFamilyOverride ? options.contactWarmStart + : options.warmStart; auto& normalRows = scratch.normalRows; auto& frictionRows = scratch.frictionRows; buildAvbdRigidContactManifoldRows( @@ -2294,7 +2303,13 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( normalRows, frictionRows, scratch.contactRows, - options.warmStart); + contactWarmStart); + if (options.hasContactFamilyOverride) { + for (AvbdRigidBodyPointPairRow& row : normalRows) { + row.hasSolveOptionsOverride = true; + row.solveOptionsOverride = options.contactRow; + } + } result.normalRows = normalRows.size(); result.frictionRows = 2u * frictionRows.size(); diff --git a/dart/simulation/detail/world_step_schedule.hpp b/dart/simulation/detail/world_step_schedule.hpp index 4ab4998274f01..2c63c55b7c196 100644 --- a/dart/simulation/detail/world_step_schedule.hpp +++ b/dart/simulation/detail/world_step_schedule.hpp @@ -44,6 +44,7 @@ namespace dart::simulation::detail { enum class BuiltInRigidBodySolverFamily { SequentialImpulse, + Avbd, Ipc, }; @@ -148,7 +149,8 @@ struct BuiltInWorldStepSchedule [[nodiscard]] constexpr bool builtInRigidSolverUsesSplitPipeline( BuiltInRigidBodySolverFamily family) noexcept { - return family == BuiltInRigidBodySolverFamily::SequentialImpulse; + return family == BuiltInRigidBodySolverFamily::SequentialImpulse + || family == BuiltInRigidBodySolverFamily::Avbd; } /// The single stage that owns rigid-body contact and advancement for families @@ -157,7 +159,7 @@ struct BuiltInWorldStepSchedule [[maybe_unused]] BuiltInRigidBodySolverFamily family) noexcept { // Ipc is currently the only rigid family with a combined contact-and-advance - // stage; the split pipeline (sequential impulse) never reaches here. + // stage; the split families (sequential impulse and AVBD) never reach here. return BuiltInWorldStepStageSlot::RigidIpcContact; } diff --git a/dart/simulation/io/binary_io.hpp b/dart/simulation/io/binary_io.hpp index 4ead0b844b32d..3a3d937913be4 100644 --- a/dart/simulation/io/binary_io.hpp +++ b/dart/simulation/io/binary_io.hpp @@ -135,7 +135,10 @@ using EntityMap = std::unordered_map; // after commandVelocity. Clean break: DART 7 has no compatibility debt, so // packets written with the three-field actuation record (versions <= 27) // no longer round-trip comps.JointActuation. -constexpr std::uint32_t kBinaryFormatVersion = 28; +// 29: World stores the rigid contact-stage iteration budget after +// differentiable physical-parameter registrations, and the rigid-body +// solver enum gains the AVBD value. +constexpr std::uint32_t kBinaryFormatVersion = 29; //============================================================================== // Low-level Binary I/O for POD types diff --git a/dart/simulation/world.cpp b/dart/simulation/world.cpp index 0e0c068f9d881..8c94fb4fb0650 100644 --- a/dart/simulation/world.cpp +++ b/dart/simulation/world.cpp @@ -473,6 +473,7 @@ struct World::ReplayState SnapshotAllocator< compute::avbd_replay::DeformableAvbdWarmStartReplayState>{ allocator}), + rigidAvbdWarmStartState(allocator), multibodyVariationalStates( SnapshotAllocator< std::pair>{ @@ -496,6 +497,7 @@ struct World::ReplayState bool simulationMode = false; Eigen::Vector3d gravity{0.0, 0.0, -9.81}; RigidBodySolver rigidBodySolver{RigidBodySolver::SequentialImpulse}; + std::size_t rigidConstraintIterations = 8; double timeStep = 0.001; bool differentiable = false; ContactSolverMethod contactSolverMethod{ @@ -518,6 +520,7 @@ struct World::ReplayState SnapshotVector deformableNodeStates; SnapshotVector deformableAvbdWarmStartStates; + compute::avbd_replay::RigidAvbdWarmStartReplayState rigidAvbdWarmStartState; ComponentSnapshot multibodyVariationalStates; ComponentSnapshot @@ -1539,6 +1542,7 @@ bool isValidRigidBodySolver(RigidBodySolver solver) { switch (solver) { case RigidBodySolver::SequentialImpulse: + case RigidBodySolver::Avbd: case RigidBodySolver::Ipc: return true; } @@ -1558,6 +1562,18 @@ bool isValidContactSolverMethod(ContactSolverMethod method) return false; } +//============================================================================== +void validateRigidSolverContactMethodCompatibility( + RigidBodySolver solver, ContactSolverMethod contactMethod) +{ + DART_SIMULATION_THROW_T_IF( + solver == RigidBodySolver::Avbd + && contactMethod != ContactSolverMethod::SequentialImpulse, + InvalidArgumentException, + "The AVBD rigid-body solver owns rigid contact resolution and cannot be " + "combined with a non-default contact solver method"); +} + //============================================================================== bool isValidContactGradientMode(ContactGradientMode mode) { @@ -1639,6 +1655,8 @@ std::uint8_t encodeRigidBodySolver(RigidBodySolver solver) switch (solver) { case RigidBodySolver::SequentialImpulse: return 0u; + case RigidBodySolver::Avbd: + return 2u; case RigidBodySolver::Ipc: return 1u; } @@ -1656,6 +1674,8 @@ RigidBodySolver decodeRigidBodySolver(std::uint8_t value) return RigidBodySolver::SequentialImpulse; case 1u: return RigidBodySolver::Ipc; + case 2u: + return RigidBodySolver::Avbd; } DART_SIMULATION_THROW_T( @@ -1808,6 +1828,8 @@ detail::BuiltInRigidBodySolverFamily toBuiltInRigidBodySolverFamily( switch (solver) { case RigidBodySolver::SequentialImpulse: return detail::BuiltInRigidBodySolverFamily::SequentialImpulse; + case RigidBodySolver::Avbd: + return detail::BuiltInRigidBodySolverFamily::Avbd; case RigidBodySolver::Ipc: return detail::BuiltInRigidBodySolverFamily::Ipc; } @@ -2150,6 +2172,12 @@ bool hasRigidBodyJointsUnsupportedByIpc(const World& world) void validateRigidBodyJointPipelineSupport( const World& world, RigidBodySolver solver) { + DART_SIMULATION_THROW_T_IF( + solver == RigidBodySolver::Avbd && hasMultibodyStructures(world), + InvalidOperationException, + "The AVBD rigid-body solver currently supports free rigid-body worlds " + "only; multibody structures require a different rigid-body solver"); + if (!hasRigidBodyAvbdPairConstraints(world)) { return; } @@ -2260,9 +2288,11 @@ void validateLoopClosureDynamicsPolicySupport( //============================================================================== struct WorldStepPipelineStages { - explicit WorldStepPipelineStages(common::MemoryManager& memoryManager) + explicit WorldStepPipelineStages( + common::MemoryManager& memoryManager, + std::size_t rigidConstraintIterations) : rigidBodyVelocity(&memoryManager), - rigidBodyContact(8, &memoryManager), + rigidBodyContact(rigidConstraintIterations, &memoryManager), rigidIpcContact(compute::RigidIpcContactStageOptions{}, &memoryManager), multibodyVelocity(&memoryManager), unifiedConstraint(8, &memoryManager), @@ -2768,6 +2798,61 @@ std::size_t validateFrameScratchInitialCapacity(std::size_t capacity) return capacity; } +//============================================================================== +std::size_t validateRigidConstraintIterations(std::size_t iterations) +{ + DART_SIMULATION_THROW_T_IF( + iterations == 0, + InvalidArgumentException, + "World rigid constraint iteration budget must be positive"); + DART_SIMULATION_THROW_T_IF( + iterations > static_cast(std::numeric_limits::max()), + InvalidArgumentException, + "World rigid constraint iteration budget is too large"); + return iterations; +} + +//============================================================================== +RigidConstraintOptions validateRigidConstraintOptions( + const RigidConstraintOptions& options, RigidBodySolver solver) +{ + RigidConstraintOptions validated = options; + validated.iterations = validateRigidConstraintIterations(options.iterations); + DART_SIMULATION_THROW_T_IF( + solver == RigidBodySolver::Ipc + && validated.iterations != RigidConstraintOptions{}.iterations, + InvalidArgumentException, + "RigidConstraintOptions are not applicable to the IPC rigid-body " + "family; use the default options"); + return validated; +} + +//============================================================================== +bool usesUnifiedConstraintStage( + const World& world, RigidBodySolver solver, bool variationalSelected) +{ + return !variationalSelected + && detail::builtInRigidSolverUsesSplitPipeline( + toBuiltInRigidBodySolverFamily(solver)) + && hasMultibodyStructures(world); +} + +//============================================================================== +void validateRigidConstraintOptionsPipelineSupport( + const World& world, + const RigidConstraintOptions& options, + RigidBodySolver solver, + bool variationalSelected) +{ + DART_SIMULATION_THROW_T_IF( + usesUnifiedConstraintStage(world, solver, variationalSelected) + && options.iterations != RigidConstraintOptions{}.iterations, + InvalidOperationException, + "Non-default RigidConstraintOptions are not applicable when " + "semi-implicit multibody structures select the unified constraint " + "stage; use the default options"); +} + //============================================================================== common::MemoryAllocator& resolveBaseAllocator(const WorldOptions& options) { @@ -3484,8 +3569,10 @@ void rebindLoadedWorldComponentAllocators( //============================================================================== struct World::StepPipelineCache { - explicit StepPipelineCache(common::MemoryManager& memoryManager) - : stages(memoryManager) + explicit StepPipelineCache( + common::MemoryManager& memoryManager, + std::size_t rigidConstraintIterations) + : stages(memoryManager, rigidConstraintIterations) { } @@ -3542,10 +3629,10 @@ void World::CollisionQueryCacheDeleter::operator()(void* cache) const noexcept //============================================================================== World::StepPipelineCachePtr World::makeStepPipelineCache( - common::MemoryManager& memoryManager) + common::MemoryManager& memoryManager, std::size_t rigidConstraintIterations) { - auto* cache - = memoryManager.constructUsingFree(memoryManager); + auto* cache = memoryManager.constructUsingFree( + memoryManager, rigidConstraintIterations); if (cache == nullptr) { throw std::bad_alloc(); } @@ -3586,7 +3673,8 @@ World::World() : m_storage(makeWorldStorage(m_memoryManager)), m_collisionQueryCache( nullptr, CollisionQueryCacheDeleter{&m_memoryManager}), - m_stepPipelineCache(makeStepPipelineCache(m_memoryManager)), + m_stepPipelineCache(makeStepPipelineCache( + m_memoryManager, m_rigidConstraintOptions.iterations)), m_replay(nullptr, ReplayStateDeleter{&m_memoryManager}) { // Empty. @@ -3599,6 +3687,7 @@ World::World(const WorldOptions& options) m_storage(makeWorldStorage(m_memoryManager)), m_gravity(options.gravity), m_rigidBodySolver(options.rigidBodySolver), + m_rigidConstraintOptions(options.rigidConstraintOptions), m_timeStep(options.timeStep), m_differentiable(options.differentiable), m_contactSolverMethod(options.contactSolverMethod), @@ -3608,7 +3697,8 @@ World::World(const WorldOptions& options) m_deactivationOptions(options.deactivationOptions), m_collisionQueryCache( nullptr, CollisionQueryCacheDeleter{&m_memoryManager}), - m_stepPipelineCache(makeStepPipelineCache(m_memoryManager)), + m_stepPipelineCache(makeStepPipelineCache( + m_memoryManager, m_rigidConstraintOptions.iterations)), m_replay(nullptr, ReplayStateDeleter{&m_memoryManager}) { DART_SIMULATION_THROW_T_IF( @@ -3623,10 +3713,14 @@ World::World(const WorldOptions& options) !isValidRigidBodySolver(options.rigidBodySolver), InvalidArgumentException, "WorldOptions.rigidBodySolver is invalid"); + m_rigidConstraintOptions = validateRigidConstraintOptions( + options.rigidConstraintOptions, options.rigidBodySolver); DART_SIMULATION_THROW_T_IF( !isValidContactSolverMethod(options.contactSolverMethod), InvalidArgumentException, "WorldOptions.contactSolverMethod is invalid"); + validateRigidSolverContactMethodCompatibility( + options.rigidBodySolver, options.contactSolverMethod); DART_SIMULATION_THROW_T_IF( !isValidContactGradientMode(options.contactGradientMode), InvalidArgumentException, @@ -3870,6 +3964,7 @@ void World::clear() m_simulationMode = false; m_gravity = Eigen::Vector3d(0.0, 0.0, -9.81); m_rigidBodySolver = RigidBodySolver::SequentialImpulse; + m_rigidConstraintOptions = {}; m_multibodyIntegrationMethod = MultibodyIntegrationMethod::SemiImplicit; m_variationalIntegratorMaxIterations = 100; m_variationalIntegratorTolerance = 1e-10; @@ -3900,7 +3995,8 @@ void World::clear() m_deformableBodyCounter = 0; m_linkCounter = 0; m_jointCounter = 0; - m_stepPipelineCache = makeStepPipelineCache(m_memoryManager); + m_stepPipelineCache = makeStepPipelineCache( + m_memoryManager, m_rigidConstraintOptions.iterations); } //============================================================================== @@ -4048,6 +4144,11 @@ void World::reserveRegistryStorageForSimulation() //============================================================================== void World::prepareStepPipelineCacheForCurrentConfiguration() { + validateRigidConstraintOptionsPipelineSupport( + *this, + m_rigidConstraintOptions, + m_rigidBodySolver, + m_multibodyIntegrationMethod == MultibodyIntegrationMethod::Variational); reserveRegistryStorageForSimulation(); (void)detail::ensureBakedWorldModelCurrent(*this); auto& cache = *m_stepPipelineCache; @@ -4121,6 +4222,9 @@ void World::recordResolvedConfiguration() case RigidBodySolver::SequentialImpulse: rigidSolver = "sequential-impulse"; break; + case RigidBodySolver::Avbd: + rigidSolver = "avbd"; + break; case RigidBodySolver::Ipc: rigidSolver = "ipc"; break; @@ -4137,26 +4241,119 @@ void World::recordResolvedConfiguration() contactMethod = "boxed-lcp"; break; } - // The internal AVBD rigid-contact opt-in is not facade-selectable (it is - // emplaced per body), so when it is present the resolved contact path differs - // from the requested `ContactSolverMethod`: configured contacts run AVBD and - // the rest fall back to sequential impulse (PLAN-091 WP-091.1). Record that - // substitution explicitly instead of letting it happen silently. + // The compatibility-only internal AVBD rigid-contact opt-in is emplaced per + // body. When it is present under a non-AVBD public family, the resolved + // contact path differs from the requested `ContactSolverMethod`: configured + // contacts run AVBD only when every active contact has an enabled config on + // at least one endpoint (PLAN-091 WP-091.1). Record that substitution only + // when the baked collision topology guarantees this all-or-nothing stage + // gate. The public AVBD family itself resolves AVBD as requested and does not + // use this substitution path. const detail::WorldRegistry& registry = m_storage->registry; const auto* avbdStorage = registry.storage(); const bool hasAvbdContactConfigs = avbdStorage != nullptr && avbdStorage->size() != 0u; - if (hasAvbdContactConfigs) { + std::size_t collisionEntityCount = 0u; + std::size_t collisionEntitiesWithoutEnabledAvbdConfig = 0u; + const auto collisionGeometryView = registry.view(); + for (const entt::entity entity : collisionGeometryView) { + if (!collisionGeometryView.get(entity) + .hasShapes()) { + continue; + } + ++collisionEntityCount; + const auto* config + = registry.try_get(entity); + if (config == nullptr || !config->enabled) { + ++collisionEntitiesWithoutEnabledAvbdConfig; + } + } + // The compatibility contact stage is all-or-nothing for an active manifold: + // every contact must have an enabled opt-in on at least one endpoint. Before + // contacts exist, reporting AVBD is therefore safe only when every possible + // pair of collision entities is covered, which is equivalent to at most one + // collision entity lacking an enabled config. + const bool avbdContactOptInCoversEveryPotentialPair + = collisionEntityCount >= 2u + && collisionEntitiesWithoutEnabledAvbdConfig <= 1u; + if (m_rigidBodySolver == RigidBodySolver::Avbd) { + m_resolvedConfiguration.notes.push_back( + {"rigid-contact", "avbd", "avbd", "as requested"}); + } else if ( + hasAvbdContactConfigs && avbdContactOptInCoversEveryPotentialPair) { m_resolvedConfiguration.notes.push_back( {"rigid-contact", contactMethod, std::string(contactMethod) + " + avbd (opt-in)", - "internal AVBD rigid-contact opt-in active on some bodies; configured " - "contacts run AVBD, the rest run sequential impulse (not " - "facade-selectable -- PLAN-091 WP-091.1)"}); + "internal AVBD rigid-contact opt-in covers every potential active " + "contact, so the compatibility stage resolves AVBD " + "(not facade-selectable -- PLAN-091 WP-091.1)"}); } else { m_resolvedConfiguration.notes.push_back( - {"rigid-contact", contactMethod, contactMethod, "as requested"}); + {"rigid-contact", + contactMethod, + contactMethod, + hasAvbdContactConfigs + ? "as requested; compatibility AVBD opt-in is disabled or does " + "not cover every potential active contact" + : "as requested"}); + } + + const bool hasAvbdPairConstraints + = detail::deformable_vbd::mayHaveAvbdRigidWorldPointJointConfigs(registry) + || detail::deformable_vbd::mayHaveAvbdRigidWorldDistanceSpringConfigs( + registry); + if (!hasAvbdPairConstraints) { + m_resolvedConfiguration.notes.push_back( + {"rigid-pair-constraint", + "inactive", + "inactive", + "no rigid pair constraints configured"}); + } else if (m_rigidBodySolver == RigidBodySolver::Avbd) { + m_resolvedConfiguration.notes.push_back( + {"rigid-pair-constraint", "avbd", "avbd", "as requested"}); + } else if (m_rigidBodySolver == RigidBodySolver::Ipc) { + m_resolvedConfiguration.notes.push_back( + {"rigid-pair-constraint", + "ipc", + "ipc", + "public fixed and revolute pair constraints enter the IPC " + "articulation equality solve"}); + } else { + m_resolvedConfiguration.notes.push_back( + {"rigid-pair-constraint", + rigidSolver, + "avbd", + "public rigid pair constraints use AVBD projection; select the AVBD " + "rigid-body family to make this method explicit"}); + } + + const std::string rigidConstraintIterations + = std::to_string(m_rigidConstraintOptions.iterations); + if (m_rigidBodySolver == RigidBodySolver::Ipc) { + m_resolvedConfiguration.notes.push_back( + {"rigid-constraint-iterations", + "not-applicable", + "not-applicable", + "the IPC family bypasses the split rigid contact stage"}); + } else if ( + usesUnifiedConstraintStage( + *this, + m_rigidBodySolver, + m_multibodyIntegrationMethod + == MultibodyIntegrationMethod::Variational)) { + m_resolvedConfiguration.notes.push_back( + {"rigid-constraint-iterations", + "not-applicable", + "not-applicable", + "semi-implicit multibody structures select the unified constraint " + "stage"}); + } else { + m_resolvedConfiguration.notes.push_back( + {"rigid-constraint-iterations", + rigidConstraintIterations, + rigidConstraintIterations, + "as requested"}); } const char* multibody = "unknown"; @@ -4348,6 +4545,10 @@ Frame World::resolveParentFrame(const Frame& parent) const Multibody World::addMultibody(std::string_view name) { ensureDesignMode(); + DART_SIMULATION_THROW_T_IF( + m_rigidBodySolver == RigidBodySolver::Avbd, + InvalidOperationException, + "Multibody structures are not supported by the AVBD rigid-body solver"); DART_SIMULATION_THROW_T_IF( hasRigidBodyAvbdPairConstraints(*this), InvalidOperationException, @@ -5367,7 +5568,14 @@ void World::enterSimulationMode() "World is already in simulation mode"); validateLoopClosureKinematicsPolicySupport(*this); + validateRigidSolverContactMethodCompatibility( + m_rigidBodySolver, m_contactSolverMethod); validateRigidBodyJointPipelineSupport(*this, m_rigidBodySolver); + validateRigidConstraintOptionsPipelineSupport( + *this, + m_rigidConstraintOptions, + m_rigidBodySolver, + m_multibodyIntegrationMethod == MultibodyIntegrationMethod::Variational); validateArticulatedPointJointPipelineSupport( *this, m_multibodyIntegrationMethod == MultibodyIntegrationMethod::Variational); @@ -5404,12 +5612,19 @@ void World::setRigidBodySolver(RigidBodySolver solver) !isValidRigidBodySolver(solver), InvalidArgumentException, "Rigid-body solver is invalid"); + validateRigidSolverContactMethodCompatibility(solver, m_contactSolverMethod); + (void)validateRigidConstraintOptions(m_rigidConstraintOptions, solver); if (m_rigidBodySolver == solver) { return; } validateRigidBodyJointPipelineSupport(*this, solver); + validateRigidConstraintOptionsPipelineSupport( + *this, + m_rigidConstraintOptions, + solver, + m_multibodyIntegrationMethod == MultibodyIntegrationMethod::Variational); m_rigidBodySolver = solver; if (m_simulationMode) { prepareStepPipelineCacheForCurrentConfiguration(); @@ -5422,6 +5637,30 @@ RigidBodySolver World::getRigidBodySolver() const noexcept return m_rigidBodySolver; } +//============================================================================== +void World::setRigidConstraintOptions(const RigidConstraintOptions& options) +{ + const auto validated + = validateRigidConstraintOptions(options, m_rigidBodySolver); + validateRigidConstraintOptionsPipelineSupport( + *this, + validated, + m_rigidBodySolver, + m_multibodyIntegrationMethod == MultibodyIntegrationMethod::Variational); + m_rigidConstraintOptions = validated; + m_stepPipelineCache->stages.rigidBodyContact.setIterations( + m_rigidConstraintOptions.iterations); + if (m_simulationMode) { + recordResolvedConfiguration(); + } +} + +//============================================================================== +const RigidConstraintOptions& World::getRigidConstraintOptions() const noexcept +{ + return m_rigidConstraintOptions; +} + //============================================================================== void World::setTimeStep(double timeStep) { @@ -5458,6 +5697,7 @@ void World::setContactSolverMethod(ContactSolverMethod method) !isValidContactSolverMethod(method), InvalidArgumentException, "Contact solver method is invalid"); + validateRigidSolverContactMethodCompatibility(m_rigidBodySolver, method); if (m_contactSolverMethod == method) { return; @@ -6551,6 +6791,11 @@ void World::setMultibodyOptions(const MultibodyOptions& options) || options.variationalTolerance <= 0.0, InvalidArgumentException, "MultibodyOptions.variationalTolerance must be positive and finite"); + validateRigidConstraintOptionsPipelineSupport( + *this, + m_rigidConstraintOptions, + m_rigidBodySolver, + method == MultibodyIntegrationMethod::Variational); m_multibodyIntegrationMethod = method; m_variationalIntegratorMaxIterations = options.variationalMaxIterations; @@ -6750,10 +6995,19 @@ void World::stepPipelineOnce( compute::ComputeExecutor& executor, compute::WorldStepPipeline& pipeline) { validateLoopClosureKinematicsPolicySupport(*this); + validateRigidSolverContactMethodCompatibility( + m_rigidBodySolver, m_contactSolverMethod); + m_rigidConstraintOptions = validateRigidConstraintOptions( + m_rigidConstraintOptions, m_rigidBodySolver); + validateRigidBodyJointPipelineSupport(*this, m_rigidBodySolver); + validateRigidConstraintOptionsPipelineSupport( + *this, + m_rigidConstraintOptions, + m_rigidBodySolver, + m_multibodyIntegrationMethod == MultibodyIntegrationMethod::Variational); validateLoopClosureDynamicsPolicySupport( *this, m_multibodyIntegrationMethod == MultibodyIntegrationMethod::Variational); - validateRigidBodyJointPipelineSupport(*this, m_rigidBodySolver); validateArticulatedPointJointPipelineSupport( *this, m_multibodyIntegrationMethod == MultibodyIntegrationMethod::Variational); @@ -7219,6 +7473,8 @@ void World::restoreReplayFrame(std::size_t index) const ReplayState::Frame& replayFrame = m_replay->frames[index]; const bool wasSimulationMode = m_simulationMode; const auto previousRigidBodySolver = m_rigidBodySolver; + const auto previousRigidConstraintIterations + = m_rigidConstraintOptions.iterations; const auto previousMultibodyIntegrationMethod = m_multibodyIntegrationMethod; const auto previousContactSolverMethod = m_contactSolverMethod; const auto previousComputeAcceleratorPolicy = m_computeAcceleratorPolicy; @@ -7526,6 +7782,13 @@ void World::restoreReplayFrame(std::size_t index) m_simulationMode = replayFrame.simulationMode; m_gravity = replayFrame.gravity; m_rigidBodySolver = replayFrame.rigidBodySolver; + m_rigidConstraintOptions = validateRigidConstraintOptions( + RigidConstraintOptions{ + .iterations = replayFrame.rigidConstraintIterations, + }, + m_rigidBodySolver); + m_stepPipelineCache->stages.rigidBodyContact.setIterations( + m_rigidConstraintOptions.iterations); m_timeStep = replayFrame.timeStep; m_differentiable = replayFrame.differentiable; m_contactSolverMethod = replayFrame.contactSolverMethod; @@ -7555,6 +7818,8 @@ void World::restoreReplayFrame(std::size_t index) const bool stepPipelinePolicyChanged = !wasSimulationMode || frameTopologyChanged || previousRigidBodySolver != m_rigidBodySolver + || previousRigidConstraintIterations + != m_rigidConstraintOptions.iterations || previousMultibodyIntegrationMethod != m_multibodyIntegrationMethod || previousContactSolverMethod != m_contactSolverMethod || previousComputeAcceleratorPolicy != m_computeAcceleratorPolicy; @@ -7562,6 +7827,8 @@ void World::restoreReplayFrame(std::size_t index) prepareStepPipelineCacheForCurrentConfiguration(); } } + m_stepPipelineCache->stages.rigidBodyContact.restoreAvbdWarmStartReplayState( + replayFrame.rigidAvbdWarmStartState); m_replay->cursor = index; } @@ -7585,6 +7852,7 @@ void World::recordReplayFrame() replayFrame.simulationMode = m_simulationMode; replayFrame.gravity = m_gravity; replayFrame.rigidBodySolver = m_rigidBodySolver; + replayFrame.rigidConstraintIterations = m_rigidConstraintOptions.iterations; replayFrame.timeStep = m_timeStep; replayFrame.differentiable = m_differentiable; replayFrame.contactSolverMethod = m_contactSolverMethod; @@ -7611,6 +7879,9 @@ void World::recordReplayFrame() replayFrame.deformableAvbdWarmStartStates = compute::avbd_replay::captureDeformableAvbdWarmStartReplayState( m_storage->registry, replayAllocator); + replayFrame.rigidAvbdWarmStartState + = m_stepPipelineCache->stages.rigidBodyContact + .captureAvbdWarmStartReplayState(replayAllocator); replayFrame.multibodyVariationalStates = captureReplayComponents( m_storage->registry, replayAllocator); @@ -8187,6 +8458,8 @@ void World::saveBinary(std::ostream& output) const io::writePOD(output, static_cast(entityMap.at(entity))); io::writePOD(output, encodeDifferentiablePhysicalParameter(parameter)); } + + io::writePOD(output, m_rigidConstraintOptions.iterations); } //============================================================================== @@ -8251,6 +8524,11 @@ void World::loadBinary(std::istream& input) io::readPOD(input, contactGradientMode); io::readPOD(input, multibodyIntegrationMethod); + DART_SIMULATION_THROW_T_IF( + formatVersion < 29 && rigidBodySolver == 2u, + InvalidArgumentException, + "Serialized World AVBD rigid-body solver requires binary format " + "version 29 or newer"); m_rigidBodySolver = decodeRigidBodySolver(rigidBodySolver); m_contactSolverMethod = decodeContactSolverMethod(contactSolverMethod); m_contactGradientMode = decodeContactGradientMode(contactGradientMode); @@ -8356,8 +8634,28 @@ void World::loadBinary(std::istream& input) entity, decodeDifferentiablePhysicalParameter(serializedParameter)); } } + + if (formatVersion >= 29) { + std::size_t rigidConstraintIterations = 0; + io::readPOD(input, rigidConstraintIterations); + m_rigidConstraintOptions.iterations + = validateRigidConstraintIterations(rigidConstraintIterations); + m_stepPipelineCache->stages.rigidBodyContact.setIterations( + m_rigidConstraintOptions.iterations); + } } + validateRigidSolverContactMethodCompatibility( + m_rigidBodySolver, m_contactSolverMethod); + m_rigidConstraintOptions = validateRigidConstraintOptions( + m_rigidConstraintOptions, m_rigidBodySolver); + validateRigidBodyJointPipelineSupport(*this, m_rigidBodySolver); + validateRigidConstraintOptionsPipelineSupport( + *this, + m_rigidConstraintOptions, + m_rigidBodySolver, + m_multibodyIntegrationMethod == MultibodyIntegrationMethod::Variational); + // Ensure all frame entities have cache components (not serialized) auto frameView = m_storage->registry.view(); for (auto entity : frameView) { diff --git a/dart/simulation/world.hpp b/dart/simulation/world.hpp index 82374a597295b..0f457106c0fa3 100644 --- a/dart/simulation/world.hpp +++ b/dart/simulation/world.hpp @@ -457,14 +457,29 @@ class DART_SIMULATION_API World /// Select the solver family used by the default rigid-body step pipeline. /// - /// The default remains SequentialImpulse. Ipc is experimental and currently - /// handles free mesh-like rigid bodies through the internal rigid IPC stage. - /// Throws if Ipc is selected while public rigid-body fixed joints exist. + /// The default remains SequentialImpulse. Avbd is an explicit opt-in for + /// free rigid-body contact and public rigid-body pair constraints. Ipc is + /// experimental and currently handles free mesh-like rigid bodies through + /// the internal rigid IPC stage. Unsupported family/domain combinations + /// throw rather than falling through to another solver. void setRigidBodySolver(RigidBodySolver solver); /// Get the solver family used by the default rigid-body step pipeline. [[nodiscard]] RigidBodySolver getRigidBodySolver() const noexcept; + /// Set domain-scoped tuning for the built-in rigid constraint stage. + /// + /// The iteration budget controls sequential-impulse contact sweeps and AVBD + /// rigid contact, joint, motor, and spring sweeps. The IPC family and mixed + /// semi-implicit multibody worlds bypass this stage and accept only the + /// default options. Otherwise safe to change in simulation mode; the next + /// step uses the new options. + void setRigidConstraintOptions(const RigidConstraintOptions& options); + + /// Get domain-scoped tuning for the built-in rigid constraint stage. + [[nodiscard]] const RigidConstraintOptions& getRigidConstraintOptions() + const noexcept; + void setTimeStep(double timeStep); [[nodiscard]] double getTimeStep() const noexcept; @@ -478,11 +493,13 @@ class DART_SIMULATION_API World /// Set the rigid-body contact resolution method this World uses. /// /// Selected via `WorldOptions::contactSolverMethod` (default - /// `SequentialImpulse`) and independent of the differentiable flag. The - /// `BoxedLcp` value opts the rigid-body contact stage into the boxed-LCP - /// normal solve; all other behavior is unchanged. Safe to change at any time: - /// when the World is already in simulation mode, the step pipeline cache is - /// rebuilt for the new contact method. + /// `SequentialImpulse`) and independent of the differentiable flag. Under + /// the `RigidBodySolver::SequentialImpulse` family, `BoxedLcp` opts the + /// rigid-body contact stage into the boxed-LCP normal solve. The + /// `RigidBodySolver::Avbd` family owns its contact formulation and rejects + /// `BoxedLcp`. Safe to change at any time: when the World is already in + /// simulation mode, the step pipeline cache is rebuilt for the new contact + /// method. /// @throws InvalidArgumentException if `method` is not a valid /// `ContactSolverMethod` enumerator. void setContactSolverMethod(ContactSolverMethod method); @@ -985,7 +1002,8 @@ class DART_SIMULATION_API World static CollisionQueryCachePtr makeCollisionQueryCache( common::MemoryManager& memoryManager); static StepPipelineCachePtr makeStepPipelineCache( - common::MemoryManager& memoryManager); + common::MemoryManager& memoryManager, + std::size_t rigidConstraintIterations); struct ReplayState; struct ReplayStateDeleter { @@ -1092,6 +1110,7 @@ class DART_SIMULATION_API World bool m_simulationMode{false}; Eigen::Vector3d m_gravity{0.0, 0.0, -9.81}; RigidBodySolver m_rigidBodySolver{RigidBodySolver::SequentialImpulse}; + RigidConstraintOptions m_rigidConstraintOptions; double m_timeStep{0.001}; bool m_differentiable{false}; ContactSolverMethod m_contactSolverMethod{ diff --git a/dart/simulation/world_options.hpp b/dart/simulation/world_options.hpp index 1dd8523ed2a73..8357156fdb4fd 100644 --- a/dart/simulation/world_options.hpp +++ b/dart/simulation/world_options.hpp @@ -49,16 +49,23 @@ enum class RigidBodySolver { SequentialImpulse, Ipc, + /// Augmented vertex block descent for free rigid-body contact and public + /// rigid-body pair constraints. This family uses the same split + /// velocity/contact/position schedule as sequential impulse, but the contact + /// stage resolves every supported active rigid contact through AVBD. + Avbd, }; /// Selects how the rigid-body contact stage resolves active contacts. /// -/// This is an explicit, documented opt-in. It is independent of the +/// This is an explicit, documented opt-in within the +/// `RigidBodySolver::SequentialImpulse` family. It is independent of the /// differentiable flag: a non-differentiable world may use either method, and a /// differentiable world defaults to the same `SequentialImpulse` path as any -/// other. The enum names the contact formulation, without exposing backend, -/// registry, ECS storage, or concrete solver-object types, so it is safe on the -/// public facade surface. +/// other. `RigidBodySolver::Avbd` owns its contact formulation and therefore +/// cannot be combined with a non-default value here. The enum names the contact +/// formulation without exposing backend, registry, ECS storage, or concrete +/// solver-object types, so it is safe on the public facade surface. enum class ContactSolverMethod { /// Sequential-impulse (Gauss-Seidel) normal+friction solve. The default and @@ -72,6 +79,17 @@ enum class ContactSolverMethod BoxedLcp, }; +/// Domain-scoped tuning for the built-in rigid constraint stage. +/// +/// The positive iteration budget controls sequential-impulse contact sweeps +/// and AVBD rigid contact, joint, motor, and spring sweeps. The IPC family and +/// mixed semi-implicit multibody worlds bypass this stage and therefore accept +/// only the default options. +struct RigidConstraintOptions +{ + std::size_t iterations = 8; +}; + /// Selects how the differentiable contact stage produces its BACKWARD-pass /// gradient. This is an opt-in refinement knob for DART 7 /// differentiable simulation (PLAN-110 WS5): it affects ONLY the gradient @@ -180,9 +198,14 @@ struct WorldOptions Eigen::Vector3d gravity{0.0, 0.0, -9.81}; /// Free rigid-body solver family used by the built-in `World::step()` - /// schedule. Defaults to sequential impulse. + /// schedule. Defaults to sequential impulse. `Avbd` is an explicit opt-in + /// for free rigid-body contact and pair constraints; it does not silently + /// substitute sequential impulse for unsupported contact envelopes. RigidBodySolver rigidBodySolver = RigidBodySolver::SequentialImpulse; + /// Tuning for the built-in rigid constraint stage. + RigidConstraintOptions rigidConstraintOptions; + /// Multibody domain method-family options used by the built-in /// `World::step()` schedule. Defaults to semi-implicit integration. MultibodyOptions multibodyOptions; diff --git a/docs/design/dart7_architecture_assessment.md b/docs/design/dart7_architecture_assessment.md index 61d9b4b94689d..b85ec8307f7b5 100644 --- a/docs/design/dart7_architecture_assessment.md +++ b/docs/design/dart7_architecture_assessment.md @@ -83,11 +83,14 @@ The domain selectors now share a typed-enum idiom on the facade: the `RigidBodySolver` enum (runtime-mutable, alters the schedule), the `ContactSolverMethod` enum (construction-only, a branch inside one stage), and the `MultibodyOptions.integrationFamily` enum (`MultibodyIntegrationFamily`, -resolved once at finalize). AVBD rigid contact remains selected by a private ECS -component invisible to the facade. Scene content -silently swaps algorithms (VBD falls back per body to projected Newton on -unsupported scene features with no facade-visible diagnostic; mixed scenes -reroute contact solves across structurally different assemblies). +resolved once at finalize). `RigidBodySolver::Avbd` now publicly selects the +supported free-rigid AVBD contact and pair-constraint schedule, while the +private `RigidAvbdContactConfig` remains only as a compatibility opt-in under a +non-AVBD public family. The resolved-configuration report distinguishes those +selection sources, and unsupported public AVBD envelopes fail closed. Other +scene-content routing remains unresolved: VBD can still fall back per body to +projected Newton on unsupported features, and mixed scenes reroute contact +solves across structurally different assemblies. Rigid contact-row physics is assembled five times (sequential-impulse scratch, boxed-LCP, unified constraint, AVBD snapshot, and the gradient @@ -155,13 +158,15 @@ internal registry access. The capability matrix is documentation-only, so a researcher cannot ask "did the configured method actually run, and does it support this scene?". There is no shared scene corpus (the same box-stack scene is hand-built in four files) and no generic harness (dozens of -per-scene packet-writer scripts). Worst, benchmark evidence packets do not +per-scene packet-writer scripts). Historically, benchmark packets did not record solver identity, and flagship AVBD contact-scene packets recorded "DART beats reference" rows on scenes that actually ran the -sequential-impulse contact path, because AVBD contact is not -facade-selectable. Evidence integrity is the cheapest and most urgent fix: -machine-record resolved solver configuration in every packet, and relabel the -affected rows. +sequential-impulse contact path. Those rows are now relabeled, the shared +packet contract records both resolved solver and rigid-contact selection +source, and the first public-AVBD Figure 13 packet uses that contract. The +broader substrate gap remains: legacy packets cannot be generalized beyond +their recorded identities, and there is still no shared cross-family +accuracy/performance harness. ### F5 — Facade erosion at the edges diff --git a/docs/design/simulation_cpp_api.md b/docs/design/simulation_cpp_api.md index 148ddc2f8074d..b0ec4be09c923 100644 --- a/docs/design/simulation_cpp_api.md +++ b/docs/design/simulation_cpp_api.md @@ -915,10 +915,12 @@ Capability selection is exposed through **domain-scoped value objects set as a whole**, not a setter/getter per capability, so new capability fields are added without growing the `World` method surface. The realized C++ shape is a world-level construction grouping for defaults and policies -(`WorldOptions::rigidBodySolver`, `WorldOptions::multibodyOptions`, -`WorldOptions::contactSolverMethod`, `WorldOptions::contactGradientMode`, and -`WorldOptions::differentiable`) plus the existing interactive setters for the -policies that are safe to switch after construction (`World::setRigidBodySolver`, +(`WorldOptions::rigidBodySolver`, `WorldOptions::rigidConstraintOptions`, +`WorldOptions::multibodyOptions`, `WorldOptions::contactSolverMethod`, +`WorldOptions::contactGradientMode`, and `WorldOptions::differentiable`) plus +the existing interactive setters for the policies that are safe to switch +after construction (`World::setRigidBodySolver`, +`World::setRigidConstraintOptions` / `getRigidConstraintOptions`, `World::setMultibodyOptions` / `getMultibodyOptions`, and `World::setContactGradientMode`). `MultibodyOptions { MultibodyIntegrationFamily integrationFamily; }` maps onto the "Integration @@ -926,6 +928,15 @@ family" matrix row. Selection is a typed enum mapped to an internal representation on construction or set, so the per-step path carries no configuration cost when a non-default family is not in use. +`RigidBodySolver::Avbd` is the explicit experimental public family for +supported free-rigid contact and pair constraints. It owns its contact +formulation, uses the positive `RigidConstraintOptions::iterations` budget, +and rejects an incompatible boxed-LCP contact-method selection instead of +silently substituting another family. +That budget applies only while the split rigid constraint stage is active. +IPC and mixed semi-implicit multibody worlds bypass it, accept only the default +options, and report the budget as not applicable. + ## Future Capability Shapes These sections describe C++ target shapes, not guaranteed DART 7 APIs. diff --git a/docs/design/simulation_python_api.md b/docs/design/simulation_python_api.md index 56e06fec42da7..49ca7889e3ef8 100644 --- a/docs/design/simulation_python_api.md +++ b/docs/design/simulation_python_api.md @@ -951,6 +951,7 @@ world = sx.World( time_step=0.001, gravity=(0.0, 0.0, -9.81), rigid_body_solver=sx.RigidBodySolver.SEQUENTIAL_IMPULSE, + rigid_constraint_options=sx.RigidConstraintOptions(iterations=8), multibody_options=sx.MultibodyOptions( integration_family=sx.MultibodyIntegrationFamily.SEMI_IMPLICIT ), @@ -977,6 +978,15 @@ constructor keywords rather than a bound `sx.WorldOptions` object. If Python later gains `sx.WorldOptions`, it should preserve the same field names and validation behavior as those constructor keywords. +For supported free-rigid AVBD contact and pair constraints, callers select +`sx.RigidBodySolver.AVBD` and a positive +`sx.RigidConstraintOptions(iterations=...)` budget. That family owns its +contact formulation and rejects an incompatible +`sx.ContactSolverMethod.BOXED_LCP` selection instead of falling back. +The budget applies only while the split rigid constraint stage is active. +IPC and mixed semi-implicit multibody worlds accept only the default options +and report the budget as not applicable. + ### Loading And Source Geometry C++ now owns the first parsed-Skeleton bridge for the tree-joint families that diff --git a/docs/design/simulation_solver_architecture.md b/docs/design/simulation_solver_architecture.md index 7e53883127b62..ad65591300325 100644 --- a/docs/design/simulation_solver_architecture.md +++ b/docs/design/simulation_solver_architecture.md @@ -373,13 +373,14 @@ levels: That placement gives DART a clean relation between the existing and proposed paths: -| Path | Domain | Forward method | Gradient method | Relationship | -| ----------------------------------------- | ------------------- | -------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------- | -| Existing rigid-body default | Rigid / articulated | Semi-implicit dynamics + existing contact path | none | easy path and compatibility baseline | -| PLAN-110 boxed-LCP/Nimble-style path | Rigid / articulated | Generalized-coordinate boxed LCP normal/friction contact solve | active-set LCP implicit differentiation | first opt-in differentiable rigid solver; default remains off | -| Planned Dojo-style evaluation | Rigid / articulated | Variational maximal/constrained coordinate NCP | IPM/KKT implicit differentiation | possible second opt-in rigid solver family after an internal spike | -| PLAN-081/PLAN-104 deformable solver paths | Deformable | IPC/VBD-family deformable methods | deferred per-solver/coupler differentiation | separate domain; later coupled to rigid solvers through pairwise couplers | -| PLAN-084 variational-integrator work | Rigid / articulated | Variational integration for articulated systems | not sufficient by itself for contact gradients | shared integration rationale; Dojo adds contact/NCP/IPM and differentiability | +| Path | Domain | Forward method | Gradient method | Relationship | +| ----------------------------------------- | ------------------- | -------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------- | +| Existing rigid-body default | Rigid / articulated | Semi-implicit dynamics + existing contact path | none | easy path and compatibility baseline | +| PLAN-104 AVBD rigid path | Rigid | Augmented VBD contact and public pair-constraint projection | none | explicit experimental `RigidBodySolver::Avbd` family; CPU scope remains bounded | +| PLAN-110 boxed-LCP/Nimble-style path | Rigid / articulated | Generalized-coordinate boxed LCP normal/friction contact solve | active-set LCP implicit differentiation | first opt-in differentiable rigid solver; default remains off | +| Planned Dojo-style evaluation | Rigid / articulated | Variational maximal/constrained coordinate NCP | IPM/KKT implicit differentiation | possible second opt-in rigid solver family after an internal spike | +| PLAN-081/PLAN-104 deformable solver paths | Deformable | IPC/VBD-family deformable methods | deferred per-solver/coupler differentiation | separate domain; later coupled to rigid solvers through pairwise couplers | +| PLAN-084 variational-integrator work | Rigid / articulated | Variational integration for articulated systems | not sufficient by itself for contact gradients | shared integration rationale; Dojo adds contact/NCP/IPM and differentiability | The public API should not expose a `DojoWorld`, `DojoSolver`, solver registry, or Dojo.jl dependency. If promoted, users would opt in through DART-owned diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index 146e99c241869..e5c980a2c1e9d 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -26,25 +26,29 @@ This folder is the temporary working surface; the durable owner is the plan. has been pushed. The Section 3.5 packet is committed locally at `a78f688a178`, and the Section 4 packet at `0b0154573b8`; the articulated finite-row packet at `761263bbd41`; the articulated finite-motor packet at - `9ebd9b895b1`; the articulated finite-fracture packet is the current local - unpushed closeout. -- **Current packet:** same-multibody finite-stiffness articulated point joints - now account for accepted finite-row force and bounded motor projection load - in public force/torque units. All rows owned by a joint contribute to one L2 - threshold; fracture clears finite row state, later steps skip the broken - joint, reset re-engages it, and weak re-arm can break it again. + `9ebd9b895b1`; and the articulated finite-fracture packet at `131981788fa`. + The public AVBD/Figure 13 slice is the current local unpushed closeout. +- **Current packet:** C++ and dartpy callers can explicitly select the public + AVBD rigid-body family and its positive contact/joint projection budget. + Selection survives binary save/load and replay, reports `avbd`, and rejects + unsupported combinations instead of silently falling back. Mixed + semi-implicit multibody worlds use the unified constraint stage, so they + accept only the default rigid constraint options and report the split-stage + budget as not applicable. The new publication-shaped Figure 13 wall uses + 252 staggered bricks, 712 breakable attachments, three impacts, a + deterministic localization/retention oracle, assessed impact/outcome + captures, allocation gates, and an absolute CPU benchmark. The contracts contain 88 VBD and 88 AVBD requirements; all 176 remain incomplete until their recorded correctness, solver-identity, CPU/CUDA, visual, and comparable-performance predicates pass. -- **Latest verified local packet:** focused finite-only, motor-only - two-timestep, combined-load, break/reset/re-arm, and save/load behavior; - warmed allocation; Python scene; exact-parent mutation; docked assessed - visual; and candidate-only scale evidence advance - `avbd.method.joints_and_attachments`, - `avbd.method.finite_stiffness_ramping`, `avbd.method.motors`, and - `avbd.method.fracture` without completing any row. The paper wall, broad - joint/fracture corpus, unified rows, CUDA, and source-matched - achieved-accuracy performance remain open. +- **Latest verified local packet:** + [`../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json) + binds the public solver identity, exact paper/page hashes, frame-60 and + frame-120 scene metrics, camera and image-verdict hashes, native semantic + review, first-post-bake allocation tests, and five-repeat Release timing. + Figure 13 and video row 12 remain partial: exact source constants, the + Sequential Impulse, XPBD, and VBD comparison rows, CUDA, and comparable + performance are open. - **Recent slices merged to `main`** (see the PLAN-104 progress log and the PRs for detail; per-slice history lives in git, not in this file): - #2991 — source-row coverage + contact-precheck (`f6fecbc5bd5`). @@ -54,22 +58,33 @@ This folder is the temporary working surface; the durable owner is the plan. - #3022 — bounded regression coverage: rigid-contact tangent-basis contract, articulated break→reset→break re-arm lifecycle, row-inventory replaced-key cold-start (`65ba05113c6`). -- **Current local gates:** the focused finite-only, bounded-motor, - combined-load, break/reset/re-arm, and save/load C++ oracles; all 55 AVBD - breakage tests; all three articulated finite-row post-bake allocation - policies; six packet-writer tests; and the focused Python lifecycle oracle - pass. The two mutation-sensitive C++ tests fail in five required cases on - exact parent `9ebd9b895b1`. The assessed 149-frame docking-build capture and - pinned 2/8/32-motor candidate-only benchmark are recorded in the packet. - The Release build, complete variational binary (186/186), aggregate C++ unit - tier (168/168), full Python tier (1658 passed, 20 skipped), PLAN-104 parity - contract (176 rows), AVBD packets (55), and PLAN-122 allocation matrix - (18 rows, 14 closed) pass. The simulation label passes 77/79 active entries; - only the split global-heap and raw-malloc binaries fail on the same four - unrelated signatures previously reproduced on exact parent (320/80 global - bytes and 320/1376 raw bytes), while all three new finite articulated gates - pass inside those splits. Two monolithic entries remain intentionally - disabled. +- **Current local gates:** public solver/schedule/serialization/Python tests, + both Figure 13 scene oracles, all three public-AVBD post-bake allocation + policies, all 16 fail-closed packet-writer tests, the full 273-test migrated + packet/schema set, all 56 working-tree AVBD packet schemas, three exact + deterministic outcome runs, both assessed captures, and the five-repeat + benchmark pass. The final broad local gates pass: default `pixi run build`, + all 168 unit targets, all 445 `test_world` cases, all 15 resolved- + configuration cases, all 20 contact-parity cases, and 1,677 Python tests + with 20 expected skips. `pixi run -e cuda test-all` passes all seven phases, + including the GUI-disabled Python catalog, documentation, all eight CUDA + runtime tests, and CUDA benchmark smoke. The first CUDA run exposed and the + candidate now covers a headless `OrbitCamera` assumption in the Figure 13 + scene. The boxed-LCP fallback allocation regression found by the full World + gate is also fixed: the runtime applies the allocation-free contact path and + copies its existing force vector instead of allocating a differentiable + snapshot. The final dual review found one additional fail-closed contract + gap: + non-default rigid iteration options were reported as applied in mixed + semi-implicit multibody worlds even though the unified stage ignored them. + Construction, runtime mutation, method-family transitions, load, and step + entry now reject that combination; the resolved report marks the default + budget not applicable. Both post-fix review lanes approve the result. The + exact post-fix reruns pass the uncached default build, 1,677 Python tests + with 20 expected skips, and all seven CUDA `test-all` phases; the eight CUDA + runtime tests include the 208.75-second Jacobi batch case. Packet provenance + was regenerated after formatting with zero source-hash mismatches, and the + PLAN-122 allocation matrix passes with 18 rows, 14 closed. ## Goal @@ -79,10 +94,11 @@ paper/site/video/demo reproduction in DART tests/benchmarks/`py-demos`, and performance that beats the reference demo repositories and published paper numbers. -## Non-Goals For Early Phases +## Non-Goals For Current Phases -- Do not expose AVBD, row storage, solver registries, CUDA types, or ECS details - as public API. +- Keep the public AVBD surface to the DART-owned method-family selector and + validated iteration policy; do not expose row storage, solver registries, + CUDA types, or ECS details. - Do not vendor or runtime-link the AVBD demo repositories. - Do not claim AVBD parity from the scalar row utility, a CPU-only slice, or one demo scene. @@ -100,15 +116,16 @@ numbers. ## Immediate Next Steps -Continue PLAN-104 with the paper wall/broader fracture and joint corpus, -unified rigid/soft rows, or another highest-priority missing paper mechanism. -Keep every dependent figure/demo/performance row partial until source-matched -CPU and CUDA evidence closes it. +Extend the publication-shaped AVBD wall into a source-matched four-method +comparison and CUDA row, or continue with unified rigid/soft rows and the +remaining fracture and joint corpus. Keep every dependent +figure/demo/performance row partial until source-matched CPU and CUDA evidence +closes it. Two smaller deferred maintenance items remain valid but do not outrank the missing paper mechanism: hoist the duplicated `makeCollisionPairKey` into a shared `detail` header, and upgrade the Spring / Spring Ratio packets from -legacy schema version 1 to version 2 with a `resolved_solver_identity`. +legacy schema version 1 to the current solver-identity contract. ## Verified Local Packet: Section 3.5 Quasi-Newton Hessian @@ -268,6 +285,39 @@ The durable evidence is [`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json). All linked canonical method rows remain partial. +## Verified Local Packet: Public AVBD Figure 13 Wall + +- **Value:** make supported free-rigid AVBD contact and pair constraints an + explicit public `World` family, then use that exact family for the first + publication-shaped breakable-wall outcome. +- **Public contract:** `RigidBodySolver::Avbd` / + `sx.RigidBodySolver.AVBD`, a positive `RigidConstraintOptions::iterations` / + `sx.RigidConstraintOptions(iterations=...)` policy, binary and replay + persistence, resolved-family reporting, and fail-closed rejection of + unsupported or incompatible configuration. +- **Scene/oracle:** 252 staggered bricks, 712 breakable fixed attachments, + three 100 kg balls, 1/60 s, and 20 projection sweeps. At frame 120, the + deterministic oracle records 359 broken and 353 unbroken attachments, + displaced-brick counts `[4, 10, 6]`, 91.16% outside-wall retention, and + 82.54% total retention. Frame 60 remains explicitly pre-evaluation because + its `[3, 9, 4]` counts leave one band below the final threshold. +- **Runtime evidence:** the public AVBD contact-plus-breakable-row fixture + passes world-base, global-`new`, and raw-malloc first-post-bake gates. The + frame-60 and frame-120 software captures bind the exact front camera and + pass engine ViewReports, pixel integrity, and image-capable semantic review + against the pinned paper page. The scene and benchmark share fingerprint + `2a746821cc10faee`. +- **Performance boundary:** five Release repetitions record 7.739 ms median + CPU time per step. This is absolute DART timing only; the paper and source + publish no directly comparable timing for this scene. +- **Non-goals:** exact replay of unpublished source constants, the Sequential + Impulse, XPBD, and VBD comparison rows, source-matched video edit, broad + fracture corpus, unified rows, and CUDA. + +The durable evidence is +[`../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). +Figure 13 and official-video row 12 remain partial. + ## History Per-slice history and durable evidence live in the PLAN-104 progress log, the diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index 6829426b33e0c..d1839aa645e39 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -9,31 +9,63 @@ this file is only the current session pointer. See ## Last Session Summary The exact 176-row VBD/AVBD paper-parity contract remains fail-closed, with no -complete rows. The latest local packet closes the narrow CPU break-load gap for -same-multibody finite articulated point joints. Accepted finite-row forces and -bounded motor projection loads now share public force/torque units, aggregate -into one per-joint L2 threshold, clear finite row state on fracture, skip while -broken, and re-engage after reset. +complete rows. The latest local slice makes supported free-rigid AVBD contact +and pair constraints explicitly selectable through the C++ and dartpy +`RigidBodySolver` family. Its positive contact/joint projection budget is +public, serialized, replay-safe, runtime-mutable, and reported in resolved +configuration when the split rigid stage is active. Mixed semi-implicit +multibody worlds use the unified constraint stage, accept only the default +rigid constraint options, and report that split-stage budget as not +applicable; unsupported or incompatible combinations fail closed. The durable packet is -[`../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json). -It records finite-only, motor-only at two timesteps, combined-load, -break/reset/re-arm, and save/load C++ oracles; all three warmed allocation -policies; a Python lifecycle oracle; assessed docked start/middle/end visual -evidence; candidate-only 2/8/32-motor scale data; and an exact-parent mutation -run. Both load/lifecycle tests fail in five required cases on exact parent -`9ebd9b895b1` and pass on the candidate. The linked method rows remain partial. - -The focused load/lifecycle/serialization C++ oracles, all existing AVBD -breakage tests, all three warmed allocation policies, the Python scene oracle, -six packet-writer tests, assessed 149-frame docking capture, and pinned -candidate-only benchmark pass. The Release build, complete variational binary -(186/186), aggregate C++ unit tier (168/168), full Python tier (1658 passed, -20 skipped), PLAN-104 parity contract (176 rows), AVBD packets (55), and -PLAN-122 allocation matrix (18 rows, 14 closed) also pass. The simulation label -passes 77/79 active entries; only the split global-heap and raw-malloc binaries -retain the four unrelated exact-parent-reproduced allocation signatures, while -all three new finite articulated gates pass inside those splits. +[`../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). +It binds that public solver identity to a publication-shaped Figure 13 scene: +252 staggered bricks, 712 breakable attachments, three high-momentum balls, +1/60 s, and 20 projection sweeps. The deterministic frame-120 oracle records +359 broken/353 unbroken attachments, `[4, 10, 6]` local displaced-brick +counts, 91.16% outside-wall retention, and 82.54% total retention. Frame 60 is +explicitly pre-evaluation because its `[3, 9, 4]` counts leave one band below +the final threshold. Both captures record the exact front camera and pass +engine ViewReports, pixel integrity, and image-capable semantic review against +the pinned paper page. The runtime scene and benchmark share fingerprint +`2a746821cc10faee`; the five-repeat Release benchmark records 7.739 ms median +CPU time per step. + +The public solver/schedule/serialization/Python tests, both scene oracles, all +three public-AVBD first-post-bake allocation policies, all 16 fail-closed +packet-writer tests, the full 273-test migrated packet/schema set, all 56 +working-tree AVBD packets, deterministic reruns, captures, and benchmark pass. +The final broad default gates pass: `pixi run build`, all 168 unit targets, all +445 `test_world` cases, all 15 resolved-configuration cases, all 20 contact- +parity cases, and 1,677 Python tests with 20 expected skips. The full CUDA +`test-all` passes all seven phases, including the GUI-disabled Python catalog, +documentation, all eight CUDA runtime tests, and CUDA benchmark smoke. Packet +provenance was regenerated after formatting and all 16 writer, 273 aggregate, +56 working-tree packet, and 176-row parity gates pass with zero source-hash +mismatches. The PLAN-122 allocation matrix passes with 18 rows, 14 closed. + +The final dual review found one additional contract mismatch: non-default +rigid iteration options were accepted and reported as applied in mixed +semi-implicit multibody worlds even though their unified constraint stage did +not consume that budget. The runtime now rejects the combination at +construction finalization, option/method mutation, load, and step entry; a +mixed active-contact regression proves the default budget is reported as not +applicable. The focused post-fix gates pass: 15/15 resolved-configuration, +445/445 World, 61/61 serialization, 17/17 schedule, and 168/168 aggregate unit +tests. Both post-fix review lanes approve the result. Exact final reruns pass +the uncached default build, the 1,677-test default Python suite, and all seven +CUDA `test-all` phases; the eight CUDA runtime tests include the 208.75-second +Jacobi batch case. + +The broad gates found two candidate defects that are now covered. The boxed- +LCP runtime had allocated a full differentiable snapshot only to recover +contact forces; it now uses the allocation-free apply path and copies the +existing force vector, restoring the zero-allocation World gate. The first +CUDA Python pass also found that the Figure 13 scene constructed +`OrbitCamera` when GUI bindings were disabled. Headless builds now expose +`view_assessment_available: false` and `view_report: null`, while GUI builds +still require the positive engine ViewReport used by the evidence writer. ## Current Branch @@ -41,22 +73,33 @@ all three new finite articulated gates pass inside those splits. `origin/main` at `83110ef54ab`. The fail-closed contract is committed locally at `710cbfc1152`, the Section 3.5 packet at `a78f688a178`, and the Section 4 packet at `0b0154573b8`; the articulated finite-row packet is committed at -`761263bbd41`, and the articulated finite-motor packet at `9ebd9b895b1`; the -articulated finite-fracture packet follows as the current local unpushed -closeout. The branch has not been pushed. +`761263bbd41`, the articulated finite-motor packet at `9ebd9b895b1`, and the +articulated finite-fracture packet at `131981788fa`. The public AVBD/Figure 13 +slice is the current local unpushed closeout. The branch has not been pushed. ## Immediate Next Step -Broaden the paper wall/fracture and joint corpus, unified rigid/soft rows, or -another highest-priority missing paper mechanism. Preserve the packet -boundary: this narrow CPU load contract does not close those rows, CUDA, the -source/paper corpus, or achieved-accuracy performance. +Extend the wall into the source-matched Sequential Impulse, XPBD, and VBD +comparison rows and CUDA row, or proceed to unified rigid/soft and the +remaining fracture/joint corpus. Preserve the packet boundary: the current row +is a publication-shaped AVBD CPU reconstruction, not exact source, full video, +four-method, CUDA, or comparable performance closure. ## Context That Would Be Lost - **Keep claims narrow.** Do not claim a source-demo/paper CPU win, GPU parity, a broad breakable-wall/fracture corpus, a same-hardware paper-number match, or an all-coefficient friction win unless the tracked artifacts directly prove it. +- Figure 13 does not publish the exact wall dimensions, mass/inertia choices, + ball speed/targets, or break threshold. The DART constants are explicitly + reconstructed and must not be described as an exact source replay. +- The benchmark is absolute DART timing. There is no paper/reference timing + denominator for this scene, so do not derive a speedup or parity ratio. +- The outcome image's optional contrast diagnostic records 1,019 bright + pixels against a 1,180-pixel heuristic. The required non-blank gate and + engine ViewReport pass, and the separate semantic review adjudicates + fracture morphology. Do not recast the optional diagnostic as a failed + render or suppress it from the packet. - The articulated finite-motor benchmark is candidate-only. Exact parent skips the new rows, so a timing ratio would compare different work and is not a speedup claim. @@ -89,6 +132,7 @@ git log --oneline --decorate -5 pixi run check-plan104-paper-parity pixi run check-avbd-packets pixi run check-plan122-allocation-matrix +pixi run build ``` Stay in this exact worktree and branch. Verify the current packet with focused diff --git a/docs/onboarding/agent-sim-verification.md b/docs/onboarding/agent-sim-verification.md index 9d3902ec7e329..cc2e740050c58 100644 --- a/docs/onboarding/agent-sim-verification.md +++ b/docs/onboarding/agent-sim-verification.md @@ -67,8 +67,10 @@ dynamic failures (explosions, tunneling); use text to decide correctness. and MP4 with `--video` when ffmpeg is present). - **View quality + adaptive viewpoints** — `dart.gui.assess_view(world, camera, size, focus=...)` returns a machine-readable `ViewReport` - (coverage/crop, subject size, occlusion via CPU pick rays, ambiguity) with - named issues (`cropped`, `off-frame`, `too-far`, `too-close`, `occluded`, + (coverage/crop, subject size, occlusion via CPU pick rays, ambiguity). + Explicit focus scopes framing and ambiguity to the named subject while + retaining other visible renderables as possible occluders. Reports use named + issues (`cropped`, `off-frame`, `too-far`, `too-close`, `occluded`, `ambiguous`, `no-bounded-focus`); `dart.gui.select_viewpoints(...)` deterministically scores a candidate grid and returns the best azimuth-diverse cameras with recorded reasons; diff --git a/docs/plans/104-vertex-block-descent-solver.md b/docs/plans/104-vertex-block-descent-solver.md index 6cd53bdcd8a52..b30cd02b27f16 100644 --- a/docs/plans/104-vertex-block-descent-solver.md +++ b/docs/plans/104-vertex-block-descent-solver.md @@ -336,24 +336,42 @@ end-to-end dashboard benchmark rows for the public motor paths, and tracked and [`avbd-rigid-prismatic-motor-packet.json`](104-vertex-block-descent-solver/avbd-rigid-prismatic-motor-packet.json) visual/benchmark evidence. -Solver-identity relabel (PLAN-091 WP-091.1): no `avbd-demo2d`/`avbd-demo3d` -benchmark or py-demo scene emplaces the internal AVBD rigid-contact opt-in -config (`comps::RigidAvbdContactConfig`), because AVBD contact is not -facade-selectable, so every rigid contact in the source-row scenes below ran -DART's default sequential-impulse contact path. The native-runner timing -ratios for contact scenes are whole-pipeline `World::step` comparisons, not -AVBD-contact-solver comparisons: the pure-contact rows (2D Dynamic Friction, -Static Friction, Pyramid, Cards, Stack, and Stack Ratio; 3D Ground, Dynamic -Friction, Static Friction, Pyramid, Stack, and Stack Ratio) timed no AVBD rows -at all; the joint-plus-contact rows (2D Fracture, Soft Body, Joint Grid, and -Net; 3D Soft Body, Bridge, and Breakable) timed AVBD -point-joint/motor/spring rows while their ordinary contacts ran sequential -impulse; and incidental link-link contacts in the chain rows (2D Rod, Rope, -Heavy Rope, and Hanging Rope; 3D Rope and Heavy Rope) also ran sequential -impulse. This relabel changes no committed packet bytes and neither closes nor -reopens any PLAN-104 completion gate; new AVBD evidence packets must -machine-record `resolved_solver_identity` at AVBD packet schema version 2, -enforced by `pixi run check-avbd-packets`. +Solver-identity relabel (PLAN-091 WP-091.1): the historical +`avbd-demo2d`/`avbd-demo3d` benchmark and py-demo packets neither selected the +new public AVBD rigid-body family nor emplaced the compatibility-only internal +AVBD rigid-contact opt-in (`comps::RigidAvbdContactConfig`). Their rigid +contacts therefore ran DART's default sequential-impulse path. The +native-runner timing ratios for those contact scenes remain whole-pipeline +`World::step` comparisons, not AVBD-contact-solver comparisons: the +pure-contact rows timed no AVBD rows; joint-plus-contact rows timed AVBD +point-joint/motor/spring rows while ordinary contacts ran sequential impulse; +and incidental chain contacts also ran sequential impulse. This historical +classification is unchanged. New packets must machine-record both +`resolved_solver_identity` and `rigid_contact_selection` at AVBD packet schema +version 3, enforced by `pixi run check-avbd-packets`. + +Public C++ and dartpy callers can now select +`RigidBodySolver::Avbd` / `RigidBodySolver.AVBD` and set a positive +`RigidConstraintOptions::iterations` / +`RigidConstraintOptions(iterations=...)` budget. The default remains +sequential impulse, the choice survives binary save/load and replay, the +resolved-configuration report names `avbd`, and unsupported combinations fail +before stepping instead of falling through to another contact family. +The publication-shaped `avbd_paper_breakable_wall` scene exercises that public +path at the paper's 1/60 s step and 20-iteration budget with 252 staggered +bricks, 712 breakable attachments, and three impacts. Its deterministic +frame-120 oracle records 359 broken and 353 retained attachments, displaced +brick counts `[4, 10, 6]` around the three targets, 91.16% retention outside +the impact bands, and 82.54% total retention. The frame-60 capture remains an +explicit pre-evaluation state: one band has three displaced bricks and no +final three-band threshold claim is made there. Both engine ViewReports pass, +the runtime scene and benchmark share fingerprint `2a746821cc10faee`, and the +five-repeat Release benchmark records 7.739 ms median CPU time per step. +Those assessed captures and the absolute CPU benchmark are sealed in +[`avbd-paper-breakable-wall-packet.json`](104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). +Figure 13 and the matching video row stay partial because the exact source +constants are unpublished and the Sequential Impulse, XPBD, and VBD comparison +rows, CUDA row, and matched performance evidence are still missing. The `avbd_empty_baseline` py-demo and `BM_AvbdEmptyWorldStep` row now provide a first runnable baseline for the 2D/3D source-demo empty rows, with source revision/default metadata and a `sceneEmpty` zero-count reference invariant. @@ -1012,10 +1030,11 @@ internal/explicit-opt-in decision, not a leaked solver registry. The free-rigid fixed-joint/contact, free-rigid/articulated one-DOF motor, breakable fixed point-joint, prismatic/revolute/spherical facade, -articulated breakable-joint, and high-ratio articulated-chain slices are -user-visible but intentionally small. -They do not cover the AVBD source-demo or paper corpus, and the dashboard rows -are narrow CPU public-World evidence only. The Section 4 post-primal +articulated breakable-joint, high-ratio articulated-chain, and public AVBD +Figure 13 slices are user-visible but intentionally bounded. The wall is the +first publication-shaped paper outcome row, not an exact source replay or a +four-method/CUDA closure; the source-demo corpus packets still carry their +historical solver identities. The Section 4 post-primal dual/stiffness update is now a verified CPU partial over the promoted deformable and private free-rigid inventories; it remains open for articulated/unified rows, CUDA, and source-matched achieved-accuracy @@ -1325,6 +1344,23 @@ AVBD parity additionally requires: Relocated from the dashboard on 2026-07-03; newest first. +On 2026-07-30, the public rigid-body solver selector gained an AVBD family and +a serialized/replay-safe rigid-constraint options value object. The default remains +sequential impulse; public AVBD owns supported rigid contact plus point-joint +rows, reports its resolved family, rejects incompatible contact-method +configuration, and is covered through C++/dartpy construction, mutation, +schedule, serialization, replay, and first-post-bake allocation gates. The +new `avbd_paper_breakable_wall` scene and +`BM_AvbdPaperBreakableWallStep` reproduce the published Figure 13 shape at +1/60 s and 20 iterations: three high-momentum balls fracture a 252-brick, +712-attachment staggered wall while the deterministic frame-120 oracle keeps +damage in all three impact bands and substantial outside/total retention. The assessed +impact/outcome renders and five-repeat Release benchmark are sealed in +[`avbd-paper-breakable-wall-packet.json`](104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). +The packet makes no reference speedup claim and keeps Figure 13 plus video row +12 partial because exact source constants, the other three method rows, CUDA, +and comparable performance remain open. + On 2026-07-30, the articulated finite-fracture packet closed the narrow CPU load-accounting gap left by the finite-motor slice. Accepted finite-row forces use the same stiffness/residual values as the variational solve, while hard and @@ -1713,13 +1749,12 @@ with finite replay counters, tracked benchmark/stability evidence in and a rendered [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-plot.svg). The same-hardware comparison and GPU gates remain open. Solver-identity -relabel (PLAN-091 WP-091.1): no -`avbd-demo2d`/`avbd-demo3d` benchmark or py-demo scene emplaces the internal -AVBD rigid-contact opt-in config (`comps::RigidAvbdContactConfig`), because -AVBD contact is not facade-selectable, so every rigid contact in the -source-row scenes below ran DART's default sequential-impulse contact path. -The native-runner timing ratios for contact scenes are whole-pipeline -`World::step` comparisons, not AVBD-contact-solver comparisons: the +relabel (PLAN-091 WP-091.1): the historical +`avbd-demo2d`/`avbd-demo3d` packets neither selected the public AVBD family nor +emplaced its compatibility-only internal body opt-in, so every rigid contact +in those source-row scenes ran DART's default sequential-impulse contact path. +Their native-runner timing ratios are whole-pipeline `World::step` +comparisons, not AVBD-contact-solver comparisons: the pure-contact rows (2D Dynamic Friction, Static Friction, Pyramid, Cards, Stack, and Stack Ratio; 3D Ground, Dynamic Friction, Static Friction, Pyramid, Stack, and Stack Ratio) timed no AVBD rows at all; the @@ -1728,9 +1763,9 @@ Soft Body, Bridge, and Breakable) timed AVBD point-joint/motor/spring rows while their ordinary contacts ran sequential impulse; and incidental link-link contacts in the chain rows (2D Rod, Rope, Heavy Rope, and Hanging Rope; 3D Rope and Heavy Rope) also ran sequential impulse. This relabel -changes no committed packet bytes and neither closes nor reopens any -PLAN-104 completion gate; new AVBD evidence packets must machine-record -`resolved_solver_identity` at AVBD packet schema version 2, enforced by +remains valid after the public family addition; new AVBD evidence packets must +machine-record `resolved_solver_identity` and `rigid_contact_selection` at +schema version 3, enforced by `pixi run check-avbd-packets`. Public empty-scene corpus baseline coverage is now visible through `avbd_empty_baseline`, a focused Python smoke that checks source revisions, diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json index 8c924f6087673..17bf1556b832f 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-fracture-packet.json @@ -360,11 +360,12 @@ "resolved_solver_identity": { "avbd_rigid_contact_config_emplaced": false, "recorded_from": "contact-free py-demo, focused World tests, and articulated finite-load benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd" }, "scene": "avbd_articulated_compliant_breakable_motor", - "schema_version": 2, + "schema_version": 3, "target": { "complete_paper_reproduction": false, "contract_rows": [ diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json index 4cad2435f9024..3fa24e2023edf 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-joints-packet.json @@ -364,6 +364,7 @@ "resolved_solver_identity": { "avbd_rigid_contact_config_emplaced": false, "recorded_from": "contact-free py-demo and articulated compliant-joint benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd" }, @@ -384,7 +385,7 @@ "start_stiffness": 10.0, "time_step": 0.002 }, - "schema_version": 2, + "schema_version": 3, "target": { "complete_paper_reproduction": false, "contract_rows": [ diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json index effb314a1ef36..55c33c01e060f 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-articulated-compliant-motors-packet.json @@ -336,6 +336,7 @@ "resolved_solver_identity": { "avbd_rigid_contact_config_emplaced": false, "recorded_from": "contact-free py-demo and articulated compliant-motor benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd" }, @@ -356,7 +357,7 @@ "start_stiffness": 20.0, "time_step": 0.005 }, - "schema_version": 2, + "schema_version": 3, "target": { "complete_paper_reproduction": false, "contract_rows": [ diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-breakable-joint-scale-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-breakable-joint-scale-packet.json index 9780e04d336e9..b0e62028e7d6c 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-breakable-joint-scale-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-breakable-joint-scale-packet.json @@ -1825,11 +1825,12 @@ "resolved_solver_identity": { "avbd_rigid_contact_config_emplaced": false, "recorded_from": "breakable joint scale benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd" }, "scene": "avbd_breakable_joint_scale", - "schema_version": 2, + "schema_version": 3, "target": { "broad_breakable_constraint_complete": false, "row_families": [ diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-breakable-motor-scale-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-breakable-motor-scale-packet.json index cd3c33d5da13d..33e2685504765 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-breakable-motor-scale-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-breakable-motor-scale-packet.json @@ -1585,11 +1585,12 @@ "resolved_solver_identity": { "avbd_rigid_contact_config_emplaced": false, "recorded_from": "breakable motor scale benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd" }, "scene": "avbd_breakable_motor_scale", - "schema_version": 2, + "schema_version": 3, "target": { "broad_motor_lifecycle_complete": false, "row_families": [ diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md index 764230faf578a..84987f6ac94ad 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md @@ -40,198 +40,196 @@ Status values: ## Solver-Identity Relabel (PLAN-091 WP-091.1) -No `avbd-demo2d`/`avbd-demo3d` benchmark or py-demo scene in this matrix -emplaces the internal AVBD rigid-contact opt-in config -(`comps::RigidAvbdContactConfig`), because AVBD contact is not -facade-selectable, so every rigid contact in those scenes ran DART's default -sequential-impulse contact path. The native-runner timing ratios for contact -scenes are whole-pipeline `World::step` comparisons, not AVBD-contact-solver -comparisons: the pure-contact rows (2D Dynamic Friction, Static Friction, -Pyramid, Cards, Stack, and Stack Ratio; 3D Ground, Dynamic Friction, Static -Friction, Pyramid, Stack, and Stack Ratio) timed no AVBD rows at all; the -joint-plus-contact rows (2D Fracture, Soft Body, Joint Grid, and Net; 3D Soft -Body, Bridge, and Breakable) timed AVBD point-joint/motor/spring rows while -their ordinary contacts ran sequential impulse; and incidental link-link -contacts in the chain rows (2D Rod, Rope, Heavy Rope, and Hanging Rope; 3D -Rope and Heavy Rope) also ran sequential impulse. A `Complete` status below -covers only this matrix's historical local artifact contract, not an -AVBD-contact-solver or paper-parity claim. This -relabel changes no committed packet bytes and neither closes nor reopens any -PLAN-104 completion gate; new AVBD evidence packets must machine-record -`resolved_solver_identity` at AVBD packet schema version 2, enforced by -`pixi run check-avbd-packets`. +The historical `avbd-demo2d`/`avbd-demo3d` packets in this matrix neither +selected the public AVBD rigid-body family nor emplaced the compatibility-only +internal AVBD rigid-contact opt-in (`comps::RigidAvbdContactConfig`). Their +rigid contacts therefore ran DART's default sequential-impulse path, and their +native-runner timing ratios remain whole-pipeline `World::step` comparisons, +not AVBD-contact-solver comparisons. Pure-contact rows timed no AVBD rows; +joint-plus-contact rows timed AVBD point-joint/motor/spring rows while +ordinary contacts ran sequential impulse; incidental chain contacts also ran +sequential impulse. A `Complete` status below covers only this matrix's +historical local artifact contract. New packets must machine-record +`resolved_solver_identity` and `rigid_contact_selection` at AVBD packet schema +version 3, enforced by `pixi run check-avbd-packets`. + +The separate `avbd_paper_breakable_wall` surface does select the public AVBD +family and records that identity. It is a paper-shaped reconstruction, not a +retroactive solver-identity change to the source-demo rows. ## Current Narrow DART Surfaces These surfaces are useful foundations, but they do not complete the overall paper/source-demo corpus: -| DART surface | Coverage | Status | -| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------- | -| `avbd_empty_baseline` py-demo | Empty variational-integrator World smoke with source-row metadata for the 2D/3D empty rows | Partial | -| `avbd-empty-baseline-packet.json` | Visual-capture hash packet and `BM_AvbdEmptyWorldStep` benchmark JSON row for the empty baseline | Complete | -| `avbd_demo2d_ground` py-demo | Source `avbd-demo2d` Ground row port with one static slab and no dynamic bodies | Complete | -| `avbd-demo2d-ground-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo2d` Ground row | Complete | -| `avbd_demo2d_motor` py-demo | Source `avbd-demo2d` Motor row port using a pinned rigid bar and public AVBD revolute motor | Partial | -| `avbd-demo2d-motor-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo2d` Motor row | Complete | -| `avbd_demo2d_hanging_rope` py-demo | Source `avbd-demo2d` Hanging Rope row port with 50 rigid bodies and 49 linear-only point joints | Partial | -| `avbd-demo2d-hanging-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Hanging Rope row | Complete | -| `avbd_demo2d_fracture` py-demo | Source `avbd-demo2d` Fracture row port with 29 rigid bodies and 10 breakable fixed joints | Complete | -| `avbd-demo2d-fracture-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Fracture row | Complete | -| `avbd_demo2d_dynamic_friction` py-demo | Source `avbd-demo2d` Dynamic Friction row port with 11 sliding boxes over rigid ground | Complete | -| `avbd-demo2d-dynamic-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Dynamic Friction row | Complete | -| `avbd_demo2d_static_friction` py-demo | Source `avbd-demo2d` Static Friction row port with rotated boxes on an inclined rigid slab | Complete | -| `avbd-demo2d-static-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Static Friction row | Complete | -| `avbd_demo2d_pyramid` py-demo | Source `avbd-demo2d` Pyramid row port with a 210-box triangular rigid pile | Complete | -| `avbd-demo2d-pyramid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Pyramid row | Complete | -| `avbd_demo2d_cards` py-demo | Source `avbd-demo2d` Cards row port with a 40-card thin rigid contact tower | Partial | -| `avbd-demo2d-cards-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Cards row | Complete | -| `avbd_demo2d_stack` py-demo | Source `avbd-demo2d` Stack row port with twenty vertical rigid boxes over ground | Complete | -| `avbd-demo2d-stack-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack row | Complete | -| `avbd_demo2d_stack_ratio` py-demo | Source `avbd-demo2d` Stack Ratio row port with six geometric-size rigid boxes over ground | Complete | -| `avbd-demo2d-stack-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack Ratio row | Complete | -| `avbd_demo2d_rod` py-demo | Source `avbd-demo2d` Rod row port with 20 rigid links and 19 all-axis fixed joints | Partial | -| `avbd-demo2d-rod-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rod row | Complete | -| `avbd_demo2d_soft_body` py-demo | Source `avbd-demo2d` Soft Body row port with two finite-stiffness 15x5 rigid-box lattices | Partial | -| `avbd-demo2d-soft-body-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 2D Soft Body row | Complete | -| `avbd_demo2d_joint_grid` py-demo | Source `avbd-demo2d` Joint Grid row port with 625 boxes and 1200 all-axis fixed joints | Partial | -| `avbd-demo2d-joint-grid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Joint Grid row | Complete | -| `avbd_demo2d_rope` py-demo | Source `avbd-demo2d` Rope row port with 20 rigid links and 19 linear-only point joints | Partial | -| `avbd-demo2d-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rope row | Complete | -| `avbd_demo2d_heavy_rope` py-demo | Source `avbd-demo2d` Heavy Rope row port with a 30 m endpoint block on point joints | Partial | -| `avbd-demo2d-heavy-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 2D Heavy Rope row | Complete | -| `avbd_demo2d_spring` py-demo | Source `avbd-demo2d` Spring row port with one radial rigid distance spring | Partial | -| `avbd-demo2d-spring-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring row | Complete | -| `avbd_demo2d_spring_ratio` py-demo | Source `avbd-demo2d` Spring Ratio row port with seven alternating-stiffness radial springs | Partial | -| `avbd-demo2d-spring-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring Ratio row | Complete | -| `avbd_demo2d_net` py-demo | Source `avbd-demo2d` Net row port with 91 rigid bodies and 39 linear-only point joints | Partial | -| `avbd-demo2d-net-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Net row | Complete | -| `avbd_demo3d_ground` py-demo | Source `avbd-demo3d` Ground row port with a static floor, falling rigid box, and rigid contacts | Complete | -| `avbd-demo3d-ground-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo3d` Ground row | Complete | -| `avbd_demo3d_dynamic_friction` py-demo | Source `avbd-demo3d` Dynamic Friction row port with 11 sliding boxes over rigid ground | Complete | -| `avbd-demo3d-dynamic-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Dynamic Friction row | Complete | -| `avbd_demo3d_static_friction` py-demo | Source `avbd-demo3d` Static Friction row port with boxes sliding on an inclined rigid ramp | Complete | -| `avbd-demo3d-static-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Static Friction row | Complete | -| `avbd_demo3d_pyramid` py-demo | Source `avbd-demo3d` Pyramid row port with a 136-box triangular rigid pile | Complete | -| `avbd-demo3d-pyramid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Pyramid row | Complete | -| `avbd_demo3d_rope` py-demo | Source `avbd-demo3d` Rope row port with 20 rigid links and 19 linear-only point joints | Partial | -| `avbd-demo3d-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rope row | Complete | -| `avbd_demo3d_heavy_rope` py-demo | Source `avbd-demo3d` Heavy Rope row port with a 5 m endpoint block on linear-only point joints | Partial | -| `avbd-demo3d-heavy-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Heavy Rope row | Complete | -| `avbd_demo3d_spring` py-demo | Source `avbd-demo3d` Spring row port with one radial rigid distance spring | Partial | -| `avbd-demo3d-spring-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring row | Complete | -| `avbd_demo3d_spring_ratio` py-demo | Source `avbd-demo3d` Spring Ratio row port with seven alternating-stiffness radial springs | Partial | -| `avbd-demo3d-spring-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring Ratio row | Complete | -| `avbd_demo3d_stack` py-demo | Source `avbd-demo3d` Stack row port with ten vertical rigid boxes over ground | Complete | -| `avbd-demo3d-stack-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack row | Complete | -| `avbd_demo3d_stack_ratio` py-demo | Source `avbd-demo3d` Stack Ratio row port with four geometric-size rigid boxes over ground | Complete | -| `avbd-demo3d-stack-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack Ratio row | Complete | -| `avbd_demo3d_soft_body` py-demo | Source `avbd-demo3d` Soft Body row port with three finite-stiffness 4x4x4 rigid-box lattices | Partial | -| `avbd-demo3d-soft-body-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 3D Soft Body row | Complete | -| `avbd_demo3d_bridge` py-demo | Source `avbd-demo3d` Bridge row port with paired plank point joints and load boxes | Partial | -| `avbd-demo3d-bridge-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Bridge row | Complete | -| `avbd_demo3d_breakable` py-demo | Source `avbd-demo3d` Breakable row port with 19 rigid bodies, 10 breakable joints, and contacts | Complete | -| `avbd-demo3d-breakable-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo3d` Breakable row | Complete | -| `avbd_rigid_fixed_joint_contact` py-demo | Public free-rigid fixed joint rows plus ordinary rigid contact on a payload | Partial | -| `avbd_rigid_revolute_motor` py-demo | Public free-rigid revolute velocity actuator extracting to AVBD motor rows | Partial | -| `avbd-rigid-revolute-motor-packet.json` | Visual/benchmark packet for the public free-rigid revolute velocity motor row | Partial | -| `avbd_rigid_prismatic_motor` py-demo | Public free-rigid prismatic velocity actuator extracting to AVBD linear motor rows | Partial | -| `avbd-rigid-prismatic-motor-packet.json` | Visual/benchmark packet for the public free-rigid prismatic velocity motor row | Partial | -| `avbd_articulated_revolute_motor` py-demo | Public articulated revolute velocity motor command updates through the bridge | Partial | -| `avbd-articulated-revolute-motor-packet.json` | Visual/benchmark packet for the public articulated revolute velocity motor row | Partial | -| `avbd_articulated_prismatic_motor` py-demo | Public articulated prismatic velocity motor command updates through the bridge | Partial | -| `avbd-articulated-prismatic-motor-packet.json` | Visual/benchmark packet for the public articulated prismatic velocity motor row | Partial | -| `avbd_articulated_motor_breakable_joint` py-demo | Public same-multibody articulated revolute motor break/reset through the bridge | Partial | -| `avbd-articulated-breakable-motor-packet.json` | Visual/benchmark packet for the public same-multibody articulated revolute motor break/reset row | Partial | -| `avbd_articulated_prismatic_pair_motor_breakable_joint` py-demo | Public same-multibody articulated prismatic motor break/reset through the bridge | Partial | -| `avbd-articulated-prismatic-pair-breakable-motor-packet.json` | Visual/benchmark packet for the public same-multibody articulated prismatic motor break/reset row | Partial | -| `avbd_articulated_prismatic_motor_breakable_joint` py-demo | Public world-anchored articulated prismatic motor break/reset through the bridge | Partial | -| `avbd-articulated-world-prismatic-breakable-motor-packet.json` | Visual/benchmark packet for the public world-anchored articulated prismatic motor break/reset row | Partial | -| `avbd_articulated_world_revolute_motor_breakable_joint` py-demo | Public world-anchored articulated revolute motor break/reset through the bridge | Partial | -| `avbd-articulated-world-revolute-breakable-motor-packet.json` | Visual/benchmark packet for the public world-anchored articulated revolute motor break/reset row | Partial | -| `avbd-breakable-motor-scale-packet.json` | Benchmark-only scale packet for four public articulated breakable motor rows over 1/8/32 motors | Partial | -| `avbd_rigid_breakable_joint` py-demo | Public free-rigid break-force lifecycle, broken-state reset/accessors, captured-pose re-engagement, and weak re-arm breakage | Partial | -| `avbd-rigid-breakable-joint-packet.json` | Visual/benchmark packet for the public free-rigid fixed point-joint break/reset row | Partial | -| `avbd_rigid_spherical_breakable_joint` py-demo | Public free-rigid spherical point-joint break/reset and weak re-arm breakage while orientation stays free | Partial | -| `avbd-rigid-spherical-breakable-joint-packet.json` | Visual/benchmark packet for the public free-rigid spherical point-joint break/reset row | Partial | -| `avbd_articulated_breakable_joint` py-demo | Public world-link articulated fixed point-joint break/reset and weak re-arm breakage through the bridge | Partial | -| `avbd-articulated-breakable-joint-packet.json` | Visual/benchmark packet for the public articulated fixed point-joint break/reset row | Partial | -| `avbd_articulated_fixed_pair_breakable_joint` py-demo | Public same-multibody articulated fixed point-joint break/reset and weak re-arm breakage through the bridge | Partial | -| `avbd-articulated-fixed-pair-breakable-joint-packet.json` | Visual/benchmark packet for the public same-multibody articulated fixed point-joint break/reset row | Partial | -| `avbd_articulated_spherical_breakable_joint` py-demo | Public world-link articulated spherical break/reset and weak re-arm breakage while orientation stays free | Partial | -| `avbd-articulated-spherical-breakable-joint-packet.json` | Visual/benchmark packet for the public world-link articulated spherical point-joint break/reset row | Partial | -| `avbd_articulated_spherical_pair_breakable_joint` py-demo | Public same-multibody articulated spherical break/reset and weak re-arm breakage while orientation stays free | Partial | -| `avbd-articulated-spherical-pair-breakable-joint-packet.json` | Visual/benchmark packet for the public same-multibody articulated spherical point-joint break/reset row | Partial | -| `avbd-breakable-joint-scale-packet.json` | Benchmark-only scale packet for five public fixed/spherical breakable point-joint rows over 1/8/32 joints | Partial | -| `avbd_articulated_compliant_joints` py-demo | Public passive finite-stiffness world-link spherical/revolute/prismatic rows with visible constrained and free coordinates | Partial | -| `BM_AvbdArticulatedCompliantJointStep` dashboard row | Candidate-only CPU cost/scaling row over 3/12/48 passive finite articulated point joints | Partial | -| `avbd-articulated-compliant-joints-packet.json` | Mutation-sensitive correctness, allocation, visual, solver-identity, and candidate-only scale evidence for the finite rows | Partial | -| `avbd_articulated_compliant_motors` py-demo | Public bounded finite-stiffness revolute/prismatic motors between same-multibody movable-link pairs | Partial | -| `BM_AvbdArticulatedCompliantMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 bounded finite articulated motors | Partial | -| `avbd-articulated-compliant-motors-packet.json` | Mutation-sensitive motor/pair correctness, allocation, docked visual, solver-identity, and candidate-only scale evidence | Partial | -| `avbd_articulated_compliant_breakable_motor` py-demo | Finite-plus-motor physical-load fracture, strong reset, and weak re-arm lifecycle for one same-multibody prismatic pair | Partial | -| `BM_AvbdArticulatedCompliantBreakableMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 finite articulated motors with armed break thresholds | Partial | -| `avbd-articulated-compliant-fracture-packet.json` | Mutation-sensitive load/lifecycle/serialization, allocation, docked visual, solver-identity, and scale evidence | Partial | -| `avbd_articulated_high_ratio_chain` py-demo | Five-link articulated variational-chain smoke with a 200:1 heavy tip | Partial | -| `BM_AvbdArticulatedHighRatioChainStep` dashboard row | Narrow CPU dashboard row for the five-link 200:1 articulated high-ratio chain smoke | Partial | -| `avbd_paper_scale_high_ratio_chain` py-demo | 50-link articulated variational-chain smoke with a 50,000:1 heavy tip | Partial | -| `BM_AvbdPaperScaleHighRatioChainStep` dashboard row | Paper-scale CPU dashboard row for the 50-link/50,000:1 articulated high-ratio chain smoke | Partial | -| `BM_AvbdPaperScaleHighRatioChainIterationSweep` dashboard row | Paper-scale CPU dashboard sweep over 25/50/100/200 max-iteration budgets for the 50-link/50,000:1 articulated chain | Partial | -| `avbd-articulated-high-ratio-chain-packet.json` | Visual/benchmark packet for the narrow five-link 200:1 high-ratio articulated-chain smoke | Partial | -| `avbd-paper-scale-high-ratio-chain-packet.json` | Visual/benchmark packet for the 50-link/50,000:1 paper-scale articulated high-ratio chain smoke | Partial | -| `avbd-paper-scale-high-ratio-iteration-sweep-packet.json` | Benchmark/stability-plot packet for the 25/50/100/200 max-iteration paper-scale high-ratio chain sweep | Partial | -| `avbd-paper-scale-high-ratio-iteration-sweep-plot.svg` | Rendered timing/stability plot for the 25/50/100/200 max-iteration paper-scale high-ratio chain sweep | Partial | -| `variational_endpoint_loop_closure` py-demo | Public multibody link closure preview, not the private AVBD config extractor | Partial | -| `BM_AvbdRigidRevoluteMotorStep` dashboard row | Narrow CPU benchmark for the public free-rigid revolute motor path | Partial | -| `BM_AvbdRigidPrismaticMotorStep` dashboard row | Narrow CPU benchmark for the public free-rigid prismatic motor path | Partial | -| `BM_AvbdDemo2dGroundStep` row | Narrow CPU benchmark for the static `avbd-demo2d` Ground source-row port | Complete | -| `BM_AvbdDemo2dMotorStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Motor source-row port | Partial | -| `BM_AvbdDemo2dHangingRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Hanging Rope source-row port | Partial | -| `BM_AvbdDemo2dFractureStep` row | Narrow CPU benchmark for the `avbd-demo2d` Fracture source-row port | Complete | -| `BM_AvbdDemo2dDynamicFrictionStep` row | Narrow CPU benchmark for the `avbd-demo2d` Dynamic Friction source-row port | Complete | -| `BM_AvbdDemo2dFrictionCoefficientSweep` row | Benchmark sweep for the Dynamic Friction scene over maximum Coulomb friction 0/0.5/1/2.5/5 | Partial | -| `avbd-friction-coefficient-sweep-packet.json` | Validated benchmark/native-reference/visual-capture packet for the Dynamic Friction maximum-friction sweep | Partial | -| `avbd-friction-coefficient-sweep-plot.svg` | Rendered DART-vs-native CPU-time plot for the Dynamic Friction maximum-friction sweep | Partial | -| `BM_AvbdDemo2dStaticFrictionStep` row | Narrow CPU benchmark for the `avbd-demo2d` Static Friction source-row port | Complete | -| `BM_AvbdDemo2dPyramidStep` row | Narrow CPU benchmark for the `avbd-demo2d` Pyramid source-row port | Complete | -| `BM_AvbdDemo2dCardsStep` row | Narrow CPU benchmark for the `avbd-demo2d` Cards source-row port | Partial | -| `BM_AvbdDemo2dStackStep` row | Narrow CPU benchmark for the `avbd-demo2d` Stack source-row port | Complete | -| `BM_AvbdDemo2dStackRatioStep` row | Narrow CPU benchmark for the `avbd-demo2d` Stack Ratio source-row port | Complete | -| `BM_AvbdDemo2dRodStep` row | Narrow CPU benchmark for the `avbd-demo2d` Rod source-row port | Partial | -| `BM_AvbdDemo2dSoftBodyStep` row | Narrow CPU benchmark for the `avbd-demo2d` Soft Body source-row port | Partial | -| `BM_AvbdDemo2dJointGridStep` row | Narrow CPU benchmark for the `avbd-demo2d` Joint Grid source-row port | Partial | -| `BM_AvbdDemo2dRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Rope source-row port | Partial | -| `BM_AvbdDemo2dHeavyRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Heavy Rope source-row port | Partial | -| `BM_AvbdDemo3dGroundStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Ground source-row port | Complete | -| `BM_AvbdDemo3dDynamicFrictionStep` row | Narrow CPU benchmark for the `avbd-demo3d` Dynamic Friction source-row port | Complete | -| `BM_AvbdDemo3dStaticFrictionStep` row | Narrow CPU benchmark for the `avbd-demo3d` Static Friction source-row port | Complete | -| `BM_AvbdDemo3dPyramidStep` row | Narrow CPU benchmark for the `avbd-demo3d` Pyramid source-row port | Complete | -| `BM_AvbdDemo3dRopeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Rope source-row port | Partial | -| `BM_AvbdDemo3dHeavyRopeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Heavy Rope source-row port | Partial | -| `BM_AvbdDemo3dStackStep` row | Narrow CPU benchmark for the `avbd-demo3d` Stack source-row port | Complete | -| `BM_AvbdDemo3dStackRatioStep` row | Narrow CPU benchmark for the `avbd-demo3d` Stack Ratio source-row port | Complete | -| `BM_AvbdDemo3dSoftBodyStep` row | Narrow CPU benchmark for the `avbd-demo3d` Soft Body source-row port | Partial | -| `BM_AvbdDemo3dBridgeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Bridge source-row port | Partial | -| `BM_AvbdDemo3dBreakableStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Breakable source-row port | Complete | -| `BM_AvbdArticulatedRevoluteMotorStep` row | Narrow CPU benchmark for the public articulated revolute motor path | Partial | -| `BM_AvbdArticulatedPrismaticMotorStep` row | Narrow CPU benchmark for the public articulated prismatic motor path | Partial | -| `BM_AvbdArticulatedPrismaticBreakableMotorStep` row | Narrow CPU benchmark for the public articulated prismatic breakable motor path | Partial | -| `BM_AvbdArticulatedWorldPrismaticBreakableMotorStep` row | Narrow CPU benchmark for the public world-anchored articulated prismatic breakable motor path | Partial | -| `BM_AvbdArticulatedWorldRevoluteBreakableMotorStep` row | Narrow CPU benchmark for the public world-anchored articulated revolute breakable motor path | Partial | -| `BM_AvbdRigidBreakableJointStep` row | Narrow CPU benchmark for the public free-rigid breakable fixed point-joint path | Partial | -| `BM_AvbdArticulatedBreakableJointStep` row | Narrow CPU benchmark for the public articulated breakable fixed point-joint path | Partial | -| `BM_AvbdRigidSphericalBreakableJointStep` row | Narrow CPU benchmark for the public free-rigid breakable spherical point-joint path | Partial | -| `BM_AvbdArticulatedWorldSphericalBreakableJointStep` row | Narrow CPU benchmark for the public world-link articulated spherical breakable point-joint path | Partial | -| `BM_AvbdArticulatedSphericalPairBreakableJointStep` row | Narrow CPU benchmark for the public same-multibody articulated spherical breakable point-joint path | Partial | -| `BM_AvbdEmptyWorldStep` dashboard row | Narrow CPU benchmark for the empty source-demo baseline World | Partial | -| `BM_AvbdDemo2dSpringStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Spring source row using rigid distance springs | Partial | -| `BM_AvbdDemo2dSpringRatioStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Spring Ratio source row | Partial | -| `BM_AvbdDemo3dSpringStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Spring source row using rigid distance springs | Partial | -| `BM_AvbdDemo3dSpringRatioStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Spring Ratio source row | Partial | -| `AvbdRigidBlock.RigidWorldContactSnapshot*` | Private rigid feature identity tests, including local-transform shape inference | Partial | -| `AvbdRigidBlock.PointPairDistanceSpring*` | Private finite-stiffness rigid radial distance-spring primitive and serial row-driver coverage | Partial | -| `World::addRigidBodyDistanceSpring` | First free-rigid radial distance-spring API and contact-stage extraction path | Partial | -| AVBD point-joint finite material stiffness | Public C++/dartpy point-joint controls for AVBD start, linear, and angular stiffness feed finite linear/angular point rows | Partial | +| DART surface | Coverage | Status | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------- | +| `avbd_empty_baseline` py-demo | Empty variational-integrator World smoke with source-row metadata for the 2D/3D empty rows | Partial | +| `avbd-empty-baseline-packet.json` | Visual-capture hash packet and `BM_AvbdEmptyWorldStep` benchmark JSON row for the empty baseline | Complete | +| `avbd_paper_breakable_wall` py-demo | Public AVBD Figure 13 reconstruction with 252 bricks, 712 breakable attachments, three impacts, and a frame-120 outcome oracle | Partial | +| `avbd-paper-breakable-wall-packet.json` | Solver-bound numeric, allocation, assessed visual, pinned-paper, and absolute CPU benchmark evidence for the AVBD wall row | Partial | +| `avbd_demo2d_ground` py-demo | Source `avbd-demo2d` Ground row port with one static slab and no dynamic bodies | Complete | +| `avbd-demo2d-ground-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo2d` Ground row | Complete | +| `avbd_demo2d_motor` py-demo | Source `avbd-demo2d` Motor row port using a pinned rigid bar and public AVBD revolute motor | Partial | +| `avbd-demo2d-motor-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo2d` Motor row | Complete | +| `avbd_demo2d_hanging_rope` py-demo | Source `avbd-demo2d` Hanging Rope row port with 50 rigid bodies and 49 linear-only point joints | Partial | +| `avbd-demo2d-hanging-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Hanging Rope row | Complete | +| `avbd_demo2d_fracture` py-demo | Source `avbd-demo2d` Fracture row port with 29 rigid bodies and 10 breakable fixed joints | Complete | +| `avbd-demo2d-fracture-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Fracture row | Complete | +| `avbd_demo2d_dynamic_friction` py-demo | Source `avbd-demo2d` Dynamic Friction row port with 11 sliding boxes over rigid ground | Complete | +| `avbd-demo2d-dynamic-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Dynamic Friction row | Complete | +| `avbd_demo2d_static_friction` py-demo | Source `avbd-demo2d` Static Friction row port with rotated boxes on an inclined rigid slab | Complete | +| `avbd-demo2d-static-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Static Friction row | Complete | +| `avbd_demo2d_pyramid` py-demo | Source `avbd-demo2d` Pyramid row port with a 210-box triangular rigid pile | Complete | +| `avbd-demo2d-pyramid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Pyramid row | Complete | +| `avbd_demo2d_cards` py-demo | Source `avbd-demo2d` Cards row port with a 40-card thin rigid contact tower | Partial | +| `avbd-demo2d-cards-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Cards row | Complete | +| `avbd_demo2d_stack` py-demo | Source `avbd-demo2d` Stack row port with twenty vertical rigid boxes over ground | Complete | +| `avbd-demo2d-stack-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack row | Complete | +| `avbd_demo2d_stack_ratio` py-demo | Source `avbd-demo2d` Stack Ratio row port with six geometric-size rigid boxes over ground | Complete | +| `avbd-demo2d-stack-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack Ratio row | Complete | +| `avbd_demo2d_rod` py-demo | Source `avbd-demo2d` Rod row port with 20 rigid links and 19 all-axis fixed joints | Partial | +| `avbd-demo2d-rod-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rod row | Complete | +| `avbd_demo2d_soft_body` py-demo | Source `avbd-demo2d` Soft Body row port with two finite-stiffness 15x5 rigid-box lattices | Partial | +| `avbd-demo2d-soft-body-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 2D Soft Body row | Complete | +| `avbd_demo2d_joint_grid` py-demo | Source `avbd-demo2d` Joint Grid row port with 625 boxes and 1200 all-axis fixed joints | Partial | +| `avbd-demo2d-joint-grid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Joint Grid row | Complete | +| `avbd_demo2d_rope` py-demo | Source `avbd-demo2d` Rope row port with 20 rigid links and 19 linear-only point joints | Partial | +| `avbd-demo2d-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rope row | Complete | +| `avbd_demo2d_heavy_rope` py-demo | Source `avbd-demo2d` Heavy Rope row port with a 30 m endpoint block on point joints | Partial | +| `avbd-demo2d-heavy-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 2D Heavy Rope row | Complete | +| `avbd_demo2d_spring` py-demo | Source `avbd-demo2d` Spring row port with one radial rigid distance spring | Partial | +| `avbd-demo2d-spring-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring row | Complete | +| `avbd_demo2d_spring_ratio` py-demo | Source `avbd-demo2d` Spring Ratio row port with seven alternating-stiffness radial springs | Partial | +| `avbd-demo2d-spring-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring Ratio row | Complete | +| `avbd_demo2d_net` py-demo | Source `avbd-demo2d` Net row port with 91 rigid bodies and 39 linear-only point joints | Partial | +| `avbd-demo2d-net-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Net row | Complete | +| `avbd_demo3d_ground` py-demo | Source `avbd-demo3d` Ground row port with a static floor, falling rigid box, and rigid contacts | Complete | +| `avbd-demo3d-ground-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo3d` Ground row | Complete | +| `avbd_demo3d_dynamic_friction` py-demo | Source `avbd-demo3d` Dynamic Friction row port with 11 sliding boxes over rigid ground | Complete | +| `avbd-demo3d-dynamic-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Dynamic Friction row | Complete | +| `avbd_demo3d_static_friction` py-demo | Source `avbd-demo3d` Static Friction row port with boxes sliding on an inclined rigid ramp | Complete | +| `avbd-demo3d-static-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Static Friction row | Complete | +| `avbd_demo3d_pyramid` py-demo | Source `avbd-demo3d` Pyramid row port with a 136-box triangular rigid pile | Complete | +| `avbd-demo3d-pyramid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Pyramid row | Complete | +| `avbd_demo3d_rope` py-demo | Source `avbd-demo3d` Rope row port with 20 rigid links and 19 linear-only point joints | Partial | +| `avbd-demo3d-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rope row | Complete | +| `avbd_demo3d_heavy_rope` py-demo | Source `avbd-demo3d` Heavy Rope row port with a 5 m endpoint block on linear-only point joints | Partial | +| `avbd-demo3d-heavy-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Heavy Rope row | Complete | +| `avbd_demo3d_spring` py-demo | Source `avbd-demo3d` Spring row port with one radial rigid distance spring | Partial | +| `avbd-demo3d-spring-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring row | Complete | +| `avbd_demo3d_spring_ratio` py-demo | Source `avbd-demo3d` Spring Ratio row port with seven alternating-stiffness radial springs | Partial | +| `avbd-demo3d-spring-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring Ratio row | Complete | +| `avbd_demo3d_stack` py-demo | Source `avbd-demo3d` Stack row port with ten vertical rigid boxes over ground | Complete | +| `avbd-demo3d-stack-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack row | Complete | +| `avbd_demo3d_stack_ratio` py-demo | Source `avbd-demo3d` Stack Ratio row port with four geometric-size rigid boxes over ground | Complete | +| `avbd-demo3d-stack-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack Ratio row | Complete | +| `avbd_demo3d_soft_body` py-demo | Source `avbd-demo3d` Soft Body row port with three finite-stiffness 4x4x4 rigid-box lattices | Partial | +| `avbd-demo3d-soft-body-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 3D Soft Body row | Complete | +| `avbd_demo3d_bridge` py-demo | Source `avbd-demo3d` Bridge row port with paired plank point joints and load boxes | Partial | +| `avbd-demo3d-bridge-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Bridge row | Complete | +| `avbd_demo3d_breakable` py-demo | Source `avbd-demo3d` Breakable row port with 19 rigid bodies, 10 breakable joints, and contacts | Complete | +| `avbd-demo3d-breakable-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo3d` Breakable row | Complete | +| `avbd_rigid_fixed_joint_contact` py-demo | Public free-rigid fixed joint rows plus ordinary rigid contact on a payload | Partial | +| `avbd_rigid_revolute_motor` py-demo | Public free-rigid revolute velocity actuator extracting to AVBD motor rows | Partial | +| `avbd-rigid-revolute-motor-packet.json` | Visual/benchmark packet for the public free-rigid revolute velocity motor row | Partial | +| `avbd_rigid_prismatic_motor` py-demo | Public free-rigid prismatic velocity actuator extracting to AVBD linear motor rows | Partial | +| `avbd-rigid-prismatic-motor-packet.json` | Visual/benchmark packet for the public free-rigid prismatic velocity motor row | Partial | +| `avbd_articulated_revolute_motor` py-demo | Public articulated revolute velocity motor command updates through the bridge | Partial | +| `avbd-articulated-revolute-motor-packet.json` | Visual/benchmark packet for the public articulated revolute velocity motor row | Partial | +| `avbd_articulated_prismatic_motor` py-demo | Public articulated prismatic velocity motor command updates through the bridge | Partial | +| `avbd-articulated-prismatic-motor-packet.json` | Visual/benchmark packet for the public articulated prismatic velocity motor row | Partial | +| `avbd_articulated_motor_breakable_joint` py-demo | Public same-multibody articulated revolute motor break/reset through the bridge | Partial | +| `avbd-articulated-breakable-motor-packet.json` | Visual/benchmark packet for the public same-multibody articulated revolute motor break/reset row | Partial | +| `avbd_articulated_prismatic_pair_motor_breakable_joint` py-demo | Public same-multibody articulated prismatic motor break/reset through the bridge | Partial | +| `avbd-articulated-prismatic-pair-breakable-motor-packet.json` | Visual/benchmark packet for the public same-multibody articulated prismatic motor break/reset row | Partial | +| `avbd_articulated_prismatic_motor_breakable_joint` py-demo | Public world-anchored articulated prismatic motor break/reset through the bridge | Partial | +| `avbd-articulated-world-prismatic-breakable-motor-packet.json` | Visual/benchmark packet for the public world-anchored articulated prismatic motor break/reset row | Partial | +| `avbd_articulated_world_revolute_motor_breakable_joint` py-demo | Public world-anchored articulated revolute motor break/reset through the bridge | Partial | +| `avbd-articulated-world-revolute-breakable-motor-packet.json` | Visual/benchmark packet for the public world-anchored articulated revolute motor break/reset row | Partial | +| `avbd-breakable-motor-scale-packet.json` | Benchmark-only scale packet for four public articulated breakable motor rows over 1/8/32 motors | Partial | +| `avbd_rigid_breakable_joint` py-demo | Public free-rigid break-force lifecycle, broken-state reset/accessors, captured-pose re-engagement, and weak re-arm breakage | Partial | +| `avbd-rigid-breakable-joint-packet.json` | Visual/benchmark packet for the public free-rigid fixed point-joint break/reset row | Partial | +| `avbd_rigid_spherical_breakable_joint` py-demo | Public free-rigid spherical point-joint break/reset and weak re-arm breakage while orientation stays free | Partial | +| `avbd-rigid-spherical-breakable-joint-packet.json` | Visual/benchmark packet for the public free-rigid spherical point-joint break/reset row | Partial | +| `avbd_articulated_breakable_joint` py-demo | Public world-link articulated fixed point-joint break/reset and weak re-arm breakage through the bridge | Partial | +| `avbd-articulated-breakable-joint-packet.json` | Visual/benchmark packet for the public articulated fixed point-joint break/reset row | Partial | +| `avbd_articulated_fixed_pair_breakable_joint` py-demo | Public same-multibody articulated fixed point-joint break/reset and weak re-arm breakage through the bridge | Partial | +| `avbd-articulated-fixed-pair-breakable-joint-packet.json` | Visual/benchmark packet for the public same-multibody articulated fixed point-joint break/reset row | Partial | +| `avbd_articulated_spherical_breakable_joint` py-demo | Public world-link articulated spherical break/reset and weak re-arm breakage while orientation stays free | Partial | +| `avbd-articulated-spherical-breakable-joint-packet.json` | Visual/benchmark packet for the public world-link articulated spherical point-joint break/reset row | Partial | +| `avbd_articulated_spherical_pair_breakable_joint` py-demo | Public same-multibody articulated spherical break/reset and weak re-arm breakage while orientation stays free | Partial | +| `avbd-articulated-spherical-pair-breakable-joint-packet.json` | Visual/benchmark packet for the public same-multibody articulated spherical point-joint break/reset row | Partial | +| `avbd-breakable-joint-scale-packet.json` | Benchmark-only scale packet for five public fixed/spherical breakable point-joint rows over 1/8/32 joints | Partial | +| `avbd_articulated_compliant_joints` py-demo | Public passive finite-stiffness world-link spherical/revolute/prismatic rows with visible constrained and free coordinates | Partial | +| `BM_AvbdArticulatedCompliantJointStep` dashboard row | Candidate-only CPU cost/scaling row over 3/12/48 passive finite articulated point joints | Partial | +| `avbd-articulated-compliant-joints-packet.json` | Mutation-sensitive correctness, allocation, visual, solver-identity, and candidate-only scale evidence for the finite rows | Partial | +| `avbd_articulated_compliant_motors` py-demo | Public bounded finite-stiffness revolute/prismatic motors between same-multibody movable-link pairs | Partial | +| `BM_AvbdArticulatedCompliantMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 bounded finite articulated motors | Partial | +| `avbd-articulated-compliant-motors-packet.json` | Mutation-sensitive motor/pair correctness, allocation, docked visual, solver-identity, and candidate-only scale evidence | Partial | +| `avbd_articulated_compliant_breakable_motor` py-demo | Finite-plus-motor physical-load fracture, strong reset, and weak re-arm lifecycle for one same-multibody prismatic pair | Partial | +| `BM_AvbdArticulatedCompliantBreakableMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 finite articulated motors with armed break thresholds | Partial | +| `avbd-articulated-compliant-fracture-packet.json` | Mutation-sensitive load/lifecycle/serialization, allocation, docked visual, solver-identity, and scale evidence | Partial | +| `avbd_articulated_high_ratio_chain` py-demo | Five-link articulated variational-chain smoke with a 200:1 heavy tip | Partial | +| `BM_AvbdArticulatedHighRatioChainStep` dashboard row | Narrow CPU dashboard row for the five-link 200:1 articulated high-ratio chain smoke | Partial | +| `avbd_paper_scale_high_ratio_chain` py-demo | 50-link articulated variational-chain smoke with a 50,000:1 heavy tip | Partial | +| `BM_AvbdPaperScaleHighRatioChainStep` dashboard row | Paper-scale CPU dashboard row for the 50-link/50,000:1 articulated high-ratio chain smoke | Partial | +| `BM_AvbdPaperScaleHighRatioChainIterationSweep` dashboard row | Paper-scale CPU dashboard sweep over 25/50/100/200 max-iteration budgets for the 50-link/50,000:1 articulated chain | Partial | +| `avbd-articulated-high-ratio-chain-packet.json` | Visual/benchmark packet for the narrow five-link 200:1 high-ratio articulated-chain smoke | Partial | +| `avbd-paper-scale-high-ratio-chain-packet.json` | Visual/benchmark packet for the 50-link/50,000:1 paper-scale articulated high-ratio chain smoke | Partial | +| `avbd-paper-scale-high-ratio-iteration-sweep-packet.json` | Benchmark/stability-plot packet for the 25/50/100/200 max-iteration paper-scale high-ratio chain sweep | Partial | +| `avbd-paper-scale-high-ratio-iteration-sweep-plot.svg` | Rendered timing/stability plot for the 25/50/100/200 max-iteration paper-scale high-ratio chain sweep | Partial | +| `variational_endpoint_loop_closure` py-demo | Public multibody link closure preview, not the private AVBD config extractor | Partial | +| `BM_AvbdRigidRevoluteMotorStep` dashboard row | Narrow CPU benchmark for the public free-rigid revolute motor path | Partial | +| `BM_AvbdRigidPrismaticMotorStep` dashboard row | Narrow CPU benchmark for the public free-rigid prismatic motor path | Partial | +| `BM_AvbdDemo2dGroundStep` row | Narrow CPU benchmark for the static `avbd-demo2d` Ground source-row port | Complete | +| `BM_AvbdDemo2dMotorStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Motor source-row port | Partial | +| `BM_AvbdDemo2dHangingRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Hanging Rope source-row port | Partial | +| `BM_AvbdDemo2dFractureStep` row | Narrow CPU benchmark for the `avbd-demo2d` Fracture source-row port | Complete | +| `BM_AvbdDemo2dDynamicFrictionStep` row | Narrow CPU benchmark for the `avbd-demo2d` Dynamic Friction source-row port | Complete | +| `BM_AvbdDemo2dFrictionCoefficientSweep` row | Benchmark sweep for the Dynamic Friction scene over maximum Coulomb friction 0/0.5/1/2.5/5 | Partial | +| `avbd-friction-coefficient-sweep-packet.json` | Validated benchmark/native-reference/visual-capture packet for the Dynamic Friction maximum-friction sweep | Partial | +| `avbd-friction-coefficient-sweep-plot.svg` | Rendered DART-vs-native CPU-time plot for the Dynamic Friction maximum-friction sweep | Partial | +| `BM_AvbdDemo2dStaticFrictionStep` row | Narrow CPU benchmark for the `avbd-demo2d` Static Friction source-row port | Complete | +| `BM_AvbdDemo2dPyramidStep` row | Narrow CPU benchmark for the `avbd-demo2d` Pyramid source-row port | Complete | +| `BM_AvbdDemo2dCardsStep` row | Narrow CPU benchmark for the `avbd-demo2d` Cards source-row port | Partial | +| `BM_AvbdDemo2dStackStep` row | Narrow CPU benchmark for the `avbd-demo2d` Stack source-row port | Complete | +| `BM_AvbdDemo2dStackRatioStep` row | Narrow CPU benchmark for the `avbd-demo2d` Stack Ratio source-row port | Complete | +| `BM_AvbdDemo2dRodStep` row | Narrow CPU benchmark for the `avbd-demo2d` Rod source-row port | Partial | +| `BM_AvbdDemo2dSoftBodyStep` row | Narrow CPU benchmark for the `avbd-demo2d` Soft Body source-row port | Partial | +| `BM_AvbdDemo2dJointGridStep` row | Narrow CPU benchmark for the `avbd-demo2d` Joint Grid source-row port | Partial | +| `BM_AvbdDemo2dRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Rope source-row port | Partial | +| `BM_AvbdDemo2dHeavyRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Heavy Rope source-row port | Partial | +| `BM_AvbdDemo3dGroundStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Ground source-row port | Complete | +| `BM_AvbdDemo3dDynamicFrictionStep` row | Narrow CPU benchmark for the `avbd-demo3d` Dynamic Friction source-row port | Complete | +| `BM_AvbdDemo3dStaticFrictionStep` row | Narrow CPU benchmark for the `avbd-demo3d` Static Friction source-row port | Complete | +| `BM_AvbdDemo3dPyramidStep` row | Narrow CPU benchmark for the `avbd-demo3d` Pyramid source-row port | Complete | +| `BM_AvbdDemo3dRopeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Rope source-row port | Partial | +| `BM_AvbdDemo3dHeavyRopeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Heavy Rope source-row port | Partial | +| `BM_AvbdDemo3dStackStep` row | Narrow CPU benchmark for the `avbd-demo3d` Stack source-row port | Complete | +| `BM_AvbdDemo3dStackRatioStep` row | Narrow CPU benchmark for the `avbd-demo3d` Stack Ratio source-row port | Complete | +| `BM_AvbdDemo3dSoftBodyStep` row | Narrow CPU benchmark for the `avbd-demo3d` Soft Body source-row port | Partial | +| `BM_AvbdDemo3dBridgeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Bridge source-row port | Partial | +| `BM_AvbdDemo3dBreakableStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Breakable source-row port | Complete | +| `BM_AvbdArticulatedRevoluteMotorStep` row | Narrow CPU benchmark for the public articulated revolute motor path | Partial | +| `BM_AvbdArticulatedPrismaticMotorStep` row | Narrow CPU benchmark for the public articulated prismatic motor path | Partial | +| `BM_AvbdArticulatedPrismaticBreakableMotorStep` row | Narrow CPU benchmark for the public articulated prismatic breakable motor path | Partial | +| `BM_AvbdArticulatedWorldPrismaticBreakableMotorStep` row | Narrow CPU benchmark for the public world-anchored articulated prismatic breakable motor path | Partial | +| `BM_AvbdArticulatedWorldRevoluteBreakableMotorStep` row | Narrow CPU benchmark for the public world-anchored articulated revolute breakable motor path | Partial | +| `BM_AvbdRigidBreakableJointStep` row | Narrow CPU benchmark for the public free-rigid breakable fixed point-joint path | Partial | +| `BM_AvbdArticulatedBreakableJointStep` row | Narrow CPU benchmark for the public articulated breakable fixed point-joint path | Partial | +| `BM_AvbdRigidSphericalBreakableJointStep` row | Narrow CPU benchmark for the public free-rigid breakable spherical point-joint path | Partial | +| `BM_AvbdArticulatedWorldSphericalBreakableJointStep` row | Narrow CPU benchmark for the public world-link articulated spherical breakable point-joint path | Partial | +| `BM_AvbdArticulatedSphericalPairBreakableJointStep` row | Narrow CPU benchmark for the public same-multibody articulated spherical breakable point-joint path | Partial | +| `BM_AvbdEmptyWorldStep` dashboard row | Narrow CPU benchmark for the empty source-demo baseline World | Partial | +| `BM_AvbdDemo2dSpringStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Spring source row using rigid distance springs | Partial | +| `BM_AvbdDemo2dSpringRatioStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Spring Ratio source row | Partial | +| `BM_AvbdDemo3dSpringStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Spring source row using rigid distance springs | Partial | +| `BM_AvbdDemo3dSpringRatioStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Spring Ratio source row | Partial | +| `AvbdRigidBlock.RigidWorldContactSnapshot*` | Private rigid feature identity tests, including local-transform shape inference | Partial | +| `AvbdRigidBlock.PointPairDistanceSpring*` | Private finite-stiffness rigid radial distance-spring primitive and serial row-driver coverage | Partial | +| `World::addRigidBodyDistanceSpring` | First free-rigid radial distance-spring API and contact-stage extraction path | Partial | +| AVBD point-joint finite material stiffness | Public C++/dartpy point-joint controls for AVBD start, linear, and angular stiffness feed finite linear/angular point rows | Partial | ## Source Demo Corpus @@ -281,24 +279,24 @@ paper/source-demo corpus: ## Paper, Website, And Video Scenes -| Target | Required DART artifact | Current DART evidence | Status | Next implementation dependency | -| ------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------- | -| High-stiffness ratio chains | Correctness test, py-demo, and benchmark sweep | Finite-stiffness ramp rows exist narrowly, and `BM_AvbdPaperScaleHighRatioChainIterationSweep`, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json), and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) now provide a first dashboard-selected 25/50/100/200 max-iteration sweep with finite replay counters and tracked timing/stability plot evidence for the 50-link/50,000:1 chain fixture | Partial | Same-hardware paper-number comparison and GPU parity | -| Flag/pole attachment | Cloth/attachment visual plus invariant | Attachment rows exist narrowly | Partial | Unified cloth AVBD scene | -| Card tower | Visual stability scene and benchmark packet | None | Missing | Broad rigid contact and performance path | -| 50-body pendulum with 50,000:1 mass ratio | Articulated high-ratio test, py-demo, benchmark packet | `avbd_articulated_high_ratio_chain`, `BM_AvbdArticulatedHighRatioChainStep`, and [`avbd-articulated-high-ratio-chain-packet.json`](avbd-articulated-high-ratio-chain-packet.json) provide a narrow five-link 200:1 variational-chain smoke; `avbd_paper_scale_high_ratio_chain`, `PaperScaleHighRatioChainStaysFiniteAndResets`, `BM_AvbdPaperScaleHighRatioChainStep`, and [`avbd-paper-scale-high-ratio-chain-packet.json`](avbd-paper-scale-high-ratio-chain-packet.json) provide 50-link/50,000:1 finite/reset plus visual/CPU benchmark evidence without a paper-number comparison or GPU parity | Partial | Broad articulated hard constraints and high-ratio benchmark | -| Two-heavy-ball chain | Articulated chain visual and invariant | `avbd_articulated_high_ratio_chain` and its packet provide a narrow heavy-tip articulated-chain smoke | Partial | Broad articulated hard constraints | -| Box stack | Rigid stack visual and benchmark packet | Narrow source-row ports now cover Pyramid, Cards, Stack, and Stack Ratio; no paper/site/video box-stack packet exists | Partial | Broad rigid contact candidate generation | -| Friction coefficient comparison | Parameter sweep with visual/JSON output | Narrow friction switching tests exist, and `BM_AvbdDemo2dFrictionCoefficientSweep`, [`avbd-friction-coefficient-sweep-packet.json`](avbd-friction-coefficient-sweep-packet.json), and [`avbd-friction-coefficient-sweep-plot.svg`](avbd-friction-coefficient-sweep-plot.svg) now provide benchmark/plot evidence over maximum Coulomb friction values 0, 0.5, 1, 2.5, and 5 for the source-shaped Dynamic Friction scene | Partial | Source/reference sweep timing, per-coefficient visual capture, and GPU parity | -| Chain mail impact | Cloth/contact impact visual and benchmark packet | None | Missing | Unified soft/rigid AVBD coupling | -| Breakable wall | Fracture visual and benchmark packet | Narrow free-rigid and articulated breakable-joint py-demos plus physical finite-plus-motor load accounting, reset/re-arm C++ tests, and dashboard benchmark rows exist | Partial | Paper-wall geometry, four-method comparison, broad fracture corpus, and CUDA | -| Large cloth plus articulated-rigid coupling | Coupled cloth/articulated rigid visual and benchmark packet | None | Missing | Unified soft/rigid coupling and GPU path | -| `beta` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | -| `alpha` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | -| `gamma` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | -| Iteration-count sweep | Benchmark JSON and convergence/stability plot data | `BM_AvbdPaperScaleHighRatioChainIterationSweep` is selected by the dashboard runner over 25/50/100/200 max-iteration budgets for the paper-scale high-ratio chain, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json) records validated benchmark JSON plus finite replay plot data, and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) renders the timing/stability sweep; no paper-number comparison or GPU parity exists yet | Partial | Paper-number comparison and GPU parity | -| Rigid stacking/piling website/video scenes | Visual artifact and benchmark packet | None | Missing | Broad rigid contact plus performance path | -| Large block-pile/sphere-smash GPU scenes | GPU visual artifact and benchmark packet | None | Missing | GPU AVBD row parity and broad rigid contact | +| Target | Required DART artifact | Current DART evidence | Status | Next implementation dependency | +| ------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| High-stiffness ratio chains | Correctness test, py-demo, and benchmark sweep | Finite-stiffness ramp rows exist narrowly, and `BM_AvbdPaperScaleHighRatioChainIterationSweep`, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json), and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) now provide a first dashboard-selected 25/50/100/200 max-iteration sweep with finite replay counters and tracked timing/stability plot evidence for the 50-link/50,000:1 chain fixture | Partial | Same-hardware paper-number comparison and GPU parity | +| Flag/pole attachment | Cloth/attachment visual plus invariant | Attachment rows exist narrowly | Partial | Unified cloth AVBD scene | +| Card tower | Visual stability scene and benchmark packet | None | Missing | Broad rigid contact and performance path | +| 50-body pendulum with 50,000:1 mass ratio | Articulated high-ratio test, py-demo, benchmark packet | `avbd_articulated_high_ratio_chain`, `BM_AvbdArticulatedHighRatioChainStep`, and [`avbd-articulated-high-ratio-chain-packet.json`](avbd-articulated-high-ratio-chain-packet.json) provide a narrow five-link 200:1 variational-chain smoke; `avbd_paper_scale_high_ratio_chain`, `PaperScaleHighRatioChainStaysFiniteAndResets`, `BM_AvbdPaperScaleHighRatioChainStep`, and [`avbd-paper-scale-high-ratio-chain-packet.json`](avbd-paper-scale-high-ratio-chain-packet.json) provide 50-link/50,000:1 finite/reset plus visual/CPU benchmark evidence without a paper-number comparison or GPU parity | Partial | Broad articulated hard constraints and high-ratio benchmark | +| Two-heavy-ball chain | Articulated chain visual and invariant | `avbd_articulated_high_ratio_chain` and its packet provide a narrow heavy-tip articulated-chain smoke | Partial | Broad articulated hard constraints | +| Box stack | Rigid stack visual and benchmark packet | Narrow source-row ports now cover Pyramid, Cards, Stack, and Stack Ratio; no paper/site/video box-stack packet exists | Partial | Broad rigid contact candidate generation | +| Friction coefficient comparison | Parameter sweep with visual/JSON output | Narrow friction switching tests exist, and `BM_AvbdDemo2dFrictionCoefficientSweep`, [`avbd-friction-coefficient-sweep-packet.json`](avbd-friction-coefficient-sweep-packet.json), and [`avbd-friction-coefficient-sweep-plot.svg`](avbd-friction-coefficient-sweep-plot.svg) now provide benchmark/plot evidence over maximum Coulomb friction values 0, 0.5, 1, 2.5, and 5 for the source-shaped Dynamic Friction scene | Partial | Source/reference sweep timing, per-coefficient visual capture, and GPU parity | +| Chain mail impact | Cloth/contact impact visual and benchmark packet | None | Missing | Unified soft/rigid AVBD coupling | +| Breakable wall | Fracture visual and benchmark packet | `avbd_paper_breakable_wall` and [`avbd-paper-breakable-wall-packet.json`](avbd-paper-breakable-wall-packet.json) provide a public-AVBD 252-brick/712-attachment/three-impact reconstruction with deterministic localization/retention oracle, assessed impact/outcome frames, allocation gates, and absolute CPU timing | Partial | Exact unpublished source constants, source-matched four-method comparison/edit, broad fracture corpus, comparable performance, and CUDA | +| Large cloth plus articulated-rigid coupling | Coupled cloth/articulated rigid visual and benchmark packet | None | Missing | Unified soft/rigid coupling and GPU path | +| `beta` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | +| `alpha` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | +| `gamma` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | +| Iteration-count sweep | Benchmark JSON and convergence/stability plot data | `BM_AvbdPaperScaleHighRatioChainIterationSweep` is selected by the dashboard runner over 25/50/100/200 max-iteration budgets for the paper-scale high-ratio chain, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json) records validated benchmark JSON plus finite replay plot data, and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) renders the timing/stability sweep; no paper-number comparison or GPU parity exists yet | Partial | Paper-number comparison and GPU parity | +| Rigid stacking/piling website/video scenes | Visual artifact and benchmark packet | None | Missing | Broad rigid contact plus performance path | +| Large block-pile/sphere-smash GPU scenes | GPU visual artifact and benchmark packet | None | Missing | GPU AVBD row parity and broad rigid contact | ## Performance Packets diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json index 5c07db46842de..4609cefd90d61 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json @@ -1172,6 +1172,7 @@ "resolved_solver_identity": { "avbd_rigid_contact_config_emplaced": false, "recorded_from": "friction coefficient sweep benchmark scene counters", + "rigid_contact_selection": "contact_solver_method", "rigid_contact_solver": "sequential_impulse", "rigid_point_joint_solver": "none" }, @@ -1196,7 +1197,7 @@ "source_demo": "avbd-demo2d", "time_step": 0.016666666666666666 }, - "schema_version": 2, + "schema_version": 3, "target": { "complete_paper_reproduction": false, "paper_gap": "friction coefficient comparison", diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json new file mode 100644 index 0000000000000..fd0a57b23a409 --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json @@ -0,0 +1,777 @@ +{ + "benchmark": { + "benchmark": "BM_AvbdPaperBreakableWallStep", + "context": { + "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", + "host_name": "mark26", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "v1.9.5", + "mhz_per_cpu": 5300, + "num_cpus": 32 + }, + "json_sha256": "12d758d44ff8e79812b23f27fd98b227e9814d9b0da7ba5dc3339b5cc527230d", + "rows": [ + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "cpu_time": 7910242.160000001, + "impacting_balls": 3.0, + "iterations": 5, + "public_avbd_family": 1.0, + "real_time": 7910501.326696249, + "repetitions": 5, + "resolved_rigid_body_avbd": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + "resolved_rigid_contact_avbd": 1.0, + "resolved_rigid_pair_constraint_avbd": 1.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "rigid_constraint_iterations": 20.0, + "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": 712271905.0, + "scene_spec_fingerprint_lo": 3423664878.0, + "time_unit": "ns", + "trajectory_frames": 120.0 + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "cpu_time": 7739033.116666669, + "impacting_balls": 3.0, + "iterations": 5, + "public_avbd_family": 1.0, + "real_time": 7739176.074877227, + "repetitions": 5, + "resolved_rigid_body_avbd": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + "resolved_rigid_contact_avbd": 1.0, + "resolved_rigid_pair_constraint_avbd": 1.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "rigid_constraint_iterations": 20.0, + "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": 712271905.0, + "scene_spec_fingerprint_lo": 3423664878.0, + "time_unit": "ns", + "trajectory_frames": 120.0 + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "cpu_time": 577236.8155847522, + "iterations": 5, + "real_time": 577272.751319648, + "repetitions": 5, + "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "time_unit": "ns" + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "cpu_time": 0.07297334315549603, + "iterations": 5, + "real_time": 0.07297549516507583, + "repetitions": 5, + "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "time_unit": "ns" + } + ], + "scene_spec_fingerprint": "2a746821cc10faee", + "stability": { + "cpu_time_cv_fraction": 0.07297334315549603, + "real_time_cv_fraction": 0.07297549516507583, + "repetitions": 5 + }, + "timing": { + "mean_cpu_time_per_step_ns": 7910242.160000001, + "mean_real_time_per_step_ns": 7910501.326696249, + "median_cpu_time_per_step_ns": 7739033.116666669, + "median_real_time_per_step_ns": 7739176.074877227 + } + }, + "claim_boundary": "This packet covers the AVBD row of the publication-shaped Figure 13 breakable-wall outcome on the public CPU solver family. It does not claim exact source-scene replay, reproduce the Sequential Impulse, XPBD, and VBD comparison rows, or establish public CUDA AVBD parity.", + "correctness": { + "allocation_scene": "configurePublicRigidAvbdContactAndBreakableJointRowsScene", + "allocation_tests": [ + "World.BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths", + "World.BakedRigidBodyContactStepsDoNotAllocateGlobalHeap", + "World.BakedAvbdVbdRowsDoNotMallocOnHeap" + ], + "determinism_test": "test_avbd_paper_breakable_wall_outcome_is_deterministic", + "outcome_test": "test_avbd_paper_breakable_wall_matches_figure13_contract", + "public_configuration": { + "rigid_body_solver": "AVBD", + "rigid_constraint_options": { + "iterations": 20 + }, + "scene_spec_fingerprint": "2a746821cc10faee", + "time_step": 0.016666666666666666 + } + }, + "packet": "avbd_paper_breakable_wall", + "paper_reference": { + "figure": { + "file": "paper-10.png", + "height": 1760, + "sha256": "040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b", + "width": 1360 + }, + "locator": "Section 5.4 and Figure 13, PDF page 10", + "paper_pdf": { + "file": "avbd.pdf", + "sha256": "7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6" + } + }, + "performance_claim_boundary": "The benchmark records absolute DART Release-build CPU step cost and stability for this trajectory. The paper and source artifacts publish no directly comparable timing for this scene, so no reference ratio or speedup claim is made.", + "reconstruction": { + "published": { + "constraint_iterations_or_substeps": 20, + "description": "Three high-momentum balls fracture a brick wall assembled with breakable attachments.", + "paper_locator": "Section 5.4 and Figure 13, PDF page 10", + "time_step": 0.016666666666666666 + }, + "reconstructed_because_unpublished": { + "ball_mass": 100.0, + "ball_radius": 0.48, + "ball_speed": 24.0, + "break_force": 8500.0, + "brick_density": 4.0, + "brick_size": [ + 0.6, + 0.3, + 0.25 + ], + "target_coordinates_xz": [ + [ + -3.1, + 1.55 + ], + [ + 0.0, + 1.75 + ], + [ + 3.1, + 2.35 + ] + ], + "wall_layout": { + "columns": 21, + "rows": 12, + "staggered": true + } + } + }, + "reproduction": { + "benchmark_command": "pixi run bm -- bm_avbd_rigid_fixed_joint -- --benchmark_filter='^BM_AvbdPaperBreakableWallStep/iterations:120$' --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out= --benchmark_out_format=json", + "capture_commands": [ + "pixi run py-demo-capture -- --scene avbd_paper_breakable_wall --frames 60 --width 1280 --height 720 --view front --camera-distance 11 --camera-target 0,0,1.4 --capture-label impact --output-dir ", + "pixi run py-demo-capture -- --scene avbd_paper_breakable_wall --frames 120 --width 1280 --height 720 --view front --camera-distance 11 --camera-target 0,0,1.4 --capture-label outcome --output-dir " + ], + "image_verdict_commands": [ + "pixi run image-verdict -- /avbd_paper_breakable_wall_impact.png --meta scene=avbd_paper_breakable_wall --meta frame=60 --meta view=front --out /image_verdict.json", + "pixi run image-verdict -- /avbd_paper_breakable_wall_outcome.png --meta scene=avbd_paper_breakable_wall --meta frame=120 --meta view=front --out /image_verdict.json" + ] + }, + "resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": false, + "recorded_from": "engine World.resolved_configuration in both captures plus benchmark runtime resolved-configuration counters", + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "avbd", + "rigid_point_joint_solver": "avbd" + }, + "scene": "avbd_paper_breakable_wall", + "schema_version": 3, + "source_provenance": { + "algorithm": "sha256-length-prefixed-path-and-content-v1", + "digest": "0140f32ad7f646daa25294149019a8725aecd79b65144e74807dead374b4c1e3", + "files": [ + { + "path": "dart/gui/view_quality.cpp", + "sha256": "e5c5f0377e8140ba543a97f79aba21e3641677c11efd08b0723f0e8b218a6de5" + }, + { + "path": "dart/gui/view_quality.hpp", + "sha256": "4d6947b833c2fe64d22593d5e7f42a98e56e490d0d0daf6b596e60056bbee9d9" + }, + { + "path": "dart/simulation/world.cpp", + "sha256": "59a99eb21aee1784822b3b6dae6b5589ae7183370d24f705e25484c77f1c7147" + }, + { + "path": "dart/simulation/world.hpp", + "sha256": "5f3c436978f16b7dc6c8b3a52e89a3fe48c5a582be92f43993115ccc61fd8892" + }, + { + "path": "dart/simulation/world_options.hpp", + "sha256": "cc7b41f9e5a92246030b61cac336ea3bcdc43b9aba63e85826637af82f368e60" + }, + { + "path": "dart/simulation/compute/rigid_body_contact_stage.cpp", + "sha256": "a2c65b3ab955e697dfba7d051f0510c0913316bd2931370fe8e62b034b2b6177" + }, + { + "path": "dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp", + "sha256": "10c567c477222bd424d5c14ead3bc95d71dbcf539550f1276d822f5b62615e75" + }, + { + "path": "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp", + "sha256": "ff12a847e74af8bec0c06136b90dce10d8d1d388d1683209bc0dc0c32f4fa8de" + }, + { + "path": "python/dartpy/_view_quality.py", + "sha256": "20436bb24fb74a4e5fc1abd6df88c3599d8756f4fe7ea6e24ffb0901c3ce4577" + }, + { + "path": "python/examples/demos/scenes/avbd_paper_breakable_wall.py", + "sha256": "3af171d6ec13e397e797a2c6182956b03ea872d2194d92f0c5b52ff7a1438fac" + }, + { + "path": "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", + "sha256": "8846b0782f3f624babccba7be746f1689071f271cc1412880e7ed9623de2d723" + }, + { + "path": "scripts/write_avbd_paper_breakable_wall_packet.py", + "sha256": "c467d3ab290e6083a32dabdbff52c6cc7871b3a9ab643645f325f3a5819281b0" + } + ] + }, + "target": { + "complete_paper_reproduction": false, + "contract_rows": [ + "avbd.method.joints_and_attachments", + "avbd.method.fracture", + "avbd.paper.fig.13" + ], + "covered_slice": "AVBD Figure 13 publication-shaped breakable-wall row with numeric, allocation, performance, and visual evidence" + }, + "visual_evidence": { + "impact": { + "camera": { + "distance": 11.0, + "target": [ + 0.0, + 0.0, + 1.4 + ], + "view": "front" + }, + "capture": { + "height": 720, + "requested_frames": 60, + "width": 1280 + }, + "image_verdict": { + "checks": { + "contrast": { + "bright_pixels": 8938, + "dark_pixels": 39301, + "mid_pixels": 159216, + "p05": 41.231, + "p95": 183.438, + "pass": true, + "reasons": [], + "region": { + "x0": 512, + "x1": 1241, + "y0": 324, + "y1": 648 + }, + "sample_count": 236196, + "spread": 142.208, + "thresholds": { + "bright_luminance_gt": 190.0, + "dark_luminance_lt": 55.0, + "mid_luminance_max": 170.0, + "mid_luminance_min": 70.0, + "min_bright_pixels": 1180, + "min_dark_pixels": 1180, + "min_mid_pixels": 590, + "min_spread": 90.0 + } + }, + "non_blank": { + "nonzero_pixels": 921600, + "pass": true, + "reasons": [], + "total_pixels": 921600, + "unique_colors_seen": 2 + } + }, + "file": "image_verdict.json", + "machine_scope": "pixel-integrity", + "metadata": { + "frame": "60", + "scene": "avbd_paper_breakable_wall", + "view": "front" + }, + "pass": true, + "sha256": "5c3f79a686fe64ab03f100b54bf8797efda665d06a9e3a92c7fb05bd3d7f0b5a" + }, + "label": "impact", + "manifest": { + "file": "manifest.json", + "sha256": "a8c962a3ca5abcc8380339f12d51a72dee6e74dfac4f39ba13f1cd6cbff9b62a" + }, + "scene_metrics": { + "event_count": 60, + "frame": 60, + "outcome": { + "ball_positions": [ + [ + -3.245476738059697, + -8.316470486067592, + 0.18572547975873185 + ], + [ + 0.25991338471716524, + -7.831053875324325, + 0.20018292957178857 + ], + [ + 3.1293491912508444, + -8.592539704372337, + 0.16392762547215264 + ] + ], + "ball_velocities": [ + [ + 0.3636271631581067, + -12.359977834589131, + -0.8863237577953503 + ], + [ + 0.3945062146016032, + -11.042056294088507, + -0.022211005673976048 + ], + [ + 0.0858339357374227, + -13.650571792657864, + -1.672605541210307 + ] + ], + "broken_joints": 359, + "contact_count": 70, + "evaluated": false, + "frame": 60, + "impact_band_displaced_counts": [ + 3, + 9, + 4 + ], + "last_step_iterations": 20, + "max_brick_displacement": 12.754398322625777, + "outside_brick_count": 181, + "outside_retained_fraction": 0.9337016574585635, + "status": "pre-evaluation", + "threshold_checks": { + "damage_in_three_impact_bands": false, + "finite_state": true, + "fracture_activated": true, + "fracture_localized": true, + "outside_wall_retained": true, + "total_wall_retained": true + }, + "thresholds_pass": false, + "total_retained_fraction": 0.8611111111111112, + "unbroken_joints": 353, + "world_time": 1.0000000000000013 + }, + "outcome_oracle": { + "displacement_threshold": 0.5, + "evaluation_frame": 120, + "impact_band_radius": 0.85, + "maximum_broken_joints": 500, + "minimum_broken_joints": 300, + "minimum_displaced_bricks_per_impact_band": 4, + "minimum_outside_retained_fraction": 0.8, + "minimum_total_retained_fraction": 0.7, + "minimum_unbroken_joints": 200, + "outside_radius": 1.15 + }, + "resolved_configuration": [ + { + "domain": "rigid-body", + "reason": "as requested", + "requested": "avbd", + "resolved": "avbd" + }, + { + "domain": "rigid-contact", + "reason": "as requested", + "requested": "avbd", + "resolved": "avbd" + }, + { + "domain": "rigid-pair-constraint", + "reason": "as requested", + "requested": "avbd", + "resolved": "avbd" + }, + { + "domain": "rigid-constraint-iterations", + "reason": "as requested", + "requested": "20", + "resolved": "20" + }, + { + "domain": "multibody", + "reason": "as requested", + "requested": "semi-implicit", + "resolved": "semi-implicit" + }, + { + "domain": "deformable-psd", + "reason": "as requested", + "requested": "cpu", + "resolved": "cpu" + } + ], + "scene_contract": { + "ball_count": 3, + "break_force": 8500.0, + "breakable_joints": 712, + "brick_count": 252, + "collision_shapes": 256, + "executor": "World.step default", + "paper_locator": "Section 5.4 and Figure 13, PDF page 10", + "rigid_bodies": 256, + "rigid_body_solver": "AVBD", + "rigid_constraint_options": { + "iterations": 20 + }, + "solver": "public_avbd", + "time_step_ms": 16.666666666666668 + }, + "scene_spec_fingerprint": "2a746821cc10faee", + "view_report": { + "camera": { + "azimuth": -1.5707963267948966, + "distance": 11.0, + "elevation": 0.0, + "target": [ + 0.0, + 0.0, + 1.4 + ] + }, + "focus": [ + "avbd_paper_wall_brick_00_00_visual", + "avbd_paper_wall_brick_00_20_visual", + "avbd_paper_wall_brick_11_00_visual", + "avbd_paper_wall_brick_11_20_visual" + ], + "issues": [], + "metrics": { + "ambiguity_iou": 0.0, + "center_visible": true, + "corner_coverage": 1.0, + "occlusion_fraction": 0.21212121212121213, + "subject_fraction": 0.30204201619716886 + }, + "pass": true, + "schema_version": "dart.view_report/v1", + "score": 0.7157334066947348, + "size": [ + 1280, + 720 + ] + } + }, + "scene_metrics_events": { + "event_count": 60, + "file": "scene_metrics.jsonl", + "sha256": "13e84122e0b4806aabb0dde2bb777027b8c25d8d730e3c091a529e33c25b2e19" + }, + "screenshot": { + "file": "avbd_paper_breakable_wall_impact.png", + "sha256": "a3ab663cdbbd030bccddbee838e134fa3326fdec459f3a8b734fa0da044f63a3" + } + }, + "outcome": { + "camera": { + "distance": 11.0, + "target": [ + 0.0, + 0.0, + 1.4 + ], + "view": "front" + }, + "capture": { + "height": 720, + "requested_frames": 120, + "width": 1280 + }, + "image_verdict": { + "checks": { + "contrast": { + "bright_pixels": 1019, + "dark_pixels": 37181, + "mid_pixels": 176501, + "p05": 41.941, + "p95": 147.723, + "pass": false, + "reasons": [ + "bright pixels 1019 < 1180" + ], + "region": { + "x0": 512, + "x1": 1241, + "y0": 324, + "y1": 648 + }, + "sample_count": 236196, + "spread": 105.782, + "thresholds": { + "bright_luminance_gt": 190.0, + "dark_luminance_lt": 55.0, + "mid_luminance_max": 170.0, + "mid_luminance_min": 70.0, + "min_bright_pixels": 1180, + "min_dark_pixels": 1180, + "min_mid_pixels": 590, + "min_spread": 90.0 + } + }, + "non_blank": { + "nonzero_pixels": 921600, + "pass": true, + "reasons": [], + "total_pixels": 921600, + "unique_colors_seen": 2 + } + }, + "file": "image_verdict.json", + "machine_scope": "pixel-integrity", + "metadata": { + "frame": "120", + "scene": "avbd_paper_breakable_wall", + "view": "front" + }, + "pass": true, + "sha256": "88bf68c2b1dd3c1354f6946b6decc5ce4718ff8745bf06c852f080f404e5feb6" + }, + "label": "outcome", + "manifest": { + "file": "manifest.json", + "sha256": "a0fabf42f8775adc2b3b13653a372df2241ea6e4005b4cd2b8dd73537c429634" + }, + "scene_metrics": { + "event_count": 120, + "frame": 120, + "outcome": { + "ball_positions": [ + [ + -2.9391256382534756, + -21.08003336875553, + -6.375045249617675 + ], + [ + 0.5623042091274508, + -19.939284947339758, + -5.898275564310961 + ], + [ + 3.215183126988267, + -22.243111497030156, + -6.495427915738158 + ] + ], + "ball_velocities": [ + [ + 0.3063510998062213, + -12.7635628826879, + -11.384020729376422 + ], + [ + 0.2994058396764654, + -12.148325923939325, + -10.96994746883128 + ], + [ + 0.0858339357374227, + -13.650571792657864, + -11.482605541210326 + ] + ], + "broken_joints": 359, + "contact_count": 137, + "evaluated": true, + "frame": 120, + "impact_band_displaced_counts": [ + 4, + 10, + 6 + ], + "last_step_iterations": 20, + "max_brick_displacement": 32.96448949589009, + "outside_brick_count": 181, + "outside_retained_fraction": 0.9116022099447514, + "status": "pass", + "threshold_checks": { + "damage_in_three_impact_bands": true, + "finite_state": true, + "fracture_activated": true, + "fracture_localized": true, + "outside_wall_retained": true, + "total_wall_retained": true + }, + "thresholds_pass": true, + "total_retained_fraction": 0.8253968253968254, + "unbroken_joints": 353, + "world_time": 1.9999999999999978 + }, + "outcome_oracle": { + "displacement_threshold": 0.5, + "evaluation_frame": 120, + "impact_band_radius": 0.85, + "maximum_broken_joints": 500, + "minimum_broken_joints": 300, + "minimum_displaced_bricks_per_impact_band": 4, + "minimum_outside_retained_fraction": 0.8, + "minimum_total_retained_fraction": 0.7, + "minimum_unbroken_joints": 200, + "outside_radius": 1.15 + }, + "resolved_configuration": [ + { + "domain": "rigid-body", + "reason": "as requested", + "requested": "avbd", + "resolved": "avbd" + }, + { + "domain": "rigid-contact", + "reason": "as requested", + "requested": "avbd", + "resolved": "avbd" + }, + { + "domain": "rigid-pair-constraint", + "reason": "as requested", + "requested": "avbd", + "resolved": "avbd" + }, + { + "domain": "rigid-constraint-iterations", + "reason": "as requested", + "requested": "20", + "resolved": "20" + }, + { + "domain": "multibody", + "reason": "as requested", + "requested": "semi-implicit", + "resolved": "semi-implicit" + }, + { + "domain": "deformable-psd", + "reason": "as requested", + "requested": "cpu", + "resolved": "cpu" + } + ], + "scene_contract": { + "ball_count": 3, + "break_force": 8500.0, + "breakable_joints": 712, + "brick_count": 252, + "collision_shapes": 256, + "executor": "World.step default", + "paper_locator": "Section 5.4 and Figure 13, PDF page 10", + "rigid_bodies": 256, + "rigid_body_solver": "AVBD", + "rigid_constraint_options": { + "iterations": 20 + }, + "solver": "public_avbd", + "time_step_ms": 16.666666666666668 + }, + "scene_spec_fingerprint": "2a746821cc10faee", + "view_report": { + "camera": { + "azimuth": -1.5707963267948966, + "distance": 11.0, + "elevation": 0.0, + "target": [ + 0.0, + 0.0, + 1.4 + ] + }, + "focus": [ + "avbd_paper_wall_brick_00_00_visual", + "avbd_paper_wall_brick_00_20_visual", + "avbd_paper_wall_brick_11_00_visual", + "avbd_paper_wall_brick_11_20_visual" + ], + "issues": [], + "metrics": { + "ambiguity_iou": 0.0, + "center_visible": true, + "corner_coverage": 1.0, + "occlusion_fraction": 0.21212121212121213, + "subject_fraction": 0.3023708268457204 + }, + "pass": true, + "schema_version": "dart.view_report/v1", + "score": 0.7156583983313981, + "size": [ + 1280, + 720 + ] + } + }, + "scene_metrics_events": { + "event_count": 120, + "file": "scene_metrics.jsonl", + "sha256": "e91b96f7dc868f4090208d937b6b02eb6664afcae9e38665b414b578449ac3c1" + }, + "screenshot": { + "file": "avbd_paper_breakable_wall_outcome.png", + "sha256": "6587784a2ea32578d46b155e4aa3b51579c5be3ec2936e168f0b43367eb94a34" + } + }, + "semantic_review": { + "claim_and_expected_observation": "The public AVBD Figure 13 scene should show localized damage across the three impact bands while substantial wall regions outside those bands remain attached and standing.", + "file": "visual_review.json", + "inspected_images": [ + { + "file": "avbd_paper_breakable_wall_impact.png", + "role": "impact_frame_60", + "sha256": "a3ab663cdbbd030bccddbee838e134fa3326fdec459f3a8b734fa0da044f63a3" + }, + { + "file": "avbd_paper_breakable_wall_outcome.png", + "role": "outcome_frame_120", + "sha256": "6587784a2ea32578d46b155e4aa3b51579c5be3ec2936e168f0b43367eb94a34" + }, + { + "file": "paper-10.png", + "role": "paper_figure_13_reference", + "sha256": "040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b" + } + ], + "not_proven_and_limitations": "The images do not prove exact unpublished Figure 13 constants, identical fracture geometry, exact joint counts without the text oracle, other solver comparison rows, CUDA parity, reference timing parity, or a paper-speedup claim. The fixed front view is intentionally assessed for this scene and is not the paper camera.", + "reconciliation_and_verdict": "PASS: both engine ViewReports pass with no issues, the frame-60 image is consistent with a pre-evaluation impact state rather than a final three-band threshold claim, and the frame-120 numeric oracle and visible retained-wall fracture agree. The paper image supports only the publication-shaped qualitative comparison.", + "reviewer_capability": "native image input inspected at original 1280x720 detail", + "sha256": "07060971349b4d1af35fdedfb3bfae880597f5b53b4aefe3c301f59f7b4c10fe", + "text_oracle": "At frame 60 the scene is intentionally pre-evaluation: fracture is active and retention checks pass, but displaced-brick counts [3, 9, 4] leave one impact band below the final threshold. At frame 120 all finite-state, fracture, three-band damage, localization, outside-retention, and total-retention checks pass with counts [4, 10, 6], 359 broken and 353 unbroken attachments, 91.1602% outside retention, and 82.5397% total retention.", + "verdict": "pass", + "visible_observation": "The frame-60 front view shows active debris and localized loss across the left, center, and right target regions while broad side and upper wall sections remain standing. By frame 120 the debris has settled, the damaged central and lower bands remain visibly separated, and large retained wall regions are still standing. The paper reference shows the same qualitative AVBD category of fractured masonry with retained wall structure, although its camera, wall dimensions, materials, and impact timing differ." + } + } +} diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json index 25595f0368a3c..e4a1658f67f00 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json @@ -292,7 +292,7 @@ {"id": "avbd.paper.fig.10", "status": "partial", "source_locator": "Figure 10, PDF page 9: high-mass-ratio box stack", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-stack-packet.json"], "blockers": ["The narrow stack port uses sequential-impulse contacts and lacks the paper sequential-impulse/AVBD comparison."]}, {"id": "avbd.paper.fig.11", "status": "partial", "source_locator": "Figure 11, PDF page 9: friction-coefficient blocks versus sequential impulse", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json"], "blockers": ["The DART sweep does not establish AVBD-contact solver identity, paper trajectory equivalence, GPU parity, or the matched visual pair."]}, {"id": "avbd.paper.fig.12", "status": "missing", "source_locator": "Figure 12, PDF page 10: heavy ball dropped on chain mail", "evidence": [], "blockers": ["The chain-mail scene, four-method comparison, hard-contact oracle, and performance packet are missing."]}, - {"id": "avbd.paper.fig.13", "status": "partial", "source_locator": "Figure 13, PDF page 10: breakable brick wall hit by three balls", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json"], "blockers": ["The narrow breakable row is not the paper wall and lacks the four-method fracture/outcome comparison."]}, + {"id": "avbd.paper.fig.13", "status": "partial", "source_locator": "Figure 13, PDF page 10: breakable brick wall hit by three balls", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json"], "blockers": ["The DART AVBD row is a publication-shaped reconstruction because the source wall geometry, masses, impact speed, targets, and break threshold are unpublished; the matched Sequential Impulse, XPBD, and VBD comparison rows and CUDA evidence remain missing."]}, {"id": "avbd.paper.fig.14", "status": "missing", "source_locator": "Figure 14, PDF page 10: 35K rigid bodies, 72K joints, and 10K-vertex cloth", "evidence": [], "blockers": ["Unified rigid/soft coupling and the 16 ms inclusive performance row are missing."]}, {"id": "avbd.paper.fig.15", "status": "partial", "source_locator": "Figure 15, PDF page 11: beta, alpha, gamma, and iteration sweeps", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["Only a DART iteration-budget sweep exists; the exact beta/alpha/gamma trajectories and defaults are missing."]} ] @@ -351,7 +351,7 @@ {"id": "avbd.video.09_box_stack", "status": "partial", "source_locator": "02:51.567-03:02.033 sequential-impulse/AVBD box stack", "source_seconds": [171.567, 182.033], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-stack-packet.json"], "blockers": ["The current port does not run AVBD contacts and lacks the paper comparison/edit."]}, {"id": "avbd.video.10_friction", "status": "partial", "source_locator": "03:02.033-03:11.800 friction coefficient comparison", "source_seconds": [182.033, 191.8], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json"], "blockers": ["The current sweep lacks AVBD-contact solver identity and the matched source visual edit."]}, {"id": "avbd.video.11_chain_mail", "status": "missing", "source_locator": "03:11.800-03:19.200 heavy-ball chain-mail comparison", "source_seconds": [191.8, 199.2], "evidence": [], "blockers": ["No chain-mail source scene, four-method outcome, or validated DART edit exists."]}, - {"id": "avbd.video.12_breakable_wall", "status": "partial", "source_locator": "03:19.200-03:59.667 four-method breakable-wall comparison", "source_seconds": [199.2, 239.667], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json"], "blockers": ["The narrow breakable scene is not the source wall and lacks the four-way edit and fracture oracle."]}, + {"id": "avbd.video.12_breakable_wall", "status": "partial", "source_locator": "03:19.200-03:59.667 four-method breakable-wall comparison", "source_seconds": [199.2, 239.667], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json"], "blockers": ["The publication-shaped DART AVBD row has a fracture/localization oracle and assessed impact/outcome frames, but the source-matched four-method video edit, exact unpublished scene constants, CUDA evidence, and comparable performance remain missing."]}, {"id": "avbd.video.13_parameter_sweeps", "status": "partial", "source_locator": "03:59.667-04:32.633 beta, alpha, gamma, and iteration sweeps", "source_seconds": [239.667, 272.633], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["Only a non-source iteration sweep exists; beta/alpha/gamma trajectories and the full edit are missing."]}, {"id": "avbd.video.14_closing", "status": "missing", "source_locator": "04:32.633-04:39.000 closing card", "source_seconds": [272.633, 279], "evidence": [], "blockers": ["No approved DART replacement closing edit exists."]} ] diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md index 98f2881df2f8a..0f4c29d328720 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md @@ -54,26 +54,25 @@ paper-inspired row update. For this paper, "implemented" means: ## Solver-Identity Relabel (PLAN-091 WP-091.1) -No `avbd-demo2d`/`avbd-demo3d` benchmark or py-demo scene cited by this audit -emplaces the internal AVBD rigid-contact opt-in config -(`comps::RigidAvbdContactConfig`), because AVBD contact is not -facade-selectable, so every rigid contact in those scenes ran DART's default -sequential-impulse contact path. The native-runner timing ratios for contact -scenes are whole-pipeline `World::step` comparisons, not AVBD-contact-solver -comparisons: the pure-contact rows (2D Dynamic Friction, Static Friction, -Pyramid, Cards, Stack, and Stack Ratio; 3D Ground, Dynamic Friction, Static -Friction, Pyramid, Stack, and Stack Ratio) timed no AVBD rows at all; the -joint-plus-contact rows (2D Fracture, Soft Body, Joint Grid, and Net; 3D Soft -Body, Bridge, and Breakable) timed AVBD point-joint/motor/spring rows while -their ordinary contacts ran sequential impulse; and incidental link-link -contacts in the chain rows (2D Rod, Rope, Heavy Rope, and Hanging Rope; 3D -Rope and Heavy Rope) also ran sequential impulse. Faster-than-native rows in -the gap matrix and performance targets below carry this classification. This -relabel changes no committed packet bytes and neither closes nor reopens any -PLAN-104 completion gate; new AVBD evidence packets must machine-record -`resolved_solver_identity` at AVBD packet schema version 2, enforced by +The historical `avbd-demo2d`/`avbd-demo3d` benchmark and py-demo packets cited +by this audit neither selected the new public AVBD rigid-body family nor +emplaced the compatibility-only internal AVBD rigid-contact opt-in +(`comps::RigidAvbdContactConfig`). Every rigid contact in those scenes +therefore ran DART's default sequential-impulse path. Their native-runner +timing ratios remain whole-pipeline `World::step` comparisons, not +AVBD-contact-solver comparisons: pure-contact rows timed no AVBD rows; +joint-plus-contact rows timed AVBD point-joint/motor/spring rows while +ordinary contacts ran sequential impulse; and incidental chain contacts also +ran sequential impulse. Faster-than-native rows below retain this historical +classification. New packets must machine-record `resolved_solver_identity` +and `rigid_contact_selection` at AVBD packet schema version 3, enforced by `pixi run check-avbd-packets`. +The public `RigidBodySolver::Avbd` family now provides an explicit, reported +world-level AVBD contact/joint selection without the private body opt-in. Its +first paper-shaped evidence is the Figure 13 wall packet below; this addition +does not retroactively change any source-row packet identity. + ## Verified Reference Details - **Hard constraints:** Each scalar row carries finite stiffness `k` and dual @@ -474,6 +473,17 @@ exact-parent mutation, allocation gates, docked lifecycle capture, and candidate-only 2/8/32-motor benchmark close the prior load-accounting gap without claiming the paper wall, broad fracture corpus, CUDA parity, or a paper/reference performance win. +[`avbd-paper-breakable-wall-packet.json`](avbd-paper-breakable-wall-packet.json) +adds the first publication-shaped Figure 13 row through the public AVBD +world-family selector. The 252-brick, 712-attachment, three-impact scene runs +at 1/60 s with 20 contact/joint projection sweeps. A deterministic frame-120 +oracle requires damage in all three impact bands, bounded fracture, retained +outside and total wall fractions, finite state, and the exact public solver identity; +frame-60/frame-120 renders passed machine pixel-integrity and native semantic +review against the pinned paper page. The five-repeat Release benchmark is +absolute DART CPU timing only. Exact scene constants were not published, and +the Sequential Impulse, XPBD, and VBD rows, source-matched video edit, CUDA +path, broad fracture corpus, and comparable performance remain open. Note: `avbd_demo2d_net`, `BM_AvbdDemo2dNetStep`, and `avbd-demo2d-net-packet.json` now cover the source Net row shape/count, @@ -655,9 +665,13 @@ first two gaps are closed on CPU. closes the third narrowly with physical accepted finite-row and bounded-motor load aggregation, timestep-unit invariance, break/reset/re-arm and save/load oracles, exact-parent mutation evidence, allocation gates, an assessed docked -capture, and candidate-only scale data. The paper wall, broad joint/fracture -corpus, unified rows, CUDA, and comparable paper/reference performance remain -open. Non-topology +capture, and candidate-only scale data. The adjacent +[`avbd-paper-breakable-wall-packet.json`](avbd-paper-breakable-wall-packet.json) +now covers the publication-shaped AVBD Figure 13 outcome with a deterministic +fracture/localization oracle, assessed impact/outcome frames, allocation +gates, and absolute CPU timing. Exact unpublished source constants, the +four-method comparison, broad joint/fracture corpus, unified rows, CUDA, and +comparable paper/reference performance remain open. Non-topology multibody-link fixed/revolute/prismatic point-joint entities can also generate hard private configs from simulation-entry current poses, and public same-multibody/world-link articulated @@ -787,10 +801,10 @@ records validated benchmark JSON plus finite replay plot data, and renders the timing/stability sweep. No same-hardware paper-number comparison or GPU parity exists yet. DART still cannot reproduce the paper's full hard-constrained articulated chains, same-hardware numbers, or -cloth-plus-articulated-rigid coupling scenes. The next articulated mechanism -slice should broaden the now-covered finite-row load/fracture lifecycle into -the paper wall and remaining joint/attachment corpus before treating the A5 -joint-family surface as broad. +cloth-plus-articulated-rigid coupling scenes. The next fracture slice should +extend the publication-shaped AVBD wall into the source-matched four-method +comparison, CUDA, and the remaining joint/attachment corpus before treating +the A5 joint-family surface as broad. The next implementation slice should either optimize the measured `avbd_demo2d_ground`, `avbd_demo2d_motor`, `avbd_demo2d_hanging_rope`, `avbd_demo2d_rod`, diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json index c1373a8183db6..99f4a00dd4d85 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json @@ -714,6 +714,7 @@ "resolved_solver_identity": { "avbd_rigid_contact_config_emplaced": false, "recorded_from": "paper-scale high-ratio iteration benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd" }, @@ -734,7 +735,7 @@ "tip_link_mass": 50000.0, "tolerance": 1e-09 }, - "schema_version": 2, + "schema_version": 3, "target": { "complete_paper_reproduction": false, "paper_gap": "iteration-count sweep for the 50-link 50,000:1 chain", diff --git a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md index 6adef0b848988..26828eac6bae0 100644 --- a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md +++ b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md @@ -46,6 +46,12 @@ below, so neither solver family has a paper-parity claim. the row still records an explicit blocker. It is negative evidence against a complete claim, not permission to generalize from the artifact. +The public-AVBD breakable-wall packet strengthens the evidence for Figure 13 +and official-video row 12 without changing these counts: both rows remain +partial because the scene is a publication-shaped reconstruction, the exact +source constants are unpublished, and the matched four-method, CUDA, and +comparable-performance evidence is still absent. + ## Authoritative Sources The contracts pin the exact source identity. Changing a pin or silently diff --git a/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md b/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md index 631af365c0c31..6dbcb897a81c6 100644 --- a/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md +++ b/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md @@ -13,23 +13,23 @@ Status meanings: - `Open`: no complete post-bake allocation row exists yet. - `Out of scope`: excluded by PLAN-122 scope. -| Row | Status | Surface | Current evidence | Remaining task | Owner | -| ----- | ------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | -| H-001 | Closed | Harness contract | `FirstPostBakeGlobalHeapGateFailsOnInjectedAllocation` covers injected post-bake allocation rejection, `scripts/check_plan122_allocation_matrix.py` validates closed-row test citations, and `docs/onboarding/testing.md` names the first-post-bake and steady-state raw helpers. | None for the harness scaffold; continue closing domain rows with first-post-bake world-base/global/raw evidence. | WP-122.1 | -| R-001 | Closed | Default rigid-body and articulated contact through DART 7 `World::step()` | `BakedRigidBodyContactStepsDoNotAllocateGlobalHeap`, `BakedArticulatedContactStepsDoNotAllocateGlobalHeap`, `BakedDefaultRigidAndArticulatedContactsDoNotGrowWorldBaseAllocator`, and `BakedDefaultRigidAndArticulatedContactsDoNotMallocOnHeap`. | None for the default rigid/articulated contact row. | WP-122.1, WP-122.4, PLAN-080 | -| R-002 | Closed | Boxed-LCP fallback contact | `BakedBoxedLcpFallbackContactsDoNotGrowWorldBaseAllocator`, `BakedBoxedLcpContactStepUsesFrameScratchWithoutBaseGrowth`, `BakedBoxedLcpFallbackContactStepsDoNotAllocateGlobalHeap`, and `BakedBoxedLcpFallbackContactStepsDoNotMallocOnHeap` cover single-island, different-DOF, and multi-island first-post-bake paths. | None for the boxed-LCP fallback contact row. | WP-122.1, PLAN-080 | -| R-003 | Closed | Rigid IPC representative dynamic and kinematic scenes | `BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`, `BakedDynamicRigidIpcStepsDoNotAllocateGlobalHeap`, `BakedKinematicIpcStepsDoNotAllocateGlobalHeap`, `BakedActiveRigidIpcBarrierStepsDoNotMallocOnHeap`, `BakedRigidIpcFixedJointStepsDoNotMallocOnHeap`, `BakedRigidIpcRevoluteJointStepsDoNotMallocOnHeap`, `BakedRigidIpcTwoBoxStackStepsDoNotMallocOnHeap`, `BakedRigidIpcDeformableSurfaceObstacleStepsDoNotMallocOnHeap`, `BakedRigidIpcKinematicConveyorStepsDoNotMallocOnHeap`, and `BakedRigidIpcKinematicTurntableStepsDoNotMallocOnHeap`. | None for the representative dynamic and kinematic rigid IPC row; large equality KKT systems stay tracked by R-004. | WP-122.2, PLAN-082 | -| R-004 | Closed | Large rigid IPC equality KKT systems | `BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`, `BakedDynamicRigidIpcStepsDoNotAllocateGlobalHeap`, and `BakedRigidIpcLargeEqualityKktStepsDoNotMallocOnHeap` cover a fixed-joint rigid IPC chain whose equality KKT system exceeds the stack solve cap and uses retained dynamic KKT storage after bake. | None for the large rigid IPC equality KKT row. | WP-122.2, PLAN-082, PLAN-083 | -| M-001 | Closed | Semi-implicit multibody velocity and force paths | `BakedVelocityActuatedMultibodyStepsDoNotMallocOnHeap` covers first-post-bake raw malloc for k == 1, k >= 2, and velocity-actuated articulated contact scenes; global-heap gates cover semi-implicit force scratch. | None for the semi-implicit multibody velocity/force row. | WP-122.4, PLAN-080 | -| M-002 | Closed | Variational multibody integration | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers representative variational scratch, compliant contact, dual state, loop closure, and long articulated point-joint scenes; `BakedVariationalMultibodyStepsDoNotMallocOnHeap` covers the same scenes for first-post-bake raw malloc. | None for currently promoted variational rows; future PLAN-084 rows require world-base/global/raw post-bake gates before promotion. | WP-122.4, PLAN-084 | -| M-003 | Closed | AVBD/VBD rows selected inside DART 7 `World::step()` | World-base/global-heap gates cover representative rigid AVBD rows, deformable AVBD rows including the Section 3.5 finite-stiffness spring path, and a VBD Chebyshev self-contact row; `BakedAvbdVbdRowsDoNotMallocOnHeap` covers the same representative rows for first-post-bake raw malloc. `WarmedParallelAvbdDualUpdatePassIsAllocationStable` extends the contract to a dispatch-sized Section 4 AVBD self-contact pass and proves warmed world-base stability plus no global-`new` or raw-malloc cost above the empty-graph floor. | None for currently promoted AVBD/VBD rows; extend with PLAN-104 corpus rows as they are promoted. | WP-122.4, PLAN-104 | -| M-004 | Open | Unified Newton-barrier multibody rows | Open. PLAN-083 owns the solver direction. | Require world-base/global/raw post-bake gates before any unified Newton-barrier row is promoted into `World::step()`. | WP-122.4, PLAN-083 | -| D-001 | Closed | Basic deformable projected-Newton and storage paths | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers particles, springs, FEM, self-contact, static obstacle, CCD, mixed storage, and production-grid global-heap rows; `BakedBasicDeformableRowsDoNotMallocOnHeap` covers dense direct, iterative sparse CG, matrix-free production, obstacle/friction, CCD, and mixed storage rows for first-post-bake raw malloc. | None for current dense direct, iterative sparse CG, matrix-free, obstacle/friction, CCD, and mixed storage rows. | WP-122.3, PLAN-081 | -| D-002 | Closed | Scripted deformable boundary | World-base/global gates and `BakedScriptedDeformableBoundaryStepsDoNotMallocOnHeap` cover the first post-bake scripted boundary path; bake primes the retained dense projected-Newton factorization scratch before the measured loop. | None for the scripted deformable boundary row. | WP-122.1, WP-122.3 | -| D-003 | Closed | 81-DOF FEM ground-friction block | `BakedDeformableFemGroundFrictionBlockStepsDoNotMallocOnHeap` covers first-post-bake raw malloc for the retained dense projected-Newton solve cutoff. | None for the retained dense 81-DOF FEM ground-friction row. | WP-122.3, PLAN-081 | -| D-004 | Closed | Deformable sparse-direct systems above retained dense cutoff | `BakedDeformableSparseIterativeStepsDoNotGrowWorldBaseAllocator`, `BakedDeformableSparseIterativeStepsDoNotAllocateGlobalHeap`, and `BakedDeformableSparseIterativeStepsDoNotMallocOnHeap` cover the default above-dense-cap FEM ground-friction path after bake. `DeformableDefaultSparseIterativeFemGroundFrictionBlockIsActive` verifies the same default path takes sparse CG and no sparse numeric factorization. | None for currently selectable above-dense deformable systems; default routing uses sparse iterative solves instead of Eigen sparse-direct numeric factorization. | WP-122.3, PLAN-081 | -| F-001 | Closed | Differentiable contact-free stepping | `BakedDifferentiableContactFreeStepsDoNotGrowWorldBaseAllocator`, `BakedDifferentiableContactFreeStepsDoNotAllocateGlobalHeap`, and `BakedDifferentiableContactFreeStepsDoNotMallocOnHeap` cover first-post-bake derivative scratch reuse when DART_BUILD_DIFF is enabled; `DifferentiableMultibodyTorqueScratchUsesWorldAllocator` also verifies bake-time scratch sizing does not publish a derivative snapshot before `step()`. | None for contact-free differentiable stepping; contact derivative and trajectory-capture rows stay tracked by F-002 until promoted. | WP-122.5, PLAN-110 | -| F-002 | Open | Differentiable contact derivatives and trajectory snapshots | Open; PLAN-110 owns promotion. | Add allocation gates when contact derivatives, reverse snapshots, or trajectory capture enter the DART 7 step loop. | WP-122.5, PLAN-110 | -| L-001 | Closed | DART 7 model-loading bridges into `World::step()` | `BakedImportedSkeletonStepsDoNotGrowWorldBaseAllocator`, `BakedImportedSkeletonStepsDoNotAllocateGlobalHeap`, and `BakedImportedSkeletonStepsDoNotMallocOnHeap` cover the first post-bake steps of a model imported through the legacy `dynamics::Skeleton` → DART 7 World bridge (`dart::simulation::io::addSkeleton`); the source model is built and translated during scene configuration, before bake, so import lies outside the measured window. | None for the `addSkeleton` model-loading bridge row; extend with imported contact-bearing scenes and additional loader front ends (URDF/SDF/MJCF/USD) as they promote new scenes into `World::step()`. | WP-122.6, PLAN-041, PLAN-042, PLAN-080 | -| G-001 | Open | Accelerator-backed DART 7 stages | Host allocation rules exist; device allocation semantics are plan-owned by compute/GPU docs. | Before a GPU stage is promoted into `World::step()`, add host gates plus backend-specific no-device-allocation evidence after bake. | WP-122.7, PLAN-030, PLAN-031 | -| X-001 | Out of scope | Classic DART 6 `Skeleton` and legacy constraint solver step paths | Out of scope by maintainer direction in this plan. | None, unless the path is migrated into DART 7 `World::step()`. | Out of scope | +| Row | Status | Surface | Current evidence | Remaining task | Owner | +| ----- | ------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | +| H-001 | Closed | Harness contract | `FirstPostBakeGlobalHeapGateFailsOnInjectedAllocation` covers injected post-bake allocation rejection, `scripts/check_plan122_allocation_matrix.py` validates closed-row test citations, and `docs/onboarding/testing.md` names the first-post-bake and steady-state raw helpers. | None for the harness scaffold; continue closing domain rows with first-post-bake world-base/global/raw evidence. | WP-122.1 | +| R-001 | Closed | Default rigid-body and articulated contact through DART 7 `World::step()` | `BakedRigidBodyContactStepsDoNotAllocateGlobalHeap`, `BakedArticulatedContactStepsDoNotAllocateGlobalHeap`, `BakedDefaultRigidAndArticulatedContactsDoNotGrowWorldBaseAllocator`, and `BakedDefaultRigidAndArticulatedContactsDoNotMallocOnHeap`. | None for the default rigid/articulated contact row. | WP-122.1, WP-122.4, PLAN-080 | +| R-002 | Closed | Boxed-LCP fallback contact | `BakedBoxedLcpFallbackContactsDoNotGrowWorldBaseAllocator`, `BakedBoxedLcpContactStepUsesFrameScratchWithoutBaseGrowth`, `BakedBoxedLcpFallbackContactStepsDoNotAllocateGlobalHeap`, and `BakedBoxedLcpFallbackContactStepsDoNotMallocOnHeap` cover single-island, different-DOF, and multi-island first-post-bake paths. | None for the boxed-LCP fallback contact row. | WP-122.1, PLAN-080 | +| R-003 | Closed | Rigid IPC representative dynamic and kinematic scenes | `BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`, `BakedDynamicRigidIpcStepsDoNotAllocateGlobalHeap`, `BakedKinematicIpcStepsDoNotAllocateGlobalHeap`, `BakedActiveRigidIpcBarrierStepsDoNotMallocOnHeap`, `BakedRigidIpcFixedJointStepsDoNotMallocOnHeap`, `BakedRigidIpcRevoluteJointStepsDoNotMallocOnHeap`, `BakedRigidIpcTwoBoxStackStepsDoNotMallocOnHeap`, `BakedRigidIpcDeformableSurfaceObstacleStepsDoNotMallocOnHeap`, `BakedRigidIpcKinematicConveyorStepsDoNotMallocOnHeap`, and `BakedRigidIpcKinematicTurntableStepsDoNotMallocOnHeap`. | None for the representative dynamic and kinematic rigid IPC row; large equality KKT systems stay tracked by R-004. | WP-122.2, PLAN-082 | +| R-004 | Closed | Large rigid IPC equality KKT systems | `BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`, `BakedDynamicRigidIpcStepsDoNotAllocateGlobalHeap`, and `BakedRigidIpcLargeEqualityKktStepsDoNotMallocOnHeap` cover a fixed-joint rigid IPC chain whose equality KKT system exceeds the stack solve cap and uses retained dynamic KKT storage after bake. | None for the large rigid IPC equality KKT row. | WP-122.2, PLAN-082, PLAN-083 | +| M-001 | Closed | Semi-implicit multibody velocity and force paths | `BakedVelocityActuatedMultibodyStepsDoNotMallocOnHeap` covers first-post-bake raw malloc for k == 1, k >= 2, and velocity-actuated articulated contact scenes; global-heap gates cover semi-implicit force scratch. | None for the semi-implicit multibody velocity/force row. | WP-122.4, PLAN-080 | +| M-002 | Closed | Variational multibody integration | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers representative variational scratch, compliant contact, dual state, loop closure, and long articulated point-joint scenes; `BakedVariationalMultibodyStepsDoNotMallocOnHeap` covers the same scenes for first-post-bake raw malloc. | None for currently promoted variational rows; future PLAN-084 rows require world-base/global/raw post-bake gates before promotion. | WP-122.4, PLAN-084 | +| M-003 | Closed | AVBD/VBD rows selected inside DART 7 `World::step()` | World-base/global-heap gates cover representative rigid AVBD rows, deformable AVBD rows including the Section 3.5 finite-stiffness spring path, and a VBD Chebyshev self-contact row; `BakedAvbdVbdRowsDoNotMallocOnHeap` covers the same representative rows for first-post-bake raw malloc. `configurePublicRigidAvbdContactAndBreakableJointRowsScene` is included in `BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths`, `BakedRigidBodyContactStepsDoNotAllocateGlobalHeap`, and `BakedAvbdVbdRowsDoNotMallocOnHeap`, proving the public AVBD family with active contact plus a breakable fixed-joint row. `WarmedParallelAvbdDualUpdatePassIsAllocationStable` extends the contract to a dispatch-sized Section 4 AVBD self-contact pass. | None for currently promoted AVBD/VBD rows; extend with PLAN-104 corpus rows as they are promoted. | WP-122.4, PLAN-104 | +| M-004 | Open | Unified Newton-barrier multibody rows | Open. PLAN-083 owns the solver direction. | Require world-base/global/raw post-bake gates before any unified Newton-barrier row is promoted into `World::step()`. | WP-122.4, PLAN-083 | +| D-001 | Closed | Basic deformable projected-Newton and storage paths | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers particles, springs, FEM, self-contact, static obstacle, CCD, mixed storage, and production-grid global-heap rows; `BakedBasicDeformableRowsDoNotMallocOnHeap` covers dense direct, iterative sparse CG, matrix-free production, obstacle/friction, CCD, and mixed storage rows for first-post-bake raw malloc. | None for current dense direct, iterative sparse CG, matrix-free, obstacle/friction, CCD, and mixed storage rows. | WP-122.3, PLAN-081 | +| D-002 | Closed | Scripted deformable boundary | World-base/global gates and `BakedScriptedDeformableBoundaryStepsDoNotMallocOnHeap` cover the first post-bake scripted boundary path; bake primes the retained dense projected-Newton factorization scratch before the measured loop. | None for the scripted deformable boundary row. | WP-122.1, WP-122.3 | +| D-003 | Closed | 81-DOF FEM ground-friction block | `BakedDeformableFemGroundFrictionBlockStepsDoNotMallocOnHeap` covers first-post-bake raw malloc for the retained dense projected-Newton solve cutoff. | None for the retained dense 81-DOF FEM ground-friction row. | WP-122.3, PLAN-081 | +| D-004 | Closed | Deformable sparse-direct systems above retained dense cutoff | `BakedDeformableSparseIterativeStepsDoNotGrowWorldBaseAllocator`, `BakedDeformableSparseIterativeStepsDoNotAllocateGlobalHeap`, and `BakedDeformableSparseIterativeStepsDoNotMallocOnHeap` cover the default above-dense-cap FEM ground-friction path after bake. `DeformableDefaultSparseIterativeFemGroundFrictionBlockIsActive` verifies the same default path takes sparse CG and no sparse numeric factorization. | None for currently selectable above-dense deformable systems; default routing uses sparse iterative solves instead of Eigen sparse-direct numeric factorization. | WP-122.3, PLAN-081 | +| F-001 | Closed | Differentiable contact-free stepping | `BakedDifferentiableContactFreeStepsDoNotGrowWorldBaseAllocator`, `BakedDifferentiableContactFreeStepsDoNotAllocateGlobalHeap`, and `BakedDifferentiableContactFreeStepsDoNotMallocOnHeap` cover first-post-bake derivative scratch reuse when DART_BUILD_DIFF is enabled; `DifferentiableMultibodyTorqueScratchUsesWorldAllocator` also verifies bake-time scratch sizing does not publish a derivative snapshot before `step()`. | None for contact-free differentiable stepping; contact derivative and trajectory-capture rows stay tracked by F-002 until promoted. | WP-122.5, PLAN-110 | +| F-002 | Open | Differentiable contact derivatives and trajectory snapshots | Open; PLAN-110 owns promotion. | Add allocation gates when contact derivatives, reverse snapshots, or trajectory capture enter the DART 7 step loop. | WP-122.5, PLAN-110 | +| L-001 | Closed | DART 7 model-loading bridges into `World::step()` | `BakedImportedSkeletonStepsDoNotGrowWorldBaseAllocator`, `BakedImportedSkeletonStepsDoNotAllocateGlobalHeap`, and `BakedImportedSkeletonStepsDoNotMallocOnHeap` cover the first post-bake steps of a model imported through the legacy `dynamics::Skeleton` → DART 7 World bridge (`dart::simulation::io::addSkeleton`); the source model is built and translated during scene configuration, before bake, so import lies outside the measured window. | None for the `addSkeleton` model-loading bridge row; extend with imported contact-bearing scenes and additional loader front ends (URDF/SDF/MJCF/USD) as they promote new scenes into `World::step()`. | WP-122.6, PLAN-041, PLAN-042, PLAN-080 | +| G-001 | Open | Accelerator-backed DART 7 stages | Host allocation rules exist; device allocation semantics are plan-owned by compute/GPU docs. | Before a GPU stage is promoted into `World::step()`, add host gates plus backend-specific no-device-allocation evidence after bake. | WP-122.7, PLAN-030, PLAN-031 | +| X-001 | Out of scope | Classic DART 6 `Skeleton` and legacy constraint solver step paths | Out of scope by maintainer direction in this plan. | None, unless the path is migrated into DART 7 `World::step()`. | Out of scope | diff --git a/docs/readthedocs/architecture.md b/docs/readthedocs/architecture.md index 3c9a2b5e312eb..bef4c0201e557 100644 --- a/docs/readthedocs/architecture.md +++ b/docs/readthedocs/architecture.md @@ -185,6 +185,7 @@ same shape is usable across domains and `World::add*` stays uniform. | ---------- | ----------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Rigid | Sequential-impulse (default) | ✅ available | `WorldOptions::rigidBodySolver` or `World::setRigidBodySolver(...)`; tested by [`test_world_default_step_golden.cpp`](https://github.com/dartsim/dart/blob/main/tests/unit/simulation/world/test_world_default_step_golden.cpp) | | Rigid | IPC (incremental potential contact) | 🧪 experimental | `WorldOptions::rigidBodySolver` or `World::setRigidBodySolver(...)` | +| Rigid | AVBD contact and public pair constraints | 🧪 experimental | Selected by `WorldOptions::rigidBodySolver` or `World::setRigidBodySolver(...)`; `RigidConstraintOptions` tunes the active split rigid stage but does not select the family | | Rigid | Contact normal/friction: sequential-impulse / boxed-LCP | 🧪 experimental | `WorldOptions::contactSolverMethod` | | Rigid | Differentiable gradient: analytic / complementarity-aware / pre-contact surrogate | 🧪 experimental | `WorldOptions::differentiable`, `WorldOptions::contactGradientMode`, setter | | Multibody | Semi-implicit joint-space integration (default) | ✅ available | `WorldOptions::multibodyOptions` or `World::setMultibodyOptions(...)`; tested by [`test_world_contact_parity.cpp`](https://github.com/dartsim/dart/blob/main/tests/unit/simulation/world/test_world_contact_parity.cpp) | @@ -199,6 +200,9 @@ differentiable LCP under the differentiable rigid family) so they share a domain, state adapter, contact representation, benchmark schema, and capability vocabulary instead of forming isolated stacks. Solvers, presets, and examples use method/approach/paper or DART-owned names — never other engines' names. +The rigid iteration tuning is not applicable when IPC or mixed semi-implicit +multibody routing bypasses the split rigid constraint stage; those +configurations accept only its default options. ### Cross-domain coupling diff --git a/python/dartpy/_view_quality.py b/python/dartpy/_view_quality.py index 8e08afa6be8cf..470b749de2853 100644 --- a/python/dartpy/_view_quality.py +++ b/python/dartpy/_view_quality.py @@ -135,7 +135,9 @@ def assess_view( Resolves the focus names to descriptor ids, delegates the projection, occlusion, and ambiguity geometry to the core ``gui.assess_view_quality``, - and adapts the result into the public :class:`ViewReport`. + and adapts the result into the public :class:`ViewReport`. An explicit + focus scopes framing and ambiguity to the named subject while other + renderables remain eligible occluders. """ from . import _world_render_bridge diff --git a/python/dartpy/simulation/module_compute.cpp b/python/dartpy/simulation/module_compute.cpp index ddffa3d2f0729..a089306965fac 100644 --- a/python/dartpy/simulation/module_compute.cpp +++ b/python/dartpy/simulation/module_compute.cpp @@ -301,6 +301,44 @@ void defSimPartCompute(nb::module_& m) .def("__repr__", &sim::compute::WorldStepProfile::toSummaryText) .def("__str__", &sim::compute::WorldStepProfile::toSummaryText); + nb::class_( + m, "ResolvedConfigurationNote") + .def_ro( + "domain", + &sim::compute::ResolvedConfigurationNote::domain, + "Domain governed by this solver-resolution decision.") + .def_ro( + "requested", + &sim::compute::ResolvedConfigurationNote::requested, + "Requested method family or policy value.") + .def_ro( + "resolved", + &sim::compute::ResolvedConfigurationNote::resolved, + "Method family or policy value actually used.") + .def_ro( + "reason", + &sim::compute::ResolvedConfigurationNote::reason, + "Reason for the resolution.") + .def( + "is_substitution", + &sim::compute::ResolvedConfigurationNote::isSubstitution); + + nb::class_( + m, "ResolvedSolverConfiguration") + .def_ro( + "notes", + &sim::compute::ResolvedSolverConfiguration::notes, + "Per-domain requested-to-resolved decisions.") + .def("is_empty", &sim::compute::ResolvedSolverConfiguration::isEmpty) + .def( + "has_substitution", + &sim::compute::ResolvedSolverConfiguration::hasSubstitution) + .def("summary", &sim::compute::ResolvedSolverConfiguration::toSummaryText) + .def( + "__repr__", &sim::compute::ResolvedSolverConfiguration::toSummaryText) + .def( + "__str__", &sim::compute::ResolvedSolverConfiguration::toSummaryText); + nb::class_(m, "StepMetrics") .def_ro( "kinetic_energy", diff --git a/python/dartpy/simulation/module_core.cpp b/python/dartpy/simulation/module_core.cpp index 1ac2ef33b662f..91dd71db83cc6 100644 --- a/python/dartpy/simulation/module_core.cpp +++ b/python/dartpy/simulation/module_core.cpp @@ -71,7 +71,8 @@ void defSimPartCore(nb::module_& m) nb::enum_(m, "RigidBodySolver") .value("SEQUENTIAL_IMPULSE", sim::RigidBodySolver::SequentialImpulse) - .value("IPC", sim::RigidBodySolver::Ipc); + .value("IPC", sim::RigidBodySolver::Ipc) + .value("AVBD", sim::RigidBodySolver::Avbd); nb::enum_(m, "ContactSolverMethod") .value("SEQUENTIAL_IMPULSE", sim::ContactSolverMethod::SequentialImpulse) .value("BOXED_LCP", sim::ContactSolverMethod::BoxedLcp); diff --git a/python/dartpy/simulation/module_frames.cpp b/python/dartpy/simulation/module_frames.cpp index e4f66318b32f1..075ede955e3a1 100644 --- a/python/dartpy/simulation/module_frames.cpp +++ b/python/dartpy/simulation/module_frames.cpp @@ -440,6 +440,19 @@ void defSimPartFrames(nb::module_& m) return format_repr("LoopClosureRuntimePolicy", fields); }); + nb::class_(m, "RigidConstraintOptions") + .def( + nb::new_([](std::size_t iterations) { + return sim::RigidConstraintOptions{.iterations = iterations}; + }), + nb::arg("iterations") = 8) + .def_rw("iterations", &sim::RigidConstraintOptions::iterations) + .def("__repr__", [](const sim::RigidConstraintOptions& self) { + return format_repr( + "RigidConstraintOptions", + {{"iterations", std::to_string(self.iterations)}}); + }); + nb::class_(m, "MultibodyOptions") .def( nb::new_([](sim::MultibodyIntegrationFamily integrationFamily, diff --git a/python/dartpy/simulation/module_world.cpp b/python/dartpy/simulation/module_world.cpp index 6fa938ab8f48b..de867006f9a90 100644 --- a/python/dartpy/simulation/module_world.cpp +++ b/python/dartpy/simulation/module_world.cpp @@ -45,6 +45,7 @@ void defSimPartWorld(nb::module_& m) const nb::handle& gravity, bool differentiable, sim::RigidBodySolver rigidBodySolver, + const sim::RigidConstraintOptions& rigidConstraintOptions, const sim::MultibodyOptions& multibodyOptions, sim::ContactSolverMethod contactSolverMethod, sim::ContactGradientMode contactGradientMode, @@ -57,6 +58,7 @@ void defSimPartWorld(nb::module_& m) } options.differentiable = differentiable; options.rigidBodySolver = rigidBodySolver; + options.rigidConstraintOptions = rigidConstraintOptions; options.multibodyOptions = multibodyOptions; options.contactSolverMethod = contactSolverMethod; options.contactGradientMode = contactGradientMode; @@ -70,6 +72,7 @@ void defSimPartWorld(nb::module_& m) nb::arg("differentiable") = false, nb::arg("rigid_body_solver") = sim::RigidBodySolver::SequentialImpulse, + nb::arg("rigid_constraint_options") = sim::RigidConstraintOptions{}, nb::arg("multibody_options") = sim::MultibodyOptions{}, nb::arg("contact_solver_method") = sim::ContactSolverMethod::SequentialImpulse, @@ -562,6 +565,12 @@ void defSimPartWorld(nb::module_& m) "Per-stage wall-clock profile of the most recent step taken while " "step profiling was enabled (empty otherwise). Call .summary() for a " "compact text breakdown of where the step spent its time.") + .def_prop_ro( + "resolved_configuration", + &sim::World::getResolvedConfiguration, + nb::rv_policy::reference_internal, + "Solver families, pair-constraint method, and iteration policy " + "actually resolved by the built-in World step.") .def( "compute_step_metrics", &sim::World::computeStepMetrics, @@ -579,6 +588,14 @@ void defSimPartWorld(nb::module_& m) "rigid_body_solver", &sim::World::getRigidBodySolver, &sim::World::setRigidBodySolver) + .def_prop_rw( + "rigid_constraint_options", + [](const sim::World& self) { + return self.getRigidConstraintOptions(); + }, + [](sim::World& self, const sim::RigidConstraintOptions& options) { + self.setRigidConstraintOptions(options); + }) .def_prop_rw( "multibody_options", &sim::World::getMultibodyOptions, diff --git a/python/examples/demos/README.md b/python/examples/demos/README.md index aae86dfdedb80..20e9b0e8c1301 100644 --- a/python/examples/demos/README.md +++ b/python/examples/demos/README.md @@ -1400,6 +1400,7 @@ constraint/contact/motor signal instead of relying on screenshots alone. | `avbd_demo3d_soft_body` | The `avbd-demo3d` Soft Body source scene as three finite-stiffness box lattices | Source-demo 3D soft-body row with finite all-axis joint stiffness and metadata | | `avbd_demo3d_bridge` | The `avbd-demo3d` Bridge source scene with paired point-jointed planks and load boxes | Source-demo bridge row with matched anchors, geometry, and metadata | | `avbd_demo3d_breakable` | The `avbd-demo3d` Breakable source scene as rigid boxes, contact shapes, and weak joints | Source-demo breakable fixed-joint row with public AVBD break thresholds | +| `avbd_paper_breakable_wall` | A publication-shaped Figure 13 brick wall hit simultaneously by three heavy balls | Public AVBD contacts, 20 sweeps, and a quantitative retained-wall outcome oracle | | `avbd_rigid_fixed_joint_contact` | A fixed rigid payload sliding against static contact | Public fixed-joint rows plus ordinary contact | | `avbd_rigid_revolute_motor` | A bounded revolute motor driving a free hinge | Public velocity actuator mapped to AVBD motor rows | | `avbd_rigid_prismatic_motor` | A bounded prismatic motor driving a free rigid slider | Public linear velocity actuator mapped to AVBD motor rows | diff --git a/python/examples/demos/registry.py b/python/examples/demos/registry.py index 42fdba90eb9c6..2270ba106ddf9 100644 --- a/python/examples/demos/registry.py +++ b/python/examples/demos/registry.py @@ -45,6 +45,7 @@ from .scenes.avbd_demo3d_stack import SCENE as AVBD_DEMO3D_STACK from .scenes.avbd_demo3d_stack_ratio import SCENE as AVBD_DEMO3D_STACK_RATIO from .scenes.avbd_demo3d_soft_body import SCENE as AVBD_DEMO3D_SOFT_BODY +from .scenes.avbd_paper_breakable_wall import SCENE as AVBD_PAPER_BREAKABLE_WALL from .scenes.avbd_articulated_breakable_joint import ( SCENE as AVBD_ARTICULATED_BREAKABLE_JOINT, ) @@ -308,6 +309,7 @@ def make_demo_scenes() -> list[PythonDemoScene]: AVBD_DEMO3D_SOFT_BODY, AVBD_DEMO3D_BRIDGE, AVBD_DEMO3D_BREAKABLE, + AVBD_PAPER_BREAKABLE_WALL, AVBD_RIGID_FIXED_JOINT_CONTACT, AVBD_RIGID_REVOLUTE_MOTOR, AVBD_RIGID_PRISMATIC_MOTOR, diff --git a/python/examples/demos/scenes/avbd_paper_breakable_wall.py b/python/examples/demos/scenes/avbd_paper_breakable_wall.py new file mode 100644 index 0000000000000..87325736e0008 --- /dev/null +++ b/python/examples/demos/scenes/avbd_paper_breakable_wall.py @@ -0,0 +1,747 @@ +"""Publication-shaped AVBD breakable-wall reconstruction. + +Figure 13 of Giles et al. shows a brick wall hit by three high-momentum balls +and compares the retained-wall outcome across four solver families. The paper +publishes the 1/60 s step, 20 iterations/substeps, impact count, and qualitative +outcome, but not the exact wall geometry, masses, launch speed, or break +threshold. This scene keeps those two classes of facts separate: published +facts are source claims, while every numeric reconstruction choice is recorded +as a visually adjudicated DART parameter. +""" + +from __future__ import annotations + +from collections import deque +from collections.abc import Sequence +import math +import struct +from typing import Any + +import numpy as np + +import dartpy as dart +import dartpy as sx + +from .._world_bridge import WorldRenderBridge +from ..runner import CAPTURE_METRICS_INFO_KEY, PythonDemoScene, ScenePanel, SceneSetup + +_TIME_STEP = 1.0 / 60.0 +_GRAVITY = -9.81 +_RIGID_CONSTRAINT_ITERATIONS = 20 +_WALL_COLUMNS = 21 +_WALL_ROWS = 12 +_BRICK_COUNT = _WALL_COLUMNS * _WALL_ROWS +_BRICK_SIZE = np.array([0.60, 0.30, 0.25]) +_BRICK_DENSITY = 4.0 +_BRICK_FRICTION = 0.50 +_BRICK_SPACING_X = 0.62 +_BRICK_SPACING_Z = 0.27 +_BRICK_BASE_CLEARANCE = 0.02 +_BALL_RADIUS = 0.48 +_BALL_MASS = 100.0 +_BALL_FRICTION = 0.30 +_BALL_START_Y = -5.0 +_BALL_LAUNCH_SPEED = 24.0 +_IMPACT_TARGETS_XZ = ( + (-3.10, 1.55), + (0.00, 1.75), + (3.10, 2.35), +) +_GROUND_SIZE = np.array([16.0, 16.0, 0.50]) +_GROUND_FRICTION = 0.60 +_BREAK_FORCE = 8500.0 +_HORIZONTAL_JOINTS = _WALL_ROWS * (_WALL_COLUMNS - 1) +_VERTICAL_JOINTS = (_WALL_ROWS - 1) * (2 * _WALL_COLUMNS - 1) +_BASE_JOINTS = _WALL_COLUMNS +_BREAKABLE_JOINTS = _HORIZONTAL_JOINTS + _VERTICAL_JOINTS + _BASE_JOINTS +_OUTCOME_FRAME = 120 +_DISPLACEMENT_THRESHOLD = 0.50 +_IMPACT_BAND_RADIUS = 0.85 +_OUTSIDE_RADIUS = 1.15 +_CAPTURE_SIZE = (1280, 720) +_CAPTURE_CAMERA_DISTANCE = 11.0 +_CAPTURE_CAMERA_TARGET = (0.0, 0.0, 1.4) +_FNV1A_64_OFFSET_BASIS = 14695981039346656037 +_FNV1A_64_PRIME = 1099511628211 +_FNV1A_64_MASK = (1 << 64) - 1 +_SCENE_SPEC_FINGERPRINT_TAG = "avbd-paper-breakable-wall/v1" + +PAPER_REFERENCE: dict[str, Any] = { + "paper": { + "authors": ("Chris Giles", "Elie Diaz", "Cem Yuksel"), + "title": "Augmented Vertex Block Descent", + "venue": "ACM Transactions on Graphics 44(4), Article 90", + "year": 2025, + "doi": "10.1145/3731195", + }, + "source_locator": "Section 5.4 and Figure 13, PDF page 10", + "published_facts": { + "time_step": _TIME_STEP, + "iterations_or_substeps": _RIGID_CONSTRAINT_ITERATIONS, + "impacting_balls": 3, + "comparison_methods": ( + "sequential impulse", + "XPBD", + "VBD", + "AVBD", + ), + "avbd_outcome": ( + "localized damage in three impact bands while large retained wall " + "regions remain attached and standing" + ), + }, + "unpublished_scene_constants": ( + "wall dimensions and brick geometry", + "brick density and friction", + "ball radius, mass, launch speed, and exact impact locations", + "break force", + ), + "dart_reconstruction": { + "wall_columns": _WALL_COLUMNS, + "wall_rows": _WALL_ROWS, + "brick_size": tuple(float(value) for value in _BRICK_SIZE), + "brick_density": _BRICK_DENSITY, + "brick_spacing_x": _BRICK_SPACING_X, + "brick_spacing_z": _BRICK_SPACING_Z, + "ball_radius": _BALL_RADIUS, + "ball_mass": _BALL_MASS, + "ball_start_y": _BALL_START_Y, + "ball_launch_speed": _BALL_LAUNCH_SPEED, + "impact_targets_xz": _IMPACT_TARGETS_XZ, + "break_force": _BREAK_FORCE, + "selection": "visual adjudication against the paper and project video", + }, +} + +OUTCOME_ORACLE: dict[str, Any] = { + "evaluation_frame": _OUTCOME_FRAME, + "displacement_threshold": _DISPLACEMENT_THRESHOLD, + "impact_band_radius": _IMPACT_BAND_RADIUS, + "outside_radius": _OUTSIDE_RADIUS, + "minimum_displaced_bricks_per_impact_band": 4, + "minimum_outside_retained_fraction": 0.80, + "minimum_total_retained_fraction": 0.70, + "minimum_broken_joints": 300, + "maximum_broken_joints": 500, + "minimum_unbroken_joints": 200, +} + + +def _fnv1a_64_update(state: int, payload: bytes) -> int: + for value in payload: + state ^= value + state = (state * _FNV1A_64_PRIME) & _FNV1A_64_MASK + return state + + +def _scene_spec_fingerprint( + topology_records: Sequence[tuple[int, int, int]], +) -> str: + """Hash the exact reconstructed scene contract shared with the benchmark.""" + state = _FNV1A_64_OFFSET_BASIS + + def update_u64(value: int) -> None: + nonlocal state + state = _fnv1a_64_update(state, struct.pack(" None: + nonlocal state + state = _fnv1a_64_update(state, struct.pack(" list[dict[str, str]]: + notes = [ + { + "domain": note.domain, + "requested": note.requested, + "resolved": note.resolved, + "reason": note.reason, + } + for note in world.resolved_configuration.notes + ] + expected = { + "rigid-body": ("avbd", "avbd"), + "rigid-contact": ("avbd", "avbd"), + "rigid-pair-constraint": ("avbd", "avbd"), + "rigid-constraint-iterations": ( + str(_RIGID_CONSTRAINT_ITERATIONS), + str(_RIGID_CONSTRAINT_ITERATIONS), + ), + } + for domain, (requested, resolved) in expected.items(): + matching = [ + note + for note in notes + if note["domain"] == domain + and note["requested"] == requested + and note["resolved"] == resolved + ] + if len(matching) != 1: + raise RuntimeError( + "AVBD breakable-wall resolved configuration drifted: " + f"expected one {domain} {requested}->{resolved} note, " + f"got {matching!r}" + ) + return notes + + +def _view_assessment_available() -> bool: + return all( + hasattr(dart.gui, name) + for name in ("OrbitCamera", "ProjectionOptions", "assess_view_quality") + ) + + +def _full_box_inertia(size: np.ndarray, mass: float) -> np.ndarray: + return np.diag( + [ + mass * float(size[1] * size[1] + size[2] * size[2]) / 12.0, + mass * float(size[0] * size[0] + size[2] * size[2]) / 12.0, + mass * float(size[0] * size[0] + size[1] * size[1]) / 12.0, + ] + ) + + +def _brick_position(row: int, column: int) -> np.ndarray: + course_offset = 0.5 if row % 2 else 0.0 + return np.array( + [ + (column - 0.5 * (_WALL_COLUMNS - 1) + course_offset) + * _BRICK_SPACING_X, + 0.0, + _BRICK_BASE_CLEARANCE + + 0.5 * float(_BRICK_SIZE[2]) + + row * _BRICK_SPACING_Z, + ] + ) + + +def _add_box( + world: sx.World, + name: str, + *, + size: np.ndarray, + position: np.ndarray, + mass: float | None, + friction: float, +) -> sx.RigidBody: + body = world.add_rigid_body(name, position=tuple(position)) + body.is_static = mass is None + body.friction = friction + body.set_collision_shape(sx.CollisionShape.box(0.5 * size)) + if mass is not None: + body.mass = mass + body.inertia = _full_box_inertia(size, mass) + return body + + +def _add_breakable_fixed_joint( + world: sx.World, + name: str, + parent: sx.RigidBody, + child: sx.RigidBody, +) -> sx.Joint: + spec = sx.JointSpec(name=name, type=sx.JointType.FIXED) + joint = world.add_joint(parent, child, spec) + joint.break_force = _BREAK_FORCE + return joint + + +def _positions(bodies: Sequence[sx.RigidBody]) -> np.ndarray: + return np.asarray( + [ + np.asarray(body.translation, dtype=float).reshape(3) + for body in bodies + ], + dtype=float, + ) + + +def compute_outcome_metrics( + *, + world: sx.World, + bricks: Sequence[sx.RigidBody], + joints: Sequence[sx.Joint], + balls: Sequence[sx.RigidBody], + initial_brick_positions: np.ndarray, +) -> dict[str, Any]: + """Evaluate the fail-closed Figure 13 outcome oracle at the current state.""" + brick_positions = _positions(bricks) + ball_positions = _positions(balls) + displacements = np.linalg.norm(brick_positions - initial_brick_positions, axis=1) + initial_xz = initial_brick_positions[:, (0, 2)] + + impact_band_displaced_counts = [] + for target in _IMPACT_TARGETS_XZ: + distances = np.linalg.norm(initial_xz - np.asarray(target), axis=1) + impact_band_displaced_counts.append( + int( + np.count_nonzero( + (distances <= _IMPACT_BAND_RADIUS) + & (displacements > _DISPLACEMENT_THRESHOLD) + ) + ) + ) + + outside_mask = np.ones(_BRICK_COUNT, dtype=bool) + for target in _IMPACT_TARGETS_XZ: + outside_mask &= ( + np.linalg.norm(initial_xz - np.asarray(target), axis=1) + > _OUTSIDE_RADIUS + ) + retained_mask = displacements < _DISPLACEMENT_THRESHOLD + outside_count = int(np.count_nonzero(outside_mask)) + outside_retained_fraction = float( + np.count_nonzero(retained_mask & outside_mask) / outside_count + ) + total_retained_fraction = float(np.count_nonzero(retained_mask) / _BRICK_COUNT) + + broken_joints = sum(1 for joint in joints if joint.is_broken) + unbroken_joints = len(joints) - broken_joints + frame = int(round(float(world.time) / _TIME_STEP)) + finite_state = bool( + np.all(np.isfinite(brick_positions)) + and np.all(np.isfinite(ball_positions)) + and all( + np.all( + np.isfinite( + np.asarray(body.linear_velocity, dtype=float).reshape(3) + ) + ) + and np.all( + np.isfinite( + np.asarray(body.angular_velocity, dtype=float).reshape(3) + ) + ) + for body in (*bricks, *balls) + ) + ) + threshold_checks = { + "finite_state": finite_state, + "damage_in_three_impact_bands": all( + count + >= OUTCOME_ORACLE["minimum_displaced_bricks_per_impact_band"] + for count in impact_band_displaced_counts + ), + "outside_wall_retained": ( + outside_retained_fraction + >= OUTCOME_ORACLE["minimum_outside_retained_fraction"] + ), + "total_wall_retained": ( + total_retained_fraction + >= OUTCOME_ORACLE["minimum_total_retained_fraction"] + ), + "fracture_activated": ( + broken_joints >= OUTCOME_ORACLE["minimum_broken_joints"] + ), + "fracture_localized": ( + broken_joints <= OUTCOME_ORACLE["maximum_broken_joints"] + and unbroken_joints >= OUTCOME_ORACLE["minimum_unbroken_joints"] + ), + } + evaluated = frame >= _OUTCOME_FRAME + thresholds_pass = evaluated and all(threshold_checks.values()) + step_metrics = world.compute_step_metrics() + return { + "frame": frame, + "world_time": float(world.time), + "evaluated": evaluated, + "status": ( + "pass" + if thresholds_pass + else "fail" + if evaluated + else "pre-evaluation" + ), + "thresholds_pass": thresholds_pass, + "threshold_checks": threshold_checks, + "impact_band_displaced_counts": impact_band_displaced_counts, + "outside_brick_count": outside_count, + "outside_retained_fraction": outside_retained_fraction, + "total_retained_fraction": total_retained_fraction, + "broken_joints": broken_joints, + "unbroken_joints": unbroken_joints, + "max_brick_displacement": float(np.max(displacements)), + "ball_positions": ball_positions.tolist(), + "ball_velocities": [ + np.asarray(ball.linear_velocity, dtype=float).reshape(3).tolist() + for ball in balls + ], + "last_step_iterations": int(step_metrics.last_step_iterations), + "contact_count": int(step_metrics.active_contact_count), + } + + +def build() -> SceneSetup: + world = sx.World( + time_step=_TIME_STEP, + gravity=(0.0, 0.0, _GRAVITY), + rigid_body_solver=sx.RigidBodySolver.AVBD, + rigid_constraint_options=sx.RigidConstraintOptions( + iterations=_RIGID_CONSTRAINT_ITERATIONS + ), + ) + + ground = _add_box( + world, + "avbd_paper_breakable_wall_ground", + size=_GROUND_SIZE, + position=np.array([0.0, 0.0, -0.5 * float(_GROUND_SIZE[2])]), + mass=None, + friction=_GROUND_FRICTION, + ) + + brick_mass = float(np.prod(_BRICK_SIZE) * _BRICK_DENSITY) + brick_rows: list[list[sx.RigidBody]] = [] + bricks: list[sx.RigidBody] = [] + for row in range(_WALL_ROWS): + course = [] + for column in range(_WALL_COLUMNS): + brick = _add_box( + world, + f"avbd_paper_wall_brick_{row:02d}_{column:02d}", + size=_BRICK_SIZE, + position=_brick_position(row, column), + mass=brick_mass, + friction=_BRICK_FRICTION, + ) + course.append(brick) + bricks.append(brick) + brick_rows.append(course) + + joints: list[sx.Joint] = [] + topology_records: list[tuple[int, int, int]] = [] + for row, course in enumerate(brick_rows): + for column, (left, right) in enumerate(zip(course, course[1:])): + joints.append( + _add_breakable_fixed_joint( + world, + f"avbd_paper_wall_horizontal_{row:02d}_{column:02d}", + left, + right, + ) + ) + topology_records.append( + ( + 1, + 1 + row * _WALL_COLUMNS + column, + 1 + row * _WALL_COLUMNS + column + 1, + ) + ) + + for row in range(1, _WALL_ROWS): + lower = brick_rows[row - 1] + upper = brick_rows[row] + for upper_column, upper_brick in enumerate(upper): + upper_x = _brick_position(row, upper_column)[0] + for lower_column, lower_brick in enumerate(lower): + lower_x = _brick_position(row - 1, lower_column)[0] + if abs(float(upper_x - lower_x)) < float(_BRICK_SIZE[0]): + joints.append( + _add_breakable_fixed_joint( + world, + "avbd_paper_wall_vertical_" + f"{row - 1:02d}_{lower_column:02d}_" + f"{row:02d}_{upper_column:02d}", + lower_brick, + upper_brick, + ) + ) + topology_records.append( + ( + 2, + 1 + (row - 1) * _WALL_COLUMNS + lower_column, + 1 + row * _WALL_COLUMNS + upper_column, + ) + ) + + for column, brick in enumerate(brick_rows[0]): + joints.append( + _add_breakable_fixed_joint( + world, + f"avbd_paper_wall_base_{column:02d}", + ground, + brick, + ) + ) + topology_records.append((3, 0, 1 + column)) + + balls: list[sx.RigidBody] = [] + sphere_inertia = ( + 2.0 / 5.0 * _BALL_MASS * _BALL_RADIUS * _BALL_RADIUS * np.eye(3) + ) + for index, (target_x, target_z) in enumerate(_IMPACT_TARGETS_XZ): + ball = world.add_rigid_body( + f"avbd_paper_wall_ball_{index}", + position=(target_x, _BALL_START_Y, target_z), + linear_velocity=(0.0, _BALL_LAUNCH_SPEED, 0.0), + ) + ball.mass = _BALL_MASS + ball.inertia = sphere_inertia + ball.friction = _BALL_FRICTION + ball.set_collision_shape(sx.CollisionShape.sphere(_BALL_RADIUS)) + balls.append(ball) + + if len(joints) != _BREAKABLE_JOINTS: + raise RuntimeError( + "breakable-wall topology drifted: " + f"expected {_BREAKABLE_JOINTS} joints, built {len(joints)}" + ) + if len(topology_records) != len(joints): + raise RuntimeError("breakable-wall topology fingerprint record drifted") + + initial_brick_positions = _positions(bricks) + world.enter_simulation_mode() + resolved_configuration = _serialize_and_validate_resolved_configuration(world) + scene_spec_fingerprint = _scene_spec_fingerprint(topology_records) + view_assessment_available = _view_assessment_available() + capture_camera = ( + dart.gui.orbit_camera( + azimuth=-0.5 * math.pi, + elevation=0.0, + distance=_CAPTURE_CAMERA_DISTANCE, + target=_CAPTURE_CAMERA_TARGET, + ) + if view_assessment_available + else None + ) + capture_focus = tuple( + f"{brick_rows[row][column].name}_visual" + for row, column in ( + (0, 0), + (0, _WALL_COLUMNS - 1), + (_WALL_ROWS - 1, 0), + (_WALL_ROWS - 1, _WALL_COLUMNS - 1), + ) + ) + + bridge = WorldRenderBridge(world, name="avbd_paper_breakable_wall_render") + bridge.add_rigid_body_visual( + ground, + dart.BoxShape(_GROUND_SIZE), + (0.40, 0.42, 0.45), + name="avbd_paper_breakable_wall_ground_visual", + ) + brick_colors = ( + (0.72, 0.25, 0.13), + (0.86, 0.38, 0.18), + (0.64, 0.19, 0.10), + (0.80, 0.31, 0.14), + ) + for index, brick in enumerate(bricks): + row, column = divmod(index, _WALL_COLUMNS) + bridge.add_rigid_body_visual( + brick, + dart.BoxShape(_BRICK_SIZE), + brick_colors[(row + column) % len(brick_colors)], + name=f"{brick.name}_visual", + ) + ball_colors = ( + (0.96, 0.82, 0.28), + (0.41, 0.76, 0.95), + (0.88, 0.54, 0.84), + ) + for ball, color in zip(balls, ball_colors): + bridge.add_rigid_body_visual( + ball, + dart.SphereShape(_BALL_RADIUS), + color, + name=f"{ball.name}_visual", + ) + + _last_metrics: dict[str, Any] = {} + broken_history: deque[float] = deque(maxlen=180) + retained_history: deque[float] = deque(maxlen=180) + impact_band_history: tuple[deque[float], ...] = tuple( + deque(maxlen=180) for _ in _IMPACT_TARGETS_XZ + ) + + def sample_metrics() -> dict[str, Any]: + return compute_outcome_metrics( + world=world, + bricks=bricks, + joints=joints, + balls=balls, + initial_brick_positions=initial_brick_positions, + ) + + def record_metrics() -> dict[str, Any]: + _last_metrics.clear() + _last_metrics.update(sample_metrics()) + broken_history.append(float(_last_metrics["broken_joints"])) + retained_history.append(float(_last_metrics["outside_retained_fraction"])) + for history, count in zip( + impact_band_history, + _last_metrics["impact_band_displaced_counts"], + ): + history.append(float(count)) + return _last_metrics + + def capture_metrics() -> dict[str, Any]: + metrics = record_metrics() + rigid_constraint_options = world.rigid_constraint_options + resolved_rigid_body = next( + note["resolved"] + for note in resolved_configuration + if note["domain"] == "rigid-body" + ) + view_report = None + if ( + view_assessment_available + and metrics["frame"] in (60, _OUTCOME_FRAME) + ): + # capture_metrics() is also a public text-oracle callback, so it + # can be invoked after direct World.step(n=...) calls that bypass + # the demo runner's render-provider synchronization. + bridge.sync() + assert capture_camera is not None + assessed_view = dart.gui.assess_view( + bridge.render_world, + capture_camera, + _CAPTURE_SIZE, + focus=capture_focus, + ) + if not assessed_view.acceptable: + raise RuntimeError( + "AVBD breakable-wall capture view failed assessment: " + f"{assessed_view.issues!r}" + ) + view_report = assessed_view.to_json() + return { + "row": "avbd_paper_breakable_wall", + "solver": f"public_{resolved_rigid_body}", + "executor": "World.step default", + "paper_locator": PAPER_REFERENCE["source_locator"], + "time_step_ms": 1000.0 * _TIME_STEP, + "rigid_constraint_options": { + "iterations": int(rigid_constraint_options.iterations) + }, + "rigid_body_solver": world.rigid_body_solver.name, + "resolved_configuration": resolved_configuration, + "scene_spec_fingerprint": scene_spec_fingerprint, + "view_assessment_available": view_assessment_available, + "view_report": view_report, + "rigid_bodies": world.num_rigid_bodies, + "collision_shapes": 1 + len(bricks) + len(balls), + "brick_count": len(bricks), + "ball_count": len(balls), + "breakable_joints": len(joints), + "break_force": _BREAK_FORCE, + "outcome_oracle": dict(OUTCOME_ORACLE), + "outcome": metrics, + } + + def replay_sync() -> None: + bridge.sync() + record_metrics() + + def pre_step() -> None: + bridge.pre_step() + record_metrics() + + bridge.sync() + record_metrics() + + def build_panel(builder: object, context: object) -> None: + metrics = record_metrics() + impact_bands = ", ".join( + str(value) for value in metrics["impact_band_displaced_counts"] + ) + builder.text("paper: Giles et al. 2025, Figure 13") + builder.text("solver: public rigid AVBD") + builder.text(f"iterations: {_RIGID_CONSTRAINT_ITERATIONS}") + builder.text(f"world time: {world.time:.3f} s") + builder.text(f"bricks / breakable joints: {len(bricks)} / {len(joints)}") + builder.text( + f"broken / retained joints: {metrics['broken_joints']} / " + f"{metrics['unbroken_joints']}" + ) + builder.text(f"displaced bricks in impact bands: {impact_bands}") + builder.text( + "outside retained: " + f"{100.0 * metrics['outside_retained_fraction']:.1f}%" + ) + builder.text( + "total retained: " + f"{100.0 * metrics['total_retained_fraction']:.1f}%" + ) + builder.text(f"outcome oracle: {metrics['status']}") + builder.plot_lines("Broken joints", list(broken_history)) + builder.plot_lines("Outside retained", list(retained_history)) + for index, history in enumerate(impact_band_history, start=1): + builder.plot_lines(f"Impact {index} displaced", list(history)) + builder.separator() + bridge.build_control_panel(builder, context) + + return SceneSetup( + world=bridge.render_world, + pre_step=pre_step, + force_drag=bridge.force_drag, + panels=[ScenePanel("AVBD Paper Breakable Wall", build_panel)], + info={ + "sx_world": world, + "ground": ground, + "bricks": tuple(bricks), + "balls": tuple(balls), + "joints": tuple(joints), + "initial_brick_positions": initial_brick_positions.copy(), + "paper_reference": PAPER_REFERENCE, + "outcome_oracle": OUTCOME_ORACLE, + "resolved_configuration": tuple(resolved_configuration), + "scene_spec_fingerprint": scene_spec_fingerprint, + "outcome_metrics": sample_metrics, + "replay_sync": replay_sync, + "replay_live_step_is_stateless": True, + CAPTURE_METRICS_INFO_KEY: capture_metrics, + }, + ) + + +SCENE = PythonDemoScene( + id="avbd_paper_breakable_wall", + title="AVBD Paper Breakable Wall (sx)", + category="AVBD Rigid Constraints (sx)", + summary="A publication-shaped Figure 13 wall hit by three balls, with public " + "AVBD contacts, 20 sweeps, and a quantitative retained-wall oracle.", + build=build, +) diff --git a/python/stubs/dartpy/simulation.pyi b/python/stubs/dartpy/simulation.pyi index c5a53e2de0797..b6faf20c64857 100644 --- a/python/stubs/dartpy/simulation.pyi +++ b/python/stubs/dartpy/simulation.pyi @@ -54,6 +54,9 @@ __all__: list[str] = [ "ParallelExecutor", "PhysicalParameter", "ReadOptions", + "ResolvedConfigurationNote", + "ResolvedSolverConfiguration", + "RigidConstraintOptions", "RigidBody", "RigidBodyOptions", "RigidBodySolver", @@ -160,11 +163,24 @@ class RigidBodySolver(enum.Enum): IPC = 1 + AVBD = 2 + class ContactSolverMethod(enum.Enum): SEQUENTIAL_IMPULSE = 0 BOXED_LCP = 1 +class RigidConstraintOptions: + def __init__(self, iterations: int = ...) -> None: ... + + @property + def iterations(self) -> int: ... + + @iterations.setter + def iterations(self, arg: int, /) -> None: ... + + def __repr__(self) -> str: ... + class ContactGradientMode(enum.Enum): ANALYTIC = 0 @@ -1617,6 +1633,35 @@ class WorldStepProfile: def __str__(self) -> str: ... +class ResolvedConfigurationNote: + @property + def domain(self) -> str: ... + + @property + def requested(self) -> str: ... + + @property + def resolved(self) -> str: ... + + @property + def reason(self) -> str: ... + + def is_substitution(self) -> bool: ... + +class ResolvedSolverConfiguration: + @property + def notes(self) -> list[ResolvedConfigurationNote]: ... + + def is_empty(self) -> bool: ... + + def has_substitution(self) -> bool: ... + + def summary(self) -> str: ... + + def __repr__(self) -> str: ... + + def __str__(self) -> str: ... + class StepMetrics: @property def kinetic_energy(self) -> float: ... @@ -2253,7 +2298,7 @@ def load_seg_line_mesh(path: str | os.PathLike) -> DeformableBodyOptions: ... def load_point_set(path: str | os.PathLike) -> DeformableBodyOptions: ... class World: - def __init__(self, time_step: float = ..., *, gravity: object | None = ..., differentiable: bool = ..., rigid_body_solver: RigidBodySolver = RigidBodySolver.SEQUENTIAL_IMPULSE, multibody_options: MultibodyOptions = ..., contact_solver_method: ContactSolverMethod = ContactSolverMethod.SEQUENTIAL_IMPULSE, contact_gradient_mode: ContactGradientMode = ContactGradientMode.ANALYTIC, compute_accelerator_policy: ComputeAcceleratorPolicy = ComputeAcceleratorPolicy.CPU_ONLY, deactivation_options: DeactivationOptions = ...) -> None: ... + def __init__(self, time_step: float = ..., *, gravity: object | None = ..., differentiable: bool = ..., rigid_body_solver: RigidBodySolver = RigidBodySolver.SEQUENTIAL_IMPULSE, rigid_constraint_options: RigidConstraintOptions = ..., multibody_options: MultibodyOptions = ..., contact_solver_method: ContactSolverMethod = ContactSolverMethod.SEQUENTIAL_IMPULSE, contact_gradient_mode: ContactGradientMode = ContactGradientMode.ANALYTIC, compute_accelerator_policy: ComputeAcceleratorPolicy = ComputeAcceleratorPolicy.CPU_ONLY, deactivation_options: DeactivationOptions = ...) -> None: ... def add_free_frame(self, name: str = ..., *, parent: object | None = ...) -> FreeFrame: ... @@ -2386,6 +2431,9 @@ class World: @property def last_step_profile(self) -> WorldStepProfile: ... + @property + def resolved_configuration(self) -> ResolvedSolverConfiguration: ... + def compute_step_metrics(self) -> StepMetrics: ... @property @@ -2412,6 +2460,12 @@ class World: @rigid_body_solver.setter def rigid_body_solver(self, arg: RigidBodySolver, /) -> None: ... + @property + def rigid_constraint_options(self) -> RigidConstraintOptions: ... + + @rigid_constraint_options.setter + def rigid_constraint_options(self, arg: RigidConstraintOptions, /) -> None: ... + @property def multibody_options(self) -> MultibodyOptions: ... diff --git a/python/tests/integration/test_demos_cycle.py b/python/tests/integration/test_demos_cycle.py index 137299ad523ee..4e1cee4cb7a6d 100644 --- a/python/tests/integration/test_demos_cycle.py +++ b/python/tests/integration/test_demos_cycle.py @@ -927,6 +927,7 @@ def test_world_scenes_use_solver_focused_categories() -> None: "avbd_demo3d_soft_body", "avbd_demo3d_bridge", "avbd_demo3d_breakable", + "avbd_paper_breakable_wall", "avbd_rigid_fixed_joint_contact", "avbd_rigid_revolute_motor", "avbd_articulated_revolute_motor", @@ -10270,6 +10271,207 @@ def test_avbd_demo3d_breakable_scene_matches_source_row() -> None: assert abs(final_chain[2] - initial_chain[2]) < 1.0 +def test_avbd_paper_breakable_wall_matches_figure13_contract() -> None: + import numpy as np + + sx = _require_simulation_experimental_symbols("World") + + from examples.demos.scenes.avbd_paper_breakable_wall import ( + OUTCOME_ORACLE, + build, + ) + + setup = build() + sx_world = setup.info["sx_world"] + ground = setup.info["ground"] + bricks = setup.info["bricks"] + balls = setup.info["balls"] + joints = setup.info["joints"] + reference = setup.info["paper_reference"] + + assert reference["source_locator"] == "Section 5.4 and Figure 13, PDF page 10" + assert reference["published_facts"] == { + "time_step": pytest.approx(1.0 / 60.0), + "iterations_or_substeps": 20, + "impacting_balls": 3, + "comparison_methods": ( + "sequential impulse", + "XPBD", + "VBD", + "AVBD", + ), + "avbd_outcome": ( + "localized damage in three impact bands while large retained wall " + "regions remain attached and standing" + ), + } + assert "break force" in reference["unpublished_scene_constants"] + assert reference["dart_reconstruction"]["break_force"] == pytest.approx(8500.0) + assert reference["dart_reconstruction"]["selection"].startswith( + "visual adjudication" + ) + + assert sx_world.rigid_body_solver == sx.RigidBodySolver.AVBD + assert sx_world.rigid_constraint_options.iterations == 20 + assert sx_world.time_step == pytest.approx(1.0 / 60.0) + assert sx_world.gravity.tolist() == pytest.approx([0.0, 0.0, -9.81]) + assert sx_world.num_rigid_bodies == 256 + assert sx_world.num_joints == 712 + assert len(bricks) == 252 + assert len(balls) == 3 + assert len(joints) == 712 + assert _fixed_joint_count(sx_world) == 712 + assert ground.is_static + assert not any(brick.is_static for brick in bricks) + assert not any(ball.is_static for ball in balls) + assert all(joint.break_force == pytest.approx(8500.0) for joint in joints) + assert sum(len(body.collision_shapes) for body in (ground, *bricks, *balls)) == 256 + assert [brick.mass for brick in bricks] == pytest.approx([0.18] * 252) + assert [ball.mass for ball in balls] == pytest.approx([100.0] * 3) + + brick_positions = np.asarray( + [np.asarray(brick.translation, dtype=float).reshape(3) for brick in bricks] + ).reshape(12, 21, 3) + assert brick_positions[:, :, 1] == pytest.approx(np.zeros((12, 21))) + assert brick_positions[0, :, 0] == pytest.approx( + np.linspace(-6.2, 6.2, num=21) + ) + assert brick_positions[1, :, 0] == pytest.approx( + np.linspace(-5.89, 6.51, num=21) + ) + assert brick_positions[:, 0, 2] == pytest.approx( + [0.145 + 0.27 * row for row in range(12)] + ) + ball_positions = np.asarray( + [np.asarray(ball.translation, dtype=float).reshape(3) for ball in balls] + ) + np.testing.assert_allclose( + ball_positions, + np.array( + [ + [-3.10, -5.0, 1.55], + [0.00, -5.0, 1.75], + [3.10, -5.0, 2.35], + ] + ), + ) + ball_velocities = np.asarray( + [ + np.asarray(ball.linear_velocity, dtype=float).reshape(3) + for ball in balls + ] + ) + np.testing.assert_allclose( + ball_velocities, np.tile(np.array([[0.0, 24.0, 0.0]]), (3, 1)) + ) + + initial = setup.info["outcome_metrics"]() + assert initial["status"] == "pre-evaluation" + assert initial["broken_joints"] == 0 + assert initial["impact_band_displaced_counts"] == [0, 0, 0] + + sx_world.step(n=OUTCOME_ORACLE["evaluation_frame"]) + outcome = setup.info["outcome_metrics"]() + assert outcome["frame"] == OUTCOME_ORACLE["evaluation_frame"] + assert outcome["status"] == "pass" + assert outcome["thresholds_pass"] is True + assert all(outcome["threshold_checks"].values()) + assert all( + count + >= OUTCOME_ORACLE["minimum_displaced_bricks_per_impact_band"] + for count in outcome["impact_band_displaced_counts"] + ) + assert ( + outcome["outside_retained_fraction"] + >= OUTCOME_ORACLE["minimum_outside_retained_fraction"] + ) + assert ( + outcome["total_retained_fraction"] + >= OUTCOME_ORACLE["minimum_total_retained_fraction"] + ) + assert ( + OUTCOME_ORACLE["minimum_broken_joints"] + <= outcome["broken_joints"] + <= OUTCOME_ORACLE["maximum_broken_joints"] + ) + assert outcome["unbroken_joints"] >= OUTCOME_ORACLE["minimum_unbroken_joints"] + assert outcome["last_step_iterations"] == 20 + + capture_metrics = setup.info[CAPTURE_METRICS_INFO_KEY]() + assert capture_metrics["solver"] == "public_avbd" + assert capture_metrics["rigid_body_solver"] == "AVBD" + assert capture_metrics["rigid_constraint_options"] == {"iterations": 20} + assert ( + capture_metrics["resolved_configuration"] + == list(setup.info["resolved_configuration"]) + ) + fingerprint = capture_metrics["scene_spec_fingerprint"] + assert len(fingerprint) == 16 + assert f"{int(fingerprint, 16):016x}" == fingerprint + view_assessment_available = all( + hasattr(sx.gui, name) + for name in ("OrbitCamera", "ProjectionOptions", "assess_view_quality") + ) + assert ( + capture_metrics["view_assessment_available"] + is view_assessment_available + ) + if view_assessment_available: + assert capture_metrics["view_report"]["pass"] is True + assert capture_metrics["view_report"]["issues"] == [] + assert capture_metrics["view_report"]["focus"] == [ + "avbd_paper_wall_brick_00_00_visual", + "avbd_paper_wall_brick_00_20_visual", + "avbd_paper_wall_brick_11_00_visual", + "avbd_paper_wall_brick_11_20_visual", + ] + else: + assert capture_metrics["view_report"] is None + assert capture_metrics["outcome"]["thresholds_pass"] is True + + +def test_avbd_paper_breakable_wall_outcome_is_deterministic() -> None: + import numpy as np + + _require_simulation_experimental_symbols("World") + + from examples.demos.scenes.avbd_paper_breakable_wall import ( + OUTCOME_ORACLE, + build, + ) + + setups = [build(), build()] + for setup in setups: + setup.info["sx_world"].step(n=OUTCOME_ORACLE["evaluation_frame"]) + + outcomes = [setup.info["outcome_metrics"]() for setup in setups] + for key in ( + "broken_joints", + "unbroken_joints", + "impact_band_displaced_counts", + "outside_retained_fraction", + "total_retained_fraction", + "last_step_iterations", + "contact_count", + ): + assert outcomes[0][key] == pytest.approx(outcomes[1][key]) + + for collection in ("bricks", "balls"): + positions = [ + np.asarray( + [ + np.asarray(body.translation, dtype=float).reshape(3) + for body in setup.info[collection] + ] + ) + for setup in setups + ] + np.testing.assert_allclose(positions[0], positions[1], rtol=0.0, atol=1.0e-12) + assert [ + joint.is_broken for joint in setups[0].info["joints"] + ] == [joint.is_broken for joint in setups[1].info["joints"]] + + def _fixed_chain_anchor_errors(chain: tuple[Any, ...]) -> list[float]: import numpy as np diff --git a/python/tests/unit/simulation/test_world.py b/python/tests/unit/simulation/test_world.py index c320772491661..c8638d8f2febe 100644 --- a/python/tests/unit/simulation/test_world.py +++ b/python/tests/unit/simulation/test_world.py @@ -6587,14 +6587,16 @@ def test_simulation_multibody_options_selector(): sx = _simulation() configured = sx.World( - rigid_body_solver=sx.RigidBodySolver.IPC, + rigid_body_solver=sx.RigidBodySolver.AVBD, + rigid_constraint_options=sx.RigidConstraintOptions(iterations=20), multibody_options=sx.MultibodyOptions( integration_family=sx.MultibodyIntegrationFamily.VARIATIONAL, variational_max_iterations=200, variational_tolerance=1.0e-9, ), ) - assert configured.rigid_body_solver == sx.RigidBodySolver.IPC + assert configured.rigid_body_solver == sx.RigidBodySolver.AVBD + assert configured.rigid_constraint_options.iterations == 20 assert configured.multibody_options.integration_family == sx.MultibodyIntegrationFamily.VARIATIONAL assert configured.multibody_options.variational_max_iterations == 200 assert configured.multibody_options.variational_tolerance == pytest.approx(1.0e-9) @@ -6633,6 +6635,18 @@ def test_simulation_multibody_options_selector(): with pytest.raises(TypeError): policy_configured.compute_accelerator_policy = 99 + configured.rigid_constraint_options = sx.RigidConstraintOptions(iterations=3) + assert configured.rigid_constraint_options.iterations == 3 + with pytest.raises(Exception, match="positive"): + configured.rigid_constraint_options = sx.RigidConstraintOptions(iterations=0) + assert configured.rigid_constraint_options.iterations == 3 + + with pytest.raises(Exception, match="not applicable"): + sx.World( + rigid_body_solver=sx.RigidBodySolver.IPC, + rigid_constraint_options=sx.RigidConstraintOptions(iterations=20), + ) + with pytest.raises(Exception): sx.World(multibody_options=sx.MultibodyOptions(integration_family="nonsense")) @@ -8475,6 +8489,37 @@ def test_simulation_contact_stops_approaching_bodies(): assert body_b.linear_velocity.tolist()[0] == pytest.approx(0.0, abs=1e-9) +def test_simulation_public_avbd_contact_family(): + sx = _simulation() + + world = sx.World( + time_step=0.05, + gravity=(0.0, 0.0, 0.0), + rigid_body_solver=sx.RigidBodySolver.AVBD, + rigid_constraint_options=sx.RigidConstraintOptions(iterations=20), + ) + ground = world.add_rigid_body("ground", position=(0.0, 0.0, -0.25)) + ground.is_static = True + ground.set_collision_shape(sx.CollisionShape.box((2.0, 2.0, 0.25))) + sphere = world.add_rigid_body("sphere", position=(0.0, 0.0, 0.4)) + sphere.set_collision_shape(sx.CollisionShape.sphere(0.5)) + + assert world.rigid_body_solver == sx.RigidBodySolver.AVBD + assert len(world.collide()) == 1 + world.step() + + assert world.compute_step_metrics().last_step_iterations == 20 + assert sphere.linear_velocity.tolist()[2] > 0.0 + assert sphere.translation.tolist()[2] > 0.4 + + with pytest.raises(Exception, match="cannot be combined"): + world.contact_solver_method = sx.ContactSolverMethod.BOXED_LCP + + avbd_only = sx.World(rigid_body_solver=sx.RigidBodySolver.AVBD) + with pytest.raises(Exception, match="Multibody"): + avbd_only.add_multibody("unsupported") + + def test_simulation_body_rests_on_static_ground(): sx = _simulation() diff --git a/python/tests/unit/test_capture_py_demo.py b/python/tests/unit/test_capture_py_demo.py index 2dc841ed0f5f7..cfb1f1f662f43 100644 --- a/python/tests/unit/test_capture_py_demo.py +++ b/python/tests/unit/test_capture_py_demo.py @@ -3758,6 +3758,14 @@ def fake_run_demo(demo_args: list[str]) -> int: "DART_TEST_CAPTURE_VALUE=new", "--metadata", "max_friction=2.5", + "--camera-azimuth", + "90", + "--camera-elevation", + "6", + "--camera-distance", + "11", + "--camera-target", + "0,0,1.4", "--output-dir", str(output), ] @@ -3769,3 +3777,9 @@ def fake_run_demo(demo_args: list[str]) -> int: manifest = capture_py_demo.json.loads((output / "manifest.json").read_text()) assert manifest["scene_environment"] == {"DART_TEST_CAPTURE_VALUE": "new"} assert manifest["metadata"] == {"max_friction": "2.5"} + assert manifest["camera"] == { + "azimuth": 90.0, + "distance": 11.0, + "elevation": 6.0, + "target": [0.0, 0.0, 1.4], + } diff --git a/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py b/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py index dc66cfe7d50f4..abfef448c145e 100644 --- a/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py +++ b/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py @@ -345,7 +345,11 @@ def test_packet_records_finite_load_fracture_evidence(tmp_path: Path) -> None: ) packet = json.loads(output.read_text(encoding="utf-8")) - assert packet["schema_version"] == 2 + assert packet["schema_version"] == 3 + assert ( + packet["resolved_solver_identity"]["rigid_contact_selection"] + == "not_applicable" + ) assert packet["resolved_solver_identity"]["rigid_contact_solver"] == "none" assert packet["resolved_solver_identity"]["rigid_point_joint_solver"] == "avbd" assert packet["load_contract"]["joint_aggregation"] == ( diff --git a/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py b/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py index b2bb46c2b3163..d0d844740057b 100644 --- a/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py +++ b/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py @@ -276,7 +276,7 @@ def test_avbd_articulated_compliant_joints_packet_records_evidence( ) packet = json.loads(output.read_text()) - assert packet["schema_version"] == 2 + assert packet["schema_version"] == 3 assert packet["scene"] == "avbd_articulated_compliant_joints" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, @@ -284,6 +284,7 @@ def test_avbd_articulated_compliant_joints_packet_records_evidence( "contact-free py-demo and articulated compliant-joint " "benchmark row family" ), + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd", } diff --git a/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py b/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py index 62bc1bb8b8b3e..8472557dad813 100644 --- a/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py +++ b/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py @@ -277,7 +277,7 @@ def test_avbd_articulated_compliant_motors_packet_records_evidence( ) packet = json.loads(output.read_text()) - assert packet["schema_version"] == 2 + assert packet["schema_version"] == 3 assert packet["scene"] == "avbd_articulated_compliant_motors" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, @@ -285,6 +285,7 @@ def test_avbd_articulated_compliant_motors_packet_records_evidence( "contact-free py-demo and articulated compliant-motor " "benchmark row family" ), + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd", } diff --git a/python/tests/unit/test_write_avbd_breakable_joint_scale_packet.py b/python/tests/unit/test_write_avbd_breakable_joint_scale_packet.py index 4965fa8ad707d..92e45afab8e22 100644 --- a/python/tests/unit/test_write_avbd_breakable_joint_scale_packet.py +++ b/python/tests/unit/test_write_avbd_breakable_joint_scale_packet.py @@ -128,11 +128,12 @@ def test_avbd_breakable_joint_scale_packet_records_scale_data( ) packet = json.loads(output.read_text()) - assert packet["schema_version"] == 2 + assert packet["schema_version"] == 3 assert packet["packet"] == "avbd_breakable_joint_scale" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, "recorded_from": "breakable joint scale benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd", } diff --git a/python/tests/unit/test_write_avbd_breakable_motor_scale_packet.py b/python/tests/unit/test_write_avbd_breakable_motor_scale_packet.py index aa3b22fc93967..a0b1aee2acbdb 100644 --- a/python/tests/unit/test_write_avbd_breakable_motor_scale_packet.py +++ b/python/tests/unit/test_write_avbd_breakable_motor_scale_packet.py @@ -134,11 +134,12 @@ def test_avbd_breakable_motor_scale_packet_records_scale_data( ) packet = json.loads(output.read_text()) - assert packet["schema_version"] == 2 + assert packet["schema_version"] == 3 assert packet["packet"] == "avbd_breakable_motor_scale" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, "recorded_from": "breakable motor scale benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd", } diff --git a/python/tests/unit/test_write_avbd_friction_coefficient_sweep_packet.py b/python/tests/unit/test_write_avbd_friction_coefficient_sweep_packet.py index a78d2d4decc72..e14fef4a6e9cb 100644 --- a/python/tests/unit/test_write_avbd_friction_coefficient_sweep_packet.py +++ b/python/tests/unit/test_write_avbd_friction_coefficient_sweep_packet.py @@ -219,11 +219,12 @@ def test_avbd_friction_coefficient_sweep_packet_records_rows( ) packet = json.loads(output.read_text(encoding="utf-8")) - assert packet["schema_version"] == 2 + assert packet["schema_version"] == 3 assert packet["packet"] == "avbd_friction_coefficient_sweep" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, "recorded_from": "friction coefficient sweep benchmark scene counters", + "rigid_contact_selection": "contact_solver_method", "rigid_contact_solver": "sequential_impulse", "rigid_point_joint_solver": "none", } diff --git a/python/tests/unit/test_write_avbd_paper_breakable_wall_packet.py b/python/tests/unit/test_write_avbd_paper_breakable_wall_packet.py new file mode 100644 index 0000000000000..f4178e0e4207e --- /dev/null +++ b/python/tests/unit/test_write_avbd_paper_breakable_wall_packet.py @@ -0,0 +1,594 @@ +from __future__ import annotations + +import importlib.util +import json +import struct +import sys +import zlib +from hashlib import sha256 +from pathlib import Path +from typing import Any + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "write_avbd_paper_breakable_wall_packet.py" + + +def _load_packet_module(): + spec = importlib.util.spec_from_file_location( + "write_avbd_paper_breakable_wall_packet", + SCRIPT, + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _write_png(path: Path, width: int = 8, height: int = 6) -> None: + def chunk(kind: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + kind + + payload + + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF) + ) + + rows = b"".join( + b"\x00" + bytes((32 + row, 64, 128)) * width for row in range(height) + ) + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(rows)) + + chunk(b"IEND", b"") + ) + + +def _sha256(path: Path) -> str: + return sha256(path.read_bytes()).hexdigest() + + +def _outcome(frame: int) -> dict[str, Any]: + expected = { + 60: { + "bands": [3, 9, 4], + "broken": 359, + "damage": False, + "evaluated": False, + "outside": 0.9337016574585635, + "status": "pre-evaluation", + "thresholds_pass": False, + "total": 0.8611111111111112, + "unbroken": 353, + }, + 120: { + "bands": [4, 10, 6], + "broken": 359, + "damage": True, + "evaluated": True, + "outside": 0.9116022099447514, + "status": "pass", + "thresholds_pass": True, + "total": 0.8253968253968254, + "unbroken": 353, + }, + }.get( + frame, + { + "bands": [0, 0, 0], + "broken": 0, + "damage": False, + "evaluated": False, + "outside": 1.0, + "status": "pre-evaluation", + "thresholds_pass": False, + "total": 1.0, + "unbroken": 712, + }, + ) + return { + "ball_positions": [[0.0, 1.0, 2.0]] * 3, + "ball_velocities": [[3.0, 4.0, 5.0]] * 3, + "broken_joints": expected["broken"], + "contact_count": 12, + "evaluated": expected["evaluated"], + "frame": frame, + "impact_band_displaced_counts": expected["bands"], + "last_step_iterations": 20, + "max_brick_displacement": 3.0, + "outside_brick_count": 181, + "outside_retained_fraction": expected["outside"], + "status": expected["status"], + "threshold_checks": { + "finite_state": True, + "fracture_activated": True, + "fracture_localized": True, + "outside_wall_retained": True, + "damage_in_three_impact_bands": expected["damage"], + "total_wall_retained": True, + }, + "thresholds_pass": expected["thresholds_pass"], + "total_retained_fraction": expected["total"], + "unbroken_joints": expected["unbroken"], + "world_time": frame / 60.0, + } + + +def _metrics(module, frame: int) -> dict[str, Any]: + return { + "ball_count": 3, + "break_force": 8500.0, + "breakable_joints": 712, + "brick_count": 252, + "collision_shapes": 256, + "executor": "World.step default", + "outcome": _outcome(frame), + "outcome_oracle": dict(module.OUTCOME_ORACLE), + "paper_locator": module.PAPER_LOCATOR, + "rigid_bodies": 256, + "rigid_body_solver": "AVBD", + "rigid_constraint_options": {"iterations": 20}, + "resolved_configuration": [ + { + "domain": "rigid-body", + "reason": "as requested", + "requested": "avbd", + "resolved": "avbd", + }, + { + "domain": "rigid-contact", + "reason": "as requested", + "requested": "avbd", + "resolved": "avbd", + }, + { + "domain": "rigid-pair-constraint", + "reason": "as requested", + "requested": "avbd", + "resolved": "avbd", + }, + { + "domain": "rigid-constraint-iterations", + "reason": "public rigid constraint iteration budget", + "requested": "20", + "resolved": "20", + }, + ], + "row": module.SCENE_ID, + "scene_spec_fingerprint": "0123456789abcdef", + "solver": "public_avbd", + "time_step_ms": 1000.0 / 60.0, + "view_report": { + "camera": { + "azimuth": -1.5707963267948966, + "distance": 11.0, + "elevation": 0.0, + "target": [0.0, 0.0, 1.4], + }, + "focus": [ + "avbd_paper_wall_brick_00_00_visual", + "avbd_paper_wall_brick_00_20_visual", + "avbd_paper_wall_brick_11_00_visual", + "avbd_paper_wall_brick_11_20_visual", + ], + "issues": [], + "metrics": { + "ambiguity_iou": 0.0, + "center_visible": True, + "corner_coverage": 1.0, + "occlusion_fraction": 0.0, + "subject_fraction": 0.5, + }, + "pass": True, + "schema_version": "dart.view_report/v1", + "score": 1.0, + "size": [8, 6], + }, + } + + +def _write_capture(module, tmp_path: Path, frame: int, label: str) -> tuple[Path, Path]: + directory = tmp_path / label + directory.mkdir() + screenshot = directory / f"{module.SCENE_ID}_{label}.png" + _write_png(screenshot) + metrics_log = directory / "scene_metrics.jsonl" + events = [ + { + "event": "scene_capture_metrics", + "frame": event_frame, + "metrics": _metrics(module, event_frame), + "scene": module.SCENE_ID, + "source": "py-demo-scene", + } + for event_frame in range(1, frame + 1) + ] + latest_event = events[-1] + metrics_log.write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + manifest = { + "artifacts": { + "events": None, + "frames": str(directory / "png_frames"), + "scene_metrics_events": str(metrics_log), + "screenshot": str(screenshot), + "video": None, + }, + "camera": { + "distance": 11.0, + "target": [0.0, 0.0, 1.4], + "view": "front", + }, + "capture": { + "converted_frames": frame, + "height": 6, + "requested_frames": frame, + "width": 8, + }, + "capture_label": label, + "force_drag": None, + "resolved_solver_identity": { + "executor": "World.step default", + "solver": "public_avbd", + "source": "scene_capture_metrics.latest.metrics", + }, + "scene": module.SCENE_ID, + "scene_metrics": { + "event_count": frame, + "latest": latest_event, + }, + "schema_version": 1, + } + manifest_path = directory / "manifest.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + verdict = { + "checks": { + "contrast": {"pass": frame == 120}, + "non_blank": {"pass": True}, + }, + "image": { + "height": 6, + "path": str(screenshot), + "width": 8, + }, + "machine_scope": "pixel-integrity", + "metadata": { + "frame": str(frame), + "scene": module.SCENE_ID, + "view": "front", + }, + "pass": True, + "schema_version": "dart.image_verdict/v1", + "semantic_review": { + "performed_by_this_tool": False, + "required": True, + }, + } + verdict_path = directory / "image_verdict.json" + verdict_path.write_text(json.dumps(verdict), encoding="utf-8") + return manifest_path, verdict_path + + +def _write_benchmark(module, tmp_path: Path) -> Path: + fingerprint = int("0123456789abcdef", 16) + rows = [] + for aggregate, real_time, cpu_time in ( + ("mean", 7_800_000.0, 7_790_000.0), + ("median", 7_750_000.0, 7_740_000.0), + ("stddev", 110_000.0, 109_000.0), + ("cv", 0.014, 0.014), + ): + row = { + "aggregate_name": aggregate, + "aggregate_unit": "percentage" if aggregate == "cv" else "time", + "cpu_time": cpu_time, + "iterations": 5, + "name": f"{module.BENCHMARK_RUN}_{aggregate}", + "real_time": real_time, + "repetitions": 5, + "run_name": module.BENCHMARK_RUN, + "run_type": "aggregate", + "time_unit": "ns", + } + if aggregate in ("mean", "median"): + row.update( + { + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "impacting_balls": 3.0, + "public_avbd_family": 1.0, + "resolved_rigid_body_avbd": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + "resolved_rigid_contact_avbd": 1.0, + "resolved_rigid_pair_constraint_avbd": 1.0, + "rigid_constraint_iterations": 20.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": float(fingerprint >> 32), + "scene_spec_fingerprint_lo": float( + fingerprint & 0xFFFFFFFF + ), + "trajectory_frames": 120.0, + } + ) + rows.append(row) + path = tmp_path / "benchmark.json" + path.write_text( + json.dumps( + { + "benchmarks": rows, + "context": { + "executable": ( + "build/default/cpp/Release/bin/" + "bm_avbd_rigid_fixed_joint" + ), + "host_name": "test-host", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "test", + "mhz_per_cpu": 5000, + "num_cpus": 8, + }, + } + ), + encoding="utf-8", + ) + return path + + +def _write_inputs(module, tmp_path: Path, monkeypatch) -> dict[str, Path]: + impact_manifest, impact_verdict = _write_capture( + module, tmp_path, 60, "impact" + ) + outcome_manifest, outcome_verdict = _write_capture( + module, tmp_path, 120, "outcome" + ) + paper_pdf = tmp_path / "avbd.pdf" + paper_pdf.write_bytes(b"test AVBD paper") + paper_figure = tmp_path / "paper-10.png" + _write_png(paper_figure, width=10, height=12) + monkeypatch.setattr(module, "PAPER_PDF_SHA256", _sha256(paper_pdf)) + monkeypatch.setattr(module, "PAPER_FIGURE_SHA256", _sha256(paper_figure)) + + impact_screenshot = ( + impact_manifest.parent / f"{module.SCENE_ID}_impact.png" + ) + outcome_screenshot = ( + outcome_manifest.parent / f"{module.SCENE_ID}_outcome.png" + ) + review = { + "inspected_images": [ + { + "file": str(impact_screenshot), + "role": "impact_frame_60", + "sha256": _sha256(impact_screenshot), + }, + { + "file": str(outcome_screenshot), + "role": "outcome_frame_120", + "sha256": _sha256(outcome_screenshot), + }, + { + "file": str(paper_figure), + "role": "paper_figure_13_reference", + "sha256": _sha256(paper_figure), + }, + ], + "claim_and_expected_observation": ( + "Three impact bands should show damage while large outside wall " + "regions remain attached and standing." + ), + "not_proven_and_limitations": ( + "The images do not prove exact unpublished source constants, " + "other solver rows, CUDA parity, or timing parity." + ), + "reconciliation_and_verdict": ( + "PASS: both engine ViewReports pass, the numeric oracle passes at " + "frame 120, and the visible retained-wall damage agrees." + ), + "reviewer_capability": "native image input at original detail", + "scene": module.SCENE_ID, + "schema_version": "dart.visual_semantic_review/v1", + "text_oracle": ( + "At frame 120 all finite, fracture, impact-band damage, and " + "retention threshold checks pass." + ), + "verdict": "pass", + "visible_observation": ( + "The fixed front views show damage at each target band and " + "substantial upper and side wall regions still standing." + ), + } + review_path = tmp_path / "visual_review.json" + review_path.write_text(json.dumps(review), encoding="utf-8") + return { + "benchmark_json": _write_benchmark(module, tmp_path), + "impact_capture_manifest": impact_manifest, + "impact_image_verdict_json": impact_verdict, + "outcome_capture_manifest": outcome_manifest, + "outcome_image_verdict_json": outcome_verdict, + "visual_review_json": review_path, + "paper_pdf": paper_pdf, + "paper_figure_image": paper_figure, + } + + +def test_make_packet_records_public_avbd_figure13_evidence( + tmp_path: Path, + monkeypatch, +) -> None: + module = _load_packet_module() + inputs = _write_inputs(module, tmp_path, monkeypatch) + + packet = module.make_packet(**inputs) + + assert packet["schema_version"] == 3 + assert packet["resolved_solver_identity"] == { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": ( + "engine World.resolved_configuration in both captures plus " + "benchmark runtime resolved-configuration counters" + ), + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "avbd", + "rigid_point_joint_solver": "avbd", + } + assert packet["visual_evidence"]["impact"]["scene_metrics"]["frame"] == 60 + assert ( + packet["visual_evidence"]["outcome"]["scene_metrics"]["outcome"]["status"] + == "pass" + ) + assert packet["benchmark"]["stability"]["repetitions"] == 5 + assert packet["benchmark"]["scene_spec_fingerprint"] == "0123456789abcdef" + assert packet["source_provenance"]["files"] + assert packet["target"]["complete_paper_reproduction"] is False + rendered = json.dumps(packet) + assert str(tmp_path) not in rendered + assert "no reference ratio or speedup claim is made" in rendered + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("camera", "serialized camera"), + ("view_report_gate", "ambiguity_iou fails"), + ("solver", "resolve public_avbd"), + ("oracle", "outcome threshold_checks"), + ("benchmark", "public_avbd_family"), + ("events_empty", "expected 60 scene metric events"), + ("events_unrelated", "event must be"), + ("events_count", "expected 60 scene metric events"), + ("events_order", "frames must be exactly"), + ("events_inner_frame", "outcome frame"), + ("events_inner_time", "outcome world_time"), + ("events_manifest_mismatch", "manifest latest scene metric event"), + ("review", "sha256"), + ("paper", "paper PDF sha256"), + ], +) +def test_make_packet_fails_closed_on_mismatched_evidence( + tmp_path: Path, + monkeypatch, + mutation: str, + message: str, +) -> None: + module = _load_packet_module() + inputs = _write_inputs(module, tmp_path, monkeypatch) + + if mutation == "camera": + path = inputs["impact_capture_manifest"] + data = json.loads(path.read_text()) + del data["camera"] + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "view_report_gate": + path = inputs["impact_capture_manifest"] + data = json.loads(path.read_text()) + data["scene_metrics"]["latest"]["metrics"]["view_report"]["metrics"][ + "ambiguity_iou" + ] = 0.75 + path.write_text(json.dumps(data), encoding="utf-8") + events_path = Path(data["artifacts"]["scene_metrics_events"]) + events = [ + json.loads(line) + for line in events_path.read_text().splitlines() + if line + ] + events[-1]["metrics"]["view_report"]["metrics"]["ambiguity_iou"] = 0.75 + events_path.write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + elif mutation == "solver": + path = inputs["impact_capture_manifest"] + data = json.loads(path.read_text()) + data["resolved_solver_identity"]["solver"] = "sequential_impulse" + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "oracle": + path = inputs["outcome_capture_manifest"] + data = json.loads(path.read_text()) + data["scene_metrics"]["latest"]["metrics"]["outcome"]["threshold_checks"][ + "fracture_localized" + ] = False + path.write_text(json.dumps(data), encoding="utf-8") + events_path = Path(data["artifacts"]["scene_metrics_events"]) + events = [ + json.loads(line) + for line in events_path.read_text().splitlines() + if line + ] + events[-1]["metrics"]["outcome"]["threshold_checks"][ + "fracture_localized" + ] = False + events_path.write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + elif mutation == "benchmark": + path = inputs["benchmark_json"] + data = json.loads(path.read_text()) + data["benchmarks"][0]["public_avbd_family"] = 0.0 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation.startswith("events_"): + manifest = json.loads(inputs["impact_capture_manifest"].read_text()) + events_path = Path(manifest["artifacts"]["scene_metrics_events"]) + events = [ + json.loads(line) + for line in events_path.read_text().splitlines() + if line + ] + if mutation == "events_empty": + events = [] + elif mutation == "events_unrelated": + events[0]["event"] = "unrelated" + elif mutation == "events_count": + events.pop() + elif mutation == "events_order": + events[0], events[1] = events[1], events[0] + elif mutation == "events_inner_frame": + events[0]["metrics"]["outcome"]["frame"] = 2 + elif mutation == "events_inner_time": + events[0]["metrics"]["outcome"]["world_time"] = 1.0 + elif mutation == "events_manifest_mismatch": + manifest["scene_metrics"]["latest"]["metrics"]["breakable_joints"] = 0 + inputs["impact_capture_manifest"].write_text( + json.dumps(manifest), + encoding="utf-8", + ) + events_path.write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + elif mutation == "review": + path = inputs["visual_review_json"] + data = json.loads(path.read_text()) + data["inspected_images"][0]["sha256"] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "paper": + inputs["paper_pdf"].write_bytes(b"not the validated paper") + + with pytest.raises(module.AvbdPaperBreakableWallPacketError, match=message): + module.make_packet(**inputs) + + +def test_main_writes_validated_packet( + tmp_path: Path, + monkeypatch, +) -> None: + module = _load_packet_module() + inputs = _write_inputs(module, tmp_path, monkeypatch) + output = tmp_path / "packet.json" + args = [] + for name, path in inputs.items(): + args.extend(("--" + name.replace("_", "-"), str(path))) + args.extend(("--output", str(output))) + + assert module.main(args) == 0 + assert json.loads(output.read_text())["packet"] == "avbd_paper_breakable_wall" diff --git a/python/tests/unit/test_write_avbd_paper_scale_high_ratio_iteration_sweep_packet.py b/python/tests/unit/test_write_avbd_paper_scale_high_ratio_iteration_sweep_packet.py index e25c450c942f3..5639cec84fb05 100644 --- a/python/tests/unit/test_write_avbd_paper_scale_high_ratio_iteration_sweep_packet.py +++ b/python/tests/unit/test_write_avbd_paper_scale_high_ratio_iteration_sweep_packet.py @@ -129,11 +129,12 @@ def test_avbd_paper_scale_high_ratio_iteration_sweep_packet_records_plot_data( ) packet = json.loads(output.read_text()) - assert packet["schema_version"] == 2 + assert packet["schema_version"] == 3 assert packet["packet"] == "avbd_paper_scale_high_ratio_iteration_sweep" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, "recorded_from": "paper-scale high-ratio iteration benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd", } diff --git a/scripts/avbd_packet_schema.py b/scripts/avbd_packet_schema.py index 54de8c1d758a3..1ff83bfecc177 100644 --- a/scripts/avbd_packet_schema.py +++ b/scripts/avbd_packet_schema.py @@ -22,18 +22,22 @@ from typing import Any # Current AVBD packet schema version. Packets written at this version or -# newer must carry RESOLVED_SOLVER_IDENTITY_KEY. -AVBD_PACKET_SCHEMA_VERSION = 2 +# newer must carry RESOLVED_SOLVER_IDENTITY_KEY and record how rigid contact +# selected the reported solver family. +AVBD_PACKET_SCHEMA_VERSION = 3 # First schema version that requires the resolved-solver-identity field. SOLVER_IDENTITY_MIN_SCHEMA_VERSION = 2 +# First schema version that requires the rigid-contact selection source. +RIGID_CONTACT_SELECTION_MIN_SCHEMA_VERSION = 3 + RESOLVED_SOLVER_IDENTITY_KEY = "resolved_solver_identity" +RIGID_CONTACT_SELECTION_KEY = "rigid_contact_selection" # The contact path that actually resolved rigid-rigid contacts in the timed -# scene. "avbd" is valid only when the scene emplaces the internal AVBD -# rigid-contact opt-in config so that every active contact has a configured -# body; contact-free scenes record "none". +# scene. "avbd" may be selected by the public world solver family or by the +# compatibility-only internal body opt-in; contact-free scenes record "none". ALLOWED_RIGID_CONTACT_SOLVERS = ( "avbd", "boxed_lcp", @@ -45,6 +49,13 @@ # rows; joint-free scenes record "none". ALLOWED_RIGID_POINT_JOINT_SOLVERS = ("avbd", "none") +ALLOWED_RIGID_CONTACT_SELECTIONS = ( + "body_opt_in", + "contact_solver_method", + "not_applicable", + "world_solver_family", +) + _REQUIRED_ENUM_FIELDS: tuple[tuple[str, tuple[str, ...]], ...] = ( ("rigid_contact_solver", ALLOWED_RIGID_CONTACT_SOLVERS), ("rigid_point_joint_solver", ALLOWED_RIGID_POINT_JOINT_SOLVERS), @@ -108,6 +119,24 @@ def resolved_solver_identity_errors( "avbd_rigid_contact_config_emplaced must be a boolean" ) + selection = identity.get(RIGID_CONTACT_SELECTION_KEY) + selection_required = version >= RIGID_CONTACT_SELECTION_MIN_SCHEMA_VERSION + if selection is None: + if selection_required: + errors.append( + f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." + f"{RIGID_CONTACT_SELECTION_KEY} is required at schema_version " + f"{version}" + ) + elif not isinstance(selection, str) or selection not in ( + ALLOWED_RIGID_CONTACT_SELECTIONS + ): + errors.append( + f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." + f"{RIGID_CONTACT_SELECTION_KEY} must be one of " + f"{sorted(ALLOWED_RIGID_CONTACT_SELECTIONS)}, got {selection!r}" + ) + recorded_from = identity.get("recorded_from") if not isinstance(recorded_from, str) or not recorded_from: errors.append( @@ -115,15 +144,48 @@ def resolved_solver_identity_errors( "must be a non-empty string naming how the identity was captured" ) - if ( - isinstance(emplaced, bool) - and not emplaced - and identity.get("rigid_contact_solver") == "avbd" - ): + rigid_contact_solver = identity.get("rigid_contact_solver") + if selection is None: + if ( + isinstance(emplaced, bool) + and not emplaced + and rigid_contact_solver == "avbd" + ): + errors.append( + f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." + "rigid_contact_solver cannot be 'avbd' when " + "avbd_rigid_contact_config_emplaced is false" + ) + elif selection == "body_opt_in": + if rigid_contact_solver != "avbd" or emplaced is not True: + errors.append( + f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." + "rigid_contact_selection 'body_opt_in' requires " + "rigid_contact_solver 'avbd' and an emplaced private config" + ) + elif selection == "world_solver_family": + if rigid_contact_solver != "avbd" or emplaced is not False: + errors.append( + f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." + "rigid_contact_selection 'world_solver_family' requires " + "rigid_contact_solver 'avbd' without a private body config" + ) + elif selection == "contact_solver_method": + if ( + rigid_contact_solver not in ("boxed_lcp", "sequential_impulse") + or emplaced is not False + ): + errors.append( + f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." + "rigid_contact_selection 'contact_solver_method' requires " + "a boxed_lcp or sequential_impulse contact solver without " + "a private AVBD body config" + ) + elif selection == "not_applicable" and rigid_contact_solver != "none": errors.append( f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." - "rigid_contact_solver cannot be 'avbd' when " - "avbd_rigid_contact_config_emplaced is false" + "rigid_contact_selection 'not_applicable' requires " + "rigid_contact_solver 'none'" ) return errors @@ -142,29 +204,36 @@ def resolved_solver_identity_errors( # Report rigid-contact ``resolved`` family strings → packet enum values. _REPORT_RIGID_CONTACT_FAMILY_TO_PACKET = { + "avbd": "avbd", "sequential-impulse": "sequential_impulse", "boxed-lcp": "boxed_lcp", } -# The report appends this marker to the resolved rigid-contact family when a -# body carries the internal AVBD rigid-contact opt-in (the WP-091.1 silent -# substitution, made explicit in slice 2). -_REPORT_AVBD_CONTACT_MARKER = "avbd" +# A non-AVBD public family may still report this exact suffix when a body +# carries the compatibility-only internal AVBD rigid-contact opt-in. +_REPORT_AVBD_CONTACT_OPT_IN_SUFFIX = " + avbd (opt-in)" def resolved_rigid_contact_solver_from_report(resolved_family: str) -> str: """Map a report rigid-contact ``resolved`` family string to the packet enum. ``resolved_family`` is the string the C++ report records for the - ``rigid-contact`` domain (e.g. ``"sequential-impulse"``, ``"boxed-lcp"``, - or a ``"... + avbd (opt-in)"`` substitution). Raises ``ValueError`` for an - empty or unrecognized family so a report-vocabulary change cannot silently - produce an out-of-contract packet enum. + ``rigid-contact`` domain (e.g. ``"avbd"``, ``"sequential-impulse"``, + ``"boxed-lcp"``, or a ``"... + avbd (opt-in)"`` substitution). Raises + ``ValueError`` for an empty or unrecognized family so a report-vocabulary + change cannot silently produce an out-of-contract packet enum. """ text = resolved_family.strip().lower() if not text: raise ValueError("resolved rigid-contact family must be non-empty") - if _REPORT_AVBD_CONTACT_MARKER in text: + if text.endswith(_REPORT_AVBD_CONTACT_OPT_IN_SUFFIX): + base_family = text[: -len(_REPORT_AVBD_CONTACT_OPT_IN_SUFFIX)] + if base_family not in ("boxed-lcp", "sequential-impulse"): + raise ValueError( + "unrecognized AVBD opt-in base rigid-contact family " + f"{base_family!r}; expected 'boxed-lcp' or " + "'sequential-impulse'" + ) return "avbd" try: return _REPORT_RIGID_CONTACT_FAMILY_TO_PACKET[text] @@ -172,7 +241,7 @@ def resolved_rigid_contact_solver_from_report(resolved_family: str) -> str: raise ValueError( f"unrecognized resolved rigid-contact family {resolved_family!r}; " f"known: {sorted(_REPORT_RIGID_CONTACT_FAMILY_TO_PACKET)} " - f"(plus an '{_REPORT_AVBD_CONTACT_MARKER}' substitution marker)" + f"(plus an '{_REPORT_AVBD_CONTACT_OPT_IN_SUFFIX}' suffix)" ) from None @@ -182,6 +251,7 @@ def make_resolved_solver_identity( rigid_point_joint_solver: str, avbd_rigid_contact_config_emplaced: bool, recorded_from: str, + rigid_contact_selection: str | None = None, ) -> dict[str, Any]: """Build a validated ``resolved_solver_identity`` object from the report. @@ -198,9 +268,18 @@ def make_resolved_solver_identity( resolved_rigid_contact_family ) + if rigid_contact_selection is None: + if rigid_contact_solver == "none": + rigid_contact_selection = "not_applicable" + elif rigid_contact_solver in ("boxed_lcp", "sequential_impulse"): + rigid_contact_selection = "contact_solver_method" + elif avbd_rigid_contact_config_emplaced: + rigid_contact_selection = "body_opt_in" + identity: dict[str, Any] = { "avbd_rigid_contact_config_emplaced": bool(avbd_rigid_contact_config_emplaced), "recorded_from": recorded_from, + RIGID_CONTACT_SELECTION_KEY: rigid_contact_selection, "rigid_contact_solver": rigid_contact_solver, "rigid_point_joint_solver": rigid_point_joint_solver, } diff --git a/scripts/benchmark_display_names.py b/scripts/benchmark_display_names.py index 424615be8c330..d4fa6f0dfec95 100644 --- a/scripts/benchmark_display_names.py +++ b/scripts/benchmark_display_names.py @@ -183,6 +183,9 @@ class Surface: "BM_AvbdDemo3dSoftBodyStep": Surface("AVBD demo3d soft body step", FAMILY_AVBD), "BM_AvbdDemo3dBridgeStep": Surface("AVBD demo3d bridge step", FAMILY_AVBD), "BM_AvbdDemo3dBreakableStep": Surface("AVBD demo3d breakable step", FAMILY_AVBD), + "BM_AvbdPaperBreakableWallStep": Surface( + "AVBD paper breakable-wall step", FAMILY_AVBD + ), "BM_AvbdRigidBreakableJointStep": Surface( "AVBD breakable-joint step", FAMILY_AVBD, ("breakable joints",) ), diff --git a/scripts/capture_py_demo.py b/scripts/capture_py_demo.py index 81a4e314c4270..df45886e16c56 100644 --- a/scripts/capture_py_demo.py +++ b/scripts/capture_py_demo.py @@ -4323,6 +4323,7 @@ def main(argv: list[str] | None = None) -> int: "screenshot": str(screenshot_png), "video": None, }, + "camera": camera_options, "capture": capture_metadata, "resolved_solver_identity": None, "scene_metrics": None, diff --git a/scripts/check_avbd_packets.py b/scripts/check_avbd_packets.py index 3d376e3b78fda..b4aee8df3544e 100644 --- a/scripts/check_avbd_packets.py +++ b/scripts/check_avbd_packets.py @@ -2,11 +2,11 @@ """Validate committed AVBD evidence packets against the shared schema. Enforces PLAN-091 WP-091.1: AVBD packets at the current schema version -must machine-record the resolved solver configuration that actually ran -(``resolved_solver_identity``). Packets committed before the identity -contract stay readable through a legacy allowlist, but new packet files -must be written at the current schema version. The field contract lives -in ``scripts/avbd_packet_schema.py``. +must machine-record the resolved solver configuration and the rigid-contact +selection source that actually ran (``resolved_solver_identity``). Packets +committed before the identity contract stay readable through a legacy +allowlist, but new packet files must be written at the current schema version. +The field contract lives in ``scripts/avbd_packet_schema.py``. """ from __future__ import annotations diff --git a/scripts/run_performance_dashboard_benchmarks.py b/scripts/run_performance_dashboard_benchmarks.py index df7ab154a79f4..87768641fc719 100644 --- a/scripts/run_performance_dashboard_benchmarks.py +++ b/scripts/run_performance_dashboard_benchmarks.py @@ -141,6 +141,7 @@ class BenchmarkSpec: "BM_AvbdDemo3dSoftBodyStep$|" "BM_AvbdDemo3dBridgeStep$|" "BM_AvbdDemo3dBreakableStep$|" + "BM_AvbdPaperBreakableWallStep/iterations:120$|" "BM_AvbdArticulatedHighRatioChainStep$|" "BM_AvbdPaperScaleHighRatioChainStep$|" "BM_AvbdPaperScaleHighRatioChainIterationSweep/.*|" diff --git a/scripts/write_avbd_friction_coefficient_sweep_plot.py b/scripts/write_avbd_friction_coefficient_sweep_plot.py index aa40803d041bf..643e4f0cde2fe 100644 --- a/scripts/write_avbd_friction_coefficient_sweep_plot.py +++ b/scripts/write_avbd_friction_coefficient_sweep_plot.py @@ -25,7 +25,7 @@ PLOT_RIGHT = 682 PLOT_TOP = 84 PLOT_BOTTOM = 292 -SUPPORTED_SCHEMA_VERSIONS = (1, 2) +SUPPORTED_SCHEMA_VERSIONS = (1, 2, 3) class AvbdFrictionCoefficientSweepPlotError(RuntimeError): @@ -69,7 +69,7 @@ def _validate_packet(packet_path: Path) -> list[dict[str, float]]: packet = _load_json(packet_path) if packet.get("schema_version") not in SUPPORTED_SCHEMA_VERSIONS: raise AvbdFrictionCoefficientSweepPlotError( - "packet schema_version must be 1 or 2" + "packet schema_version must be 1, 2, or 3" ) if packet.get("packet") != "avbd_friction_coefficient_sweep": raise AvbdFrictionCoefficientSweepPlotError( diff --git a/scripts/write_avbd_paper_breakable_wall_packet.py b/scripts/write_avbd_paper_breakable_wall_packet.py new file mode 100644 index 0000000000000..c10d752dd5790 --- /dev/null +++ b/scripts/write_avbd_paper_breakable_wall_packet.py @@ -0,0 +1,1424 @@ +#!/usr/bin/env python3 +"""Write the validated AVBD paper Figure 13 breakable-wall packet.""" + +from __future__ import annotations + +import argparse +import json +import math +import struct +import sys +from hashlib import sha256 +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from avbd_packet_schema import ( # noqa: E402 + AVBD_PACKET_SCHEMA_VERSION, + make_resolved_solver_identity, +) + +DEFAULT_OUTPUT = Path( + "docs/plans/104-vertex-block-descent-solver/" + "avbd-paper-breakable-wall-packet.json" +) +SCENE_ID = "avbd_paper_breakable_wall" +BENCHMARK_NAME = "BM_AvbdPaperBreakableWallStep" +BENCHMARK_RUN = f"{BENCHMARK_NAME}/iterations:120" +PAPER_LOCATOR = "Section 5.4 and Figure 13, PDF page 10" +PAPER_PDF_SHA256 = "7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6" +PAPER_FIGURE_SHA256 = "040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b" + +TIME_STEP = 1.0 / 60.0 +RIGID_CONSTRAINT_ITERATIONS = 20 +BRICK_COUNT = 252 +RIGID_BODIES = 256 +COLLISION_SHAPES = 256 +BREAKABLE_JOINTS = 712 +IMPACTING_BALLS = 3 +BREAK_FORCE = 8500.0 +CAMERA_DISTANCE = 11.0 +CAMERA_TARGET = (0.0, 0.0, 1.4) +CAMERA_VIEW = "front" +VIEW_FOCUS = ( + "avbd_paper_wall_brick_00_00_visual", + "avbd_paper_wall_brick_00_20_visual", + "avbd_paper_wall_brick_11_00_visual", + "avbd_paper_wall_brick_11_20_visual", +) + +OUTCOME_ORACLE = { + "displacement_threshold": 0.5, + "evaluation_frame": 120, + "impact_band_radius": 0.85, + "maximum_broken_joints": 500, + "minimum_broken_joints": 300, + "minimum_displaced_bricks_per_impact_band": 4, + "minimum_outside_retained_fraction": 0.8, + "minimum_total_retained_fraction": 0.7, + "minimum_unbroken_joints": 200, + "outside_radius": 1.15, +} + +EXPECTED_OUTCOMES = { + 60: { + "broken_joints": 359, + "evaluated": False, + "impact_band_displaced_counts": [3, 9, 4], + "outside_retained_fraction": 0.9337016574585635, + "status": "pre-evaluation", + "threshold_checks": { + "damage_in_three_impact_bands": False, + "finite_state": True, + "fracture_activated": True, + "fracture_localized": True, + "outside_wall_retained": True, + "total_wall_retained": True, + }, + "thresholds_pass": False, + "total_retained_fraction": 0.8611111111111112, + "unbroken_joints": 353, + }, + 120: { + "broken_joints": 359, + "evaluated": True, + "impact_band_displaced_counts": [4, 10, 6], + "outside_retained_fraction": 0.9116022099447514, + "status": "pass", + "threshold_checks": { + "damage_in_three_impact_bands": True, + "finite_state": True, + "fracture_activated": True, + "fracture_localized": True, + "outside_wall_retained": True, + "total_wall_retained": True, + }, + "thresholds_pass": True, + "total_retained_fraction": 0.8253968253968254, + "unbroken_joints": 353, + }, +} + +RESOLVED_SOLVER_IDENTITY = make_resolved_solver_identity( + resolved_rigid_contact_family="avbd", + rigid_point_joint_solver="avbd", + avbd_rigid_contact_config_emplaced=False, + rigid_contact_selection="world_solver_family", + recorded_from=( + "engine World.resolved_configuration in both captures plus benchmark " + "runtime resolved-configuration counters" + ), +) + +REPO_ROOT = SCRIPT_DIR.parent +SOURCE_PATHS = ( + Path("dart/gui/view_quality.cpp"), + Path("dart/gui/view_quality.hpp"), + Path("dart/simulation/world.cpp"), + Path("dart/simulation/world.hpp"), + Path("dart/simulation/world_options.hpp"), + Path("dart/simulation/compute/rigid_body_contact_stage.cpp"), + Path("dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp"), + Path("dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp"), + Path("python/dartpy/_view_quality.py"), + Path("python/examples/demos/scenes/avbd_paper_breakable_wall.py"), + Path("tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp"), + Path("scripts/write_avbd_paper_breakable_wall_packet.py"), +) + + +class AvbdPaperBreakableWallPacketError(RuntimeError): + """Raised when an input cannot support the packet's claims.""" + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--benchmark-json", type=Path, required=True) + parser.add_argument("--impact-capture-manifest", type=Path, required=True) + parser.add_argument("--impact-image-verdict-json", type=Path, required=True) + parser.add_argument("--outcome-capture-manifest", type=Path, required=True) + parser.add_argument("--outcome-image-verdict-json", type=Path, required=True) + parser.add_argument("--visual-review-json", type=Path, required=True) + parser.add_argument("--paper-pdf", type=Path, required=True) + parser.add_argument("--paper-figure-image", type=Path, required=True) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + return parser.parse_args(argv) + + +def _load_json(path: Path) -> dict[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise AvbdPaperBreakableWallPacketError(f"{path}: file not found") from exc + except json.JSONDecodeError as exc: + raise AvbdPaperBreakableWallPacketError(f"{path}: invalid JSON: {exc}") from exc + if not isinstance(data, dict): + raise AvbdPaperBreakableWallPacketError( + f"{path}: top-level JSON value must be an object" + ) + return data + + +def _sha256(path: Path) -> str: + digest = sha256() + try: + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + except FileNotFoundError as exc: + raise AvbdPaperBreakableWallPacketError(f"{path}: file not found") from exc + return digest.hexdigest() + + +def _source_provenance() -> dict[str, Any]: + combined = sha256() + files = [] + for relative_path in SOURCE_PATHS: + path = REPO_ROOT / relative_path + try: + payload = path.read_bytes() + except FileNotFoundError as exc: + raise AvbdPaperBreakableWallPacketError( + f"{relative_path}: source file not found" + ) from exc + encoded_path = relative_path.as_posix().encode("utf-8") + combined.update(struct.pack(" tuple[int, int]: + try: + with path.open("rb") as file: + header = file.read(24) + except FileNotFoundError as exc: + raise AvbdPaperBreakableWallPacketError(f"{path}: image not found") from exc + if ( + len(header) != 24 + or header[:8] != b"\x89PNG\r\n\x1a\n" + or header[12:16] != b"IHDR" + ): + raise AvbdPaperBreakableWallPacketError(f"{path}: expected a PNG image") + width, height = struct.unpack(">II", header[16:24]) + if width < 1 or height < 1: + raise AvbdPaperBreakableWallPacketError(f"{path}: invalid PNG dimensions") + return width, height + + +def _finite_number(value: object, label: str) -> float: + if ( + not isinstance(value, (int, float)) + or isinstance(value, bool) + or not math.isfinite(float(value)) + ): + raise AvbdPaperBreakableWallPacketError(f"{label} must be a finite number") + return float(value) + + +def _require_close( + value: object, + expected: float, + label: str, + *, + absolute_tolerance: float = 1e-12, +) -> None: + actual = _finite_number(value, label) + if not math.isclose( + actual, + expected, + rel_tol=0.0, + abs_tol=absolute_tolerance, + ): + raise AvbdPaperBreakableWallPacketError( + f"{label} must be {expected:g}, got {actual:g}" + ) + + +def _require_exact(value: object, expected: object, label: str) -> None: + if value != expected: + raise AvbdPaperBreakableWallPacketError( + f"{label} must be {expected!r}, got {value!r}" + ) + + +def _artifact_path(manifest_path: Path, value: object, label: str) -> Path: + if not isinstance(value, str) or not value: + raise AvbdPaperBreakableWallPacketError(f"capture manifest missing {label}") + path = Path(value) + if not path.is_absolute(): + path = manifest_path.parent / path + return path + + +def _validate_finite_tree(value: object, label: str) -> None: + if isinstance(value, bool) or value is None or isinstance(value, str): + return + if isinstance(value, (int, float)): + _finite_number(value, label) + return + if isinstance(value, list): + for index, item in enumerate(value): + _validate_finite_tree(item, f"{label}[{index}]") + return + if isinstance(value, dict): + for key, item in value.items(): + _validate_finite_tree(item, f"{label}.{key}") + return + raise AvbdPaperBreakableWallPacketError( + f"{label} contains unsupported value {value!r}" + ) + + +def _validate_camera(manifest: dict[str, Any]) -> dict[str, Any]: + camera = manifest.get("camera") + if not isinstance(camera, dict): + raise AvbdPaperBreakableWallPacketError( + "capture manifest missing serialized camera" + ) + _require_exact(camera.get("view"), CAMERA_VIEW, "camera view") + _require_close(camera.get("distance"), CAMERA_DISTANCE, "camera distance") + target = camera.get("target") + if not isinstance(target, list) or len(target) != 3: + raise AvbdPaperBreakableWallPacketError( + "camera target must contain three coordinates" + ) + for index, expected in enumerate(CAMERA_TARGET): + _require_close(target[index], expected, f"camera target[{index}]") + return { + "distance": CAMERA_DISTANCE, + "target": list(CAMERA_TARGET), + "view": CAMERA_VIEW, + } + + +def _validate_scene_spec_fingerprint(value: object, label: str) -> str: + if not isinstance(value, str) or len(value) != 16: + raise AvbdPaperBreakableWallPacketError( + f"{label} must be a 16-character lowercase hexadecimal string" + ) + try: + parsed = int(value, 16) + except ValueError as exc: + raise AvbdPaperBreakableWallPacketError(f"{label} must be hexadecimal") from exc + if f"{parsed:016x}" != value: + raise AvbdPaperBreakableWallPacketError( + f"{label} must use canonical lowercase hexadecimal" + ) + return value + + +def _validate_resolved_configuration(metrics: dict[str, Any]) -> list[dict[str, str]]: + notes = metrics.get("resolved_configuration") + if not isinstance(notes, list): + raise AvbdPaperBreakableWallPacketError( + "scene metrics missing engine resolved_configuration" + ) + normalized = [] + for index, note in enumerate(notes): + if not isinstance(note, dict): + raise AvbdPaperBreakableWallPacketError( + f"resolved_configuration[{index}] must be an object" + ) + normalized_note = {} + for key in ("domain", "requested", "resolved", "reason"): + value = note.get(key) + if not isinstance(value, str) or not value: + raise AvbdPaperBreakableWallPacketError( + f"resolved_configuration[{index}].{key} must be non-empty" + ) + normalized_note[key] = value + normalized.append(normalized_note) + + expected = { + "rigid-body": ("avbd", "avbd"), + "rigid-contact": ("avbd", "avbd"), + "rigid-pair-constraint": ("avbd", "avbd"), + "rigid-constraint-iterations": ( + str(RIGID_CONSTRAINT_ITERATIONS), + str(RIGID_CONSTRAINT_ITERATIONS), + ), + } + for domain, (requested, resolved) in expected.items(): + matching = [ + note + for note in normalized + if note["domain"] == domain + and note["requested"] == requested + and note["resolved"] == resolved + ] + if len(matching) != 1: + raise AvbdPaperBreakableWallPacketError( + "engine resolved_configuration must contain exactly one " + f"{domain} {requested}->{resolved} note" + ) + return normalized + + +def _validate_view_report( + metrics: dict[str, Any], + *, + width: int, + height: int, +) -> dict[str, Any]: + report = metrics.get("view_report") + if not isinstance(report, dict): + raise AvbdPaperBreakableWallPacketError( + "scene metrics missing engine ViewReport" + ) + _require_exact( + report.get("schema_version"), + "dart.view_report/v1", + "ViewReport schema_version", + ) + _require_exact(report.get("pass"), True, "ViewReport pass") + _require_exact(report.get("issues"), [], "ViewReport issues") + _require_exact(report.get("size"), [width, height], "ViewReport size") + _require_exact(report.get("focus"), list(VIEW_FOCUS), "ViewReport focus") + camera = report.get("camera") + if not isinstance(camera, dict): + raise AvbdPaperBreakableWallPacketError("ViewReport missing camera") + _require_close( + camera.get("azimuth"), + -0.5 * math.pi, + "ViewReport front-camera azimuth", + ) + _require_close( + camera.get("elevation"), + 0.0, + "ViewReport front-camera elevation", + ) + _require_close(camera.get("distance"), CAMERA_DISTANCE, "ViewReport distance") + target = camera.get("target") + if not isinstance(target, list) or len(target) != 3: + raise AvbdPaperBreakableWallPacketError( + "ViewReport camera target must contain three coordinates" + ) + for index, expected in enumerate(CAMERA_TARGET): + _require_close(target[index], expected, f"ViewReport target[{index}]") + report_metrics = report.get("metrics") + if not isinstance(report_metrics, dict): + raise AvbdPaperBreakableWallPacketError("ViewReport missing metrics") + _require_exact( + report_metrics.get("center_visible"), + True, + "ViewReport center_visible", + ) + corner_coverage = _finite_number( + report_metrics.get("corner_coverage"), + "ViewReport corner_coverage", + ) + subject_fraction = _finite_number( + report_metrics.get("subject_fraction"), + "ViewReport subject_fraction", + ) + occlusion_fraction = _finite_number( + report_metrics.get("occlusion_fraction"), + "ViewReport occlusion_fraction", + ) + ambiguity_iou = _finite_number( + report_metrics.get("ambiguity_iou"), + "ViewReport ambiguity_iou", + ) + for key, value in ( + ("corner_coverage", corner_coverage), + ("subject_fraction", subject_fraction), + ("occlusion_fraction", occlusion_fraction), + ("ambiguity_iou", ambiguity_iou), + ): + if not 0.0 <= value <= 1.0: + raise AvbdPaperBreakableWallPacketError( + f"ViewReport {key} must be in [0, 1]" + ) + if corner_coverage < 0.999: + raise AvbdPaperBreakableWallPacketError( + "ViewReport corner_coverage fails the engine acceptance threshold" + ) + if not 0.015 <= subject_fraction <= 0.75: + raise AvbdPaperBreakableWallPacketError( + "ViewReport subject_fraction fails the engine acceptance band" + ) + if occlusion_fraction > 0.35: + raise AvbdPaperBreakableWallPacketError( + "ViewReport occlusion_fraction fails the engine acceptance threshold" + ) + if ambiguity_iou > 0.55: + raise AvbdPaperBreakableWallPacketError( + "ViewReport ambiguity_iou fails the engine acceptance threshold" + ) + score = _finite_number(report.get("score"), "ViewReport score") + if not 0.0 <= score <= 1.0: + raise AvbdPaperBreakableWallPacketError("ViewReport score must be in [0, 1]") + return report + + +def _read_scene_metric_events( + path: Path, + *, + expected_frame: int, +) -> list[dict[str, Any]]: + try: + lines = path.read_text(encoding="utf-8").splitlines() + except FileNotFoundError as exc: + raise AvbdPaperBreakableWallPacketError( + f"{path}: scene metrics event log not found" + ) from exc + + events = [] + for line_number, line in enumerate(lines, start=1): + if not line.strip(): + continue + try: + event = json.loads(line) + except json.JSONDecodeError as exc: + raise AvbdPaperBreakableWallPacketError( + f"{path}:{line_number}: invalid scene metric JSON: {exc}" + ) from exc + if not isinstance(event, dict): + raise AvbdPaperBreakableWallPacketError( + f"{path}:{line_number}: scene metric event must be an object" + ) + _require_exact( + event.get("event"), + "scene_capture_metrics", + f"{path}:{line_number} event", + ) + _require_exact( + event.get("scene"), + SCENE_ID, + f"{path}:{line_number} scene", + ) + _require_exact( + event.get("source"), + "py-demo-scene", + f"{path}:{line_number} source", + ) + metrics = event.get("metrics") + if not isinstance(metrics, dict): + raise AvbdPaperBreakableWallPacketError( + f"{path}:{line_number}: metrics must be an object" + ) + _validate_finite_tree(metrics, f"{path}:{line_number}.metrics") + outcome = metrics.get("outcome") + if not isinstance(outcome, dict): + raise AvbdPaperBreakableWallPacketError( + f"{path}:{line_number}: metrics.outcome must be an object" + ) + event_frame = event.get("frame") + if not isinstance(event_frame, int) or isinstance(event_frame, bool): + raise AvbdPaperBreakableWallPacketError( + f"{path}:{line_number}: frame must be an integer" + ) + _require_exact( + outcome.get("frame"), + event_frame, + f"{path}:{line_number} outcome frame", + ) + _require_close( + outcome.get("world_time"), + event_frame * TIME_STEP, + f"{path}:{line_number} outcome world_time", + ) + events.append(event) + + if len(events) != expected_frame: + raise AvbdPaperBreakableWallPacketError( + f"{path}: expected {expected_frame} scene metric events, " + f"got {len(events)}" + ) + frames = [event.get("frame") for event in events] + if frames != list(range(1, expected_frame + 1)): + raise AvbdPaperBreakableWallPacketError( + f"{path}: scene metric frames must be exactly 1..{expected_frame}" + ) + return events + + +def _validate_scene_metrics( + manifest: dict[str, Any], + *, + expected_frame: int, + height: int, + logged_latest: dict[str, Any], + width: int, +) -> dict[str, Any]: + summary = manifest.get("scene_metrics") + if not isinstance(summary, dict): + raise AvbdPaperBreakableWallPacketError( + "capture manifest missing scene_metrics" + ) + _require_exact( + summary.get("event_count"), + expected_frame, + "scene metric event_count", + ) + latest = summary.get("latest") + if not isinstance(latest, dict): + raise AvbdPaperBreakableWallPacketError( + "capture scene_metrics missing latest event" + ) + _require_exact(latest, logged_latest, "manifest latest scene metric event") + _require_exact(latest.get("scene"), SCENE_ID, "latest scene metric scene") + _require_exact(latest.get("frame"), expected_frame, "latest scene metric frame") + metrics = latest.get("metrics") + if not isinstance(metrics, dict): + raise AvbdPaperBreakableWallPacketError( + "capture latest scene metric payload must be an object" + ) + + exact_metrics = { + "ball_count": IMPACTING_BALLS, + "breakable_joints": BREAKABLE_JOINTS, + "brick_count": BRICK_COUNT, + "collision_shapes": COLLISION_SHAPES, + "executor": "World.step default", + "paper_locator": PAPER_LOCATOR, + "rigid_bodies": RIGID_BODIES, + "rigid_body_solver": "AVBD", + "row": SCENE_ID, + "solver": "public_avbd", + } + for key, expected in exact_metrics.items(): + _require_exact(metrics.get(key), expected, f"scene metrics {key}") + _require_close(metrics.get("break_force"), BREAK_FORCE, "scene break_force") + _require_close( + metrics.get("time_step_ms"), + TIME_STEP * 1000.0, + "scene time_step_ms", + ) + rigid_constraint_options = metrics.get("rigid_constraint_options") + if not isinstance(rigid_constraint_options, dict): + raise AvbdPaperBreakableWallPacketError( + "scene metrics missing rigid_constraint_options" + ) + _require_exact( + rigid_constraint_options, + {"iterations": RIGID_CONSTRAINT_ITERATIONS}, + "scene rigid_constraint_options", + ) + resolved_configuration = _validate_resolved_configuration(metrics) + view_report = _validate_view_report(metrics, width=width, height=height) + scene_spec_fingerprint = _validate_scene_spec_fingerprint( + metrics.get("scene_spec_fingerprint"), + "scene metrics scene_spec_fingerprint", + ) + + oracle = metrics.get("outcome_oracle") + if not isinstance(oracle, dict): + raise AvbdPaperBreakableWallPacketError("scene metrics missing outcome_oracle") + for key, expected in OUTCOME_ORACLE.items(): + if isinstance(expected, float): + _require_close(oracle.get(key), expected, f"outcome oracle {key}") + else: + _require_exact(oracle.get(key), expected, f"outcome oracle {key}") + + outcome = metrics.get("outcome") + if not isinstance(outcome, dict): + raise AvbdPaperBreakableWallPacketError("scene metrics missing outcome") + _validate_finite_tree(outcome, "outcome") + expected_outcome = EXPECTED_OUTCOMES[expected_frame] + for key in ( + "broken_joints", + "evaluated", + "impact_band_displaced_counts", + "status", + "thresholds_pass", + "unbroken_joints", + ): + _require_exact( + outcome.get(key), + expected_outcome[key], + f"frame {expected_frame} outcome {key}", + ) + for key in ("outside_retained_fraction", "total_retained_fraction"): + _require_close( + outcome.get(key), + expected_outcome[key], + f"frame {expected_frame} outcome {key}", + ) + _require_exact( + outcome.get("last_step_iterations"), + RIGID_CONSTRAINT_ITERATIONS, + f"frame {expected_frame} last_step_iterations", + ) + _require_exact( + outcome.get("frame"), + expected_frame, + f"frame {expected_frame} outcome frame", + ) + threshold_checks = outcome.get("threshold_checks") + if not isinstance(threshold_checks, dict): + raise AvbdPaperBreakableWallPacketError( + f"frame {expected_frame} outcome missing threshold_checks" + ) + _require_exact( + threshold_checks, + expected_outcome["threshold_checks"], + f"frame {expected_frame} outcome threshold_checks", + ) + if ( + _finite_number( + outcome.get("max_brick_displacement"), + f"frame {expected_frame} max_brick_displacement", + ) + <= 0.0 + ): + raise AvbdPaperBreakableWallPacketError( + f"frame {expected_frame} max_brick_displacement must be positive" + ) + if ( + _finite_number( + outcome.get("contact_count"), + f"frame {expected_frame} contact_count", + ) + <= 0.0 + ): + raise AvbdPaperBreakableWallPacketError( + f"frame {expected_frame} contact_count must be positive" + ) + + return { + "event_count": expected_frame, + "frame": expected_frame, + "outcome": outcome, + "outcome_oracle": oracle, + "resolved_configuration": resolved_configuration, + "scene_spec_fingerprint": scene_spec_fingerprint, + "view_report": view_report, + "scene_contract": { + key: metrics[key] + for key in ( + "ball_count", + "break_force", + "breakable_joints", + "brick_count", + "collision_shapes", + "executor", + "paper_locator", + "rigid_bodies", + "rigid_body_solver", + "solver", + "time_step_ms", + ) + } + | {"rigid_constraint_options": rigid_constraint_options}, + } + + +def _validate_capture( + manifest_path: Path, + *, + expected_frame: int, + expected_label: str, +) -> tuple[dict[str, Any], Path]: + manifest = _load_json(manifest_path) + _require_exact(manifest.get("schema_version"), 1, "capture schema_version") + _require_exact(manifest.get("scene"), SCENE_ID, "capture scene") + _require_exact( + manifest.get("capture_label"), + expected_label, + "capture label", + ) + _require_exact(manifest.get("force_drag"), None, "capture force_drag") + + capture = manifest.get("capture") + if not isinstance(capture, dict): + raise AvbdPaperBreakableWallPacketError( + "capture manifest missing capture dimensions" + ) + _require_exact( + capture.get("requested_frames"), + expected_frame, + "capture requested_frames", + ) + _require_exact( + capture.get("converted_frames"), + expected_frame, + "capture converted_frames", + ) + width = capture.get("width") + height = capture.get("height") + if ( + not isinstance(width, int) + or isinstance(width, bool) + or not isinstance(height, int) + or isinstance(height, bool) + or width < 1 + or height < 1 + ): + raise AvbdPaperBreakableWallPacketError( + "capture width and height must be positive integers" + ) + + identity = manifest.get("resolved_solver_identity") + if identity != { + "executor": "World.step default", + "solver": "public_avbd", + "source": "scene_capture_metrics.latest.metrics", + }: + raise AvbdPaperBreakableWallPacketError( + "capture must resolve public_avbd through World.step default" + ) + + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict): + raise AvbdPaperBreakableWallPacketError("capture manifest missing artifacts") + screenshot = _artifact_path( + manifest_path, + artifacts.get("screenshot"), + "artifacts.screenshot", + ) + metrics_events = _artifact_path( + manifest_path, + artifacts.get("scene_metrics_events"), + "artifacts.scene_metrics_events", + ) + if not screenshot.is_file(): + raise AvbdPaperBreakableWallPacketError(f"{screenshot}: screenshot not found") + if not metrics_events.is_file(): + raise AvbdPaperBreakableWallPacketError( + f"{metrics_events}: scene metrics event log not found" + ) + if _png_dimensions(screenshot) != (width, height): + raise AvbdPaperBreakableWallPacketError( + "capture screenshot dimensions do not match manifest" + ) + metric_events = _read_scene_metric_events( + metrics_events, + expected_frame=expected_frame, + ) + scene_metrics = _validate_scene_metrics( + manifest, + expected_frame=expected_frame, + height=height, + logged_latest=metric_events[-1], + width=width, + ) + + return ( + { + "camera": _validate_camera(manifest), + "capture": { + "height": height, + "requested_frames": expected_frame, + "width": width, + }, + "label": expected_label, + "manifest": { + "file": manifest_path.name, + "sha256": _sha256(manifest_path), + }, + "scene_metrics": scene_metrics, + "scene_metrics_events": { + "event_count": len(metric_events), + "file": metrics_events.name, + "sha256": _sha256(metrics_events), + }, + "screenshot": { + "file": screenshot.name, + "sha256": _sha256(screenshot), + }, + }, + screenshot, + ) + + +def _validate_image_verdict( + verdict_path: Path, + screenshot: Path, + *, + expected_frame: int, +) -> dict[str, Any]: + verdict = _load_json(verdict_path) + _require_exact( + verdict.get("schema_version"), + "dart.image_verdict/v1", + "image verdict schema_version", + ) + _require_exact(verdict.get("pass"), True, "image verdict pass") + _require_exact( + verdict.get("machine_scope"), + "pixel-integrity", + "image verdict machine_scope", + ) + checks = verdict.get("checks") + if not isinstance(checks, dict): + raise AvbdPaperBreakableWallPacketError("image verdict missing checks") + non_blank = checks.get("non_blank") + if not isinstance(non_blank, dict) or non_blank.get("pass") is not True: + raise AvbdPaperBreakableWallPacketError( + "image verdict non_blank check must pass" + ) + semantic_review = verdict.get("semantic_review") + if ( + not isinstance(semantic_review, dict) + or semantic_review.get("required") is not True + or semantic_review.get("performed_by_this_tool") is not False + ): + raise AvbdPaperBreakableWallPacketError( + "image verdict must require a separate semantic review" + ) + metadata = verdict.get("metadata") + expected_metadata = { + "frame": str(expected_frame), + "scene": SCENE_ID, + "view": CAMERA_VIEW, + } + _require_exact(metadata, expected_metadata, "image verdict metadata") + + image = verdict.get("image") + if not isinstance(image, dict): + raise AvbdPaperBreakableWallPacketError("image verdict missing image metadata") + image_path = image.get("path") + if ( + not isinstance(image_path, str) + or Path(image_path).resolve() != screenshot.resolve() + ): + raise AvbdPaperBreakableWallPacketError( + "image verdict path does not match capture screenshot" + ) + width, height = _png_dimensions(screenshot) + _require_exact(image.get("width"), width, "image verdict width") + _require_exact(image.get("height"), height, "image verdict height") + return { + "checks": checks, + "file": verdict_path.name, + "machine_scope": "pixel-integrity", + "metadata": expected_metadata, + "pass": True, + "sha256": _sha256(verdict_path), + } + + +def _canonical_benchmark_name(row: dict[str, Any]) -> str: + name = row.get("run_name", row.get("name")) + if not isinstance(name, str): + return "" + for suffix in ("_mean", "_median", "_stddev", "_cv"): + if name.endswith(suffix): + name = name[: -len(suffix)] + break + return name + + +def _validate_benchmark( + benchmark_path: Path, + *, + expected_scene_spec_fingerprint: str, +) -> dict[str, Any]: + data = _load_json(benchmark_path) + rows = data.get("benchmarks") + if not isinstance(rows, list): + raise AvbdPaperBreakableWallPacketError( + "benchmark JSON missing benchmarks list" + ) + matching = [ + row + for row in rows + if isinstance(row, dict) and _canonical_benchmark_name(row) == BENCHMARK_RUN + ] + by_aggregate: dict[str, dict[str, Any]] = {} + for row in matching: + aggregate = row.get("aggregate_name") + if not isinstance(aggregate, str): + raise AvbdPaperBreakableWallPacketError( + f"{BENCHMARK_RUN}: every row must be an aggregate" + ) + if aggregate in by_aggregate: + raise AvbdPaperBreakableWallPacketError( + f"{BENCHMARK_RUN}: duplicate {aggregate} aggregate" + ) + by_aggregate[aggregate] = row + expected_aggregates = {"mean", "median", "stddev", "cv"} + if set(by_aggregate) != expected_aggregates: + raise AvbdPaperBreakableWallPacketError( + f"{BENCHMARK_RUN}: expected aggregates {sorted(expected_aggregates)}, " + f"got {sorted(by_aggregate)}" + ) + + counters = { + "breakable_joints": BREAKABLE_JOINTS, + "collision_shapes": COLLISION_SHAPES, + "impacting_balls": IMPACTING_BALLS, + "public_avbd_family": 1, + "resolved_rigid_body_avbd": 1, + "resolved_rigid_constraint_iterations": 1, + "resolved_rigid_contact_avbd": 1, + "resolved_rigid_pair_constraint_avbd": 1, + "rigid_constraint_iterations": RIGID_CONSTRAINT_ITERATIONS, + "rigid_bodies": RIGID_BODIES, + "rigid_body_joints": BREAKABLE_JOINTS, + "runtime_contract_passed": 1, + "trajectory_frames": 120, + } + expected_fingerprint_value = int(expected_scene_spec_fingerprint, 16) + fingerprint_counters = { + "scene_spec_fingerprint_hi": expected_fingerprint_value >> 32, + "scene_spec_fingerprint_lo": expected_fingerprint_value & 0xFFFFFFFF, + } + packet_rows = [] + for aggregate in ("mean", "median", "stddev", "cv"): + row = by_aggregate[aggregate] + _require_exact(row.get("run_type"), "aggregate", "benchmark run_type") + _require_exact(row.get("repetitions"), 5, "benchmark repetitions") + _require_exact(row.get("iterations"), 5, "benchmark iterations") + _require_exact(row.get("time_unit"), "ns", "benchmark time_unit") + real_time = _finite_number( + row.get("real_time"), + f"{BENCHMARK_RUN} {aggregate} real_time", + ) + cpu_time = _finite_number( + row.get("cpu_time"), + f"{BENCHMARK_RUN} {aggregate} cpu_time", + ) + if real_time < 0.0 or cpu_time < 0.0: + raise AvbdPaperBreakableWallPacketError( + f"{BENCHMARK_RUN}: aggregate timings must be non-negative" + ) + if aggregate in ("mean", "median"): + if real_time <= 0.0 or cpu_time <= 0.0: + raise AvbdPaperBreakableWallPacketError( + f"{BENCHMARK_RUN}: representative timings must be positive" + ) + for key, expected in counters.items(): + _require_close( + row.get(key), + float(expected), + f"{BENCHMARK_RUN} {aggregate} {key}", + ) + for key, expected in fingerprint_counters.items(): + _require_close( + row.get(key), + float(expected), + f"{BENCHMARK_RUN} {aggregate} {key}", + ) + packet_rows.append( + { + key: row[key] + for key in ( + "aggregate_name", + "aggregate_unit", + "cpu_time", + "iterations", + "real_time", + "repetitions", + "run_name", + "run_type", + "time_unit", + ) + if key in row + } + | ( + {key: row[key] for key in (*counters, *fingerprint_counters)} + if aggregate in ("mean", "median") + else {} + ) + ) + + cv_row = by_aggregate["cv"] + real_cv = _finite_number(cv_row.get("real_time"), "benchmark real-time CV") + cpu_cv = _finite_number(cv_row.get("cpu_time"), "benchmark CPU-time CV") + if not 0.0 <= real_cv <= 0.10 or not 0.0 <= cpu_cv <= 0.10: + raise AvbdPaperBreakableWallPacketError( + f"{BENCHMARK_RUN}: timing CV must be between 0 and 10 percent" + ) + + context = data.get("context") + if not isinstance(context, dict): + raise AvbdPaperBreakableWallPacketError("benchmark JSON missing context object") + executable = context.get("executable") + if not isinstance(executable, str) or Path(executable).name != ( + "bm_avbd_rigid_fixed_joint" + ): + raise AvbdPaperBreakableWallPacketError( + "benchmark context identifies the wrong executable" + ) + _require_exact( + context.get("library_build_type"), + "release", + "benchmark library_build_type", + ) + return { + "benchmark": BENCHMARK_NAME, + "context": { + key: context[key] + for key in ( + "executable", + "host_name", + "json_schema_version", + "library_build_type", + "library_version", + "mhz_per_cpu", + "num_cpus", + ) + if key in context + }, + "json_sha256": _sha256(benchmark_path), + "rows": packet_rows, + "scene_spec_fingerprint": expected_scene_spec_fingerprint, + "stability": { + "cpu_time_cv_fraction": cpu_cv, + "real_time_cv_fraction": real_cv, + "repetitions": 5, + }, + "timing": { + "mean_cpu_time_per_step_ns": by_aggregate["mean"]["cpu_time"], + "mean_real_time_per_step_ns": by_aggregate["mean"]["real_time"], + "median_cpu_time_per_step_ns": by_aggregate["median"]["cpu_time"], + "median_real_time_per_step_ns": by_aggregate["median"]["real_time"], + }, + } + + +def _validate_paper_artifacts( + paper_pdf: Path, + paper_figure: Path, +) -> dict[str, Any]: + pdf_hash = _sha256(paper_pdf) + figure_hash = _sha256(paper_figure) + if pdf_hash != PAPER_PDF_SHA256: + raise AvbdPaperBreakableWallPacketError( + f"paper PDF sha256 must be {PAPER_PDF_SHA256}, got {pdf_hash}" + ) + if figure_hash != PAPER_FIGURE_SHA256: + raise AvbdPaperBreakableWallPacketError( + f"paper figure sha256 must be {PAPER_FIGURE_SHA256}, got {figure_hash}" + ) + width, height = _png_dimensions(paper_figure) + return { + "figure": { + "file": paper_figure.name, + "height": height, + "sha256": figure_hash, + "width": width, + }, + "locator": PAPER_LOCATOR, + "paper_pdf": { + "file": paper_pdf.name, + "sha256": pdf_hash, + }, + } + + +def _validate_visual_review( + review_path: Path, + *, + impact_screenshot: Path, + outcome_screenshot: Path, + paper_figure: Path, +) -> dict[str, Any]: + review = _load_json(review_path) + _require_exact( + review.get("schema_version"), + "dart.visual_semantic_review/v1", + "visual review schema_version", + ) + _require_exact(review.get("scene"), SCENE_ID, "visual review scene") + _require_exact(review.get("verdict"), "pass", "visual review verdict") + review_fields = ( + "reviewer_capability", + "claim_and_expected_observation", + "text_oracle", + "visible_observation", + "reconciliation_and_verdict", + "not_proven_and_limitations", + ) + for key in review_fields: + value = review.get(key) + if not isinstance(value, str) or not value.strip(): + raise AvbdPaperBreakableWallPacketError( + f"visual review {key} must be non-empty" + ) + if "image" not in review["reviewer_capability"].lower(): + raise AvbdPaperBreakableWallPacketError( + "visual review must name an image-capable reviewer" + ) + if ( + "viewreport" + not in review["reconciliation_and_verdict"].replace(" ", "").lower() + ): + raise AvbdPaperBreakableWallPacketError( + "visual review reconciliation must account for the engine ViewReports" + ) + + expected = { + "impact_frame_60": impact_screenshot, + "outcome_frame_120": outcome_screenshot, + "paper_figure_13_reference": paper_figure, + } + entries = review.get("inspected_images") + if not isinstance(entries, list): + raise AvbdPaperBreakableWallPacketError( + "visual review inspected_images must be a list" + ) + by_role = { + entry.get("role"): entry + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("role"), str) + } + if set(by_role) != set(expected): + raise AvbdPaperBreakableWallPacketError( + "visual review must inspect the impact, outcome, and paper images" + ) + inspected_images = [] + for role, path in expected.items(): + entry = by_role[role] + file_value = entry.get("file") + if ( + not isinstance(file_value, str) + or Path(file_value).resolve() != path.resolve() + ): + raise AvbdPaperBreakableWallPacketError( + f"visual review {role} file does not match the inspected image" + ) + expected_hash = _sha256(path) + _require_exact( + entry.get("sha256"), + expected_hash, + f"visual review {role} sha256", + ) + inspected_images.append( + { + "file": path.name, + "role": role, + "sha256": expected_hash, + } + ) + return { + "claim_and_expected_observation": review["claim_and_expected_observation"], + "file": review_path.name, + "inspected_images": inspected_images, + "not_proven_and_limitations": review["not_proven_and_limitations"], + "reconciliation_and_verdict": review["reconciliation_and_verdict"], + "reviewer_capability": review["reviewer_capability"], + "sha256": _sha256(review_path), + "text_oracle": review["text_oracle"], + "verdict": "pass", + "visible_observation": review["visible_observation"], + } + + +def make_packet( + *, + benchmark_json: Path, + impact_capture_manifest: Path, + impact_image_verdict_json: Path, + outcome_capture_manifest: Path, + outcome_image_verdict_json: Path, + visual_review_json: Path, + paper_pdf: Path, + paper_figure_image: Path, +) -> dict[str, Any]: + impact_capture, impact_screenshot = _validate_capture( + impact_capture_manifest, + expected_frame=60, + expected_label="impact", + ) + impact_capture["image_verdict"] = _validate_image_verdict( + impact_image_verdict_json, + impact_screenshot, + expected_frame=60, + ) + outcome_capture, outcome_screenshot = _validate_capture( + outcome_capture_manifest, + expected_frame=120, + expected_label="outcome", + ) + outcome_capture["image_verdict"] = _validate_image_verdict( + outcome_image_verdict_json, + outcome_screenshot, + expected_frame=120, + ) + paper_reference = _validate_paper_artifacts(paper_pdf, paper_figure_image) + semantic_review = _validate_visual_review( + visual_review_json, + impact_screenshot=impact_screenshot, + outcome_screenshot=outcome_screenshot, + paper_figure=paper_figure_image, + ) + scene_spec_fingerprint = impact_capture["scene_metrics"]["scene_spec_fingerprint"] + _require_exact( + outcome_capture["scene_metrics"]["scene_spec_fingerprint"], + scene_spec_fingerprint, + "impact/outcome scene_spec_fingerprint", + ) + benchmark = _validate_benchmark( + benchmark_json, + expected_scene_spec_fingerprint=scene_spec_fingerprint, + ) + + return { + "benchmark": benchmark, + "claim_boundary": ( + "This packet covers the AVBD row of the publication-shaped " + "Figure 13 breakable-wall outcome on the public CPU solver family. " + "It does not claim exact source-scene replay, reproduce the " + "Sequential Impulse, XPBD, and VBD comparison rows, or establish " + "public CUDA AVBD parity." + ), + "correctness": { + "allocation_tests": [ + "World.BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths", + "World.BakedRigidBodyContactStepsDoNotAllocateGlobalHeap", + "World.BakedAvbdVbdRowsDoNotMallocOnHeap", + ], + "allocation_scene": ( + "configurePublicRigidAvbdContactAndBreakableJointRowsScene" + ), + "determinism_test": ( + "test_avbd_paper_breakable_wall_outcome_is_deterministic" + ), + "outcome_test": ( + "test_avbd_paper_breakable_wall_matches_figure13_contract" + ), + "public_configuration": { + "rigid_body_solver": "AVBD", + "rigid_constraint_options": { + "iterations": RIGID_CONSTRAINT_ITERATIONS, + }, + "scene_spec_fingerprint": scene_spec_fingerprint, + "time_step": TIME_STEP, + }, + }, + "packet": "avbd_paper_breakable_wall", + "paper_reference": paper_reference, + "performance_claim_boundary": ( + "The benchmark records absolute DART Release-build CPU step cost " + "and stability for this trajectory. The paper and source artifacts " + "publish no directly comparable timing for this scene, so no " + "reference ratio or speedup claim is made." + ), + "reconstruction": { + "published": { + "description": ( + "Three high-momentum balls fracture a brick wall assembled " + "with breakable attachments." + ), + "constraint_iterations_or_substeps": (RIGID_CONSTRAINT_ITERATIONS), + "paper_locator": PAPER_LOCATOR, + "time_step": TIME_STEP, + }, + "reconstructed_because_unpublished": { + "ball_mass": 100.0, + "ball_radius": 0.48, + "ball_speed": 24.0, + "break_force": BREAK_FORCE, + "brick_density": 4.0, + "brick_size": [0.6, 0.3, 0.25], + "target_coordinates_xz": [ + [-3.1, 1.55], + [0.0, 1.75], + [3.1, 2.35], + ], + "wall_layout": { + "columns": 21, + "rows": 12, + "staggered": True, + }, + }, + }, + "resolved_solver_identity": RESOLVED_SOLVER_IDENTITY, + "schema_version": AVBD_PACKET_SCHEMA_VERSION, + "scene": SCENE_ID, + "source_provenance": _source_provenance(), + "target": { + "complete_paper_reproduction": False, + "contract_rows": [ + "avbd.method.joints_and_attachments", + "avbd.method.fracture", + "avbd.paper.fig.13", + ], + "covered_slice": ( + "AVBD Figure 13 publication-shaped breakable-wall row with " + "numeric, allocation, performance, and visual evidence" + ), + }, + "visual_evidence": { + "impact": impact_capture, + "outcome": outcome_capture, + "semantic_review": semantic_review, + }, + "reproduction": { + "benchmark_command": ( + "pixi run bm -- bm_avbd_rigid_fixed_joint -- " + f"--benchmark_filter='^{BENCHMARK_RUN}$' " + "--benchmark_repetitions=5 " + "--benchmark_report_aggregates_only=true " + "--benchmark_out= " + "--benchmark_out_format=json" + ), + "capture_commands": [ + ( + "pixi run py-demo-capture -- " + f"--scene {SCENE_ID} --frames 60 --width 1280 " + "--height 720 --view front --camera-distance 11 " + "--camera-target 0,0,1.4 --capture-label impact " + "--output-dir " + ), + ( + "pixi run py-demo-capture -- " + f"--scene {SCENE_ID} --frames 120 --width 1280 " + "--height 720 --view front --camera-distance 11 " + "--camera-target 0,0,1.4 --capture-label outcome " + "--output-dir " + ), + ], + "image_verdict_commands": [ + ( + "pixi run image-verdict -- " + f"/{SCENE_ID}_impact.png " + f"--meta scene={SCENE_ID} --meta frame=60 " + "--meta view=front " + "--out /image_verdict.json" + ), + ( + "pixi run image-verdict -- " + f"/{SCENE_ID}_outcome.png " + f"--meta scene={SCENE_ID} --meta frame=120 " + "--meta view=front " + "--out /image_verdict.json" + ), + ], + }, + } + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + packet = make_packet( + benchmark_json=args.benchmark_json, + impact_capture_manifest=args.impact_capture_manifest, + impact_image_verdict_json=args.impact_image_verdict_json, + outcome_capture_manifest=args.outcome_capture_manifest, + outcome_image_verdict_json=args.outcome_image_verdict_json, + visual_review_json=args.visual_review_json, + paper_pdf=args.paper_pdf, + paper_figure_image=args.paper_figure_image, + ) + except (OSError, ValueError, AvbdPaperBreakableWallPacketError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(packet, indent=2, sort_keys=True) + "\n") + print(f"Wrote {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp index 8f83c5a7e0e1e..eee6a37c3c508 100644 --- a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp +++ b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp @@ -52,7 +52,9 @@ #include #include +#include #include +#include #include #include #include @@ -60,6 +62,9 @@ #include #include +#include +#include + namespace sx = dart::simulation; namespace vbd = dart::simulation::detail::deformable_vbd; @@ -2051,6 +2056,253 @@ std::unique_ptr makeAvbdDemo3dBreakableWorld() return world; } +class Fnv1a64 +{ +public: + void updateUint64(std::uint64_t value) + { + for (int byte = 0; byte < 8; ++byte) { + updateByte(static_cast(value & 0xffu)); + value >>= 8u; + } + } + + void updateDouble(double value) + { + updateUint64(std::bit_cast(value)); + } + + void updateString(std::string_view value) + { + updateUint64(static_cast(value.size())); + for (const char character : value) { + updateByte(static_cast(character)); + } + } + + [[nodiscard]] std::uint64_t value() const + { + return m_value; + } + +private: + void updateByte(std::uint8_t value) + { + m_value ^= value; + m_value *= 1099511628211ull; + } + + std::uint64_t m_value{14695981039346656037ull}; +}; + +struct AvbdPaperBreakableWallFixture +{ + std::unique_ptr world; + std::uint64_t sceneSpecFingerprint{0}; +}; + +std::uint64_t avbdPaperBreakableWallFingerprint( + const std::vector>& topologyRecords) +{ + constexpr std::string_view kTag = "avbd-paper-breakable-wall/v1"; + constexpr double kTimeStep = 1.0 / 60.0; + constexpr double kGravity = -9.81; + constexpr std::uint64_t kConstraintIterations = 20; + constexpr std::uint64_t kColumns = 21; + constexpr std::uint64_t kRows = 12; + constexpr std::array kBrickSize{0.60, 0.30, 0.25}; + constexpr std::array kBodyParameters{ + 4.0, 0.50, 0.62, 0.27, 0.02, 0.48, 100.0, 0.30, -5.0, 24.0}; + constexpr std::array, 3> kImpactTargets{{ + {-3.10, 1.55}, + {0.00, 1.75}, + {3.10, 2.35}, + }}; + constexpr std::array kGroundSize{16.0, 16.0, 0.50}; + constexpr double kGroundFriction = 0.60; + constexpr double kBreakForce = 8500.0; + + Fnv1a64 fingerprint; + fingerprint.updateString(kTag); + fingerprint.updateDouble(kTimeStep); + fingerprint.updateDouble(kGravity); + fingerprint.updateUint64(kConstraintIterations); + fingerprint.updateUint64(kColumns); + fingerprint.updateUint64(kRows); + for (const double value : kBrickSize) { + fingerprint.updateDouble(value); + } + for (const double value : kBodyParameters) { + fingerprint.updateDouble(value); + } + fingerprint.updateUint64(kImpactTargets.size()); + for (const auto& target : kImpactTargets) { + fingerprint.updateDouble(target[0]); + fingerprint.updateDouble(target[1]); + } + for (const double value : kGroundSize) { + fingerprint.updateDouble(value); + } + fingerprint.updateDouble(kGroundFriction); + fingerprint.updateDouble(kBreakForce); + fingerprint.updateUint64(topologyRecords.size()); + for (const auto& record : topologyRecords) { + for (const std::uint64_t value : record) { + fingerprint.updateUint64(value); + } + } + return fingerprint.value(); +} + +AvbdPaperBreakableWallFixture makeAvbdPaperBreakableWallWorld() +{ + constexpr int kColumns = 21; + constexpr int kRows = 12; + constexpr int kBrickCount = kColumns * kRows; + constexpr int kHorizontalJoints = kRows * (kColumns - 1); + constexpr int kVerticalJoints = (kRows - 1) * (2 * kColumns - 1); + constexpr int kBaseJoints = kColumns; + constexpr int kJointCount = kHorizontalJoints + kVerticalJoints + kBaseJoints; + constexpr double kBrickDensity = 4.0; + constexpr double kSpacingX = 0.62; + constexpr double kSpacingZ = 0.27; + constexpr double kBaseClearance = 0.02; + constexpr double kBreakForce = 8500.0; + constexpr double kBallRadius = 0.48; + constexpr double kBallMass = 100.0; + constexpr double kBallLaunchSpeed = 24.0; + const Eigen::Vector3d brickSize(0.60, 0.30, 0.25); + const std::array impactTargets{ + Eigen::Vector2d(-3.10, 1.55), + Eigen::Vector2d(0.00, 1.75), + Eigen::Vector2d(3.10, 2.35)}; + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d(0.0, 0.0, -9.81); + options.timeStep = 1.0 / 60.0; + options.rigidBodySolver = sx::RigidBodySolver::Avbd; + options.rigidConstraintOptions.iterations = 20; + auto world = std::make_unique(options); + + auto ground = addAvbdDemo3dSourceBox( + *world, + "avbd_paper_breakable_wall_ground", + Eigen::Vector3d(16.0, 16.0, 0.50), + 0.0, + Eigen::Vector3d(0.0, 0.0, -0.25), + true); + ground.setFriction(0.60); + + const auto brickPosition = [&](const int row, const int column) { + const double courseOffset = row % 2 == 0 ? 0.0 : 0.5; + return Eigen::Vector3d( + (static_cast(column) - 0.5 * (kColumns - 1) + courseOffset) + * kSpacingX, + 0.0, + kBaseClearance + 0.5 * brickSize.z() + + static_cast(row) * kSpacingZ); + }; + const auto brickIndex = [=](const int row, const int column) { + return static_cast(row * kColumns + column); + }; + + std::vector bricks; + bricks.reserve(kBrickCount); + for (int row = 0; row < kRows; ++row) { + for (int column = 0; column < kColumns; ++column) { + bricks.push_back(addAvbdDemo3dSourceBox( + *world, + "avbd_paper_wall_brick_" + std::to_string(row) + "_" + + std::to_string(column), + brickSize, + kBrickDensity, + brickPosition(row, column))); + } + } + + std::vector joints; + joints.reserve(kJointCount); + std::vector> topologyRecords; + topologyRecords.reserve(kJointCount); + const auto addBreakable = [&](const std::string& name, + const sx::RigidBody& parent, + const sx::RigidBody& child, + const std::uint64_t kind, + const std::uint64_t parentIndex, + const std::uint64_t childIndex) { + auto joint = addFixedJoint(*world, name, parent, child); + joint.setBreakForce(kBreakForce); + joints.push_back(joint); + topologyRecords.push_back({kind, parentIndex, childIndex}); + }; + + for (int row = 0; row < kRows; ++row) { + for (int column = 0; column + 1 < kColumns; ++column) { + addBreakable( + "avbd_paper_wall_horizontal_" + std::to_string(row) + "_" + + std::to_string(column), + bricks[brickIndex(row, column)], + bricks[brickIndex(row, column + 1)], + 1u, + 1u + brickIndex(row, column), + 1u + brickIndex(row, column + 1)); + } + } + for (int row = 1; row < kRows; ++row) { + for (int upperColumn = 0; upperColumn < kColumns; ++upperColumn) { + const double upperX = brickPosition(row, upperColumn).x(); + for (int lowerColumn = 0; lowerColumn < kColumns; ++lowerColumn) { + const double lowerX = brickPosition(row - 1, lowerColumn).x(); + if (std::abs(upperX - lowerX) < brickSize.x()) { + addBreakable( + "avbd_paper_wall_vertical_" + std::to_string(row - 1) + "_" + + std::to_string(lowerColumn) + "_" + std::to_string(row) + + "_" + std::to_string(upperColumn), + bricks[brickIndex(row - 1, lowerColumn)], + bricks[brickIndex(row, upperColumn)], + 2u, + 1u + brickIndex(row - 1, lowerColumn), + 1u + brickIndex(row, upperColumn)); + } + } + } + } + for (int column = 0; column < kColumns; ++column) { + addBreakable( + "avbd_paper_wall_base_" + std::to_string(column), + ground, + bricks[brickIndex(0, column)], + 3u, + 0u, + 1u + brickIndex(0, column)); + } + + std::vector balls; + balls.reserve(impactTargets.size()); + for (std::size_t index = 0; index < impactTargets.size(); ++index) { + sx::RigidBodyOptions ballOptions; + ballOptions.mass = kBallMass; + ballOptions.inertia = Eigen::Matrix3d::Identity() + * (2.0 / 5.0 * kBallMass * kBallRadius * kBallRadius); + ballOptions.position = Eigen::Vector3d( + impactTargets[index].x(), -5.0, impactTargets[index].y()); + ballOptions.linearVelocity = Eigen::Vector3d(0.0, kBallLaunchSpeed, 0.0); + auto ball = world->addRigidBody( + "avbd_paper_wall_ball_" + std::to_string(index), ballOptions); + ball.setFriction(0.30); + ball.setCollisionShape(sx::CollisionShape::makeSphere(kBallRadius)); + balls.push_back(ball); + } + + benchmark::DoNotOptimize(bricks.data()); + benchmark::DoNotOptimize(joints.data()); + benchmark::DoNotOptimize(balls.data()); + return AvbdPaperBreakableWallFixture{ + .world = std::move(world), + .sceneSpecFingerprint + = avbdPaperBreakableWallFingerprint(topologyRecords)}; +} + std::unique_ptr makeRigidBreakableJointWorld(std::size_t jointCount) { sx::WorldOptions options; @@ -3785,6 +4037,119 @@ static void BM_AvbdDemo3dBreakableStep(benchmark::State& state) } BENCHMARK(BM_AvbdDemo3dBreakableStep); +//============================================================================== +static void BM_AvbdPaperBreakableWallStep(benchmark::State& state) +{ + constexpr std::size_t kTrajectoryFrames = 120; + auto fixture = makeAvbdPaperBreakableWallWorld(); + fixture.world->enterSimulationMode(); + + const auto bodyNames = fixture.world->getRigidBodyNames(); + std::size_t collisionShapeCount = 0; + std::size_t impactingBallCount = 0; + for (const auto& name : bodyNames) { + const auto body = fixture.world->getRigidBody(name); + if (body.has_value() && body->hasCollisionShape()) { + ++collisionShapeCount; + } + if (std::string_view(name).starts_with("avbd_paper_wall_ball_")) { + ++impactingBallCount; + } + } + + const auto joints = fixture.world->getJoints(); + const auto breakableJointCount = static_cast( + std::ranges::count_if(joints, [](const sx::Joint& joint) { + return std::isfinite(joint.getBreakForce()) + && joint.getBreakForce() > 0.0; + })); + const auto rigidConstraintIterations + = fixture.world->getRigidConstraintOptions().iterations; + const bool publicAvbdFamily + = fixture.world->getRigidBodySolver() == sx::RigidBodySolver::Avbd; + const auto& resolved = fixture.world->getResolvedConfiguration(); + const auto hasResolvedNote = [&](std::string_view domain, + std::string_view requested, + std::string_view actual) { + return std::ranges::count_if( + resolved.notes, + [&](const sx::compute::ResolvedConfigurationNote& note) { + return note.domain == domain && note.requested == requested + && note.resolved == actual; + }) + == 1; + }; + const bool resolvedRigidBodyAvbd + = hasResolvedNote("rigid-body", "avbd", "avbd"); + const bool resolvedRigidContactAvbd + = hasResolvedNote("rigid-contact", "avbd", "avbd"); + const bool resolvedRigidPairConstraintAvbd + = hasResolvedNote("rigid-pair-constraint", "avbd", "avbd"); + const std::string iterationText = std::to_string(rigidConstraintIterations); + const bool resolvedRigidConstraintIterations = hasResolvedNote( + "rigid-constraint-iterations", iterationText, iterationText); + const bool runtimeContractPassed + = fixture.world->getRigidBodyCount() == bodyNames.size() + && fixture.world->getRigidBodyCount() == 256u + && fixture.world->getJointCount() == joints.size() + && fixture.world->getJointCount() == 712u + && breakableJointCount == joints.size() && collisionShapeCount == 256u + && impactingBallCount == 3u && rigidConstraintIterations == 20u + && publicAvbdFamily && resolvedRigidBodyAvbd && resolvedRigidContactAvbd + && resolvedRigidPairConstraintAvbd && resolvedRigidConstraintIterations; + if (!runtimeContractPassed) { + state.SkipWithError( + "AVBD paper breakable-wall runtime configuration drifted"); + return; + } + + const std::uint64_t sceneSpecFingerprint = fixture.sceneSpecFingerprint; + std::size_t frame = 0; + + for (auto _ : state) { + fixture.world->step(); + benchmark::ClobberMemory(); + ++frame; + if (frame == kTrajectoryFrames) { + state.PauseTiming(); + fixture = makeAvbdPaperBreakableWallWorld(); + if (fixture.sceneSpecFingerprint != sceneSpecFingerprint) { + state.SkipWithError( + "AVBD paper breakable-wall scene fingerprint drifted"); + return; + } + fixture.world->enterSimulationMode(); + frame = 0; + state.ResumeTiming(); + } + } + state.counters["rigid_bodies"] + = static_cast(fixture.world->getRigidBodyCount()); + state.counters["rigid_body_joints"] + = static_cast(fixture.world->getJointCount()); + state.counters["breakable_joints"] = static_cast(breakableJointCount); + state.counters["collision_shapes"] = static_cast(collisionShapeCount); + state.counters["impacting_balls"] = static_cast(impactingBallCount); + state.counters["rigid_constraint_iterations"] + = static_cast(rigidConstraintIterations); + state.counters["trajectory_frames"] = static_cast(kTrajectoryFrames); + state.counters["public_avbd_family"] = publicAvbdFamily ? 1.0 : 0.0; + state.counters["resolved_rigid_body_avbd"] + = resolvedRigidBodyAvbd ? 1.0 : 0.0; + state.counters["resolved_rigid_contact_avbd"] + = resolvedRigidContactAvbd ? 1.0 : 0.0; + state.counters["resolved_rigid_pair_constraint_avbd"] + = resolvedRigidPairConstraintAvbd ? 1.0 : 0.0; + state.counters["resolved_rigid_constraint_iterations"] + = resolvedRigidConstraintIterations ? 1.0 : 0.0; + state.counters["runtime_contract_passed"] = runtimeContractPassed ? 1.0 : 0.0; + state.counters["scene_spec_fingerprint_hi"] = static_cast( + static_cast(sceneSpecFingerprint >> 32u)); + state.counters["scene_spec_fingerprint_lo"] + = static_cast(static_cast(sceneSpecFingerprint)); +} +BENCHMARK(BM_AvbdPaperBreakableWallStep)->Iterations(120); + //============================================================================== static void BM_AvbdRigidBreakableJointStep(benchmark::State& state) { diff --git a/tests/test_avbd_packet_schema.py b/tests/test_avbd_packet_schema.py index f1be137dff2e4..5f12366d85c9b 100644 --- a/tests/test_avbd_packet_schema.py +++ b/tests/test_avbd_packet_schema.py @@ -44,6 +44,7 @@ def _load_module(): { "avbd_rigid_contact_config_emplaced": False, "recorded_from": "breakable joint scale benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd", }, @@ -58,6 +59,7 @@ def _load_module(): { "avbd_rigid_contact_config_emplaced": False, "recorded_from": "breakable motor scale benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd", }, @@ -72,6 +74,7 @@ def _load_module(): { "avbd_rigid_contact_config_emplaced": False, "recorded_from": "friction coefficient sweep benchmark scene counters", + "rigid_contact_selection": "contact_solver_method", "rigid_contact_solver": "sequential_impulse", "rigid_point_joint_solver": "none", }, @@ -86,6 +89,7 @@ def _load_module(): { "avbd_rigid_contact_config_emplaced": False, "recorded_from": "paper-scale high-ratio iteration benchmark row family", + "rigid_contact_selection": "not_applicable", "rigid_contact_solver": "none", "rigid_point_joint_solver": "avbd", }, @@ -109,11 +113,30 @@ def test_builder_output_passes_schema_contract(): assert schema.resolved_solver_identity_errors(packet, "case") == [] +def test_contact_solver_method_rejects_private_avbd_body_config(): + identity = schema.make_resolved_solver_identity( + resolved_rigid_contact_family="sequential-impulse", + rigid_point_joint_solver="none", + avbd_rigid_contact_config_emplaced=False, + recorded_from="resolved report", + ) + identity["avbd_rigid_contact_config_emplaced"] = True + packet = { + "schema_version": schema.AVBD_PACKET_SCHEMA_VERSION, + schema.RESOLVED_SOLVER_IDENTITY_KEY: identity, + } + + errors = schema.resolved_solver_identity_errors(packet, "case") + + assert any("without a private AVBD body config" in error for error in errors) + + @pytest.mark.parametrize( ("report_family", "expected"), [ ("sequential-impulse", "sequential_impulse"), ("boxed-lcp", "boxed_lcp"), + ("avbd", "avbd"), ("sequential-impulse + avbd (opt-in)", "avbd"), ("SEQUENTIAL-IMPULSE", "sequential_impulse"), ], @@ -154,9 +177,40 @@ def test_avbd_substitution_requires_emplaced_config(): ) +def test_public_avbd_family_records_selection_without_private_config(): + identity = schema.make_resolved_solver_identity( + resolved_rigid_contact_family="avbd", + rigid_point_joint_solver="avbd", + avbd_rigid_contact_config_emplaced=False, + recorded_from="World resolved-configuration report", + rigid_contact_selection="world_solver_family", + ) + assert identity == { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": "World resolved-configuration report", + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "avbd", + "rigid_point_joint_solver": "avbd", + } + + +def test_public_avbd_family_requires_explicit_selection_source(): + with pytest.raises(ValueError): + schema.make_resolved_solver_identity( + resolved_rigid_contact_family="avbd", + rigid_point_joint_solver="avbd", + avbd_rigid_contact_config_emplaced=False, + recorded_from="World resolved-configuration report", + ) + + def test_unrecognized_report_family_raises(): with pytest.raises(ValueError): schema.resolved_rigid_contact_solver_from_report("magic-solver") + with pytest.raises(ValueError): + schema.resolved_rigid_contact_solver_from_report("magic-avbd-solver") + with pytest.raises(ValueError): + schema.resolved_rigid_contact_solver_from_report("magic + avbd (opt-in)") with pytest.raises(ValueError): schema.resolved_rigid_contact_solver_from_report("") diff --git a/tests/test_check_avbd_packets.py b/tests/test_check_avbd_packets.py index 848c59f1216f2..e2510d18935b2 100644 --- a/tests/test_check_avbd_packets.py +++ b/tests/test_check_avbd_packets.py @@ -26,6 +26,7 @@ def _identity(**overrides): "rigid_point_joint_solver": "avbd", "avbd_rigid_contact_config_emplaced": False, "recorded_from": "scene-construction audit (WP-091.1)", + "rigid_contact_selection": "contact_solver_method", } identity.update(overrides) return identity @@ -67,7 +68,7 @@ def test_new_version_packet_with_identity_passes(tmp_path): assert module.main(["--packet", str(path)]) == 0 -def test_avbd_contact_claim_without_emplaced_config_is_rejected(tmp_path): +def test_avbd_contact_claim_with_wrong_selection_source_is_rejected(tmp_path): module = _load_module() path = _write_packet( tmp_path, @@ -81,7 +82,26 @@ def test_avbd_contact_claim_without_emplaced_config_is_rejected(tmp_path): }, ) errors = module.packet_errors(path) - assert any("cannot be 'avbd' when" in error for error in errors), errors + assert any( + "contact_solver_method" in error and "sequential_impulse" in error + for error in errors + ), errors + + +def test_public_avbd_contact_claim_without_private_config_passes(tmp_path): + module = _load_module() + path = _write_packet( + tmp_path, + "avbd-new-scene-packet.json", + { + "schema_version": module.AVBD_PACKET_SCHEMA_VERSION, + "resolved_solver_identity": _identity( + rigid_contact_solver="avbd", + rigid_contact_selection="world_solver_family", + ), + }, + ) + assert module.packet_errors(path) == [] def test_unknown_solver_name_is_rejected(tmp_path): diff --git a/tests/unit/gui/test_gui_debug_visuals.cpp b/tests/unit/gui/test_gui_debug_visuals.cpp index 1d554de7d2584..d76a2aca6389d 100644 --- a/tests/unit/gui/test_gui_debug_visuals.cpp +++ b/tests/unit/gui/test_gui_debug_visuals.cpp @@ -620,4 +620,44 @@ TEST(GuiDebugVisuals, AssessViewFlagsAnOccludedFocus) != report.issues.end()); } +TEST(GuiDebugVisuals, AssessViewScopesAmbiguityToExplicitFocus) +{ + // Camera at +X looking toward the origin along -X. The two decoys are + // depth-separated on the same ray and therefore ambiguous when the whole + // scene is assessed, while the offset focus remains individually clear. + dart::gui::OrbitCamera camera; + camera.target = Eigen::Vector3d::Zero(); + camera.distance = 4.0; + camera.yaw = 0.0; + camera.pitch = 0.0; + + const dart::gui::RenderableDescriptor focus = makeBoundedBoxDescriptor( + 1u, Eigen::Vector3d(0.0, 0.9, 0.0), Eigen::Vector3d::Constant(0.2)); + const dart::gui::RenderableDescriptor nearDecoy = makeBoundedBoxDescriptor( + 2u, Eigen::Vector3d(0.25, -0.8, 0.0), Eigen::Vector3d::Constant(0.2)); + const dart::gui::RenderableDescriptor farDecoy = makeBoundedBoxDescriptor( + 3u, Eigen::Vector3d(-0.25, -0.8, 0.0), Eigen::Vector3d::Constant(0.2)); + const std::vector descriptors{ + focus, nearDecoy, farDecoy}; + + const dart::gui::ViewQualityReport wholeScene + = dart::gui::assessView(descriptors, camera, 320, 240); + EXPECT_GT(wholeScene.ambiguityIoU, 0.55); + EXPECT_NE( + std::find( + wholeScene.issues.begin(), wholeScene.issues.end(), "ambiguous"), + wholeScene.issues.end()); + + const dart::gui::ViewQualityReport focused = dart::gui::assessView( + descriptors, + camera, + 320, + 240, + std::vector{focus.id}); + EXPECT_DOUBLE_EQ(focused.ambiguityIoU, 0.0); + EXPECT_EQ( + std::find(focused.issues.begin(), focused.issues.end(), "ambiguous"), + focused.issues.end()); +} + } // namespace diff --git a/tests/unit/simulation/world/test_serialization.cpp b/tests/unit/simulation/world/test_serialization.cpp index 52ab43661ff34..2e7fd517484e5 100644 --- a/tests/unit/simulation/world/test_serialization.cpp +++ b/tests/unit/simulation/world/test_serialization.cpp @@ -192,11 +192,12 @@ constexpr std::size_t kVariationalOptionTailBytes = sizeof(std::size_t) + sizeof(double); constexpr std::size_t kComputeAcceleratorPolicyTailBytes = sizeof(std::uint8_t); constexpr std::size_t kDifferentiableParameterTailBytes = sizeof(std::size_t); +constexpr std::size_t kRigidConstraintIterationTailBytes = sizeof(std::size_t); constexpr std::size_t kWorldOptionTailBytes = sizeof(std::uint8_t) + kSolverOptionTailBytes + kIgnoredCollisionPairTailBytes + kVariationalOptionTailBytes + kDeactivationOptionTailBytes + kComputeAcceleratorPolicyTailBytes - + kDifferentiableParameterTailBytes; + + kDifferentiableParameterTailBytes + kRigidConstraintIterationTailBytes; dart::simulation::JointSpec makeJointSpec( std::string_view name, @@ -1060,6 +1061,7 @@ TEST(Serialization, PreservesWorldSolverOptions) world2.loadBinary(ss); EXPECT_EQ(world2.getRigidBodySolver(), sx::RigidBodySolver::Ipc); + EXPECT_EQ(world2.getRigidConstraintOptions().iterations, 8u); EXPECT_EQ( world2.getMultibodyOptions().integrationFamily, sx::MultibodyIntegrationFamily::Variational); @@ -1075,6 +1077,117 @@ TEST(Serialization, PreservesWorldSolverOptions) sx::ComputeAcceleratorPolicy::PreferAccelerated); } +TEST(Serialization, PreservesPublicAvbdSolverFamily) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.rigidBodySolver = sx::RigidBodySolver::Avbd; + options.rigidConstraintOptions.iterations = 20; + sx::World world1(options); + + std::stringstream ss; + world1.saveBinary(ss); + + sx::World world2; + world2.loadBinary(ss); + + EXPECT_EQ(world2.getRigidBodySolver(), sx::RigidBodySolver::Avbd); + EXPECT_EQ(world2.getRigidConstraintOptions().iterations, 20u); + EXPECT_EQ( + world2.getContactSolverMethod(), + sx::ContactSolverMethod::SequentialImpulse); +} + +TEST(Serialization, RejectsMissingRigidConstraintIterationBudget) +{ + namespace sx = dart::simulation; + + sx::World world1; + std::stringstream output; + world1.saveBinary(output); + + auto serialized = output.str(); + ASSERT_GE(serialized.size(), sizeof(std::size_t)); + serialized.resize(serialized.size() - sizeof(std::size_t)); + std::stringstream truncated(serialized); + + sx::World world2; + EXPECT_THROW(world2.loadBinary(truncated), sx::InvalidArgumentException); +} + +TEST(Serialization, RejectsAvbdSolverInPreAvbdFormat) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.rigidBodySolver = sx::RigidBodySolver::Avbd; + sx::World world1(options); + std::stringstream output; + world1.saveBinary(output); + + auto serialized = output.str(); + ASSERT_GE( + serialized.size(), 2u * sizeof(std::uint32_t) + sizeof(std::size_t)); + serialized.resize(serialized.size() - sizeof(std::size_t)); + constexpr std::uint32_t legacyVersion = 28u; + std::memcpy( + serialized.data() + sizeof(std::uint32_t), + &legacyVersion, + sizeof(legacyVersion)); + std::stringstream downgraded(serialized); + + sx::World world2; + EXPECT_THROW(world2.loadBinary(downgraded), sx::InvalidArgumentException); +} + +TEST(Serialization, RejectsNonDefaultRigidConstraintOptionsForIpc) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.rigidBodySolver = sx::RigidBodySolver::Ipc; + sx::World world1(options); + std::stringstream output; + world1.saveBinary(output); + + auto serialized = output.str(); + ASSERT_GE(serialized.size(), sizeof(std::size_t)); + const std::size_t nonDefaultIterations = 20u; + std::memcpy( + serialized.data() + serialized.size() - sizeof(std::size_t), + &nonDefaultIterations, + sizeof(nonDefaultIterations)); + std::stringstream incompatible(serialized); + + sx::World world2; + EXPECT_THROW(world2.loadBinary(incompatible), sx::InvalidArgumentException); +} + +TEST(Serialization, RejectsAvbdSolverForLoadedMultibodyTopology) +{ + namespace sx = dart::simulation; + + sx::World world1; + [[maybe_unused]] auto multibody = world1.addMultibody("robot"); + std::stringstream output; + world1.saveBinary(output); + + auto serialized = output.str(); + const std::size_t solverSuffixBytes + = kIgnoredCollisionPairTailBytes + kVariationalOptionTailBytes + + kDeactivationOptionTailBytes + kComputeAcceleratorPolicyTailBytes + + kDifferentiableParameterTailBytes + + kRigidConstraintIterationTailBytes; + ASSERT_GE(serialized.size(), solverSuffixBytes + kSolverOptionTailBytes); + serialized[serialized.size() - solverSuffixBytes - kSolverOptionTailBytes] + = static_cast(2u); + std::stringstream incompatible(serialized); + + sx::World world2; + EXPECT_THROW(world2.loadBinary(incompatible), sx::InvalidOperationException); +} + TEST(Serialization, PreservesComplementarityAwareContactGradientMode) { namespace sx = dart::simulation; @@ -1226,7 +1339,8 @@ TEST(Serialization, RejectsInvalidWorldSolverOptionTail) const std::size_t solverSuffixBytes = kIgnoredCollisionPairTailBytes + kVariationalOptionTailBytes + kDeactivationOptionTailBytes + kComputeAcceleratorPolicyTailBytes - + kDifferentiableParameterTailBytes; + + kDifferentiableParameterTailBytes + + kRigidConstraintIterationTailBytes; ASSERT_GE(validRecord.size(), solverSuffixBytes + kSolverOptionTailBytes); expectInvalidByte(solverSuffixBytes + 4u); // rigid-body solver expectInvalidByte(solverSuffixBytes + 3u); // contact solver method @@ -1244,8 +1358,9 @@ TEST(Serialization, RejectsInvalidWorldSolverOptionTail) }; const std::size_t deactivationOffset - = validRecord.size() - kDifferentiableParameterTailBytes - - kComputeAcceleratorPolicyTailBytes - kDeactivationOptionTailBytes; + = validRecord.size() - kRigidConstraintIterationTailBytes + - kDifferentiableParameterTailBytes - kComputeAcceleratorPolicyTailBytes + - kDeactivationOptionTailBytes; const std::size_t toleranceOffset = deactivationOffset - sizeof(double); const std::size_t iterationOffset = toleranceOffset - sizeof(std::size_t); const std::size_t invalidIterations = 0u; @@ -1256,11 +1371,18 @@ TEST(Serialization, RejectsInvalidWorldSolverOptionTail) toleranceOffset, &invalidTolerance, sizeof(invalidTolerance)); const std::size_t computePolicyOffset = validRecord.size() + - kRigidConstraintIterationTailBytes - kDifferentiableParameterTailBytes - kComputeAcceleratorPolicyTailBytes; const std::uint8_t invalidComputePolicy = 99u; expectInvalidTailField( computePolicyOffset, &invalidComputePolicy, sizeof(invalidComputePolicy)); + + const std::size_t invalidRigidConstraintIterations = 0u; + expectInvalidTailField( + validRecord.size() - kRigidConstraintIterationTailBytes, + &invalidRigidConstraintIterations, + sizeof(invalidRigidConstraintIterations)); } // Test loadBinary resets solver-family and policy metadata when reading records diff --git a/tests/unit/simulation/world/test_world.cpp b/tests/unit/simulation/world/test_world.cpp index 9264d1f0f908c..059b0e80e805c 100644 --- a/tests/unit/simulation/world/test_world.cpp +++ b/tests/unit/simulation/world/test_world.cpp @@ -3499,6 +3499,46 @@ void configureRigidAvbdContactRowsScene(dart::simulation::World& world) sx::detail::toRegistryEntity(sphere.getEntity())); } +void configurePublicRigidAvbdContactAndBreakableJointRowsScene( + dart::simulation::World& world) +{ + namespace sx = dart::simulation; + + world.setRigidBodySolver(sx::RigidBodySolver::Avbd); + world.setRigidConstraintOptions({.iterations = 20u}); + world.setGravity(Eigen::Vector3d::Zero()); + world.setTimeStep(1.0 / 60.0); + + sx::RigidBodyOptions groundOptions; + groundOptions.isStatic = true; + groundOptions.position = Eigen::Vector3d(0.0, 0.0, -0.25); + auto ground + = world.addRigidBody("public_avbd_allocation_ground", groundOptions); + ground.setCollisionShape( + sx::CollisionShape::makeBox(Eigen::Vector3d(2.0, 2.0, 0.25))); + + sx::RigidBodyOptions brickOptions; + brickOptions.mass = 1.0; + brickOptions.position = Eigen::Vector3d(0.0, 0.0, 0.20); + auto brick = world.addRigidBody("public_avbd_allocation_brick", brickOptions); + brick.setCollisionShape( + sx::CollisionShape::makeBox(Eigen::Vector3d(0.3, 0.15, 0.2))); + + auto joint = world.addJoint( + ground, + brick, + makeJointSpec( + "public_avbd_allocation_breakable_joint", sx::JointType::Fixed)); + joint.setBreakForce(1.0e6); + + sx::RigidBodyOptions ballOptions; + ballOptions.mass = 5.0; + ballOptions.position = Eigen::Vector3d(0.0, -0.35, 0.20); + ballOptions.linearVelocity = Eigen::Vector3d(0.0, 1.0, 0.0); + auto ball = world.addRigidBody("public_avbd_allocation_ball", ballOptions); + ball.setCollisionShape(sx::CollisionShape::makeSphere(0.25)); +} + void configureRigidBoxedLcpContactRowsScene(dart::simulation::World& world) { namespace sx = dart::simulation; @@ -5781,13 +5821,15 @@ TEST(World, WorldOptionsConfigureDomainSolverFamilies) namespace sx = dart::simulation; sx::WorldOptions options; - options.rigidBodySolver = sx::RigidBodySolver::Ipc; + options.rigidBodySolver = sx::RigidBodySolver::Avbd; + options.rigidConstraintOptions.iterations = 20; options.multibodyOptions.integrationFamily = sx::MultibodyIntegrationFamily::Variational; sx::World world(options); - EXPECT_EQ(world.getRigidBodySolver(), sx::RigidBodySolver::Ipc); + EXPECT_EQ(world.getRigidBodySolver(), sx::RigidBodySolver::Avbd); + EXPECT_EQ(world.getRigidConstraintOptions().iterations, 20u); EXPECT_EQ( world.getMultibodyOptions().integrationFamily, sx::MultibodyIntegrationFamily::Variational); @@ -5798,7 +5840,8 @@ TEST(World, ClearResetsWorldOptionPoliciesToDefaults) namespace sx = dart::simulation; sx::WorldOptions options; - options.rigidBodySolver = sx::RigidBodySolver::Ipc; + options.rigidBodySolver = sx::RigidBodySolver::SequentialImpulse; + options.rigidConstraintOptions.iterations = 20; options.multibodyOptions.integrationFamily = sx::MultibodyIntegrationFamily::Variational; options.differentiable = true; @@ -5811,6 +5854,7 @@ TEST(World, ClearResetsWorldOptionPoliciesToDefaults) world.clear(); EXPECT_EQ(world.getRigidBodySolver(), sx::RigidBodySolver::SequentialImpulse); + EXPECT_EQ(world.getRigidConstraintOptions().iterations, 8u); EXPECT_EQ( world.getMultibodyOptions().integrationFamily, sx::MultibodyIntegrationFamily::SemiImplicit); @@ -5847,6 +5891,33 @@ TEST(World, WorldOptionsRejectInvalidDomainSolverFamilies) }, sx::InvalidArgumentException); + sx::WorldOptions invalidRigidConstraintIterations; + invalidRigidConstraintIterations.rigidConstraintOptions.iterations = 0; + EXPECT_THROW( + { + sx::World world(invalidRigidConstraintIterations); + (void)world; + }, + sx::InvalidArgumentException); + invalidRigidConstraintIterations.rigidConstraintOptions.iterations + = std::numeric_limits::max(); + EXPECT_THROW( + { + sx::World world(invalidRigidConstraintIterations); + (void)world; + }, + sx::InvalidArgumentException); + + sx::WorldOptions invalidIpcConstraintOptions; + invalidIpcConstraintOptions.rigidBodySolver = sx::RigidBodySolver::Ipc; + invalidIpcConstraintOptions.rigidConstraintOptions.iterations = 20; + EXPECT_THROW( + { + sx::World world(invalidIpcConstraintOptions); + (void)world; + }, + sx::InvalidArgumentException); + sx::WorldOptions invalidContactSolver; invalidContactSolver.contactSolverMethod = static_cast(99); @@ -5889,6 +5960,142 @@ TEST(World, SetContactGradientModeRejectsInvalidEnum) EXPECT_EQ(world.getContactGradientMode(), sx::ContactGradientMode::Analytic); } +TEST(World, RigidConstraintIterationBudgetControlsAvbdProjectionSweeps) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.timeStep = 1.0 / 60.0; + options.rigidConstraintOptions.iterations = 20; + sx::World world(options); + + auto parent = world.addRigidBody("iteration_parent"); + sx::RigidBodyOptions childOptions; + childOptions.position = Eigen::Vector3d(1.0, 0.0, 0.0); + childOptions.linearVelocity = Eigen::Vector3d(0.0, 1.0, 0.0); + auto child = world.addRigidBody("iteration_child", childOptions); + world.addJoint( + parent, + child, + sx::JointSpec{ + .name = "iteration_fixed", + .type = sx::JointType::Fixed, + }); + + world.enterSimulationMode(); + world.step(); + EXPECT_EQ(world.computeStepMetrics().lastStepIterations, 20u); + + world.setRigidConstraintOptions({.iterations = 3}); + EXPECT_EQ(world.getRigidConstraintOptions().iterations, 3u); + world.step(); + EXPECT_EQ(world.computeStepMetrics().lastStepIterations, 3u); + + EXPECT_THROW( + world.setRigidConstraintOptions({.iterations = 0}), + sx::InvalidArgumentException); + EXPECT_EQ(world.getRigidConstraintOptions().iterations, 3u); + + EXPECT_THROW( + world.setRigidBodySolver(sx::RigidBodySolver::Ipc), + sx::InvalidArgumentException); + EXPECT_EQ(world.getRigidBodySolver(), sx::RigidBodySolver::SequentialImpulse); +} + +TEST(World, PublicAvbdFamilyProjectsContactsWithoutPrivateBodyConfigs) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.timeStep = 0.05; + options.rigidBodySolver = sx::RigidBodySolver::Avbd; + options.rigidConstraintOptions.iterations = 20; + sx::World world(options); + + sx::RigidBodyOptions groundOptions; + groundOptions.isStatic = true; + groundOptions.position = Eigen::Vector3d(0.0, 0.0, -0.25); + auto ground = world.addRigidBody("public_avbd_ground", groundOptions); + ground.setCollisionShape( + sx::CollisionShape::makeBox(Eigen::Vector3d(2.0, 2.0, 0.25))); + + sx::RigidBodyOptions sphereOptions; + sphereOptions.mass = 1.0; + sphereOptions.position = Eigen::Vector3d(0.0, 0.0, 0.4); + auto sphere = world.addRigidBody("public_avbd_sphere", sphereOptions); + sphere.setCollisionShape(sx::CollisionShape::makeSphere(0.5)); + + const auto& registry = sx::detail::registryOf(world); + EXPECT_FALSE(registry.all_of( + sx::detail::toRegistryEntity(ground.getEntity()))); + EXPECT_FALSE(registry.all_of( + sx::detail::toRegistryEntity(sphere.getEntity()))); + ASSERT_EQ(world.collide().size(), 1u); + + const Eigen::Vector3d initialPosition = sphere.getTranslation(); + world.step(); + + EXPECT_EQ(world.getRigidBodySolver(), sx::RigidBodySolver::Avbd); + EXPECT_EQ(world.computeStepMetrics().lastStepIterations, 20u); + EXPECT_GT(sphere.getLinearVelocity().z(), 0.0); + EXPECT_GT(sphere.getTranslation().z(), initialPosition.z()); +} + +TEST(World, PublicAvbdFamilyRejectsConflictingContactPolicy) +{ + namespace sx = dart::simulation; + + sx::WorldOptions invalidOptions; + invalidOptions.rigidBodySolver = sx::RigidBodySolver::Avbd; + invalidOptions.contactSolverMethod = sx::ContactSolverMethod::BoxedLcp; + EXPECT_THROW( + { + sx::World invalidWorld(invalidOptions); + (void)invalidWorld; + }, + sx::InvalidArgumentException); + + sx::World boxedWorld; + boxedWorld.setContactSolverMethod(sx::ContactSolverMethod::BoxedLcp); + EXPECT_THROW( + boxedWorld.setRigidBodySolver(sx::RigidBodySolver::Avbd), + sx::InvalidArgumentException); + EXPECT_EQ( + boxedWorld.getRigidBodySolver(), sx::RigidBodySolver::SequentialImpulse); + + sx::World avbdWorld; + avbdWorld.setRigidBodySolver(sx::RigidBodySolver::Avbd); + EXPECT_THROW( + avbdWorld.setContactSolverMethod(sx::ContactSolverMethod::BoxedLcp), + sx::InvalidArgumentException); + EXPECT_EQ( + avbdWorld.getContactSolverMethod(), + sx::ContactSolverMethod::SequentialImpulse); +} + +TEST(World, PublicAvbdFamilyRejectsMultibodyWorlds) +{ + namespace sx = dart::simulation; + + sx::World avbdFirst; + avbdFirst.setRigidBodySolver(sx::RigidBodySolver::Avbd); + EXPECT_THROW( + avbdFirst.addMultibody("unsupported_robot"), + sx::InvalidOperationException); + EXPECT_EQ(avbdFirst.getMultibodyCount(), 0u); + + sx::World multibodyFirst; + (void)multibodyFirst.addMultibody("robot"); + EXPECT_THROW( + multibodyFirst.setRigidBodySolver(sx::RigidBodySolver::Avbd), + sx::InvalidOperationException); + EXPECT_EQ( + multibodyFirst.getRigidBodySolver(), + sx::RigidBodySolver::SequentialImpulse); +} + TEST(World, SetContactSolverMethodUpdatesPolicy) { namespace sx = dart::simulation; @@ -8273,6 +8480,10 @@ TEST(World, BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths) true); expectNoWorldBaseAllocatorActivityDuringBakedSteps( "rigid AVBD contact rows", configureRigidAvbdContactRowsScene, true); + expectNoWorldBaseAllocatorActivityDuringBakedSteps( + "public rigid AVBD contact and breakable-joint rows", + configurePublicRigidAvbdContactAndBreakableJointRowsScene, + true); expectNoWorldBaseAllocatorActivityDuringBakedSteps( "rigid AVBD fixed-joint rows", configureRigidAvbdFixedJointRowsScene); expectNoWorldBaseAllocatorActivityDuringBakedSteps( @@ -9652,6 +9863,77 @@ TEST(World, RigidAvbdFixedJointRowsAreActiveWithoutContacts) EXPECT_LT(link->getLinearVelocity().x(), 0.0); } +TEST(World, RigidAvbdFixedJointRowsIgnoreDisconnectedContactPresence) +{ + namespace sx = dart::simulation; + + struct Outcome + { + Eigen::Isometry3d transform = Eigen::Isometry3d::Identity(); + Eigen::Vector3d linearVelocity = Eigen::Vector3d::Zero(); + Eigen::Vector3d angularVelocity = Eigen::Vector3d::Zero(); + }; + + const auto run = [](bool addDisconnectedContact) { + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.timeStep = 0.05; + options.rigidBodySolver = sx::RigidBodySolver::Avbd; + options.rigidConstraintOptions.iterations = 1u; + sx::World world(options); + + sx::RigidBodyOptions parentOptions; + parentOptions.isStatic = true; + auto parent = world.addRigidBody("independent_joint_parent", parentOptions); + + sx::RigidBodyOptions childOptions; + childOptions.position = Eigen::Vector3d::UnitX(); + childOptions.linearVelocity = Eigen::Vector3d::UnitY(); + auto child = world.addRigidBody("independent_joint_child", childOptions); + world.addJoint( + parent, + child, + sx::JointSpec{ + .name = "independent_fixed_joint", + .type = sx::JointType::Fixed, + }); + + if (addDisconnectedContact) { + sx::RigidBodyOptions obstacleOptions; + obstacleOptions.isStatic = true; + obstacleOptions.position = Eigen::Vector3d(100.0, 0.0, -0.25); + auto obstacle + = world.addRigidBody("disconnected_obstacle", obstacleOptions); + obstacle.setCollisionShape( + sx::CollisionShape::makeBox(Eigen::Vector3d(2.0, 2.0, 0.25))); + + sx::RigidBodyOptions sphereOptions; + sphereOptions.position = Eigen::Vector3d(100.0, 0.0, 0.4); + auto sphere + = world.addRigidBody("disconnected_contact_body", sphereOptions); + sphere.setCollisionShape(sx::CollisionShape::makeSphere(0.5)); + EXPECT_EQ(world.collide().size(), 1u); + } else { + EXPECT_TRUE(world.collide().empty()); + } + + world.step(2u); + return Outcome{ + child.getTransform(), + child.getLinearVelocity(), + child.getAngularVelocity(), + }; + }; + + const Outcome withoutContact = run(false); + const Outcome withContact = run(true); + EXPECT_TRUE(withContact.transform.isApprox(withoutContact.transform, 1e-12)); + EXPECT_TRUE(withContact.linearVelocity.isApprox( + withoutContact.linearVelocity, 1e-12)); + EXPECT_TRUE(withContact.angularVelocity.isApprox( + withoutContact.angularVelocity, 1e-12)); +} + TEST(World, RigidAvbdDistanceSpringRowsAreActiveWithoutContacts) { namespace sx = dart::simulation; @@ -9902,6 +10184,10 @@ TEST(World, BakedRigidBodyContactStepsDoNotAllocateGlobalHeap) true); expectNoGlobalHeapAllocationsDuringBakedSteps( "rigid AVBD contact rows", configureRigidAvbdContactRowsScene, true); + expectNoGlobalHeapAllocationsDuringBakedSteps( + "public rigid AVBD contact and breakable-joint rows", + configurePublicRigidAvbdContactAndBreakableJointRowsScene, + true); expectNoGlobalHeapAllocationsDuringBakedSteps( "rigid AVBD fixed-joint rows", configureRigidAvbdFixedJointRowsScene); expectNoGlobalHeapAllocationsDuringBakedSteps( @@ -10523,6 +10809,10 @@ TEST(World, BakedAvbdVbdRowsDoNotMallocOnHeap) #else expectNoRawHeapAllocationsDuringFirstPostBakeSteps( "rigid AVBD contact rows", configureRigidAvbdContactRowsScene, true); + expectNoRawHeapAllocationsDuringFirstPostBakeSteps( + "public rigid AVBD contact and breakable-joint rows", + configurePublicRigidAvbdContactAndBreakableJointRowsScene, + true); expectNoRawHeapAllocationsDuringFirstPostBakeSteps( "rigid AVBD fixed-joint rows", configureRigidAvbdFixedJointRowsScene); expectNoRawHeapAllocationsDuringFirstPostBakeSteps( @@ -24934,6 +25224,7 @@ TEST(World, ReplayRecordingRestoresSolverOptions) ASSERT_EQ(world.getReplayFrameCount(), 2u); world.setRigidBodySolver(sx::RigidBodySolver::SequentialImpulse); + world.setRigidConstraintOptions({.iterations = 3}); world.setMultibodyOptions({sx::MultibodyIntegrationFamily::SemiImplicit}); world.setContactGradientMode(sx::ContactGradientMode::Analytic); @@ -24941,6 +25232,7 @@ TEST(World, ReplayRecordingRestoresSolverOptions) EXPECT_TRUE(world.isSimulationMode()); EXPECT_EQ(world.getRigidBodySolver(), sx::RigidBodySolver::Ipc); + EXPECT_EQ(world.getRigidConstraintOptions().iterations, 8u); EXPECT_EQ( world.getMultibodyOptions().integrationFamily, sx::MultibodyIntegrationFamily::Variational); @@ -24953,6 +25245,82 @@ TEST(World, ReplayRecordingRestoresSolverOptions) EXPECT_NO_THROW(world.step()); } +TEST(World, ReplayRecordingRestoresPublicAvbdFamily) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.rigidBodySolver = sx::RigidBodySolver::Avbd; + options.rigidConstraintOptions.iterations = 20; + sx::World world(options); + world.setReplayRecordingEnabled(true); + world.step(); + ASSERT_EQ(world.getReplayFrameCount(), 2u); + + world.setRigidBodySolver(sx::RigidBodySolver::SequentialImpulse); + world.setRigidConstraintOptions({.iterations = 3}); + world.restoreReplayFrame(1); + + EXPECT_EQ(world.getRigidBodySolver(), sx::RigidBodySolver::Avbd); + EXPECT_EQ(world.getRigidConstraintOptions().iterations, 20u); + const auto& resolved = world.getResolvedConfiguration(); + EXPECT_NE( + resolved.toSummaryText().find("rigid-body: avbd -> avbd"), + std::string::npos); + EXPECT_NE( + resolved.toSummaryText().find("rigid-constraint-iterations: 20 -> 20"), + std::string::npos); +} + +// Test that replay restores rigid AVBD row inventories, so branching from an +// earlier frame does not retain later lambda/stiffness continuation state. +TEST(World, ReplayRecordingRestoresRigidAvbdWarmStartState) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.timeStep = 0.05; + options.rigidBodySolver = sx::RigidBodySolver::Avbd; + options.rigidConstraintOptions.iterations = 1; + sx::World world(options); + + sx::RigidBodyOptions parentOptions; + parentOptions.isStatic = true; + auto parent = world.addRigidBody("rigid_avbd_replay_parent", parentOptions); + + sx::RigidBodyOptions childOptions; + childOptions.position = Eigen::Vector3d::UnitX(); + childOptions.linearVelocity = Eigen::Vector3d::UnitY(); + auto child = world.addRigidBody("rigid_avbd_replay_child", childOptions); + world.addJoint( + parent, + child, + sx::JointSpec{ + .name = "rigid_avbd_replay_fixed", + .type = sx::JointType::Fixed, + }); + + world.setReplayRecordingEnabled(true); + world.step(); + ASSERT_EQ(world.getReplayFrameCount(), 2u); + world.step(); + ASSERT_EQ(world.getReplayFrameCount(), 3u); + const Eigen::Isometry3d expectedTransform = child.getTransform(); + const Eigen::Vector3d expectedLinearVelocity = child.getLinearVelocity(); + const Eigen::Vector3d expectedAngularVelocity = child.getAngularVelocity(); + + world.step(5); + world.restoreReplayFrame(1); + world.step(); + + EXPECT_TRUE(child.getTransform().isApprox(expectedTransform, 1e-12)); + EXPECT_TRUE( + child.getLinearVelocity().isApprox(expectedLinearVelocity, 1e-12)); + EXPECT_TRUE( + child.getAngularVelocity().isApprox(expectedAngularVelocity, 1e-12)); +} + // Test that replay restores rigid IPC's adaptive barrier continuation state, so // a branch from an earlier contact frame matches an uninterrupted rollout. TEST(World, ReplayRecordingRestoresRigidIpcAdaptiveBranchState) diff --git a/tests/unit/simulation/world/test_world_resolved_configuration.cpp b/tests/unit/simulation/world/test_world_resolved_configuration.cpp index afabe22ce224c..1173605987f39 100644 --- a/tests/unit/simulation/world/test_world_resolved_configuration.cpp +++ b/tests/unit/simulation/world/test_world_resolved_configuration.cpp @@ -35,12 +35,15 @@ // alongside the step profile. Today requested == resolved (no substitution); // later slices classify the known silent substitutions. +#include #include #include +#include #include #include #include #include +#include #include #include @@ -138,30 +141,98 @@ TEST(ResolvedConfiguration, ReflectsRequestedContactMethod) EXPECT_EQ(contact->resolved, "boxed-lcp"); } +TEST(ResolvedConfiguration, RecordsPublicAvbdFamilyAsRequested) +{ + sx::WorldOptions options; + options.rigidBodySolver = sx::RigidBodySolver::Avbd; + options.rigidConstraintOptions.iterations = 20; + sx::World world(options); + auto parent = world.addRigidBody("parent"); + sx::RigidBodyOptions childOptions; + childOptions.position = Eigen::Vector3d::UnitX(); + auto child = world.addRigidBody("child", childOptions); + world.addJoint( + parent, + child, + sx::JointSpec{.name = "fixed", .type = sx::JointType::Fixed}); + world.enterSimulationMode(); + + const auto& config = world.getResolvedConfiguration(); + EXPECT_FALSE(config.hasSubstitution()); + + const auto* rigid = findNote(config, "rigid-body"); + ASSERT_NE(rigid, nullptr); + EXPECT_EQ(rigid->requested, "avbd"); + EXPECT_EQ(rigid->resolved, "avbd"); + + const auto* contact = findNote(config, "rigid-contact"); + ASSERT_NE(contact, nullptr); + EXPECT_EQ(contact->requested, "avbd"); + EXPECT_EQ(contact->resolved, "avbd"); + EXPECT_EQ(contact->reason, "as requested"); + + const auto* pairConstraint = findNote(config, "rigid-pair-constraint"); + ASSERT_NE(pairConstraint, nullptr); + EXPECT_EQ(pairConstraint->requested, "avbd"); + EXPECT_EQ(pairConstraint->resolved, "avbd"); + EXPECT_EQ(pairConstraint->reason, "as requested"); + + const auto* iterations = findNote(config, "rigid-constraint-iterations"); + ASSERT_NE(iterations, nullptr); + EXPECT_EQ(iterations->requested, "20"); + EXPECT_EQ(iterations->resolved, "20"); +} + namespace { // Emplace the internal AVBD rigid-contact opt-in on a body. The opt-in is not // facade-selectable (PLAN-091 WP-091.1), so the resolved contact path then // differs from the requested ContactSolverMethod. -sx::RigidBody addBodyWithAvbdContactConfig(sx::World& world) +sx::RigidBody addCollisionBody( + sx::World& world, std::string_view name, double x) { sx::RigidBodyOptions options; - options.position = Eigen::Vector3d(0.0, 0.0, 0.5); - auto body = world.addRigidBody("avbd_body", options); + options.position = Eigen::Vector3d(x, 0.0, 0.5); + auto body = world.addRigidBody(name, options); body.setCollisionShape(sx::CollisionShape::makeSphere(0.5)); + return body; +} + +sx::RigidBody addBodyWithAvbdContactConfig( + sx::World& world, bool enabled = true) +{ + auto body = addCollisionBody(world, "avbd_body", 0.0); auto& registry = sx::detail::registryOf(world); - registry.emplace_or_replace( + auto& config = registry.emplace_or_replace( sx::detail::toRegistryEntity(body.getEntity())); + config.enabled = enabled; return body; } +void addMixedRigidAndMultibodyContactScene(sx::World& world) +{ + world.setGravity(Eigen::Vector3d::Zero()); + + sx::RigidBodyOptions rigidOptions; + rigidOptions.position = Eigen::Vector3d(0.0, 0.0, 0.75); + auto rigidBody = world.addRigidBody("mixed_rigid", rigidOptions); + rigidBody.setCollisionShape(sx::CollisionShape::makeSphere(0.5)); + + auto multibody = world.addMultibody("mixed_multibody"); + auto base = multibody.addLink("base"); + base.setMass(1.0); + base.setCollisionShape(sx::CollisionShape::makeSphere(0.5)); +} + } // namespace TEST(ResolvedConfiguration, RecordsAvbdContactSubstitution) { sx::World world; addBodyWithAvbdContactConfig(world); + addCollisionBody(world, "plain_body", 0.5); world.enterSimulationMode(); + world.step(); const auto& config = world.getResolvedConfiguration(); EXPECT_TRUE(config.hasSubstitution()); @@ -173,12 +244,190 @@ TEST(ResolvedConfiguration, RecordsAvbdContactSubstitution) EXPECT_NE(contact->resolved.find("avbd"), std::string::npos); } +TEST(ResolvedConfiguration, DisabledAvbdContactConfigDoesNotClaimAvbd) +{ + sx::World world; + addBodyWithAvbdContactConfig(world, false); + addCollisionBody(world, "plain_body", 0.5); + world.enterSimulationMode(); + world.step(); + + const auto& config = world.getResolvedConfiguration(); + EXPECT_FALSE(config.hasSubstitution()); + const auto* contact = findNote(config, "rigid-contact"); + ASSERT_NE(contact, nullptr); + EXPECT_EQ(contact->requested, "sequential-impulse"); + EXPECT_EQ(contact->resolved, "sequential-impulse"); + EXPECT_NE(contact->reason.find("disabled"), std::string::npos); +} + +TEST(ResolvedConfiguration, UncoveredAvbdContactPairDoesNotClaimAvbd) +{ + sx::World world; + addBodyWithAvbdContactConfig(world); + addCollisionBody(world, "plain_body_a", 0.5); + addCollisionBody(world, "plain_body_b", 1.0); + world.enterSimulationMode(); + world.step(); + + const auto& config = world.getResolvedConfiguration(); + EXPECT_FALSE(config.hasSubstitution()); + const auto* contact = findNote(config, "rigid-contact"); + ASSERT_NE(contact, nullptr); + EXPECT_EQ(contact->requested, "sequential-impulse"); + EXPECT_EQ(contact->resolved, "sequential-impulse"); + EXPECT_NE(contact->reason.find("does not cover"), std::string::npos); +} + +TEST(ResolvedConfiguration, RecordsAvbdPairConstraintSubstitution) +{ + sx::World world; + auto parent = world.addRigidBody("parent"); + sx::RigidBodyOptions childOptions; + childOptions.position = Eigen::Vector3d::UnitX(); + auto child = world.addRigidBody("child", childOptions); + world.addJoint( + parent, + child, + sx::JointSpec{.name = "fixed", .type = sx::JointType::Fixed}); + world.enterSimulationMode(); + + const auto& config = world.getResolvedConfiguration(); + EXPECT_TRUE(config.hasSubstitution()); + + const auto* pairConstraint = findNote(config, "rigid-pair-constraint"); + ASSERT_NE(pairConstraint, nullptr); + EXPECT_TRUE(pairConstraint->isSubstitution()); + EXPECT_EQ(pairConstraint->requested, "sequential-impulse"); + EXPECT_EQ(pairConstraint->resolved, "avbd"); +} + +TEST(ResolvedConfiguration, RecordsIpcPairConstraintsWithoutAvbdSubstitution) +{ + sx::WorldOptions options; + options.rigidBodySolver = sx::RigidBodySolver::Ipc; + sx::World world(options); + auto parent = world.addRigidBody("parent"); + sx::RigidBodyOptions childOptions; + childOptions.position = Eigen::Vector3d::UnitX(); + auto child = world.addRigidBody("child", childOptions); + world.addJoint( + parent, + child, + sx::JointSpec{.name = "fixed", .type = sx::JointType::Fixed}); + world.enterSimulationMode(); + + const auto& config = world.getResolvedConfiguration(); + EXPECT_FALSE(config.hasSubstitution()); + + const auto* pairConstraint = findNote(config, "rigid-pair-constraint"); + ASSERT_NE(pairConstraint, nullptr); + EXPECT_EQ(pairConstraint->requested, "ipc"); + EXPECT_EQ(pairConstraint->resolved, "ipc"); + EXPECT_NE(pairConstraint->reason.find("IPC articulation"), std::string::npos); + + const auto* iterations = findNote(config, "rigid-constraint-iterations"); + ASSERT_NE(iterations, nullptr); + EXPECT_EQ(iterations->resolved, "not-applicable"); +} + +TEST(ResolvedConfiguration, RuntimeIterationMutationRefreshesReport) +{ + sx::World world; + world.enterSimulationMode(); + + world.setRigidConstraintOptions({.iterations = 3u}); + + const auto* iterations = findNote( + world.getResolvedConfiguration(), "rigid-constraint-iterations"); + ASSERT_NE(iterations, nullptr); + EXPECT_EQ(iterations->requested, "3"); + EXPECT_EQ(iterations->resolved, "3"); +} + +TEST( + ResolvedConfiguration, + MixedSemiImplicitWorldMarksRigidIterationsNotApplicable) +{ + sx::World world; + addMixedRigidAndMultibodyContactScene(world); + world.enterSimulationMode(); + + const auto* iterations = findNote( + world.getResolvedConfiguration(), "rigid-constraint-iterations"); + ASSERT_NE(iterations, nullptr); + EXPECT_EQ(iterations->requested, "not-applicable"); + EXPECT_EQ(iterations->resolved, "not-applicable"); + EXPECT_NE(iterations->reason.find("unified constraint"), std::string::npos); + + ASSERT_FALSE(world.collide().empty()); + EXPECT_NO_THROW(world.step()); + EXPECT_EQ(world.computeStepMetrics().lastStepIterations, 0u); + + EXPECT_THROW( + world.setRigidConstraintOptions({.iterations = 3u}), + sx::InvalidOperationException); + EXPECT_EQ(world.getRigidConstraintOptions().iterations, 8u); + + iterations = findNote( + world.getResolvedConfiguration(), "rigid-constraint-iterations"); + ASSERT_NE(iterations, nullptr); + EXPECT_EQ(iterations->resolved, "not-applicable"); +} + +TEST( + ResolvedConfiguration, + MixedSemiImplicitWorldRejectsNonDefaultIterationsAtEntry) +{ + sx::WorldOptions options; + options.rigidConstraintOptions.iterations = 3u; + sx::World world(options); + addMixedRigidAndMultibodyContactScene(world); + + EXPECT_THROW(world.enterSimulationMode(), sx::InvalidOperationException); + EXPECT_FALSE(world.isSimulationMode()); +} + +TEST( + ResolvedConfiguration, + MixedWorldRejectsSemiImplicitTransitionWithNonDefaultIterations) +{ + sx::WorldOptions options; + options.rigidConstraintOptions.iterations = 3u; + options.multibodyOptions.integrationFamily + = sx::MultibodyIntegrationFamily::Variational; + sx::World world(options); + addMixedRigidAndMultibodyContactScene(world); + world.enterSimulationMode(); + + const auto* iterations = findNote( + world.getResolvedConfiguration(), "rigid-constraint-iterations"); + ASSERT_NE(iterations, nullptr); + EXPECT_EQ(iterations->requested, "3"); + EXPECT_EQ(iterations->resolved, "3"); + + EXPECT_THROW( + world.setMultibodyOptions( + {.integrationFamily = sx::MultibodyIntegrationFamily::SemiImplicit}), + sx::InvalidOperationException); + EXPECT_EQ( + world.getMultibodyOptions().integrationFamily, + sx::MultibodyIntegrationFamily::Variational); + + iterations = findNote( + world.getResolvedConfiguration(), "rigid-constraint-iterations"); + ASSERT_NE(iterations, nullptr); + EXPECT_EQ(iterations->resolved, "3"); + EXPECT_NO_THROW(world.step()); +} + TEST(ResolvedConfiguration, StrictResolutionRejectsSubstitution) { sx::WorldOptions options; options.strictSolverResolution = true; sx::World world(options); addBodyWithAvbdContactConfig(world); + addCollisionBody(world, "plain_body", 0.5); EXPECT_THROW(world.enterSimulationMode(), std::exception); } diff --git a/tests/unit/simulation/world/test_world_step_schedule.cpp b/tests/unit/simulation/world/test_world_step_schedule.cpp index 6f8c4986d188d..5e1247f1022ba 100644 --- a/tests/unit/simulation/world/test_world_step_schedule.cpp +++ b/tests/unit/simulation/world/test_world_step_schedule.cpp @@ -91,6 +91,21 @@ TEST(BuiltInWorldStepSchedule, FreeRigidDefaultUsesSplitContactPipeline) expectInlineCapacity(options); } +TEST(BuiltInWorldStepSchedule, AvbdUsesSplitContactPipeline) +{ + auto options = defaultOptions(); + options.rigidBodySolver = RigidFamily::Avbd; + + EXPECT_EQ( + slots(options), + (std::vector{ + Slot::RigidBodyVelocity, + Slot::RigidBodyContact, + Slot::RigidBodyPosition, + Slot::Kinematics})); + expectInlineCapacity(options); +} + TEST(BuiltInWorldStepSchedule, EmptyWorldUsesOnlyWorldLevelKinematics) { sxdetail::BuiltInWorldStepScheduleOptions options; From d9af744a9cb0763033d76e01381dbf7c1e6ecaa9 Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Thu, 30 Jul 2026 21:08:35 -0700 Subject: [PATCH 08/13] Add public fixed-penalty VBD comparison --- CHANGELOG.md | 17 +- dart/simulation/comps/rigid_body.hpp | 9 +- .../compute/detail/world_step_stages.hpp | 5 +- .../compute/rigid_body_contact_stage.cpp | 98 +- .../detail/rigid_avbd/rigid_block_kernel.hpp | 11 +- .../detail/rigid_avbd/rigid_world_contact.hpp | 139 +- .../simulation/detail/world_step_schedule.hpp | 2 + dart/simulation/io/binary_io.hpp | 3 +- dart/simulation/world.cpp | 60 +- dart/simulation/world.hpp | 31 +- dart/simulation/world_options.hpp | 26 +- docs/design/dart7_architecture_assessment.md | 24 +- docs/design/simulation_cpp_api.md | 13 +- docs/design/simulation_python_api.md | 7 +- docs/design/simulation_solver_architecture.md | 17 +- docs/dev_tasks/avbd_solver/README.md | 119 +- docs/dev_tasks/avbd_solver/RESUME.md | 88 +- docs/plans/104-vertex-block-descent-solver.md | 64 +- .../avbd-demo-corpus.md | 44 +- .../avbd-paper-breakable-wall-packet.json | 54 +- .../avbd-paper-coverage-contract.json | 11 +- .../avbd-paper-gap-audit.md | 31 +- .../avbd-paper-vbd-comparison-packet.json | 977 ++++++++++++ .../paper-parity-matrix.md | 11 +- .../coverage-matrix.md | 40 +- docs/readthedocs/architecture.md | 1 + docs/readthedocs/papers.md | 4 + python/dartpy/simulation/module_core.cpp | 3 +- python/examples/demos/README.md | 1 + python/examples/demos/registry.py | 2 + .../demos/scenes/avbd_paper_breakable_wall.py | 212 ++- .../demos/scenes/vbd_paper_breakable_wall.py | 54 + python/stubs/dartpy/simulation.pyi | 2 + python/tests/integration/test_demos_cycle.py | 138 ++ python/tests/unit/simulation/test_world.py | 31 + .../unit/test_benchmark_display_names.py | 7 + ..._write_avbd_paper_vbd_comparison_packet.py | 679 ++++++++ scripts/avbd_packet_schema.py | 41 +- scripts/benchmark_display_names.py | 3 + .../write_avbd_paper_breakable_wall_packet.py | 14 +- .../write_avbd_paper_vbd_comparison_packet.py | 1374 +++++++++++++++++ .../simulation/bm_avbd_rigid_fixed_joint.cpp | 106 +- tests/test_avbd_packet_schema.py | 77 + tests/test_check_avbd_packets.py | 38 + .../deformable_vbd/test_avbd_rigid_block.cpp | 452 ++++++ .../simulation/world/test_serialization.cpp | 45 + tests/unit/simulation/world/test_world.cpp | 141 +- .../test_world_resolved_configuration.cpp | 42 + .../world/test_world_step_schedule.cpp | 15 + 49 files changed, 4962 insertions(+), 421 deletions(-) create mode 100644 docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json create mode 100644 python/examples/demos/scenes/vbd_paper_breakable_wall.py create mode 100644 python/tests/unit/test_write_avbd_paper_vbd_comparison_packet.py create mode 100644 scripts/write_avbd_paper_vbd_comparison_packet.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 082780ca7e434..e30ec6d463afd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -202,14 +202,15 @@ compatibility remains on the active DART 6 LTS branch._ finite plus motor row loads now share physical break-force accounting with reset, re-arm, and binary persistence. ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) -- Added an explicit experimental AVBD rigid-body family to the DART 7 - `World` C++ and dartpy facades, including typed rigid-constraint options, - binary/replay persistence, fail-closed configuration, and a - publication-shaped breakable-wall demo with quantitative, allocation, - performance, and assessed visual evidence. Claim-tied view assessment now - scopes ambiguity to an explicitly named subject while still treating other - renderables as possible occluders, so dense debris scenes cannot reject a - clear subject view because of an unrelated overlapping pair. +- Added explicit experimental fixed-penalty VBD and augmented-Lagrangian AVBD + rigid-body families to the DART 7 `World` C++ and dartpy facades, including + typed rigid-constraint options, binary/replay persistence, fail-closed + configuration, and matched publication-shaped breakable-wall demos with + quantitative, allocation, performance, and assessed visual evidence. + Claim-tied view assessment now scopes ambiguity to an explicitly named + subject while still treating other renderables as possible occluders, so + dense debris scenes cannot reject a clear subject view because of an + unrelated overlapping pair. ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) - Added compute-executor and backend-boundary work so CPU threading, optional CUDA experiments, and future accelerator sidecars do not leak into the default diff --git a/dart/simulation/comps/rigid_body.hpp b/dart/simulation/comps/rigid_body.hpp index 456509f58f217..c6079a3e14eb5 100644 --- a/dart/simulation/comps/rigid_body.hpp +++ b/dart/simulation/comps/rigid_body.hpp @@ -91,10 +91,11 @@ struct KinematicBodyStepTrace /// Internal per-body configuration for AVBD rigid contact. /// /// This component is intentionally not surfaced through the public `World` -/// facade. `RigidBodySolver::Avbd` selects AVBD globally and uses these values -/// only as internal per-body refinements. Compatibility tests may also attach -/// enabled configs while another public family is selected; only that private -/// compatibility path may fall back to sequential impulse. +/// facade. `RigidBodySolver::Vbd` and `RigidBodySolver::Avbd` select their +/// block-descent formulations globally and use these values only as internal +/// per-body contact refinements. Compatibility tests may also attach enabled +/// configs while another public family is selected; that private path remains +/// AVBD-only and may fall back to sequential impulse. struct RigidAvbdContactConfig { bool enabled = true; diff --git a/dart/simulation/compute/detail/world_step_stages.hpp b/dart/simulation/compute/detail/world_step_stages.hpp index d2d780c202450..d3f453ca1820c 100644 --- a/dart/simulation/compute/detail/world_step_stages.hpp +++ b/dart/simulation/compute/detail/world_step_stages.hpp @@ -384,8 +384,9 @@ class DART_SIMULATION_API RigidBodyPositionStage final : public WorldStepStage /// mass) act as immovable. /// /// The selected `WorldOptions::rigidBodySolver` family owns the top-level -/// contact formulation. `RigidBodySolver::Avbd` routes every supported active -/// free-rigid contact through AVBD. Under the default +/// contact formulation. `RigidBodySolver::Vbd` and `RigidBodySolver::Avbd` +/// route every supported active free-rigid contact through fixed-penalty VBD +/// and augmented VBD, respectively. Under the default /// `RigidBodySolver::SequentialImpulse` family, two contact-policy paths are /// available via `WorldOptions::contactSolverMethod`: /// - `SequentialImpulse` (default): the long-standing sequential normal + diff --git a/dart/simulation/compute/rigid_body_contact_stage.cpp b/dart/simulation/compute/rigid_body_contact_stage.cpp index 22622125ebae6..9501ff47e0cb3 100644 --- a/dart/simulation/compute/rigid_body_contact_stage.cpp +++ b/dart/simulation/compute/rigid_body_contact_stage.cpp @@ -138,12 +138,14 @@ std::optional rigidAvbdContactStageConfig( //============================================================================== dvbd::AvbdRigidWorldContactSolveOptions rigidAvbdWorldSolveOptions( std::size_t iterations, + dvbd::AvbdRigidWorldContactSolveOptions::Formulation formulation, const comps::RigidAvbdContactConfig* contactConfig = nullptr, double contactMaxStiffness = std::numeric_limits::infinity()) { dvbd::AvbdRigidWorldContactSolveOptions options; options.descent.iterations = iterations; options.descent.regularization = 1e-12; + options.formulation = formulation; if (contactConfig != nullptr) { options.hasContactFamilyOverride = true; @@ -632,8 +634,9 @@ void RigidBodyContactStage::prepare(World& world) constraints.clear(); const auto& registry = dart::simulation::detail::registryOf(world); - const bool useAvbdFamily - = world.getRigidBodySolver() == RigidBodySolver::Avbd; + const RigidBodySolver rigidBodySolver = world.getRigidBodySolver(); + const bool useOwnedBlockFamily = rigidBodySolver == RigidBodySolver::Vbd + || rigidBodySolver == RigidBodySolver::Avbd; const bool skipContactQuery = shouldSkipRigidBodyContactQuery(world); std::size_t contactCapacity = 0u; if (!skipContactQuery) { @@ -685,7 +688,8 @@ void RigidBodyContactStage::prepare(World& world) = distanceSpringStorage != nullptr ? distanceSpringStorage->size() : 0u; const std::size_t avbdContactCapacity = contactCapacity != 0u - && (useAvbdFamily || mayHaveRigidAvbdContactConfigs(registry)) + && (useOwnedBlockFamily + || mayHaveRigidAvbdContactConfigs(registry)) ? contactCapacity : 0u; if (avbdContactCapacity == 0u && jointCapacity == 0u @@ -719,10 +723,18 @@ void RigidBodyContactStage::prepare(World& world) void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) { auto& registry = dart::simulation::detail::registryOf(world); + const bool useVbdFamily = world.getRigidBodySolver() == RigidBodySolver::Vbd; const bool useAvbdFamily = world.getRigidBodySolver() == RigidBodySolver::Avbd; - - const auto projectAvbdRigidPointJoints = [&]() { + const bool useOwnedBlockFamily = useVbdFamily || useAvbdFamily; + const auto formulation + = useVbdFamily + ? dvbd::AvbdRigidWorldContactSolveOptions::Formulation::FixedPenalty + : dvbd::AvbdRigidWorldContactSolveOptions::Formulation:: + AugmentedLagrangian; + const std::string_view solverFamilyName = useVbdFamily ? "VBD" : "AVBD"; + + const auto projectRigidBlockPointJoints = [&]() { const bool hasPointJointConfigs = dvbd::mayHaveAvbdRigidWorldPointJointConfigs(registry); const bool hasDistanceSpringConfigs @@ -778,7 +790,7 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) registry, scratch.snapshot, timeStep); const dvbd::AvbdRigidWorldContactSolveOptions solveOptions - = rigidAvbdWorldSolveOptions(m_iterations); + = rigidAvbdWorldSolveOptions(m_iterations, formulation); const dvbd::AvbdRigidWorldContactSolveResult solveResult = dvbd::solveAvbdRigidWorldContactSnapshot( scratch.snapshot, @@ -808,7 +820,7 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) }; if (shouldSkipRigidBodyContactQuery(world)) { - if (projectAvbdRigidPointJoints()) { + if (projectRigidBlockPointJoints()) { return; } @@ -819,7 +831,7 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) } const bool mayUseAvbdContactDetails - = useAvbdFamily || mayHaveRigidAvbdContactConfigs(registry); + = useOwnedBlockFamily || mayHaveRigidAvbdContactConfigs(registry); const auto queriedContacts = world.queryContacts( CollisionQueryOptions{}, /*includeShapeContactDetails=*/mayUseAvbdContactDetails); @@ -831,7 +843,7 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) } recordRigidContactEnvelopeMetrics(world, registry, contacts); if (contacts.empty()) { - if (projectAvbdRigidPointJoints()) { + if (projectRigidBlockPointJoints()) { return; } @@ -841,16 +853,15 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) return; } - // AVBD rigid contact path (PLAN-104): the public AVBD family selects every - // supported active free-rigid contact. The compatibility path under another - // family still requires every active contact to carry a private opt-in. - // Both assemble 6-DOF point-pair rows, solve against velocity-predicted - // inertial targets, and project the displacement back into velocities. The - // standard rigid position stage then advances poses. - const auto avbdConfig - = mayUseAvbdContactDetails - ? rigidAvbdContactStageConfig(registry, contacts, useAvbdFamily) - : std::optional{}; + // Rigid block-descent contact path (PLAN-104): the public VBD and AVBD + // families select every supported active free-rigid contact. VBD holds each + // conservative row at a finite penalty stiffness; AVBD augments and ramps the + // same row families. The compatibility path under another family remains + // AVBD-only and requires every active contact to carry a private opt-in. + const auto avbdConfig = mayUseAvbdContactDetails + ? rigidAvbdContactStageConfig( + registry, contacts, useOwnedBlockFamily) + : std::optional{}; if (avbdConfig) { if (m_avbdScratch == nullptr) { m_avbdScratch = AvbdScratchPtr( @@ -870,22 +881,26 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) scratch.buildScratch, contactOptions); const auto expectedPublicContactCount - = useAvbdFamily ? countProjectableRigidAvbdContacts(registry, contacts) - : std::optional{}; + = useOwnedBlockFamily + ? countProjectableRigidAvbdContacts(registry, contacts) + : std::optional{}; DART_SIMULATION_THROW_T_IF( - useAvbdFamily && !expectedPublicContactCount.has_value(), + useOwnedBlockFamily && !expectedPublicContactCount.has_value(), NotImplementedException, - "The AVBD rigid-body solver encountered an active contact envelope it " - "cannot project; sequential-impulse fallback is disabled"); + "The {} rigid-body solver encountered an active contact envelope it " + "cannot project; sequential-impulse fallback is disabled", + solverFamilyName); const bool allContactsCovered - = useAvbdFamily ? scratch.snapshot.contacts.size() - == expectedPublicContactCount.value() - : scratch.snapshot.contacts.size() == contacts.size(); + = useOwnedBlockFamily + ? scratch.snapshot.contacts.size() + == expectedPublicContactCount.value() + : scratch.snapshot.contacts.size() == contacts.size(); DART_SIMULATION_THROW_T_IF( - useAvbdFamily && !allContactsCovered, + useOwnedBlockFamily && !allContactsCovered, NotImplementedException, - "The AVBD rigid-body solver could not assemble every active contact; " - "sequential-impulse fallback is disabled"); + "The {} rigid-body solver could not assemble every active contact; " + "sequential-impulse fallback is disabled", + solverFamilyName); std::size_t appendedJoints = 0u; std::size_t appendedDistanceSprings = 0u; @@ -925,7 +940,10 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) const dvbd::AvbdRigidWorldContactSolveOptions solveOptions = rigidAvbdWorldSolveOptions( - m_iterations, &*avbdConfig, contactOptions.maxStiffness); + m_iterations, + formulation, + &*avbdConfig, + contactOptions.maxStiffness); const dvbd::AvbdRigidWorldContactSolveResult solveResult = dvbd::solveAvbdRigidWorldContactSnapshot( @@ -956,12 +974,13 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) } DART_SIMULATION_THROW_T_IF( - useAvbdFamily, + useOwnedBlockFamily, InvalidOperationException, - "The AVBD rigid-body solver assembled active constraints but " - "produced no AVBD rows; sequential-impulse fallback is disabled"); + "The {} rigid-body solver assembled active constraints but produced " + "no block-descent rows; sequential-impulse fallback is disabled", + solverFamilyName); } else if ( - useAvbdFamily && allContactsCovered + useOwnedBlockFamily && allContactsCovered && expectedPublicContactCount.value() == 0u) { m_avbdScratch->clear(); return; @@ -971,12 +990,13 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) } DART_SIMULATION_THROW_T_IF( - useAvbdFamily, + useOwnedBlockFamily, InvalidOperationException, - "The AVBD rigid-body solver did not resolve the active rigid contacts; " - "sequential-impulse fallback is disabled"); + "The {} rigid-body solver did not resolve the active rigid contacts; " + "sequential-impulse fallback is disabled", + solverFamilyName); - if (projectAvbdRigidPointJoints()) { + if (projectRigidBlockPointJoints()) { // Keep the AVBD joint projection active while ordinary contacts continue // through the selected non-AVBD contact solver below. } else if (m_avbdScratch != nullptr) { diff --git a/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp b/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp index b6e43eaffe551..47df60c43274b 100644 --- a/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp +++ b/dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp @@ -328,6 +328,7 @@ struct AvbdRigidPointPairFrictionOptions double beta = 1.0; double maxStiffness = std::numeric_limits::infinity(); double staticFrictionTolerance = 1e-12; + bool fixedPenalty = false; }; struct AvbdRigidPointPairDistanceSpringOptions @@ -1769,7 +1770,7 @@ inline double avbdRigidScalarRowForce( double materialStiffness) { if (avbdRigidRowUsesFiniteMaterial(materialStiffness)) { - return state.stiffness * constraintValue; + return clampAvbdRowForce(state.stiffness * constraintValue, bounds); } return computeAvbdHardConstraintForce(state, constraintValue, bounds); @@ -2095,6 +2096,14 @@ inline void updateAvbdRigidPointPairFrictionTangentPair( const AvbdRigidBodyState& stateB, const AvbdRigidPointPairFrictionOptions& options) { + if (options.fixedPenalty) { + // Fixed-penalty VBD recomputes k*C from the current configuration on the + // next block sweep. It has no friction dual and no stiffness evolution. + first.state.lambda = 0.0; + second.state.lambda = 0.0; + return; + } + bool clamped = false; const Eigen::Vector3d firstWorldPointA = avbdRigidBodyWorldPoint(stateA, first.localPointA); diff --git a/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp b/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp index 542687bbb3aab..bc2b96a5251cf 100644 --- a/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp +++ b/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp @@ -33,6 +33,7 @@ #pragma once #include +#include #include #include #include @@ -300,6 +301,12 @@ inline void clearAvbdRigidWorldContactSnapshot( struct AvbdRigidWorldContactSolveOptions { + enum class Formulation + { + AugmentedLagrangian, + FixedPenalty, + }; + // Constraint-family parameters stay independent of whether contact rows are // present. Contact stages may opt into the contact-family override below // without retuning disconnected joint, motor, or spring rows. @@ -311,6 +318,7 @@ struct AvbdRigidWorldContactSolveOptions AvbdRigidPointAttachmentOptions contactRow; AvbdRigidPointPairFrictionOptions friction; AvbdRigidBlockDescentOptions descent; + Formulation formulation = Formulation::AugmentedLagrangian; }; struct AvbdRigidWorldContactSolveResult @@ -2287,7 +2295,51 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( return result; } + const bool fixedPenalty + = options.formulation + == AvbdRigidWorldContactSolveOptions::Formulation::FixedPenalty; + if (fixedPenalty) { + // VBD has no persistent dual or progressive stiffness state. Reset every + // row inventory before descriptor synchronization so runtime family + // changes cannot leak AVBD state into the fixed-penalty solve. + normalInventory.clear(); + frictionInventory.clear(); + jointLinearInventory.clear(); + jointAngularInventory.clear(); + motorInventory.clear(); + distanceSpringInventory.clear(); + } + scratch.clear(); + const auto applyFixedPenaltyRows = [&](auto& rows, + AvbdScalarRowInventory& inventory, + std::size_t inventoryOffset = 0u) { + if (!fixedPenalty) { + return; + } + for (std::size_t i = 0; i < rows.size(); ++i) { + DART_SIMULATION_THROW_T_IF( + inventoryOffset + i >= inventory.size(), + InvalidOperationException, + "VBD fixed-penalty row inventory drifted from assembled rows"); + const AvbdScalarRowDescriptor& descriptor + = inventory[inventoryOffset + i].descriptor; + const double requestedStiffness + = std::isfinite(descriptor.materialStiffness) + ? descriptor.materialStiffness + : descriptor.startStiffness; + const double fixedStiffness + = std::min(requestedStiffness, descriptor.maxStiffness); + DART_SIMULATION_THROW_T_IF( + !std::isfinite(fixedStiffness) || fixedStiffness < 0.0, + InvalidOperationException, + "VBD requires every active penalty row to resolve a finite, " + "non-negative stiffness"); + rows[i].row.state + = AvbdScalarRowState{.stiffness = fixedStiffness, .lambda = 0.0}; + rows[i].row.materialStiffness = fixedStiffness; + } + }; const AvbdRowWarmStartOptions& contactWarmStart = options.hasContactFamilyOverride ? options.contactWarmStart : options.warmStart; @@ -2310,6 +2362,35 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( row.solveOptionsOverride = options.contactRow; } } + applyFixedPenaltyRows(normalRows, normalInventory); + if (fixedPenalty) { + for (std::size_t i = 0; i < frictionRows.size(); ++i) { + const std::size_t first = 2u * i; + const std::size_t second = first + 1u; + DART_SIMULATION_THROW_T_IF( + second >= frictionInventory.size(), + InvalidOperationException, + "VBD fixed-penalty friction inventory drifted from assembled rows"); + const auto applyFrictionRow + = [&](AvbdRigidPointPairRow& row, + const AvbdScalarRowDescriptor& descriptor) { + const double fixedStiffness = std::min( + descriptor.startStiffness, descriptor.maxStiffness); + DART_SIMULATION_THROW_T_IF( + !std::isfinite(fixedStiffness) || fixedStiffness < 0.0, + InvalidOperationException, + "VBD requires every active friction penalty row to resolve a " + "finite, non-negative stiffness"); + row.state = AvbdScalarRowState{ + .stiffness = fixedStiffness, .lambda = 0.0}; + row.materialStiffness = fixedStiffness; + }; + applyFrictionRow( + frictionRows[i].first, frictionInventory[first].descriptor); + applyFrictionRow( + frictionRows[i].second, frictionInventory[second].descriptor); + } + } result.normalRows = normalRows.size(); result.frictionRows = 2u * frictionRows.size(); @@ -2327,6 +2408,8 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( scratch.jointLinearRowsScratch, scratch.jointAngularRowsScratch, options.warmStart); + applyFixedPenaltyRows(jointLinearRows, jointLinearInventory); + applyFixedPenaltyRows(jointAngularRows, jointAngularInventory); result.jointLinearRows = jointLinearRows.size(); result.jointAngularRows = jointAngularRows.size(); @@ -2345,6 +2428,8 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( timeStep, scratch.motorRowsScratch, options.warmStart); + applyFixedPenaltyRows(linearMotorRows, motorInventory); + applyFixedPenaltyRows(motorRows, motorInventory, linearMotorRows.size()); result.motorRows = linearMotorRows.size() + motorRows.size(); auto& distanceSpringRows = scratch.distanceSpringRows; @@ -2355,6 +2440,7 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( distanceSpringRows, scratch.distanceSpringRowsScratch, options.warmStart); + applyFixedPenaltyRows(distanceSpringRows, distanceSpringInventory); result.distanceSpringRows = distanceSpringRows.size(); const std::size_t normalRowCount = normalRows.size(); @@ -2430,6 +2516,8 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( inertialTargets = std::span{ fallbackInertialTargets.data(), fallbackInertialTargets.size()}; } + AvbdRigidPointPairFrictionOptions frictionOptions = options.friction; + frictionOptions.fixedPenalty = fixedPenalty; result.stats = blockDescentRigidBodiesAvbdRows( std::span{ snapshot.states.data(), snapshot.states.size()}, @@ -2446,7 +2534,7 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( frictionRows, options.descent, options.row, - options.friction, + frictionOptions, &scratch.rowIndexScratch, distanceSpringRows, options.distanceSpring, @@ -2509,22 +2597,45 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( = avbdRigidWorldActiveJointAxisCount(joint.linearAxisMask); const std::size_t angularRowsForJoint = avbdRigidWorldActiveJointAxisCount(joint.angularAxisMask); - double lambdaSquared = 0.0; + double forceSquared = 0.0; for (std::size_t i = 0; i < linearRows && linearCursor + i < jointLinearRowCount; ++i) { - lambdaSquared += avbdRigidWorldJointRowLambdaSquared( - (*solvePointPairRows)[jointLinearOffset + linearCursor + i] - .row.state.lambda); + const AvbdRigidBodyPointPairRow& indexedRow + = (*solvePointPairRows)[jointLinearOffset + linearCursor + i]; + const AvbdRigidPointPairRow& row = indexedRow.row; + const double force = avbdRigidRowUsesFiniteMaterial(row.materialStiffness) + ? avbdRigidScalarRowForce( + row.state, + avbdRigidPointPairConstraintValue( + snapshot.states[indexedRow.bodyA], + snapshot.states[indexedRow.bodyB], + row), + row.bounds, + row.materialStiffness) + : row.state.lambda; + forceSquared += avbdRigidWorldJointRowLambdaSquared(force); } for (std::size_t i = 0; i < angularRowsForJoint && angularCursor + i < jointAngularRowCount; ++i) { - lambdaSquared += avbdRigidWorldJointRowLambdaSquared( - (*solveAngularRows)[angularCursor + i].row.state.lambda); + const AvbdRigidBodyAngularPairRow& indexedRow + = (*solveAngularRows)[angularCursor + i]; + const AvbdRigidAngularPairRow& row = indexedRow.row; + const double force = avbdRigidRowUsesFiniteMaterial(row.materialStiffness) + ? avbdRigidScalarRowForce( + row.state, + avbdRigidAngularPairConstraintValue( + snapshot.states[indexedRow.bodyA], + snapshot.states[indexedRow.bodyB], + row), + row.bounds, + row.materialStiffness) + : row.state.lambda; + forceSquared += avbdRigidWorldJointRowLambdaSquared(force); } if (joint.fractureThreshold > 0.0 && std::isfinite(joint.fractureThreshold) - && std::sqrt(lambdaSquared) >= joint.fractureThreshold) { + && std::sqrt(forceSquared) >= joint.fractureThreshold) { result.fracturedJointIndices.push_back(jointIndex); } @@ -2533,6 +2644,18 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( } result.fracturedJoints = result.fracturedJointIndices.size(); + if (fixedPenalty) { + // Fixed-penalty VBD owns no cross-step row state. Leaving the material + // stiffness written above in these shared inventories would let a later + // AVBD solve warm-start from VBD state after a runtime family switch. + normalInventory.clear(); + frictionInventory.clear(); + jointLinearInventory.clear(); + jointAngularInventory.clear(); + motorInventory.clear(); + distanceSpringInventory.clear(); + } + return result; } diff --git a/dart/simulation/detail/world_step_schedule.hpp b/dart/simulation/detail/world_step_schedule.hpp index 2c63c55b7c196..e3bc7f32861ac 100644 --- a/dart/simulation/detail/world_step_schedule.hpp +++ b/dart/simulation/detail/world_step_schedule.hpp @@ -45,6 +45,7 @@ enum class BuiltInRigidBodySolverFamily { SequentialImpulse, Avbd, + Vbd, Ipc, }; @@ -150,6 +151,7 @@ struct BuiltInWorldStepSchedule BuiltInRigidBodySolverFamily family) noexcept { return family == BuiltInRigidBodySolverFamily::SequentialImpulse + || family == BuiltInRigidBodySolverFamily::Vbd || family == BuiltInRigidBodySolverFamily::Avbd; } diff --git a/dart/simulation/io/binary_io.hpp b/dart/simulation/io/binary_io.hpp index 3a3d937913be4..c61745d645a42 100644 --- a/dart/simulation/io/binary_io.hpp +++ b/dart/simulation/io/binary_io.hpp @@ -138,7 +138,8 @@ using EntityMap = std::unordered_map; // 29: World stores the rigid contact-stage iteration budget after // differentiable physical-parameter registrations, and the rigid-body // solver enum gains the AVBD value. -constexpr std::uint32_t kBinaryFormatVersion = 29; +// 30: The rigid-body solver enum gains the fixed-penalty VBD value. +constexpr std::uint32_t kBinaryFormatVersion = 30; //============================================================================== // Low-level Binary I/O for POD types diff --git a/dart/simulation/world.cpp b/dart/simulation/world.cpp index 8c94fb4fb0650..25b144f21e1a8 100644 --- a/dart/simulation/world.cpp +++ b/dart/simulation/world.cpp @@ -1543,6 +1543,7 @@ bool isValidRigidBodySolver(RigidBodySolver solver) switch (solver) { case RigidBodySolver::SequentialImpulse: case RigidBodySolver::Avbd: + case RigidBodySolver::Vbd: case RigidBodySolver::Ipc: return true; } @@ -1550,6 +1551,12 @@ bool isValidRigidBodySolver(RigidBodySolver solver) return false; } +//============================================================================== +bool isRigidBlockDescentSolver(RigidBodySolver solver) +{ + return solver == RigidBodySolver::Vbd || solver == RigidBodySolver::Avbd; +} + //============================================================================== bool isValidContactSolverMethod(ContactSolverMethod method) { @@ -1567,11 +1574,11 @@ void validateRigidSolverContactMethodCompatibility( RigidBodySolver solver, ContactSolverMethod contactMethod) { DART_SIMULATION_THROW_T_IF( - solver == RigidBodySolver::Avbd + isRigidBlockDescentSolver(solver) && contactMethod != ContactSolverMethod::SequentialImpulse, InvalidArgumentException, - "The AVBD rigid-body solver owns rigid contact resolution and cannot be " - "combined with a non-default contact solver method"); + "The VBD and AVBD rigid-body solvers own rigid contact resolution and " + "cannot be combined with a non-default contact solver method"); } //============================================================================== @@ -1657,6 +1664,8 @@ std::uint8_t encodeRigidBodySolver(RigidBodySolver solver) return 0u; case RigidBodySolver::Avbd: return 2u; + case RigidBodySolver::Vbd: + return 3u; case RigidBodySolver::Ipc: return 1u; } @@ -1676,6 +1685,8 @@ RigidBodySolver decodeRigidBodySolver(std::uint8_t value) return RigidBodySolver::Ipc; case 2u: return RigidBodySolver::Avbd; + case 3u: + return RigidBodySolver::Vbd; } DART_SIMULATION_THROW_T( @@ -1830,6 +1841,8 @@ detail::BuiltInRigidBodySolverFamily toBuiltInRigidBodySolverFamily( return detail::BuiltInRigidBodySolverFamily::SequentialImpulse; case RigidBodySolver::Avbd: return detail::BuiltInRigidBodySolverFamily::Avbd; + case RigidBodySolver::Vbd: + return detail::BuiltInRigidBodySolverFamily::Vbd; case RigidBodySolver::Ipc: return detail::BuiltInRigidBodySolverFamily::Ipc; } @@ -2173,10 +2186,11 @@ void validateRigidBodyJointPipelineSupport( const World& world, RigidBodySolver solver) { DART_SIMULATION_THROW_T_IF( - solver == RigidBodySolver::Avbd && hasMultibodyStructures(world), + isRigidBlockDescentSolver(solver) && hasMultibodyStructures(world), InvalidOperationException, - "The AVBD rigid-body solver currently supports free rigid-body worlds " - "only; multibody structures require a different rigid-body solver"); + "The VBD and AVBD rigid-body solvers currently support free rigid-body " + "worlds only; multibody structures require a different rigid-body " + "solver"); if (!hasRigidBodyAvbdPairConstraints(world)) { return; @@ -2204,9 +2218,8 @@ void validateRigidBodyJointPipelineSupport( DART_SIMULATION_THROW_T_IF( hasMultibodyStructures(world), InvalidOperationException, - "Rigid-body AVBD pair constraints are not supported in worlds with " - "multibody " - "structures"); + "Rigid-body VBD and AVBD pair constraints are not supported in worlds " + "with multibody structures"); } //============================================================================== @@ -4225,6 +4238,9 @@ void World::recordResolvedConfiguration() case RigidBodySolver::Avbd: rigidSolver = "avbd"; break; + case RigidBodySolver::Vbd: + rigidSolver = "vbd"; + break; case RigidBodySolver::Ipc: rigidSolver = "ipc"; break; @@ -4242,13 +4258,13 @@ void World::recordResolvedConfiguration() break; } // The compatibility-only internal AVBD rigid-contact opt-in is emplaced per - // body. When it is present under a non-AVBD public family, the resolved + // body. When it is present under a non-VBD/AVBD public family, the resolved // contact path differs from the requested `ContactSolverMethod`: configured // contacts run AVBD only when every active contact has an enabled config on // at least one endpoint (PLAN-091 WP-091.1). Record that substitution only // when the baked collision topology guarantees this all-or-nothing stage - // gate. The public AVBD family itself resolves AVBD as requested and does not - // use this substitution path. + // gate. The public VBD and AVBD families resolve their owned formulations as + // requested and do not use this substitution path. const detail::WorldRegistry& registry = m_storage->registry; const auto* avbdStorage = registry.storage(); const bool hasAvbdContactConfigs @@ -4276,7 +4292,10 @@ void World::recordResolvedConfiguration() const bool avbdContactOptInCoversEveryPotentialPair = collisionEntityCount >= 2u && collisionEntitiesWithoutEnabledAvbdConfig <= 1u; - if (m_rigidBodySolver == RigidBodySolver::Avbd) { + if (m_rigidBodySolver == RigidBodySolver::Vbd) { + m_resolvedConfiguration.notes.push_back( + {"rigid-contact", "vbd", "vbd", "as requested"}); + } else if (m_rigidBodySolver == RigidBodySolver::Avbd) { m_resolvedConfiguration.notes.push_back( {"rigid-contact", "avbd", "avbd", "as requested"}); } else if ( @@ -4309,6 +4328,9 @@ void World::recordResolvedConfiguration() "inactive", "inactive", "no rigid pair constraints configured"}); + } else if (m_rigidBodySolver == RigidBodySolver::Vbd) { + m_resolvedConfiguration.notes.push_back( + {"rigid-pair-constraint", "vbd", "vbd", "as requested"}); } else if (m_rigidBodySolver == RigidBodySolver::Avbd) { m_resolvedConfiguration.notes.push_back( {"rigid-pair-constraint", "avbd", "avbd", "as requested"}); @@ -4546,14 +4568,15 @@ Multibody World::addMultibody(std::string_view name) { ensureDesignMode(); DART_SIMULATION_THROW_T_IF( - m_rigidBodySolver == RigidBodySolver::Avbd, + isRigidBlockDescentSolver(m_rigidBodySolver), InvalidOperationException, - "Multibody structures are not supported by the AVBD rigid-body solver"); + "Multibody structures are not supported by the VBD or AVBD rigid-body " + "solver"); DART_SIMULATION_THROW_T_IF( hasRigidBodyAvbdPairConstraints(*this), InvalidOperationException, "Multibody structures are not supported in worlds with rigid-body " - "AVBD pair constraints"); + "VBD or AVBD pair constraints"); std::string candidateName; if (name.empty()) { @@ -8529,6 +8552,11 @@ void World::loadBinary(std::istream& input) InvalidArgumentException, "Serialized World AVBD rigid-body solver requires binary format " "version 29 or newer"); + DART_SIMULATION_THROW_T_IF( + formatVersion < 30 && rigidBodySolver == 3u, + InvalidArgumentException, + "Serialized World VBD rigid-body solver requires binary format " + "version 30 or newer"); m_rigidBodySolver = decodeRigidBodySolver(rigidBodySolver); m_contactSolverMethod = decodeContactSolverMethod(contactSolverMethod); m_contactGradientMode = decodeContactGradientMode(contactGradientMode); diff --git a/dart/simulation/world.hpp b/dart/simulation/world.hpp index 0f457106c0fa3..e584cf80b2c92 100644 --- a/dart/simulation/world.hpp +++ b/dart/simulation/world.hpp @@ -380,8 +380,9 @@ class DART_SIMULATION_API World /// Create a finite-stiffness radial spring between two free rigid bodies. /// /// The spring connects each body's origin by default. The overload with - /// anchors accepts body-local anchor points. This experimental AVBD path is - /// design-mode only and is projected by the rigid-body contact stage. + /// anchors accepts body-local anchor points. This experimental VBD/AVBD + /// block-descent path is design-mode only and is projected by the rigid-body + /// contact stage. void addRigidBodyDistanceSpring( std::string_view name, const RigidBody& parent, @@ -457,11 +458,13 @@ class DART_SIMULATION_API World /// Select the solver family used by the default rigid-body step pipeline. /// - /// The default remains SequentialImpulse. Avbd is an explicit opt-in for - /// free rigid-body contact and public rigid-body pair constraints. Ipc is + /// The default remains SequentialImpulse. Vbd and Avbd are explicit opt-ins + /// for free rigid-body contact and public rigid-body pair constraints. Vbd + /// holds conservative rows at fixed finite penalty stiffness; Avbd adds the + /// augmented-Lagrangian dual and progressive stiffness update. Ipc is /// experimental and currently handles free mesh-like rigid bodies through - /// the internal rigid IPC stage. Unsupported family/domain combinations - /// throw rather than falling through to another solver. + /// the internal rigid IPC stage. Unsupported family/domain combinations throw + /// rather than falling through to another solver. void setRigidBodySolver(RigidBodySolver solver); /// Get the solver family used by the default rigid-body step pipeline. @@ -469,10 +472,10 @@ class DART_SIMULATION_API World /// Set domain-scoped tuning for the built-in rigid constraint stage. /// - /// The iteration budget controls sequential-impulse contact sweeps and AVBD - /// rigid contact, joint, motor, and spring sweeps. The IPC family and mixed - /// semi-implicit multibody worlds bypass this stage and accept only the - /// default options. Otherwise safe to change in simulation mode; the next + /// The iteration budget controls sequential-impulse contact sweeps and + /// VBD/AVBD rigid contact, joint, motor, and spring sweeps. The IPC family + /// and mixed semi-implicit multibody worlds bypass this stage and accept only + /// the default options. Otherwise safe to change in simulation mode; the next /// step uses the new options. void setRigidConstraintOptions(const RigidConstraintOptions& options); @@ -496,10 +499,10 @@ class DART_SIMULATION_API World /// `SequentialImpulse`) and independent of the differentiable flag. Under /// the `RigidBodySolver::SequentialImpulse` family, `BoxedLcp` opts the /// rigid-body contact stage into the boxed-LCP normal solve. The - /// `RigidBodySolver::Avbd` family owns its contact formulation and rejects - /// `BoxedLcp`. Safe to change at any time: when the World is already in - /// simulation mode, the step pipeline cache is rebuilt for the new contact - /// method. + /// `RigidBodySolver::Vbd` and `RigidBodySolver::Avbd` families own their + /// contact formulations and reject `BoxedLcp`. Safe to change at any time: + /// when the World is already in simulation mode, the step pipeline cache is + /// rebuilt for the new contact method. /// @throws InvalidArgumentException if `method` is not a valid /// `ContactSolverMethod` enumerator. void setContactSolverMethod(ContactSolverMethod method); diff --git a/dart/simulation/world_options.hpp b/dart/simulation/world_options.hpp index 8357156fdb4fd..e87c6c1a7bb90 100644 --- a/dart/simulation/world_options.hpp +++ b/dart/simulation/world_options.hpp @@ -54,6 +54,11 @@ enum class RigidBodySolver /// velocity/contact/position schedule as sequential impulse, but the contact /// stage resolves every supported active rigid contact through AVBD. Avbd, + /// Fixed-penalty vertex block descent for free rigid-body contact and public + /// rigid-body pair constraints. Unlike `Avbd`, this family does not use an + /// augmented-Lagrangian dual update or progressive stiffness ramp: each + /// supported constraint remains at its configured finite penalty stiffness. + Vbd, }; /// Selects how the rigid-body contact stage resolves active contacts. @@ -62,10 +67,11 @@ enum class RigidBodySolver /// `RigidBodySolver::SequentialImpulse` family. It is independent of the /// differentiable flag: a non-differentiable world may use either method, and a /// differentiable world defaults to the same `SequentialImpulse` path as any -/// other. `RigidBodySolver::Avbd` owns its contact formulation and therefore -/// cannot be combined with a non-default value here. The enum names the contact -/// formulation without exposing backend, registry, ECS storage, or concrete -/// solver-object types, so it is safe on the public facade surface. +/// other. The VBD and AVBD rigid-body families own their contact formulations +/// and therefore cannot be combined with a non-default value here. The enum +/// names the contact formulation without exposing backend, registry, ECS +/// storage, or concrete solver-object types, so it is safe on the public facade +/// surface. enum class ContactSolverMethod { /// Sequential-impulse (Gauss-Seidel) normal+friction solve. The default and @@ -82,9 +88,9 @@ enum class ContactSolverMethod /// Domain-scoped tuning for the built-in rigid constraint stage. /// /// The positive iteration budget controls sequential-impulse contact sweeps -/// and AVBD rigid contact, joint, motor, and spring sweeps. The IPC family and -/// mixed semi-implicit multibody worlds bypass this stage and therefore accept -/// only the default options. +/// and VBD/AVBD rigid contact, joint, motor, and spring sweeps. The IPC family +/// and mixed semi-implicit multibody worlds bypass this stage and therefore +/// accept only the default options. struct RigidConstraintOptions { std::size_t iterations = 8; @@ -198,9 +204,9 @@ struct WorldOptions Eigen::Vector3d gravity{0.0, 0.0, -9.81}; /// Free rigid-body solver family used by the built-in `World::step()` - /// schedule. Defaults to sequential impulse. `Avbd` is an explicit opt-in - /// for free rigid-body contact and pair constraints; it does not silently - /// substitute sequential impulse for unsupported contact envelopes. + /// schedule. Defaults to sequential impulse. `Vbd` and `Avbd` are explicit + /// opt-ins for free rigid-body contact and pair constraints; neither silently + /// substitutes sequential impulse for unsupported contact envelopes. RigidBodySolver rigidBodySolver = RigidBodySolver::SequentialImpulse; /// Tuning for the built-in rigid constraint stage. diff --git a/docs/design/dart7_architecture_assessment.md b/docs/design/dart7_architecture_assessment.md index b85ec8307f7b5..0bb88a43fd436 100644 --- a/docs/design/dart7_architecture_assessment.md +++ b/docs/design/dart7_architecture_assessment.md @@ -83,11 +83,13 @@ The domain selectors now share a typed-enum idiom on the facade: the `RigidBodySolver` enum (runtime-mutable, alters the schedule), the `ContactSolverMethod` enum (construction-only, a branch inside one stage), and the `MultibodyOptions.integrationFamily` enum (`MultibodyIntegrationFamily`, -resolved once at finalize). `RigidBodySolver::Avbd` now publicly selects the -supported free-rigid AVBD contact and pair-constraint schedule, while the -private `RigidAvbdContactConfig` remains only as a compatibility opt-in under a -non-AVBD public family. The resolved-configuration report distinguishes those -selection sources, and unsupported public AVBD envelopes fail closed. Other +resolved once at finalize). `RigidBodySolver::Vbd` and +`RigidBodySolver::Avbd` now publicly select the supported free-rigid +fixed-penalty VBD and augmented-Lagrangian AVBD contact and pair-constraint +schedules. The private `RigidAvbdContactConfig` remains only as a compatibility +AVBD opt-in under a non-VBD/non-AVBD public family. The +resolved-configuration report distinguishes those selection sources, and +unsupported public VBD/AVBD envelopes fail closed. Other scene-content routing remains unresolved: VBD can still fall back per body to projected Newton on unsupported features, and mixed scenes reroute contact solves across structurally different assemblies. @@ -163,10 +165,14 @@ record solver identity, and flagship AVBD contact-scene packets recorded "DART beats reference" rows on scenes that actually ran the sequential-impulse contact path. Those rows are now relabeled, the shared packet contract records both resolved solver and rigid-contact selection -source, and the first public-AVBD Figure 13 packet uses that contract. The -broader substrate gap remains: legacy packets cannot be generalized beyond -their recorded identities, and there is still no shared cross-family -accuracy/performance harness. +source, and the public-AVBD Figure 13 packet uses that contract. One matched +public fixed-penalty VBD/AVBD Figure 13 comparison now shares a single scene +builder and fingerprint across its demos and benchmark, binds independent +outcome oracles and assessed renders, and keeps its same-host timing ratio +descriptive. The broader substrate gap remains: this is one scenario-specific +writer rather than a generic harness, legacy packets cannot be generalized +beyond their recorded identities, and there is still no shared cross-family +accuracy/performance substrate. ### F5 — Facade erosion at the edges diff --git a/docs/design/simulation_cpp_api.md b/docs/design/simulation_cpp_api.md index b0ec4be09c923..16a2eb42388b2 100644 --- a/docs/design/simulation_cpp_api.md +++ b/docs/design/simulation_cpp_api.md @@ -928,11 +928,14 @@ family" matrix row. Selection is a typed enum mapped to an internal representation on construction or set, so the per-step path carries no configuration cost when a non-default family is not in use. -`RigidBodySolver::Avbd` is the explicit experimental public family for -supported free-rigid contact and pair constraints. It owns its contact -formulation, uses the positive `RigidConstraintOptions::iterations` budget, -and rejects an incompatible boxed-LCP contact-method selection instead of -silently substituting another family. +`RigidBodySolver::Vbd` and `RigidBodySolver::Avbd` are the explicit +experimental public families for supported free-rigid contact and pair +constraints. VBD uses fixed finite-penalty rows without dual accumulation or +progressive stiffness; AVBD uses augmented-Lagrangian dual/stiffness state. +Each owns its contact formulation, uses the positive +`RigidConstraintOptions::iterations` budget, and rejects an incompatible +boxed-LCP contact-method selection instead of silently substituting another +family. That budget applies only while the split rigid constraint stage is active. IPC and mixed semi-implicit multibody worlds bypass it, accept only the default options, and report the budget as not applicable. diff --git a/docs/design/simulation_python_api.md b/docs/design/simulation_python_api.md index 49ca7889e3ef8..c434383c05c5f 100644 --- a/docs/design/simulation_python_api.md +++ b/docs/design/simulation_python_api.md @@ -978,9 +978,10 @@ constructor keywords rather than a bound `sx.WorldOptions` object. If Python later gains `sx.WorldOptions`, it should preserve the same field names and validation behavior as those constructor keywords. -For supported free-rigid AVBD contact and pair constraints, callers select -`sx.RigidBodySolver.AVBD` and a positive -`sx.RigidConstraintOptions(iterations=...)` budget. That family owns its +For supported free-rigid contact and pair constraints, callers select either +the fixed-penalty `sx.RigidBodySolver.VBD` family or the augmented-Lagrangian +`sx.RigidBodySolver.AVBD` family and provide a positive +`sx.RigidConstraintOptions(iterations=...)` budget. Each family owns its contact formulation and rejects an incompatible `sx.ContactSolverMethod.BOXED_LCP` selection instead of falling back. The budget applies only while the split rigid constraint stage is active. diff --git a/docs/design/simulation_solver_architecture.md b/docs/design/simulation_solver_architecture.md index ad65591300325..2b8130f8e1702 100644 --- a/docs/design/simulation_solver_architecture.md +++ b/docs/design/simulation_solver_architecture.md @@ -373,14 +373,15 @@ levels: That placement gives DART a clean relation between the existing and proposed paths: -| Path | Domain | Forward method | Gradient method | Relationship | -| ----------------------------------------- | ------------------- | -------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------- | -| Existing rigid-body default | Rigid / articulated | Semi-implicit dynamics + existing contact path | none | easy path and compatibility baseline | -| PLAN-104 AVBD rigid path | Rigid | Augmented VBD contact and public pair-constraint projection | none | explicit experimental `RigidBodySolver::Avbd` family; CPU scope remains bounded | -| PLAN-110 boxed-LCP/Nimble-style path | Rigid / articulated | Generalized-coordinate boxed LCP normal/friction contact solve | active-set LCP implicit differentiation | first opt-in differentiable rigid solver; default remains off | -| Planned Dojo-style evaluation | Rigid / articulated | Variational maximal/constrained coordinate NCP | IPM/KKT implicit differentiation | possible second opt-in rigid solver family after an internal spike | -| PLAN-081/PLAN-104 deformable solver paths | Deformable | IPC/VBD-family deformable methods | deferred per-solver/coupler differentiation | separate domain; later coupled to rigid solvers through pairwise couplers | -| PLAN-084 variational-integrator work | Rigid / articulated | Variational integration for articulated systems | not sufficient by itself for contact gradients | shared integration rationale; Dojo adds contact/NCP/IPM and differentiability | +| Path | Domain | Forward method | Gradient method | Relationship | +| ----------------------------------------- | ------------------- | --------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------- | +| Existing rigid-body default | Rigid / articulated | Semi-implicit dynamics + existing contact path | none | easy path and compatibility baseline | +| PLAN-104 VBD rigid path | Rigid | Fixed-penalty VBD contact and public pair-constraint projection | none | explicit experimental `RigidBodySolver::Vbd` family; CPU scope remains bounded | +| PLAN-104 AVBD rigid path | Rigid | Augmented VBD contact and public pair-constraint projection | none | explicit experimental `RigidBodySolver::Avbd` family; CPU scope remains bounded | +| PLAN-110 boxed-LCP/Nimble-style path | Rigid / articulated | Generalized-coordinate boxed LCP normal/friction contact solve | active-set LCP implicit differentiation | first opt-in differentiable rigid solver; default remains off | +| Planned Dojo-style evaluation | Rigid / articulated | Variational maximal/constrained coordinate NCP | IPM/KKT implicit differentiation | possible second opt-in rigid solver family after an internal spike | +| PLAN-081/PLAN-104 deformable solver paths | Deformable | IPC/VBD-family deformable methods | deferred per-solver/coupler differentiation | separate domain; later coupled to rigid solvers through pairwise couplers | +| PLAN-084 variational-integrator work | Rigid / articulated | Variational integration for articulated systems | not sufficient by itself for contact gradients | shared integration rationale; Dojo adds contact/NCP/IPM and differentiability | The public API should not expose a `DojoWorld`, `DojoSolver`, solver registry, or Dojo.jl dependency. If promoted, users would opt in through DART-owned diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index e5c980a2c1e9d..9b053513a7755 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -26,29 +26,32 @@ This folder is the temporary working surface; the durable owner is the plan. has been pushed. The Section 3.5 packet is committed locally at `a78f688a178`, and the Section 4 packet at `0b0154573b8`; the articulated finite-row packet at `761263bbd41`; the articulated finite-motor packet at - `9ebd9b895b1`; and the articulated finite-fracture packet at `131981788fa`. - The public AVBD/Figure 13 slice is the current local unpushed closeout. -- **Current packet:** C++ and dartpy callers can explicitly select the public - AVBD rigid-body family and its positive contact/joint projection budget. - Selection survives binary save/load and replay, reports `avbd`, and rejects - unsupported combinations instead of silently falling back. Mixed - semi-implicit multibody worlds use the unified constraint stage, so they - accept only the default rigid constraint options and report the split-stage - budget as not applicable. The new publication-shaped Figure 13 wall uses - 252 staggered bricks, 712 breakable attachments, three impacts, a - deterministic localization/retention oracle, assessed impact/outcome - captures, allocation gates, and an absolute CPU benchmark. + `9ebd9b895b1`; the articulated finite-fracture packet at `131981788fa`; and + the public AVBD/Figure 13 slice at `646b447d6cc`. The matched public + fixed-penalty VBD/Figure 13 comparison is the current local closeout. +- **Current packet:** C++ and dartpy callers can explicitly select public VBD + and AVBD rigid-body families with one positive contact/joint projection + budget. VBD uses fixed finite-penalty rows without dual accumulation or + progressive stiffness; AVBD retains augmented-Lagrangian dual/stiffness + state. Both selections survive binary save/load and replay, report their + resolved identities, and reject boxed-LCP or multibody envelopes instead of + silently falling back. The matched publication-shaped Figure 13 demos share + 252 staggered bricks, 712 attachments, three impacts, 1/60 s, 20 sweeps, and + fingerprint `2a746821cc10faee`, but carry distinct paper-shaped outcome + oracles. The contracts contain 88 VBD and 88 AVBD requirements; all 176 remain incomplete until their recorded correctness, solver-identity, CPU/CUDA, visual, and comparable-performance predicates pass. - **Latest verified local packet:** - [`../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json) - binds the public solver identity, exact paper/page hashes, frame-60 and - frame-120 scene metrics, camera and image-verdict hashes, native semantic - review, first-post-bake allocation tests, and five-repeat Release timing. - Figure 13 and video row 12 remain partial: exact source constants, the - Sequential Impulse, XPBD, and VBD comparison rows, CUDA, and comparable - performance are open. + [`../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json) + binds the current AVBD packet to the matched fixed-penalty VBD frame-14 bend + and frame-120 retention oracles, exact paper hashes, assessed VBD + captures, allocation gates, and five-repeat same-host timing. The benchmark + records 9.218 ms VBD versus 7.467 ms AVBD median CPU time, a descriptive + 1.234 cost ratio with no speedup or achieved-accuracy equivalence claim. + Figure 13 and video row 12 remain partial: exact source constants, + Sequential Impulse, XPBD, a source edit, CUDA, and achieved-accuracy + reference performance are open. - **Recent slices merged to `main`** (see the PLAN-104 progress log and the PRs for detail; per-slice history lives in git, not in this file): - #2991 — source-row coverage + contact-precheck (`f6fecbc5bd5`). @@ -58,33 +61,20 @@ This folder is the temporary working surface; the durable owner is the plan. - #3022 — bounded regression coverage: rigid-contact tangent-basis contract, articulated break→reset→break re-arm lifecycle, row-inventory replaced-key cold-start (`65ba05113c6`). -- **Current local gates:** public solver/schedule/serialization/Python tests, - both Figure 13 scene oracles, all three public-AVBD post-bake allocation - policies, all 16 fail-closed packet-writer tests, the full 273-test migrated - packet/schema set, all 56 working-tree AVBD packet schemas, three exact - deterministic outcome runs, both assessed captures, and the five-repeat - benchmark pass. The final broad local gates pass: default `pixi run build`, - all 168 unit targets, all 445 `test_world` cases, all 15 resolved- - configuration cases, all 20 contact-parity cases, and 1,677 Python tests - with 20 expected skips. `pixi run -e cuda test-all` passes all seven phases, - including the GUI-disabled Python catalog, documentation, all eight CUDA - runtime tests, and CUDA benchmark smoke. The first CUDA run exposed and the - candidate now covers a headless `OrbitCamera` assumption in the Figure 13 - scene. The boxed-LCP fallback allocation regression found by the full World - gate is also fixed: the runtime applies the allocation-free contact path and - copies its existing force vector instead of allocating a differentiable - snapshot. The final dual review found one additional fail-closed contract - gap: - non-default rigid iteration options were reported as applied in mixed - semi-implicit multibody worlds even though the unified stage ignored them. - Construction, runtime mutation, method-family transitions, load, and step - entry now reject that combination; the resolved report marks the default - budget not applicable. Both post-fix review lanes approve the result. The - exact post-fix reruns pass the uncached default build, 1,677 Python tests - with 20 expected skips, and all seven CUDA `test-all` phases; the eight CUDA - runtime tests include the 208.75-second Jacobi batch case. Packet provenance - was regenerated after formatting with zero source-hash mismatches, and the - PLAN-122 allocation matrix passes with 18 rows, 14 closed. +- **Current local gates:** the VBD fixed-penalty row/state, public + solver/schedule/serialization/resolved-configuration/Python, matched demo, + and all three post-bake allocation-policy tests pass. Both assessed VBD + captures and the five-repeat comparison benchmark pass. After the final + packet-provenance repair, 188 focused Python/world/writer/schema tests, three + selected VBD Figure 13 demo cases, all 57 working-tree AVBD packet schemas, + and both 88-row PLAN-104 contracts pass. Fresh uncached default `test-all` + passes all six phases, including 79 enabled simulation tests and + documentation. Fresh uncached CUDA `test-all` passes all seven phases, + including all eight CUDA runtime tests and benchmark smoke; the long Jacobi + batch case took 206.81 seconds. This CUDA environment gate does not close + the still-missing VBD/AVBD GPU solver-parity rows. Independent final + code/correctness and architecture/docs/claims review lanes both approve the + post-fix tree. ## Goal @@ -307,17 +297,48 @@ All linked canonical method rows remain partial. pass engine ViewReports, pixel integrity, and image-capable semantic review against the pinned paper page. The scene and benchmark share fingerprint `2a746821cc10faee`. -- **Performance boundary:** five Release repetitions record 7.739 ms median +- **Performance boundary:** the current five-repeat Release benchmark records + 7.467 ms median CPU time per step. This is absolute DART timing only; the paper and source publish no directly comparable timing for this scene. - **Non-goals:** exact replay of unpublished source constants, the Sequential - Impulse, XPBD, and VBD comparison rows, source-matched video edit, broad - fracture corpus, unified rows, and CUDA. + Impulse and XPBD comparison rows, source-matched video edit, broad fracture + corpus, unified rows, and CUDA. The matched VBD row is tracked separately + below. The durable evidence is [`../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). Figure 13 and official-video row 12 remain partial. +## Verified Local Packet: Matched Public VBD / AVBD Figure 13 Rows + +- **Value:** expose an honest public fixed-penalty VBD family over the same + DART-owned rigid 6-DOF block infrastructure, then compare it with public AVBD + using one exact reconstructed scene rather than relabeling sequential + impulse. +- **Public contract:** `RigidBodySolver::Vbd` / + `sx.RigidBodySolver.VBD`, the same positive rigid-constraint iteration + policy, binary/replay persistence, resolved-family reporting, and + fail-closed boxed-LCP and multibody rejection. VBD uses finite penalty + stiffness, zero dual state, and no progressive stiffness update. +- **Matched outcome evidence:** both demos and benchmark share fingerprint + `2a746821cc10faee`, 252 bricks, 712 attachments, three balls, 1/60 s, and 20 + sweeps. VBD records no fracture, 0.229 m peak and 0.122 m RMS brick + displacement with 131 bricks beyond 0.1 m at frame 14, then 100% retained + attachments at frame 120. Both current-build VBD captures pass their engine + ViewReports, pixel-integrity checks, and paper-grounded semantic review. +- **Performance boundary:** five Release repetitions record 9.218 ms VBD + versus 7.467 ms AVBD median CPU time. The 1.234 VBD/AVBD median cost ratio is + descriptive only: the outcomes intentionally differ, and no source + achieved-accuracy or same-hardware denominator exists. +- **Remaining boundary:** exact unpublished constants, honest Sequential + Impulse and XPBD rows, a source-matched four-method edit, CUDA, broad + fracture/unified rows, and reference-performance parity remain open. + +The durable evidence is +[`../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json). +Figure 13 and official-video row 12 remain partial. + ## History Per-slice history and durable evidence live in the PLAN-104 progress log, the diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index d1839aa645e39..b453cf3fd4607 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -9,41 +9,41 @@ this file is only the current session pointer. See ## Last Session Summary The exact 176-row VBD/AVBD paper-parity contract remains fail-closed, with no -complete rows. The latest local slice makes supported free-rigid AVBD contact -and pair constraints explicitly selectable through the C++ and dartpy -`RigidBodySolver` family. Its positive contact/joint projection budget is +complete rows. The current uncommitted slice adds a distinct public +fixed-penalty `RigidBodySolver::Vbd` / `sx.RigidBodySolver.VBD` family beside +the committed public AVBD family. VBD reuses the DART-owned 6-DOF rigid block +contact and pair-row path with finite penalty stiffness, zero dual +accumulation, and no progressive stiffness. Its positive projection budget is public, serialized, replay-safe, runtime-mutable, and reported in resolved -configuration when the split rigid stage is active. Mixed semi-implicit -multibody worlds use the unified constraint stage, accept only the default -rigid constraint options, and report that split-stage budget as not -applicable; unsupported or incompatible combinations fail closed. - -The durable packet is -[`../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). -It binds that public solver identity to a publication-shaped Figure 13 scene: -252 staggered bricks, 712 breakable attachments, three high-momentum balls, -1/60 s, and 20 projection sweeps. The deterministic frame-120 oracle records -359 broken/353 unbroken attachments, `[4, 10, 6]` local displaced-brick -counts, 91.16% outside-wall retention, and 82.54% total retention. Frame 60 is -explicitly pre-evaluation because its `[3, 9, 4]` counts leave one band below -the final threshold. Both captures record the exact front camera and pass -engine ViewReports, pixel integrity, and image-capable semantic review against -the pinned paper page. The runtime scene and benchmark share fingerprint -`2a746821cc10faee`; the five-repeat Release benchmark records 7.739 ms median -CPU time per step. - -The public solver/schedule/serialization/Python tests, both scene oracles, all -three public-AVBD first-post-bake allocation policies, all 16 fail-closed -packet-writer tests, the full 273-test migrated packet/schema set, all 56 -working-tree AVBD packets, deterministic reruns, captures, and benchmark pass. -The final broad default gates pass: `pixi run build`, all 168 unit targets, all -445 `test_world` cases, all 15 resolved-configuration cases, all 20 contact- -parity cases, and 1,677 Python tests with 20 expected skips. The full CUDA -`test-all` passes all seven phases, including the GUI-disabled Python catalog, -documentation, all eight CUDA runtime tests, and CUDA benchmark smoke. Packet -provenance was regenerated after formatting and all 16 writer, 273 aggregate, -56 working-tree packet, and 176-row parity gates pass with zero source-hash -mismatches. The PLAN-122 allocation matrix passes with 18 rows, 14 closed. +configuration. Boxed-LCP and multibody envelopes fail closed rather than +substituting another solver. + +The current durable packet is +[`../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json). +It binds the existing public AVBD packet to a matched VBD +`vbd_paper_breakable_wall` row: both use 252 staggered bricks, 712 +attachments, three high-momentum balls, 1/60 s, 20 sweeps, and fingerprint +`2a746821cc10faee`. The VBD frame-14 oracle records 0 broken/712 unbroken +attachments, 0.229 m peak and 0.122 m RMS brick displacement, and 131 bricks +beyond 0.1 m; the frame-120 oracle retains 100% of attachments. Both VBD +captures pass engine ViewReports, pixel integrity, and paper-grounded semantic +review. The five-repeat Release benchmark records 9.218 ms VBD versus 7.467 ms +AVBD median CPU time, or a descriptive 1.234 VBD/AVBD cost ratio. It is not a +speedup or achieved-accuracy equivalence claim. + +The VBD fixed-penalty row/state, public +solver/schedule/serialization/resolved-configuration/Python, matched demo, and +all three first-post-bake allocation-policy tests pass. Both assessed VBD +captures and the five-repeat comparison benchmark pass. After the final +packet-provenance repair, 188 focused Python/world/writer/schema tests, three +selected VBD Figure 13 demo cases, all 57 working-tree packets, and both +88-row PLAN-104 contracts pass. Fresh uncached default `test-all` passes all +six phases, including 79 enabled simulation tests and documentation. Fresh +uncached CUDA `test-all` passes all seven phases, including all eight CUDA +runtime tests and benchmark smoke; the long Jacobi batch case took 206.81 +seconds. This CUDA environment gate does not close the still-missing VBD/AVBD +GPU solver-parity rows. Independent final code/correctness and +architecture/docs/claims review lanes both approve the post-fix tree. The final dual review found one additional contract mismatch: non-default rigid iteration options were accepted and reported as applied in mixed @@ -75,15 +75,17 @@ at `710cbfc1152`, the Section 3.5 packet at `a78f688a178`, and the Section 4 packet at `0b0154573b8`; the articulated finite-row packet is committed at `761263bbd41`, the articulated finite-motor packet at `9ebd9b895b1`, and the articulated finite-fracture packet at `131981788fa`. The public AVBD/Figure 13 -slice is the current local unpushed closeout. The branch has not been pushed. +slice is committed at `646b447d6cc`. The matched public fixed-penalty +VBD/Figure 13 comparison is the current local closeout. The branch has not +been pushed. ## Immediate Next Step -Extend the wall into the source-matched Sequential Impulse, XPBD, and VBD -comparison rows and CUDA row, or proceed to unified rigid/soft and the -remaining fracture/joint corpus. Preserve the packet boundary: the current row -is a publication-shaped AVBD CPU reconstruction, not exact source, full video, -four-method, CUDA, or comparable performance closure. +Extend the matched wall into honest Sequential Impulse and XPBD rows, a +source-matched four-method edit, and CUDA, or proceed to unified rigid/soft and +the remaining fracture/joint corpus. Preserve the packet boundary: the current +rows are a publication-shaped VBD/AVBD CPU comparison, not exact source, full +video, four-method, CUDA, or achieved-accuracy reference-performance closure. ## Context That Would Be Lost @@ -93,8 +95,10 @@ four-method, CUDA, or comparable performance closure. - Figure 13 does not publish the exact wall dimensions, mass/inertia choices, ball speed/targets, or break threshold. The DART constants are explicitly reconstructed and must not be described as an exact source replay. -- The benchmark is absolute DART timing. There is no paper/reference timing - denominator for this scene, so do not derive a speedup or parity ratio. +- The benchmark is same-host descriptive DART timing. There is no + paper/reference achieved-accuracy denominator for this scene, so do not + describe the 1.234 VBD/AVBD cost ratio as a speedup, parity, or quality + comparison. - The outcome image's optional contrast diagnostic records 1,019 bright pixels against a 1,180-pixel heuristic. The required non-blank gate and engine ViewReport pass, and the separate semantic review adjudicates diff --git a/docs/plans/104-vertex-block-descent-solver.md b/docs/plans/104-vertex-block-descent-solver.md index b30cd02b27f16..16700cbb646e3 100644 --- a/docs/plans/104-vertex-block-descent-solver.md +++ b/docs/plans/104-vertex-block-descent-solver.md @@ -350,13 +350,15 @@ classification is unchanged. New packets must machine-record both `resolved_solver_identity` and `rigid_contact_selection` at AVBD packet schema version 3, enforced by `pixi run check-avbd-packets`. -Public C++ and dartpy callers can now select -`RigidBodySolver::Avbd` / `RigidBodySolver.AVBD` and set a positive -`RigidConstraintOptions::iterations` / +Public C++ and dartpy callers can now select the fixed-penalty +`RigidBodySolver::Vbd` / `RigidBodySolver.VBD` family or the +augmented-Lagrangian `RigidBodySolver::Avbd` / `RigidBodySolver.AVBD` family and +set a positive `RigidConstraintOptions::iterations` / `RigidConstraintOptions(iterations=...)` budget. The default remains -sequential impulse, the choice survives binary save/load and replay, the -resolved-configuration report names `avbd`, and unsupported combinations fail -before stepping instead of falling through to another contact family. +sequential impulse, each choice survives binary save/load and replay, the +resolved-configuration report names the selected family, and unsupported +combinations fail before stepping instead of falling through to another +contact family. The publication-shaped `avbd_paper_breakable_wall` scene exercises that public path at the paper's 1/60 s step and 20-iteration budget with 252 staggered bricks, 712 breakable attachments, and three impacts. Its deterministic @@ -366,12 +368,24 @@ the impact bands, and 82.54% total retention. The frame-60 capture remains an explicit pre-evaluation state: one band has three displaced bricks and no final three-band threshold claim is made there. Both engine ViewReports pass, the runtime scene and benchmark share fingerprint `2a746821cc10faee`, and the -five-repeat Release benchmark records 7.739 ms median CPU time per step. +current five-repeat Release benchmark records 7.467 ms median CPU time per +step. Those assessed captures and the absolute CPU benchmark are sealed in [`avbd-paper-breakable-wall-packet.json`](104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). +The matched `vbd_paper_breakable_wall` row reuses that exact scene fingerprint +and projection budget with fixed finite-penalty rows, no dual accumulation or +progressive stiffness. Its frame-14 oracle records 0 broken and 712 retained +attachments, 0.229 m peak and 0.122 m RMS brick displacement, and 131 bricks +beyond 0.1 m; its frame-120 oracle retains the full connected wall. Both +assessed VBD renders pass. The same five-repeat benchmark records 9.218 ms +median CPU time, or 1.234 times the AVBD median cost on this host; that ratio is +descriptive only because the methods produce different physical outcomes and +there is no achieved-accuracy reference denominator. The cross-linked evidence +is sealed in +[`avbd-paper-vbd-comparison-packet.json`](104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json). Figure 13 and the matching video row stay partial because the exact source -constants are unpublished and the Sequential Impulse, XPBD, and VBD comparison -rows, CUDA row, and matched performance evidence are still missing. +constants are unpublished and the Sequential Impulse, XPBD, source-edit, CUDA, +and achieved-accuracy reference-performance evidence is still missing. The `avbd_empty_baseline` py-demo and `BM_AvbdEmptyWorldStep` row now provide a first runnable baseline for the 2D/3D source-demo empty rows, with source revision/default metadata and a `sceneEmpty` zero-count reference invariant. @@ -1031,10 +1045,13 @@ internal/explicit-opt-in decision, not a leaked solver registry. The free-rigid fixed-joint/contact, free-rigid/articulated one-DOF motor, breakable fixed point-joint, prismatic/revolute/spherical facade, articulated breakable-joint, high-ratio articulated-chain, and public AVBD -Figure 13 slices are user-visible but intentionally bounded. The wall is the -first publication-shaped paper outcome row, not an exact source replay or a -four-method/CUDA closure; the source-demo corpus packets still carry their -historical solver identities. The Section 4 post-primal +Figure 13 slices are user-visible but intentionally bounded. The wall now has +matched public fixed-penalty VBD and public AVBD rows on one reconstructed +scene fingerprint, with distinct quantitative and assessed visual outcomes. +It is not an exact source replay or a four-method/CUDA closure; Sequential +Impulse, XPBD, the source edit, and achieved-accuracy reference performance +remain open, and the source-demo corpus packets still carry their historical +solver identities. The Section 4 post-primal dual/stiffness update is now a verified CPU partial over the promoted deformable and private free-rigid inventories; it remains open for articulated/unified rows, CUDA, and source-matched achieved-accuracy @@ -1344,6 +1361,25 @@ AVBD parity additionally requires: Relocated from the dashboard on 2026-07-03; newest first. +On 2026-07-30, the rigid-body solver selector gained a distinct public +fixed-penalty VBD family in C++ and dartpy. It reuses the DART-owned 6-DOF +block-descent contact and pair-row infrastructure while disabling AVBD dual +accumulation and progressive stiffness, preserves selection through binary +save/load and resolved configuration, rejects boxed-LCP and multibody +envelopes, and has schedule, serialization, Python, fixed-penalty state, +fracture-load, and all three first-post-bake allocation-policy regressions. The +matched `vbd_paper_breakable_wall` scene shares the AVBD wall's exact +`2a746821cc10faee` fingerprint, 1/60 s step, and 20-sweep budget. Its frame-14 +bend and frame-120 retained-wall oracles pass with 0 broken and 712 unbroken +attachments, as do both assessed renders. The five-repeat same-host benchmark +records 9.218 ms VBD versus 7.467 ms AVBD median CPU time, a descriptive +1.234 cost ratio with no speedup or achieved-accuracy equivalence claim. +[`avbd-paper-vbd-comparison-packet.json`](104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json) +binds those rows to the current AVBD packet and pinned source hashes. Figure 13 +and video row 12 remain partial because Sequential Impulse, XPBD, exact +unpublished constants, a source edit, CUDA, and achieved-accuracy reference +performance remain open. + On 2026-07-30, the public rigid-body solver selector gained an AVBD family and a serialized/replay-safe rigid-constraint options value object. The default remains sequential impulse; public AVBD owns supported rigid contact plus point-joint @@ -1359,7 +1395,7 @@ impact/outcome renders and five-repeat Release benchmark are sealed in [`avbd-paper-breakable-wall-packet.json`](104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). The packet makes no reference speedup claim and keeps Figure 13 plus video row 12 partial because exact source constants, the other three method rows, CUDA, -and comparable performance remain open. +and comparable performance were open at that slice. On 2026-07-30, the articulated finite-fracture packet closed the narrow CPU load-accounting gap left by the finite-motor slice. Accepted finite-row forces diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md index 84987f6ac94ad..ba705e4c07aa9 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md @@ -53,8 +53,10 @@ historical local artifact contract. New packets must machine-record `resolved_solver_identity` and `rigid_contact_selection` at AVBD packet schema version 3, enforced by `pixi run check-avbd-packets`. -The separate `avbd_paper_breakable_wall` surface does select the public AVBD -family and records that identity. It is a paper-shaped reconstruction, not a +The separate `avbd_paper_breakable_wall` surface selects the public AVBD +family, and the matched `vbd_paper_breakable_wall` surface selects the public +fixed-penalty VBD family. Both record solver identity and share one +reconstructed scene fingerprint. They are paper-shaped comparison rows, not a retroactive solver-identity change to the source-demo rows. ## Current Narrow DART Surfaces @@ -68,6 +70,8 @@ paper/source-demo corpus: | `avbd-empty-baseline-packet.json` | Visual-capture hash packet and `BM_AvbdEmptyWorldStep` benchmark JSON row for the empty baseline | Complete | | `avbd_paper_breakable_wall` py-demo | Public AVBD Figure 13 reconstruction with 252 bricks, 712 breakable attachments, three impacts, and a frame-120 outcome oracle | Partial | | `avbd-paper-breakable-wall-packet.json` | Solver-bound numeric, allocation, assessed visual, pinned-paper, and absolute CPU benchmark evidence for the AVBD wall row | Partial | +| `vbd_paper_breakable_wall` py-demo | Matched public fixed-penalty VBD Figure 13 reconstruction with bend and retained-wall oracles | Partial | +| `avbd-paper-vbd-comparison-packet.json` | Same-fingerprint AVBD/VBD solver identity, numeric, assessed visual, allocation, and descriptive CPU timing evidence | Partial | | `avbd_demo2d_ground` py-demo | Source `avbd-demo2d` Ground row port with one static slab and no dynamic bodies | Complete | | `avbd-demo2d-ground-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo2d` Ground row | Complete | | `avbd_demo2d_motor` py-demo | Source `avbd-demo2d` Motor row port using a pinned rigid bar and public AVBD revolute motor | Partial | @@ -279,24 +283,24 @@ paper/source-demo corpus: ## Paper, Website, And Video Scenes -| Target | Required DART artifact | Current DART evidence | Status | Next implementation dependency | -| ------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| High-stiffness ratio chains | Correctness test, py-demo, and benchmark sweep | Finite-stiffness ramp rows exist narrowly, and `BM_AvbdPaperScaleHighRatioChainIterationSweep`, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json), and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) now provide a first dashboard-selected 25/50/100/200 max-iteration sweep with finite replay counters and tracked timing/stability plot evidence for the 50-link/50,000:1 chain fixture | Partial | Same-hardware paper-number comparison and GPU parity | -| Flag/pole attachment | Cloth/attachment visual plus invariant | Attachment rows exist narrowly | Partial | Unified cloth AVBD scene | -| Card tower | Visual stability scene and benchmark packet | None | Missing | Broad rigid contact and performance path | -| 50-body pendulum with 50,000:1 mass ratio | Articulated high-ratio test, py-demo, benchmark packet | `avbd_articulated_high_ratio_chain`, `BM_AvbdArticulatedHighRatioChainStep`, and [`avbd-articulated-high-ratio-chain-packet.json`](avbd-articulated-high-ratio-chain-packet.json) provide a narrow five-link 200:1 variational-chain smoke; `avbd_paper_scale_high_ratio_chain`, `PaperScaleHighRatioChainStaysFiniteAndResets`, `BM_AvbdPaperScaleHighRatioChainStep`, and [`avbd-paper-scale-high-ratio-chain-packet.json`](avbd-paper-scale-high-ratio-chain-packet.json) provide 50-link/50,000:1 finite/reset plus visual/CPU benchmark evidence without a paper-number comparison or GPU parity | Partial | Broad articulated hard constraints and high-ratio benchmark | -| Two-heavy-ball chain | Articulated chain visual and invariant | `avbd_articulated_high_ratio_chain` and its packet provide a narrow heavy-tip articulated-chain smoke | Partial | Broad articulated hard constraints | -| Box stack | Rigid stack visual and benchmark packet | Narrow source-row ports now cover Pyramid, Cards, Stack, and Stack Ratio; no paper/site/video box-stack packet exists | Partial | Broad rigid contact candidate generation | -| Friction coefficient comparison | Parameter sweep with visual/JSON output | Narrow friction switching tests exist, and `BM_AvbdDemo2dFrictionCoefficientSweep`, [`avbd-friction-coefficient-sweep-packet.json`](avbd-friction-coefficient-sweep-packet.json), and [`avbd-friction-coefficient-sweep-plot.svg`](avbd-friction-coefficient-sweep-plot.svg) now provide benchmark/plot evidence over maximum Coulomb friction values 0, 0.5, 1, 2.5, and 5 for the source-shaped Dynamic Friction scene | Partial | Source/reference sweep timing, per-coefficient visual capture, and GPU parity | -| Chain mail impact | Cloth/contact impact visual and benchmark packet | None | Missing | Unified soft/rigid AVBD coupling | -| Breakable wall | Fracture visual and benchmark packet | `avbd_paper_breakable_wall` and [`avbd-paper-breakable-wall-packet.json`](avbd-paper-breakable-wall-packet.json) provide a public-AVBD 252-brick/712-attachment/three-impact reconstruction with deterministic localization/retention oracle, assessed impact/outcome frames, allocation gates, and absolute CPU timing | Partial | Exact unpublished source constants, source-matched four-method comparison/edit, broad fracture corpus, comparable performance, and CUDA | -| Large cloth plus articulated-rigid coupling | Coupled cloth/articulated rigid visual and benchmark packet | None | Missing | Unified soft/rigid coupling and GPU path | -| `beta` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | -| `alpha` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | -| `gamma` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | -| Iteration-count sweep | Benchmark JSON and convergence/stability plot data | `BM_AvbdPaperScaleHighRatioChainIterationSweep` is selected by the dashboard runner over 25/50/100/200 max-iteration budgets for the paper-scale high-ratio chain, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json) records validated benchmark JSON plus finite replay plot data, and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) renders the timing/stability sweep; no paper-number comparison or GPU parity exists yet | Partial | Paper-number comparison and GPU parity | -| Rigid stacking/piling website/video scenes | Visual artifact and benchmark packet | None | Missing | Broad rigid contact plus performance path | -| Large block-pile/sphere-smash GPU scenes | GPU visual artifact and benchmark packet | None | Missing | GPU AVBD row parity and broad rigid contact | +| Target | Required DART artifact | Current DART evidence | Status | Next implementation dependency | +| ------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| High-stiffness ratio chains | Correctness test, py-demo, and benchmark sweep | Finite-stiffness ramp rows exist narrowly, and `BM_AvbdPaperScaleHighRatioChainIterationSweep`, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json), and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) now provide a first dashboard-selected 25/50/100/200 max-iteration sweep with finite replay counters and tracked timing/stability plot evidence for the 50-link/50,000:1 chain fixture | Partial | Same-hardware paper-number comparison and GPU parity | +| Flag/pole attachment | Cloth/attachment visual plus invariant | Attachment rows exist narrowly | Partial | Unified cloth AVBD scene | +| Card tower | Visual stability scene and benchmark packet | None | Missing | Broad rigid contact and performance path | +| 50-body pendulum with 50,000:1 mass ratio | Articulated high-ratio test, py-demo, benchmark packet | `avbd_articulated_high_ratio_chain`, `BM_AvbdArticulatedHighRatioChainStep`, and [`avbd-articulated-high-ratio-chain-packet.json`](avbd-articulated-high-ratio-chain-packet.json) provide a narrow five-link 200:1 variational-chain smoke; `avbd_paper_scale_high_ratio_chain`, `PaperScaleHighRatioChainStaysFiniteAndResets`, `BM_AvbdPaperScaleHighRatioChainStep`, and [`avbd-paper-scale-high-ratio-chain-packet.json`](avbd-paper-scale-high-ratio-chain-packet.json) provide 50-link/50,000:1 finite/reset plus visual/CPU benchmark evidence without a paper-number comparison or GPU parity | Partial | Broad articulated hard constraints and high-ratio benchmark | +| Two-heavy-ball chain | Articulated chain visual and invariant | `avbd_articulated_high_ratio_chain` and its packet provide a narrow heavy-tip articulated-chain smoke | Partial | Broad articulated hard constraints | +| Box stack | Rigid stack visual and benchmark packet | Narrow source-row ports now cover Pyramid, Cards, Stack, and Stack Ratio; no paper/site/video box-stack packet exists | Partial | Broad rigid contact candidate generation | +| Friction coefficient comparison | Parameter sweep with visual/JSON output | Narrow friction switching tests exist, and `BM_AvbdDemo2dFrictionCoefficientSweep`, [`avbd-friction-coefficient-sweep-packet.json`](avbd-friction-coefficient-sweep-packet.json), and [`avbd-friction-coefficient-sweep-plot.svg`](avbd-friction-coefficient-sweep-plot.svg) now provide benchmark/plot evidence over maximum Coulomb friction values 0, 0.5, 1, 2.5, and 5 for the source-shaped Dynamic Friction scene | Partial | Source/reference sweep timing, per-coefficient visual capture, and GPU parity | +| Chain mail impact | Cloth/contact impact visual and benchmark packet | None | Missing | Unified soft/rigid AVBD coupling | +| Breakable wall | Fracture visual and benchmark packet | `avbd_paper_breakable_wall`, matched `vbd_paper_breakable_wall`, [`avbd-paper-breakable-wall-packet.json`](avbd-paper-breakable-wall-packet.json), and [`avbd-paper-vbd-comparison-packet.json`](avbd-paper-vbd-comparison-packet.json) provide public AVBD fracture/localization and fixed-penalty VBD bend/retention rows on one 252-brick/712-attachment/three-impact fingerprint, with independent oracles, assessed frames, allocation gates, and descriptive CPU timing | Partial | Exact unpublished source constants, Sequential Impulse/XPBD rows, source-matched four-method edit, broad fracture corpus, achieved-accuracy reference performance, and CUDA | +| Large cloth plus articulated-rigid coupling | Coupled cloth/articulated rigid visual and benchmark packet | None | Missing | Unified soft/rigid coupling and GPU path | +| `beta` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | +| `alpha` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | +| `gamma` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | +| Iteration-count sweep | Benchmark JSON and convergence/stability plot data | `BM_AvbdPaperScaleHighRatioChainIterationSweep` is selected by the dashboard runner over 25/50/100/200 max-iteration budgets for the paper-scale high-ratio chain, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json) records validated benchmark JSON plus finite replay plot data, and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) renders the timing/stability sweep; no paper-number comparison or GPU parity exists yet | Partial | Paper-number comparison and GPU parity | +| Rigid stacking/piling website/video scenes | Visual artifact and benchmark packet | None | Missing | Broad rigid contact plus performance path | +| Large block-pile/sphere-smash GPU scenes | GPU visual artifact and benchmark packet | None | Missing | GPU AVBD row parity and broad rigid contact | ## Performance Packets diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json index fd0a57b23a409..320a2195626d4 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json @@ -10,18 +10,18 @@ "mhz_per_cpu": 5300, "num_cpus": 32 }, - "json_sha256": "12d758d44ff8e79812b23f27fd98b227e9814d9b0da7ba5dc3339b5cc527230d", + "json_sha256": "d5aa41fe4ae4c2bbcfe23b12c87b031d4ec839123e5e104cbdfd3401a52ccbb9", "rows": [ { "aggregate_name": "mean", "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 7910242.160000001, + "cpu_time": 7434849.3500000015, "impacting_balls": 3.0, "iterations": 5, "public_avbd_family": 1.0, - "real_time": 7910501.326696249, + "real_time": 7436581.405102817, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -43,11 +43,11 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 7739033.116666669, + "cpu_time": 7467247.275000003, "impacting_balls": 3.0, "iterations": 5, "public_avbd_family": 1.0, - "real_time": 7739176.074877227, + "real_time": 7467605.933440305, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -67,9 +67,9 @@ { "aggregate_name": "stddev", "aggregate_unit": "time", - "cpu_time": 577236.8155847522, + "cpu_time": 352941.77407079434, "iterations": 5, - "real_time": 577272.751319648, + "real_time": 353435.6344223652, "repetitions": 5, "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", @@ -78,9 +78,9 @@ { "aggregate_name": "cv", "aggregate_unit": "percentage", - "cpu_time": 0.07297334315549603, + "cpu_time": 0.047471274461101796, "iterations": 5, - "real_time": 0.07297549516507583, + "real_time": 0.04752662751460576, "repetitions": 5, "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", @@ -89,15 +89,15 @@ ], "scene_spec_fingerprint": "2a746821cc10faee", "stability": { - "cpu_time_cv_fraction": 0.07297334315549603, - "real_time_cv_fraction": 0.07297549516507583, + "cpu_time_cv_fraction": 0.047471274461101796, + "real_time_cv_fraction": 0.04752662751460576, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 7910242.160000001, - "mean_real_time_per_step_ns": 7910501.326696249, - "median_cpu_time_per_step_ns": 7739033.116666669, - "median_real_time_per_step_ns": 7739176.074877227 + "mean_cpu_time_per_step_ns": 7434849.3500000015, + "mean_real_time_per_step_ns": 7436581.405102817, + "median_cpu_time_per_step_ns": 7467247.275000003, + "median_real_time_per_step_ns": 7467605.933440305 } }, "claim_boundary": "This packet covers the AVBD row of the publication-shaped Figure 13 breakable-wall outcome on the public CPU solver family. It does not claim exact source-scene replay, reproduce the Sequential Impulse, XPBD, and VBD comparison rows, or establish public CUDA AVBD parity.", @@ -195,7 +195,7 @@ "schema_version": 3, "source_provenance": { "algorithm": "sha256-length-prefixed-path-and-content-v1", - "digest": "0140f32ad7f646daa25294149019a8725aecd79b65144e74807dead374b4c1e3", + "digest": "ab55ac1c20fdcd75e0d29bced2f62b37fb8b9f56ae68dffb64ec70ac3eff38ab", "files": [ { "path": "dart/gui/view_quality.cpp", @@ -207,27 +207,27 @@ }, { "path": "dart/simulation/world.cpp", - "sha256": "59a99eb21aee1784822b3b6dae6b5589ae7183370d24f705e25484c77f1c7147" + "sha256": "df7b4d1dfb4140190a43e65f0dc6d46e8121b489bda796f65afb77dac190bf50" }, { "path": "dart/simulation/world.hpp", - "sha256": "5f3c436978f16b7dc6c8b3a52e89a3fe48c5a582be92f43993115ccc61fd8892" + "sha256": "a95470a832553e59a19b8a25f38e524e1f449d17a65d31c3185969d2275323da" }, { "path": "dart/simulation/world_options.hpp", - "sha256": "cc7b41f9e5a92246030b61cac336ea3bcdc43b9aba63e85826637af82f368e60" + "sha256": "6306b5aa84cc523d8edde96385cbba5a6b461e26f4eb8fdba9e659d704756b88" }, { "path": "dart/simulation/compute/rigid_body_contact_stage.cpp", - "sha256": "a2c65b3ab955e697dfba7d051f0510c0913316bd2931370fe8e62b034b2b6177" + "sha256": "125a7d11510749db22d28d3b9dfe08888aeb31b8711238e4ff81c90dca5559ff" }, { "path": "dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp", - "sha256": "10c567c477222bd424d5c14ead3bc95d71dbcf539550f1276d822f5b62615e75" + "sha256": "36b8f2ba77e45f0ad54d6ca0baf9ddf929264f5697b70040c3165f427d029a79" }, { "path": "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp", - "sha256": "ff12a847e74af8bec0c06136b90dce10d8d1d388d1683209bc0dc0c32f4fa8de" + "sha256": "1184d6f71fd9705bd03760cefdf6e471f3c0ff0d73c4886f7014ff8812ae0ced" }, { "path": "python/dartpy/_view_quality.py", @@ -235,15 +235,19 @@ }, { "path": "python/examples/demos/scenes/avbd_paper_breakable_wall.py", - "sha256": "3af171d6ec13e397e797a2c6182956b03ea872d2194d92f0c5b52ff7a1438fac" + "sha256": "bb418854c79e6a2f5ca3d01059c9ce94ae0f135b9acecb46e2a46d34bfd5ebac" }, { "path": "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", - "sha256": "8846b0782f3f624babccba7be746f1689071f271cc1412880e7ed9623de2d723" + "sha256": "24e5a9658898806ab9cfa0d765076884630c8cb100a52cf168d53e91a4ade01f" + }, + { + "path": "scripts/avbd_packet_schema.py", + "sha256": "f3bb031ca321abea4a49d8fc1392f33dfffca482c6797618f94f680f3543c04b" }, { "path": "scripts/write_avbd_paper_breakable_wall_packet.py", - "sha256": "c467d3ab290e6083a32dabdbff52c6cc7871b3a9ab643645f325f3a5819281b0" + "sha256": "4d2b0938a2bc98f7ac2740bb20ee5b02721039d0c388997e4e090ff2dc50220e" } ] }, diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json index e4a1658f67f00..27074b2393ff9 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json @@ -261,8 +261,11 @@ "id": "avbd.method.baseline_comparisons", "status": "partial", "source_locator": "Section 5 comparisons against VBD, XPBD, and sequential impulse", - "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md"], - "blockers": ["Narrow native-demo timings exist, but the paper's matched VBD/XPBD/sequential-impulse accuracy and performance rows are incomplete."] + "evidence": [ + "docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md", + "docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json" + ], + "blockers": ["A matched public fixed-penalty VBD versus public AVBD Figure 13 reconstruction now binds solver identity, identical scene topology, independent outcome oracles, assessed VBD renders, and descriptive same-host CPU timing. The paper's matched XPBD and sequential-impulse rows, exact unpublished scene constants, CUDA, and achieved-accuracy reference performance remain incomplete."] } ] }, @@ -292,7 +295,7 @@ {"id": "avbd.paper.fig.10", "status": "partial", "source_locator": "Figure 10, PDF page 9: high-mass-ratio box stack", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-stack-packet.json"], "blockers": ["The narrow stack port uses sequential-impulse contacts and lacks the paper sequential-impulse/AVBD comparison."]}, {"id": "avbd.paper.fig.11", "status": "partial", "source_locator": "Figure 11, PDF page 9: friction-coefficient blocks versus sequential impulse", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json"], "blockers": ["The DART sweep does not establish AVBD-contact solver identity, paper trajectory equivalence, GPU parity, or the matched visual pair."]}, {"id": "avbd.paper.fig.12", "status": "missing", "source_locator": "Figure 12, PDF page 10: heavy ball dropped on chain mail", "evidence": [], "blockers": ["The chain-mail scene, four-method comparison, hard-contact oracle, and performance packet are missing."]}, - {"id": "avbd.paper.fig.13", "status": "partial", "source_locator": "Figure 13, PDF page 10: breakable brick wall hit by three balls", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json"], "blockers": ["The DART AVBD row is a publication-shaped reconstruction because the source wall geometry, masses, impact speed, targets, and break threshold are unpublished; the matched Sequential Impulse, XPBD, and VBD comparison rows and CUDA evidence remain missing."]}, + {"id": "avbd.paper.fig.13", "status": "partial", "source_locator": "Figure 13, PDF page 10: breakable brick wall hit by three balls", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json"], "blockers": ["Matched public fixed-penalty VBD and public AVBD rows now share the reconstructed 252-brick, 712-attachment, three-ball scene fingerprint and bind independent paper-shaped outcome oracles plus assessed renders. Exact wall geometry, masses, impact speed, targets, and break threshold remain unpublished; matched Sequential Impulse and XPBD rows, CUDA, a source edit, and achieved-accuracy reference performance are still missing."]}, {"id": "avbd.paper.fig.14", "status": "missing", "source_locator": "Figure 14, PDF page 10: 35K rigid bodies, 72K joints, and 10K-vertex cloth", "evidence": [], "blockers": ["Unified rigid/soft coupling and the 16 ms inclusive performance row are missing."]}, {"id": "avbd.paper.fig.15", "status": "partial", "source_locator": "Figure 15, PDF page 11: beta, alpha, gamma, and iteration sweeps", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["Only a DART iteration-budget sweep exists; the exact beta/alpha/gamma trajectories and defaults are missing."]} ] @@ -351,7 +354,7 @@ {"id": "avbd.video.09_box_stack", "status": "partial", "source_locator": "02:51.567-03:02.033 sequential-impulse/AVBD box stack", "source_seconds": [171.567, 182.033], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-stack-packet.json"], "blockers": ["The current port does not run AVBD contacts and lacks the paper comparison/edit."]}, {"id": "avbd.video.10_friction", "status": "partial", "source_locator": "03:02.033-03:11.800 friction coefficient comparison", "source_seconds": [182.033, 191.8], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json"], "blockers": ["The current sweep lacks AVBD-contact solver identity and the matched source visual edit."]}, {"id": "avbd.video.11_chain_mail", "status": "missing", "source_locator": "03:11.800-03:19.200 heavy-ball chain-mail comparison", "source_seconds": [191.8, 199.2], "evidence": [], "blockers": ["No chain-mail source scene, four-method outcome, or validated DART edit exists."]}, - {"id": "avbd.video.12_breakable_wall", "status": "partial", "source_locator": "03:19.200-03:59.667 four-method breakable-wall comparison", "source_seconds": [199.2, 239.667], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json"], "blockers": ["The publication-shaped DART AVBD row has a fracture/localization oracle and assessed impact/outcome frames, but the source-matched four-method video edit, exact unpublished scene constants, CUDA evidence, and comparable performance remain missing."]}, + {"id": "avbd.video.12_breakable_wall", "status": "partial", "source_locator": "03:19.200-03:59.667 four-method breakable-wall comparison", "source_seconds": [199.2, 239.667], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json"], "blockers": ["The publication-shaped DART AVBD and matched fixed-penalty VBD rows bind identical reconstructed scene topology, distinct quantitative outcomes, and assessed current-build renders. The Sequential Impulse and XPBD rows, source-matched four-method video edit, exact unpublished scene constants, CUDA evidence, and achieved-accuracy comparable performance remain missing."]}, {"id": "avbd.video.13_parameter_sweeps", "status": "partial", "source_locator": "03:59.667-04:32.633 beta, alpha, gamma, and iteration sweeps", "source_seconds": [239.667, 272.633], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["Only a non-source iteration sweep exists; beta/alpha/gamma trajectories and the full edit are missing."]}, {"id": "avbd.video.14_closing", "status": "missing", "source_locator": "04:32.633-04:39.000 closing card", "source_seconds": [272.633, 279], "evidence": [], "blockers": ["No approved DART replacement closing edit exists."]} ] diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md index 0f4c29d328720..e7e7dc2d2aeb3 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md @@ -482,8 +482,18 @@ outside and total wall fractions, finite state, and the exact public solver iden frame-60/frame-120 renders passed machine pixel-integrity and native semantic review against the pinned paper page. The five-repeat Release benchmark is absolute DART CPU timing only. Exact scene constants were not published, and -the Sequential Impulse, XPBD, and VBD rows, source-matched video edit, CUDA -path, broad fracture corpus, and comparable performance remain open. +the source-matched video edit, CUDA path, broad fracture corpus, and comparable +performance remain open. +[`avbd-paper-vbd-comparison-packet.json`](avbd-paper-vbd-comparison-packet.json) +adds a matched public fixed-penalty VBD row over that exact reconstructed scene +fingerprint. Its independent frame-14 bend and frame-120 retention oracles, +assessed renders, solver identity, allocation gates, and same-host +five-repetition benchmark are bound to the current AVBD packet and pinned +paper/video sources. The timing ratio is descriptive cost only because the +methods intentionally produce different outcomes and no achieved-accuracy +reference denominator exists. Sequential Impulse, XPBD, exact unpublished +constants, a source-matched four-method edit, CUDA, and achieved-accuracy +reference performance remain open. Note: `avbd_demo2d_net`, `BM_AvbdDemo2dNetStep`, and `avbd-demo2d-net-packet.json` now cover the source Net row shape/count, @@ -669,9 +679,13 @@ capture, and candidate-only scale data. The adjacent [`avbd-paper-breakable-wall-packet.json`](avbd-paper-breakable-wall-packet.json) now covers the publication-shaped AVBD Figure 13 outcome with a deterministic fracture/localization oracle, assessed impact/outcome frames, allocation -gates, and absolute CPU timing. Exact unpublished source constants, the -four-method comparison, broad joint/fracture corpus, unified rows, CUDA, and -comparable paper/reference performance remain open. Non-topology +gates, and absolute CPU timing. The linked +[`avbd-paper-vbd-comparison-packet.json`](avbd-paper-vbd-comparison-packet.json) +adds the matched fixed-penalty VBD bend-without-fracture row on the same scene +fingerprint, with independent numeric and assessed visual evidence. Exact +unpublished source constants, Sequential Impulse and XPBD rows, a source edit, +broad joint/fracture corpus, unified rows, CUDA, and comparable paper/reference +performance remain open. Non-topology multibody-link fixed/revolute/prismatic point-joint entities can also generate hard private configs from simulation-entry current poses, and public same-multibody/world-link articulated @@ -802,9 +816,10 @@ renders the timing/stability sweep. No same-hardware paper-number comparison or GPU parity exists yet. DART still cannot reproduce the paper's full hard-constrained articulated chains, same-hardware numbers, or cloth-plus-articulated-rigid coupling scenes. The next fracture slice should -extend the publication-shaped AVBD wall into the source-matched four-method -comparison, CUDA, and the remaining joint/attachment corpus before treating -the A5 joint-family surface as broad. +extend the matched publication-shaped AVBD/VBD wall into honest Sequential +Impulse and XPBD rows, a source-matched four-method edit, CUDA, and the +remaining joint/attachment corpus before treating the A5 joint-family surface +as broad. The next implementation slice should either optimize the measured `avbd_demo2d_ground`, `avbd_demo2d_motor`, `avbd_demo2d_hanging_rope`, `avbd_demo2d_rod`, diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json new file mode 100644 index 0000000000000..a525180630b59 --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json @@ -0,0 +1,977 @@ +{ + "benchmark": { + "comparison": { + "basis": "same executable, host, reconstructed scene fingerprint, trajectory length, time step, and 20-sweep budget", + "vbd_to_avbd_median_cpu_cost_ratio": 1.234459059747689 + }, + "context": { + "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", + "host_name": "mark26", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "v1.9.5", + "mhz_per_cpu": 5300, + "num_cpus": 32 + }, + "json_sha256": "d5aa41fe4ae4c2bbcfe23b12c87b031d4ec839123e5e104cbdfd3401a52ccbb9", + "methods": { + "avbd": { + "benchmark": "BM_AvbdPaperBreakableWallStep", + "rows": [ + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "cpu_time": 7434849.3500000015, + "family_index": 0, + "impacting_balls": 3.0, + "iterations": 5, + "name": "BM_AvbdPaperBreakableWallStep/iterations:120_mean", + "per_family_instance_index": 0, + "public_avbd_family": 1.0, + "real_time": 7436581.405102817, + "repetitions": 5, + "resolved_rigid_body_avbd": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + "resolved_rigid_contact_avbd": 1.0, + "resolved_rigid_pair_constraint_avbd": 1.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "rigid_constraint_iterations": 20.0, + "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": 712271905.0, + "scene_spec_fingerprint_lo": 3423664878.0, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 120.0 + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "cpu_time": 7467247.275000003, + "family_index": 0, + "impacting_balls": 3.0, + "iterations": 5, + "name": "BM_AvbdPaperBreakableWallStep/iterations:120_median", + "per_family_instance_index": 0, + "public_avbd_family": 1.0, + "real_time": 7467605.933440305, + "repetitions": 5, + "resolved_rigid_body_avbd": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + "resolved_rigid_contact_avbd": 1.0, + "resolved_rigid_pair_constraint_avbd": 1.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "rigid_constraint_iterations": 20.0, + "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": 712271905.0, + "scene_spec_fingerprint_lo": 3423664878.0, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 120.0 + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "breakable_joints": 0.0, + "collision_shapes": 0.0, + "cpu_time": 352941.77407079434, + "family_index": 0, + "impacting_balls": 0.0, + "iterations": 5, + "name": "BM_AvbdPaperBreakableWallStep/iterations:120_stddev", + "per_family_instance_index": 0, + "public_avbd_family": 0.0, + "real_time": 353435.6344223652, + "repetitions": 5, + "resolved_rigid_body_avbd": 0.0, + "resolved_rigid_constraint_iterations": 0.0, + "resolved_rigid_contact_avbd": 0.0, + "resolved_rigid_pair_constraint_avbd": 0.0, + "rigid_bodies": 0.0, + "rigid_body_joints": 0.0, + "rigid_constraint_iterations": 0.0, + "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 0.0, + "scene_spec_fingerprint_hi": 8.94427190999916, + "scene_spec_fingerprint_lo": 50.59644256269407, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 0.0 + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "breakable_joints": 0.0, + "collision_shapes": 0.0, + "cpu_time": 0.047471274461101796, + "family_index": 0, + "impacting_balls": 0.0, + "iterations": 5, + "name": "BM_AvbdPaperBreakableWallStep/iterations:120_cv", + "per_family_instance_index": 0, + "public_avbd_family": 0.0, + "real_time": 0.04752662751460576, + "repetitions": 5, + "resolved_rigid_body_avbd": 0.0, + "resolved_rigid_constraint_iterations": 0.0, + "resolved_rigid_contact_avbd": 0.0, + "resolved_rigid_pair_constraint_avbd": 0.0, + "rigid_bodies": 0.0, + "rigid_body_joints": 0.0, + "rigid_constraint_iterations": 0.0, + "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 0.0, + "scene_spec_fingerprint_hi": 1.2557384121446091e-08, + "scene_spec_fingerprint_lo": 1.4778444843658577e-08, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 0.0 + } + ], + "stability": { + "cpu_time_cv_fraction": 0.047471274461101796, + "real_time_cv_fraction": 0.04752662751460576, + "repetitions": 5 + }, + "timing": { + "mean_cpu_time_per_step_ns": 7434849.3500000015, + "mean_real_time_per_step_ns": 7436581.405102817, + "median_cpu_time_per_step_ns": 7467247.275000003, + "median_real_time_per_step_ns": 7467605.933440305 + } + }, + "vbd": { + "benchmark": "BM_VbdPaperBreakableWallStep", + "rows": [ + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "cpu_time": 9283993.19666667, + "family_index": 1, + "impacting_balls": 3.0, + "iterations": 5, + "name": "BM_VbdPaperBreakableWallStep/iterations:120_mean", + "per_family_instance_index": 0, + "public_vbd_family": 1.0, + "real_time": 9285653.501719933, + "repetitions": 5, + "resolved_rigid_body_vbd": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + "resolved_rigid_contact_vbd": 1.0, + "resolved_rigid_pair_constraint_vbd": 1.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "rigid_constraint_iterations": 20.0, + "run_name": "BM_VbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": 712271905.0, + "scene_spec_fingerprint_lo": 3423664878.0, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 120.0 + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "cpu_time": 9218011.049999997, + "family_index": 1, + "impacting_balls": 3.0, + "iterations": 5, + "name": "BM_VbdPaperBreakableWallStep/iterations:120_median", + "per_family_instance_index": 0, + "public_vbd_family": 1.0, + "real_time": 9218644.316812666, + "repetitions": 5, + "resolved_rigid_body_vbd": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + "resolved_rigid_contact_vbd": 1.0, + "resolved_rigid_pair_constraint_vbd": 1.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "rigid_constraint_iterations": 20.0, + "run_name": "BM_VbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": 712271905.0, + "scene_spec_fingerprint_lo": 3423664878.0, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 120.0 + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "breakable_joints": 0.0, + "collision_shapes": 0.0, + "cpu_time": 315697.4492439518, + "family_index": 1, + "impacting_balls": 0.0, + "iterations": 5, + "name": "BM_VbdPaperBreakableWallStep/iterations:120_stddev", + "per_family_instance_index": 0, + "public_vbd_family": 0.0, + "real_time": 316183.0099676496, + "repetitions": 5, + "resolved_rigid_body_vbd": 0.0, + "resolved_rigid_constraint_iterations": 0.0, + "resolved_rigid_contact_vbd": 0.0, + "resolved_rigid_pair_constraint_vbd": 0.0, + "rigid_bodies": 0.0, + "rigid_body_joints": 0.0, + "rigid_constraint_iterations": 0.0, + "run_name": "BM_VbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 0.0, + "scene_spec_fingerprint_hi": 8.94427190999916, + "scene_spec_fingerprint_lo": 50.59644256269407, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 0.0 + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "breakable_joints": 0.0, + "collision_shapes": 0.0, + "cpu_time": 0.0340044895075214, + "family_index": 1, + "impacting_balls": 0.0, + "iterations": 5, + "name": "BM_VbdPaperBreakableWallStep/iterations:120_cv", + "per_family_instance_index": 0, + "public_vbd_family": 0.0, + "real_time": 0.03405070089133572, + "repetitions": 5, + "resolved_rigid_body_vbd": 0.0, + "resolved_rigid_constraint_iterations": 0.0, + "resolved_rigid_contact_vbd": 0.0, + "resolved_rigid_pair_constraint_vbd": 0.0, + "rigid_bodies": 0.0, + "rigid_body_joints": 0.0, + "rigid_constraint_iterations": 0.0, + "run_name": "BM_VbdPaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 0.0, + "scene_spec_fingerprint_hi": 1.2557384121446091e-08, + "scene_spec_fingerprint_lo": 1.4778444843658577e-08, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 0.0 + } + ], + "stability": { + "cpu_time_cv_fraction": 0.0340044895075214, + "real_time_cv_fraction": 0.03405070089133572, + "repetitions": 5 + }, + "timing": { + "mean_cpu_time_per_step_ns": 9283993.19666667, + "mean_real_time_per_step_ns": 9285653.501719933, + "median_cpu_time_per_step_ns": 9218011.049999997, + "median_real_time_per_step_ns": 9218644.316812666 + } + } + }, + "scene_spec_fingerprint": "2a746821cc10faee" + }, + "claim_boundary": "This packet covers the matched fixed-penalty VBD row and its same-host CPU comparison with the linked public AVBD Figure 13 packet. It does not reproduce the Sequential Impulse or XPBD rows, claim exact unpublished source constants, establish CUDA parity, or compare against a published/source timing.", + "correctness": { + "allocation_scene": "configurePublicRigidVbdContactAndBreakableJointRowsScene", + "allocation_tests": [ + "World.BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths", + "World.BakedRigidBodyContactStepsDoNotAllocateGlobalHeap", + "World.BakedAvbdVbdRowsDoNotMallocOnHeap" + ], + "determinism_test": "test_vbd_paper_breakable_wall_checkpoints_are_deterministic", + "outcome_test": "test_vbd_paper_breakable_wall_matches_figure13_contract", + "public_configuration": { + "rigid_body_solver": "VBD", + "rigid_constraint_options": { + "iterations": 20 + }, + "scene_spec_fingerprint": "2a746821cc10faee", + "time_step": 0.016666666666666666 + } + }, + "linked_avbd_evidence": { + "file": "avbd-paper-breakable-wall-packet.json", + "resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": false, + "recorded_from": "engine World.resolved_configuration in both captures plus benchmark runtime resolved-configuration counters", + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "avbd", + "rigid_point_joint_solver": "avbd" + }, + "scene_spec_fingerprint": "2a746821cc10faee", + "sha256": "ffb8c76fa9708ce7e6660d18087cfe6602acfd960b6a88898f1a88e0a099d0c0", + "visual_evidence": { + "impact_screenshot": { + "file": "avbd_paper_breakable_wall_impact.png", + "sha256": "a3ab663cdbbd030bccddbee838e134fa3326fdec459f3a8b734fa0da044f63a3" + }, + "outcome_screenshot": { + "file": "avbd_paper_breakable_wall_outcome.png", + "sha256": "6587784a2ea32578d46b155e4aa3b51579c5be3ec2936e168f0b43367eb94a34" + }, + "semantic_review": { + "file": "visual_review.json", + "sha256": "07060971349b4d1af35fdedfb3bfae880597f5b53b4aefe3c301f59f7b4c10fe", + "verdict": "pass" + } + } + }, + "packet": "avbd_paper_vbd_comparison", + "paper_reference": { + "figure": { + "file": "paper-10.png", + "height": 1760, + "sha256": "040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b", + "width": 1360 + }, + "locator": "Section 5.4 and Figure 13, PDF page 10", + "paper_pdf": { + "file": "avbd.pdf", + "sha256": "7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6" + } + }, + "performance_claim_boundary": "The ratio is a descriptive same-host cost comparison for two public DART families running an identical reconstructed scene and iteration budget. It is not a paper/reference speedup claim.", + "publication_observation": "The Figure 13 VBD row bends under the three impacts but does not break.", + "reconstruction": { + "matched_with_avbd": { + "break_force": 8500.0, + "breakable_joints": 712, + "bricks": 252, + "impacting_balls": 3, + "scene_spec_fingerprint": "2a746821cc10faee" + }, + "published": { + "constraint_iterations_or_substeps": 20, + "paper_locator": "Section 5.4 and Figure 13, PDF page 10", + "time_step": 0.016666666666666666 + }, + "unpublished": "wall dimensions, body materials, ball properties and launch conditions, break threshold, and exact source scene" + }, + "reproduction": { + "benchmark_command": "pixi run bm -- bm_avbd_rigid_fixed_joint -- --benchmark_filter='^BM_(Avbd|Vbd)PaperBreakableWallStep/iterations:120$' --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out= --benchmark_out_format=json", + "capture_commands": [ + "pixi run py-demo-capture -- --scene vbd_paper_breakable_wall --frames 14 --width 1280 --height 720 --view front --camera-distance 11 --camera-target 0,0,1.4 --capture-label bend --output-dir ", + "pixi run py-demo-capture -- --scene vbd_paper_breakable_wall --frames 120 --width 1280 --height 720 --view front --camera-distance 11 --camera-target 0,0,1.4 --capture-label retention --output-dir " + ] + }, + "resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": false, + "recorded_from": "engine World.resolved_configuration in both VBD captures plus benchmark runtime resolved-configuration counters", + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "vbd", + "rigid_point_joint_solver": "vbd" + }, + "scene": "vbd_paper_breakable_wall", + "schema_version": 3, + "source_provenance": { + "algorithm": "sha256-length-prefixed-path-and-content-v1", + "digest": "5dfbf32dbc165a1e83dd3e23e1a6d1aa95ffbf9749fbac3a49d7a305253d49b0", + "files": [ + { + "path": "dart/simulation/world.cpp", + "sha256": "df7b4d1dfb4140190a43e65f0dc6d46e8121b489bda796f65afb77dac190bf50" + }, + { + "path": "dart/simulation/world.hpp", + "sha256": "a95470a832553e59a19b8a25f38e524e1f449d17a65d31c3185969d2275323da" + }, + { + "path": "dart/simulation/world_options.hpp", + "sha256": "6306b5aa84cc523d8edde96385cbba5a6b461e26f4eb8fdba9e659d704756b88" + }, + { + "path": "dart/simulation/compute/rigid_body_contact_stage.cpp", + "sha256": "125a7d11510749db22d28d3b9dfe08888aeb31b8711238e4ff81c90dca5559ff" + }, + { + "path": "dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp", + "sha256": "36b8f2ba77e45f0ad54d6ca0baf9ddf929264f5697b70040c3165f427d029a79" + }, + { + "path": "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp", + "sha256": "1184d6f71fd9705bd03760cefdf6e471f3c0ff0d73c4886f7014ff8812ae0ced" + }, + { + "path": "dart/simulation/detail/world_step_schedule.hpp", + "sha256": "715a42595c19e8dba821d865cb477086f55600d54cf870902be73e253f1d1bec" + }, + { + "path": "python/examples/demos/scenes/avbd_paper_breakable_wall.py", + "sha256": "bb418854c79e6a2f5ca3d01059c9ce94ae0f135b9acecb46e2a46d34bfd5ebac" + }, + { + "path": "python/examples/demos/scenes/vbd_paper_breakable_wall.py", + "sha256": "9ec3f058fb63cc896b1c27e141c3dee34d3890c5c03f57ce72a5cb16a692bce4" + }, + { + "path": "python/tests/integration/test_demos_cycle.py", + "sha256": "f792e19e4d3da4497122b0f55d7baca7c24f7f8e58fd4e5d266d7290c5f8e447" + }, + { + "path": "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", + "sha256": "24e5a9658898806ab9cfa0d765076884630c8cb100a52cf168d53e91a4ade01f" + }, + { + "path": "tests/unit/simulation/world/test_world.cpp", + "sha256": "d4dde65145b8d3628489e597dd1b813b1a69b6ab1a60a666187fbe3dae7cb460" + }, + { + "path": "scripts/avbd_packet_schema.py", + "sha256": "f3bb031ca321abea4a49d8fc1392f33dfffca482c6797618f94f680f3543c04b" + }, + { + "path": "scripts/write_avbd_paper_vbd_comparison_packet.py", + "sha256": "7924bbd7e1f61b4e8812678c7566a8bcf04d0cb3d4c8f827d7b8dc96f26a0513" + } + ] + }, + "target": { + "complete_paper_reproduction": false, + "contract_rows": [ + "avbd.paper.fig.13" + ], + "covered_slice": "matched CPU VBD bend/no-break row with numeric, allocation, performance, and assessed visual evidence" + }, + "visual_evidence": { + "bend": { + "camera": { + "distance": 11.0, + "target": [ + 0.0, + 0.0, + 1.4 + ], + "view": "front" + }, + "capture": { + "height": 720, + "requested_frames": 14, + "width": 1280 + }, + "image_verdict": { + "checks": { + "contrast": { + "bright_pixels": 1259, + "dark_pixels": 57821, + "mid_pixels": 151043, + "p05": 35.09, + "p95": 147.723, + "pass": true, + "reasons": [], + "region": { + "x0": 512, + "x1": 1241, + "y0": 324, + "y1": 648 + }, + "sample_count": 236196, + "spread": 112.633, + "thresholds": { + "bright_luminance_gt": 190.0, + "dark_luminance_lt": 55.0, + "mid_luminance_max": 170.0, + "mid_luminance_min": 70.0, + "min_bright_pixels": 1180, + "min_dark_pixels": 1180, + "min_mid_pixels": 590, + "min_spread": 90.0 + } + }, + "non_blank": { + "nonzero_pixels": 921600, + "pass": true, + "reasons": [], + "total_pixels": 921600, + "unique_colors_seen": 2 + } + }, + "file": "image_verdict.json", + "machine_scope": "pixel-integrity", + "metadata": { + "frame": "14", + "scene": "vbd_paper_breakable_wall", + "view": "front" + }, + "pass": true, + "sha256": "035aff2135867535ca55ff658fc6d9b56701edb3e497c29f209ee6282bebda70" + }, + "label": "bend", + "manifest": { + "file": "manifest.json", + "sha256": "afce8eb0b0e2b4a9b12327184473888b8dc97a5e899463effdd6af249db6f10c" + }, + "scene_metrics": { + "event_count": 14, + "frame": 14, + "outcome": { + "ball_positions": [ + [ + -3.095194390778397, + -0.31337254836840883, + 1.3152668021613967 + ], + [ + -0.00018660146588299803, + -0.2732814645230808, + 1.4713295069252923 + ], + [ + 3.095554987013543, + -0.2789269932584263, + 2.0800199857034722 + ] + ], + "ball_velocities": [ + [ + 0.19974399132345333, + -2.8630967401358034, + -0.5913280598170401 + ], + [ + -0.008003948397866957, + -1.5926396369675018, + -1.9674643237228473 + ], + [ + -0.17792006977352948, + -1.598896714370408, + -1.849455289044748 + ] + ], + "bent_brick_count": 131, + "broken_joints": 0, + "checkpoint": "bend", + "contact_count": 26, + "evaluated": true, + "frame": 14, + "impact_band_displaced_counts": [ + 0, + 0, + 0 + ], + "last_step_iterations": 20, + "max_brick_displacement": 0.22874158419119156, + "maximum_wall_normal_displacement": 0.2287292903493461, + "outside_brick_count": 181, + "outside_retained_fraction": 1.0, + "rms_wall_normal_displacement": 0.12227322736028712, + "status": "pass", + "threshold_checks": { + "bend_is_spatially_resolved": true, + "finite_state": true, + "no_fracture": true, + "topology_retained": true, + "wall_bend_is_distributed": true, + "wall_bends": true + }, + "thresholds_pass": true, + "total_retained_fraction": 1.0, + "unbroken_joints": 712, + "world_time": 0.2333333333333333 + }, + "outcome_oracle": { + "bent_brick_displacement_threshold": 0.1, + "evaluation_frame": 14, + "maximum_broken_joints": 0, + "minimum_bent_bricks": 100, + "minimum_maximum_wall_normal_displacement": 0.2, + "minimum_rms_wall_normal_displacement": 0.1, + "minimum_total_retained_fraction": 0.99, + "minimum_unbroken_joints": 712, + "retention_evaluation_frame": 120 + }, + "resolved_configuration": [ + { + "domain": "rigid-body", + "reason": "as requested", + "requested": "vbd", + "resolved": "vbd" + }, + { + "domain": "rigid-contact", + "reason": "as requested", + "requested": "vbd", + "resolved": "vbd" + }, + { + "domain": "rigid-pair-constraint", + "reason": "as requested", + "requested": "vbd", + "resolved": "vbd" + }, + { + "domain": "rigid-constraint-iterations", + "reason": "as requested", + "requested": "20", + "resolved": "20" + }, + { + "domain": "multibody", + "reason": "as requested", + "requested": "semi-implicit", + "resolved": "semi-implicit" + }, + { + "domain": "deformable-psd", + "reason": "as requested", + "requested": "cpu", + "resolved": "cpu" + } + ], + "scene_contract": { + "ball_count": 3, + "break_force": 8500.0, + "breakable_joints": 712, + "brick_count": 252, + "collision_shapes": 256, + "executor": "World.step default", + "paper_locator": "Section 5.4 and Figure 13, PDF page 10", + "rigid_bodies": 256, + "rigid_body_solver": "VBD", + "rigid_constraint_options": { + "iterations": 20 + }, + "solver": "public_vbd", + "time_step_ms": 16.666666666666668 + }, + "scene_spec_fingerprint": "2a746821cc10faee", + "view_report": { + "camera": { + "azimuth": -1.5707963267948966, + "distance": 11.0, + "elevation": 0.0, + "target": [ + 0.0, + 0.0, + 1.4 + ] + }, + "focus": [ + "vbd_paper_wall_brick_00_00_visual", + "vbd_paper_wall_brick_00_20_visual", + "vbd_paper_wall_brick_11_00_visual", + "vbd_paper_wall_brick_11_20_visual" + ], + "issues": [], + "metrics": { + "ambiguity_iou": 0.0, + "center_visible": true, + "corner_coverage": 1.0, + "occlusion_fraction": 0.3333333333333333, + "subject_fraction": 0.29777577849388215 + }, + "pass": true, + "schema_version": "dart.view_report/v1", + "score": 0.606450387098188, + "size": [ + 1280, + 720 + ] + } + }, + "scene_metrics_events": { + "event_count": 14, + "file": "scene_metrics.jsonl", + "sha256": "bde75666cd05199605562416a7fd994dacbf8a78d152e6b7c6736b57937cd4f8" + }, + "screenshot": { + "file": "vbd_paper_breakable_wall_bend.png", + "sha256": "aae6cec800f888dff30f09c6c9209e7a7030ebc6505d646e68f44c2466cfdd24" + } + }, + "retention": { + "camera": { + "distance": 11.0, + "target": [ + 0.0, + 0.0, + 1.4 + ], + "view": "front" + }, + "capture": { + "height": 720, + "requested_frames": 120, + "width": 1280 + }, + "image_verdict": { + "checks": { + "contrast": { + "bright_pixels": 1086, + "dark_pixels": 61703, + "mid_pixels": 146859, + "p05": 34.878, + "p95": 147.723, + "pass": false, + "reasons": [ + "bright pixels 1086 < 1180" + ], + "region": { + "x0": 512, + "x1": 1241, + "y0": 324, + "y1": 648 + }, + "sample_count": 236196, + "spread": 112.845, + "thresholds": { + "bright_luminance_gt": 190.0, + "dark_luminance_lt": 55.0, + "mid_luminance_max": 170.0, + "mid_luminance_min": 70.0, + "min_bright_pixels": 1180, + "min_dark_pixels": 1180, + "min_mid_pixels": 590, + "min_spread": 90.0 + } + }, + "non_blank": { + "nonzero_pixels": 921600, + "pass": true, + "reasons": [], + "total_pixels": 921600, + "unique_colors_seen": 2 + } + }, + "file": "image_verdict.json", + "machine_scope": "pixel-integrity", + "metadata": { + "frame": "120", + "scene": "vbd_paper_breakable_wall", + "view": "front" + }, + "pass": true, + "sha256": "6fab3e582e99401f21a67d468a61aaf8c0307f6677f366f7b90be8804359ea55" + }, + "label": "retention", + "manifest": { + "file": "manifest.json", + "sha256": "840d694d7fe98e236e01bd3a6c8eaa407e8d17b3c8426f5348fc2bdf512acbc4" + }, + "scene_metrics": { + "event_count": 120, + "frame": 120, + "outcome": { + "ball_positions": [ + [ + -2.650841152312106, + -10.500981184136311, + -0.3106405050823772 + ], + [ + -0.020969640339728923, + -11.800473594287705, + -0.8858731141943095 + ], + [ + 2.6902750342446717, + -10.912281727032484, + -0.6842234485751176 + ] + ], + "ball_velocities": [ + [ + 0.27289846885341973, + -6.292490243031423, + -3.797196084793277 + ], + [ + -0.013530988922874528, + -7.289482888726795, + -5.0532410859474455 + ], + [ + -0.25247807940752764, + -6.8264214493861175, + -4.676213287271364 + ] + ], + "bent_brick_count": 0, + "broken_joints": 0, + "checkpoint": "retention", + "contact_count": 0, + "evaluated": true, + "frame": 120, + "impact_band_displaced_counts": [ + 0, + 0, + 0 + ], + "last_step_iterations": 20, + "max_brick_displacement": 0.001208511643710914, + "maximum_wall_normal_displacement": 0.0008573600302032246, + "outside_brick_count": 181, + "outside_retained_fraction": 1.0, + "rms_wall_normal_displacement": 0.0004662711877188896, + "status": "pass", + "threshold_checks": { + "finite_state": true, + "no_fracture": true, + "topology_retained": true, + "wall_retained": true + }, + "thresholds_pass": true, + "total_retained_fraction": 1.0, + "unbroken_joints": 712, + "world_time": 1.9999999999999978 + }, + "outcome_oracle": { + "bent_brick_displacement_threshold": 0.1, + "evaluation_frame": 14, + "maximum_broken_joints": 0, + "minimum_bent_bricks": 100, + "minimum_maximum_wall_normal_displacement": 0.2, + "minimum_rms_wall_normal_displacement": 0.1, + "minimum_total_retained_fraction": 0.99, + "minimum_unbroken_joints": 712, + "retention_evaluation_frame": 120 + }, + "resolved_configuration": [ + { + "domain": "rigid-body", + "reason": "as requested", + "requested": "vbd", + "resolved": "vbd" + }, + { + "domain": "rigid-contact", + "reason": "as requested", + "requested": "vbd", + "resolved": "vbd" + }, + { + "domain": "rigid-pair-constraint", + "reason": "as requested", + "requested": "vbd", + "resolved": "vbd" + }, + { + "domain": "rigid-constraint-iterations", + "reason": "as requested", + "requested": "20", + "resolved": "20" + }, + { + "domain": "multibody", + "reason": "as requested", + "requested": "semi-implicit", + "resolved": "semi-implicit" + }, + { + "domain": "deformable-psd", + "reason": "as requested", + "requested": "cpu", + "resolved": "cpu" + } + ], + "scene_contract": { + "ball_count": 3, + "break_force": 8500.0, + "breakable_joints": 712, + "brick_count": 252, + "collision_shapes": 256, + "executor": "World.step default", + "paper_locator": "Section 5.4 and Figure 13, PDF page 10", + "rigid_bodies": 256, + "rigid_body_solver": "VBD", + "rigid_constraint_options": { + "iterations": 20 + }, + "solver": "public_vbd", + "time_step_ms": 16.666666666666668 + }, + "scene_spec_fingerprint": "2a746821cc10faee", + "view_report": { + "camera": { + "azimuth": -1.5707963267948966, + "distance": 11.0, + "elevation": 0.0, + "target": [ + 0.0, + 0.0, + 1.4 + ] + }, + "focus": [ + "vbd_paper_wall_brick_00_00_visual", + "vbd_paper_wall_brick_00_20_visual", + "vbd_paper_wall_brick_11_00_visual", + "vbd_paper_wall_brick_11_20_visual" + ], + "issues": [], + "metrics": { + "ambiguity_iou": 0.0, + "center_visible": true, + "corner_coverage": 1.0, + "occlusion_fraction": 0.30303030303030304, + "subject_fraction": 0.29831663683376924 + }, + "pass": true, + "schema_version": "dart.view_report/v1", + "score": 0.633905645800401, + "size": [ + 1280, + 720 + ] + } + }, + "scene_metrics_events": { + "event_count": 120, + "file": "scene_metrics.jsonl", + "sha256": "229207ff8e2c10f80b6d020bfa078f03b086cd17a5f9353b7ca2f5da247cb55d" + }, + "screenshot": { + "file": "vbd_paper_breakable_wall_retention.png", + "sha256": "162d40cce657b9f474aff35d8533852be24185592c90eb518fdaee024a2f5ec7" + } + }, + "semantic_review": { + "claim_and_expected_observation": "For the matched Figure 13 VBD row, the wall should remain one connected structure while distributing the three impacts as a broad transient bend; at the later retention checkpoint it should still have no detached bricks or fracture holes.", + "file": "vbd-paper-breakable-wall-visual-review-fixed.json", + "inspected_images": [ + { + "file": "vbd_paper_breakable_wall_bend.png", + "role": "bend_frame_14", + "sha256": "aae6cec800f888dff30f09c6c9209e7a7030ebc6505d646e68f44c2466cfdd24" + }, + { + "file": "vbd_paper_breakable_wall_retention.png", + "role": "retention_frame_120", + "sha256": "162d40cce657b9f474aff35d8533852be24185592c90eb518fdaee024a2f5ec7" + }, + { + "file": "paper-10.png", + "role": "paper_figure_13_reference", + "sha256": "040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b" + } + ], + "not_proven_and_limitations": "The fixed front projection does not itself measure wall-normal depth, so the numeric displacement oracle supplies that quantitative check. These images do not prove exact unpublished source constants, the Sequential Impulse or XPBD comparison rows, CUDA parity, source-scene replay, or a published/reference timing match.", + "reconciliation_and_verdict": "PASS: both engine ViewReports pass, both pixel-integrity verdicts pass, frame 14 records a distributed 0.2287 m maximum and 0.1223 m RMS wall-normal bend across 131 bricks with 0 of 712 joints broken, and the image shows the connected courses displaced around all three balls. At frame 120 the image shows the wall intact with no holes or debris, agreeing with 712 retained joints and 100% retained bricks. Together those observations agree with Figure 13(c) and its bends-but-does-not-break text oracle.", + "reviewer_capability": "native image input inspected at original 1280x720 and 1360x1760 detail", + "sha256": "4844a9e6345073f5666af845e2a9cfe4e0dfc8f0b697903319f7fa5a29b6e475", + "text_oracle": "AVBD paper Figure 13 and its surrounding text state that VBD entirely prevents fracture but bends the wall, smoothly distributing constraint error.", + "verdict": "pass", + "visible_observation": "At frame 14 the three balls are aligned with three visibly displaced wall bands, yet every course remains continuous and no brick is detached. By frame 120 the balls have left the view and the same wall is standing as one complete structure without fracture holes, rubble, or a collapsed section." + } + } +} diff --git a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md index 26828eac6bae0..d7bcaa3dfa99f 100644 --- a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md +++ b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md @@ -46,11 +46,12 @@ below, so neither solver family has a paper-parity claim. the row still records an explicit blocker. It is negative evidence against a complete claim, not permission to generalize from the artifact. -The public-AVBD breakable-wall packet strengthens the evidence for Figure 13 -and official-video row 12 without changing these counts: both rows remain -partial because the scene is a publication-shaped reconstruction, the exact -source constants are unpublished, and the matched four-method, CUDA, and -comparable-performance evidence is still absent. +The public-AVBD breakable-wall packet and matched fixed-penalty VBD comparison +packet strengthen the evidence for Figure 13 and official-video row 12 without +changing these counts. Both rows remain partial because the shared scene is a +publication-shaped reconstruction, the exact source constants are unpublished, +and the Sequential Impulse, XPBD, source-edit, CUDA, and achieved-accuracy +reference-performance evidence is still absent. ## Authoritative Sources diff --git a/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md b/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md index 6dbcb897a81c6..3130d8004d43d 100644 --- a/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md +++ b/docs/plans/122-simulation-loop-allocation-hardening/coverage-matrix.md @@ -13,23 +13,23 @@ Status meanings: - `Open`: no complete post-bake allocation row exists yet. - `Out of scope`: excluded by PLAN-122 scope. -| Row | Status | Surface | Current evidence | Remaining task | Owner | -| ----- | ------------ | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | -| H-001 | Closed | Harness contract | `FirstPostBakeGlobalHeapGateFailsOnInjectedAllocation` covers injected post-bake allocation rejection, `scripts/check_plan122_allocation_matrix.py` validates closed-row test citations, and `docs/onboarding/testing.md` names the first-post-bake and steady-state raw helpers. | None for the harness scaffold; continue closing domain rows with first-post-bake world-base/global/raw evidence. | WP-122.1 | -| R-001 | Closed | Default rigid-body and articulated contact through DART 7 `World::step()` | `BakedRigidBodyContactStepsDoNotAllocateGlobalHeap`, `BakedArticulatedContactStepsDoNotAllocateGlobalHeap`, `BakedDefaultRigidAndArticulatedContactsDoNotGrowWorldBaseAllocator`, and `BakedDefaultRigidAndArticulatedContactsDoNotMallocOnHeap`. | None for the default rigid/articulated contact row. | WP-122.1, WP-122.4, PLAN-080 | -| R-002 | Closed | Boxed-LCP fallback contact | `BakedBoxedLcpFallbackContactsDoNotGrowWorldBaseAllocator`, `BakedBoxedLcpContactStepUsesFrameScratchWithoutBaseGrowth`, `BakedBoxedLcpFallbackContactStepsDoNotAllocateGlobalHeap`, and `BakedBoxedLcpFallbackContactStepsDoNotMallocOnHeap` cover single-island, different-DOF, and multi-island first-post-bake paths. | None for the boxed-LCP fallback contact row. | WP-122.1, PLAN-080 | -| R-003 | Closed | Rigid IPC representative dynamic and kinematic scenes | `BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`, `BakedDynamicRigidIpcStepsDoNotAllocateGlobalHeap`, `BakedKinematicIpcStepsDoNotAllocateGlobalHeap`, `BakedActiveRigidIpcBarrierStepsDoNotMallocOnHeap`, `BakedRigidIpcFixedJointStepsDoNotMallocOnHeap`, `BakedRigidIpcRevoluteJointStepsDoNotMallocOnHeap`, `BakedRigidIpcTwoBoxStackStepsDoNotMallocOnHeap`, `BakedRigidIpcDeformableSurfaceObstacleStepsDoNotMallocOnHeap`, `BakedRigidIpcKinematicConveyorStepsDoNotMallocOnHeap`, and `BakedRigidIpcKinematicTurntableStepsDoNotMallocOnHeap`. | None for the representative dynamic and kinematic rigid IPC row; large equality KKT systems stay tracked by R-004. | WP-122.2, PLAN-082 | -| R-004 | Closed | Large rigid IPC equality KKT systems | `BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`, `BakedDynamicRigidIpcStepsDoNotAllocateGlobalHeap`, and `BakedRigidIpcLargeEqualityKktStepsDoNotMallocOnHeap` cover a fixed-joint rigid IPC chain whose equality KKT system exceeds the stack solve cap and uses retained dynamic KKT storage after bake. | None for the large rigid IPC equality KKT row. | WP-122.2, PLAN-082, PLAN-083 | -| M-001 | Closed | Semi-implicit multibody velocity and force paths | `BakedVelocityActuatedMultibodyStepsDoNotMallocOnHeap` covers first-post-bake raw malloc for k == 1, k >= 2, and velocity-actuated articulated contact scenes; global-heap gates cover semi-implicit force scratch. | None for the semi-implicit multibody velocity/force row. | WP-122.4, PLAN-080 | -| M-002 | Closed | Variational multibody integration | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers representative variational scratch, compliant contact, dual state, loop closure, and long articulated point-joint scenes; `BakedVariationalMultibodyStepsDoNotMallocOnHeap` covers the same scenes for first-post-bake raw malloc. | None for currently promoted variational rows; future PLAN-084 rows require world-base/global/raw post-bake gates before promotion. | WP-122.4, PLAN-084 | -| M-003 | Closed | AVBD/VBD rows selected inside DART 7 `World::step()` | World-base/global-heap gates cover representative rigid AVBD rows, deformable AVBD rows including the Section 3.5 finite-stiffness spring path, and a VBD Chebyshev self-contact row; `BakedAvbdVbdRowsDoNotMallocOnHeap` covers the same representative rows for first-post-bake raw malloc. `configurePublicRigidAvbdContactAndBreakableJointRowsScene` is included in `BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths`, `BakedRigidBodyContactStepsDoNotAllocateGlobalHeap`, and `BakedAvbdVbdRowsDoNotMallocOnHeap`, proving the public AVBD family with active contact plus a breakable fixed-joint row. `WarmedParallelAvbdDualUpdatePassIsAllocationStable` extends the contract to a dispatch-sized Section 4 AVBD self-contact pass. | None for currently promoted AVBD/VBD rows; extend with PLAN-104 corpus rows as they are promoted. | WP-122.4, PLAN-104 | -| M-004 | Open | Unified Newton-barrier multibody rows | Open. PLAN-083 owns the solver direction. | Require world-base/global/raw post-bake gates before any unified Newton-barrier row is promoted into `World::step()`. | WP-122.4, PLAN-083 | -| D-001 | Closed | Basic deformable projected-Newton and storage paths | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers particles, springs, FEM, self-contact, static obstacle, CCD, mixed storage, and production-grid global-heap rows; `BakedBasicDeformableRowsDoNotMallocOnHeap` covers dense direct, iterative sparse CG, matrix-free production, obstacle/friction, CCD, and mixed storage rows for first-post-bake raw malloc. | None for current dense direct, iterative sparse CG, matrix-free, obstacle/friction, CCD, and mixed storage rows. | WP-122.3, PLAN-081 | -| D-002 | Closed | Scripted deformable boundary | World-base/global gates and `BakedScriptedDeformableBoundaryStepsDoNotMallocOnHeap` cover the first post-bake scripted boundary path; bake primes the retained dense projected-Newton factorization scratch before the measured loop. | None for the scripted deformable boundary row. | WP-122.1, WP-122.3 | -| D-003 | Closed | 81-DOF FEM ground-friction block | `BakedDeformableFemGroundFrictionBlockStepsDoNotMallocOnHeap` covers first-post-bake raw malloc for the retained dense projected-Newton solve cutoff. | None for the retained dense 81-DOF FEM ground-friction row. | WP-122.3, PLAN-081 | -| D-004 | Closed | Deformable sparse-direct systems above retained dense cutoff | `BakedDeformableSparseIterativeStepsDoNotGrowWorldBaseAllocator`, `BakedDeformableSparseIterativeStepsDoNotAllocateGlobalHeap`, and `BakedDeformableSparseIterativeStepsDoNotMallocOnHeap` cover the default above-dense-cap FEM ground-friction path after bake. `DeformableDefaultSparseIterativeFemGroundFrictionBlockIsActive` verifies the same default path takes sparse CG and no sparse numeric factorization. | None for currently selectable above-dense deformable systems; default routing uses sparse iterative solves instead of Eigen sparse-direct numeric factorization. | WP-122.3, PLAN-081 | -| F-001 | Closed | Differentiable contact-free stepping | `BakedDifferentiableContactFreeStepsDoNotGrowWorldBaseAllocator`, `BakedDifferentiableContactFreeStepsDoNotAllocateGlobalHeap`, and `BakedDifferentiableContactFreeStepsDoNotMallocOnHeap` cover first-post-bake derivative scratch reuse when DART_BUILD_DIFF is enabled; `DifferentiableMultibodyTorqueScratchUsesWorldAllocator` also verifies bake-time scratch sizing does not publish a derivative snapshot before `step()`. | None for contact-free differentiable stepping; contact derivative and trajectory-capture rows stay tracked by F-002 until promoted. | WP-122.5, PLAN-110 | -| F-002 | Open | Differentiable contact derivatives and trajectory snapshots | Open; PLAN-110 owns promotion. | Add allocation gates when contact derivatives, reverse snapshots, or trajectory capture enter the DART 7 step loop. | WP-122.5, PLAN-110 | -| L-001 | Closed | DART 7 model-loading bridges into `World::step()` | `BakedImportedSkeletonStepsDoNotGrowWorldBaseAllocator`, `BakedImportedSkeletonStepsDoNotAllocateGlobalHeap`, and `BakedImportedSkeletonStepsDoNotMallocOnHeap` cover the first post-bake steps of a model imported through the legacy `dynamics::Skeleton` → DART 7 World bridge (`dart::simulation::io::addSkeleton`); the source model is built and translated during scene configuration, before bake, so import lies outside the measured window. | None for the `addSkeleton` model-loading bridge row; extend with imported contact-bearing scenes and additional loader front ends (URDF/SDF/MJCF/USD) as they promote new scenes into `World::step()`. | WP-122.6, PLAN-041, PLAN-042, PLAN-080 | -| G-001 | Open | Accelerator-backed DART 7 stages | Host allocation rules exist; device allocation semantics are plan-owned by compute/GPU docs. | Before a GPU stage is promoted into `World::step()`, add host gates plus backend-specific no-device-allocation evidence after bake. | WP-122.7, PLAN-030, PLAN-031 | -| X-001 | Out of scope | Classic DART 6 `Skeleton` and legacy constraint solver step paths | Out of scope by maintainer direction in this plan. | None, unless the path is migrated into DART 7 `World::step()`. | Out of scope | +| Row | Status | Surface | Current evidence | Remaining task | Owner | +| ----- | ------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | +| H-001 | Closed | Harness contract | `FirstPostBakeGlobalHeapGateFailsOnInjectedAllocation` covers injected post-bake allocation rejection, `scripts/check_plan122_allocation_matrix.py` validates closed-row test citations, and `docs/onboarding/testing.md` names the first-post-bake and steady-state raw helpers. | None for the harness scaffold; continue closing domain rows with first-post-bake world-base/global/raw evidence. | WP-122.1 | +| R-001 | Closed | Default rigid-body and articulated contact through DART 7 `World::step()` | `BakedRigidBodyContactStepsDoNotAllocateGlobalHeap`, `BakedArticulatedContactStepsDoNotAllocateGlobalHeap`, `BakedDefaultRigidAndArticulatedContactsDoNotGrowWorldBaseAllocator`, and `BakedDefaultRigidAndArticulatedContactsDoNotMallocOnHeap`. | None for the default rigid/articulated contact row. | WP-122.1, WP-122.4, PLAN-080 | +| R-002 | Closed | Boxed-LCP fallback contact | `BakedBoxedLcpFallbackContactsDoNotGrowWorldBaseAllocator`, `BakedBoxedLcpContactStepUsesFrameScratchWithoutBaseGrowth`, `BakedBoxedLcpFallbackContactStepsDoNotAllocateGlobalHeap`, and `BakedBoxedLcpFallbackContactStepsDoNotMallocOnHeap` cover single-island, different-DOF, and multi-island first-post-bake paths. | None for the boxed-LCP fallback contact row. | WP-122.1, PLAN-080 | +| R-003 | Closed | Rigid IPC representative dynamic and kinematic scenes | `BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`, `BakedDynamicRigidIpcStepsDoNotAllocateGlobalHeap`, `BakedKinematicIpcStepsDoNotAllocateGlobalHeap`, `BakedActiveRigidIpcBarrierStepsDoNotMallocOnHeap`, `BakedRigidIpcFixedJointStepsDoNotMallocOnHeap`, `BakedRigidIpcRevoluteJointStepsDoNotMallocOnHeap`, `BakedRigidIpcTwoBoxStackStepsDoNotMallocOnHeap`, `BakedRigidIpcDeformableSurfaceObstacleStepsDoNotMallocOnHeap`, `BakedRigidIpcKinematicConveyorStepsDoNotMallocOnHeap`, and `BakedRigidIpcKinematicTurntableStepsDoNotMallocOnHeap`. | None for the representative dynamic and kinematic rigid IPC row; large equality KKT systems stay tracked by R-004. | WP-122.2, PLAN-082 | +| R-004 | Closed | Large rigid IPC equality KKT systems | `BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`, `BakedDynamicRigidIpcStepsDoNotAllocateGlobalHeap`, and `BakedRigidIpcLargeEqualityKktStepsDoNotMallocOnHeap` cover a fixed-joint rigid IPC chain whose equality KKT system exceeds the stack solve cap and uses retained dynamic KKT storage after bake. | None for the large rigid IPC equality KKT row. | WP-122.2, PLAN-082, PLAN-083 | +| M-001 | Closed | Semi-implicit multibody velocity and force paths | `BakedVelocityActuatedMultibodyStepsDoNotMallocOnHeap` covers first-post-bake raw malloc for k == 1, k >= 2, and velocity-actuated articulated contact scenes; global-heap gates cover semi-implicit force scratch. | None for the semi-implicit multibody velocity/force row. | WP-122.4, PLAN-080 | +| M-002 | Closed | Variational multibody integration | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers representative variational scratch, compliant contact, dual state, loop closure, and long articulated point-joint scenes; `BakedVariationalMultibodyStepsDoNotMallocOnHeap` covers the same scenes for first-post-bake raw malloc. | None for currently promoted variational rows; future PLAN-084 rows require world-base/global/raw post-bake gates before promotion. | WP-122.4, PLAN-084 | +| M-003 | Closed | AVBD/VBD rows selected inside DART 7 `World::step()` | World-base/global-heap gates cover representative rigid AVBD rows, deformable AVBD rows including the Section 3.5 finite-stiffness spring path, and a VBD Chebyshev self-contact row; `BakedAvbdVbdRowsDoNotMallocOnHeap` covers the same representative rows for first-post-bake raw malloc. The shared public rigid-block fixture runs active contact plus a breakable fixed-joint row under both `RigidBodySolver::Avbd` and fixed-penalty `RigidBodySolver::Vbd` in `BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths`, `BakedRigidBodyContactStepsDoNotAllocateGlobalHeap`, and `BakedAvbdVbdRowsDoNotMallocOnHeap`. `WarmedParallelAvbdDualUpdatePassIsAllocationStable` extends the contract to a dispatch-sized Section 4 AVBD self-contact pass. | None for currently promoted AVBD/VBD rows; extend with PLAN-104 corpus rows as they are promoted. | WP-122.4, PLAN-104 | +| M-004 | Open | Unified Newton-barrier multibody rows | Open. PLAN-083 owns the solver direction. | Require world-base/global/raw post-bake gates before any unified Newton-barrier row is promoted into `World::step()`. | WP-122.4, PLAN-083 | +| D-001 | Closed | Basic deformable projected-Newton and storage paths | `BakedMultibodyAndDeformableStepsDoNotAllocateGlobalHeap` covers particles, springs, FEM, self-contact, static obstacle, CCD, mixed storage, and production-grid global-heap rows; `BakedBasicDeformableRowsDoNotMallocOnHeap` covers dense direct, iterative sparse CG, matrix-free production, obstacle/friction, CCD, and mixed storage rows for first-post-bake raw malloc. | None for current dense direct, iterative sparse CG, matrix-free, obstacle/friction, CCD, and mixed storage rows. | WP-122.3, PLAN-081 | +| D-002 | Closed | Scripted deformable boundary | World-base/global gates and `BakedScriptedDeformableBoundaryStepsDoNotMallocOnHeap` cover the first post-bake scripted boundary path; bake primes the retained dense projected-Newton factorization scratch before the measured loop. | None for the scripted deformable boundary row. | WP-122.1, WP-122.3 | +| D-003 | Closed | 81-DOF FEM ground-friction block | `BakedDeformableFemGroundFrictionBlockStepsDoNotMallocOnHeap` covers first-post-bake raw malloc for the retained dense projected-Newton solve cutoff. | None for the retained dense 81-DOF FEM ground-friction row. | WP-122.3, PLAN-081 | +| D-004 | Closed | Deformable sparse-direct systems above retained dense cutoff | `BakedDeformableSparseIterativeStepsDoNotGrowWorldBaseAllocator`, `BakedDeformableSparseIterativeStepsDoNotAllocateGlobalHeap`, and `BakedDeformableSparseIterativeStepsDoNotMallocOnHeap` cover the default above-dense-cap FEM ground-friction path after bake. `DeformableDefaultSparseIterativeFemGroundFrictionBlockIsActive` verifies the same default path takes sparse CG and no sparse numeric factorization. | None for currently selectable above-dense deformable systems; default routing uses sparse iterative solves instead of Eigen sparse-direct numeric factorization. | WP-122.3, PLAN-081 | +| F-001 | Closed | Differentiable contact-free stepping | `BakedDifferentiableContactFreeStepsDoNotGrowWorldBaseAllocator`, `BakedDifferentiableContactFreeStepsDoNotAllocateGlobalHeap`, and `BakedDifferentiableContactFreeStepsDoNotMallocOnHeap` cover first-post-bake derivative scratch reuse when DART_BUILD_DIFF is enabled; `DifferentiableMultibodyTorqueScratchUsesWorldAllocator` also verifies bake-time scratch sizing does not publish a derivative snapshot before `step()`. | None for contact-free differentiable stepping; contact derivative and trajectory-capture rows stay tracked by F-002 until promoted. | WP-122.5, PLAN-110 | +| F-002 | Open | Differentiable contact derivatives and trajectory snapshots | Open; PLAN-110 owns promotion. | Add allocation gates when contact derivatives, reverse snapshots, or trajectory capture enter the DART 7 step loop. | WP-122.5, PLAN-110 | +| L-001 | Closed | DART 7 model-loading bridges into `World::step()` | `BakedImportedSkeletonStepsDoNotGrowWorldBaseAllocator`, `BakedImportedSkeletonStepsDoNotAllocateGlobalHeap`, and `BakedImportedSkeletonStepsDoNotMallocOnHeap` cover the first post-bake steps of a model imported through the legacy `dynamics::Skeleton` → DART 7 World bridge (`dart::simulation::io::addSkeleton`); the source model is built and translated during scene configuration, before bake, so import lies outside the measured window. | None for the `addSkeleton` model-loading bridge row; extend with imported contact-bearing scenes and additional loader front ends (URDF/SDF/MJCF/USD) as they promote new scenes into `World::step()`. | WP-122.6, PLAN-041, PLAN-042, PLAN-080 | +| G-001 | Open | Accelerator-backed DART 7 stages | Host allocation rules exist; device allocation semantics are plan-owned by compute/GPU docs. | Before a GPU stage is promoted into `World::step()`, add host gates plus backend-specific no-device-allocation evidence after bake. | WP-122.7, PLAN-030, PLAN-031 | +| X-001 | Out of scope | Classic DART 6 `Skeleton` and legacy constraint solver step paths | Out of scope by maintainer direction in this plan. | None, unless the path is migrated into DART 7 `World::step()`. | Out of scope | diff --git a/docs/readthedocs/architecture.md b/docs/readthedocs/architecture.md index bef4c0201e557..483a4e58b3574 100644 --- a/docs/readthedocs/architecture.md +++ b/docs/readthedocs/architecture.md @@ -185,6 +185,7 @@ same shape is usable across domains and `World::add*` stays uniform. | ---------- | ----------------------------------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Rigid | Sequential-impulse (default) | ✅ available | `WorldOptions::rigidBodySolver` or `World::setRigidBodySolver(...)`; tested by [`test_world_default_step_golden.cpp`](https://github.com/dartsim/dart/blob/main/tests/unit/simulation/world/test_world_default_step_golden.cpp) | | Rigid | IPC (incremental potential contact) | 🧪 experimental | `WorldOptions::rigidBodySolver` or `World::setRigidBodySolver(...)` | +| Rigid | Fixed-penalty VBD contact and public pair constraints | 🧪 experimental | Selected by `WorldOptions::rigidBodySolver` or `World::setRigidBodySolver(...)`; `RigidConstraintOptions` tunes the active split rigid stage but does not select the family | | Rigid | AVBD contact and public pair constraints | 🧪 experimental | Selected by `WorldOptions::rigidBodySolver` or `World::setRigidBodySolver(...)`; `RigidConstraintOptions` tunes the active split rigid stage but does not select the family | | Rigid | Contact normal/friction: sequential-impulse / boxed-LCP | 🧪 experimental | `WorldOptions::contactSolverMethod` | | Rigid | Differentiable gradient: analytic / complementarity-aware / pre-contact surrogate | 🧪 experimental | `WorldOptions::differentiable`, `WorldOptions::contactGradientMode`, setter | diff --git a/docs/readthedocs/papers.md b/docs/readthedocs/papers.md index 97b0b801e7a7a..924f83dcac547 100644 --- a/docs/readthedocs/papers.md +++ b/docs/readthedocs/papers.md @@ -779,6 +779,10 @@ Related public resources: ratios, quasi-Newton Hessian approximation, alpha-regularized error correction, warm-started dual/stiffness state, rigid-body 6-DOF blocks, articulated joints, fracture limits, and unified soft/rigid interactions. + DART now has one publication-shaped Figure 13 reconstruction with matched + public fixed-penalty VBD and public AVBD CPU rows, independent numeric and + assessed visual outcomes, and descriptive same-host timing; it is not an + exact source replay or an achieved-accuracy performance comparison. Full completion requires CPU and GPU implementations, all paper/site/video experiments and demos integrated into DART tests/benchmarks/`py-demos`, and benchmark packets proving DART beats the reference demo repositories and the diff --git a/python/dartpy/simulation/module_core.cpp b/python/dartpy/simulation/module_core.cpp index 91dd71db83cc6..194f81ec8a125 100644 --- a/python/dartpy/simulation/module_core.cpp +++ b/python/dartpy/simulation/module_core.cpp @@ -72,7 +72,8 @@ void defSimPartCore(nb::module_& m) nb::enum_(m, "RigidBodySolver") .value("SEQUENTIAL_IMPULSE", sim::RigidBodySolver::SequentialImpulse) .value("IPC", sim::RigidBodySolver::Ipc) - .value("AVBD", sim::RigidBodySolver::Avbd); + .value("AVBD", sim::RigidBodySolver::Avbd) + .value("VBD", sim::RigidBodySolver::Vbd); nb::enum_(m, "ContactSolverMethod") .value("SEQUENTIAL_IMPULSE", sim::ContactSolverMethod::SequentialImpulse) .value("BOXED_LCP", sim::ContactSolverMethod::BoxedLcp); diff --git a/python/examples/demos/README.md b/python/examples/demos/README.md index 20e9b0e8c1301..9530ca494218f 100644 --- a/python/examples/demos/README.md +++ b/python/examples/demos/README.md @@ -1401,6 +1401,7 @@ constraint/contact/motor signal instead of relying on screenshots alone. | `avbd_demo3d_bridge` | The `avbd-demo3d` Bridge source scene with paired point-jointed planks and load boxes | Source-demo bridge row with matched anchors, geometry, and metadata | | `avbd_demo3d_breakable` | The `avbd-demo3d` Breakable source scene as rigid boxes, contact shapes, and weak joints | Source-demo breakable fixed-joint row with public AVBD break thresholds | | `avbd_paper_breakable_wall` | A publication-shaped Figure 13 brick wall hit simultaneously by three heavy balls | Public AVBD contacts, 20 sweeps, and a quantitative retained-wall outcome oracle | +| `vbd_paper_breakable_wall` | The matched Figure 13 reconstruction using public fixed-penalty VBD | Bend-without-fracture and retained-wall oracles over the shared AVBD scene | | `avbd_rigid_fixed_joint_contact` | A fixed rigid payload sliding against static contact | Public fixed-joint rows plus ordinary contact | | `avbd_rigid_revolute_motor` | A bounded revolute motor driving a free hinge | Public velocity actuator mapped to AVBD motor rows | | `avbd_rigid_prismatic_motor` | A bounded prismatic motor driving a free rigid slider | Public linear velocity actuator mapped to AVBD motor rows | diff --git a/python/examples/demos/registry.py b/python/examples/demos/registry.py index 2270ba106ddf9..2079411c84929 100644 --- a/python/examples/demos/registry.py +++ b/python/examples/demos/registry.py @@ -46,6 +46,7 @@ from .scenes.avbd_demo3d_stack_ratio import SCENE as AVBD_DEMO3D_STACK_RATIO from .scenes.avbd_demo3d_soft_body import SCENE as AVBD_DEMO3D_SOFT_BODY from .scenes.avbd_paper_breakable_wall import SCENE as AVBD_PAPER_BREAKABLE_WALL +from .scenes.vbd_paper_breakable_wall import SCENE as VBD_PAPER_BREAKABLE_WALL from .scenes.avbd_articulated_breakable_joint import ( SCENE as AVBD_ARTICULATED_BREAKABLE_JOINT, ) @@ -310,6 +311,7 @@ def make_demo_scenes() -> list[PythonDemoScene]: AVBD_DEMO3D_BRIDGE, AVBD_DEMO3D_BREAKABLE, AVBD_PAPER_BREAKABLE_WALL, + VBD_PAPER_BREAKABLE_WALL, AVBD_RIGID_FIXED_JOINT_CONTACT, AVBD_RIGID_REVOLUTE_MOTOR, AVBD_RIGID_PRISMATIC_MOTOR, diff --git a/python/examples/demos/scenes/avbd_paper_breakable_wall.py b/python/examples/demos/scenes/avbd_paper_breakable_wall.py index 87325736e0008..cadcf8ebfb15a 100644 --- a/python/examples/demos/scenes/avbd_paper_breakable_wall.py +++ b/python/examples/demos/scenes/avbd_paper_breakable_wall.py @@ -189,6 +189,7 @@ def update_f64(value: float) -> None: def _serialize_and_validate_resolved_configuration( world: sx.World, + solver_key: str, ) -> list[dict[str, str]]: notes = [ { @@ -200,9 +201,9 @@ def _serialize_and_validate_resolved_configuration( for note in world.resolved_configuration.notes ] expected = { - "rigid-body": ("avbd", "avbd"), - "rigid-contact": ("avbd", "avbd"), - "rigid-pair-constraint": ("avbd", "avbd"), + "rigid-body": (solver_key, solver_key), + "rigid-contact": (solver_key, solver_key), + "rigid-pair-constraint": (solver_key, solver_key), "rigid-constraint-iterations": ( str(_RIGID_CONSTRAINT_ITERATIONS), str(_RIGID_CONSTRAINT_ITERATIONS), @@ -218,7 +219,8 @@ def _serialize_and_validate_resolved_configuration( ] if len(matching) != 1: raise RuntimeError( - "AVBD breakable-wall resolved configuration drifted: " + f"{solver_key.upper()} breakable-wall resolved configuration " + "drifted: " f"expected one {domain} {requested}->{resolved} note, " f"got {matching!r}" ) @@ -304,11 +306,16 @@ def compute_outcome_metrics( joints: Sequence[sx.Joint], balls: Sequence[sx.RigidBody], initial_brick_positions: np.ndarray, + solver_family: str = "avbd", + outcome_oracle: dict[str, Any] = OUTCOME_ORACLE, ) -> dict[str, Any]: """Evaluate the fail-closed Figure 13 outcome oracle at the current state.""" brick_positions = _positions(bricks) ball_positions = _positions(balls) displacements = np.linalg.norm(brick_positions - initial_brick_positions, axis=1) + wall_normal_displacements = np.abs( + brick_positions[:, 1] - initial_brick_positions[:, 1] + ) initial_xz = initial_brick_positions[:, (0, 2)] impact_band_displaced_counts = [] @@ -339,6 +346,9 @@ def compute_outcome_metrics( broken_joints = sum(1 for joint in joints if joint.is_broken) unbroken_joints = len(joints) - broken_joints frame = int(round(float(world.time) / _TIME_STEP)) + bent_brick_displacement_threshold = float( + outcome_oracle.get("bent_brick_displacement_threshold", 0.05) + ) finite_state = bool( np.all(np.isfinite(brick_positions)) and np.all(np.isfinite(ball_positions)) @@ -356,42 +366,101 @@ def compute_outcome_metrics( for body in (*bricks, *balls) ) ) - threshold_checks = { - "finite_state": finite_state, - "damage_in_three_impact_bands": all( - count - >= OUTCOME_ORACLE["minimum_displaced_bricks_per_impact_band"] - for count in impact_band_displaced_counts - ), - "outside_wall_retained": ( - outside_retained_fraction - >= OUTCOME_ORACLE["minimum_outside_retained_fraction"] - ), - "total_wall_retained": ( - total_retained_fraction - >= OUTCOME_ORACLE["minimum_total_retained_fraction"] - ), - "fracture_activated": ( - broken_joints >= OUTCOME_ORACLE["minimum_broken_joints"] - ), - "fracture_localized": ( - broken_joints <= OUTCOME_ORACLE["maximum_broken_joints"] - and unbroken_joints >= OUTCOME_ORACLE["minimum_unbroken_joints"] - ), - } - evaluated = frame >= _OUTCOME_FRAME + evaluation_frame = int(outcome_oracle["evaluation_frame"]) + checkpoint = "outcome" + if solver_family == "avbd": + threshold_checks = { + "finite_state": finite_state, + "damage_in_three_impact_bands": all( + count + >= outcome_oracle["minimum_displaced_bricks_per_impact_band"] + for count in impact_band_displaced_counts + ), + "outside_wall_retained": ( + outside_retained_fraction + >= outcome_oracle["minimum_outside_retained_fraction"] + ), + "total_wall_retained": ( + total_retained_fraction + >= outcome_oracle["minimum_total_retained_fraction"] + ), + "fracture_activated": ( + broken_joints >= outcome_oracle["minimum_broken_joints"] + ), + "fracture_localized": ( + broken_joints <= outcome_oracle["maximum_broken_joints"] + and unbroken_joints >= outcome_oracle["minimum_unbroken_joints"] + ), + } + evaluated = frame >= evaluation_frame + elif solver_family == "vbd": + retention_frame = int(outcome_oracle["retention_evaluation_frame"]) + if frame >= retention_frame: + checkpoint = "retention" + threshold_checks = { + "finite_state": finite_state, + "no_fracture": ( + broken_joints <= outcome_oracle["maximum_broken_joints"] + ), + "topology_retained": ( + unbroken_joints >= outcome_oracle["minimum_unbroken_joints"] + ), + "wall_retained": ( + total_retained_fraction + >= outcome_oracle["minimum_total_retained_fraction"] + ), + } + evaluated = True + else: + checkpoint = "bend" + threshold_checks = { + "finite_state": finite_state, + "no_fracture": ( + broken_joints <= outcome_oracle["maximum_broken_joints"] + ), + "topology_retained": ( + unbroken_joints >= outcome_oracle["minimum_unbroken_joints"] + ), + "wall_bends": ( + float(np.max(wall_normal_displacements)) + >= outcome_oracle[ + "minimum_maximum_wall_normal_displacement" + ] + ), + "wall_bend_is_distributed": ( + float(np.sqrt(np.mean(np.square(wall_normal_displacements)))) + >= outcome_oracle["minimum_rms_wall_normal_displacement"] + ), + "bend_is_spatially_resolved": ( + int( + np.count_nonzero( + wall_normal_displacements + >= outcome_oracle[ + "bent_brick_displacement_threshold" + ] + ) + ) + >= outcome_oracle["minimum_bent_bricks"] + ), + } + evaluated = frame == evaluation_frame + else: + raise ValueError(f"unsupported breakable-wall solver family: {solver_family}") thresholds_pass = evaluated and all(threshold_checks.values()) step_metrics = world.compute_step_metrics() return { "frame": frame, "world_time": float(world.time), "evaluated": evaluated, + "checkpoint": checkpoint, "status": ( "pass" if thresholds_pass else "fail" if evaluated else "pre-evaluation" + if frame < evaluation_frame + else "between-checkpoints" ), "thresholds_pass": thresholds_pass, "threshold_checks": threshold_checks, @@ -402,6 +471,18 @@ def compute_outcome_metrics( "broken_joints": broken_joints, "unbroken_joints": unbroken_joints, "max_brick_displacement": float(np.max(displacements)), + "maximum_wall_normal_displacement": float( + np.max(wall_normal_displacements) + ), + "rms_wall_normal_displacement": float( + np.sqrt(np.mean(np.square(wall_normal_displacements))) + ), + "bent_brick_count": int( + np.count_nonzero( + wall_normal_displacements + >= bent_brick_displacement_threshold + ) + ), "ball_positions": ball_positions.tolist(), "ball_velocities": [ np.asarray(ball.linear_velocity, dtype=float).reshape(3).tolist() @@ -412,11 +493,21 @@ def compute_outcome_metrics( } -def build() -> SceneSetup: +def build_solver_variant( + *, + rigid_body_solver: sx.RigidBodySolver, + solver_key: str, + solver_display: str, + paper_reference: dict[str, Any], + outcome_oracle: dict[str, Any], +) -> SceneSetup: + """Build one matched Figure 13 solver row from the shared reconstruction.""" + scene_id = f"{solver_key}_paper_breakable_wall" + object_prefix = f"{solver_key}_paper" world = sx.World( time_step=_TIME_STEP, gravity=(0.0, 0.0, _GRAVITY), - rigid_body_solver=sx.RigidBodySolver.AVBD, + rigid_body_solver=rigid_body_solver, rigid_constraint_options=sx.RigidConstraintOptions( iterations=_RIGID_CONSTRAINT_ITERATIONS ), @@ -424,7 +515,7 @@ def build() -> SceneSetup: ground = _add_box( world, - "avbd_paper_breakable_wall_ground", + f"{object_prefix}_breakable_wall_ground", size=_GROUND_SIZE, position=np.array([0.0, 0.0, -0.5 * float(_GROUND_SIZE[2])]), mass=None, @@ -439,7 +530,7 @@ def build() -> SceneSetup: for column in range(_WALL_COLUMNS): brick = _add_box( world, - f"avbd_paper_wall_brick_{row:02d}_{column:02d}", + f"{object_prefix}_wall_brick_{row:02d}_{column:02d}", size=_BRICK_SIZE, position=_brick_position(row, column), mass=brick_mass, @@ -456,7 +547,7 @@ def build() -> SceneSetup: joints.append( _add_breakable_fixed_joint( world, - f"avbd_paper_wall_horizontal_{row:02d}_{column:02d}", + f"{object_prefix}_wall_horizontal_{row:02d}_{column:02d}", left, right, ) @@ -480,7 +571,7 @@ def build() -> SceneSetup: joints.append( _add_breakable_fixed_joint( world, - "avbd_paper_wall_vertical_" + f"{object_prefix}_wall_vertical_" f"{row - 1:02d}_{lower_column:02d}_" f"{row:02d}_{upper_column:02d}", lower_brick, @@ -499,7 +590,7 @@ def build() -> SceneSetup: joints.append( _add_breakable_fixed_joint( world, - f"avbd_paper_wall_base_{column:02d}", + f"{object_prefix}_wall_base_{column:02d}", ground, brick, ) @@ -512,7 +603,7 @@ def build() -> SceneSetup: ) for index, (target_x, target_z) in enumerate(_IMPACT_TARGETS_XZ): ball = world.add_rigid_body( - f"avbd_paper_wall_ball_{index}", + f"{object_prefix}_wall_ball_{index}", position=(target_x, _BALL_START_Y, target_z), linear_velocity=(0.0, _BALL_LAUNCH_SPEED, 0.0), ) @@ -532,7 +623,9 @@ def build() -> SceneSetup: initial_brick_positions = _positions(bricks) world.enter_simulation_mode() - resolved_configuration = _serialize_and_validate_resolved_configuration(world) + resolved_configuration = _serialize_and_validate_resolved_configuration( + world, solver_key + ) scene_spec_fingerprint = _scene_spec_fingerprint(topology_records) view_assessment_available = _view_assessment_available() capture_camera = ( @@ -555,12 +648,12 @@ def build() -> SceneSetup: ) ) - bridge = WorldRenderBridge(world, name="avbd_paper_breakable_wall_render") + bridge = WorldRenderBridge(world, name=f"{scene_id}_render") bridge.add_rigid_body_visual( ground, dart.BoxShape(_GROUND_SIZE), (0.40, 0.42, 0.45), - name="avbd_paper_breakable_wall_ground_visual", + name=f"{object_prefix}_breakable_wall_ground_visual", ) brick_colors = ( (0.72, 0.25, 0.13), @@ -603,6 +696,8 @@ def sample_metrics() -> dict[str, Any]: joints=joints, balls=balls, initial_brick_positions=initial_brick_positions, + solver_family=solver_key, + outcome_oracle=outcome_oracle, ) def record_metrics() -> dict[str, Any]: @@ -626,9 +721,19 @@ def capture_metrics() -> dict[str, Any]: if note["domain"] == "rigid-body" ) view_report = None + assessed_frames = { + 60, + int(outcome_oracle["evaluation_frame"]), + int( + outcome_oracle.get( + "retention_evaluation_frame", + outcome_oracle["evaluation_frame"], + ) + ), + } if ( view_assessment_available - and metrics["frame"] in (60, _OUTCOME_FRAME) + and metrics["frame"] in assessed_frames ): # capture_metrics() is also a public text-oracle callback, so it # can be invoked after direct World.step(n=...) calls that bypass @@ -643,15 +748,16 @@ def capture_metrics() -> dict[str, Any]: ) if not assessed_view.acceptable: raise RuntimeError( - "AVBD breakable-wall capture view failed assessment: " + f"{solver_display} breakable-wall capture view failed " + "assessment: " f"{assessed_view.issues!r}" ) view_report = assessed_view.to_json() return { - "row": "avbd_paper_breakable_wall", + "row": scene_id, "solver": f"public_{resolved_rigid_body}", "executor": "World.step default", - "paper_locator": PAPER_REFERENCE["source_locator"], + "paper_locator": paper_reference["source_locator"], "time_step_ms": 1000.0 * _TIME_STEP, "rigid_constraint_options": { "iterations": int(rigid_constraint_options.iterations) @@ -667,7 +773,7 @@ def capture_metrics() -> dict[str, Any]: "ball_count": len(balls), "breakable_joints": len(joints), "break_force": _BREAK_FORCE, - "outcome_oracle": dict(OUTCOME_ORACLE), + "outcome_oracle": dict(outcome_oracle), "outcome": metrics, } @@ -688,7 +794,7 @@ def build_panel(builder: object, context: object) -> None: str(value) for value in metrics["impact_band_displaced_counts"] ) builder.text("paper: Giles et al. 2025, Figure 13") - builder.text("solver: public rigid AVBD") + builder.text(f"solver: public rigid {solver_display}") builder.text(f"iterations: {_RIGID_CONSTRAINT_ITERATIONS}") builder.text(f"world time: {world.time:.3f} s") builder.text(f"bricks / breakable joints: {len(bricks)} / {len(joints)}") @@ -717,7 +823,7 @@ def build_panel(builder: object, context: object) -> None: world=bridge.render_world, pre_step=pre_step, force_drag=bridge.force_drag, - panels=[ScenePanel("AVBD Paper Breakable Wall", build_panel)], + panels=[ScenePanel(f"{solver_display} Paper Breakable Wall", build_panel)], info={ "sx_world": world, "ground": ground, @@ -725,8 +831,8 @@ def build_panel(builder: object, context: object) -> None: "balls": tuple(balls), "joints": tuple(joints), "initial_brick_positions": initial_brick_positions.copy(), - "paper_reference": PAPER_REFERENCE, - "outcome_oracle": OUTCOME_ORACLE, + "paper_reference": paper_reference, + "outcome_oracle": outcome_oracle, "resolved_configuration": tuple(resolved_configuration), "scene_spec_fingerprint": scene_spec_fingerprint, "outcome_metrics": sample_metrics, @@ -737,6 +843,16 @@ def build_panel(builder: object, context: object) -> None: ) +def build() -> SceneSetup: + return build_solver_variant( + rigid_body_solver=sx.RigidBodySolver.AVBD, + solver_key="avbd", + solver_display="AVBD", + paper_reference=PAPER_REFERENCE, + outcome_oracle=OUTCOME_ORACLE, + ) + + SCENE = PythonDemoScene( id="avbd_paper_breakable_wall", title="AVBD Paper Breakable Wall (sx)", diff --git a/python/examples/demos/scenes/vbd_paper_breakable_wall.py b/python/examples/demos/scenes/vbd_paper_breakable_wall.py new file mode 100644 index 0000000000000..ed6d4c5851abd --- /dev/null +++ b/python/examples/demos/scenes/vbd_paper_breakable_wall.py @@ -0,0 +1,54 @@ +"""Matched fixed-penalty VBD row for the Figure 13 breakable wall.""" + +from __future__ import annotations + +from typing import Any + +import dartpy as sx + +from ..runner import PythonDemoScene, SceneSetup +from .avbd_paper_breakable_wall import ( + PAPER_REFERENCE as _SHARED_PAPER_REFERENCE, + build_solver_variant, +) + +PAPER_REFERENCE: dict[str, Any] = { + **_SHARED_PAPER_REFERENCE, + "published_facts": { + **_SHARED_PAPER_REFERENCE["published_facts"], + "vbd_outcome": "the wall bends under impact but does not break", + }, + "dart_reconstruction": dict(_SHARED_PAPER_REFERENCE["dart_reconstruction"]), +} + +OUTCOME_ORACLE: dict[str, Any] = { + "evaluation_frame": 14, + "retention_evaluation_frame": 120, + "maximum_broken_joints": 0, + "minimum_unbroken_joints": 712, + "minimum_maximum_wall_normal_displacement": 0.20, + "minimum_rms_wall_normal_displacement": 0.10, + "bent_brick_displacement_threshold": 0.10, + "minimum_bent_bricks": 100, + "minimum_total_retained_fraction": 0.99, +} + + +def build() -> SceneSetup: + return build_solver_variant( + rigid_body_solver=sx.RigidBodySolver.VBD, + solver_key="vbd", + solver_display="VBD", + paper_reference=PAPER_REFERENCE, + outcome_oracle=OUTCOME_ORACLE, + ) + + +SCENE = PythonDemoScene( + id="vbd_paper_breakable_wall", + title="VBD Paper Breakable Wall (sx)", + category="VBD Rigid Constraints (sx)", + summary="The matched fixed-penalty Figure 13 wall row: 20 sweeps, retained " + "topology, and a quantitative bending oracle.", + build=build, +) diff --git a/python/stubs/dartpy/simulation.pyi b/python/stubs/dartpy/simulation.pyi index b6faf20c64857..dc19fd3408cca 100644 --- a/python/stubs/dartpy/simulation.pyi +++ b/python/stubs/dartpy/simulation.pyi @@ -165,6 +165,8 @@ class RigidBodySolver(enum.Enum): AVBD = 2 + VBD = 3 + class ContactSolverMethod(enum.Enum): SEQUENTIAL_IMPULSE = 0 diff --git a/python/tests/integration/test_demos_cycle.py b/python/tests/integration/test_demos_cycle.py index 4e1cee4cb7a6d..33eb1bad79a9a 100644 --- a/python/tests/integration/test_demos_cycle.py +++ b/python/tests/integration/test_demos_cycle.py @@ -947,6 +947,9 @@ def test_world_scenes_use_solver_focused_categories() -> None: "avbd_articulated_spherical_breakable_joint", "avbd_articulated_spherical_pair_breakable_joint", }, + "VBD Rigid Constraints (sx)": { + "vbd_paper_breakable_wall", + }, "Planned World Ports": { "planned_inverse_kinematics", "planned_simbicon_walking", @@ -10472,6 +10475,141 @@ def test_avbd_paper_breakable_wall_outcome_is_deterministic() -> None: ] == [joint.is_broken for joint in setups[1].info["joints"]] +def test_vbd_paper_breakable_wall_matches_figure13_contract() -> None: + sx = _require_simulation_experimental_symbols("World") + + from examples.demos.scenes.avbd_paper_breakable_wall import ( + build as build_avbd, + ) + from examples.demos.scenes.vbd_paper_breakable_wall import ( + OUTCOME_ORACLE, + build, + ) + + setup = build() + sx_world = setup.info["sx_world"] + reference = setup.info["paper_reference"] + + assert sx_world.rigid_body_solver == sx.RigidBodySolver.VBD + assert sx_world.rigid_constraint_options.iterations == 20 + assert sx_world.time_step == pytest.approx(1.0 / 60.0) + assert sx_world.num_rigid_bodies == 256 + assert sx_world.num_joints == 712 + assert reference["published_facts"]["vbd_outcome"] == ( + "the wall bends under impact but does not break" + ) + assert ( + setup.info["scene_spec_fingerprint"] + == build_avbd().info["scene_spec_fingerprint"] + ) + + sx_world.step(n=OUTCOME_ORACLE["evaluation_frame"]) + bend = setup.info["outcome_metrics"]() + assert bend["frame"] == OUTCOME_ORACLE["evaluation_frame"] + assert bend["checkpoint"] == "bend" + assert bend["status"] == "pass" + assert bend["thresholds_pass"] is True + assert all(bend["threshold_checks"].values()) + assert bend["broken_joints"] == 0 + assert bend["unbroken_joints"] == 712 + assert ( + bend["maximum_wall_normal_displacement"] + >= OUTCOME_ORACLE["minimum_maximum_wall_normal_displacement"] + ) + assert ( + bend["rms_wall_normal_displacement"] + >= OUTCOME_ORACLE["minimum_rms_wall_normal_displacement"] + ) + assert bend["bent_brick_count"] >= OUTCOME_ORACLE["minimum_bent_bricks"] + assert bend["last_step_iterations"] == 20 + + bend_capture = setup.info[CAPTURE_METRICS_INFO_KEY]() + assert bend_capture["solver"] == "public_vbd" + assert bend_capture["rigid_body_solver"] == "VBD" + assert bend_capture["outcome"]["checkpoint"] == "bend" + assert bend_capture["outcome"]["thresholds_pass"] is True + + sx_world.step( + n=( + OUTCOME_ORACLE["retention_evaluation_frame"] + - OUTCOME_ORACLE["evaluation_frame"] + ) + ) + retention = setup.info["outcome_metrics"]() + assert retention["frame"] == OUTCOME_ORACLE["retention_evaluation_frame"] + assert retention["checkpoint"] == "retention" + assert retention["status"] == "pass" + assert retention["thresholds_pass"] is True + assert all(retention["threshold_checks"].values()) + assert retention["broken_joints"] == 0 + assert retention["unbroken_joints"] == 712 + assert ( + retention["total_retained_fraction"] + >= OUTCOME_ORACLE["minimum_total_retained_fraction"] + ) + + +def test_vbd_paper_breakable_wall_checkpoints_are_deterministic() -> None: + import numpy as np + + _require_simulation_experimental_symbols("World") + + from examples.demos.scenes.vbd_paper_breakable_wall import ( + OUTCOME_ORACLE, + build, + ) + + setups = [build(), build()] + for setup in setups: + setup.info["sx_world"].step(n=OUTCOME_ORACLE["evaluation_frame"]) + + bend_outcomes = [setup.info["outcome_metrics"]() for setup in setups] + for key in ( + "broken_joints", + "unbroken_joints", + "maximum_wall_normal_displacement", + "rms_wall_normal_displacement", + "bent_brick_count", + "last_step_iterations", + "contact_count", + ): + assert bend_outcomes[0][key] == pytest.approx(bend_outcomes[1][key]) + + for setup in setups: + setup.info["sx_world"].step( + n=( + OUTCOME_ORACLE["retention_evaluation_frame"] + - OUTCOME_ORACLE["evaluation_frame"] + ) + ) + retention_outcomes = [setup.info["outcome_metrics"]() for setup in setups] + for key in ( + "broken_joints", + "unbroken_joints", + "total_retained_fraction", + "last_step_iterations", + "contact_count", + ): + assert retention_outcomes[0][key] == pytest.approx( + retention_outcomes[1][key] + ) + + for collection in ("bricks", "balls"): + positions = [ + np.asarray( + [ + np.asarray(body.translation, dtype=float).reshape(3) + for body in setup.info[collection] + ] + ) + for setup in setups + ] + np.testing.assert_allclose(positions[0], positions[1], rtol=0.0, atol=1.0e-12) + assert [ + joint.is_broken for joint in setups[0].info["joints"] + ] == [joint.is_broken for joint in setups[1].info["joints"]] + + def _fixed_chain_anchor_errors(chain: tuple[Any, ...]) -> list[float]: import numpy as np diff --git a/python/tests/unit/simulation/test_world.py b/python/tests/unit/simulation/test_world.py index c8638d8f2febe..78e4c091209d3 100644 --- a/python/tests/unit/simulation/test_world.py +++ b/python/tests/unit/simulation/test_world.py @@ -8520,6 +8520,37 @@ def test_simulation_public_avbd_contact_family(): avbd_only.add_multibody("unsupported") +def test_simulation_public_vbd_contact_family(): + sx = _simulation() + + world = sx.World( + time_step=0.05, + gravity=(0.0, 0.0, 0.0), + rigid_body_solver=sx.RigidBodySolver.VBD, + rigid_constraint_options=sx.RigidConstraintOptions(iterations=20), + ) + ground = world.add_rigid_body("ground", position=(0.0, 0.0, -0.25)) + ground.is_static = True + ground.set_collision_shape(sx.CollisionShape.box((2.0, 2.0, 0.25))) + sphere = world.add_rigid_body("sphere", position=(0.0, 0.0, 0.4)) + sphere.set_collision_shape(sx.CollisionShape.sphere(0.5)) + + assert world.rigid_body_solver == sx.RigidBodySolver.VBD + assert len(world.collide()) == 1 + world.step() + + assert world.compute_step_metrics().last_step_iterations == 20 + assert sphere.linear_velocity.tolist()[2] > 0.0 + assert sphere.translation.tolist()[2] > 0.4 + + with pytest.raises(Exception, match="cannot be combined"): + world.contact_solver_method = sx.ContactSolverMethod.BOXED_LCP + + vbd_only = sx.World(rigid_body_solver=sx.RigidBodySolver.VBD) + with pytest.raises(Exception, match="Multibody"): + vbd_only.add_multibody("unsupported") + + def test_simulation_body_rests_on_static_ground(): sx = _simulation() diff --git a/python/tests/unit/test_benchmark_display_names.py b/python/tests/unit/test_benchmark_display_names.py index cf99fa7aa7c80..034f8c94096a0 100644 --- a/python/tests/unit/test_benchmark_display_names.py +++ b/python/tests/unit/test_benchmark_display_names.py @@ -72,6 +72,10 @@ def test_humanize_new_solver_surfaces(): module.humanize_name("BM_AvbdEmptyWorldStep") == "AVBD empty baseline step" ) + assert ( + module.humanize_name("BM_VbdPaperBreakableWallStep") + == "VBD paper breakable-wall step" + ) assert ( module.humanize_name("BM_AvbdRigidFixedJointStep/8") == "AVBD fixed-joint step · 8 links" @@ -301,6 +305,9 @@ def test_family_grouping(): assert module.family_of("BM_VbdWorldStepDefault/8") == module.FAMILY_VBD assert module.family_of("BM_DeformableFemBarStep/2") == module.FAMILY_FEM assert module.family_of("BM_AvbdEmptyWorldStep") == module.FAMILY_AVBD + assert ( + module.family_of("BM_VbdPaperBreakableWallStep") == module.FAMILY_RIGID + ) assert ( module.family_of("BM_AvbdPaperScaleHighRatioChainIterationSweep/100") == module.FAMILY_AVBD diff --git a/python/tests/unit/test_write_avbd_paper_vbd_comparison_packet.py b/python/tests/unit/test_write_avbd_paper_vbd_comparison_packet.py new file mode 100644 index 0000000000000..f856a37e41e74 --- /dev/null +++ b/python/tests/unit/test_write_avbd_paper_vbd_comparison_packet.py @@ -0,0 +1,679 @@ +from __future__ import annotations + +from hashlib import sha256 +import importlib.util +import json +from pathlib import Path +import struct +import sys +from typing import Any +import zlib + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "write_avbd_paper_vbd_comparison_packet.py" + + +def _load_packet_module(): + spec = importlib.util.spec_from_file_location( + "write_avbd_paper_vbd_comparison_packet", + SCRIPT, + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _write_png(path: Path, width: int = 8, height: int = 6) -> None: + def chunk(kind: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + kind + + payload + + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF) + ) + + rows = b"".join( + b"\x00" + bytes((32 + row, 64, 128)) * width + for row in range(height) + ) + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + chunk( + b"IHDR", + struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0), + ) + + chunk(b"IDAT", zlib.compress(rows)) + + chunk(b"IEND", b"") + ) + + +def _sha256(path: Path) -> str: + return sha256(path.read_bytes()).hexdigest() + + +def _source_provenance(module) -> dict[str, Any]: + combined = sha256() + files = [] + for relative in module.shared.SOURCE_PATHS: + path = module.shared.REPO_ROOT / relative + payload = path.read_bytes() + encoded_path = relative.as_posix().encode("utf-8") + combined.update(struct.pack(" dict[str, Any]: + generic = { + "ball_positions": [[0.0, 1.0, 2.0]] * 3, + "ball_velocities": [[3.0, 4.0, 5.0]] * 3, + "bent_brick_count": 0, + "broken_joints": 0, + "checkpoint": "bend", + "contact_count": 0, + "evaluated": False, + "frame": frame, + "impact_band_displaced_counts": [0, 0, 0], + "last_step_iterations": 20, + "max_brick_displacement": 0.0, + "maximum_wall_normal_displacement": 0.0, + "outside_brick_count": 181, + "outside_retained_fraction": 1.0, + "rms_wall_normal_displacement": 0.0, + "status": "pre-evaluation", + "threshold_checks": {"finite_state": True}, + "thresholds_pass": False, + "total_retained_fraction": 1.0, + "unbroken_joints": 712, + "world_time": frame / 60.0, + } + if frame == 14: + return generic | { + "bent_brick_count": 131, + "contact_count": 26, + "evaluated": True, + "max_brick_displacement": 0.22961765643338428, + "maximum_wall_normal_displacement": 0.22961612378147464, + "rms_wall_normal_displacement": 0.12274369318326252, + "status": "pass", + "threshold_checks": dict( + module.CHECKPOINTS[14]["threshold_checks"] + ), + "thresholds_pass": True, + } + if frame == 120: + return generic | { + "checkpoint": "retention", + "evaluated": True, + "max_brick_displacement": 0.0012, + "maximum_wall_normal_displacement": 0.00086, + "rms_wall_normal_displacement": 0.00047, + "status": "pass", + "threshold_checks": dict( + module.CHECKPOINTS[120]["threshold_checks"] + ), + "thresholds_pass": True, + } + return generic + + +def _metrics(module, frame: int) -> dict[str, Any]: + return { + "ball_count": 3, + "break_force": 8500.0, + "breakable_joints": 712, + "brick_count": 252, + "collision_shapes": 256, + "executor": "World.step default", + "outcome": _outcome(module, frame), + "outcome_oracle": dict(module.OUTCOME_ORACLE), + "paper_locator": module.PAPER_LOCATOR, + "rigid_bodies": 256, + "rigid_body_solver": "VBD", + "rigid_constraint_options": {"iterations": 20}, + "resolved_configuration": [ + { + "domain": "rigid-body", + "reason": "as requested", + "requested": "vbd", + "resolved": "vbd", + }, + { + "domain": "rigid-contact", + "reason": "as requested", + "requested": "vbd", + "resolved": "vbd", + }, + { + "domain": "rigid-pair-constraint", + "reason": "as requested", + "requested": "vbd", + "resolved": "vbd", + }, + { + "domain": "rigid-constraint-iterations", + "reason": "public rigid constraint iteration budget", + "requested": "20", + "resolved": "20", + }, + ], + "row": module.SCENE_ID, + "scene_spec_fingerprint": "0123456789abcdef", + "solver": "public_vbd", + "time_step_ms": 1000.0 / 60.0, + "view_report": { + "camera": { + "azimuth": -1.5707963267948966, + "distance": 11.0, + "elevation": 0.0, + "target": [0.0, 0.0, 1.4], + }, + "focus": list(module.VIEW_FOCUS), + "issues": [], + "metrics": { + "ambiguity_iou": 0.0, + "center_visible": True, + "corner_coverage": 1.0, + "occlusion_fraction": 0.0, + "subject_fraction": 0.5, + }, + "pass": True, + "schema_version": "dart.view_report/v1", + "score": 1.0, + "size": [8, 6], + }, + } + + +def _write_capture( + module, + tmp_path: Path, + frame: int, + label: str, +) -> tuple[Path, Path]: + directory = tmp_path / label + directory.mkdir() + screenshot = directory / f"{module.SCENE_ID}_{label}.png" + _write_png(screenshot) + metrics_log = directory / "scene_metrics.jsonl" + events = [ + { + "event": "scene_capture_metrics", + "frame": event_frame, + "metrics": _metrics(module, event_frame), + "scene": module.SCENE_ID, + "source": "py-demo-scene", + } + for event_frame in range(1, frame + 1) + ] + metrics_log.write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + manifest = { + "artifacts": { + "events": None, + "frames": str(directory / "png_frames"), + "scene_metrics_events": str(metrics_log), + "screenshot": str(screenshot), + "video": None, + }, + "camera": { + "distance": 11.0, + "target": [0.0, 0.0, 1.4], + "view": "front", + }, + "capture": { + "converted_frames": frame, + "height": 6, + "requested_frames": frame, + "width": 8, + }, + "capture_label": label, + "force_drag": None, + "resolved_solver_identity": { + "executor": "World.step default", + "solver": "public_vbd", + "source": "scene_capture_metrics.latest.metrics", + }, + "scene": module.SCENE_ID, + "scene_metrics": { + "event_count": frame, + "latest": events[-1], + }, + "schema_version": 1, + } + manifest_path = directory / "manifest.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + verdict = { + "checks": {"non_blank": {"pass": True}}, + "image": { + "height": 6, + "path": str(screenshot), + "width": 8, + }, + "machine_scope": "pixel-integrity", + "metadata": { + "frame": str(frame), + "scene": module.SCENE_ID, + "view": "front", + }, + "pass": True, + "schema_version": "dart.image_verdict/v1", + "semantic_review": { + "performed_by_this_tool": False, + "required": True, + }, + } + verdict_path = directory / "image_verdict.json" + verdict_path.write_text(json.dumps(verdict), encoding="utf-8") + return manifest_path, verdict_path + + +def _write_benchmark(module, tmp_path: Path) -> Path: + fingerprint = int("0123456789abcdef", 16) + rows = [] + for solver_key in ("avbd", "vbd"): + run = module.BENCHMARK_RUNS[solver_key] + scale = 1.0 if solver_key == "avbd" else 1.25 + for aggregate, real_time, cpu_time in ( + ("mean", 7_800_000.0 * scale, 7_790_000.0 * scale), + ("median", 7_750_000.0 * scale, 7_740_000.0 * scale), + ("stddev", 110_000.0, 109_000.0), + ("cv", 0.014, 0.014), + ): + row = { + "aggregate_name": aggregate, + "aggregate_unit": ( + "percentage" if aggregate == "cv" else "time" + ), + "cpu_time": cpu_time, + "iterations": 5, + "name": f"{run}_{aggregate}", + "real_time": real_time, + "repetitions": 5, + "run_name": run, + "run_type": "aggregate", + "time_unit": "ns", + } + if aggregate in ("mean", "median"): + row.update( + { + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "impacting_balls": 3.0, + f"public_{solver_key}_family": 1.0, + f"resolved_rigid_body_{solver_key}": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + f"resolved_rigid_contact_{solver_key}": 1.0, + ( + f"resolved_rigid_pair_constraint_{solver_key}" + ): 1.0, + "rigid_constraint_iterations": 20.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": float( + fingerprint >> 32 + ), + "scene_spec_fingerprint_lo": float( + fingerprint & 0xFFFFFFFF + ), + "trajectory_frames": 120.0, + } + ) + rows.append(row) + path = tmp_path / "benchmark.json" + path.write_text( + json.dumps( + { + "benchmarks": rows, + "context": { + "executable": ( + "build/default/cpp/Release/bin/" + "bm_avbd_rigid_fixed_joint" + ), + "host_name": "test-host", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "test", + "mhz_per_cpu": 5000, + "num_cpus": 8, + }, + } + ), + encoding="utf-8", + ) + return path + + +def _write_inputs(module, tmp_path: Path, monkeypatch) -> dict[str, Path]: + bend_manifest, bend_verdict = _write_capture( + module, + tmp_path, + 14, + "bend", + ) + retention_manifest, retention_verdict = _write_capture( + module, + tmp_path, + 120, + "retention", + ) + benchmark = _write_benchmark(module, tmp_path) + + paper_pdf = tmp_path / "avbd.pdf" + paper_pdf.write_bytes(b"test AVBD paper") + paper_figure = tmp_path / "paper-10.png" + _write_png(paper_figure, width=10, height=12) + monkeypatch.setattr( + module.shared, + "PAPER_PDF_SHA256", + _sha256(paper_pdf), + ) + monkeypatch.setattr( + module.shared, + "PAPER_FIGURE_SHA256", + _sha256(paper_figure), + ) + + bend_screenshot = ( + bend_manifest.parent / f"{module.SCENE_ID}_bend.png" + ) + retention_screenshot = ( + retention_manifest.parent / f"{module.SCENE_ID}_retention.png" + ) + review = { + "claim_and_expected_observation": "VBD bends without fracture.", + "inspected_images": [ + { + "file": str(bend_screenshot), + "role": "bend_frame_14", + "sha256": _sha256(bend_screenshot), + }, + { + "file": str(retention_screenshot), + "role": "retention_frame_120", + "sha256": _sha256(retention_screenshot), + }, + { + "file": str(paper_figure), + "role": "paper_figure_13_reference", + "sha256": _sha256(paper_figure), + }, + ], + "not_proven_and_limitations": ( + "Exact unpublished constants, Sequential Impulse, XPBD, and CUDA " + "remain unproven." + ), + "reconciliation_and_verdict": ( + "PASS: both ViewReports and both numeric checkpoints pass." + ), + "reviewer_capability": "native image input at original detail", + "scene": module.SCENE_ID, + "schema_version": "dart.visual_semantic_review/v1", + "text_oracle": "Figure 13 says VBD bends but does not break.", + "verdict": "pass", + "visible_observation": "The wall bends and later remains intact.", + } + review_path = tmp_path / "visual_review.json" + review_path.write_text(json.dumps(review), encoding="utf-8") + + impact_hash = "1" * 64 + outcome_hash = "2" * 64 + semantic_hash = "3" * 64 + paper_figure_hash = _sha256(paper_figure) + avbd_visual = { + "impact": { + "image_verdict": {"pass": True, "sha256": "4" * 64}, + "scene_metrics": { + "frame": 60, + "outcome": dict(module.shared.EXPECTED_OUTCOMES[60]) + | { + "frame": 60, + "last_step_iterations": 20, + }, + "outcome_oracle": dict(module.shared.OUTCOME_ORACLE), + "scene_spec_fingerprint": "0123456789abcdef", + }, + "screenshot": {"file": "impact.png", "sha256": impact_hash}, + }, + "outcome": { + "image_verdict": {"pass": True, "sha256": "5" * 64}, + "scene_metrics": { + "frame": 120, + "outcome": dict(module.shared.EXPECTED_OUTCOMES[120]) + | { + "frame": 120, + "last_step_iterations": 20, + }, + "outcome_oracle": dict(module.shared.OUTCOME_ORACLE), + "scene_spec_fingerprint": "0123456789abcdef", + }, + "screenshot": {"file": "outcome.png", "sha256": outcome_hash}, + }, + "semantic_review": { + "file": "review.json", + "inspected_images": [ + { + "file": "impact.png", + "role": "impact_frame_60", + "sha256": impact_hash, + }, + { + "file": "outcome.png", + "role": "outcome_frame_120", + "sha256": outcome_hash, + }, + { + "file": paper_figure.name, + "role": "paper_figure_13_reference", + "sha256": paper_figure_hash, + }, + ], + "sha256": semantic_hash, + "verdict": "pass", + }, + } + avbd_packet = { + "benchmark": { + "json_sha256": _sha256(benchmark), + "scene_spec_fingerprint": "0123456789abcdef", + }, + "correctness": { + "determinism_test": ( + "test_avbd_paper_breakable_wall_outcome_is_deterministic" + ), + "outcome_test": ( + "test_avbd_paper_breakable_wall_matches_figure13_contract" + ), + }, + "packet": "avbd_paper_breakable_wall", + "paper_reference": { + "figure": { + "file": paper_figure.name, + "sha256": paper_figure_hash, + } + }, + "resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": "test", + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "avbd", + "rigid_point_joint_solver": "avbd", + }, + "scene": module.AVBD_SCENE_ID, + "schema_version": module.AVBD_PACKET_SCHEMA_VERSION, + "source_provenance": _source_provenance(module), + "visual_evidence": avbd_visual, + } + avbd_packet_path = tmp_path / "avbd-packet.json" + avbd_packet_path.write_text(json.dumps(avbd_packet), encoding="utf-8") + + return { + "benchmark_json": benchmark, + "bend_capture_manifest": bend_manifest, + "bend_image_verdict_json": bend_verdict, + "retention_capture_manifest": retention_manifest, + "retention_image_verdict_json": retention_verdict, + "visual_review_json": review_path, + "paper_pdf": paper_pdf, + "paper_figure_image": paper_figure, + "avbd_packet": avbd_packet_path, + } + + +def test_make_packet_records_matched_vbd_and_avbd_evidence( + tmp_path: Path, + monkeypatch, +) -> None: + module = _load_packet_module() + inputs = _write_inputs(module, tmp_path, monkeypatch) + + packet = module.make_packet(**inputs) + + assert packet["schema_version"] == 3 + assert packet["resolved_solver_identity"]["rigid_contact_solver"] == "vbd" + assert packet["linked_avbd_evidence"]["resolved_solver_identity"][ + "rigid_contact_solver" + ] == "avbd" + assert ( + packet["visual_evidence"]["bend"]["scene_metrics"]["outcome"][ + "bent_brick_count" + ] + == 131 + ) + assert packet["visual_evidence"]["retention"]["scene_metrics"]["outcome"][ + "unbroken_joints" + ] == 712 + assert packet["benchmark"]["comparison"][ + "vbd_to_avbd_median_cpu_cost_ratio" + ] == pytest.approx(1.25) + assert packet["correctness"]["determinism_test"] == ( + "test_vbd_paper_breakable_wall_checkpoints_are_deterministic" + ) + assert packet["publication_observation"].endswith("does not break.") + assert packet["target"]["contract_rows"] == ["avbd.paper.fig.13"] + assert packet["target"]["complete_paper_reproduction"] is False + provenance_paths = { + entry["path"] for entry in packet["source_provenance"]["files"] + } + assert { + "dart/simulation/detail/world_step_schedule.hpp", + "python/tests/integration/test_demos_cycle.py", + "tests/unit/simulation/world/test_world.cpp", + } <= provenance_paths + assert str(tmp_path) not in json.dumps(packet) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("solver", "capture resolved_solver_identity"), + ("fracture", "broken_joints"), + ("benchmark", "expected aggregates"), + ("linked_hash", "linked AVBD benchmark JSON hash"), + ("linked_outcome", "frame 120 outcome status"), + ("linked_screenshot", "semantic review impact_frame_60 sha256"), + ("linked_review_hash", "semantic review sha256"), + ("limitations", "limitations must name xpbd"), + ], +) +def test_make_packet_fails_closed_on_mismatched_comparison_evidence( + tmp_path: Path, + monkeypatch, + mutation: str, + message: str, +) -> None: + module = _load_packet_module() + inputs = _write_inputs(module, tmp_path, monkeypatch) + + if mutation == "solver": + path = inputs["bend_capture_manifest"] + data = json.loads(path.read_text()) + data["resolved_solver_identity"]["solver"] = "public_avbd" + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "fracture": + path = inputs["bend_capture_manifest"] + data = json.loads(path.read_text()) + data["scene_metrics"]["latest"]["metrics"]["outcome"][ + "broken_joints" + ] = 1 + path.write_text(json.dumps(data), encoding="utf-8") + events_path = Path(data["artifacts"]["scene_metrics_events"]) + events = [ + json.loads(line) + for line in events_path.read_text().splitlines() + if line + ] + events[-1]["metrics"]["outcome"]["broken_joints"] = 1 + events_path.write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + elif mutation == "benchmark": + path = inputs["benchmark_json"] + data = json.loads(path.read_text()) + data["benchmarks"] = [ + row + for row in data["benchmarks"] + if not row["run_name"].startswith("BM_Avbd") + ] + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "linked_hash": + path = inputs["avbd_packet"] + data = json.loads(path.read_text()) + data["benchmark"]["json_sha256"] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "linked_outcome": + path = inputs["avbd_packet"] + data = json.loads(path.read_text()) + data["visual_evidence"]["outcome"]["scene_metrics"]["outcome"][ + "status" + ] = "fail" + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "linked_screenshot": + path = inputs["avbd_packet"] + data = json.loads(path.read_text()) + data["visual_evidence"]["impact"]["screenshot"]["sha256"] = "6" * 64 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "linked_review_hash": + path = inputs["avbd_packet"] + data = json.loads(path.read_text()) + data["visual_evidence"]["semantic_review"]["sha256"] = "replaced" + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "limitations": + path = inputs["visual_review_json"] + data = json.loads(path.read_text()) + data["not_proven_and_limitations"] = ( + "Exact unpublished constants, Sequential Impulse, and CUDA remain " + "unproven." + ) + path.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises( + ( + module.AvbdPaperVbdComparisonPacketError, + module.shared.AvbdPaperBreakableWallPacketError, + ), + match=message, + ): + module.make_packet(**inputs) diff --git a/scripts/avbd_packet_schema.py b/scripts/avbd_packet_schema.py index 1ff83bfecc177..79c6e015dc3b7 100644 --- a/scripts/avbd_packet_schema.py +++ b/scripts/avbd_packet_schema.py @@ -36,18 +36,20 @@ RIGID_CONTACT_SELECTION_KEY = "rigid_contact_selection" # The contact path that actually resolved rigid-rigid contacts in the timed -# scene. "avbd" may be selected by the public world solver family or by the -# compatibility-only internal body opt-in; contact-free scenes record "none". +# scene. "vbd" and "avbd" may be selected by their public world solver +# families; "avbd" may also be selected by the compatibility-only internal +# body opt-in. Contact-free scenes record "none". ALLOWED_RIGID_CONTACT_SOLVERS = ( "avbd", "boxed_lcp", "none", "sequential_impulse", + "vbd", ) # The solver family that resolved rigid-body point-joint/motor/distance-spring # rows; joint-free scenes record "none". -ALLOWED_RIGID_POINT_JOINT_SOLVERS = ("avbd", "none") +ALLOWED_RIGID_POINT_JOINT_SOLVERS = ("avbd", "none", "vbd") ALLOWED_RIGID_CONTACT_SELECTIONS = ( "body_opt_in", @@ -145,6 +147,14 @@ def resolved_solver_identity_errors( ) rigid_contact_solver = identity.get("rigid_contact_solver") + rigid_point_joint_solver = identity.get("rigid_point_joint_solver") + if version < RIGID_CONTACT_SELECTION_MIN_SCHEMA_VERSION and ( + rigid_contact_solver == "vbd" or rigid_point_joint_solver == "vbd" + ): + errors.append( + f"{packet_name}: VBD solver identities require schema_version " + f"{RIGID_CONTACT_SELECTION_MIN_SCHEMA_VERSION} or newer" + ) if selection is None: if ( isinstance(emplaced, bool) @@ -163,12 +173,26 @@ def resolved_solver_identity_errors( "rigid_contact_selection 'body_opt_in' requires " "rigid_contact_solver 'avbd' and an emplaced private config" ) + if identity.get("rigid_point_joint_solver") == "vbd": + errors.append( + f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." + "rigid_point_joint_solver 'vbd' requires " + "rigid_contact_selection 'world_solver_family'" + ) elif selection == "world_solver_family": - if rigid_contact_solver != "avbd" or emplaced is not False: + if rigid_contact_solver not in ("avbd", "vbd") or emplaced is not False: errors.append( f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." "rigid_contact_selection 'world_solver_family' requires " - "rigid_contact_solver 'avbd' without a private body config" + "rigid_contact_solver 'vbd' or 'avbd' without a private body " + "config" + ) + if rigid_point_joint_solver not in ("none", rigid_contact_solver): + errors.append( + f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." + "rigid_contact_selection 'world_solver_family' requires " + "rigid_point_joint_solver to be 'none' or match " + "rigid_contact_solver" ) elif selection == "contact_solver_method": if ( @@ -181,6 +205,12 @@ def resolved_solver_identity_errors( "a boxed_lcp or sequential_impulse contact solver without " "a private AVBD body config" ) + if identity.get("rigid_point_joint_solver") == "vbd": + errors.append( + f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." + "rigid_point_joint_solver 'vbd' requires " + "rigid_contact_selection 'world_solver_family'" + ) elif selection == "not_applicable" and rigid_contact_solver != "none": errors.append( f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." @@ -207,6 +237,7 @@ def resolved_solver_identity_errors( "avbd": "avbd", "sequential-impulse": "sequential_impulse", "boxed-lcp": "boxed_lcp", + "vbd": "vbd", } # A non-AVBD public family may still report this exact suffix when a body diff --git a/scripts/benchmark_display_names.py b/scripts/benchmark_display_names.py index d4fa6f0dfec95..02c7fac3107b0 100644 --- a/scripts/benchmark_display_names.py +++ b/scripts/benchmark_display_names.py @@ -186,6 +186,9 @@ class Surface: "BM_AvbdPaperBreakableWallStep": Surface( "AVBD paper breakable-wall step", FAMILY_AVBD ), + "BM_VbdPaperBreakableWallStep": Surface( + "VBD paper breakable-wall step", FAMILY_RIGID + ), "BM_AvbdRigidBreakableJointStep": Surface( "AVBD breakable-joint step", FAMILY_AVBD, ("breakable joints",) ), diff --git a/scripts/write_avbd_paper_breakable_wall_packet.py b/scripts/write_avbd_paper_breakable_wall_packet.py index c10d752dd5790..fe3f56393d36e 100644 --- a/scripts/write_avbd_paper_breakable_wall_packet.py +++ b/scripts/write_avbd_paper_breakable_wall_packet.py @@ -126,6 +126,7 @@ Path("python/dartpy/_view_quality.py"), Path("python/examples/demos/scenes/avbd_paper_breakable_wall.py"), Path("tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp"), + Path("scripts/avbd_packet_schema.py"), Path("scripts/write_avbd_paper_breakable_wall_packet.py"), ) @@ -372,6 +373,7 @@ def _validate_resolved_configuration(metrics: dict[str, Any]) -> list[dict[str, def _validate_view_report( metrics: dict[str, Any], *, + expected_focus: tuple[str, ...] = VIEW_FOCUS, width: int, height: int, ) -> dict[str, Any]: @@ -388,7 +390,11 @@ def _validate_view_report( _require_exact(report.get("pass"), True, "ViewReport pass") _require_exact(report.get("issues"), [], "ViewReport issues") _require_exact(report.get("size"), [width, height], "ViewReport size") - _require_exact(report.get("focus"), list(VIEW_FOCUS), "ViewReport focus") + _require_exact( + report.get("focus"), + list(expected_focus), + "ViewReport focus", + ) camera = report.get("camera") if not isinstance(camera, dict): raise AvbdPaperBreakableWallPacketError("ViewReport missing camera") @@ -470,6 +476,7 @@ def _read_scene_metric_events( path: Path, *, expected_frame: int, + expected_scene_id: str = SCENE_ID, ) -> list[dict[str, Any]]: try: lines = path.read_text(encoding="utf-8").splitlines() @@ -499,7 +506,7 @@ def _read_scene_metric_events( ) _require_exact( event.get("scene"), - SCENE_ID, + expected_scene_id, f"{path}:{line_number} scene", ) _require_exact( @@ -842,6 +849,7 @@ def _validate_image_verdict( screenshot: Path, *, expected_frame: int, + expected_scene_id: str = SCENE_ID, ) -> dict[str, Any]: verdict = _load_json(verdict_path) _require_exact( @@ -875,7 +883,7 @@ def _validate_image_verdict( metadata = verdict.get("metadata") expected_metadata = { "frame": str(expected_frame), - "scene": SCENE_ID, + "scene": expected_scene_id, "view": CAMERA_VIEW, } _require_exact(metadata, expected_metadata, "image verdict metadata") diff --git a/scripts/write_avbd_paper_vbd_comparison_packet.py b/scripts/write_avbd_paper_vbd_comparison_packet.py new file mode 100644 index 0000000000000..face0693afa7b --- /dev/null +++ b/scripts/write_avbd_paper_vbd_comparison_packet.py @@ -0,0 +1,1374 @@ +#!/usr/bin/env python3 +"""Write the matched fixed-penalty VBD row for AVBD paper Figure 13.""" + +from __future__ import annotations + +import argparse +import json +import math +import struct +import sys +from hashlib import sha256 +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +import write_avbd_paper_breakable_wall_packet as shared # noqa: E402 +from avbd_packet_schema import ( # noqa: E402 + AVBD_PACKET_SCHEMA_VERSION, + make_resolved_solver_identity, +) + +DEFAULT_OUTPUT = Path( + "docs/plans/104-vertex-block-descent-solver/" + "avbd-paper-vbd-comparison-packet.json" +) +DEFAULT_AVBD_PACKET = Path( + "docs/plans/104-vertex-block-descent-solver/" + "avbd-paper-breakable-wall-packet.json" +) + +SCENE_ID = "vbd_paper_breakable_wall" +AVBD_SCENE_ID = "avbd_paper_breakable_wall" +PAPER_LOCATOR = "Section 5.4 and Figure 13, PDF page 10" +TIME_STEP = 1.0 / 60.0 +RIGID_CONSTRAINT_ITERATIONS = 20 +BRICK_COUNT = 252 +RIGID_BODIES = 256 +COLLISION_SHAPES = 256 +BREAKABLE_JOINTS = 712 +IMPACTING_BALLS = 3 +BREAK_FORCE = 8500.0 +CAMERA_DISTANCE = 11.0 +CAMERA_TARGET = (0.0, 0.0, 1.4) +CAMERA_VIEW = "front" +VIEW_FOCUS = ( + "vbd_paper_wall_brick_00_00_visual", + "vbd_paper_wall_brick_00_20_visual", + "vbd_paper_wall_brick_11_00_visual", + "vbd_paper_wall_brick_11_20_visual", +) + +CHECKPOINTS = { + 14: { + "label": "bend", + "checkpoint": "bend", + "threshold_checks": { + "bend_is_spatially_resolved": True, + "finite_state": True, + "no_fracture": True, + "topology_retained": True, + "wall_bend_is_distributed": True, + "wall_bends": True, + }, + }, + 120: { + "label": "retention", + "checkpoint": "retention", + "threshold_checks": { + "finite_state": True, + "no_fracture": True, + "topology_retained": True, + "wall_retained": True, + }, + }, +} + +OUTCOME_ORACLE = { + "evaluation_frame": 14, + "retention_evaluation_frame": 120, + "maximum_broken_joints": 0, + "minimum_unbroken_joints": BREAKABLE_JOINTS, + "minimum_maximum_wall_normal_displacement": 0.20, + "minimum_rms_wall_normal_displacement": 0.10, + "bent_brick_displacement_threshold": 0.10, + "minimum_bent_bricks": 100, + "minimum_total_retained_fraction": 0.99, +} + +BENCHMARK_RUNS = { + "avbd": "BM_AvbdPaperBreakableWallStep/iterations:120", + "vbd": "BM_VbdPaperBreakableWallStep/iterations:120", +} + +RESOLVED_SOLVER_IDENTITY = make_resolved_solver_identity( + resolved_rigid_contact_family="vbd", + rigid_point_joint_solver="vbd", + avbd_rigid_contact_config_emplaced=False, + rigid_contact_selection="world_solver_family", + recorded_from=( + "engine World.resolved_configuration in both VBD captures plus " + "benchmark runtime resolved-configuration counters" + ), +) + +REPO_ROOT = SCRIPT_DIR.parent +SOURCE_PATHS = ( + Path("dart/simulation/world.cpp"), + Path("dart/simulation/world.hpp"), + Path("dart/simulation/world_options.hpp"), + Path("dart/simulation/compute/rigid_body_contact_stage.cpp"), + Path("dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp"), + Path("dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp"), + Path("dart/simulation/detail/world_step_schedule.hpp"), + Path("python/examples/demos/scenes/avbd_paper_breakable_wall.py"), + Path("python/examples/demos/scenes/vbd_paper_breakable_wall.py"), + Path("python/tests/integration/test_demos_cycle.py"), + Path("tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp"), + Path("tests/unit/simulation/world/test_world.cpp"), + Path("scripts/avbd_packet_schema.py"), + Path("scripts/write_avbd_paper_vbd_comparison_packet.py"), +) + + +class AvbdPaperVbdComparisonPacketError(RuntimeError): + """Raised when inputs cannot support the matched comparison claim.""" + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--benchmark-json", type=Path, required=True) + parser.add_argument("--bend-capture-manifest", type=Path, required=True) + parser.add_argument("--bend-image-verdict-json", type=Path, required=True) + parser.add_argument("--retention-capture-manifest", type=Path, required=True) + parser.add_argument( + "--retention-image-verdict-json", + type=Path, + required=True, + ) + parser.add_argument("--visual-review-json", type=Path, required=True) + parser.add_argument("--paper-pdf", type=Path, required=True) + parser.add_argument("--paper-figure-image", type=Path, required=True) + parser.add_argument( + "--avbd-packet", + type=Path, + default=DEFAULT_AVBD_PACKET, + ) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + return parser.parse_args(argv) + + +def _source_provenance() -> dict[str, Any]: + combined = sha256() + files = [] + for relative_path in SOURCE_PATHS: + path = REPO_ROOT / relative_path + try: + payload = path.read_bytes() + except FileNotFoundError as exc: + raise AvbdPaperVbdComparisonPacketError( + f"{relative_path}: source file not found" + ) from exc + encoded_path = relative_path.as_posix().encode("utf-8") + combined.update(struct.pack(" str: + if not isinstance(value, str) or len(value) != 16: + raise AvbdPaperVbdComparisonPacketError( + f"{label} must be a 16-character lowercase hexadecimal string" + ) + try: + parsed = int(value, 16) + except ValueError as exc: + raise AvbdPaperVbdComparisonPacketError(f"{label} must be hexadecimal") from exc + if f"{parsed:016x}" != value: + raise AvbdPaperVbdComparisonPacketError( + f"{label} must use canonical lowercase hexadecimal" + ) + return value + + +def _validate_sha256_value(value: object, label: str) -> str: + if not isinstance(value, str) or len(value) != 64: + raise AvbdPaperVbdComparisonPacketError( + f"{label} must be a 64-character lowercase hexadecimal string" + ) + try: + parsed = int(value, 16) + except ValueError as exc: + raise AvbdPaperVbdComparisonPacketError(f"{label} must be hexadecimal") from exc + if f"{parsed:064x}" != value: + raise AvbdPaperVbdComparisonPacketError( + f"{label} must use canonical lowercase hexadecimal" + ) + return value + + +def _validate_resolved_configuration( + metrics: dict[str, Any], +) -> list[dict[str, str]]: + notes = metrics.get("resolved_configuration") + if not isinstance(notes, list): + raise AvbdPaperVbdComparisonPacketError( + "scene metrics missing engine resolved_configuration" + ) + normalized = [] + for index, note in enumerate(notes): + if not isinstance(note, dict): + raise AvbdPaperVbdComparisonPacketError( + f"resolved_configuration[{index}] must be an object" + ) + normalized_note = {} + for key in ("domain", "requested", "resolved", "reason"): + value = note.get(key) + if not isinstance(value, str) or not value: + raise AvbdPaperVbdComparisonPacketError( + f"resolved_configuration[{index}].{key} must be non-empty" + ) + normalized_note[key] = value + normalized.append(normalized_note) + + expected = { + "rigid-body": ("vbd", "vbd"), + "rigid-contact": ("vbd", "vbd"), + "rigid-pair-constraint": ("vbd", "vbd"), + "rigid-constraint-iterations": ( + str(RIGID_CONSTRAINT_ITERATIONS), + str(RIGID_CONSTRAINT_ITERATIONS), + ), + } + for domain, (requested, resolved) in expected.items(): + matching = [ + note + for note in normalized + if note["domain"] == domain + and note["requested"] == requested + and note["resolved"] == resolved + ] + if len(matching) != 1: + raise AvbdPaperVbdComparisonPacketError( + "engine resolved_configuration must contain exactly one " + f"{domain} {requested}->{resolved} note" + ) + return normalized + + +def _validate_outcome( + outcome: dict[str, Any], + *, + expected_frame: int, +) -> dict[str, Any]: + checkpoint = CHECKPOINTS[expected_frame] + shared._validate_finite_tree(outcome, f"frame {expected_frame} outcome") + for key, expected in ( + ("frame", expected_frame), + ("evaluated", True), + ("checkpoint", checkpoint["checkpoint"]), + ("status", "pass"), + ("thresholds_pass", True), + ("threshold_checks", checkpoint["threshold_checks"]), + ("broken_joints", 0), + ("unbroken_joints", BREAKABLE_JOINTS), + ("last_step_iterations", RIGID_CONSTRAINT_ITERATIONS), + ): + shared._require_exact( + outcome.get(key), + expected, + f"frame {expected_frame} outcome {key}", + ) + shared._require_close( + outcome.get("world_time"), + expected_frame * TIME_STEP, + f"frame {expected_frame} outcome world_time", + ) + + if expected_frame == 14: + minimums = { + "maximum_wall_normal_displacement": ( + OUTCOME_ORACLE["minimum_maximum_wall_normal_displacement"] + ), + "rms_wall_normal_displacement": ( + OUTCOME_ORACLE["minimum_rms_wall_normal_displacement"] + ), + "bent_brick_count": OUTCOME_ORACLE["minimum_bent_bricks"], + } + for key, minimum in minimums.items(): + value = shared._finite_number( + outcome.get(key), + f"frame {expected_frame} outcome {key}", + ) + if value < float(minimum): + raise AvbdPaperVbdComparisonPacketError( + f"frame {expected_frame} outcome {key} must be >= " + f"{minimum}, got {value}" + ) + if ( + shared._finite_number( + outcome.get("contact_count"), + "frame 14 outcome contact_count", + ) + <= 0.0 + ): + raise AvbdPaperVbdComparisonPacketError( + "frame 14 must record active impact contact" + ) + else: + retained = shared._finite_number( + outcome.get("total_retained_fraction"), + "frame 120 outcome total_retained_fraction", + ) + if retained < OUTCOME_ORACLE["minimum_total_retained_fraction"]: + raise AvbdPaperVbdComparisonPacketError( + "frame 120 wall-retention oracle failed" + ) + return outcome + + +def _validate_scene_metrics( + manifest: dict[str, Any], + *, + expected_frame: int, + height: int, + logged_latest: dict[str, Any], + width: int, +) -> dict[str, Any]: + summary = manifest.get("scene_metrics") + if not isinstance(summary, dict): + raise AvbdPaperVbdComparisonPacketError( + "capture manifest missing scene_metrics" + ) + shared._require_exact( + summary.get("event_count"), + expected_frame, + "scene metric event_count", + ) + latest = summary.get("latest") + if not isinstance(latest, dict): + raise AvbdPaperVbdComparisonPacketError( + "capture scene_metrics missing latest event" + ) + shared._require_exact( + latest, + logged_latest, + "manifest latest scene metric event", + ) + shared._require_exact(latest.get("scene"), SCENE_ID, "latest scene") + shared._require_exact( + latest.get("frame"), + expected_frame, + "latest scene frame", + ) + metrics = latest.get("metrics") + if not isinstance(metrics, dict): + raise AvbdPaperVbdComparisonPacketError( + "capture latest scene metric payload must be an object" + ) + shared._validate_finite_tree(metrics, "scene metrics") + + exact_metrics = { + "ball_count": IMPACTING_BALLS, + "breakable_joints": BREAKABLE_JOINTS, + "brick_count": BRICK_COUNT, + "collision_shapes": COLLISION_SHAPES, + "executor": "World.step default", + "paper_locator": PAPER_LOCATOR, + "rigid_bodies": RIGID_BODIES, + "rigid_body_solver": "VBD", + "row": SCENE_ID, + "solver": "public_vbd", + } + for key, expected in exact_metrics.items(): + shared._require_exact(metrics.get(key), expected, f"scene metrics {key}") + shared._require_close( + metrics.get("break_force"), + BREAK_FORCE, + "scene break_force", + ) + shared._require_close( + metrics.get("time_step_ms"), + TIME_STEP * 1000.0, + "scene time_step_ms", + ) + shared._require_exact( + metrics.get("rigid_constraint_options"), + {"iterations": RIGID_CONSTRAINT_ITERATIONS}, + "scene rigid_constraint_options", + ) + + oracle = metrics.get("outcome_oracle") + if not isinstance(oracle, dict): + raise AvbdPaperVbdComparisonPacketError("scene metrics missing outcome_oracle") + for key, expected in OUTCOME_ORACLE.items(): + if isinstance(expected, float): + shared._require_close(oracle.get(key), expected, f"oracle {key}") + else: + shared._require_exact(oracle.get(key), expected, f"oracle {key}") + + outcome = metrics.get("outcome") + if not isinstance(outcome, dict): + raise AvbdPaperVbdComparisonPacketError("scene metrics missing outcome") + _validate_outcome(outcome, expected_frame=expected_frame) + resolved = _validate_resolved_configuration(metrics) + view_report = shared._validate_view_report( + metrics, + expected_focus=VIEW_FOCUS, + width=width, + height=height, + ) + fingerprint = _validate_scene_spec_fingerprint( + metrics.get("scene_spec_fingerprint"), + "scene metrics scene_spec_fingerprint", + ) + return { + "event_count": expected_frame, + "frame": expected_frame, + "outcome": outcome, + "outcome_oracle": oracle, + "resolved_configuration": resolved, + "scene_spec_fingerprint": fingerprint, + "view_report": view_report, + "scene_contract": { + key: metrics[key] + for key in ( + "ball_count", + "break_force", + "breakable_joints", + "brick_count", + "collision_shapes", + "executor", + "paper_locator", + "rigid_bodies", + "rigid_body_solver", + "solver", + "time_step_ms", + ) + } + | {"rigid_constraint_options": metrics["rigid_constraint_options"]}, + } + + +def _validate_capture( + manifest_path: Path, + *, + expected_frame: int, +) -> tuple[dict[str, Any], Path]: + expected_label = CHECKPOINTS[expected_frame]["label"] + manifest = shared._load_json(manifest_path) + shared._require_exact(manifest.get("schema_version"), 1, "capture schema") + shared._require_exact(manifest.get("scene"), SCENE_ID, "capture scene") + shared._require_exact( + manifest.get("capture_label"), + expected_label, + "capture label", + ) + shared._require_exact(manifest.get("force_drag"), None, "capture force_drag") + shared._require_exact( + manifest.get("resolved_solver_identity"), + { + "executor": "World.step default", + "solver": "public_vbd", + "source": "scene_capture_metrics.latest.metrics", + }, + "capture resolved_solver_identity", + ) + capture = manifest.get("capture") + if not isinstance(capture, dict): + raise AvbdPaperVbdComparisonPacketError("capture manifest missing dimensions") + for key in ("requested_frames", "converted_frames"): + shared._require_exact( + capture.get(key), + expected_frame, + f"capture {key}", + ) + width = capture.get("width") + height = capture.get("height") + if ( + not isinstance(width, int) + or isinstance(width, bool) + or not isinstance(height, int) + or isinstance(height, bool) + or width < 1 + or height < 1 + ): + raise AvbdPaperVbdComparisonPacketError( + "capture width and height must be positive integers" + ) + + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict): + raise AvbdPaperVbdComparisonPacketError("capture manifest missing artifacts") + screenshot = shared._artifact_path( + manifest_path, + artifacts.get("screenshot"), + "artifacts.screenshot", + ) + metrics_events = shared._artifact_path( + manifest_path, + artifacts.get("scene_metrics_events"), + "artifacts.scene_metrics_events", + ) + if not screenshot.is_file() or not metrics_events.is_file(): + raise AvbdPaperVbdComparisonPacketError( + "capture screenshot and scene metric log must both exist" + ) + if shared._png_dimensions(screenshot) != (width, height): + raise AvbdPaperVbdComparisonPacketError( + "capture screenshot dimensions do not match manifest" + ) + events = shared._read_scene_metric_events( + metrics_events, + expected_frame=expected_frame, + expected_scene_id=SCENE_ID, + ) + scene_metrics = _validate_scene_metrics( + manifest, + expected_frame=expected_frame, + height=height, + logged_latest=events[-1], + width=width, + ) + return ( + { + "camera": shared._validate_camera(manifest), + "capture": { + "height": height, + "requested_frames": expected_frame, + "width": width, + }, + "label": expected_label, + "manifest": { + "file": manifest_path.name, + "sha256": shared._sha256(manifest_path), + }, + "scene_metrics": scene_metrics, + "scene_metrics_events": { + "event_count": len(events), + "file": metrics_events.name, + "sha256": shared._sha256(metrics_events), + }, + "screenshot": { + "file": screenshot.name, + "sha256": shared._sha256(screenshot), + }, + }, + screenshot, + ) + + +def _benchmark_rows( + data: dict[str, Any], + *, + solver_key: str, + expected_fingerprint: str, +) -> dict[str, Any]: + benchmark_run = BENCHMARK_RUNS[solver_key] + rows = data.get("benchmarks") + if not isinstance(rows, list): + raise AvbdPaperVbdComparisonPacketError( + "benchmark JSON missing benchmarks list" + ) + matching = [ + row + for row in rows + if isinstance(row, dict) + and shared._canonical_benchmark_name(row) == benchmark_run + ] + by_aggregate: dict[str, dict[str, Any]] = {} + for row in matching: + aggregate = row.get("aggregate_name") + if not isinstance(aggregate, str): + raise AvbdPaperVbdComparisonPacketError( + f"{benchmark_run}: every row must be an aggregate" + ) + if aggregate in by_aggregate: + raise AvbdPaperVbdComparisonPacketError( + f"{benchmark_run}: duplicate {aggregate} aggregate" + ) + by_aggregate[aggregate] = row + expected_aggregates = {"mean", "median", "stddev", "cv"} + if set(by_aggregate) != expected_aggregates: + raise AvbdPaperVbdComparisonPacketError( + f"{benchmark_run}: expected aggregates " + f"{sorted(expected_aggregates)}, got {sorted(by_aggregate)}" + ) + + counters = { + "breakable_joints": BREAKABLE_JOINTS, + "collision_shapes": COLLISION_SHAPES, + "impacting_balls": IMPACTING_BALLS, + f"public_{solver_key}_family": 1, + f"resolved_rigid_body_{solver_key}": 1, + "resolved_rigid_constraint_iterations": 1, + f"resolved_rigid_contact_{solver_key}": 1, + f"resolved_rigid_pair_constraint_{solver_key}": 1, + "rigid_constraint_iterations": RIGID_CONSTRAINT_ITERATIONS, + "rigid_bodies": RIGID_BODIES, + "rigid_body_joints": BREAKABLE_JOINTS, + "runtime_contract_passed": 1, + "trajectory_frames": 120, + } + fingerprint = int(expected_fingerprint, 16) + fingerprint_counters = { + "scene_spec_fingerprint_hi": fingerprint >> 32, + "scene_spec_fingerprint_lo": fingerprint & 0xFFFFFFFF, + } + packet_rows = [] + for aggregate in ("mean", "median", "stddev", "cv"): + row = by_aggregate[aggregate] + shared._require_exact( + row.get("run_type"), + "aggregate", + f"{benchmark_run} run_type", + ) + shared._require_exact( + row.get("repetitions"), + 5, + f"{benchmark_run} repetitions", + ) + shared._require_exact( + row.get("iterations"), + 5, + f"{benchmark_run} iterations", + ) + shared._require_exact( + row.get("time_unit"), + "ns", + f"{benchmark_run} time_unit", + ) + real_time = shared._finite_number( + row.get("real_time"), + f"{benchmark_run} {aggregate} real_time", + ) + cpu_time = shared._finite_number( + row.get("cpu_time"), + f"{benchmark_run} {aggregate} cpu_time", + ) + if real_time < 0.0 or cpu_time < 0.0: + raise AvbdPaperVbdComparisonPacketError( + f"{benchmark_run}: timings must be non-negative" + ) + if aggregate in ("mean", "median"): + if real_time <= 0.0 or cpu_time <= 0.0: + raise AvbdPaperVbdComparisonPacketError( + f"{benchmark_run}: representative timings must be positive" + ) + for key, expected in counters.items(): + shared._require_close( + row.get(key), + float(expected), + f"{benchmark_run} {aggregate} {key}", + ) + for key, expected in fingerprint_counters.items(): + shared._require_close( + row.get(key), + float(expected), + f"{benchmark_run} {aggregate} {key}", + ) + packet_rows.append(dict(row)) + + cv_row = by_aggregate["cv"] + real_cv = shared._finite_number( + cv_row.get("real_time"), + f"{benchmark_run} real-time CV", + ) + cpu_cv = shared._finite_number( + cv_row.get("cpu_time"), + f"{benchmark_run} CPU-time CV", + ) + if not 0.0 <= real_cv <= 0.10 or not 0.0 <= cpu_cv <= 0.10: + raise AvbdPaperVbdComparisonPacketError( + f"{benchmark_run}: timing CV must be between 0 and 10 percent" + ) + return { + "benchmark": benchmark_run.removesuffix("/iterations:120"), + "rows": packet_rows, + "stability": { + "cpu_time_cv_fraction": cpu_cv, + "real_time_cv_fraction": real_cv, + "repetitions": 5, + }, + "timing": { + "mean_cpu_time_per_step_ns": by_aggregate["mean"]["cpu_time"], + "mean_real_time_per_step_ns": by_aggregate["mean"]["real_time"], + "median_cpu_time_per_step_ns": by_aggregate["median"]["cpu_time"], + "median_real_time_per_step_ns": by_aggregate["median"]["real_time"], + }, + } + + +def _validate_benchmark( + benchmark_path: Path, + *, + expected_fingerprint: str, +) -> dict[str, Any]: + data = shared._load_json(benchmark_path) + methods = { + solver_key: _benchmark_rows( + data, + solver_key=solver_key, + expected_fingerprint=expected_fingerprint, + ) + for solver_key in ("avbd", "vbd") + } + context = data.get("context") + if not isinstance(context, dict): + raise AvbdPaperVbdComparisonPacketError("benchmark JSON missing context object") + executable = context.get("executable") + if ( + not isinstance(executable, str) + or Path(executable).name != "bm_avbd_rigid_fixed_joint" + ): + raise AvbdPaperVbdComparisonPacketError( + "benchmark context identifies the wrong executable" + ) + shared._require_exact( + context.get("library_build_type"), + "release", + "benchmark library_build_type", + ) + avbd_median = float(methods["avbd"]["timing"]["median_cpu_time_per_step_ns"]) + vbd_median = float(methods["vbd"]["timing"]["median_cpu_time_per_step_ns"]) + return { + "context": { + key: context[key] + for key in ( + "executable", + "host_name", + "json_schema_version", + "library_build_type", + "library_version", + "mhz_per_cpu", + "num_cpus", + ) + if key in context + }, + "json_sha256": shared._sha256(benchmark_path), + "methods": methods, + "scene_spec_fingerprint": expected_fingerprint, + "comparison": { + "basis": ( + "same executable, host, reconstructed scene fingerprint, " + "trajectory length, time step, and 20-sweep budget" + ), + "vbd_to_avbd_median_cpu_cost_ratio": vbd_median / avbd_median, + }, + } + + +def _validate_avbd_packet( + packet_path: Path, + *, + benchmark_sha256: str, + expected_fingerprint: str, +) -> dict[str, Any]: + packet = shared._load_json(packet_path) + shared._require_exact( + packet.get("schema_version"), + AVBD_PACKET_SCHEMA_VERSION, + "linked AVBD packet schema_version", + ) + shared._require_exact( + packet.get("packet"), + "avbd_paper_breakable_wall", + "linked AVBD packet identity", + ) + shared._require_exact( + packet.get("scene"), + AVBD_SCENE_ID, + "linked AVBD packet scene", + ) + identity = packet.get("resolved_solver_identity") + if not isinstance(identity, dict): + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD packet lacks resolved_solver_identity" + ) + shared._require_exact( + identity.get("rigid_contact_solver"), + "avbd", + "linked AVBD rigid contact solver", + ) + shared._require_exact( + identity.get("rigid_point_joint_solver"), + "avbd", + "linked AVBD point-joint solver", + ) + benchmark = packet.get("benchmark") + if not isinstance(benchmark, dict): + raise AvbdPaperVbdComparisonPacketError("linked AVBD packet lacks benchmark") + shared._require_exact( + benchmark.get("json_sha256"), + benchmark_sha256, + "linked AVBD benchmark JSON hash", + ) + shared._require_exact( + benchmark.get("scene_spec_fingerprint"), + expected_fingerprint, + "linked AVBD scene fingerprint", + ) + + correctness = packet.get("correctness") + if not isinstance(correctness, dict): + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD packet lacks correctness evidence" + ) + shared._require_exact( + correctness.get("determinism_test"), + "test_avbd_paper_breakable_wall_outcome_is_deterministic", + "linked AVBD determinism test", + ) + shared._require_exact( + correctness.get("outcome_test"), + "test_avbd_paper_breakable_wall_matches_figure13_contract", + "linked AVBD outcome test", + ) + + visual = packet.get("visual_evidence") + if not isinstance(visual, dict): + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD packet lacks visual evidence" + ) + + validated_screenshots: dict[str, dict[str, str]] = {} + for label, expected_frame in (("impact", 60), ("outcome", 120)): + capture = visual.get(label) + if not isinstance(capture, dict): + raise AvbdPaperVbdComparisonPacketError( + f"linked AVBD packet lacks {label} visual evidence" + ) + screenshot = capture.get("screenshot") + if not isinstance(screenshot, dict): + raise AvbdPaperVbdComparisonPacketError( + f"linked AVBD {label} evidence lacks a screenshot" + ) + screenshot_file = screenshot.get("file") + if not isinstance(screenshot_file, str) or not screenshot_file: + raise AvbdPaperVbdComparisonPacketError( + f"linked AVBD {label} screenshot file must be non-empty" + ) + screenshot_hash = _validate_sha256_value( + screenshot.get("sha256"), + f"linked AVBD {label} screenshot sha256", + ) + validated_screenshots[label] = { + "file": screenshot_file, + "sha256": screenshot_hash, + } + + image_verdict = capture.get("image_verdict") + if not isinstance(image_verdict, dict) or image_verdict.get("pass") is not True: + raise AvbdPaperVbdComparisonPacketError( + f"linked AVBD {label} image verdict must pass" + ) + _validate_sha256_value( + image_verdict.get("sha256"), + f"linked AVBD {label} image verdict sha256", + ) + + scene_metrics = capture.get("scene_metrics") + if not isinstance(scene_metrics, dict): + raise AvbdPaperVbdComparisonPacketError( + f"linked AVBD {label} evidence lacks scene metrics" + ) + shared._require_exact( + scene_metrics.get("frame"), + expected_frame, + f"linked AVBD {label} frame", + ) + shared._require_exact( + scene_metrics.get("scene_spec_fingerprint"), + expected_fingerprint, + f"linked AVBD {label} scene fingerprint", + ) + oracle = scene_metrics.get("outcome_oracle") + if not isinstance(oracle, dict): + raise AvbdPaperVbdComparisonPacketError( + f"linked AVBD {label} evidence lacks outcome oracle" + ) + for key, expected in shared.OUTCOME_ORACLE.items(): + if isinstance(expected, float): + shared._require_close( + oracle.get(key), + expected, + f"linked AVBD {label} outcome oracle {key}", + ) + else: + shared._require_exact( + oracle.get(key), + expected, + f"linked AVBD {label} outcome oracle {key}", + ) + outcome = scene_metrics.get("outcome") + if not isinstance(outcome, dict): + raise AvbdPaperVbdComparisonPacketError( + f"linked AVBD {label} evidence lacks outcome" + ) + expected_outcome = shared.EXPECTED_OUTCOMES[expected_frame] + for key in ( + "broken_joints", + "evaluated", + "impact_band_displaced_counts", + "status", + "threshold_checks", + "thresholds_pass", + "unbroken_joints", + ): + shared._require_exact( + outcome.get(key), + expected_outcome[key], + f"linked AVBD frame {expected_frame} outcome {key}", + ) + for key in ("outside_retained_fraction", "total_retained_fraction"): + shared._require_close( + outcome.get(key), + expected_outcome[key], + f"linked AVBD frame {expected_frame} outcome {key}", + ) + shared._require_exact( + outcome.get("frame"), + expected_frame, + f"linked AVBD frame {expected_frame} outcome frame", + ) + shared._require_exact( + outcome.get("last_step_iterations"), + shared.RIGID_CONSTRAINT_ITERATIONS, + f"linked AVBD frame {expected_frame} outcome iterations", + ) + + semantic = visual.get("semantic_review") + if not isinstance(semantic, dict) or semantic.get("verdict") != "pass": + raise AvbdPaperVbdComparisonPacketError("linked AVBD semantic review must pass") + semantic_file = semantic.get("file") + if not isinstance(semantic_file, str) or not semantic_file: + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD semantic review file must be non-empty" + ) + semantic_hash = _validate_sha256_value( + semantic.get("sha256"), + "linked AVBD semantic review sha256", + ) + inspected_images = semantic.get("inspected_images") + if not isinstance(inspected_images, list): + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD semantic review must list inspected images" + ) + by_role = { + entry.get("role"): entry + for entry in inspected_images + if isinstance(entry, dict) and isinstance(entry.get("role"), str) + } + paper_reference = packet.get("paper_reference") + paper_figure = ( + paper_reference.get("figure") if isinstance(paper_reference, dict) else None + ) + if not isinstance(paper_figure, dict): + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD packet lacks paper figure evidence" + ) + expected_inspected = { + "impact_frame_60": validated_screenshots["impact"], + "outcome_frame_120": validated_screenshots["outcome"], + "paper_figure_13_reference": { + "file": paper_figure.get("file"), + "sha256": _validate_sha256_value( + paper_figure.get("sha256"), + "linked AVBD paper figure sha256", + ), + }, + } + if set(by_role) != set(expected_inspected): + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD semantic review must inspect impact, outcome, and " + "paper Figure 13 images" + ) + for role, expected in expected_inspected.items(): + shared._require_exact( + by_role[role].get("file"), + expected["file"], + f"linked AVBD semantic review {role} file", + ) + shared._require_exact( + by_role[role].get("sha256"), + expected["sha256"], + f"linked AVBD semantic review {role} sha256", + ) + + provenance = packet.get("source_provenance") + if not isinstance(provenance, dict): + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD packet lacks source provenance" + ) + files = provenance.get("files") + if not isinstance(files, list) or not files: + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD source provenance must list files" + ) + expected_source_paths = {relative.as_posix() for relative in shared.SOURCE_PATHS} + actual_source_paths = { + entry.get("path") for entry in files if isinstance(entry, dict) + } + if actual_source_paths != expected_source_paths: + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD source provenance must list the canonical writer " "source set" + ) + combined = sha256() + for entry in files: + if not isinstance(entry, dict): + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD source provenance entry must be an object" + ) + relative = entry.get("path") + expected_hash = entry.get("sha256") + if not isinstance(relative, str) or not isinstance(expected_hash, str): + raise AvbdPaperVbdComparisonPacketError( + "linked AVBD source provenance entry is incomplete" + ) + current_hash = shared._sha256(REPO_ROOT / relative) + if current_hash != expected_hash: + raise AvbdPaperVbdComparisonPacketError( + f"linked AVBD source provenance drifted for {relative}" + ) + payload = (REPO_ROOT / relative).read_bytes() + encoded_path = relative.encode("utf-8") + combined.update(struct.pack(" dict[str, Any]: + review = shared._load_json(review_path) + shared._require_exact( + review.get("schema_version"), + "dart.visual_semantic_review/v1", + "visual review schema_version", + ) + shared._require_exact(review.get("scene"), SCENE_ID, "visual review scene") + shared._require_exact( + review.get("verdict"), + "pass", + "visual review verdict", + ) + fields = ( + "reviewer_capability", + "claim_and_expected_observation", + "text_oracle", + "visible_observation", + "reconciliation_and_verdict", + "not_proven_and_limitations", + ) + for key in fields: + value = review.get(key) + if not isinstance(value, str) or not value.strip(): + raise AvbdPaperVbdComparisonPacketError( + f"visual review {key} must be non-empty" + ) + if "image" not in review["reviewer_capability"].lower(): + raise AvbdPaperVbdComparisonPacketError( + "visual review must name an image-capable reviewer" + ) + if ( + "viewreport" + not in review["reconciliation_and_verdict"].replace(" ", "").lower() + ): + raise AvbdPaperVbdComparisonPacketError( + "visual review reconciliation must account for ViewReports" + ) + limitations = review["not_proven_and_limitations"].lower() + for required in ("cuda", "sequential impulse", "unpublished", "xpbd"): + if required not in limitations: + raise AvbdPaperVbdComparisonPacketError( + f"visual review limitations must name {required}" + ) + + expected = { + "bend_frame_14": bend_screenshot, + "retention_frame_120": retention_screenshot, + "paper_figure_13_reference": paper_figure, + } + entries = review.get("inspected_images") + if not isinstance(entries, list): + raise AvbdPaperVbdComparisonPacketError( + "visual review inspected_images must be a list" + ) + by_role = { + entry.get("role"): entry + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("role"), str) + } + if set(by_role) != set(expected): + raise AvbdPaperVbdComparisonPacketError( + "visual review must inspect bend, retention, and paper images" + ) + inspected = [] + for role, path in expected.items(): + entry = by_role[role] + file_value = entry.get("file") + if ( + not isinstance(file_value, str) + or Path(file_value).resolve() != path.resolve() + ): + raise AvbdPaperVbdComparisonPacketError( + f"visual review {role} file does not match inspected image" + ) + expected_hash = shared._sha256(path) + shared._require_exact( + entry.get("sha256"), + expected_hash, + f"visual review {role} sha256", + ) + inspected.append({"file": path.name, "role": role, "sha256": expected_hash}) + return { + "claim_and_expected_observation": review["claim_and_expected_observation"], + "file": review_path.name, + "inspected_images": inspected, + "not_proven_and_limitations": review["not_proven_and_limitations"], + "reconciliation_and_verdict": review["reconciliation_and_verdict"], + "reviewer_capability": review["reviewer_capability"], + "sha256": shared._sha256(review_path), + "text_oracle": review["text_oracle"], + "verdict": "pass", + "visible_observation": review["visible_observation"], + } + + +def make_packet( + *, + benchmark_json: Path, + bend_capture_manifest: Path, + bend_image_verdict_json: Path, + retention_capture_manifest: Path, + retention_image_verdict_json: Path, + visual_review_json: Path, + paper_pdf: Path, + paper_figure_image: Path, + avbd_packet: Path, +) -> dict[str, Any]: + bend, bend_screenshot = _validate_capture( + bend_capture_manifest, + expected_frame=14, + ) + bend["image_verdict"] = shared._validate_image_verdict( + bend_image_verdict_json, + bend_screenshot, + expected_frame=14, + expected_scene_id=SCENE_ID, + ) + retention, retention_screenshot = _validate_capture( + retention_capture_manifest, + expected_frame=120, + ) + retention["image_verdict"] = shared._validate_image_verdict( + retention_image_verdict_json, + retention_screenshot, + expected_frame=120, + expected_scene_id=SCENE_ID, + ) + fingerprint = bend["scene_metrics"]["scene_spec_fingerprint"] + shared._require_exact( + retention["scene_metrics"]["scene_spec_fingerprint"], + fingerprint, + "bend/retention scene fingerprint", + ) + benchmark = _validate_benchmark( + benchmark_json, + expected_fingerprint=fingerprint, + ) + linked_avbd = _validate_avbd_packet( + avbd_packet, + benchmark_sha256=benchmark["json_sha256"], + expected_fingerprint=fingerprint, + ) + paper_reference = shared._validate_paper_artifacts( + paper_pdf, + paper_figure_image, + ) + semantic_review = _validate_visual_review( + visual_review_json, + bend_screenshot=bend_screenshot, + retention_screenshot=retention_screenshot, + paper_figure=paper_figure_image, + ) + + return { + "benchmark": benchmark, + "claim_boundary": ( + "This packet covers the matched fixed-penalty VBD row and its " + "same-host CPU comparison with the linked public AVBD Figure 13 " + "packet. It does not reproduce the Sequential Impulse or XPBD " + "rows, claim exact unpublished source constants, establish CUDA " + "parity, or compare against a published/source timing." + ), + "correctness": { + "allocation_scene": ( + "configurePublicRigidVbdContactAndBreakableJointRowsScene" + ), + "allocation_tests": [ + "World.BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths", + "World.BakedRigidBodyContactStepsDoNotAllocateGlobalHeap", + "World.BakedAvbdVbdRowsDoNotMallocOnHeap", + ], + "determinism_test": ( + "test_vbd_paper_breakable_wall_checkpoints_are_deterministic" + ), + "outcome_test": ("test_vbd_paper_breakable_wall_matches_figure13_contract"), + "public_configuration": { + "rigid_body_solver": "VBD", + "rigid_constraint_options": { + "iterations": RIGID_CONSTRAINT_ITERATIONS, + }, + "scene_spec_fingerprint": fingerprint, + "time_step": TIME_STEP, + }, + }, + "linked_avbd_evidence": linked_avbd, + "packet": "avbd_paper_vbd_comparison", + "paper_reference": paper_reference, + "performance_claim_boundary": ( + "The ratio is a descriptive same-host cost comparison for two " + "public DART families running an identical reconstructed scene and " + "iteration budget. It is not a paper/reference speedup claim." + ), + "publication_observation": ( + "The Figure 13 VBD row bends under the three impacts but does not " "break." + ), + "reconstruction": { + "matched_with_avbd": { + "break_force": BREAK_FORCE, + "breakable_joints": BREAKABLE_JOINTS, + "bricks": BRICK_COUNT, + "impacting_balls": IMPACTING_BALLS, + "scene_spec_fingerprint": fingerprint, + }, + "published": { + "constraint_iterations_or_substeps": (RIGID_CONSTRAINT_ITERATIONS), + "paper_locator": PAPER_LOCATOR, + "time_step": TIME_STEP, + }, + "unpublished": ( + "wall dimensions, body materials, ball properties and launch " + "conditions, break threshold, and exact source scene" + ), + }, + "reproduction": { + "benchmark_command": ( + "pixi run bm -- bm_avbd_rigid_fixed_joint -- " + "--benchmark_filter=" + "'^BM_(Avbd|Vbd)PaperBreakableWallStep/iterations:120$' " + "--benchmark_repetitions=5 " + "--benchmark_report_aggregates_only=true " + "--benchmark_out= " + "--benchmark_out_format=json" + ), + "capture_commands": [ + ( + "pixi run py-demo-capture -- " + f"--scene {SCENE_ID} --frames 14 --width 1280 " + "--height 720 --view front --camera-distance 11 " + "--camera-target 0,0,1.4 --capture-label bend " + "--output-dir " + ), + ( + "pixi run py-demo-capture -- " + f"--scene {SCENE_ID} --frames 120 --width 1280 " + "--height 720 --view front --camera-distance 11 " + "--camera-target 0,0,1.4 --capture-label retention " + "--output-dir " + ), + ], + }, + "resolved_solver_identity": RESOLVED_SOLVER_IDENTITY, + "schema_version": AVBD_PACKET_SCHEMA_VERSION, + "scene": SCENE_ID, + "source_provenance": _source_provenance(), + "target": { + "complete_paper_reproduction": False, + "contract_rows": [ + "avbd.paper.fig.13", + ], + "covered_slice": ( + "matched CPU VBD bend/no-break row with numeric, allocation, " + "performance, and assessed visual evidence" + ), + }, + "visual_evidence": { + "bend": bend, + "retention": retention, + "semantic_review": semantic_review, + }, + } + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + packet = make_packet( + benchmark_json=args.benchmark_json, + bend_capture_manifest=args.bend_capture_manifest, + bend_image_verdict_json=args.bend_image_verdict_json, + retention_capture_manifest=args.retention_capture_manifest, + retention_image_verdict_json=(args.retention_image_verdict_json), + visual_review_json=args.visual_review_json, + paper_pdf=args.paper_pdf, + paper_figure_image=args.paper_figure_image, + avbd_packet=args.avbd_packet, + ) + except ( + OSError, + ValueError, + shared.AvbdPaperBreakableWallPacketError, + AvbdPaperVbdComparisonPacketError, + ) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(packet, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"Wrote {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp index eee6a37c3c508..99d4521b47c5b 100644 --- a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp +++ b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp @@ -2095,13 +2095,13 @@ class Fnv1a64 std::uint64_t m_value{14695981039346656037ull}; }; -struct AvbdPaperBreakableWallFixture +struct PaperBreakableWallFixture { std::unique_ptr world; std::uint64_t sceneSpecFingerprint{0}; }; -std::uint64_t avbdPaperBreakableWallFingerprint( +std::uint64_t paperBreakableWallFingerprint( const std::vector>& topologyRecords) { constexpr std::string_view kTag = "avbd-paper-breakable-wall/v1"; @@ -2154,7 +2154,8 @@ std::uint64_t avbdPaperBreakableWallFingerprint( return fingerprint.value(); } -AvbdPaperBreakableWallFixture makeAvbdPaperBreakableWallWorld() +PaperBreakableWallFixture makePaperBreakableWallWorld( + sx::RigidBodySolver solver) { constexpr int kColumns = 21; constexpr int kRows = 12; @@ -2180,13 +2181,15 @@ AvbdPaperBreakableWallFixture makeAvbdPaperBreakableWallWorld() sx::WorldOptions options; options.gravity = Eigen::Vector3d(0.0, 0.0, -9.81); options.timeStep = 1.0 / 60.0; - options.rigidBodySolver = sx::RigidBodySolver::Avbd; + options.rigidBodySolver = solver; options.rigidConstraintOptions.iterations = 20; auto world = std::make_unique(options); + const std::string solverPrefix + = solver == sx::RigidBodySolver::Vbd ? "vbd" : "avbd"; auto ground = addAvbdDemo3dSourceBox( *world, - "avbd_paper_breakable_wall_ground", + solverPrefix + "_paper_breakable_wall_ground", Eigen::Vector3d(16.0, 16.0, 0.50), 0.0, Eigen::Vector3d(0.0, 0.0, -0.25), @@ -2212,7 +2215,7 @@ AvbdPaperBreakableWallFixture makeAvbdPaperBreakableWallWorld() for (int column = 0; column < kColumns; ++column) { bricks.push_back(addAvbdDemo3dSourceBox( *world, - "avbd_paper_wall_brick_" + std::to_string(row) + "_" + solverPrefix + "_paper_wall_brick_" + std::to_string(row) + "_" + std::to_string(column), brickSize, kBrickDensity, @@ -2239,7 +2242,7 @@ AvbdPaperBreakableWallFixture makeAvbdPaperBreakableWallWorld() for (int row = 0; row < kRows; ++row) { for (int column = 0; column + 1 < kColumns; ++column) { addBreakable( - "avbd_paper_wall_horizontal_" + std::to_string(row) + "_" + solverPrefix + "_paper_wall_horizontal_" + std::to_string(row) + "_" + std::to_string(column), bricks[brickIndex(row, column)], bricks[brickIndex(row, column + 1)], @@ -2255,9 +2258,9 @@ AvbdPaperBreakableWallFixture makeAvbdPaperBreakableWallWorld() const double lowerX = brickPosition(row - 1, lowerColumn).x(); if (std::abs(upperX - lowerX) < brickSize.x()) { addBreakable( - "avbd_paper_wall_vertical_" + std::to_string(row - 1) + "_" - + std::to_string(lowerColumn) + "_" + std::to_string(row) - + "_" + std::to_string(upperColumn), + solverPrefix + "_paper_wall_vertical_" + std::to_string(row - 1) + + "_" + std::to_string(lowerColumn) + "_" + + std::to_string(row) + "_" + std::to_string(upperColumn), bricks[brickIndex(row - 1, lowerColumn)], bricks[brickIndex(row, upperColumn)], 2u, @@ -2269,7 +2272,7 @@ AvbdPaperBreakableWallFixture makeAvbdPaperBreakableWallWorld() } for (int column = 0; column < kColumns; ++column) { addBreakable( - "avbd_paper_wall_base_" + std::to_string(column), + solverPrefix + "_paper_wall_base_" + std::to_string(column), ground, bricks[brickIndex(0, column)], 3u, @@ -2288,7 +2291,8 @@ AvbdPaperBreakableWallFixture makeAvbdPaperBreakableWallWorld() impactTargets[index].x(), -5.0, impactTargets[index].y()); ballOptions.linearVelocity = Eigen::Vector3d(0.0, kBallLaunchSpeed, 0.0); auto ball = world->addRigidBody( - "avbd_paper_wall_ball_" + std::to_string(index), ballOptions); + solverPrefix + "_paper_wall_ball_" + std::to_string(index), + ballOptions); ball.setFriction(0.30); ball.setCollisionShape(sx::CollisionShape::makeSphere(kBallRadius)); balls.push_back(ball); @@ -2297,10 +2301,9 @@ AvbdPaperBreakableWallFixture makeAvbdPaperBreakableWallWorld() benchmark::DoNotOptimize(bricks.data()); benchmark::DoNotOptimize(joints.data()); benchmark::DoNotOptimize(balls.data()); - return AvbdPaperBreakableWallFixture{ + return PaperBreakableWallFixture{ .world = std::move(world), - .sceneSpecFingerprint - = avbdPaperBreakableWallFingerprint(topologyRecords)}; + .sceneSpecFingerprint = paperBreakableWallFingerprint(topologyRecords)}; } std::unique_ptr makeRigidBreakableJointWorld(std::size_t jointCount) @@ -4038,11 +4041,15 @@ static void BM_AvbdDemo3dBreakableStep(benchmark::State& state) BENCHMARK(BM_AvbdDemo3dBreakableStep); //============================================================================== -static void BM_AvbdPaperBreakableWallStep(benchmark::State& state) +void runPaperBreakableWallStepBenchmark( + benchmark::State& state, + sx::RigidBodySolver solver, + std::string_view solverKey) { constexpr std::size_t kTrajectoryFrames = 120; - auto fixture = makeAvbdPaperBreakableWallWorld(); + auto fixture = makePaperBreakableWallWorld(solver); fixture.world->enterSimulationMode(); + const std::string bodyPrefix = std::string(solverKey) + "_paper_wall_ball_"; const auto bodyNames = fixture.world->getRigidBodyNames(); std::size_t collisionShapeCount = 0; @@ -4052,7 +4059,7 @@ static void BM_AvbdPaperBreakableWallStep(benchmark::State& state) if (body.has_value() && body->hasCollisionShape()) { ++collisionShapeCount; } - if (std::string_view(name).starts_with("avbd_paper_wall_ball_")) { + if (std::string_view(name).starts_with(bodyPrefix)) { ++impactingBallCount; } } @@ -4065,8 +4072,7 @@ static void BM_AvbdPaperBreakableWallStep(benchmark::State& state) })); const auto rigidConstraintIterations = fixture.world->getRigidConstraintOptions().iterations; - const bool publicAvbdFamily - = fixture.world->getRigidBodySolver() == sx::RigidBodySolver::Avbd; + const bool publicFamily = fixture.world->getRigidBodySolver() == solver; const auto& resolved = fixture.world->getResolvedConfiguration(); const auto hasResolvedNote = [&](std::string_view domain, std::string_view requested, @@ -4079,12 +4085,12 @@ static void BM_AvbdPaperBreakableWallStep(benchmark::State& state) }) == 1; }; - const bool resolvedRigidBodyAvbd - = hasResolvedNote("rigid-body", "avbd", "avbd"); - const bool resolvedRigidContactAvbd - = hasResolvedNote("rigid-contact", "avbd", "avbd"); - const bool resolvedRigidPairConstraintAvbd - = hasResolvedNote("rigid-pair-constraint", "avbd", "avbd"); + const bool resolvedRigidBody + = hasResolvedNote("rigid-body", solverKey, solverKey); + const bool resolvedRigidContact + = hasResolvedNote("rigid-contact", solverKey, solverKey); + const bool resolvedRigidPairConstraint + = hasResolvedNote("rigid-pair-constraint", solverKey, solverKey); const std::string iterationText = std::to_string(rigidConstraintIterations); const bool resolvedRigidConstraintIterations = hasResolvedNote( "rigid-constraint-iterations", iterationText, iterationText); @@ -4095,11 +4101,13 @@ static void BM_AvbdPaperBreakableWallStep(benchmark::State& state) && fixture.world->getJointCount() == 712u && breakableJointCount == joints.size() && collisionShapeCount == 256u && impactingBallCount == 3u && rigidConstraintIterations == 20u - && publicAvbdFamily && resolvedRigidBodyAvbd && resolvedRigidContactAvbd - && resolvedRigidPairConstraintAvbd && resolvedRigidConstraintIterations; + && publicFamily && resolvedRigidBody && resolvedRigidContact + && resolvedRigidPairConstraint && resolvedRigidConstraintIterations; if (!runtimeContractPassed) { - state.SkipWithError( - "AVBD paper breakable-wall runtime configuration drifted"); + const std::string error = std::string(solverKey) + + " paper breakable-wall runtime configuration " + "drifted"; + state.SkipWithError(error.c_str()); return; } @@ -4112,10 +4120,12 @@ static void BM_AvbdPaperBreakableWallStep(benchmark::State& state) ++frame; if (frame == kTrajectoryFrames) { state.PauseTiming(); - fixture = makeAvbdPaperBreakableWallWorld(); + fixture = makePaperBreakableWallWorld(solver); if (fixture.sceneSpecFingerprint != sceneSpecFingerprint) { - state.SkipWithError( - "AVBD paper breakable-wall scene fingerprint drifted"); + const std::string error = std::string(solverKey) + + " paper breakable-wall scene fingerprint " + "drifted"; + state.SkipWithError(error.c_str()); return; } fixture.world->enterSimulationMode(); @@ -4133,13 +4143,16 @@ static void BM_AvbdPaperBreakableWallStep(benchmark::State& state) state.counters["rigid_constraint_iterations"] = static_cast(rigidConstraintIterations); state.counters["trajectory_frames"] = static_cast(kTrajectoryFrames); - state.counters["public_avbd_family"] = publicAvbdFamily ? 1.0 : 0.0; - state.counters["resolved_rigid_body_avbd"] - = resolvedRigidBodyAvbd ? 1.0 : 0.0; - state.counters["resolved_rigid_contact_avbd"] - = resolvedRigidContactAvbd ? 1.0 : 0.0; - state.counters["resolved_rigid_pair_constraint_avbd"] - = resolvedRigidPairConstraintAvbd ? 1.0 : 0.0; + state.counters[std::string("public_") + std::string(solverKey) + "_family"] + = publicFamily ? 1.0 : 0.0; + state.counters[std::string("resolved_rigid_body_") + std::string(solverKey)] + = resolvedRigidBody ? 1.0 : 0.0; + state + .counters[std::string("resolved_rigid_contact_") + std::string(solverKey)] + = resolvedRigidContact ? 1.0 : 0.0; + state.counters + [std::string("resolved_rigid_pair_constraint_") + std::string(solverKey)] + = resolvedRigidPairConstraint ? 1.0 : 0.0; state.counters["resolved_rigid_constraint_iterations"] = resolvedRigidConstraintIterations ? 1.0 : 0.0; state.counters["runtime_contract_passed"] = runtimeContractPassed ? 1.0 : 0.0; @@ -4148,8 +4161,21 @@ static void BM_AvbdPaperBreakableWallStep(benchmark::State& state) state.counters["scene_spec_fingerprint_lo"] = static_cast(static_cast(sceneSpecFingerprint)); } + +//============================================================================== +static void BM_AvbdPaperBreakableWallStep(benchmark::State& state) +{ + runPaperBreakableWallStepBenchmark(state, sx::RigidBodySolver::Avbd, "avbd"); +} BENCHMARK(BM_AvbdPaperBreakableWallStep)->Iterations(120); +//============================================================================== +static void BM_VbdPaperBreakableWallStep(benchmark::State& state) +{ + runPaperBreakableWallStepBenchmark(state, sx::RigidBodySolver::Vbd, "vbd"); +} +BENCHMARK(BM_VbdPaperBreakableWallStep)->Iterations(120); + //============================================================================== static void BM_AvbdRigidBreakableJointStep(benchmark::State& state) { diff --git a/tests/test_avbd_packet_schema.py b/tests/test_avbd_packet_schema.py index 5f12366d85c9b..c5b11b1e77804 100644 --- a/tests/test_avbd_packet_schema.py +++ b/tests/test_avbd_packet_schema.py @@ -204,6 +204,83 @@ def test_public_avbd_family_requires_explicit_selection_source(): ) +def test_public_vbd_family_records_matching_world_owned_rows(): + identity = schema.make_resolved_solver_identity( + resolved_rigid_contact_family="vbd", + rigid_point_joint_solver="vbd", + avbd_rigid_contact_config_emplaced=False, + recorded_from="World resolved-configuration report", + rigid_contact_selection="world_solver_family", + ) + assert identity["rigid_contact_solver"] == "vbd" + assert identity["rigid_point_joint_solver"] == "vbd" + + +def test_public_world_family_rejects_contradictory_point_joint_family(): + packet = { + "schema_version": schema.AVBD_PACKET_SCHEMA_VERSION, + schema.RESOLVED_SOLVER_IDENTITY_KEY: { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": "contradictory test identity", + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "vbd", + "rigid_point_joint_solver": "avbd", + }, + } + + errors = schema.resolved_solver_identity_errors(packet, "case") + + assert any( + "rigid_point_joint_solver to be 'none' or match" in error for error in errors + ) + + +@pytest.mark.parametrize( + ("selection", "contact_solver", "emplaced"), + [ + ("body_opt_in", "avbd", True), + ("contact_solver_method", "sequential_impulse", False), + ], +) +def test_vbd_point_rows_require_public_vbd_world_family( + selection, + contact_solver, + emplaced, +): + packet = { + "schema_version": schema.AVBD_PACKET_SCHEMA_VERSION, + schema.RESOLVED_SOLVER_IDENTITY_KEY: { + "avbd_rigid_contact_config_emplaced": emplaced, + "recorded_from": "impossible VBD point-row identity", + "rigid_contact_selection": selection, + "rigid_contact_solver": contact_solver, + "rigid_point_joint_solver": "vbd", + }, + } + + errors = schema.resolved_solver_identity_errors(packet, "case") + + assert any("rigid_point_joint_solver 'vbd' requires" in error for error in errors) + + +def test_schema_v2_cannot_claim_vbd_without_selection_source_contract(): + packet = { + "schema_version": 2, + schema.RESOLVED_SOLVER_IDENTITY_KEY: { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": "pre-selection-source schema", + "rigid_contact_solver": "vbd", + "rigid_point_joint_solver": "vbd", + }, + } + + errors = schema.resolved_solver_identity_errors(packet, "case") + + assert any( + "VBD solver identities require schema_version 3" in error for error in errors + ) + + def test_unrecognized_report_family_raises(): with pytest.raises(ValueError): schema.resolved_rigid_contact_solver_from_report("magic-solver") diff --git a/tests/test_check_avbd_packets.py b/tests/test_check_avbd_packets.py index e2510d18935b2..936fc2ae1835e 100644 --- a/tests/test_check_avbd_packets.py +++ b/tests/test_check_avbd_packets.py @@ -104,6 +104,44 @@ def test_public_avbd_contact_claim_without_private_config_passes(tmp_path): assert module.packet_errors(path) == [] +def test_public_world_family_with_crossed_point_joint_solver_is_rejected( + tmp_path, +): + module = _load_module() + path = _write_packet( + tmp_path, + "avbd-new-scene-packet.json", + { + "schema_version": module.AVBD_PACKET_SCHEMA_VERSION, + "resolved_solver_identity": _identity( + rigid_contact_solver="vbd", + rigid_contact_selection="world_solver_family", + rigid_point_joint_solver="avbd", + ), + }, + ) + errors = module.packet_errors(path) + assert any( + "rigid_point_joint_solver to be 'none' or match" in error for error in errors + ) + + +def test_contact_method_with_vbd_point_rows_is_rejected(tmp_path): + module = _load_module() + path = _write_packet( + tmp_path, + "avbd-new-scene-packet.json", + { + "schema_version": module.AVBD_PACKET_SCHEMA_VERSION, + "resolved_solver_identity": _identity( + rigid_point_joint_solver="vbd", + ), + }, + ) + errors = module.packet_errors(path) + assert any("rigid_point_joint_solver 'vbd' requires" in error for error in errors) + + def test_unknown_solver_name_is_rejected(tmp_path): module = _load_module() path = _write_packet( diff --git a/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp b/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp index 835c3c83983ea..f58b9a79fca4d 100644 --- a/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp +++ b/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp @@ -5573,6 +5573,75 @@ TEST(AvbdRigidBlock, RigidWorldPointJointFractureMarksJointBroken) EXPECT_TRUE(registry.get(jointEntity).broken); } +//============================================================================== +TEST(AvbdRigidBlock, RigidWorldFixedPenaltyFractureUsesFinitePenaltyForce) +{ + sx::World world; + world.setGravity(Vec3::Zero()); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + + sx::RigidBodyOptions linkOptions; + linkOptions.mass = 1.0; + linkOptions.position = Vec3::UnitX(); + linkOptions.orientation = rotationZ(0.6); + auto link = world.addRigidBody("link", linkOptions); + + auto& registry = dart::simulation::detail::registryOf(world); + const entt::entity jointEntity = registry.create(); + auto& joint = registry.emplace(jointEntity); + registry.emplace(jointEntity); + registry.emplace(jointEntity); + joint.type = sx::comps::JointType::Fixed; + + std::vector joints(1); + joints[0].joint = jointEntity; + joints[0].bodyA = sx::detail::toRegistryEntity(base.getEntity()); + joints[0].bodyB = sx::detail::toRegistryEntity(link.getEntity()); + joints[0].anchorA = Vec3::Zero(); + joints[0].anchorB = Vec3::UnitX(); + joints[0].targetRelativeOrientation = Eigen::Quaterniond::Identity(); + joints[0].startStiffness = 100.0; + joints[0].linearMaterialStiffness = 100.0; + joints[0].angularMaterialStiffness = 100.0; + joints[0].maxStiffness = 1000.0; + joints[0].fractureThreshold = 1e-12; + + vbd::AvbdRigidWorldContactStepOptions stepOptions; + stepOptions.solve.descent.iterations = 1; + stepOptions.solve.descent.regularization = 1e-12; + stepOptions.solve.formulation + = vbd::AvbdRigidWorldContactSolveOptions::Formulation::FixedPenalty; + vbd::AvbdScalarRowInventory normalInventory; + vbd::AvbdScalarRowInventory frictionInventory; + vbd::AvbdScalarRowInventory jointLinearInventory; + vbd::AvbdScalarRowInventory jointAngularInventory; + + const vbd::AvbdRigidWorldContactStepResult result + = vbd::runAvbdRigidWorldContactStep( + registry, + std::span(), + joints, + normalInventory, + frictionInventory, + jointLinearInventory, + jointAngularInventory, + /*timeStep=*/1.0, + stepOptions); + + EXPECT_EQ(result.solve.fracturedJoints, 1u); + ASSERT_EQ(result.solve.fracturedJointIndices.size(), 1u); + EXPECT_EQ(result.solve.fracturedJointIndices[0], 0u); + EXPECT_EQ(result.fracturedJoints, 1u); + EXPECT_TRUE(registry.get(jointEntity).broken); + EXPECT_TRUE(normalInventory.empty()); + EXPECT_TRUE(frictionInventory.empty()); + EXPECT_TRUE(jointLinearInventory.empty()); + EXPECT_TRUE(jointAngularInventory.empty()); +} + //============================================================================== TEST(AvbdRigidBlock, RigidWorldPointJointFractureUsesSolveScratchAllocator) { @@ -6536,6 +6605,389 @@ TEST(AvbdRigidBlock, RigidWorldContactSnapshotSolveMovesDynamicBody) EXPECT_TRUE(foundPositiveNormalDual); } +//============================================================================== +TEST(AvbdRigidBlock, RigidWorldFixedPenaltyClearsAugmentedNormalState) +{ + sx::World world; + world.setGravity(Vec3::Zero()); + + sx::RigidBodyOptions groundOptions; + groundOptions.isStatic = true; + groundOptions.position = Vec3(0.0, 0.0, -0.25); + auto ground = world.addRigidBody("ground", groundOptions); + ground.setCollisionShape(sx::CollisionShape::makeBox(Vec3(2.0, 2.0, 0.25))); + + sx::RigidBodyOptions sphereOptions; + sphereOptions.mass = 1.0; + sphereOptions.position = Vec3(0.0, 0.0, 0.4); + auto sphere = world.addRigidBody("sphere", sphereOptions); + sphere.setCollisionShape(sx::CollisionShape::makeSphere(0.5)); + + vbd::AvbdRigidWorldContactOptions contactOptions; + contactOptions.startStiffness = 200.0; + contactOptions.maxStiffness = 1000.0; + const auto buildSnapshot = [&]() { + return vbd::buildAvbdRigidWorldContactSnapshot( + dart::simulation::detail::registryOf(world), + world.collide(), + contactOptions); + }; + + vbd::AvbdScalarRowInventory normalInventory; + vbd::AvbdScalarRowInventory frictionInventory; + vbd::AvbdScalarRowInventory jointLinearInventory; + vbd::AvbdScalarRowInventory jointAngularInventory; + vbd::AvbdScalarRowInventory motorInventory; + vbd::AvbdScalarRowInventory distanceSpringInventory; + vbd::AvbdRigidWorldContactSolveScratch solveScratch; + vbd::AvbdRigidWorldContactSolveOptions solveOptions; + solveOptions.descent.iterations = 4; + solveOptions.descent.convergenceDisplacement = 1e-12; + + auto augmentedSnapshot = buildSnapshot(); + ASSERT_FALSE(augmentedSnapshot.contacts.empty()); + const auto augmentedResult = vbd::solveAvbdRigidWorldContactSnapshot( + augmentedSnapshot, + normalInventory, + frictionInventory, + jointLinearInventory, + jointAngularInventory, + motorInventory, + distanceSpringInventory, + /*timeStep=*/1.0, + solveScratch, + solveOptions); + ASSERT_EQ(augmentedResult.normalRows, augmentedSnapshot.contacts.size()); + ASSERT_FALSE(normalInventory.empty()); + EXPECT_TRUE( + std::ranges::any_of( + normalInventory.records(), + [](const vbd::AvbdScalarRowRecord& record) { + return record.state.lambda > 0.0; + })); + + solveOptions.formulation + = vbd::AvbdRigidWorldContactSolveOptions::Formulation::FixedPenalty; + auto fixedSnapshot = buildSnapshot(); + const auto fixedResult = vbd::solveAvbdRigidWorldContactSnapshot( + fixedSnapshot, + normalInventory, + frictionInventory, + jointLinearInventory, + jointAngularInventory, + motorInventory, + distanceSpringInventory, + /*timeStep=*/1.0, + solveScratch, + solveOptions); + ASSERT_EQ(fixedResult.normalRows, fixedSnapshot.contacts.size()); + EXPECT_TRUE(normalInventory.empty()); + EXPECT_TRUE(frictionInventory.empty()); + ASSERT_EQ(solveScratch.normalRows.size(), fixedResult.normalRows); + for (const auto& row : solveScratch.normalRows) { + EXPECT_DOUBLE_EQ(row.row.state.stiffness, 200.0); + EXPECT_DOUBLE_EQ(row.row.state.lambda, 0.0); + } + + auto repeatedFixedSnapshot = buildSnapshot(); + const auto repeatedFixedResult = vbd::solveAvbdRigidWorldContactSnapshot( + repeatedFixedSnapshot, + normalInventory, + frictionInventory, + jointLinearInventory, + jointAngularInventory, + motorInventory, + distanceSpringInventory, + /*timeStep=*/1.0, + solveScratch, + solveOptions); + ASSERT_EQ( + repeatedFixedResult.normalRows, repeatedFixedSnapshot.contacts.size()); + EXPECT_TRUE(normalInventory.empty()); + EXPECT_TRUE(frictionInventory.empty()); + ASSERT_EQ(solveScratch.normalRows.size(), repeatedFixedResult.normalRows); + for (const auto& row : solveScratch.normalRows) { + EXPECT_DOUBLE_EQ(row.row.state.stiffness, 200.0); + EXPECT_DOUBLE_EQ(row.row.state.lambda, 0.0); + } +} + +//============================================================================== +TEST(AvbdRigidBlock, RigidWorldFixedPenaltyClearsAugmentedFrictionState) +{ + sx::World world; + world.setGravity(Vec3::Zero()); + + sx::RigidBodyOptions groundOptions; + groundOptions.isStatic = true; + groundOptions.position = Vec3(0.0, 0.0, -0.25); + auto ground = world.addRigidBody("ground", groundOptions); + ground.setCollisionShape(sx::CollisionShape::makeBox(Vec3(2.0, 2.0, 0.25))); + + sx::RigidBodyOptions sphereOptions; + sphereOptions.mass = 1.0; + sphereOptions.position = Vec3(0.0, 0.0, 0.4); + auto sphere = world.addRigidBody("sphere", sphereOptions); + sphere.setCollisionShape(sx::CollisionShape::makeSphere(0.5)); + + vbd::AvbdRigidWorldContactOptions contactOptions; + contactOptions.startStiffness = 200.0; + contactOptions.maxStiffness = 1000.0; + const auto buildSlidingSnapshot = [&]() { + auto snapshot = vbd::buildAvbdRigidWorldContactSnapshot( + dart::simulation::detail::registryOf(world), + world.collide(), + contactOptions); + const std::size_t sphereIndex = findEntityIndex( + snapshot.entities, + dart::simulation::detail::toRegistryEntity(sphere.getEntity())); + snapshot.inertialTargets[sphereIndex].position.x() += 0.1; + for (auto& contact : snapshot.contacts) { + contact.frictionCoefficient = 1.0; + } + return snapshot; + }; + + vbd::AvbdRigidWorldContactSolveOptions solveOptions; + solveOptions.descent.iterations = 4; + solveOptions.descent.convergenceDisplacement = 0.0; + solveOptions.friction.beta = 500.0; + solveOptions.friction.maxStiffness = 1000.0; + + vbd::AvbdScalarRowInventory normalInventory; + vbd::AvbdScalarRowInventory frictionInventory; + vbd::AvbdScalarRowInventory jointLinearInventory; + vbd::AvbdScalarRowInventory jointAngularInventory; + vbd::AvbdScalarRowInventory motorInventory; + vbd::AvbdScalarRowInventory distanceSpringInventory; + vbd::AvbdRigidWorldContactSolveScratch solveScratch; + auto augmentedSnapshot = buildSlidingSnapshot(); + const auto augmentedResult = vbd::solveAvbdRigidWorldContactSnapshot( + augmentedSnapshot, + normalInventory, + frictionInventory, + jointLinearInventory, + jointAngularInventory, + motorInventory, + distanceSpringInventory, + /*timeStep=*/1.0, + solveScratch, + solveOptions); + ASSERT_GT(augmentedResult.frictionRows, 0u); + ASSERT_FALSE(frictionInventory.empty()); + for (std::size_t i = 0; i < frictionInventory.size(); ++i) { + frictionInventory[i].state.lambda = i % 2u == 0u ? 3.0 : -4.0; + frictionInventory[i].state.stiffness = 900.0; + } + + solveOptions.formulation + = vbd::AvbdRigidWorldContactSolveOptions::Formulation::FixedPenalty; + auto switchedSnapshot = buildSlidingSnapshot(); + const auto switchedResult = vbd::solveAvbdRigidWorldContactSnapshot( + switchedSnapshot, + normalInventory, + frictionInventory, + jointLinearInventory, + jointAngularInventory, + motorInventory, + distanceSpringInventory, + /*timeStep=*/1.0, + solveScratch, + solveOptions); + ASSERT_GT(switchedResult.frictionRows, 0u); + EXPECT_TRUE(normalInventory.empty()); + EXPECT_TRUE(frictionInventory.empty()); + ASSERT_EQ(2u * solveScratch.frictionRows.size(), switchedResult.frictionRows); + for (const auto& rowPair : solveScratch.frictionRows) { + EXPECT_DOUBLE_EQ(rowPair.first.state.stiffness, 200.0); + EXPECT_DOUBLE_EQ(rowPair.first.state.lambda, 0.0); + EXPECT_DOUBLE_EQ(rowPair.second.state.stiffness, 200.0); + EXPECT_DOUBLE_EQ(rowPair.second.state.lambda, 0.0); + } + + auto repeatedSnapshot = buildSlidingSnapshot(); + const auto repeatedResult = vbd::solveAvbdRigidWorldContactSnapshot( + repeatedSnapshot, + normalInventory, + frictionInventory, + jointLinearInventory, + jointAngularInventory, + motorInventory, + distanceSpringInventory, + /*timeStep=*/1.0, + solveScratch, + solveOptions); + ASSERT_EQ(repeatedResult.frictionRows, switchedResult.frictionRows); + EXPECT_TRUE(normalInventory.empty()); + EXPECT_TRUE(frictionInventory.empty()); + ASSERT_EQ(2u * solveScratch.frictionRows.size(), repeatedResult.frictionRows); + for (const auto& rowPair : solveScratch.frictionRows) { + EXPECT_DOUBLE_EQ(rowPair.first.state.stiffness, 200.0); + EXPECT_DOUBLE_EQ(rowPair.first.state.lambda, 0.0); + EXPECT_DOUBLE_EQ(rowPair.second.state.stiffness, 200.0); + EXPECT_DOUBLE_EQ(rowPair.second.state.lambda, 0.0); + } + + vbd::AvbdScalarRowInventory coldNormalInventory; + vbd::AvbdScalarRowInventory coldFrictionInventory; + vbd::AvbdScalarRowInventory coldJointLinearInventory; + vbd::AvbdScalarRowInventory coldJointAngularInventory; + vbd::AvbdScalarRowInventory coldMotorInventory; + vbd::AvbdScalarRowInventory coldDistanceSpringInventory; + vbd::AvbdRigidWorldContactSolveScratch coldSolveScratch; + auto coldSnapshot = buildSlidingSnapshot(); + const auto coldResult = vbd::solveAvbdRigidWorldContactSnapshot( + coldSnapshot, + coldNormalInventory, + coldFrictionInventory, + coldJointLinearInventory, + coldJointAngularInventory, + coldMotorInventory, + coldDistanceSpringInventory, + /*timeStep=*/1.0, + coldSolveScratch, + solveOptions); + ASSERT_EQ(coldResult.frictionRows, switchedResult.frictionRows); + ASSERT_EQ(coldSnapshot.states.size(), switchedSnapshot.states.size()); + for (std::size_t i = 0; i < coldSnapshot.states.size(); ++i) { + EXPECT_NEAR( + (coldSnapshot.states[i].position - switchedSnapshot.states[i].position) + .norm(), + 0.0, + 1e-12); + EXPECT_NEAR( + (coldSnapshot.states[i].orientation.coeffs() + - switchedSnapshot.states[i].orientation.coeffs()) + .norm(), + 0.0, + 1e-12); + } +} + +//============================================================================== +TEST(AvbdRigidBlock, RigidWorldVbdToAvbdFiniteRowsMatchFreshAvbd) +{ + sx::World world; + world.setGravity(Vec3::Zero()); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + + sx::RigidBodyOptions linkOptions; + linkOptions.mass = 1.0; + linkOptions.position = Vec3::UnitX(); + auto link = world.addRigidBody("link", linkOptions); + + auto& registry = dart::simulation::detail::registryOf(world); + const entt::entity jointEntity = registry.create(); + auto& jointModel = registry.emplace(jointEntity); + jointModel.type = sx::comps::JointType::Fixed; + + vbd::AvbdRigidWorldPointJointInput joint; + joint.joint = jointEntity; + joint.bodyA = sx::detail::toRegistryEntity(base.getEntity()); + joint.bodyB = sx::detail::toRegistryEntity(link.getEntity()); + joint.anchorA = Vec3::Zero(); + joint.anchorB = Vec3::UnitX(); + joint.linearAxisMask = 1u; + joint.angularAxisMask = 0u; + joint.startStiffness = 4.0; + joint.linearMaterialStiffness = 20.0; + joint.maxStiffness = 100.0; + + const auto buildSnapshot = [&]() { + vbd::AvbdRigidWorldContactSnapshot snapshot + = vbd::buildAvbdRigidWorldContactSnapshot( + registry, std::span()); + EXPECT_EQ( + vbd::appendAvbdRigidWorldPointJoints( + registry, + std::span{&joint, 1u}, + snapshot), + 1u); + vbd::predictAvbdRigidWorldContactInertialTargets( + registry, snapshot, /*timeStep=*/1.0); + return snapshot; + }; + + vbd::AvbdScalarRowInventory normalInventory; + vbd::AvbdScalarRowInventory frictionInventory; + vbd::AvbdScalarRowInventory jointLinearInventory; + vbd::AvbdScalarRowInventory jointAngularInventory; + + vbd::AvbdRigidWorldContactSolveOptions vbdOptions; + vbdOptions.descent.iterations = 1; + vbdOptions.descent.regularization = 1e-12; + vbdOptions.formulation + = vbd::AvbdRigidWorldContactSolveOptions::Formulation::FixedPenalty; + auto vbdSnapshot = buildSnapshot(); + const auto vbdResult = vbd::solveAvbdRigidWorldContactSnapshot( + vbdSnapshot, + normalInventory, + frictionInventory, + jointLinearInventory, + jointAngularInventory, + /*timeStep=*/1.0, + vbdOptions); + ASSERT_EQ(vbdResult.jointLinearRows, 1u); + EXPECT_TRUE(normalInventory.empty()); + EXPECT_TRUE(frictionInventory.empty()); + EXPECT_TRUE(jointLinearInventory.empty()); + EXPECT_TRUE(jointAngularInventory.empty()); + + vbd::AvbdRigidWorldContactSolveOptions avbdOptions; + avbdOptions.descent.iterations = 1; + avbdOptions.descent.regularization = 1e-12; + avbdOptions.row.beta = 0.0; + auto switchedSnapshot = buildSnapshot(); + const auto switchedResult = vbd::solveAvbdRigidWorldContactSnapshot( + switchedSnapshot, + normalInventory, + frictionInventory, + jointLinearInventory, + jointAngularInventory, + /*timeStep=*/1.0, + avbdOptions); + ASSERT_EQ(switchedResult.jointLinearRows, 1u); + ASSERT_EQ(jointLinearInventory.size(), 1u); + EXPECT_DOUBLE_EQ(jointLinearInventory[0].state.stiffness, 4.0); + + vbd::AvbdScalarRowInventory coldNormalInventory; + vbd::AvbdScalarRowInventory coldFrictionInventory; + vbd::AvbdScalarRowInventory coldJointLinearInventory; + vbd::AvbdScalarRowInventory coldJointAngularInventory; + auto coldSnapshot = buildSnapshot(); + const auto coldResult = vbd::solveAvbdRigidWorldContactSnapshot( + coldSnapshot, + coldNormalInventory, + coldFrictionInventory, + coldJointLinearInventory, + coldJointAngularInventory, + /*timeStep=*/1.0, + avbdOptions); + ASSERT_EQ(coldResult.jointLinearRows, switchedResult.jointLinearRows); + ASSERT_EQ(coldJointLinearInventory.size(), jointLinearInventory.size()); + EXPECT_DOUBLE_EQ(coldJointLinearInventory[0].state.stiffness, 4.0); + EXPECT_DOUBLE_EQ( + jointLinearInventory[0].state.stiffness, + coldJointLinearInventory[0].state.stiffness); + ASSERT_EQ(coldSnapshot.states.size(), switchedSnapshot.states.size()); + for (std::size_t i = 0; i < coldSnapshot.states.size(); ++i) { + EXPECT_NEAR( + (coldSnapshot.states[i].position - switchedSnapshot.states[i].position) + .norm(), + 0.0, + 1e-12); + EXPECT_NEAR( + (coldSnapshot.states[i].orientation.coeffs() + - switchedSnapshot.states[i].orientation.coeffs()) + .norm(), + 0.0, + 1e-12); + } +} + //============================================================================== TEST(AvbdRigidBlock, RigidWorldContactSnapshotApplyWritesDynamicBodyState) { diff --git a/tests/unit/simulation/world/test_serialization.cpp b/tests/unit/simulation/world/test_serialization.cpp index 2e7fd517484e5..7ea120e0bd10f 100644 --- a/tests/unit/simulation/world/test_serialization.cpp +++ b/tests/unit/simulation/world/test_serialization.cpp @@ -1099,6 +1099,28 @@ TEST(Serialization, PreservesPublicAvbdSolverFamily) sx::ContactSolverMethod::SequentialImpulse); } +TEST(Serialization, PreservesPublicVbdSolverFamily) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.rigidBodySolver = sx::RigidBodySolver::Vbd; + options.rigidConstraintOptions.iterations = 20; + sx::World world1(options); + + std::stringstream ss; + world1.saveBinary(ss); + + sx::World world2; + world2.loadBinary(ss); + + EXPECT_EQ(world2.getRigidBodySolver(), sx::RigidBodySolver::Vbd); + EXPECT_EQ(world2.getRigidConstraintOptions().iterations, 20u); + EXPECT_EQ( + world2.getContactSolverMethod(), + sx::ContactSolverMethod::SequentialImpulse); +} + TEST(Serialization, RejectsMissingRigidConstraintIterationBudget) { namespace sx = dart::simulation; @@ -1141,6 +1163,29 @@ TEST(Serialization, RejectsAvbdSolverInPreAvbdFormat) EXPECT_THROW(world2.loadBinary(downgraded), sx::InvalidArgumentException); } +TEST(Serialization, RejectsVbdSolverInPreVbdFormat) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.rigidBodySolver = sx::RigidBodySolver::Vbd; + sx::World world1(options); + std::stringstream output; + world1.saveBinary(output); + + auto serialized = output.str(); + ASSERT_GE(serialized.size(), 2u * sizeof(std::uint32_t)); + constexpr std::uint32_t legacyVersion = 29u; + std::memcpy( + serialized.data() + sizeof(std::uint32_t), + &legacyVersion, + sizeof(legacyVersion)); + std::stringstream downgraded(serialized); + + sx::World world2; + EXPECT_THROW(world2.loadBinary(downgraded), sx::InvalidArgumentException); +} + TEST(Serialization, RejectsNonDefaultRigidConstraintOptionsForIpc) { namespace sx = dart::simulation; diff --git a/tests/unit/simulation/world/test_world.cpp b/tests/unit/simulation/world/test_world.cpp index 059b0e80e805c..097ccbb0eacc8 100644 --- a/tests/unit/simulation/world/test_world.cpp +++ b/tests/unit/simulation/world/test_world.cpp @@ -3499,46 +3499,64 @@ void configureRigidAvbdContactRowsScene(dart::simulation::World& world) sx::detail::toRegistryEntity(sphere.getEntity())); } -void configurePublicRigidAvbdContactAndBreakableJointRowsScene( - dart::simulation::World& world) +void configurePublicRigidBlockDescentContactAndBreakableJointRowsScene( + dart::simulation::World& world, + dart::simulation::RigidBodySolver solver, + std::string_view solverName) { namespace sx = dart::simulation; - world.setRigidBodySolver(sx::RigidBodySolver::Avbd); + world.setRigidBodySolver(solver); world.setRigidConstraintOptions({.iterations = 20u}); world.setGravity(Eigen::Vector3d::Zero()); world.setTimeStep(1.0 / 60.0); + const auto objectName = [solverName](std::string_view suffix) { + return std::format("public_{}_allocation_{}", solverName, suffix); + }; + sx::RigidBodyOptions groundOptions; groundOptions.isStatic = true; groundOptions.position = Eigen::Vector3d(0.0, 0.0, -0.25); - auto ground - = world.addRigidBody("public_avbd_allocation_ground", groundOptions); + auto ground = world.addRigidBody(objectName("ground"), groundOptions); ground.setCollisionShape( sx::CollisionShape::makeBox(Eigen::Vector3d(2.0, 2.0, 0.25))); sx::RigidBodyOptions brickOptions; brickOptions.mass = 1.0; brickOptions.position = Eigen::Vector3d(0.0, 0.0, 0.20); - auto brick = world.addRigidBody("public_avbd_allocation_brick", brickOptions); + auto brick = world.addRigidBody(objectName("brick"), brickOptions); brick.setCollisionShape( sx::CollisionShape::makeBox(Eigen::Vector3d(0.3, 0.15, 0.2))); auto joint = world.addJoint( ground, brick, - makeJointSpec( - "public_avbd_allocation_breakable_joint", sx::JointType::Fixed)); + makeJointSpec(objectName("breakable_joint"), sx::JointType::Fixed)); joint.setBreakForce(1.0e6); sx::RigidBodyOptions ballOptions; ballOptions.mass = 5.0; ballOptions.position = Eigen::Vector3d(0.0, -0.35, 0.20); ballOptions.linearVelocity = Eigen::Vector3d(0.0, 1.0, 0.0); - auto ball = world.addRigidBody("public_avbd_allocation_ball", ballOptions); + auto ball = world.addRigidBody(objectName("ball"), ballOptions); ball.setCollisionShape(sx::CollisionShape::makeSphere(0.25)); } +void configurePublicRigidAvbdContactAndBreakableJointRowsScene( + dart::simulation::World& world) +{ + configurePublicRigidBlockDescentContactAndBreakableJointRowsScene( + world, dart::simulation::RigidBodySolver::Avbd, "avbd"); +} + +void configurePublicRigidVbdContactAndBreakableJointRowsScene( + dart::simulation::World& world) +{ + configurePublicRigidBlockDescentContactAndBreakableJointRowsScene( + world, dart::simulation::RigidBodySolver::Vbd, "vbd"); +} + void configureRigidBoxedLcpContactRowsScene(dart::simulation::World& world) { namespace sx = dart::simulation; @@ -6043,6 +6061,46 @@ TEST(World, PublicAvbdFamilyProjectsContactsWithoutPrivateBodyConfigs) EXPECT_GT(sphere.getTranslation().z(), initialPosition.z()); } +TEST(World, PublicVbdFamilyProjectsContactsWithoutPrivateBodyConfigs) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.timeStep = 0.05; + options.rigidBodySolver = sx::RigidBodySolver::Vbd; + options.rigidConstraintOptions.iterations = 20; + sx::World world(options); + + sx::RigidBodyOptions groundOptions; + groundOptions.isStatic = true; + groundOptions.position = Eigen::Vector3d(0.0, 0.0, -0.25); + auto ground = world.addRigidBody("public_vbd_ground", groundOptions); + ground.setCollisionShape( + sx::CollisionShape::makeBox(Eigen::Vector3d(2.0, 2.0, 0.25))); + + sx::RigidBodyOptions sphereOptions; + sphereOptions.mass = 1.0; + sphereOptions.position = Eigen::Vector3d(0.0, 0.0, 0.4); + auto sphere = world.addRigidBody("public_vbd_sphere", sphereOptions); + sphere.setCollisionShape(sx::CollisionShape::makeSphere(0.5)); + + const auto& registry = sx::detail::registryOf(world); + EXPECT_FALSE(registry.all_of( + sx::detail::toRegistryEntity(ground.getEntity()))); + EXPECT_FALSE(registry.all_of( + sx::detail::toRegistryEntity(sphere.getEntity()))); + ASSERT_EQ(world.collide().size(), 1u); + + const Eigen::Vector3d initialPosition = sphere.getTranslation(); + world.step(); + + EXPECT_EQ(world.getRigidBodySolver(), sx::RigidBodySolver::Vbd); + EXPECT_EQ(world.computeStepMetrics().lastStepIterations, 20u); + EXPECT_GT(sphere.getLinearVelocity().z(), 0.0); + EXPECT_GT(sphere.getTranslation().z(), initialPosition.z()); +} + TEST(World, PublicAvbdFamilyRejectsConflictingContactPolicy) { namespace sx = dart::simulation; @@ -6075,6 +6133,38 @@ TEST(World, PublicAvbdFamilyRejectsConflictingContactPolicy) sx::ContactSolverMethod::SequentialImpulse); } +TEST(World, PublicVbdFamilyRejectsConflictingContactPolicy) +{ + namespace sx = dart::simulation; + + sx::WorldOptions invalidOptions; + invalidOptions.rigidBodySolver = sx::RigidBodySolver::Vbd; + invalidOptions.contactSolverMethod = sx::ContactSolverMethod::BoxedLcp; + EXPECT_THROW( + { + sx::World invalidWorld(invalidOptions); + (void)invalidWorld; + }, + sx::InvalidArgumentException); + + sx::World boxedWorld; + boxedWorld.setContactSolverMethod(sx::ContactSolverMethod::BoxedLcp); + EXPECT_THROW( + boxedWorld.setRigidBodySolver(sx::RigidBodySolver::Vbd), + sx::InvalidArgumentException); + EXPECT_EQ( + boxedWorld.getRigidBodySolver(), sx::RigidBodySolver::SequentialImpulse); + + sx::World vbdWorld; + vbdWorld.setRigidBodySolver(sx::RigidBodySolver::Vbd); + EXPECT_THROW( + vbdWorld.setContactSolverMethod(sx::ContactSolverMethod::BoxedLcp), + sx::InvalidArgumentException); + EXPECT_EQ( + vbdWorld.getContactSolverMethod(), + sx::ContactSolverMethod::SequentialImpulse); +} + TEST(World, PublicAvbdFamilyRejectsMultibodyWorlds) { namespace sx = dart::simulation; @@ -6096,6 +6186,27 @@ TEST(World, PublicAvbdFamilyRejectsMultibodyWorlds) sx::RigidBodySolver::SequentialImpulse); } +TEST(World, PublicVbdFamilyRejectsMultibodyWorlds) +{ + namespace sx = dart::simulation; + + sx::World vbdFirst; + vbdFirst.setRigidBodySolver(sx::RigidBodySolver::Vbd); + EXPECT_THROW( + vbdFirst.addMultibody("unsupported_robot"), + sx::InvalidOperationException); + EXPECT_EQ(vbdFirst.getMultibodyCount(), 0u); + + sx::World multibodyFirst; + (void)multibodyFirst.addMultibody("robot"); + EXPECT_THROW( + multibodyFirst.setRigidBodySolver(sx::RigidBodySolver::Vbd), + sx::InvalidOperationException); + EXPECT_EQ( + multibodyFirst.getRigidBodySolver(), + sx::RigidBodySolver::SequentialImpulse); +} + TEST(World, SetContactSolverMethodUpdatesPolicy) { namespace sx = dart::simulation; @@ -8484,6 +8595,10 @@ TEST(World, BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths) "public rigid AVBD contact and breakable-joint rows", configurePublicRigidAvbdContactAndBreakableJointRowsScene, true); + expectNoWorldBaseAllocatorActivityDuringBakedSteps( + "public rigid VBD contact and breakable-joint rows", + configurePublicRigidVbdContactAndBreakableJointRowsScene, + true); expectNoWorldBaseAllocatorActivityDuringBakedSteps( "rigid AVBD fixed-joint rows", configureRigidAvbdFixedJointRowsScene); expectNoWorldBaseAllocatorActivityDuringBakedSteps( @@ -10188,6 +10303,10 @@ TEST(World, BakedRigidBodyContactStepsDoNotAllocateGlobalHeap) "public rigid AVBD contact and breakable-joint rows", configurePublicRigidAvbdContactAndBreakableJointRowsScene, true); + expectNoGlobalHeapAllocationsDuringBakedSteps( + "public rigid VBD contact and breakable-joint rows", + configurePublicRigidVbdContactAndBreakableJointRowsScene, + true); expectNoGlobalHeapAllocationsDuringBakedSteps( "rigid AVBD fixed-joint rows", configureRigidAvbdFixedJointRowsScene); expectNoGlobalHeapAllocationsDuringBakedSteps( @@ -10813,6 +10932,10 @@ TEST(World, BakedAvbdVbdRowsDoNotMallocOnHeap) "public rigid AVBD contact and breakable-joint rows", configurePublicRigidAvbdContactAndBreakableJointRowsScene, true); + expectNoRawHeapAllocationsDuringFirstPostBakeSteps( + "public rigid VBD contact and breakable-joint rows", + configurePublicRigidVbdContactAndBreakableJointRowsScene, + true); expectNoRawHeapAllocationsDuringFirstPostBakeSteps( "rigid AVBD fixed-joint rows", configureRigidAvbdFixedJointRowsScene); expectNoRawHeapAllocationsDuringFirstPostBakeSteps( diff --git a/tests/unit/simulation/world/test_world_resolved_configuration.cpp b/tests/unit/simulation/world/test_world_resolved_configuration.cpp index 1173605987f39..f235ea6a32ea4 100644 --- a/tests/unit/simulation/world/test_world_resolved_configuration.cpp +++ b/tests/unit/simulation/world/test_world_resolved_configuration.cpp @@ -183,6 +183,48 @@ TEST(ResolvedConfiguration, RecordsPublicAvbdFamilyAsRequested) EXPECT_EQ(iterations->resolved, "20"); } +TEST(ResolvedConfiguration, RecordsPublicVbdFamilyAsRequested) +{ + sx::WorldOptions options; + options.rigidBodySolver = sx::RigidBodySolver::Vbd; + options.rigidConstraintOptions.iterations = 20; + sx::World world(options); + auto parent = world.addRigidBody("parent"); + sx::RigidBodyOptions childOptions; + childOptions.position = Eigen::Vector3d::UnitX(); + auto child = world.addRigidBody("child", childOptions); + world.addJoint( + parent, + child, + sx::JointSpec{.name = "fixed", .type = sx::JointType::Fixed}); + world.enterSimulationMode(); + + const auto& config = world.getResolvedConfiguration(); + EXPECT_FALSE(config.hasSubstitution()); + + const auto* rigid = findNote(config, "rigid-body"); + ASSERT_NE(rigid, nullptr); + EXPECT_EQ(rigid->requested, "vbd"); + EXPECT_EQ(rigid->resolved, "vbd"); + + const auto* contact = findNote(config, "rigid-contact"); + ASSERT_NE(contact, nullptr); + EXPECT_EQ(contact->requested, "vbd"); + EXPECT_EQ(contact->resolved, "vbd"); + EXPECT_EQ(contact->reason, "as requested"); + + const auto* pairConstraint = findNote(config, "rigid-pair-constraint"); + ASSERT_NE(pairConstraint, nullptr); + EXPECT_EQ(pairConstraint->requested, "vbd"); + EXPECT_EQ(pairConstraint->resolved, "vbd"); + EXPECT_EQ(pairConstraint->reason, "as requested"); + + const auto* iterations = findNote(config, "rigid-constraint-iterations"); + ASSERT_NE(iterations, nullptr); + EXPECT_EQ(iterations->requested, "20"); + EXPECT_EQ(iterations->resolved, "20"); +} + namespace { // Emplace the internal AVBD rigid-contact opt-in on a body. The opt-in is not diff --git a/tests/unit/simulation/world/test_world_step_schedule.cpp b/tests/unit/simulation/world/test_world_step_schedule.cpp index 5e1247f1022ba..4238d5d24c4c9 100644 --- a/tests/unit/simulation/world/test_world_step_schedule.cpp +++ b/tests/unit/simulation/world/test_world_step_schedule.cpp @@ -106,6 +106,21 @@ TEST(BuiltInWorldStepSchedule, AvbdUsesSplitContactPipeline) expectInlineCapacity(options); } +TEST(BuiltInWorldStepSchedule, VbdUsesSplitContactPipeline) +{ + auto options = defaultOptions(); + options.rigidBodySolver = RigidFamily::Vbd; + + EXPECT_EQ( + slots(options), + (std::vector{ + Slot::RigidBodyVelocity, + Slot::RigidBodyContact, + Slot::RigidBodyPosition, + Slot::Kinematics})); + expectInlineCapacity(options); +} + TEST(BuiltInWorldStepSchedule, EmptyWorldUsesOnlyWorldLevelKinematics) { sxdetail::BuiltInWorldStepScheduleOptions options; From 37178193ccf090843c5df6afeb1221d090f2b673 Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Sat, 1 Aug 2026 10:40:31 -0700 Subject: [PATCH 09/13] Add public sequential impulse rigid pair constraints --- CHANGELOG.md | 15 +- dart/simulation/comps/joint.hpp | 17 +- .../compute/detail/world_step_stages.hpp | 16 +- .../compute/rigid_body_contact_stage.cpp | 856 +- .../detail/rigid_avbd/rigid_world_contact.hpp | 378 +- .../detail/rigid_pair_constraint.hpp | 513 + .../simulation/detail/world_step_schedule.hpp | 3 +- dart/simulation/multibody/joint.hpp | 5 +- dart/simulation/world.cpp | 140 +- dart/simulation/world_options.hpp | 14 +- docs/design/dart7_architecture_assessment.md | 15 +- docs/dev_tasks/avbd_solver/README.md | 240 +- docs/dev_tasks/avbd_solver/RESUME.md | 498 +- docs/plans/104-vertex-block-descent-solver.md | 251 +- .../avbd-demo-corpus.md | 416 +- .../avbd-paper-breakable-wall-packet.json | 8708 ++++++++++++++++- .../avbd-paper-coverage-contract.json | 4 +- .../avbd-paper-gap-audit.md | 63 +- ...-sequential-impulse-comparison-packet.json | 1787 ++++ .../avbd-paper-vbd-comparison-packet.json | 1038 +- .../paper-parity-matrix.md | 13 +- docs/readthedocs/papers.md | 9 +- python/examples/demos/README.md | 1 + python/examples/demos/registry.py | 4 + .../demos/scenes/avbd_demo2d_fracture.py | 14 +- .../demos/scenes/avbd_demo2d_hanging_rope.py | 6 +- .../demos/scenes/avbd_demo2d_heavy_rope.py | 6 +- .../examples/demos/scenes/avbd_demo2d_rod.py | 6 +- .../demos/scenes/avbd_demo2d_soft_body.py | 6 +- .../demos/scenes/avbd_demo3d_breakable.py | 14 +- .../demos/scenes/avbd_demo3d_soft_body.py | 6 +- .../demos/scenes/avbd_paper_breakable_wall.py | 557 +- ...sequential_impulse_paper_breakable_wall.py | 74 + .../demos/scenes/vbd_paper_breakable_wall.py | 17 +- python/tests/integration/test_demos_cycle.py | 403 +- python/tests/unit/simulation/test_world.py | 3 +- python/tests/unit/test_capture_py_demo.py | 17 + ...d_articulated_compliant_fracture_packet.py | 2 +- ...vbd_articulated_compliant_joints_packet.py | 2 +- ...vbd_articulated_compliant_motors_packet.py | 2 +- ...write_avbd_breakable_joint_scale_packet.py | 2 +- ...write_avbd_breakable_motor_scale_packet.py | 2 +- ..._avbd_friction_coefficient_sweep_packet.py | 2 +- ..._write_avbd_paper_breakable_wall_packet.py | 233 +- ...scale_high_ratio_iteration_sweep_packet.py | 2 +- ...er_sequential_impulse_comparison_packet.py | 926 ++ ..._write_avbd_paper_vbd_comparison_packet.py | 226 +- scripts/avbd_packet_schema.py | 123 +- scripts/capture_py_demo.py | 24 + scripts/capture_source_provenance.py | 188 + scripts/check_avbd_packets.py | 490 +- ...te_avbd_friction_coefficient_sweep_plot.py | 11 +- .../write_avbd_paper_breakable_wall_packet.py | 579 +- ...er_sequential_impulse_comparison_packet.py | 1547 +++ .../write_avbd_paper_vbd_comparison_packet.py | 189 +- .../simulation/bm_avbd_rigid_fixed_joint.cpp | 32 +- tests/test_avbd_packet_schema.py | 117 +- tests/test_check_avbd_packets.py | 427 +- .../compute/test_variational_integration.cpp | 89 +- .../contact/test_boxed_lcp_contact.cpp | 347 +- .../simulation/world/test_serialization.cpp | 1 + tests/unit/simulation/world/test_world.cpp | 496 +- .../test_world_resolved_configuration.cpp | 13 +- 63 files changed, 20577 insertions(+), 1628 deletions(-) create mode 100644 dart/simulation/detail/rigid_pair_constraint.hpp create mode 100644 docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json create mode 100644 python/examples/demos/scenes/sequential_impulse_paper_breakable_wall.py create mode 100644 python/tests/unit/test_write_avbd_paper_sequential_impulse_comparison_packet.py create mode 100644 scripts/capture_source_provenance.py create mode 100644 scripts/write_avbd_paper_sequential_impulse_comparison_packet.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e30ec6d463afd..a9620bdc6dd05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -205,13 +205,24 @@ compatibility remains on the active DART 6 LTS branch._ - Added explicit experimental fixed-penalty VBD and augmented-Lagrangian AVBD rigid-body families to the DART 7 `World` C++ and dartpy facades, including typed rigid-constraint options, binary/replay persistence, fail-closed - configuration, and matched publication-shaped breakable-wall demos with - quantitative, allocation, performance, and assessed visual evidence. + configuration, and matched publication-shaped breakable-wall demos. The + matched Figure 13 evidence records exact broken-joint identities, + retained-row residuals, allocation gates, descriptive performance, and + assessed visuals across Sequential Impulse, VBD, and AVBD. Claim-tied view assessment now scopes ambiguity to an explicitly named subject while still treating other renderables as possible occluders, so dense debris scenes cannot reject a clear subject view because of an unrelated overlapping pair. ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) +- Changed the default Sequential Impulse rigid-body family to own hard + fixed/spherical/revolute/prismatic pair constraints, bounded velocity + motors, impulse-derived fracture, and non-velocity post-stabilization in the + same projected Gauss-Seidel sweeps as contacts, instead of routing public + pair constraints through the private AVBD projection; finite-stiffness pair + rows fail closed to the VBD/AVBD families, and bounded velocity-motor loads + now count toward the public break threshold identically across Sequential + Impulse, VBD, and AVBD. + ([PLAN-104](docs/plans/104-vertex-block-descent-solver.md)) - Added compute-executor and backend-boundary work so CPU threading, optional CUDA experiments, and future accelerator sidecars do not leak into the default public API. diff --git a/dart/simulation/comps/joint.hpp b/dart/simulation/comps/joint.hpp index 66998936e708a..224cdd1cb08f8 100644 --- a/dart/simulation/comps/joint.hpp +++ b/dart/simulation/comps/joint.hpp @@ -197,7 +197,7 @@ struct JointLimits /// live in `JointState`; commanded inputs live in `JointActuation`. /// /// Field usage by joint type: -/// - Fixed: rigidBodyFixedJointLocalAnchor* for public rigid-body joints +/// - Fixed: rigidBodyPairConstraintLocalAnchor* for public rigid pairs /// - Revolute: axis /// - Prismatic: axis /// - Screw: axis, pitch @@ -227,8 +227,8 @@ struct JointModel /// (per generalized coordinate). JointVector coulombFriction; - /// Maximum physical AVBD row-load norm before the joint is marked broken. A - /// value of 0 disables automatic breakage. + /// Maximum physical solver-row load norm before the joint is marked broken. + /// A value of 0 disables automatic breakage. double breakForce = 0.0; JointLimits limits; @@ -242,11 +242,12 @@ struct JointModel entt::entity parentLink = entt::null; entt::entity childLink = entt::null; - bool hasRigidBodyFixedJointAnchors = false; - Eigen::Vector3d rigidBodyFixedJointLocalAnchorParent + bool hasRigidBodyPairConstraintGeometry = false; + Eigen::Vector3d rigidBodyPairConstraintLocalAnchorParent = Eigen::Vector3d::Zero(); - Eigen::Vector3d rigidBodyFixedJointLocalAnchorChild = Eigen::Vector3d::Zero(); - Eigen::Quaterniond rigidBodyFixedJointTargetRelativeOrientation + Eigen::Vector3d rigidBodyPairConstraintLocalAnchorChild + = Eigen::Vector3d::Zero(); + Eigen::Quaterniond rigidBodyPairConstraintTargetRelativeOrientation = Eigen::Quaterniond::Identity(); static constexpr auto entityFields() @@ -296,7 +297,7 @@ struct JointState JointVector velocity; JointVector acceleration; - /// Whether the joint has been broken by an AVBD break-force threshold. + /// Whether the joint has been broken by a solver break-force threshold. bool broken = false; }; diff --git a/dart/simulation/compute/detail/world_step_stages.hpp b/dart/simulation/compute/detail/world_step_stages.hpp index d3f453ca1820c..f156f144bf1d5 100644 --- a/dart/simulation/compute/detail/world_step_stages.hpp +++ b/dart/simulation/compute/detail/world_step_stages.hpp @@ -390,15 +390,17 @@ class DART_SIMULATION_API RigidBodyPositionStage final : public WorldStepStage /// `RigidBodySolver::SequentialImpulse` family, two contact-policy paths are /// available via `WorldOptions::contactSolverMethod`: /// - `SequentialImpulse` (default): the long-standing sequential normal + -/// friction impulse solve with positional correction. +/// friction impulse solve, interleaved with solver-owned hard rigid +/// pair-constraint rows, with positional correction/post-stabilization. /// - `BoxedLcp`: an opt-in boxed-LCP normal/friction solve via the pivoting -/// Dantzig solver. Articulated-link contacts are handled by the unified -/// constraint/contact path, not by this free-rigid stage. +/// Dantzig solver; hard rigid pair constraints still use their +/// sequential-impulse rows. Articulated-link contacts are handled by the +/// unified constraint/contact path, not by this free-rigid stage. /// -/// Internal PLAN-104 compatibility tests may still opt specific rigid bodies -/// into the private `RigidAvbdContactConfig` row projection while the public -/// family remains sequential impulse. That compatibility path does not expose -/// AVBD row storage or solver registries through the facade. +/// Internal PLAN-104 compatibility tests may still opt specific rigid +/// contacts and experimental distance springs into private AVBD projection +/// while the public family remains sequential impulse. Public hard pair +/// constraints never use that compatibility path. class DART_SIMULATION_API RigidBodyContactStage final : public WorldStepStage { public: diff --git a/dart/simulation/compute/rigid_body_contact_stage.cpp b/dart/simulation/compute/rigid_body_contact_stage.cpp index 9501ff47e0cb3..2b679c30962a7 100644 --- a/dart/simulation/compute/rigid_body_contact_stage.cpp +++ b/dart/simulation/compute/rigid_body_contact_stage.cpp @@ -46,6 +46,7 @@ #include "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp" #include "dart/simulation/detail/rigid_contact/boxed_lcp_contact.hpp" #include "dart/simulation/detail/rigid_contact/rigid_contact_assembly.hpp" +#include "dart/simulation/detail/rigid_pair_constraint.hpp" #include "dart/simulation/detail/world_registry_access.hpp" #include "dart/simulation/detail/world_storage.hpp" #include "dart/simulation/world.hpp" @@ -59,6 +60,7 @@ #include #include +#include #include #include #include @@ -444,6 +446,579 @@ void resolveRigidBodyContactPositions( } } +struct RigidSequentialImpulseJointConstraint +{ + entt::entity joint = entt::null; + entt::entity bodyA = entt::null; + entt::entity bodyB = entt::null; + Eigen::Vector3d localAnchorA = Eigen::Vector3d::Zero(); + Eigen::Vector3d localAnchorB = Eigen::Vector3d::Zero(); + Eigen::Quaterniond targetRelativeOrientation = Eigen::Quaterniond::Identity(); + Eigen::Matrix3d linearAxes = Eigen::Matrix3d::Identity(); + Eigen::Matrix3d angularAxes = Eigen::Matrix3d::Identity(); + std::uint8_t linearAxisMask = detail::kRigidPairConstraintAllAxesMask; + std::uint8_t angularAxisMask = detail::kRigidPairConstraintAllAxesMask; + bool staticA = false; + bool staticB = false; + double invMassA = 0.0; + double invMassB = 0.0; + Eigen::Matrix3d invInertiaA = Eigen::Matrix3d::Zero(); + Eigen::Matrix3d invInertiaB = Eigen::Matrix3d::Zero(); + bool useLinearMotor = false; + bool useAngularMotor = false; + double motorTargetSpeed = 0.0; + double motorMaxForce = std::numeric_limits::infinity(); + double motorMaxTorque = std::numeric_limits::infinity(); + double breakForce = 0.0; + std::array linearImpulses{}; + std::array angularImpulses{}; + double linearMotorImpulse = 0.0; + double angularMotorImpulse = 0.0; +}; + +//============================================================================== +bool rigidSequentialImpulseJointAxisEnabled(std::uint8_t mask, int axis) +{ + return (mask & (std::uint8_t{1u} << static_cast(axis))) != 0u; +} + +//============================================================================== +Eigen::Quaterniond normalizedRigidSequentialImpulseOrientation( + const Eigen::Quaterniond& orientation) +{ + return detail::normalizeRigidPairConstraintOrientation(orientation); +} + +//============================================================================== +Eigen::Vector3d rigidSequentialImpulseJointWorldArm( + const comps::Transform& transform, const Eigen::Vector3d& localAnchor) +{ + return normalizedRigidSequentialImpulseOrientation(transform.orientation) + * localAnchor; +} + +//============================================================================== +Eigen::Vector3d rigidSequentialImpulseJointAngularVelocity( + const comps::Velocity& velocity, bool prescribed) +{ + return prescribed ? Eigen::Vector3d::Zero() : velocity.angular; +} + +//============================================================================== +std::size_t expectedRigidSequentialImpulseJointCount( + const detail::WorldRegistry& registry) +{ + std::size_t expected = 0u; + const auto view = registry.view< + comps::JointModel, + comps::JointState, + comps::JointActuation>(); + for (const entt::entity entity : view) { + const auto& model = view.get(entity); + const auto& state = view.get(entity); + if (state.broken || model.parentLink == entt::null + || model.childLink == entt::null + || model.parentLink == model.childLink) { + continue; + } + + const bool rigidA = registry.all_of(model.parentLink); + const bool rigidB = registry.all_of(model.childLink); + if (rigidA && rigidB) { + DART_SIMULATION_THROW_T_IF( + !model.hasRigidBodyPairConstraintGeometry, + InvalidOperationException, + "Sequential-impulse rigid joint is missing its canonical pair-row " + "geometry"); + } + + // The count must accept exactly the joints the shared extractor accepts, + // including its degenerate and non-finite skip rules, so a legitimately + // skipped joint never trips the coverage check mid-step. + const std::optional eligibility + = detail::eligibleRigidPairConstraint( + registry, + entity, + model, + state, + [](entt::entity, const comps::JointModel& joint) { + return detail::rigidPairConstraintGeometryFromJointModel(joint); + }); + if (eligibility.has_value()) { + ++expected; + } + } + return expected; +} + +//============================================================================== +double rigidSequentialImpulseLinearEffectiveMass( + const RigidSequentialImpulseJointConstraint& constraint, + const Eigen::Vector3d& armA, + const Eigen::Vector3d& armB, + const Eigen::Vector3d& axis) +{ + const Eigen::Vector3d crossA = armA.cross(axis); + const Eigen::Vector3d crossB = armB.cross(axis); + return constraint.invMassA + constraint.invMassB + + axis.dot( + (constraint.invInertiaA * crossA).cross(armA) + + (constraint.invInertiaB * crossB).cross(armB)); +} + +//============================================================================== +double rigidSequentialImpulseAngularEffectiveMass( + const RigidSequentialImpulseJointConstraint& constraint, + const Eigen::Vector3d& axis) +{ + return axis.dot((constraint.invInertiaA + constraint.invInertiaB) * axis); +} + +//============================================================================== +void applyRigidSequentialImpulseJointLinearImpulse( + detail::WorldRegistry& registry, + const RigidSequentialImpulseJointConstraint& constraint, + const Eigen::Vector3d& armA, + const Eigen::Vector3d& armB, + const Eigen::Vector3d& impulse) +{ + auto& velocityA = registry.get(constraint.bodyA); + auto& velocityB = registry.get(constraint.bodyB); + velocityB.linear += constraint.invMassB * impulse; + velocityB.angular += constraint.invInertiaB * armB.cross(impulse); + velocityA.linear -= constraint.invMassA * impulse; + velocityA.angular -= constraint.invInertiaA * armA.cross(impulse); +} + +//============================================================================== +void applyRigidSequentialImpulseJointAngularImpulse( + detail::WorldRegistry& registry, + const RigidSequentialImpulseJointConstraint& constraint, + const Eigen::Vector3d& impulse) +{ + auto& velocityA = registry.get(constraint.bodyA); + auto& velocityB = registry.get(constraint.bodyB); + velocityB.angular += constraint.invInertiaB * impulse; + velocityA.angular -= constraint.invInertiaA * impulse; +} + +//============================================================================== +template +std::size_t assembleRigidSequentialImpulseJointsInto( + ConstraintVector& constraints, + const detail::WorldRegistry& registry, + std::span inputs) +{ + constraints.clear(); + constraints.reserve(inputs.size()); + std::size_t activeRows = 0u; + + for (const auto& input : inputs) { + const auto* model = registry.try_get(input.joint); + const auto* state = registry.try_get(input.joint); + DART_SIMULATION_THROW_T_IF( + model == nullptr || state == nullptr || state->broken, + InvalidOperationException, + "Sequential-impulse rigid joint extraction produced a stale or " + "broken joint row"); + const bool validBodyA = registry.all_of< + comps::RigidBodyTag, + comps::Transform, + comps::Velocity, + comps::MassProperties>(input.bodyA); + const bool validBodyB = registry.all_of< + comps::RigidBodyTag, + comps::Transform, + comps::Velocity, + comps::MassProperties>(input.bodyB); + DART_SIMULATION_THROW_T_IF( + input.bodyA == entt::null || input.bodyB == entt::null + || input.bodyA == input.bodyB || !validBodyA || !validBodyB, + NotImplementedException, + "Sequential-impulse rigid pair constraints require two distinct free " + "rigid-body endpoints"); + DART_SIMULATION_THROW_T_IF( + !input.anchorsAreLocal || !input.anchorA.allFinite() + || !input.anchorB.allFinite() + || !input.targetRelativeOrientation.coeffs().allFinite() + || input.targetRelativeOrientation.norm() == 0.0 + || !input.linearAxes.allFinite() || !input.angularAxes.allFinite(), + InvalidOperationException, + "Sequential-impulse rigid pair constraint geometry is invalid"); + + RigidSequentialImpulseJointConstraint constraint; + constraint.joint = input.joint; + constraint.bodyA = input.bodyA; + constraint.bodyB = input.bodyB; + constraint.localAnchorA = input.anchorA; + constraint.localAnchorB = input.anchorB; + constraint.targetRelativeOrientation + = normalizedRigidSequentialImpulseOrientation( + input.targetRelativeOrientation); + constraint.linearAxisMask = input.linearAxisMask; + constraint.angularAxisMask = input.angularAxisMask; + for (int axis = 0; axis < 3; ++axis) { + if (rigidSequentialImpulseJointAxisEnabled( + constraint.linearAxisMask, axis)) { + const double norm = input.linearAxes.col(axis).norm(); + DART_SIMULATION_THROW_T_IF( + !(norm > 0.0) || !std::isfinite(norm), + InvalidOperationException, + "Sequential-impulse rigid joint has an invalid linear row axis"); + constraint.linearAxes.col(axis) = input.linearAxes.col(axis) / norm; + ++activeRows; + } + if (rigidSequentialImpulseJointAxisEnabled( + constraint.angularAxisMask, axis)) { + const double norm = input.angularAxes.col(axis).norm(); + DART_SIMULATION_THROW_T_IF( + !(norm > 0.0) || !std::isfinite(norm), + InvalidOperationException, + "Sequential-impulse rigid joint has an invalid angular row axis"); + constraint.angularAxes.col(axis) = input.angularAxes.col(axis) / norm; + ++activeRows; + } + } + + if (input.useLinearMotor) { + const double norm = input.linearAxes.col(2).norm(); + DART_SIMULATION_THROW_T_IF( + !(norm > 0.0) || !std::isfinite(norm) + || !std::isfinite(input.motorTargetSpeed) + || !(input.motorMaxForce > 0.0) + || std::isnan(input.motorMaxForce), + InvalidOperationException, + "Sequential-impulse rigid joint has an invalid linear motor row"); + constraint.linearAxes.col(2) = input.linearAxes.col(2) / norm; + } + if (input.useAngularMotor) { + const double norm = input.angularAxes.col(2).norm(); + DART_SIMULATION_THROW_T_IF( + !(norm > 0.0) || !std::isfinite(norm) + || !std::isfinite(input.motorTargetSpeed) + || !(input.motorMaxTorque > 0.0) + || std::isnan(input.motorMaxTorque), + InvalidOperationException, + "Sequential-impulse rigid joint has an invalid angular motor row"); + constraint.angularAxes.col(2) = input.angularAxes.col(2) / norm; + } + + constraint.staticA + = detail::hasPrescribedRigidBodyContactResponse(registry, input.bodyA); + constraint.staticB + = detail::hasPrescribedRigidBodyContactResponse(registry, input.bodyB); + const auto& massA = registry.get(input.bodyA); + const auto& massB = registry.get(input.bodyB); + const auto& transformA = registry.get(input.bodyA); + const auto& transformB = registry.get(input.bodyB); + constraint.invMassA + = constraint.staticA ? 0.0 : detail::inverseMassOf(massA); + constraint.invMassB + = constraint.staticB ? 0.0 : detail::inverseMassOf(massB); + constraint.invInertiaA + = constraint.staticA ? Eigen::Matrix3d::Zero() + : detail::inverseWorldInertiaOf(massA, transformA); + constraint.invInertiaB + = constraint.staticB ? Eigen::Matrix3d::Zero() + : detail::inverseWorldInertiaOf(massB, transformB); + DART_SIMULATION_THROW_T_IF( + constraint.staticA && constraint.staticB, + InvalidOperationException, + "Sequential-impulse rigid pair constraint has no dynamic endpoint"); + + constraint.useLinearMotor = input.useLinearMotor; + constraint.useAngularMotor = input.useAngularMotor; + constraint.motorTargetSpeed = input.motorTargetSpeed; + constraint.motorMaxForce = input.motorMaxForce; + constraint.motorMaxTorque = input.motorMaxTorque; + constraint.breakForce = model->breakForce; + if (constraint.useLinearMotor) { + ++activeRows; + } + if (constraint.useAngularMotor) { + ++activeRows; + } + constraints.push_back(constraint); + } + + return activeRows; +} + +//============================================================================== +template +void sweepRigidSequentialImpulseJoints( + detail::WorldRegistry& registry, + ConstraintVector& constraints, + double timeStep) +{ + for (auto& constraint : constraints) { + const auto& transformA = registry.get(constraint.bodyA); + const auto& transformB = registry.get(constraint.bodyB); + const Eigen::Vector3d armA = rigidSequentialImpulseJointWorldArm( + transformA, constraint.localAnchorA); + const Eigen::Vector3d armB = rigidSequentialImpulseJointWorldArm( + transformB, constraint.localAnchorB); + + for (int row = 0; row < 3; ++row) { + if (!rigidSequentialImpulseJointAxisEnabled( + constraint.linearAxisMask, row)) { + continue; + } + const Eigen::Vector3d axis = constraint.linearAxes.col(row); + const double effectiveMass = rigidSequentialImpulseLinearEffectiveMass( + constraint, armA, armB, axis); + if (!(effectiveMass > 0.0) || !std::isfinite(effectiveMass)) { + continue; + } + + const auto& velocityA = registry.get(constraint.bodyA); + const auto& velocityB = registry.get(constraint.bodyB); + const double relativeVelocity = (computeRigidBodyContactPointVelocity( + velocityB, armB, constraint.staticB) + - computeRigidBodyContactPointVelocity( + velocityA, armA, constraint.staticA)) + .dot(axis); + const double lambda = -relativeVelocity / effectiveMass; + if (!std::isfinite(lambda) || lambda == 0.0) { + continue; + } + constraint.linearImpulses[static_cast(row)] += lambda; + applyRigidSequentialImpulseJointLinearImpulse( + registry, constraint, armA, armB, lambda * axis); + } + + for (int row = 0; row < 3; ++row) { + if (!rigidSequentialImpulseJointAxisEnabled( + constraint.angularAxisMask, row)) { + continue; + } + const Eigen::Vector3d axis = constraint.angularAxes.col(row); + const double effectiveMass + = rigidSequentialImpulseAngularEffectiveMass(constraint, axis); + if (!(effectiveMass > 0.0) || !std::isfinite(effectiveMass)) { + continue; + } + + const auto& velocityA = registry.get(constraint.bodyA); + const auto& velocityB = registry.get(constraint.bodyB); + const double relativeVelocity + = (rigidSequentialImpulseJointAngularVelocity( + velocityB, constraint.staticB) + - rigidSequentialImpulseJointAngularVelocity( + velocityA, constraint.staticA)) + .dot(axis); + const double lambda = -relativeVelocity / effectiveMass; + if (!std::isfinite(lambda) || lambda == 0.0) { + continue; + } + constraint.angularImpulses[static_cast(row)] += lambda; + applyRigidSequentialImpulseJointAngularImpulse( + registry, constraint, lambda * axis); + } + + if (constraint.useLinearMotor) { + const Eigen::Vector3d axis = constraint.linearAxes.col(2); + const double effectiveMass = rigidSequentialImpulseLinearEffectiveMass( + constraint, armA, armB, axis); + if (effectiveMass > 0.0 && std::isfinite(effectiveMass)) { + const auto& velocityA = registry.get(constraint.bodyA); + const auto& velocityB = registry.get(constraint.bodyB); + const double relativeVelocity + = (computeRigidBodyContactPointVelocity( + velocityB, armB, constraint.staticB) + - computeRigidBodyContactPointVelocity( + velocityA, armA, constraint.staticA)) + .dot(axis); + const double limit = constraint.motorMaxForce * timeStep; + const double candidate + = constraint.linearMotorImpulse + + (constraint.motorTargetSpeed - relativeVelocity) + / effectiveMass; + const double clamped = std::clamp(candidate, -limit, limit); + const double lambda = clamped - constraint.linearMotorImpulse; + constraint.linearMotorImpulse = clamped; + if (std::isfinite(lambda) && lambda != 0.0) { + applyRigidSequentialImpulseJointLinearImpulse( + registry, constraint, armA, armB, lambda * axis); + } + } + } + + if (constraint.useAngularMotor) { + const Eigen::Vector3d axis = constraint.angularAxes.col(2); + const double effectiveMass + = rigidSequentialImpulseAngularEffectiveMass(constraint, axis); + if (effectiveMass > 0.0 && std::isfinite(effectiveMass)) { + const auto& velocityA = registry.get(constraint.bodyA); + const auto& velocityB = registry.get(constraint.bodyB); + const double relativeVelocity + = (rigidSequentialImpulseJointAngularVelocity( + velocityB, constraint.staticB) + - rigidSequentialImpulseJointAngularVelocity( + velocityA, constraint.staticA)) + .dot(axis); + const double limit = constraint.motorMaxTorque * timeStep; + const double candidate + = constraint.angularMotorImpulse + + (constraint.motorTargetSpeed - relativeVelocity) + / effectiveMass; + const double clamped = std::clamp(candidate, -limit, limit); + const double lambda = clamped - constraint.angularMotorImpulse; + constraint.angularMotorImpulse = clamped; + if (std::isfinite(lambda) && lambda != 0.0) { + applyRigidSequentialImpulseJointAngularImpulse( + registry, constraint, lambda * axis); + } + } + } + } +} + +//============================================================================== +template +std::size_t markBrokenRigidSequentialImpulseJoints( + detail::WorldRegistry& registry, + const ConstraintVector& constraints, + double timeStep) +{ + if (!(timeStep > 0.0) || !std::isfinite(timeStep)) { + return 0u; + } + + const double inverseTimeStep = 1.0 / timeStep; + std::size_t broken = 0u; + for (const auto& constraint : constraints) { + if (!(constraint.breakForce > 0.0) + || !std::isfinite(constraint.breakForce)) { + continue; + } + + double impulseSquared = 0.0; + for (const double impulse : constraint.linearImpulses) { + impulseSquared += impulse * impulse; + } + for (const double impulse : constraint.angularImpulses) { + impulseSquared += impulse * impulse; + } + impulseSquared + += constraint.linearMotorImpulse * constraint.linearMotorImpulse; + impulseSquared + += constraint.angularMotorImpulse * constraint.angularMotorImpulse; + const double load = std::sqrt(impulseSquared) * inverseTimeStep; + if (load < constraint.breakForce) { + continue; + } + + auto& state = registry.get(constraint.joint); + if (!state.broken) { + state.broken = true; + ++broken; + } + } + return broken; +} + +//============================================================================== +void applyRigidSequentialImpulseOrientationCorrection( + comps::Transform& transform, const Eigen::Vector3d& rotationVector) +{ + const double angle = rotationVector.norm(); + if (!(angle > 0.0) || !std::isfinite(angle)) { + return; + } + + Eigen::Quaterniond correction( + Eigen::AngleAxisd(angle, rotationVector / angle)); + transform.orientation = normalizedRigidSequentialImpulseOrientation( + correction + * normalizedRigidSequentialImpulseOrientation(transform.orientation)); +} + +//============================================================================== +template +void postStabilizeRigidSequentialImpulseJoints( + detail::WorldRegistry& registry, + const ConstraintVector& constraints, + std::size_t iterations) +{ + constexpr double correctionFactor + = detail::kRigidContactPositionCorrectionFactor; + for (std::size_t iteration = 0; iteration < iterations; ++iteration) { + for (const auto& constraint : constraints) { + if (registry.get(constraint.joint).broken) { + continue; + } + + auto& transformA = registry.get(constraint.bodyA); + auto& transformB = registry.get(constraint.bodyB); + for (int row = 0; row < 3; ++row) { + if (!rigidSequentialImpulseJointAxisEnabled( + constraint.linearAxisMask, row)) { + continue; + } + + const Eigen::Vector3d armA = rigidSequentialImpulseJointWorldArm( + transformA, constraint.localAnchorA); + const Eigen::Vector3d armB = rigidSequentialImpulseJointWorldArm( + transformB, constraint.localAnchorB); + const Eigen::Vector3d axis = constraint.linearAxes.col(row); + const double effectiveMass = rigidSequentialImpulseLinearEffectiveMass( + constraint, armA, armB, axis); + if (!(effectiveMass > 0.0) || !std::isfinite(effectiveMass)) { + continue; + } + + const Eigen::Vector3d anchorA = transformA.position + armA; + const Eigen::Vector3d anchorB = transformB.position + armB; + const double error = axis.dot(anchorB - anchorA); + const double lambda = -correctionFactor * error / effectiveMass; + if (!std::isfinite(lambda) || lambda == 0.0) { + continue; + } + const Eigen::Vector3d correction = lambda * axis; + transformB.position += constraint.invMassB * correction; + transformA.position -= constraint.invMassA * correction; + applyRigidSequentialImpulseOrientationCorrection( + transformB, constraint.invInertiaB * armB.cross(correction)); + applyRigidSequentialImpulseOrientationCorrection( + transformA, -constraint.invInertiaA * armA.cross(correction)); + } + + for (int row = 0; row < 3; ++row) { + if (!rigidSequentialImpulseJointAxisEnabled( + constraint.angularAxisMask, row)) { + continue; + } + + const Eigen::Vector3d axis = constraint.angularAxes.col(row); + const double effectiveMass + = rigidSequentialImpulseAngularEffectiveMass(constraint, axis); + if (!(effectiveMass > 0.0) || !std::isfinite(effectiveMass)) { + continue; + } + const Eigen::Quaterniond orientationA + = normalizedRigidSequentialImpulseOrientation( + transformA.orientation); + const Eigen::Quaterniond targetOrientationB + = normalizedRigidSequentialImpulseOrientation( + orientationA * constraint.targetRelativeOrientation); + const double error = axis.dot( + detail::rigidPairConstraintOrientationError( + transformB.orientation, targetOrientationB)); + const double lambda = -correctionFactor * error / effectiveMass; + if (!std::isfinite(lambda) || lambda == 0.0) { + continue; + } + const Eigen::Vector3d correction = lambda * axis; + applyRigidSequentialImpulseOrientationCorrection( + transformB, constraint.invInertiaB * correction); + applyRigidSequentialImpulseOrientationCorrection( + transformA, -constraint.invInertiaA * correction); + } + } + } +} + } // namespace struct RigidBodyContactStage::AvbdScratch @@ -486,6 +1061,26 @@ struct RigidBodyContactStage::AvbdScratch solveScratch.clear(); } + void clearPointJointWarmStart() + { + pointJoints.clear(); + jointLinearInventory.records().clear(); + jointAngularInventory.records().clear(); + motorInventory.records().clear(); + } + + void clearContactWarmStart() + { + normalInventory.records().clear(); + frictionInventory.records().clear(); + } + + void clearSequentialImpulseOwnedWarmStart() + { + clearContactWarmStart(); + clearPointJointWarmStart(); + } + void reserve( std::size_t bodyCapacity, std::size_t contactCapacity, @@ -538,14 +1133,27 @@ struct RigidBodyContactStage::AvbdScratch //============================================================================== struct RigidBodyContactStage::ContactScratch { + using RigidPairInputAllocator + = common::StlAllocator; + using SequentialImpulseJointAllocator + = common::StlAllocator; + ContactScratch() = default; explicit ContactScratch(common::MemoryAllocator& allocator) - : problem(allocator) + : problem(allocator), + rigidPairInputs(RigidPairInputAllocator{allocator}), + sequentialImpulseJoints(SequentialImpulseJointAllocator{allocator}) { } RigidBodyContactProblem problem; + std::vector + rigidPairInputs; + std::vector< + RigidSequentialImpulseJointConstraint, + SequentialImpulseJointAllocator> + sequentialImpulseJoints; }; //============================================================================== @@ -674,6 +1282,10 @@ void RigidBodyContactStage::prepare(World& world) const bool mayHavePointJointConfigs = dvbd::mayHaveAvbdRigidWorldPointJointConfigs(registry); + const std::size_t rigidPairCapacity + = registry + .view() + .size_hint(); const std::size_t jointCapacity = mayHavePointJointConfigs ? registry @@ -682,6 +1294,10 @@ void RigidBodyContactStage::prepare(World& world) dvbd::AvbdRigidWorldPointJointConfig>() .size_hint() : 0u; + m_contactScratch->rigidPairInputs.clear(); + m_contactScratch->rigidPairInputs.reserve(rigidPairCapacity); + m_contactScratch->sequentialImpulseJoints.clear(); + m_contactScratch->sequentialImpulseJoints.reserve(rigidPairCapacity); const auto* distanceSpringStorage = registry.storage(); const std::size_t distanceSpringCapacity @@ -723,6 +1339,8 @@ void RigidBodyContactStage::prepare(World& world) void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) { auto& registry = dart::simulation::detail::registryOf(world); + const bool useSequentialImpulseFamily + = world.getRigidBodySolver() == RigidBodySolver::SequentialImpulse; const bool useVbdFamily = world.getRigidBodySolver() == RigidBodySolver::Vbd; const bool useAvbdFamily = world.getRigidBodySolver() == RigidBodySolver::Avbd; @@ -734,9 +1352,74 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) AugmentedLagrangian; const std::string_view solverFamilyName = useVbdFamily ? "VBD" : "AVBD"; + if (m_contactScratch == nullptr) { + m_contactScratch = ContactScratchPtr( + createContactScratch(m_memoryManager), + ContactScratchDeleter{m_memoryManager}); + } + auto& sequentialImpulseJoints = m_contactScratch->sequentialImpulseJoints; + auto& rigidPairInputs = m_contactScratch->rigidPairInputs; + rigidPairInputs.clear(); + sequentialImpulseJoints.clear(); + if (useSequentialImpulseFamily && m_avbdScratch != nullptr) { + // Sequential Impulse owns public hard pair rows independently of AVBD. + // Crossing through this family invalidates AVBD's accumulated joint and + // motor multipliers, while AVBD compatibility distance-spring state + // remains live. + m_avbdScratch->clearPointJointWarmStart(); + } + std::size_t sequentialImpulseJointRows = 0u; + if (useSequentialImpulseFamily) { + const std::size_t expectedJointCount + = expectedRigidSequentialImpulseJointCount(registry); + if (expectedJointCount == 0u) { + // Broken, degenerate, or static-static rigid pair constraints own no + // rows; the shared eligibility rule decides both the count and the + // extraction. + } else { + detail::extractRigidPairConstraintInputsInto( + registry, + rigidPairInputs, + /*includeWorldAnchors=*/false); + DART_SIMULATION_THROW_T_IF( + rigidPairInputs.size() != expectedJointCount, + InvalidOperationException, + "Sequential-impulse rigid joint extraction covered {} of {} active " + "public pair constraints", + rigidPairInputs.size(), + expectedJointCount); + sequentialImpulseJointRows = assembleRigidSequentialImpulseJointsInto( + sequentialImpulseJoints, registry, rigidPairInputs); + } + } + + const auto sweepSequentialImpulseJoints = [&]() { + sweepRigidSequentialImpulseJoints( + registry, sequentialImpulseJoints, world.getTimeStep()); + }; + const auto runSequentialImpulseJointSweeps = [&]() { + if (sequentialImpulseJointRows == 0u) { + return; + } + recordSolverDiagnostics(world, m_iterations); + for (std::size_t iteration = 0; iteration < m_iterations; ++iteration) { + sweepSequentialImpulseJoints(); + } + }; + const auto finalizeSequentialImpulseJoints = [&]() { + if (sequentialImpulseJointRows == 0u) { + return; + } + (void)markBrokenRigidSequentialImpulseJoints( + registry, sequentialImpulseJoints, world.getTimeStep()); + postStabilizeRigidSequentialImpulseJoints( + registry, sequentialImpulseJoints, m_iterations); + }; + const auto projectRigidBlockPointJoints = [&]() { const bool hasPointJointConfigs - = dvbd::mayHaveAvbdRigidWorldPointJointConfigs(registry); + = !useSequentialImpulseFamily + && dvbd::mayHaveAvbdRigidWorldPointJointConfigs(registry); const bool hasDistanceSpringConfigs = dvbd::mayHaveAvbdRigidWorldDistanceSpringConfigs(registry); if (!hasPointJointConfigs && !hasDistanceSpringConfigs) { @@ -820,12 +1503,19 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) }; if (shouldSkipRigidBodyContactQuery(world)) { - if (projectRigidBlockPointJoints()) { + const bool projectedRigidBlockRows = projectRigidBlockPointJoints(); + if (!useSequentialImpulseFamily && projectedRigidBlockRows) { return; } + runSequentialImpulseJointSweeps(); + finalizeSequentialImpulseJoints(); if (m_avbdScratch != nullptr) { - m_avbdScratch->clear(); + if (useSequentialImpulseFamily) { + m_avbdScratch->clearSequentialImpulseOwnedWarmStart(); + } else { + m_avbdScratch->clear(); + } } return; } @@ -843,12 +1533,19 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) } recordRigidContactEnvelopeMetrics(world, registry, contacts); if (contacts.empty()) { - if (projectRigidBlockPointJoints()) { + const bool projectedRigidBlockRows = projectRigidBlockPointJoints(); + if (!useSequentialImpulseFamily && projectedRigidBlockRows) { return; } + runSequentialImpulseJointSweeps(); + finalizeSequentialImpulseJoints(); if (m_avbdScratch != nullptr) { - m_avbdScratch->clear(); + if (useSequentialImpulseFamily) { + m_avbdScratch->clearSequentialImpulseOwnedWarmStart(); + } else { + m_avbdScratch->clear(); + } } return; } @@ -904,7 +1601,8 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) std::size_t appendedJoints = 0u; std::size_t appendedDistanceSprings = 0u; - if (dvbd::mayHaveAvbdRigidWorldPointJointConfigs(registry)) { + if (!useSequentialImpulseFamily + && dvbd::mayHaveAvbdRigidWorldPointJointConfigs(registry)) { dvbd::extractAvbdRigidWorldPointJointInputsInto( registry, scratch.pointJoints, /*includeWorldAnchors=*/false); if (!scratch.pointJoints.empty()) { @@ -969,6 +1667,8 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) = dvbd::applyAvbdRigidWorldContactVelocityProjection( registry, scratch.snapshot, timeStep); if (projection.bodies != 0u) { + runSequentialImpulseJointSweeps(); + finalizeSequentialImpulseJoints(); return; } } @@ -986,7 +1686,14 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) return; } - m_avbdScratch->clear(); + if (useSequentialImpulseFamily) { + // The private AVBD contact compatibility path declined this envelope, + // so SI owns the fallback contact and hard-joint rows. Preserve the + // independently active distance-spring continuation inventory. + m_avbdScratch->clearSequentialImpulseOwnedWarmStart(); + } else { + m_avbdScratch->clear(); + } } DART_SIMULATION_THROW_T_IF( @@ -996,11 +1703,23 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) "sequential-impulse fallback is disabled", solverFamilyName); + if (useSequentialImpulseFamily && m_avbdScratch != nullptr) { + // This contact envelope reached the SI-owned path rather than the explicit + // private AVBD compatibility path above. Its normal and friction + // multipliers must not survive a later runtime switch back to AVBD. + m_avbdScratch->clearContactWarmStart(); + } + if (projectRigidBlockPointJoints()) { - // Keep the AVBD joint projection active while ordinary contacts continue - // through the selected non-AVBD contact solver below. + // Keep explicitly configured AVBD distance springs active while ordinary + // contacts and public hard pair constraints continue through their + // selected non-AVBD solvers below. } else if (m_avbdScratch != nullptr) { - m_avbdScratch->clear(); + if (useSequentialImpulseFamily) { + m_avbdScratch->clearPointJointWarmStart(); + } else { + m_avbdScratch->clear(); + } } // Opt-in boxed-LCP path (PLAN-080 WS4): assemble and solve the Coulomb @@ -1020,7 +1739,14 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) frameScratch.problem.constraints, frameScratch.systemF); captureRigidContactForces( registry, world, frameScratch.problem.constraints, world.getTimeStep()); + runSequentialImpulseJointSweeps(); + (void)markBrokenRigidSequentialImpulseJoints( + registry, sequentialImpulseJoints, world.getTimeStep()); resolveRigidBodyContactPositions(registry, contacts, world.getTimeStep()); + // The contact-unaware joint pass runs last and may re-introduce shallow + // contact penetration; the SI wall oracles budget that residual. + postStabilizeRigidSequentialImpulseJoints( + registry, sequentialImpulseJoints, m_iterations); return; } @@ -1038,7 +1764,7 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) constraints.begin(), constraints.end(), [](const auto& constraint) { return constraint.friction > 0.0; }); - if (!constraints.empty()) { + if (!constraints.empty() || sequentialImpulseJointRows != 0u) { recordSolverDiagnostics(world, m_iterations); } @@ -1068,66 +1794,64 @@ void RigidBodyContactStage::execute(World& world, ComputeExecutor& executor) registry, constraint, lambda * constraint.normal); }; - if (!hasFrictionConstraints) { - for (std::size_t iteration = 0; iteration < m_iterations; ++iteration) { - for (auto& constraint : constraints) { - solveNormalImpulse(constraint); - } - } - } else { - for (std::size_t iteration = 0; iteration < m_iterations; ++iteration) { - for (auto& constraint : constraints) { - solveNormalImpulse(constraint); - - // Coulomb friction along each tangent, clamped to the friction pyramid - // bounded by the accumulated normal impulse. - if (constraint.friction > 0.0) { - const double frictionLimit - = constraint.friction * constraint.normalImpulse; - const auto solveFriction = [&](const Eigen::Vector3d& tangent, - double tangentMass, - double& tangentImpulse) { - if (tangentMass <= 0.0 || frictionLimit <= 0.0) { - return; - } - const auto& velocityA - = registry.get(constraint.bodyA); - const auto& velocityB - = registry.get(constraint.bodyB); - const Eigen::Vector3d tangentVelocity - = computeRigidBodyContactPointVelocity( - velocityB, constraint.armB, constraint.staticB) - - computeRigidBodyContactPointVelocity( - velocityA, constraint.armA, constraint.staticA); - double tangentLambda = -tangentVelocity.dot(tangent) / tangentMass; - const double clampedTangent = std::clamp( - tangentImpulse + tangentLambda, -frictionLimit, frictionLimit); - tangentLambda = clampedTangent - tangentImpulse; - tangentImpulse = clampedTangent; - if (tangentLambda == 0.0) { - return; - } - - const Eigen::Vector3d tangentImpulseVector - = tangentLambda * tangent; - applyRigidBodyContactImpulse( - registry, constraint, tangentImpulseVector); - }; - solveFriction( - constraint.tangent1, - constraint.tangentMass1, - constraint.tangentImpulse1); - solveFriction( - constraint.tangent2, - constraint.tangentMass2, - constraint.tangentImpulse2); - } + for (std::size_t iteration = 0; iteration < m_iterations; ++iteration) { + sweepSequentialImpulseJoints(); + for (auto& constraint : constraints) { + solveNormalImpulse(constraint); + + // Coulomb friction along each tangent, clamped to the friction pyramid + // bounded by the accumulated normal impulse. + if (hasFrictionConstraints && constraint.friction > 0.0) { + const double frictionLimit + = constraint.friction * constraint.normalImpulse; + const auto solveFriction = [&](const Eigen::Vector3d& tangent, + double tangentMass, + double& tangentImpulse) { + if (tangentMass <= 0.0 || frictionLimit <= 0.0) { + return; + } + const auto& velocityA + = registry.get(constraint.bodyA); + const auto& velocityB + = registry.get(constraint.bodyB); + const Eigen::Vector3d tangentVelocity + = computeRigidBodyContactPointVelocity( + velocityB, constraint.armB, constraint.staticB) + - computeRigidBodyContactPointVelocity( + velocityA, constraint.armA, constraint.staticA); + double tangentLambda = -tangentVelocity.dot(tangent) / tangentMass; + const double clampedTangent = std::clamp( + tangentImpulse + tangentLambda, -frictionLimit, frictionLimit); + tangentLambda = clampedTangent - tangentImpulse; + tangentImpulse = clampedTangent; + if (tangentLambda == 0.0) { + return; + } + + const Eigen::Vector3d tangentImpulseVector = tangentLambda * tangent; + applyRigidBodyContactImpulse( + registry, constraint, tangentImpulseVector); + }; + solveFriction( + constraint.tangent1, + constraint.tangentMass1, + constraint.tangentImpulse1); + solveFriction( + constraint.tangent2, + constraint.tangentMass2, + constraint.tangentImpulse2); } } } captureRigidContactForces(registry, world, constraints, world.getTimeStep()); + (void)markBrokenRigidSequentialImpulseJoints( + registry, sequentialImpulseJoints, world.getTimeStep()); resolveRigidBodyContactPositions(registry, contacts, world.getTimeStep()); + // The contact-unaware joint pass runs last and may re-introduce shallow + // contact penetration; the SI wall oracles budget that residual. + postStabilizeRigidSequentialImpulseJoints( + registry, sequentialImpulseJoints, m_iterations); } //============================================================================== diff --git a/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp b/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp index bc2b96a5251cf..d916a879860b6 100644 --- a/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp +++ b/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include @@ -65,6 +66,12 @@ namespace dart::simulation::detail::deformable_vbd { +static_assert( + kAvbdRigidJointAllAxesMask + == ::dart::simulation::detail::kRigidPairConstraintAllAxesMask, + "The AVBD kernel and solver-neutral rigid pair-constraint axis masks must " + "stay identical; consolidate before changing either"); + struct AvbdRigidWorldContactOptions { double startStiffness = 1.0; @@ -85,43 +92,16 @@ struct AvbdRigidWorldEndpoint bool canProjectAsRigidBody = false; }; -struct AvbdRigidWorldProjectableBodyView -{ - const comps::Transform* transform = nullptr; - const comps::MassProperties* mass = nullptr; - bool isStatic = false; - - explicit operator bool() const noexcept - { - return transform != nullptr && mass != nullptr; - } -}; +using AvbdRigidWorldProjectableBodyView + = ::dart::simulation::detail::RigidPairConstraintBodyView; struct AvbdRigidWorldPointJointInput + : ::dart::simulation::detail::RigidPairConstraintInput { - entt::entity joint = entt::null; - entt::entity bodyA = entt::null; - entt::entity bodyB = entt::null; - AvbdRigidWorldProjectableBodyView bodyAView; - AvbdRigidWorldProjectableBodyView bodyBView; - Eigen::Vector3d anchorA = Eigen::Vector3d::Zero(); - Eigen::Vector3d anchorB = Eigen::Vector3d::Zero(); - bool anchorsAreLocal = false; - Eigen::Quaterniond targetRelativeOrientation = Eigen::Quaterniond::Identity(); - Eigen::Matrix3d linearAxes = Eigen::Matrix3d::Identity(); - Eigen::Matrix3d angularAxes = Eigen::Matrix3d::Identity(); - std::uint8_t linearAxisMask = kAvbdRigidJointAllAxesMask; - std::uint8_t angularAxisMask = kAvbdRigidJointAllAxesMask; - bool useLinearMotor = false; - bool useAngularMotor = false; - double motorTargetSpeed = 0.0; - double motorMaxForce = std::numeric_limits::infinity(); - double motorMaxTorque = std::numeric_limits::infinity(); double startStiffness = 1.0; double linearMaterialStiffness = std::numeric_limits::infinity(); double angularMaterialStiffness = std::numeric_limits::infinity(); double maxStiffness = std::numeric_limits::infinity(); - double fractureThreshold = 0.0; }; struct AvbdRigidWorldDistanceSpringInput @@ -148,8 +128,10 @@ struct AvbdRigidWorldPointJointConfig Eigen::Quaterniond targetRelativeOrientation = Eigen::Quaterniond::Identity(); Eigen::Matrix3d linearAxes = Eigen::Matrix3d::Identity(); Eigen::Matrix3d angularAxes = Eigen::Matrix3d::Identity(); - std::uint8_t linearAxisMask = kAvbdRigidJointAllAxesMask; - std::uint8_t angularAxisMask = kAvbdRigidJointAllAxesMask; + std::uint8_t linearAxisMask + = ::dart::simulation::detail::kRigidPairConstraintAllAxesMask; + std::uint8_t angularAxisMask + = ::dart::simulation::detail::kRigidPairConstraintAllAxesMask; double startStiffness = 1.0; double linearMaterialStiffness = std::numeric_limits::infinity(); double angularMaterialStiffness = std::numeric_limits::infinity(); @@ -647,22 +629,7 @@ inline AvbdRigidWorldProjectableBodyView avbdRigidWorldProjectableBody( const ::dart::simulation::detail::WorldRegistry& registry, entt::entity entity) { - if (entity == entt::null || !registry.valid(entity) - || !registry.all_of(entity)) { - return {}; - } - - const auto* transform = registry.try_get(entity); - const auto* mass = registry.try_get(entity); - if (transform == nullptr || mass == nullptr) { - return {}; - } - - return AvbdRigidWorldProjectableBodyView{ - transform, - mass, - registry.all_of(entity) - || registry.all_of(entity)}; + return ::dart::simulation::detail::rigidPairConstraintBody(registry, entity); } //============================================================================== @@ -738,9 +705,7 @@ inline Eigen::Isometry3d avbdRigidWorldContactToIsometry( //============================================================================== inline bool isAvbdRigidWorldPointJointType(comps::JointType type) { - return type == comps::JointType::Fixed || type == comps::JointType::Revolute - || type == comps::JointType::Prismatic - || type == comps::JointType::Spherical; + return ::dart::simulation::detail::isRigidPairConstraintType(type); } //============================================================================== @@ -857,25 +822,8 @@ inline bool computeAvbdRigidWorldPointJointDefaultStiffness( //============================================================================== inline double avbdRigidWorldSymmetricEffortLimit(const comps::JointModel& joint) { - if (joint.limits.effortLower.size() < 1 || joint.limits.effortUpper.size() < 1 - || std::isnan(joint.limits.effortLower[0]) - || std::isnan(joint.limits.effortUpper[0])) { - return std::numeric_limits::infinity(); - } - - const double lower = joint.limits.effortLower[0]; - const double upper = joint.limits.effortUpper[0]; - if (lower > 0.0 || upper < 0.0 || lower > upper) { - return 0.0; - } - - const double lowerMagnitude = std::isfinite(lower) - ? std::max(0.0, -lower) - : std::numeric_limits::infinity(); - const double upperMagnitude = std::isfinite(upper) - ? std::max(0.0, upper) - : std::numeric_limits::infinity(); - return std::min(lowerMagnitude, upperMagnitude); + return ::dart::simulation::detail::rigidPairConstraintSymmetricEffortLimit( + joint); } //============================================================================== @@ -984,18 +932,19 @@ inline bool configureAvbdRigidWorldPointJointFromCurrentPose( Eigen::Vector3d localAnchorA = Eigen::Vector3d::Zero(); Eigen::Vector3d localAnchorB = Eigen::Vector3d::Zero(); Eigen::Quaterniond targetRelativeOrientation = Eigen::Quaterniond::Identity(); - if (joint->hasRigidBodyFixedJointAnchors) { - if (!joint->rigidBodyFixedJointLocalAnchorParent.allFinite() - || !joint->rigidBodyFixedJointLocalAnchorChild.allFinite() - || !joint->rigidBodyFixedJointTargetRelativeOrientation.coeffs() + if (joint->hasRigidBodyPairConstraintGeometry) { + if (!joint->rigidBodyPairConstraintLocalAnchorParent.allFinite() + || !joint->rigidBodyPairConstraintLocalAnchorChild.allFinite() + || !joint->rigidBodyPairConstraintTargetRelativeOrientation.coeffs() .allFinite() - || joint->rigidBodyFixedJointTargetRelativeOrientation.norm() == 0.0) { + || joint->rigidBodyPairConstraintTargetRelativeOrientation.norm() + == 0.0) { return false; } - localAnchorA = joint->rigidBodyFixedJointLocalAnchorParent; - localAnchorB = joint->rigidBodyFixedJointLocalAnchorChild; + localAnchorA = joint->rigidBodyPairConstraintLocalAnchorParent; + localAnchorB = joint->rigidBodyPairConstraintLocalAnchorChild; targetRelativeOrientation = normalizeAvbdRigidOrientation( - joint->rigidBodyFixedJointTargetRelativeOrientation); + joint->rigidBodyPairConstraintTargetRelativeOrientation); } else { const Eigen::Quaterniond orientationA = normalizeAvbdRigidOrientation( Eigen::Quaterniond(worldAFromEndpoint.linear())); @@ -1009,10 +958,10 @@ inline bool configureAvbdRigidWorldPointJointFromCurrentPose( targetRelativeOrientation = normalizeAvbdRigidOrientation( orientationA.conjugate() * orientationB); - joint->hasRigidBodyFixedJointAnchors = true; - joint->rigidBodyFixedJointLocalAnchorParent = localAnchorA; - joint->rigidBodyFixedJointLocalAnchorChild = localAnchorB; - joint->rigidBodyFixedJointTargetRelativeOrientation + joint->hasRigidBodyPairConstraintGeometry = true; + joint->rigidBodyPairConstraintLocalAnchorParent = localAnchorA; + joint->rigidBodyPairConstraintLocalAnchorChild = localAnchorB; + joint->rigidBodyPairConstraintTargetRelativeOrientation = targetRelativeOrientation; } @@ -1030,14 +979,19 @@ inline bool configureAvbdRigidWorldPointJointFromCurrentPose( } const Eigen::Matrix3d jointAxes - = avbdRigidJointAxesFromFreeAxis(joint->axis); + = ::dart::simulation::detail::rigidPairConstraintAxesFromFreeAxis( + joint->axis); if (joint->type == comps::JointType::Revolute) { angularAxes = jointAxes; - angularAxisMask = avbdRigidJointAllButAxisMask(/*freeAxis=*/2u); + angularAxisMask + = ::dart::simulation::detail::rigidPairConstraintAllButAxisMask( + /*freeAxis=*/2u); } else { linearAxes = jointAxes; angularAxes = jointAxes; - linearAxisMask = avbdRigidJointAllButAxisMask(/*freeAxis=*/2u); + linearAxisMask + = ::dart::simulation::detail::rigidPairConstraintAllButAxisMask( + /*freeAxis=*/2u); } } @@ -2586,9 +2540,92 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( std::size_t linearCursor = 0; std::size_t angularCursor = 0; + std::size_t linearMotorInputCursor = 0; + std::size_t angularMotorInputCursor = 0; + std::size_t linearMotorBuiltCursor = 0; + std::size_t angularMotorBuiltCursor = 0; for (std::size_t jointIndex = 0; jointIndex < snapshot.joints.size(); ++jointIndex) { const AvbdRigidPointJoint& joint = snapshot.joints[jointIndex]; + + // Bounded one-DOF motor rows are stored as a separate row family but load + // the same physical joint, so their accepted force joins the masked + // linear/angular rows in the fracture norm. Motors were appended in joint + // order and identified by the owning joint's row id and endpoints; the + // cursors advance for every joint, valid or not, to stay aligned with + // that append order. + double motorForceSquared = 0.0; + while (linearMotorInputCursor < snapshot.linearMotors.size()) { + const AvbdRigidLinearMotor& motor + = snapshot.linearMotors[linearMotorInputCursor]; + if (motor.row != joint.row + || motor.endpointA.object != joint.endpointA.object + || motor.endpointA.feature != joint.endpointA.feature + || motor.endpointB.object != joint.endpointB.object + || motor.endpointB.feature != joint.endpointB.feature) { + break; + } + ++linearMotorInputCursor; + if (!detail::isValidAvbdRigidLinearMotor( + motor, snapshot.states.size(), timeStep)) { + continue; + } + if (linearMotorBuiltCursor >= linearMotorRowCount) { + break; + } + const AvbdRigidBodyPointPairRow& indexedMotorRow + = (*solvePointPairRows)[linearMotorOffset + linearMotorBuiltCursor]; + ++linearMotorBuiltCursor; + const AvbdRigidPointPairRow& motorRow = indexedMotorRow.row; + const double force + = avbdRigidRowUsesFiniteMaterial(motorRow.materialStiffness) + ? avbdRigidScalarRowForce( + motorRow.state, + avbdRigidPointPairConstraintValue( + snapshot.states[indexedMotorRow.bodyA], + snapshot.states[indexedMotorRow.bodyB], + motorRow), + motorRow.bounds, + motorRow.materialStiffness) + : motorRow.state.lambda; + motorForceSquared += avbdRigidWorldJointRowLambdaSquared(force); + } + while (angularMotorInputCursor < snapshot.motors.size()) { + const AvbdRigidAngularMotor& motor + = snapshot.motors[angularMotorInputCursor]; + if (motor.row != joint.row + || motor.endpointA.object != joint.endpointA.object + || motor.endpointA.feature != joint.endpointA.feature + || motor.endpointB.object != joint.endpointB.object + || motor.endpointB.feature != joint.endpointB.feature) { + break; + } + ++angularMotorInputCursor; + if (!detail::isValidAvbdRigidAngularMotor( + motor, snapshot.states.size(), timeStep)) { + continue; + } + if (angularMotorBuiltCursor >= angularMotorRowCount) { + break; + } + const AvbdRigidBodyAngularPairRow& indexedMotorRow + = (*solveAngularRows)[angularMotorOffset + angularMotorBuiltCursor]; + ++angularMotorBuiltCursor; + const AvbdRigidAngularPairRow& motorRow = indexedMotorRow.row; + const double force + = avbdRigidRowUsesFiniteMaterial(motorRow.materialStiffness) + ? avbdRigidScalarRowForce( + motorRow.state, + avbdRigidAngularPairConstraintValue( + snapshot.states[indexedMotorRow.bodyA], + snapshot.states[indexedMotorRow.bodyB], + motorRow), + motorRow.bounds, + motorRow.materialStiffness) + : motorRow.state.lambda; + motorForceSquared += avbdRigidWorldJointRowLambdaSquared(force); + } + if (!detail::isValidAvbdRigidPointJoint(joint, snapshot.states.size())) { continue; } @@ -2597,7 +2634,7 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( = avbdRigidWorldActiveJointAxisCount(joint.linearAxisMask); const std::size_t angularRowsForJoint = avbdRigidWorldActiveJointAxisCount(joint.angularAxisMask); - double forceSquared = 0.0; + double forceSquared = motorForceSquared; for (std::size_t i = 0; i < linearRows && linearCursor + i < jointLinearRowCount; ++i) { @@ -3085,129 +3122,52 @@ inline void extractAvbdRigidWorldPointJointInputsInto( InputVector& inputs, bool includeWorldAnchors = true) { - const auto view - = registry.view(); - inputs.clear(); - inputs.reserve(view.size_hint()); - - for (const entt::entity entity : view) { - const auto& jointModel = view.get(entity); - const auto& jointState = registry.get(entity); - const auto& jointActuation = registry.get(entity); - const auto& config = view.get(entity); - if (!config.enabled || jointState.broken - || !isAvbdRigidWorldPointJointType(jointModel.type)) { - continue; - } - - if (jointModel.parentLink == entt::null - || jointModel.childLink == entt::null - || jointModel.parentLink == jointModel.childLink) { - continue; - } - - const AvbdRigidWorldProjectableBodyView bodyA - = avbdRigidWorldProjectableBody(registry, jointModel.parentLink); - const AvbdRigidWorldProjectableBodyView bodyB - = avbdRigidWorldProjectableBody(registry, jointModel.childLink); - if (!bodyA || !bodyB || (bodyA.isStatic && bodyB.isStatic)) { - continue; - } - const auto* transformA = bodyA.transform; - const auto* transformB = bodyB.transform; - - if (!config.localAnchorA.allFinite() || !config.localAnchorB.allFinite() - || !config.targetRelativeOrientation.coeffs().allFinite() - || !config.linearAxes.allFinite() || !config.angularAxes.allFinite() - || std::isnan(config.startStiffness) || config.startStiffness < 0.0 - || std::isnan(config.linearMaterialStiffness) - || config.linearMaterialStiffness < 0.0 - || std::isnan(config.angularMaterialStiffness) - || config.angularMaterialStiffness < 0.0 - || std::isnan(config.maxStiffness) - || config.maxStiffness < config.startStiffness) { - continue; - } - - if (!transformA->position.allFinite() - || !transformA->orientation.coeffs().allFinite() - || !transformB->position.allFinite() - || !transformB->orientation.coeffs().allFinite()) { - continue; - } - - const bool hasAngularVelocityMotor - = jointModel.type == comps::JointType::Revolute - && jointActuation.actuatorType == comps::ActuatorType::Velocity - && jointActuation.commandVelocity.size() == 1 - && jointActuation.commandVelocity.allFinite(); - const bool hasLinearVelocityMotor - = jointModel.type == comps::JointType::Prismatic - && jointActuation.actuatorType == comps::ActuatorType::Velocity - && jointActuation.commandVelocity.size() == 1 - && jointActuation.commandVelocity.allFinite(); - const bool useStableFullLinearBasis - = !includeWorldAnchors - && config.linearAxisMask == kAvbdRigidJointAllAxesMask - && config.angularAxisMask == 0u && !hasAngularVelocityMotor - && !hasLinearVelocityMotor - && config.linearAxes.isApprox(Eigen::Matrix3d::Identity(), 0.0); - Eigen::Quaterniond orientationA = Eigen::Quaterniond::Identity(); - Eigen::Matrix3d parentRotation = Eigen::Matrix3d::Identity(); - if (!useStableFullLinearBasis || includeWorldAnchors) { - orientationA = normalizeAvbdRigidOrientation(transformA->orientation); - parentRotation = orientationA.toRotationMatrix(); - } - - AvbdRigidWorldPointJointInput input; - input.joint = entity; - input.bodyA = jointModel.parentLink; - input.bodyB = jointModel.childLink; - input.bodyAView = bodyA; - input.bodyBView = bodyB; - if (includeWorldAnchors) { - const Eigen::Quaterniond orientationB - = normalizeAvbdRigidOrientation(transformB->orientation); - input.anchorA = transformA->position + orientationA * config.localAnchorA; - input.anchorB = transformB->position + orientationB * config.localAnchorB; - } else { - input.anchorA = config.localAnchorA; - input.anchorB = config.localAnchorB; - input.anchorsAreLocal = true; - } - input.targetRelativeOrientation - = normalizeAvbdRigidOrientation(config.targetRelativeOrientation); - input.linearAxes = useStableFullLinearBasis - ? config.linearAxes - : parentRotation * config.linearAxes; - input.angularAxes = useStableFullLinearBasis - ? config.angularAxes - : parentRotation * config.angularAxes; - input.linearAxisMask = config.linearAxisMask; - input.angularAxisMask = config.angularAxisMask; - if (hasAngularVelocityMotor) { - const double maxTorque = avbdRigidWorldSymmetricEffortLimit(jointModel); - if (maxTorque > 0.0 && !std::isnan(maxTorque)) { - input.useAngularMotor = true; - input.motorTargetSpeed = jointActuation.commandVelocity[0]; - input.motorMaxTorque = maxTorque; - } - } - if (hasLinearVelocityMotor) { - const double maxForce = avbdRigidWorldSymmetricEffortLimit(jointModel); - if (maxForce > 0.0 && !std::isnan(maxForce)) { - input.useLinearMotor = true; - input.motorTargetSpeed = jointActuation.commandVelocity[0]; - input.motorMaxForce = maxForce; - } - } - input.startStiffness = config.startStiffness; - input.linearMaterialStiffness = config.linearMaterialStiffness; - input.angularMaterialStiffness = config.angularMaterialStiffness; - input.maxStiffness = config.maxStiffness; - input.fractureThreshold = jointModel.breakForce; - inputs.push_back(input); - } + using Input = typename InputVector::value_type; + static_assert(std::is_same_v); + + const auto geometryProvider + = [®istry](entt::entity entity, const comps::JointModel&) + -> std::optional< + ::dart::simulation::detail::RigidPairConstraintGeometry> { + const auto* config + = registry.try_get(entity); + if (config == nullptr) { + return std::nullopt; + } + + ::dart::simulation::detail::RigidPairConstraintGeometry geometry; + geometry.enabled = config->enabled; + geometry.localAnchorA = config->localAnchorA; + geometry.localAnchorB = config->localAnchorB; + geometry.targetRelativeOrientation = config->targetRelativeOrientation; + geometry.linearAxes = config->linearAxes; + geometry.angularAxes = config->angularAxes; + geometry.linearAxisMask = config->linearAxisMask; + geometry.angularAxisMask = config->angularAxisMask; + return geometry; + }; + const auto enrichInput + = [®istry](entt::entity entity, AvbdRigidWorldPointJointInput& input) { + const auto& config + = registry.get(entity); + if (std::isnan(config.startStiffness) || config.startStiffness < 0.0 + || std::isnan(config.linearMaterialStiffness) + || config.linearMaterialStiffness < 0.0 + || std::isnan(config.angularMaterialStiffness) + || config.angularMaterialStiffness < 0.0 + || std::isnan(config.maxStiffness) + || config.maxStiffness < config.startStiffness) { + return false; + } + + input.startStiffness = config.startStiffness; + input.linearMaterialStiffness = config.linearMaterialStiffness; + input.angularMaterialStiffness = config.angularMaterialStiffness; + input.maxStiffness = config.maxStiffness; + return true; + }; + ::dart::simulation::detail::extractRigidPairConstraintInputsInto( + registry, inputs, includeWorldAnchors, geometryProvider, enrichInput); } //============================================================================== diff --git a/dart/simulation/detail/rigid_pair_constraint.hpp b/dart/simulation/detail/rigid_pair_constraint.hpp new file mode 100644 index 0000000000000..2cf9ae19db5f4 --- /dev/null +++ b/dart/simulation/detail/rigid_pair_constraint.hpp @@ -0,0 +1,513 @@ +/* + * Copyright (c) 2011, The DART development contributors + * All rights reserved. + * + * The list of contributors can be found at: + * https://github.com/dartsim/dart/blob/main/LICENSE + * + * This file is provided under the following "BSD-style" License: + * Redistribution and use in source and binary forms, with or + * without modification, are permitted provided that the following + * conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF + * USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED + * AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include +#include + +namespace dart::simulation::detail { + +inline constexpr std::uint8_t kRigidPairConstraintAllAxesMask = 0x7u; + +/// Solver-neutral geometric description of a public rigid-body pair +/// constraint. +/// +/// Material stiffness and persistent multipliers intentionally do not live +/// here. Solver families enrich this geometry with their own policy/state. +struct RigidPairConstraintGeometry +{ + bool enabled = true; + Eigen::Vector3d localAnchorA = Eigen::Vector3d::Zero(); + Eigen::Vector3d localAnchorB = Eigen::Vector3d::Zero(); + Eigen::Quaterniond targetRelativeOrientation = Eigen::Quaterniond::Identity(); + Eigen::Matrix3d linearAxes = Eigen::Matrix3d::Identity(); + Eigen::Matrix3d angularAxes = Eigen::Matrix3d::Identity(); + std::uint8_t linearAxisMask = kRigidPairConstraintAllAxesMask; + std::uint8_t angularAxisMask = kRigidPairConstraintAllAxesMask; +}; + +/// Read-only endpoint data shared by rigid pair-constraint solver families. +struct RigidPairConstraintBodyView +{ + const comps::Transform* transform = nullptr; + const comps::MassProperties* mass = nullptr; + bool isStatic = false; + + explicit operator bool() const noexcept + { + return transform != nullptr && mass != nullptr; + } +}; + +/// Solver-neutral runtime input for a public rigid-body pair constraint. +/// +/// The input includes public geometry, actuation, and fracture policy. AVBD +/// stiffness and warm-start state are deliberately owned by AVBD adapters. +struct RigidPairConstraintInput +{ + entt::entity joint = entt::null; + entt::entity bodyA = entt::null; + entt::entity bodyB = entt::null; + RigidPairConstraintBodyView bodyAView; + RigidPairConstraintBodyView bodyBView; + Eigen::Vector3d anchorA = Eigen::Vector3d::Zero(); + Eigen::Vector3d anchorB = Eigen::Vector3d::Zero(); + bool anchorsAreLocal = false; + Eigen::Quaterniond targetRelativeOrientation = Eigen::Quaterniond::Identity(); + Eigen::Matrix3d linearAxes = Eigen::Matrix3d::Identity(); + Eigen::Matrix3d angularAxes = Eigen::Matrix3d::Identity(); + std::uint8_t linearAxisMask = kRigidPairConstraintAllAxesMask; + std::uint8_t angularAxisMask = kRigidPairConstraintAllAxesMask; + bool useLinearMotor = false; + bool useAngularMotor = false; + double motorTargetSpeed = 0.0; + double motorMaxForce = std::numeric_limits::infinity(); + double motorMaxTorque = std::numeric_limits::infinity(); + double fractureThreshold = 0.0; +}; + +//============================================================================== +inline Eigen::Quaterniond normalizeRigidPairConstraintOrientation( + const Eigen::Quaterniond& orientation) +{ + const double squaredNorm = orientation.squaredNorm(); + if (!std::isfinite(squaredNorm) || squaredNorm <= 0.0) { + return Eigen::Quaterniond::Identity(); + } + if (squaredNorm == 1.0) { + return orientation; + } + + Eigen::Quaterniond normalized = orientation; + normalized.coeffs() /= std::sqrt(squaredNorm); + return normalized; +} + +inline constexpr double kRigidPairConstraintPi + = 3.141592653589793238462643383279502884; + +//============================================================================== +inline Eigen::Vector3d rigidPairConstraintRotationVectorFromNormalized( + const Eigen::Quaterniond& q) +{ + const Eigen::Vector3d vector = q.vec(); + const double vectorNorm = vector.norm(); + if (vectorNorm <= 0.0 || !std::isfinite(vectorNorm)) { + return Eigen::Vector3d::Zero(); + } + + double angle = 2.0 * std::atan2(vectorNorm, q.w()); + if (angle > kRigidPairConstraintPi) { + angle -= 2.0 * kRigidPairConstraintPi; + } else if (angle < -kRigidPairConstraintPi) { + angle += 2.0 * kRigidPairConstraintPi; + } + return (angle / vectorNorm) * vector; +} + +//============================================================================== +/// Shortest-arc rotation vector taking `targetOrientation` to `orientation`. +inline Eigen::Vector3d rigidPairConstraintOrientationError( + const Eigen::Quaterniond& orientation, + const Eigen::Quaterniond& targetOrientation) +{ + const Eigen::Quaterniond current + = normalizeRigidPairConstraintOrientation(orientation); + const Eigen::Quaterniond target + = normalizeRigidPairConstraintOrientation(targetOrientation); + return rigidPairConstraintRotationVectorFromNormalized( + current * target.conjugate()); +} + +//============================================================================== +inline bool isRigidPairConstraintType(comps::JointType type) +{ + return type == comps::JointType::Fixed || type == comps::JointType::Revolute + || type == comps::JointType::Prismatic + || type == comps::JointType::Spherical; +} + +//============================================================================== +inline std::uint8_t rigidPairConstraintAxisBit(std::uint8_t axis) +{ + return axis < 3u ? static_cast(1u << axis) : 0u; +} + +//============================================================================== +inline std::uint8_t rigidPairConstraintAllButAxisMask(std::uint8_t freeAxis) +{ + return static_cast( + kRigidPairConstraintAllAxesMask & ~rigidPairConstraintAxisBit(freeAxis)); +} + +//============================================================================== +inline Eigen::Matrix3d rigidPairConstraintAxesFromFreeAxis( + const Eigen::Vector3d& freeAxis, + const Eigen::Vector3d& fallback = Eigen::Vector3d::UnitZ()) +{ + Eigen::Vector3d axis = freeAxis; + const double axisNorm = axis.norm(); + if (!axis.allFinite() || axisNorm <= 0.0) { + axis = fallback; + } else { + axis /= axisNorm; + } + + const Eigen::Vector3d first = axis.unitOrthogonal(); + Eigen::Vector3d second = axis.cross(first); + const double secondNorm = second.norm(); + if (!second.allFinite() || secondNorm <= 0.0) { + second = axis.cross(Eigen::Vector3d::UnitX()); + if (second.squaredNorm() <= 0.0) { + second = axis.cross(Eigen::Vector3d::UnitY()); + } + second.normalize(); + } else { + second /= secondNorm; + } + + Eigen::Matrix3d axes; + axes.col(0) = first; + axes.col(1) = second; + axes.col(2) = axis; + return axes; +} + +//============================================================================== +inline RigidPairConstraintBodyView rigidPairConstraintBody( + const WorldRegistry& registry, entt::entity entity) +{ + if (entity == entt::null || !registry.valid(entity) + || !registry.all_of(entity)) { + return {}; + } + + const auto* transform = registry.try_get(entity); + const auto* mass = registry.try_get(entity); + if (transform == nullptr || mass == nullptr) { + return {}; + } + + return RigidPairConstraintBodyView{ + transform, + mass, + registry.all_of(entity) + || registry.all_of(entity)}; +} + +//============================================================================== +inline double rigidPairConstraintSymmetricEffortLimit( + const comps::JointModel& joint) +{ + if (joint.limits.effortLower.size() < 1 || joint.limits.effortUpper.size() < 1 + || std::isnan(joint.limits.effortLower[0]) + || std::isnan(joint.limits.effortUpper[0])) { + return std::numeric_limits::infinity(); + } + + const double lower = joint.limits.effortLower[0]; + const double upper = joint.limits.effortUpper[0]; + if (lower > 0.0 || upper < 0.0 || lower > upper) { + return 0.0; + } + + const double lowerMagnitude = std::isfinite(lower) + ? std::max(0.0, -lower) + : std::numeric_limits::infinity(); + const double upperMagnitude = std::isfinite(upper) + ? std::max(0.0, upper) + : std::numeric_limits::infinity(); + return std::min(lowerMagnitude, upperMagnitude); +} + +//============================================================================== +inline std::optional +rigidPairConstraintGeometryFromJointModel(const comps::JointModel& joint) +{ + if (!joint.hasRigidBodyPairConstraintGeometry + || !isRigidPairConstraintType(joint.type)) { + return std::nullopt; + } + + RigidPairConstraintGeometry geometry; + geometry.localAnchorA = joint.rigidBodyPairConstraintLocalAnchorParent; + geometry.localAnchorB = joint.rigidBodyPairConstraintLocalAnchorChild; + geometry.targetRelativeOrientation + = joint.rigidBodyPairConstraintTargetRelativeOrientation; + + if (joint.type == comps::JointType::Spherical) { + geometry.angularAxisMask = 0u; + } else if ( + joint.type == comps::JointType::Revolute + || joint.type == comps::JointType::Prismatic) { + if (!joint.axis.allFinite() || joint.axis.squaredNorm() <= 0.0) { + return std::nullopt; + } + const Eigen::Matrix3d axes + = rigidPairConstraintAxesFromFreeAxis(joint.axis); + if (joint.type == comps::JointType::Revolute) { + geometry.angularAxes = axes; + geometry.angularAxisMask + = rigidPairConstraintAllButAxisMask(/*freeAxis=*/2u); + } else { + geometry.linearAxes = axes; + geometry.angularAxes = axes; + geometry.linearAxisMask + = rigidPairConstraintAllButAxisMask(/*freeAxis=*/2u); + } + } + return geometry; +} + +/// Per-entity eligibility decision shared by row extraction and expected-row +/// counting so both surfaces always skip exactly the same joints, including +/// degenerate or non-finite state. +struct RigidPairConstraintEligibility +{ + RigidPairConstraintGeometry geometry; + RigidPairConstraintBodyView bodyA; + RigidPairConstraintBodyView bodyB; +}; + +template +inline std::optional +eligibleRigidPairConstraint( + const WorldRegistry& registry, + entt::entity entity, + const comps::JointModel& jointModel, + const comps::JointState& jointState, + GeometryProvider&& geometryProvider) +{ + if (jointState.broken || !isRigidPairConstraintType(jointModel.type)) { + return std::nullopt; + } + + std::optional geometryResult + = geometryProvider(entity, jointModel); + if (!geometryResult.has_value() || !geometryResult->enabled) { + return std::nullopt; + } + + if (jointModel.parentLink == entt::null || jointModel.childLink == entt::null + || jointModel.parentLink == jointModel.childLink) { + return std::nullopt; + } + + const RigidPairConstraintBodyView bodyA + = rigidPairConstraintBody(registry, jointModel.parentLink); + const RigidPairConstraintBodyView bodyB + = rigidPairConstraintBody(registry, jointModel.childLink); + if (!bodyA || !bodyB || (bodyA.isStatic && bodyB.isStatic)) { + return std::nullopt; + } + + const RigidPairConstraintGeometry& geometry = *geometryResult; + if (!geometry.localAnchorA.allFinite() || !geometry.localAnchorB.allFinite() + || !geometry.targetRelativeOrientation.coeffs().allFinite() + || geometry.targetRelativeOrientation.squaredNorm() <= 0.0 + || !geometry.linearAxes.allFinite() + || !geometry.angularAxes.allFinite()) { + return std::nullopt; + } + if (!bodyA.transform->position.allFinite() + || !bodyA.transform->orientation.coeffs().allFinite() + || !bodyB.transform->position.allFinite() + || !bodyB.transform->orientation.coeffs().allFinite()) { + return std::nullopt; + } + + return RigidPairConstraintEligibility{ + std::move(*geometryResult), bodyA, bodyB}; +} + +/// Shared extraction implementation. Geometry providers may select canonical +/// JointModel geometry or a family-private compatibility geometry. Enrichers +/// add solver-owned policy without moving it into the shared input. +/// +/// Row order follows the iteration order of the +/// `JointModel`/`JointState`/`JointActuation` view. Gauss-Seidel style +/// consumers are order-sensitive, so changing the view composition or its +/// driving pool changes solver trajectories. +template < + typename InputVector, + typename GeometryProvider, + typename InputEnricher> +inline void extractRigidPairConstraintInputsInto( + const WorldRegistry& registry, + InputVector& inputs, + bool includeWorldAnchors, + GeometryProvider&& geometryProvider, + InputEnricher&& enrichInput) +{ + using Input = typename InputVector::value_type; + static_assert( + std::is_same_v + || std::is_base_of_v); + + const auto view = registry.view< + comps::JointModel, + comps::JointState, + comps::JointActuation>(); + inputs.clear(); + inputs.reserve(view.size_hint()); + + for (const entt::entity entity : view) { + const auto& jointModel = view.get(entity); + const auto& jointState = view.get(entity); + const auto& jointActuation = view.get(entity); + + const std::optional eligibility + = eligibleRigidPairConstraint( + registry, entity, jointModel, jointState, geometryProvider); + if (!eligibility.has_value()) { + continue; + } + const RigidPairConstraintGeometry& geometry = eligibility->geometry; + const RigidPairConstraintBodyView& bodyA = eligibility->bodyA; + const RigidPairConstraintBodyView& bodyB = eligibility->bodyB; + const auto* transformA = bodyA.transform; + const auto* transformB = bodyB.transform; + + const bool hasAngularVelocityMotor + = jointModel.type == comps::JointType::Revolute + && jointActuation.actuatorType == comps::ActuatorType::Velocity + && jointActuation.commandVelocity.size() == 1 + && jointActuation.commandVelocity.allFinite(); + const bool hasLinearVelocityMotor + = jointModel.type == comps::JointType::Prismatic + && jointActuation.actuatorType == comps::ActuatorType::Velocity + && jointActuation.commandVelocity.size() == 1 + && jointActuation.commandVelocity.allFinite(); + const bool useStableFullLinearBasis + = !includeWorldAnchors + && geometry.linearAxisMask == kRigidPairConstraintAllAxesMask + && geometry.angularAxisMask == 0u && !hasAngularVelocityMotor + && !hasLinearVelocityMotor + && geometry.linearAxes.isApprox(Eigen::Matrix3d::Identity(), 0.0); + + Eigen::Quaterniond orientationA = Eigen::Quaterniond::Identity(); + Eigen::Matrix3d parentRotation = Eigen::Matrix3d::Identity(); + if (!useStableFullLinearBasis) { + orientationA + = normalizeRigidPairConstraintOrientation(transformA->orientation); + parentRotation = orientationA.toRotationMatrix(); + } + + Input input; + RigidPairConstraintInput& sharedInput = input; + sharedInput.joint = entity; + sharedInput.bodyA = jointModel.parentLink; + sharedInput.bodyB = jointModel.childLink; + sharedInput.bodyAView = bodyA; + sharedInput.bodyBView = bodyB; + if (includeWorldAnchors) { + const Eigen::Quaterniond orientationB + = normalizeRigidPairConstraintOrientation(transformB->orientation); + sharedInput.anchorA + = transformA->position + orientationA * geometry.localAnchorA; + sharedInput.anchorB + = transformB->position + orientationB * geometry.localAnchorB; + } else { + sharedInput.anchorA = geometry.localAnchorA; + sharedInput.anchorB = geometry.localAnchorB; + sharedInput.anchorsAreLocal = true; + } + sharedInput.targetRelativeOrientation + = normalizeRigidPairConstraintOrientation( + geometry.targetRelativeOrientation); + sharedInput.linearAxes = useStableFullLinearBasis + ? geometry.linearAxes + : parentRotation * geometry.linearAxes; + sharedInput.angularAxes = useStableFullLinearBasis + ? geometry.angularAxes + : parentRotation * geometry.angularAxes; + sharedInput.linearAxisMask = geometry.linearAxisMask; + sharedInput.angularAxisMask = geometry.angularAxisMask; + if (hasAngularVelocityMotor) { + const double maxTorque + = rigidPairConstraintSymmetricEffortLimit(jointModel); + if (maxTorque > 0.0 && !std::isnan(maxTorque)) { + sharedInput.useAngularMotor = true; + sharedInput.motorTargetSpeed = jointActuation.commandVelocity[0]; + sharedInput.motorMaxTorque = maxTorque; + } + } + if (hasLinearVelocityMotor) { + const double maxForce + = rigidPairConstraintSymmetricEffortLimit(jointModel); + if (maxForce > 0.0 && !std::isnan(maxForce)) { + sharedInput.useLinearMotor = true; + sharedInput.motorTargetSpeed = jointActuation.commandVelocity[0]; + sharedInput.motorMaxForce = maxForce; + } + } + sharedInput.fractureThreshold = jointModel.breakForce; + if (enrichInput(entity, input)) { + inputs.push_back(std::move(input)); + } + } +} + +template +inline void extractRigidPairConstraintInputsInto( + const WorldRegistry& registry, + InputVector& inputs, + bool includeWorldAnchors = true) +{ + const auto geometryProvider + = [](entt::entity, const comps::JointModel& joint) { + return rigidPairConstraintGeometryFromJointModel(joint); + }; + const auto accept = [](entt::entity, auto&) { + return true; + }; + extractRigidPairConstraintInputsInto( + registry, inputs, includeWorldAnchors, geometryProvider, accept); +} + +} // namespace dart::simulation::detail diff --git a/dart/simulation/detail/world_step_schedule.hpp b/dart/simulation/detail/world_step_schedule.hpp index e3bc7f32861ac..c190d52a1d4b0 100644 --- a/dart/simulation/detail/world_step_schedule.hpp +++ b/dart/simulation/detail/world_step_schedule.hpp @@ -161,7 +161,8 @@ struct BuiltInWorldStepSchedule [[maybe_unused]] BuiltInRigidBodySolverFamily family) noexcept { // Ipc is currently the only rigid family with a combined contact-and-advance - // stage; the split families (sequential impulse and AVBD) never reach here. + // stage; the split families (sequential impulse, VBD, and AVBD) never reach + // here. return BuiltInWorldStepStageSlot::RigidIpcContact; } diff --git a/dart/simulation/multibody/joint.hpp b/dart/simulation/multibody/joint.hpp index 5465a493f293b..4e33b1724e5a9 100644 --- a/dart/simulation/multibody/joint.hpp +++ b/dart/simulation/multibody/joint.hpp @@ -72,7 +72,10 @@ enum class ActuatorType /// /// The policy applies to public world-owned point-joint facades. A finite /// linear or angular stiffness makes the matching projected rows finite- -/// stiffness rows; infinity keeps the default hard-constraint behavior. +/// stiffness rows under the VBD and AVBD rigid-body families; infinity keeps +/// the default hard-constraint behavior. The default Sequential Impulse +/// family supports hard rows only and fails closed before the next step when +/// a finite stiffness is configured. struct JointConstraintProjectionPolicy { /// Starting projection stiffness. Infinity keeps the hard-start projection. diff --git a/dart/simulation/world.cpp b/dart/simulation/world.cpp index 25b144f21e1a8..2407e61dea392 100644 --- a/dart/simulation/world.cpp +++ b/dart/simulation/world.cpp @@ -359,12 +359,12 @@ struct World::ReplayState double pitch = 0.0; entt::entity parentLink = entt::null; entt::entity childLink = entt::null; - bool hasRigidBodyFixedJointAnchors = false; - Eigen::Vector3d rigidBodyFixedJointLocalAnchorParent + bool hasRigidBodyPairConstraintGeometry = false; + Eigen::Vector3d rigidBodyPairConstraintLocalAnchorParent = Eigen::Vector3d::Zero(); - Eigen::Vector3d rigidBodyFixedJointLocalAnchorChild + Eigen::Vector3d rigidBodyPairConstraintLocalAnchorChild = Eigen::Vector3d::Zero(); - Eigen::Quaterniond rigidBodyFixedJointTargetRelativeOrientation + Eigen::Quaterniond rigidBodyPairConstraintTargetRelativeOrientation = Eigen::Quaterniond::Identity(); }; @@ -661,15 +661,15 @@ bool sameReplayJointLayout( && jointModel.pitch == layout.pitch && jointModel.parentLink == layout.parentLink && jointModel.childLink == layout.childLink - && jointModel.hasRigidBodyFixedJointAnchors - == layout.hasRigidBodyFixedJointAnchors - && jointModel.rigidBodyFixedJointLocalAnchorParent.isApprox( - layout.rigidBodyFixedJointLocalAnchorParent, 0.0) - && jointModel.rigidBodyFixedJointLocalAnchorChild.isApprox( - layout.rigidBodyFixedJointLocalAnchorChild, 0.0) - && jointModel.rigidBodyFixedJointTargetRelativeOrientation.coeffs() + && jointModel.hasRigidBodyPairConstraintGeometry + == layout.hasRigidBodyPairConstraintGeometry + && jointModel.rigidBodyPairConstraintLocalAnchorParent.isApprox( + layout.rigidBodyPairConstraintLocalAnchorParent, 0.0) + && jointModel.rigidBodyPairConstraintLocalAnchorChild.isApprox( + layout.rigidBodyPairConstraintLocalAnchorChild, 0.0) + && jointModel.rigidBodyPairConstraintTargetRelativeOrientation.coeffs() .isApprox( - layout.rigidBodyFixedJointTargetRelativeOrientation + layout.rigidBodyPairConstraintTargetRelativeOrientation .coeffs(), 0.0); } @@ -2181,6 +2181,38 @@ bool hasRigidBodyJointsUnsupportedByIpc(const World& world) return false; } +//============================================================================== +bool hasFiniteStiffnessRigidBodyJointRows(const World& world) +{ + const auto& registry = detail::registryOf(world); + const auto view = registry.view(); + for (const entt::entity entity : view) { + const auto& joint = view.get(entity); + if (!isRigidBodyJoint(registry, joint)) { + continue; + } + + const auto* config + = registry + .try_get( + entity); + if (config == nullptr || !config->enabled) { + continue; + } + + const bool hasFiniteLinearRows + = config->linearAxisMask != 0u + && !std::isinf(config->linearMaterialStiffness); + const bool hasFiniteAngularRows + = config->angularAxisMask != 0u + && !std::isinf(config->angularMaterialStiffness); + if (hasFiniteLinearRows || hasFiniteAngularRows) { + return true; + } + } + return false; +} + //============================================================================== void validateRigidBodyJointPipelineSupport( const World& world, RigidBodySolver solver) @@ -2196,6 +2228,14 @@ void validateRigidBodyJointPipelineSupport( return; } + DART_SIMULATION_THROW_T_IF( + solver == RigidBodySolver::SequentialImpulse + && hasFiniteStiffnessRigidBodyJointRows(world), + InvalidOperationException, + "The Sequential Impulse rigid-body solver supports hard rigid-body " + "joint rows only; finite constraint projection stiffness requires the " + "VBD or AVBD rigid-body solver"); + if (solver == RigidBodySolver::Ipc) { DART_SIMULATION_THROW_T_IF( hasRigidBodyDistanceSprings(world), @@ -2218,8 +2258,8 @@ void validateRigidBodyJointPipelineSupport( DART_SIMULATION_THROW_T_IF( hasMultibodyStructures(world), InvalidOperationException, - "Rigid-body VBD and AVBD pair constraints are not supported in worlds " - "with multibody structures"); + "Public rigid-body pair constraints are not supported in worlds with " + "multibody structures under Sequential Impulse, VBD, or AVBD"); } //============================================================================== @@ -4318,11 +4358,13 @@ void World::recordResolvedConfiguration() : "as requested"}); } - const bool hasAvbdPairConstraints - = detail::deformable_vbd::mayHaveAvbdRigidWorldPointJointConfigs(registry) - || detail::deformable_vbd::mayHaveAvbdRigidWorldDistanceSpringConfigs( - registry); - if (!hasAvbdPairConstraints) { + const bool hasRigidPointJoints + = detail::deformable_vbd::mayHaveAvbdRigidWorldPointJointConfigs( + registry); + const bool hasRigidDistanceSprings + = detail::deformable_vbd::mayHaveAvbdRigidWorldDistanceSpringConfigs( + registry); + if (!hasRigidPointJoints && !hasRigidDistanceSprings) { m_resolvedConfiguration.notes.push_back( {"rigid-pair-constraint", "inactive", @@ -4341,13 +4383,29 @@ void World::recordResolvedConfiguration() "ipc", "public fixed and revolute pair constraints enter the IPC " "articulation equality solve"}); + } else if (hasRigidPointJoints && !hasRigidDistanceSprings) { + m_resolvedConfiguration.notes.push_back( + {"rigid-pair-constraint", + rigidSolver, + rigidSolver, + "hard public rigid pair constraints use solver-owned " + "sequential-impulse rows"}); + } else if (!hasRigidPointJoints && hasRigidDistanceSprings) { + m_resolvedConfiguration.notes.push_back( + {"rigid-pair-constraint", + rigidSolver, + "avbd-distance-spring", + "experimental finite-stiffness distance springs retain their " + "explicit AVBD compatibility projection"}); } else { m_resolvedConfiguration.notes.push_back( {"rigid-pair-constraint", rigidSolver, - "avbd", - "public rigid pair constraints use AVBD projection; select the AVBD " - "rigid-body family to make this method explicit"}); + std::string(rigidSolver) + " + avbd-distance-spring", + "hard public rigid pair constraints use solver-owned " + "sequential-impulse rows while experimental finite-stiffness " + "distance springs retain their explicit AVBD compatibility " + "projection"}); } const std::string rigidConstraintIterations @@ -4576,7 +4634,7 @@ Multibody World::addMultibody(std::string_view name) hasRigidBodyAvbdPairConstraints(*this), InvalidOperationException, "Multibody structures are not supported in worlds with rigid-body " - "VBD or AVBD pair constraints"); + "pair constraints under Sequential Impulse, VBD, or AVBD"); std::string candidateName; if (name.empty()) { @@ -4862,12 +4920,12 @@ Joint World::createArticulatedPointJoint( parentOrientation.normalize(); childOrientation.normalize(); - jointModel.hasRigidBodyFixedJointAnchors = true; - jointModel.rigidBodyFixedJointLocalAnchorParent = *parentAnchor; - jointModel.rigidBodyFixedJointLocalAnchorChild = *childAnchor; - jointModel.rigidBodyFixedJointTargetRelativeOrientation + jointModel.hasRigidBodyPairConstraintGeometry = true; + jointModel.rigidBodyPairConstraintLocalAnchorParent = *parentAnchor; + jointModel.rigidBodyPairConstraintLocalAnchorChild = *childAnchor; + jointModel.rigidBodyPairConstraintTargetRelativeOrientation = parentOrientation.conjugate() * childOrientation; - jointModel.rigidBodyFixedJointTargetRelativeOrientation.normalize(); + jointModel.rigidBodyPairConstraintTargetRelativeOrientation.normalize(); } return Joint(detail::fromRegistryEntity(jointEntity), this); @@ -5374,12 +5432,12 @@ Joint World::createRigidBodyPointJoint( parentOrientation.normalize(); childOrientation.normalize(); - jointModel.hasRigidBodyFixedJointAnchors = true; - jointModel.rigidBodyFixedJointLocalAnchorParent = *parentAnchor; - jointModel.rigidBodyFixedJointLocalAnchorChild = *childAnchor; - jointModel.rigidBodyFixedJointTargetRelativeOrientation + jointModel.hasRigidBodyPairConstraintGeometry = true; + jointModel.rigidBodyPairConstraintLocalAnchorParent = *parentAnchor; + jointModel.rigidBodyPairConstraintLocalAnchorChild = *childAnchor; + jointModel.rigidBodyPairConstraintTargetRelativeOrientation = parentOrientation.conjugate() * childOrientation; - jointModel.rigidBodyFixedJointTargetRelativeOrientation.normalize(); + jointModel.rigidBodyPairConstraintTargetRelativeOrientation.normalize(); } const Eigen::Index dof = static_cast(jointModel.getDOF()); @@ -7948,14 +8006,14 @@ void World::recordReplayFrame() state.layout.pitch = joint.pitch; state.layout.parentLink = joint.parentLink; state.layout.childLink = joint.childLink; - state.layout.hasRigidBodyFixedJointAnchors - = joint.hasRigidBodyFixedJointAnchors; - state.layout.rigidBodyFixedJointLocalAnchorParent - = joint.rigidBodyFixedJointLocalAnchorParent; - state.layout.rigidBodyFixedJointLocalAnchorChild - = joint.rigidBodyFixedJointLocalAnchorChild; - state.layout.rigidBodyFixedJointTargetRelativeOrientation - = joint.rigidBodyFixedJointTargetRelativeOrientation; + state.layout.hasRigidBodyPairConstraintGeometry + = joint.hasRigidBodyPairConstraintGeometry; + state.layout.rigidBodyPairConstraintLocalAnchorParent + = joint.rigidBodyPairConstraintLocalAnchorParent; + state.layout.rigidBodyPairConstraintLocalAnchorChild + = joint.rigidBodyPairConstraintLocalAnchorChild; + state.layout.rigidBodyPairConstraintTargetRelativeOrientation + = joint.rigidBodyPairConstraintTargetRelativeOrientation; captureReplayVector(jointState.position, state.position); captureReplayVector(jointState.velocity, state.velocity); captureReplayVector(jointState.acceleration, state.acceleration); diff --git a/dart/simulation/world_options.hpp b/dart/simulation/world_options.hpp index e87c6c1a7bb90..5f3edc40ec9dc 100644 --- a/dart/simulation/world_options.hpp +++ b/dart/simulation/world_options.hpp @@ -47,6 +47,10 @@ namespace dart::simulation { /// World step pipeline. enum class RigidBodySolver { + /// Velocity-level projected Gauss-Seidel for free-rigid contacts and hard + /// public rigid-body pair constraints, followed by non-velocity positional + /// stabilization. Breakable-joint loads are derived from the accumulated + /// constraint impulses over the step. SequentialImpulse, Ipc, /// Augmented vertex block descent for free rigid-body contact and public @@ -87,10 +91,12 @@ enum class ContactSolverMethod /// Domain-scoped tuning for the built-in rigid constraint stage. /// -/// The positive iteration budget controls sequential-impulse contact sweeps -/// and VBD/AVBD rigid contact, joint, motor, and spring sweeps. The IPC family -/// and mixed semi-implicit multibody worlds bypass this stage and therefore -/// accept only the default options. +/// The positive iteration budget controls sequential-impulse contact and hard +/// pair-constraint sweeps, plus VBD/AVBD rigid contact, joint, motor, and +/// spring sweeps. Under Sequential Impulse the same budget additionally runs +/// a separate non-velocity joint post-stabilization pass after contact +/// position correction. The IPC family and mixed semi-implicit multibody +/// worlds bypass this stage and therefore accept only the default options. struct RigidConstraintOptions { std::size_t iterations = 8; diff --git a/docs/design/dart7_architecture_assessment.md b/docs/design/dart7_architecture_assessment.md index 0bb88a43fd436..e05fa497e5f5c 100644 --- a/docs/design/dart7_architecture_assessment.md +++ b/docs/design/dart7_architecture_assessment.md @@ -166,13 +166,14 @@ record solver identity, and flagship AVBD contact-scene packets recorded sequential-impulse contact path. Those rows are now relabeled, the shared packet contract records both resolved solver and rigid-contact selection source, and the public-AVBD Figure 13 packet uses that contract. One matched -public fixed-penalty VBD/AVBD Figure 13 comparison now shares a single scene -builder and fingerprint across its demos and benchmark, binds independent -outcome oracles and assessed renders, and keeps its same-host timing ratio -descriptive. The broader substrate gap remains: this is one scenario-specific -writer rather than a generic harness, legacy packets cannot be generalized -beyond their recorded identities, and there is still no shared cross-family -accuracy/performance substrate. +public Sequential Impulse/VBD/AVBD Figure 13 comparison now shares a single +scene builder and fingerprint across its demos and benchmark, binds +independent outcome oracles and assessed renders, records broken-joint +identities plus retained-row residuals, and keeps its same-host timing ratios +descriptive. The broader substrate gap remains: XPBD is absent, this is one +scenario-specific writer rather than a generic harness, legacy packets cannot +be generalized beyond their recorded identities, and there is still no shared +cross-family accuracy/performance substrate. ### F5 — Facade erosion at the edges diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index 9b053513a7755..ad5cf96ed9ea3 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -22,36 +22,64 @@ This folder is the temporary working surface; the durable owner is the plan. same-hardware paper-number match. - **Branch state:** active local branch `feature/vbd-avbd-paper-parity-contract`, based on `main` at `83110ef54ab`; - the fail-closed contract is committed locally at `710cbfc1152`, and nothing - has been pushed. The Section 3.5 packet is committed locally at + the fail-closed contract is committed at `710cbfc1152`. The Section 3.5 + packet is committed at `a78f688a178`, and the Section 4 packet at `0b0154573b8`; the articulated finite-row packet at `761263bbd41`; the articulated finite-motor packet at `9ebd9b895b1`; the articulated finite-fracture packet at `131981788fa`; and the public AVBD/Figure 13 slice at `646b447d6cc`. The matched public - fixed-penalty VBD/Figure 13 comparison is the current local closeout. -- **Current packet:** C++ and dartpy callers can explicitly select public VBD - and AVBD rigid-body families with one positive contact/joint projection - budget. VBD uses fixed finite-penalty rows without dual accumulation or - progressive stiffness; AVBD retains augmented-Lagrangian dual/stiffness - state. Both selections survive binary save/load and replay, report their - resolved identities, and reject boxed-LCP or multibody envelopes instead of - silently falling back. The matched publication-shaped Figure 13 demos share - 252 staggered bricks, 712 attachments, three impacts, 1/60 s, 20 sweeps, and - fingerprint `2a746821cc10faee`, but carry distinct paper-shaped outcome + fixed-penalty VBD/Figure 13 comparison is committed at `d9af744a9cb`. The + branch-tip closeout commit containing this handoff adds the public Sequential + Impulse pair-row/Figure 13 slice. Its pre-closeout `HEAD` and last-fetched + upstream ref both pointed to `d9af744a9cb`; no fetch, push, PR update, or + other GitHub mutation was performed while closing it. Verify the live local + tip and remote state before any external action. +- **Current packet:** C++ and dartpy callers can explicitly select public + Sequential Impulse, VBD, and AVBD rigid-body families with one positive + contact/joint solve budget. Sequential Impulse now owns hard fixed, + spherical, revolute, and prismatic pair rows, velocity motors, + impulse-derived breakage, and non-velocity post-stabilization in the same + projected Gauss-Seidel sweeps as contacts. Shared rigid-pair geometry and + input extraction now have solver-neutral ownership, while SI and AVBD keep + separate row/dual state. Crossing through SI invalidates AVBD contact and + hard-joint continuation state while preserving independently active AVBD + distance-spring continuation. The regression + `World.SequentialImpulseFallbackPreservesAvbdDistanceSpringWarmStart` + protects that boundary, including the private-AVBD fallback under public SI. + Finite-stiffness pair rows fail closed to VBD/AVBD. VBD uses + fixed finite-penalty rows without dual accumulation or progressive + stiffness; AVBD retains + augmented-Lagrangian dual/stiffness state. VBD and AVBD still reject + unsupported boxed-LCP or multibody envelopes instead of silently falling + back. The matched publication-shaped Figure 13 demos share 252 staggered + bricks, 712 attachments, three 40 kg impacts at 24 m/s, 1/60 s, 20 sweeps, + and fingerprint `8ca3fbfa00c3dce9`, but carry distinct paper-shaped outcome oracles. The contracts contain 88 VBD and 88 AVBD requirements; all 176 remain incomplete until their recorded correctness, solver-identity, CPU/CUDA, visual, and comparable-performance predicates pass. -- **Latest verified local packet:** - [`../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json) - binds the current AVBD packet to the matched fixed-penalty VBD frame-14 bend - and frame-120 retention oracles, exact paper hashes, assessed VBD - captures, allocation gates, and five-repeat same-host timing. The benchmark - records 9.218 ms VBD versus 7.467 ms AVBD median CPU time, a descriptive - 1.234 cost ratio with no speedup or achieved-accuracy equivalence claim. - Figure 13 and video row 12 remain partial: exact source constants, - Sequential Impulse, XPBD, a source edit, CUDA, and achieved-accuracy - reference performance are open. +- **Latest local packet chain (current-source reseal complete):** + [`../../plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json) + links the current matched AVBD/VBD packet to an independently assessed + Sequential Impulse frame-14 fracture and frame-120 collapse oracle. Exact + joint identities and retained-row residuals are recorded at both + checkpoints. Six fresh current-source captures and semantic reviews are + sealed to capture-source digest + `556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12`. + One uncontaminated quiet-host five-repeat Release run records median CPU + costs of 8.612602 ms AVBD, 8.302657 ms VBD, and 14.296125 ms Sequential + Impulse, with CPU CVs of 2.04%, 1.94%, and 2.12%, respectively. Its raw JSON + SHA-256 is + `c2c81c26ca0fa96d1cf11da4c5a166a3a2eb7ead9b80aafac207c20f03cf7c67`. + The regenerated AVBD, VBD, and SI packet SHA-256 values are + `3e15adb9093c10567c0f8c22c5f8e8ad4e5d47a7acd5aca7a107fb97de7fc54c`, + `9f8e6dbc9cb2f37af5d268fa83c13e67f3185cb39e14cebf413a288b6ee298d8`, + and `07151151acec718e2e851e92af2dd127f2833ee65f049445624ccffd29233ce9`. + The same-host ratios are descriptive costs, not speedup or + achieved-accuracy equivalence claims. + Figure 13 and video row 12 remain partial: exact source constants, XPBD, a + source-matched four-method edit, CUDA, and achieved-accuracy reference + performance are open. - **Recent slices merged to `main`** (see the PLAN-104 progress log and the PRs for detail; per-slice history lives in git, not in this file): - #2991 — source-row coverage + contact-precheck (`f6fecbc5bd5`). @@ -61,20 +89,24 @@ This folder is the temporary working surface; the durable owner is the plan. - #3022 — bounded regression coverage: rigid-contact tangent-basis contract, articulated break→reset→break re-arm lifecycle, row-inventory replaced-key cold-start (`65ba05113c6`). -- **Current local gates:** the VBD fixed-penalty row/state, public - solver/schedule/serialization/resolved-configuration/Python, matched demo, - and all three post-bake allocation-policy tests pass. Both assessed VBD - captures and the five-repeat comparison benchmark pass. After the final - packet-provenance repair, 188 focused Python/world/writer/schema tests, three - selected VBD Figure 13 demo cases, all 57 working-tree AVBD packet schemas, - and both 88-row PLAN-104 contracts pass. Fresh uncached default `test-all` - passes all six phases, including 79 enabled simulation tests and - documentation. Fresh uncached CUDA `test-all` passes all seven phases, - including all eight CUDA runtime tests and benchmark smoke; the long Jacobi - batch case took 206.81 seconds. This CUDA environment gate does not close - the still-missing VBD/AVBD GPU solver-parity rows. Independent final - code/correctness and architecture/docs/claims review lanes both approve the - post-fix tree. +- **Current local gates:** the runtime/capture source tree passes an uncached Release + build and all 79 enabled simulation executables in 906.85 seconds (the two + aggregate wrappers remain intentionally disabled), plus the complete Python + suite with 1,711 passed and 20 expected skips in 238.72 seconds. The exact + current checker/capture/writer surface passes all 192 focused tests, + including both live-corpus checks. Six fresh + 1280x720 captures bind the current source digest and pass engine ViewReports, + pixel-integrity checks, and separate original-resolution semantic review + against the pinned paper page. Rejected benchmark attempts remain recorded; + the strict quiet-host run and all three dependent packets are now sealed. The + post-reseal runtime/capture source tree passes uncached default `test-all` + with all six phases and uncached CUDA `test-all` with all seven phases, + including CUDA C++ tests and benchmark smoke. The later checker/docs-only + review fixes are covered by the 192 focused tests and leave the bound source + digest unchanged. This environment pass does not close the missing + solver-specific VBD/AVBD GPU-parity predicates. Independent code and + architecture/docs/claims reviews approve the complete closeout tree with no + actionable finding. ## Goal @@ -106,16 +138,35 @@ numbers. ## Immediate Next Steps -Extend the publication-shaped AVBD wall into a source-matched four-method -comparison and CUDA row, or continue with unified rigid/soft rows and the -remaining fracture and joint corpus. Keep every dependent -figure/demo/performance row partial until source-matched CPU and CUDA evidence -closes it. - -Two smaller deferred maintenance items remain valid but do not outrank the -missing paper mechanism: hoist the duplicated `makeCollisionPairKey` into a -shared `detail` header, and upgrade the Spring / Spring Ratio packets from -legacy schema version 1 to the current solver-identity contract. +Verify that the SI closeout commit containing this handoff is the clean local +branch tip; if not, preserve the exact dirty scope, run the mandatory final +lint and packet/provenance checks, and make that local commit. Then merge the +latest local `main` into the branch (never rebase the published branch) and +refresh every gate or evidence artifact invalidated by the merge. Add an honest +public XPBD substep family and its matched Figure 13 row next. Only after that +should the four-method source-matched edit, CUDA evidence, unified rigid/soft +rows, and remaining fracture/joint corpus proceed. Keep every dependent +figure/demo/performance row partial until its own source-matched CPU and CUDA +evidence closes it. + +Deferred maintenance items remain valid but do not outrank the missing paper +mechanism: + +- hoist the duplicated `makeCollisionPairKey` into a shared `detail` header; +- upgrade the Spring / Spring Ratio packets from legacy schema version 1 to + the current solver-identity contract; +- consolidate the duplicated pair-constraint mask/basis/orientation math into + one owner (drift is currently pinned by a kernel `static_assert` and the + `RigidPairConstraintNeutralHelpers` equivalence test) and route the SI + finite-stiffness admission rule through a solver-neutral accessor; +- revisit two recorded SI conventions: kinematic joint endpoints are treated + as zero-velocity (shared with the contact path, so a kinematic-driven + jointed body lags), and joint post-stabilization runs after contact + position correction, so it can re-introduce shallow penetration that the + wall oracles budget; and +- consolidate SI per-step joint-view walks, skip SI container reserves for + non-SI families, and record post-stabilization work in the step-iteration + diagnostic. ## Verified Local Packet: Section 3.5 Quasi-Newton Hessian @@ -286,25 +337,33 @@ All linked canonical method rows remain partial. persistence, resolved-family reporting, and fail-closed rejection of unsupported or incompatible configuration. - **Scene/oracle:** 252 staggered bricks, 712 breakable fixed attachments, - three 100 kg balls, 1/60 s, and 20 projection sweeps. At frame 120, the - deterministic oracle records 359 broken and 353 unbroken attachments, - displaced-brick counts `[4, 10, 6]`, 91.16% outside-wall retention, and - 82.54% total retention. Frame 60 remains explicitly pre-evaluation because - its `[3, 9, 4]` counts leave one band below the final threshold. + three 40 kg balls launched at 24 m/s, 1/60 s, and 20 projection sweeps. At + frame 120, the deterministic oracle records 154 broken and 558 unbroken + attachments, displaced-brick counts `[11, 14, 11]`, 99.43% outside-wall + retention, and 99.21% total retention. The same broken-identity digest is + recorded at frames 60 and 120. Of those identities, `[18, 44, 13]` have + initial anchors within the three selected 1.15 m impact regions and 79 lie + outside; this packet therefore claims localized displacement damage, not + localized broken topology. The 558 surviving rows stay below 0.772 mm + linear and 0.000317 rad angular residual at frame 120. Frame 60 remains + explicitly pre-evaluation even though its `[13, 14, 12]` three-band checks + already pass; the registered AVBD claim checkpoint is frame 120. - **Runtime evidence:** the public AVBD contact-plus-breakable-row fixture passes world-base, global-`new`, and raw-malloc first-post-bake gates. The frame-60 and frame-120 software captures bind the exact front camera and pass engine ViewReports, pixel integrity, and image-capable semantic review against the pinned paper page. The scene and benchmark share fingerprint - `2a746821cc10faee`. -- **Performance boundary:** the current five-repeat Release benchmark records - 7.467 ms median - CPU time per step. This is absolute DART timing only; the paper and source - publish no directly comparable timing for this scene. -- **Non-goals:** exact replay of unpublished source constants, the Sequential - Impulse and XPBD comparison rows, source-matched video edit, broad fracture - corpus, unified rows, and CUDA. The matched VBD row is tracked separately - below. + `8ca3fbfa00c3dce9`. Capture manifests bind their capture-time source tree, + screenshot, and scene-metrics log; the benchmark JSON binds the same source + tree plus its benchmark translation unit. +- **Performance boundary:** the current-source five-repeat Release benchmark + records an 8.612602 ms median CPU cost per step with 2.04% CPU CV. This is an + absolute DART timing only; the paper and source publish no directly + comparable timing for this scene. +- **Non-goals:** exact replay of unpublished source constants, the XPBD + comparison row, source-matched video edit, broad fracture corpus, unified + rows, and CUDA. The matched VBD and Sequential Impulse rows are tracked + separately below. The durable evidence is [`../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). @@ -322,23 +381,62 @@ Figure 13 and official-video row 12 remain partial. fail-closed boxed-LCP and multibody rejection. VBD uses finite penalty stiffness, zero dual state, and no progressive stiffness update. - **Matched outcome evidence:** both demos and benchmark share fingerprint - `2a746821cc10faee`, 252 bricks, 712 attachments, three balls, 1/60 s, and 20 - sweeps. VBD records no fracture, 0.229 m peak and 0.122 m RMS brick - displacement with 131 bricks beyond 0.1 m at frame 14, then 100% retained - attachments at frame 120. Both current-build VBD captures pass their engine - ViewReports, pixel-integrity checks, and paper-grounded semantic review. -- **Performance boundary:** five Release repetitions record 9.218 ms VBD - versus 7.467 ms AVBD median CPU time. The 1.234 VBD/AVBD median cost ratio is - descriptive only: the outcomes intentionally differ, and no source - achieved-accuracy or same-hardware denominator exists. -- **Remaining boundary:** exact unpublished constants, honest Sequential - Impulse and XPBD rows, a source-matched four-method edit, CUDA, broad + `8ca3fbfa00c3dce9`, 252 bricks, 712 attachments, three balls, 1/60 s, and 20 + sweeps. VBD records no fracture, 0.132 m peak wall-normal and 0.062 m RMS + displacement with 120 bricks beyond 0.05 m at frame 18, then 100% retained + attachments at frame 120. All 712 retained rows stay below 16.39 mm and + 0.01328 rad at frame 18, then 11.10 mm and 0.001310 rad at frame 120. Both + current-build VBD captures pass their engine ViewReports, pixel-integrity + checks, and paper-grounded semantic review. +- **Performance boundary:** the current-source five-repeat medians are + 8.302657 ms VBD and 8.612602 ms AVBD, with 1.94% and 2.04% CPU CV. The + VBD/AVBD median CPU-cost ratio is 0.9640x and is descriptive only: the + outcomes intentionally differ, and no source achieved-accuracy or + same-hardware denominator exists. +- **Remaining boundary:** exact unpublished constants, honest XPBD, a + source-matched four-method edit, CUDA, broad fracture/unified rows, and reference-performance parity remain open. The durable evidence is [`../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json). Figure 13 and official-video row 12 remain partial. +## Verified Local Packet: Matched Sequential Impulse / VBD / AVBD Figure 13 Rows + +- **Value:** replace the default contact-only label with solver-owned public + hard pair rows and use that actual Sequential Impulse family for the third + matched Figure 13 row. +- **Public contract:** hard fixed/spherical/revolute/prismatic masks and + bounded one-DOF velocity motors interleave with contact rows across the + configured projected Gauss-Seidel sweeps. Break force is derived from + accumulated impulse over `dt`; a non-velocity post-stabilization pass reduces + pose drift. Finite-stiffness public pair rows reject SI and direct callers to + VBD/AVBD. +- **Matched outcome evidence:** frame 14 records exactly 5 broken/707 + unbroken joints. Their impact-region counts are `[2, 1, 2]` with none + outside, `[13, 14, 13]` bricks are displaced in the three impact bands, and + 100% of brick placement remains. Frame 120 has the same broken-identity + digest and no additional fracture, but the 484 retained rows outside all + impact regions reach 0.06346 m maximum / 0.02076 m RMS linear residual and + 0.7270 rad maximum / 0.2164 rad RMS angular residual. Only 27.27% outside + the impact bands and 19.05% overall remain placed, with 2.878 m maximum + wall-normal displacement. Both assessed images match the paper's initial + fracture followed by retained-row failure and collapse. +- **Performance boundary:** the current-source five-repeat median CPU costs + are 14.296125 ms SI, 8.302657 ms VBD, and 8.612602 ms AVBD, with 2.12%, + 1.94%, and 2.04% CPU CV. SI/AVBD is 1.6599x and SI/VBD is 1.7219x. These are + descriptive costs for intentionally different outcomes, not paper speedups + or achieved-accuracy comparisons. Frame 120 is the only shared quantitative + checkpoint across the three rows; the earlier frame-14/18/60 checkpoints + are per-family diagnostics. +- **Remaining boundary:** XPBD, exact unpublished constants, a source-matched + four-method edit, CUDA, broad fracture/unified rows, and reference- + performance parity remain open. + +The durable evidence is +[`../../plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json). +Figure 13 and official-video row 12 remain partial. + ## History Per-slice history and durable evidence live in the PLAN-104 progress log, the diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index b453cf3fd4607..7d8e709a56ecc 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -1,145 +1,375 @@ # Resume: AVBD Solver -Working handoff for PLAN-104's Augmented Vertex Block Descent implementation. -The durable roadmap, evidence, next gaps, and slice history live in +Working handoff for PLAN-104's full VBD/AVBD paper-parity implementation. The +durable roadmap and acceptance bar live in [`../../plans/104-vertex-block-descent-solver.md`](../../plans/104-vertex-block-descent-solver.md); -this file is only the current session pointer. See -[`README.md`](README.md) for status, goal, decisions, and next steps. - -## Last Session Summary - -The exact 176-row VBD/AVBD paper-parity contract remains fail-closed, with no -complete rows. The current uncommitted slice adds a distinct public -fixed-penalty `RigidBodySolver::Vbd` / `sx.RigidBodySolver.VBD` family beside -the committed public AVBD family. VBD reuses the DART-owned 6-DOF rigid block -contact and pair-row path with finite penalty stiffness, zero dual -accumulation, and no progressive stiffness. Its positive projection budget is -public, serialized, replay-safe, runtime-mutable, and reported in resolved -configuration. Boxed-LCP and multibody envelopes fail closed rather than -substituting another solver. - -The current durable packet is -[`../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json`](../../plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json). -It binds the existing public AVBD packet to a matched VBD -`vbd_paper_breakable_wall` row: both use 252 staggered bricks, 712 -attachments, three high-momentum balls, 1/60 s, 20 sweeps, and fingerprint -`2a746821cc10faee`. The VBD frame-14 oracle records 0 broken/712 unbroken -attachments, 0.229 m peak and 0.122 m RMS brick displacement, and 131 bricks -beyond 0.1 m; the frame-120 oracle retains 100% of attachments. Both VBD -captures pass engine ViewReports, pixel integrity, and paper-grounded semantic -review. The five-repeat Release benchmark records 9.218 ms VBD versus 7.467 ms -AVBD median CPU time, or a descriptive 1.234 VBD/AVBD cost ratio. It is not a -speedup or achieved-accuracy equivalence claim. - -The VBD fixed-penalty row/state, public -solver/schedule/serialization/resolved-configuration/Python, matched demo, and -all three first-post-bake allocation-policy tests pass. Both assessed VBD -captures and the five-repeat comparison benchmark pass. After the final -packet-provenance repair, 188 focused Python/world/writer/schema tests, three -selected VBD Figure 13 demo cases, all 57 working-tree packets, and both -88-row PLAN-104 contracts pass. Fresh uncached default `test-all` passes all -six phases, including 79 enabled simulation tests and documentation. Fresh -uncached CUDA `test-all` passes all seven phases, including all eight CUDA -runtime tests and benchmark smoke; the long Jacobi batch case took 206.81 -seconds. This CUDA environment gate does not close the still-missing VBD/AVBD -GPU solver-parity rows. Independent final code/correctness and -architecture/docs/claims review lanes both approve the post-fix tree. - -The final dual review found one additional contract mismatch: non-default -rigid iteration options were accepted and reported as applied in mixed -semi-implicit multibody worlds even though their unified constraint stage did -not consume that budget. The runtime now rejects the combination at -construction finalization, option/method mutation, load, and step entry; a -mixed active-contact regression proves the default budget is reported as not -applicable. The focused post-fix gates pass: 15/15 resolved-configuration, -445/445 World, 61/61 serialization, 17/17 schedule, and 168/168 aggregate unit -tests. Both post-fix review lanes approve the result. Exact final reruns pass -the uncached default build, the 1,677-test default Python suite, and all seven -CUDA `test-all` phases; the eight CUDA runtime tests include the 208.75-second -Jacobi batch case. - -The broad gates found two candidate defects that are now covered. The boxed- -LCP runtime had allocated a full differentiable snapshot only to recover -contact forces; it now uses the allocation-free apply path and copies the -existing force vector, restoring the zero-allocation World gate. The first -CUDA Python pass also found that the Figure 13 scene constructed -`OrbitCamera` when GUI bindings were disabled. Headless builds now expose -`view_assessment_available: false` and `view_report: null`, while GUI builds -still require the positive engine ViewReport used by the evidence writer. - -## Current Branch - -`feature/vbd-avbd-paper-parity-contract`, based on the then-current -`origin/main` at `83110ef54ab`. The fail-closed contract is committed locally -at `710cbfc1152`, the Section 3.5 packet at `a78f688a178`, and the Section 4 -packet at `0b0154573b8`; the articulated finite-row packet is committed at -`761263bbd41`, the articulated finite-motor packet at `9ebd9b895b1`, and the -articulated finite-fracture packet at `131981788fa`. The public AVBD/Figure 13 -slice is committed at `646b447d6cc`. The matched public fixed-penalty -VBD/Figure 13 comparison is the current local closeout. The branch has not -been pushed. - -## Immediate Next Step - -Extend the matched wall into honest Sequential Impulse and XPBD rows, a -source-matched four-method edit, and CUDA, or proceed to unified rigid/soft and -the remaining fracture/joint corpus. Preserve the packet boundary: the current -rows are a publication-shaped VBD/AVBD CPU comparison, not exact source, full -video, four-method, CUDA, or achieved-accuracy reference-performance closure. - -## Context That Would Be Lost - -- **Keep claims narrow.** Do not claim a source-demo/paper CPU win, GPU parity, - a broad breakable-wall/fracture corpus, a same-hardware paper-number match, or - an all-coefficient friction win unless the tracked artifacts directly prove it. -- Figure 13 does not publish the exact wall dimensions, mass/inertia choices, - ball speed/targets, or break threshold. The DART constants are explicitly - reconstructed and must not be described as an exact source replay. -- The benchmark is same-host descriptive DART timing. There is no - paper/reference achieved-accuracy denominator for this scene, so do not - describe the 1.234 VBD/AVBD cost ratio as a speedup, parity, or quality - comparison. -- The outcome image's optional contrast diagnostic records 1,019 bright - pixels against a 1,180-pixel heuristic. The required non-blank gate and - engine ViewReport pass, and the separate semantic review adjudicates - fracture morphology. Do not recast the optional diagnostic as a failed - render or suppress it from the packet. -- The articulated finite-motor benchmark is candidate-only. Exact parent skips - the new rows, so a timing ratio would compare different work and is not a - speedup claim. -- Finite public one-DOF `Velocity` rows use a bounded motor-only projection row - beside their compliant masks. Its `dt^2` position-level load must remain - associated with the owning compliant constraint and converted to physical - units before comparison; do not regress to an isolated motor threshold or - compare raw projection lambda to public break force. -- The pinned 2D source applies Section 3.5 generically and the 3D joint source - applies it to nonlinear point rows, but the pinned 3D Spring source omits the - geometric term. DART follows the paper for distance springs; do not call that - source equivalence without explicit adjudication and evidence. -- The durable completion rule is - [`../../plans/104-vertex-block-descent-solver/paper-parity-matrix.md`](../../plans/104-vertex-block-descent-solver/paper-parity-matrix.md); - the older corpus matrix's `Complete` labels describe narrow historical assets, - not canonical parity rows. -- Two allocator tests previously recorded as profile-build failures - (`DantzigSolver.ScratchUsesProvidedAllocatorForDantzigWorkBuffers` and - `World.BakedDynamicRigidIpcStepsDoNotGrowWorldBaseAllocator`) both pass in the - current fresh uncached profile build; do not carry the old failure claim - forward without a new reproduction. -- PLAN-104's progress log + git history hold the full per-slice record; this - folder no longer duplicates it. - -## How to Resume +the fail-closed completion contract is +[`../../plans/104-vertex-block-descent-solver/paper-parity-matrix.md`](../../plans/104-vertex-block-descent-solver/paper-parity-matrix.md). +This file owns only the exact branch/session stop point and takeover sequence. + +## Stop Point + +At the branch-tip closeout commit containing this handoff, the public +Sequential Impulse (SI) pair-row slice, its warm-start fix, the subsequent +review fixes, and a source-bound six-capture evidence reseal are closed. One +strict quiet-host current-source benchmark is accepted, the AVBD -> VBD -> SI +packets are regenerated from it, all 192 focused tests pass, and the uncached +default and CUDA `test-all` gates pass for the unchanged runtime/capture source +tree. Independent code and architecture/docs/claims reviews approve the final +closeout tree with no actionable finding. If that commit is not yet the local +branch tip, preserve the dirty scope and finish only its final lint and local +commit before continuing. + +The 88-row VBD and 88-row AVBD contracts remain fail-closed with no complete +rows. The local three-method Figure 13 packet is useful partial evidence, not +paper parity. Full scope is still all paper/reference algorithms and features, +CPU and GPU implementations, every paper/site/video/source-demo experiment, +and performance that beats the comparable reference and published paper +numbers. Do not reduce this to an MVP, a CPU-only lane, or one wall demo. + +## Exact Worktree And Branch + +- Worktree: `/home/js/dev/dartsim/dart/task_5` +- Branch: `feature/vbd-avbd-paper-parity-contract` +- Pre-closeout local `HEAD`: `d9af744a9cb0763033d76e01381dbf7c1e6ecaa9` +- Resume tip: the local commit containing this handoff; verify its live SHA +- Last-fetched upstream ref: + `origin/feature/vbd-avbd-paper-parity-contract` at the same SHA (`+0/-0`) +- Current remote-tracking `origin/main` snapshot: + `b15e7a652b766897a8a69a121742ef5973c77f3c` (nine commits beyond local + `main`) + +No fetch, push, PR edit, review trigger, or other GitHub mutation was performed +while preparing this handoff. Treat the upstream SHA as a local snapshot, not a +fresh remote assertion. Verify live state before any external action, and do +not push or mutate GitHub without explicit maintainer/user approval. + +Immediately before the closeout commit, the working tree intentionally had 57 +tracked modifications and six untracked files. The commit containing this +handoff should leave that scope clean. If resuming before it exists, preserve +the dirty tree exactly; do not reset, stash, rebase, switch worktrees, or +discard files. The six pre-closeout untracked files are: + +- `dart/simulation/detail/rigid_pair_constraint.hpp` +- `docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json` +- `python/examples/demos/scenes/sequential_impulse_paper_breakable_wall.py` +- `python/tests/unit/test_write_avbd_paper_sequential_impulse_comparison_packet.py` +- `scripts/capture_source_provenance.py` +- `scripts/write_avbd_paper_sequential_impulse_comparison_packet.py` + +The local commit ladder above `main` is: + +| Commit | Local slice | +| ------------- | ------------------------------------------------- | +| `710cbfc1152` | Fail-closed VBD/AVBD paper-parity contracts | +| `a78f688a178` | AVBD Section 3.5 quasi-Newton geometric stiffness | +| `0b0154573b8` | AVBD Section 4 parallel dual/stiffness update | +| `761263bbd41` | Articulated finite masked rows | +| `9ebd9b895b1` | Articulated finite movable-pair motors | +| `131981788fa` | Articulated finite load and fracture accounting | +| `646b447d6cc` | Public AVBD solver and Figure 13 evidence | +| `d9af744a9cb` | Public fixed-penalty VBD comparison | +| branch tip | Public SI pair rows and Figure 13 comparison | + +## Current SI Slice + +The dirty slice makes the default public SI family own its hard rigid pair +constraints instead of borrowing the private AVBD projector: + +- fixed, spherical, revolute, and prismatic masks interleave with contact rows + in the configured projected Gauss-Seidel sweeps; +- bounded one-DOF velocity motors use the same SI row ownership; +- break force is derived from accumulated impulse over `dt`, broken rows stay + excluded, and a non-velocity post-stabilization pass reduces pose drift; +- finite-stiffness public pair rows reject SI and direct callers to VBD/AVBD; +- shared rigid-pair geometry and input extraction have solver-neutral + ownership, while SI and AVBD retain separate row/continuation state; and +- the matched `sequential_impulse_paper_breakable_wall` demo, writer, packet, + benchmark row, source-provenance utility, and tests complete the third honest + Figure 13 method row. + +The warm-start ownership rule is deliberately narrower than a full AVBD scratch +clear. Crossing through SI invalidates AVBD contact and hard point-joint/motor +continuation state, but preserves independently active AVBD distance-spring +continuation. + +### Post-review correctness fix + +The first correctness review found a real P2 defect: when public SI declined a +private `RigidAvbdContactConfig` compatibility envelope, the fallback called +the full AVBD scratch `clear()`. That erased `distanceSpringInventory` and +cold-started unrelated distance springs every step. + +The working tree fixes the cause in +`dart/simulation/compute/rigid_body_contact_stage.cpp` by calling +`clearSequentialImpulseOwnedWarmStart()`. That clears contact and hard +point-joint/motor continuation only. The regression +`World.SequentialImpulseFallbackPreservesAvbdDistanceSpringWarmStart` in +`tests/unit/simulation/world/test_world.cpp` compares fallback and control +worlds with a stiffness-ramping distance spring; it failed at the second-step +transform/velocity before the fix and passes after it. + +The first architecture/claims review separately rejected the old statement +that every timing CV was below 0.7%. A later source fix invalidated that +otherwise clean benchmark snapshot. Three current-source reruns were rejected +for host contention, and a fourth candidate was rejected because its task +wrapper rebuilt after the quiet gate and its watchdog fired. The accepted run +used the already-built target after a renewed two-minute quiet window and a +clean in-run watchdog. Do not restore an old timing bound or hand-edit +generated packet JSON; all three packets were regenerated from that one JSON +in AVBD -> VBD -> SI order. + +## Current Evidence + +### Correctness and aggregate gates + +- The exact current tree passes an uncached Release build and all 79 enabled + simulation executables in 906.85 seconds (the two aggregate wrappers remain + intentionally disabled). The complete exact-current-tree Python suite also + passes with 1,711 tests passed and 20 expected skips in 238.72 seconds. +- The exact current checker/capture/writer surface passes all 192 focused tests, + including required top-level paper-capture provenance, exact ordered + writer-owned source lists, required self-method medians, exact timing-ratio + keys, future schema rejection, exact legacy filename-to-version pinning, + transitive packet-link validation, and both live-corpus checks. +- Six regenerated captures and three semantic reviews bind the current source + digest below. The accepted current-source benchmark and regenerated packet + chain bind the same digest. +- The complete post-fix, post-reseal tree passes uncached default `test-all` + with all six phases and uncached CUDA `test-all` with all seven phases. The + CUDA run passed all 79 enabled simulation executables (the two aggregate + wrappers remain intentionally disabled) and its CUDA C++ test/benchmark + smoke path. Rerun affected gates if a review fix changes covered source. A + green CUDA environment gate still does **not** close the missing VBD/AVBD + solver GPU-parity rows. + +### Source and benchmark provenance + +- Current capture-source digest: + `556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12` +- Benchmark translation-unit digest: + `f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370` +- Accepted current-source benchmark: + `/tmp/plan104-figure13-reseal.FTkRfX/figure13-three-method-accepted.json` + (SHA-256 + `c2c81c26ca0fa96d1cf11da4c5a166a3a2eb7ead9b80aafac207c20f03cf7c67`). + Median CPU costs/CVs are 8.612602 ms/2.04% AVBD, + 8.302657 ms/1.94% VBD, and 14.296125 ms/2.12% SI. +- Rejected current-source attempts in + `/tmp/plan104-figure13-reseal.FTkRfX`: + - `figure13-three-method.json`: CPU CVs 4.46% AVBD, 7.47% VBD, and 2.51% SI; + - `figure13-three-method-clean.json`: a fresh external-load spike produced + 43.86% VBD CPU CV; + - `figure13-three-method-quiet.json`: an unrelated parallel build started + during the run, producing 25.74% AVBD CPU CV and implausibly inflated VBD + and SI medians; and + - `figure13-three-method-rejected-watchdog.json`: the task wrapper rebuilt + after its quiet gate and the in-run watchdog fired, so the candidate was + rejected before packet generation. + +The regenerated AVBD/VBD/SI packet SHA-256 values are, in dependency order, +`3e15adb9093c10567c0f8c22c5f8e8ad4e5d47a7acd5aca7a107fb97de7fc54c`, +`9f8e6dbc9cb2f37af5d268fa83c13e67f3185cb39e14cebf413a288b6ee298d8`, +and `07151151acec718e2e851e92af2dd127f2833ee65f049445624ccffd29233ce9`. +VBD/AVBD is 0.9640x, SI/AVBD is 1.6599x, and SI/VBD is 1.7219x. Outcomes +intentionally differ, and there is no source achieved-accuracy or +same-hardware denominator for this reconstructed scene; no raw cost ratio is a +speedup, parity, or quality claim. + +### Visual evidence + +The temporary evidence root is +`/tmp/plan104-figure13-reseal.FTkRfX`. +All six +1280x720 captures passed engine ViewReports, pixel-integrity checks, and +separate original-resolution semantic review. The review records are +`avbd-visual-review.json`, `vbd-visual-review.json`, and +`si-visual-review.json` in that root. + +| Method/checkpoint | Image SHA-256 | Assessed observation | +| ----------------- | ------------------------------------------------------------------ | ------------------------------------------------------- | +| AVBD frame 60 | `1d72ad775b690494372585ba9f91eb751aaefb2ba6c94a2466c9009ace94c3d5` | Globally standing wall with visible displacement damage | +| AVBD frame 120 | `199c4f6d676a65c59403da39adce607ace101ac55674f1766c0be6deaf4954d7` | Standing retained structure after fracture | +| VBD frame 18 | `a94339bf5f07c337133ceb5790b96db33e90471cf24abca7a15f6efccb4d0676` | Bend without fracture | +| VBD frame 120 | `039ee1fe05fc7fc0c350da8818be4acc7d812d7d13fcab481b18562adc84751e` | Recovery with all attachments retained | +| SI frame 14 | `24cff2be00f71a3ed24216e577d7a6f3d9519e69d076c724a59f4a1ac108ddee` | Initial fracture while placement remains | +| SI frame 120 | `e1f9ecfc683fb60974a6ca76905a4f40b6b0a981e53463af230b7241cc40c97f` | Retained-row failure and collapse | + +The pinned paper inputs used for adjudication were +`/tmp/dart-vbd-avbd-parity.teIxO8/avbd.pdf` (SHA-256 +`7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6`) and +`/tmp/avbd-wall-visuals.SalJ3h/paper-10.png` (SHA-256 +`040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b`). All +`/tmp` paths are conveniences, not durable sources. If they have expired, +regenerate evidence with the tracked demos/writers and reacquire the pinned +paper input; never weaken a packet because a temporary artifact disappeared. + +### Review state + +The first role-separated review pair found the stale CV claim and the +distance-spring warm-start defect described above. A subsequent architecture +review found four more current-tree gaps: the wall claim lacked exact broken +identities and retained-row residuals, packet checking did not fail closed on +source or transitive-link drift, shared SI/AVBD pair geometry still had AVBD +ownership, and one rejection diagnostic was not solver-neutral. The current +tree addresses those findings and reseals all six captures, the accepted +benchmark, and the tracked packet chain to the new source digest. Final +independent code review approved the complete code/evidence tree. Final +architecture/docs/claims review then found two remaining fail-closed gaps: the +canonical Figure 13/video rows omitted the SI packet, and legacy packet names +were not pinned to their exact historical schema versions. The closeout tree +adds the SI evidence links, preserves the honest XPBD/CUDA/performance +blockers, pins each legacy name to version 1 or 3 with downgrade mutation +coverage, corrects active schema-version prose to version 4, and passes both +fresh reviews with no actionable finding. + +## Immediate Resume Sequence + +1. Stay in the exact worktree and verify the branch, upstream snapshot, status, + and commit ladder. Expect the SI closeout commit containing this handoff at + the clean local tip. If it is absent, preserve the recorded dirty scope and + complete only the already-reviewed closeout procedure below. +2. Recompute the capture-source digest and run the parity, packet, and + allocation checkers. The expected digest, accepted benchmark hash, and + packet hashes are the values above. If any differs after a source change, + regenerate all dependent captures, benchmark, packets, and semantic reviews + before trusting them. Find stale live claims with: + + ```bash + rg -n '8\.660|8\.370|13\.905|8\.697|8\.620|13\.945|9\.305|9\.043|15\.308|0\.967|0\.991|0\.972|1\.603|1\.618|1\.645|1\.693|0\.7%|1\.1%|4f8bc725|c46c2de5|bbf00f868375449e|694cde6e' \ + docs/dev_tasks/avbd_solver \ + docs/plans/104-vertex-block-descent-solver.md + ``` + +3. If the SI closeout commit is absent, run `pixi run lint` immediately before + it, re-run packet/provenance checks, inspect `git diff --check` and the exact + staged scope, and commit locally with a plain descriptive message and no AI + attribution. The two required independent reviews are already clean; repeat + them only if the tree changes materially. +4. Merge the latest local `main` into this branch, never rebase it. Reconcile + conflicts against PLAN-104's solver-family ownership, then rerun every gate + and regenerate every source-bound artifact invalidated by the merge. Do not + fetch, push, update a PR, resolve threads, or trigger a review without + explicit approval. +5. Move directly to the honest public XPBD substep family and its matched + Figure 13 row. Do not spend the next implementation slice on the two smaller + maintenance items unless they become blocking. + +## Resume Commands ```bash +cd /home/js/dev/dartsim/dart/task_5 git status --short --branch -git log --oneline --decorate -5 +git branch -vv --no-abbrev +git log --oneline --decorate -12 +git diff --stat + +pixi run python scripts/capture_source_provenance.py --digest-only pixi run check-plan104-paper-parity pixi run check-avbd-packets pixi run check-plan122-allocation-matrix -pixi run build ``` -Stay in this exact worktree and branch. Verify the current packet with focused -AVBD/world allocation/Python writer and scene tests, the parity and allocation -contract checkers, aggregate build/unit gates, and `pixi run lint`; do not push -or mutate GitHub without explicit approval. +After any code or evidence change, select proportional focused gates first. +Before SI closeout, the authoritative broad gates are: + +```bash +DART_DISABLE_COMPILER_CACHE=ON pixi run test-simulation +DART_DISABLE_COMPILER_CACHE=ON pixi run test-py +DART_DISABLE_COMPILER_CACHE=ON pixi run test-all +DART_DISABLE_COMPILER_CACHE=ON pixi run -e cuda test-all +pixi run lint +git diff --check +``` + +`pixi run lint` is mandatory immediately before every commit. It may regenerate +or validate source-bound artifacts, so recheck provenance and packets after it. + +## Continue The Full Plan After SI Closeout + +1. **Honest public XPBD row.** Define a real XPBD substep solver identity and + semantics; do not relabel SI or fixed-penalty VBD. Wire C++, dartpy, + serialization/replay, resolved configuration, fail-closed compatibility, + allocation evidence, focused correctness tests, and the matched Figure 13 + demo/benchmark/packet. Give XPBD an independent quantitative and assessed + visual oracle on the shared scene fingerprint. +2. **Source-matched four-method Figure 13/video edit.** Compare SI, XPBD, VBD, + and AVBD with audited method identities and matched inputs. Preserve the + explicit limitation that exact Figure 13 constants are unpublished. Add + private CUDA evidence and achieved-accuracy comparison before advancing the + canonical figure/video rows beyond partial. +3. **Remaining CPU mechanism gaps.** Follow PLAN-104's current-next-gap owner: + broaden articulated/joint/fracture coverage, rigid contact persistence and + realistic stacks/piles, then unified rigid/soft rows and coupled scenes. + Keep allocation, replay, serialization, invalidation, and fail-closed + behavior first-class for every new world stage or row family. +4. **Complete the paper/source-demo corpus.** Use `avbd-demo-corpus.md` and the + gap audit for every 2D/3D source scene, paper figure, parameter sweep, + website/video scene, and comparison. Each row needs solver identity, text + correctness, benchmark JSON, visual evidence where applicable, and explicit + CPU/GPU status. +5. **GPU parity.** Route every landed CPU row family, candidate path, color + update, dual/stiffness pass, and benchmark scene through the private CUDA + boundary with deterministic warm-state behavior. A green CUDA environment + gate alone is not solver parity. +6. **Performance leadership.** Optimize CPU and GPU until matched, + achieved-accuracy packets beat the reference demos and the paper's + published numbers on comparable hardware. Record hardware, commands, + source/guard hashes, rejected experiments, and honest negative results. +7. **Completion audit.** Close all 176 canonical predicates, obtain two clean + current-tree reviews, run default and CUDA full gates, promote durable + lessons/evidence to their plan/design/onboarding owners, and delete this + dev-task folder in the completing PR. Do not retire it while any doable or + unparked parity work remains. + +Two valid but lower-priority maintenance items remain: hoist duplicated +`makeCollisionPairKey` logic into one shared `detail` owner, and upgrade the +Spring / Spring Ratio packets from schema version 1 to the current +solver-identity contract. + +## Claim Boundaries To Preserve + +- Figure 13 does not publish exact wall dimensions, mass/inertia choices, ball + speed/targets, or break threshold. The DART scene is a + publication-shaped reconstruction, never an exact source replay. +- The SI frame-14 image cannot identify the five broken constraints, and the + front-oblique VBD bend is subtle. Counts, residuals, and bend magnitude come + from the text oracle; do not inflate image-only claims. +- AVBD has 79 of 154 broken identities outside the selected impact regions. + Claim localized displacement damage and tightly satisfied retained rows, + not localized broken topology. +- Candidate-only articulated finite-row timings compare work that the exact + parent skipped. Do not manufacture before/after speedups from them. +- Finite one-DOF motor load is position-level and must be converted by + `1 / dt^2` and aggregated with its owning compliant rows before comparison to + public physical break force. +- DART follows paper Section 3.5 for distance-spring geometric stiffness even + though the pinned 3D Spring source omits that term. Do not claim 3D source + equivalence without separate adjudication. +- Historical `Complete` labels in the corpus matrix are narrow asset labels. + The machine-checked 176-row parity contract is the completion authority. +- Previously reported allocator failures now pass in a fresh uncached profile + build. Do not revive an old blocker without reproducing it on the current + tree. + +## Copy-paste Resume Prompt + +```text +Resume PLAN-104 AVBD/VBD paper parity from +/home/js/dev/dartsim/dart/task_5 using +docs/dev_tasks/avbd_solver/RESUME.md as the exact stop-point contract. Stay on +feature/vbd-avbd-paper-parity-contract; verify live state before acting and do +not reset, stash, rebase, switch trees, push, or mutate GitHub without explicit +approval. First read AGENTS.md, +docs/ai/principles.md, docs/ai/verification.md, the PLAN-104 owner, paper gap +audit, demo corpus, parity matrix, and both dev-task files. Verify that the +reviewed SI closeout is the clean branch-tip commit; if it is not, preserve the +recorded dirty scope and finish only its final lint/check/commit sequence. +Merge the latest local main into the branch, refresh invalidated gates and +source-bound evidence, then implement an honest public XPBD substep family and +matched Figure 13 row. Continue with the source-matched four-method/CUDA +evidence and every remaining CPU/GPU, paper/site/video/demo, unified-row, and +performance gap. Keep all claims fail-closed until every one of the 176 +canonical predicates passes. Do not stop at an MVP, CPU-only implementation, +or documentation checkpoint. +``` diff --git a/docs/plans/104-vertex-block-descent-solver.md b/docs/plans/104-vertex-block-descent-solver.md index 16700cbb646e3..e0897816ef44d 100644 --- a/docs/plans/104-vertex-block-descent-solver.md +++ b/docs/plans/104-vertex-block-descent-solver.md @@ -336,56 +336,96 @@ end-to-end dashboard benchmark rows for the public motor paths, and tracked and [`avbd-rigid-prismatic-motor-packet.json`](104-vertex-block-descent-solver/avbd-rigid-prismatic-motor-packet.json) visual/benchmark evidence. -Solver-identity relabel (PLAN-091 WP-091.1): the historical -`avbd-demo2d`/`avbd-demo3d` benchmark and py-demo packets neither selected the -new public AVBD rigid-body family nor emplaced the compatibility-only internal -AVBD rigid-contact opt-in (`comps::RigidAvbdContactConfig`). Their rigid -contacts therefore ran DART's default sequential-impulse path. The -native-runner timing ratios for those contact scenes remain whole-pipeline -`World::step` comparisons, not AVBD-contact-solver comparisons: the -pure-contact rows timed no AVBD rows; joint-plus-contact rows timed AVBD -point-joint/motor/spring rows while ordinary contacts ran sequential impulse; -and incidental chain contacts also ran sequential impulse. This historical -classification is unchanged. New packets must machine-record both -`resolved_solver_identity` and `rigid_contact_selection` at AVBD packet schema -version 3, enforced by `pixi run check-avbd-packets`. +Solver-identity relabel (PLAN-091 WP-091.1): the checked-in historical +`avbd-demo2d`/`avbd-demo3d` benchmark and py-demo packets were captured before +their scenes selected the public AVBD rigid-body family or emplaced the +compatibility-only internal AVBD rigid-contact opt-in +(`comps::RigidAvbdContactConfig`). At capture time, their rigid contacts ran +DART's default sequential-impulse path. Their recorded native-runner timing +ratios therefore remain historical whole-pipeline `World::step` comparisons, +not AVBD-contact-solver comparisons: pure-contact rows timed no AVBD rows; +joint-plus-contact rows timed AVBD point-joint/motor/spring rows while ordinary +contacts ran sequential impulse; and incidental chain contacts also ran +sequential impulse. The current `avbd_demo2d_fracture`, +`avbd_demo2d_hanging_rope`, `avbd_demo2d_heavy_rope`, `avbd_demo2d_rod`, +`avbd_demo2d_soft_body`, `avbd_demo3d_breakable`, and +`avbd_demo3d_soft_body` scenes now explicitly select public AVBD so they fail +closed instead of silently exercising SI. That source change does not +retroactively reclassify their historical packets; refreshed claims need +current schema-version-4 packets that machine-record both +`resolved_solver_identity` and `rigid_contact_selection`, enforced by +`pixi run check-avbd-packets`. -Public C++ and dartpy callers can now select the fixed-penalty -`RigidBodySolver::Vbd` / `RigidBodySolver.VBD` family or the +Public C++ and dartpy callers can now select the default +`RigidBodySolver::SequentialImpulse`, the fixed-penalty +`RigidBodySolver::Vbd` / `RigidBodySolver.VBD` family, or the augmented-Lagrangian `RigidBodySolver::Avbd` / `RigidBodySolver.AVBD` family and set a positive `RigidConstraintOptions::iterations` / -`RigidConstraintOptions(iterations=...)` budget. The default remains -sequential impulse, each choice survives binary save/load and replay, the -resolved-configuration report names the selected family, and unsupported -combinations fail before stepping instead of falling through to another -contact family. +`RigidConstraintOptions(iterations=...)` budget. Sequential Impulse owns hard +fixed/spherical/revolute/prismatic rigid pair rows, bounded velocity motors, +impulse-derived fracture, and non-velocity post-stabilization in the same +projected Gauss-Seidel sweeps as contacts; finite-stiffness pair rows fail +closed to VBD/AVBD. Crossing through Sequential Impulse, including the +private-AVBD compatibility fallback, invalidates AVBD contact and hard +point-joint/motor continuation state while preserving independently active +AVBD distance-spring continuation, guarded by the +`World.SequentialImpulseFallbackPreservesAvbdDistanceSpringWarmStart` +regression. Each choice survives binary save/load and replay where +supported, the resolved-configuration report names the selected family, and +unsupported combinations fail before stepping instead of falling through to +another contact family. The publication-shaped `avbd_paper_breakable_wall` scene exercises that public path at the paper's 1/60 s step and 20-iteration budget with 252 staggered bricks, 712 breakable attachments, and three impacts. Its deterministic -frame-120 oracle records 359 broken and 353 retained attachments, displaced -brick counts `[4, 10, 6]` around the three targets, 91.16% retention outside -the impact bands, and 82.54% total retention. The frame-60 capture remains an -explicit pre-evaluation state: one band has three displaced bricks and no -final three-band threshold claim is made there. Both engine ViewReports pass, -the runtime scene and benchmark share fingerprint `2a746821cc10faee`, and the -current five-repeat Release benchmark records 7.467 ms median CPU time per -step. -Those assessed captures and the absolute CPU benchmark are sealed in +frame-120 oracle records 154 broken and 558 retained attachments, displaced +brick counts `[11, 14, 11]` around the three targets, 99.43% retention outside +the impact bands, and 99.21% total retention. Frames 60 and 120 record one +stable broken-joint identity digest; `[18, 44, 13]` identities start inside the +three selected impact regions and 79 start outside, so this row claims +localized displacement damage rather than localized broken topology. The 558 +surviving rows stay below 0.772 mm linear and 0.000317 rad angular residual at +frame 120. The frame-60 capture remains an explicit pre-evaluation state. Both +engine ViewReports pass, the runtime scene and benchmark share fingerprint +`8ca3fbfa00c3dce9`, and the six current captures bind source digest +`556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12`. +Capture manifests bind the capture-time source tree and artifacts; an accepted +benchmark binds that same source tree plus its translation unit. The accepted +quiet-host five-repeat Release run records 8.612602 ms median CPU cost per step +with 2.04% CPU CV; its raw JSON SHA-256 is +`c2c81c26ca0fa96d1cf11da4c5a166a3a2eb7ead9b80aafac207c20f03cf7c67`. +The current evidence is sealed in [`avbd-paper-breakable-wall-packet.json`](104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). The matched `vbd_paper_breakable_wall` row reuses that exact scene fingerprint and projection budget with fixed finite-penalty rows, no dual accumulation or -progressive stiffness. Its frame-14 oracle records 0 broken and 712 retained -attachments, 0.229 m peak and 0.122 m RMS brick displacement, and 131 bricks -beyond 0.1 m; its frame-120 oracle retains the full connected wall. Both -assessed VBD renders pass. The same five-repeat benchmark records 9.218 ms -median CPU time, or 1.234 times the AVBD median cost on this host; that ratio is -descriptive only because the methods produce different physical outcomes and -there is no achieved-accuracy reference denominator. The cross-linked evidence -is sealed in +progressive stiffness. Its frame-18 oracle records 0 broken and 712 retained +attachments, 0.132 m peak wall-normal and 0.062 m RMS displacement, and 120 +bricks beyond 0.05 m; its frame-120 oracle retains the full connected wall. +All 712 retained VBD rows stay below 16.39 mm and 0.01328 rad at frame 18, +then 11.10 mm and 0.001310 rad at frame 120. Both assessed VBD renders pass. +The same five-repeat benchmark records 8.302657 ms VBD and 8.612602 ms AVBD +median CPU cost per step with 1.94% and 2.04% CPU CV. Its 0.9640x VBD/AVBD +ratio is descriptive only because the methods produce different physical +outcomes and there is no achieved-accuracy reference denominator. The current +cross-linked evidence is sealed in [`avbd-paper-vbd-comparison-packet.json`](104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json). +The matched `sequential_impulse_paper_breakable_wall` row records exactly five +frame-14 broken identities, distributed `[2, 1, 2]` across the three impact +regions with none outside, while 100% of brick placement remains. At frame 120 +the broken-identity digest is unchanged, but the 484 retained rows outside all +impact regions reach 0.06346 m maximum / 0.02076 m RMS linear residual and +0.7270 rad maximum / 0.2164 rad RMS angular residual. Only 27.27% of bricks +outside impact bands and 19.05% overall remain placed, with 2.878 m maximum +wall-normal displacement. Both assessed SI renders pass. The same benchmark +records 14.296125 ms SI median CPU cost per step with 2.12% CPU CV. Its +1.6599x SI/AVBD and 1.7219x SI/VBD ratios are descriptive only. Frame 120 is +the only shared quantitative +checkpoint across the three method rows; the earlier frame-14, frame-18, and +frame-60 checkpoints are per-family diagnostic oracles, not cross-method +comparisons. The current linked three-method evidence is sealed in +[`avbd-paper-sequential-impulse-comparison-packet.json`](104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json). Figure 13 and the matching video row stay partial because the exact source -constants are unpublished and the Sequential Impulse, XPBD, source-edit, CUDA, -and achieved-accuracy reference-performance evidence is still missing. +constants are unpublished and XPBD, the source-matched four-method edit, CUDA, +and achieved-accuracy reference-performance evidence are still missing. The `avbd_empty_baseline` py-demo and `BM_AvbdEmptyWorldStep` row now provide a first runnable baseline for the 2D/3D source-demo empty rows, with source revision/default metadata and a `sceneEmpty` zero-count reference invariant. @@ -431,9 +471,13 @@ reference-comparison gate remains open. The `avbd_demo2d_fracture` py-demo now ports the `avbd-demo2d` Fracture source row with source revision/scene metadata, 11 chain links, two dynamic supports, 15 falling blocks, 10 public breakable fixed joints, focused Python source-row -coverage, focused break/reset lifecycle coverage proving the source-row fixed -joints fracture, reset at a high break force, stay unbroken, and reduce their -anchor residuals again, and a `BM_AvbdDemo2dFractureStep` dashboard row. The +coverage, and focused break/reset lifecycle coverage proving the +source-configured fixed joints eventually fracture under DART's public +combined-row load contract, reset at a high break force, stay unbroken, and +reduce their anchor residuals again, plus a `BM_AvbdDemo2dFractureStep` +dashboard row. This does not claim native fracture timing or predicate parity: +the pinned 2D source breaks only its torque-arm-scaled angular dual row, while +DART evaluates the combined physical fixed-joint row load. The tracked [`avbd-demo2d-fracture-packet.json`](104-vertex-block-descent-solver/avbd-demo2d-fracture-packet.json) adds headless visual capture, DART benchmark JSON, and native source timing; @@ -778,10 +822,14 @@ the DART public World Bridge row at 0.746 ms median CPU time per step versus row while leaving broad coupled-constraint, GPU, and paper-number gates open. The `avbd_demo3d_breakable` py-demo now ports the `avbd-demo3d` Breakable source row with source revision/scene metadata, 19 rigid bodies, 10 breakable -fixed joints, 19 collision shapes, focused Python source-row coverage, focused -break/reset lifecycle coverage proving the source-row fixed joints fracture, +fixed joints, 19 collision shapes, focused Python source-row coverage, and +focused break/reset lifecycle coverage proving the source-configured fixed +joints eventually fracture under DART's public combined-row load contract, reset at a high break force, stay unbroken, and reduce their anchor residuals -again, and a `BM_AvbdDemo3dBreakableStep` dashboard row. The tracked +again, plus a `BM_AvbdDemo3dBreakableStep` dashboard row. This does not claim +native fracture timing or predicate parity: the pinned 3D source breaks only +its torque-arm-scaled angular dual vector, while DART evaluates the combined +physical fixed-joint row load. The tracked [`avbd-demo3d-breakable-packet.json`](104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json) adds headless visual capture, DART benchmark JSON, and native source timing; it records the DART public World Breakable row at 79.5 us median CPU time per @@ -1046,12 +1094,12 @@ The free-rigid fixed-joint/contact, free-rigid/articulated one-DOF motor, breakable fixed point-joint, prismatic/revolute/spherical facade, articulated breakable-joint, high-ratio articulated-chain, and public AVBD Figure 13 slices are user-visible but intentionally bounded. The wall now has -matched public fixed-penalty VBD and public AVBD rows on one reconstructed -scene fingerprint, with distinct quantitative and assessed visual outcomes. -It is not an exact source replay or a four-method/CUDA closure; Sequential -Impulse, XPBD, the source edit, and achieved-accuracy reference performance -remain open, and the source-demo corpus packets still carry their historical -solver identities. The Section 4 post-primal +matched public Sequential Impulse, fixed-penalty VBD, and AVBD rows on one +reconstructed scene fingerprint, with distinct quantitative and assessed +visual outcomes. It is not an exact source replay or a four-method/CUDA +closure; XPBD, the source-matched edit, and achieved-accuracy reference +performance remain open, and the source-demo corpus packets still carry their +historical solver identities. The Section 4 post-primal dual/stiffness update is now a verified CPU partial over the promoted deformable and private free-rigid inventories; it remains open for articulated/unified rows, CUDA, and source-matched achieved-accuracy @@ -1361,6 +1409,52 @@ AVBD parity additionally requires: Relocated from the dashboard on 2026-07-03; newest first. +On 2026-08-01, one strict quiet-host five-repeat Figure 13 run was accepted +after four fail-closed candidate rejections. It binds capture-source digest +`556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12` and +benchmark translation-unit digest +`f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370`. +Median CPU costs/CVs are 8.612602 ms/2.04% AVBD, 8.302657 ms/1.94% VBD, and +14.296125 ms/2.12% Sequential Impulse. The AVBD -> VBD -> SI packet chain was +regenerated from the one raw JSON and passes all 192 focused tests plus the +58-packet corpus checker. The exact post-reseal tree also passes uncached +default and CUDA `test-all`; CUDA environment success does not close the open +solver-specific GPU-parity rows. Final independent code and +architecture/docs/claims reviews approved the closeout after the canonical +Figure 13/video rows linked the SI packet and the checker pinned every legacy +filename to its exact version-1 or version-3 schema. Ratios remain descriptive +because the three outcomes differ and no achieved-accuracy reference +denominator exists. + +On 2026-07-30, the default public Sequential Impulse family gained +solver-owned hard rigid pair rows. Fixed/spherical/revolute/prismatic masks and +bounded velocity motors interleave with contact rows across the configured PGS +sweeps; accumulated impulse over `dt` drives breakage, broken rows remain +excluded, and the configured non-velocity post-stabilization sweeps reduce +pose drift. +Finite-stiffness pair rows fail closed to VBD/AVBD. Crossing through SI, +including the private-AVBD compatibility fallback, clears only the SI-owned +AVBD contact and hard point-joint/motor continuation state and preserves +independently active AVBD distance-spring continuation. Focused C++ regressions +cover no-contact stabilization without velocity injection, joint/contact +co-convergence, impulse-derived breakage, finite-row rejection, public motors, +AVBD compatibility, and fallback distance-spring warm-start preservation. The +matched +`sequential_impulse_paper_breakable_wall` scene shares fingerprint +`8ca3fbfa00c3dce9` with regenerated AVBD/VBD evidence. It records exactly five +frame-14 broken identities spanning the three impact regions and none outside +while retaining all brick placement. At frame 120 the same five identities +remain broken, but retained rows outside the impacts carry large measured +linear/angular residuals and total placement falls to 19.05% as the wall +visibly collapses. Six current-source three-method captures pass their engine, +pixel-integrity, and semantic-review gates. Three benchmark attempts were +rejected for host contention, so the five-repeat timing and current packet +reseal remain pending, with no speedup or achieved-accuracy claim. The +cross-linked evidence will be sealed in +[`avbd-paper-sequential-impulse-comparison-packet.json`](104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json). +Figure 13 remains partial for XPBD, exact unpublished constants, the +source-matched four-method edit, CUDA, and comparable reference performance. + On 2026-07-30, the rigid-body solver selector gained a distinct public fixed-penalty VBD family in C++ and dartpy. It reuses the DART-owned 6-DOF block-descent contact and pair-row infrastructure while disabling AVBD dual @@ -1375,10 +1469,14 @@ attachments, as do both assessed renders. The five-repeat same-host benchmark records 9.218 ms VBD versus 7.467 ms AVBD median CPU time, a descriptive 1.234 cost ratio with no speedup or achieved-accuracy equivalence claim. [`avbd-paper-vbd-comparison-packet.json`](104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json) -binds those rows to the current AVBD packet and pinned source hashes. Figure 13 -and video row 12 remain partial because Sequential Impulse, XPBD, exact -unpublished constants, a source edit, CUDA, and achieved-accuracy reference -performance remain open. +bound those rows to the then-current AVBD packet and pinned source hashes. +This entry is a point-in-time record: the initial Sequential Impulse bake +regenerated the tracked packet with the shared `8ca3fbfa00c3dce9` +fingerprint, a frame-18 bend oracle, and 8.620 ms VBD versus 8.697 ms AVBD +medians. The later post-review reseal is summarized in the newest entry above. +Figure 13 and video row 12 remained partial +because Sequential Impulse, XPBD, exact unpublished constants, a source edit, +CUDA, and achieved-accuracy reference performance were open at that bake. On 2026-07-30, the public rigid-body solver selector gained an AVBD family and a serialized/replay-safe rigid-constraint options value object. The default remains @@ -1785,23 +1883,18 @@ with finite replay counters, tracked benchmark/stability evidence in and a rendered [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-plot.svg). The same-hardware comparison and GPU gates remain open. Solver-identity -relabel (PLAN-091 WP-091.1): the historical -`avbd-demo2d`/`avbd-demo3d` packets neither selected the public AVBD family nor -emplaced its compatibility-only internal body opt-in, so every rigid contact -in those source-row scenes ran DART's default sequential-impulse contact path. -Their native-runner timing ratios are whole-pipeline `World::step` -comparisons, not AVBD-contact-solver comparisons: the -pure-contact rows (2D Dynamic Friction, Static Friction, Pyramid, Cards, -Stack, and Stack Ratio; 3D Ground, Dynamic Friction, Static Friction, -Pyramid, Stack, and Stack Ratio) timed no AVBD rows at all; the -joint-plus-contact rows (2D Fracture, Soft Body, Joint Grid, and Net; 3D -Soft Body, Bridge, and Breakable) timed AVBD point-joint/motor/spring rows -while their ordinary contacts ran sequential impulse; and incidental -link-link contacts in the chain rows (2D Rod, Rope, Heavy Rope, and Hanging -Rope; 3D Rope and Heavy Rope) also ran sequential impulse. This relabel -remains valid after the public family addition; new AVBD evidence packets must -machine-record `resolved_solver_identity` and `rigid_contact_selection` at -schema version 3, enforced by +relabel (PLAN-091 WP-091.1): the checked-in historical +`avbd-demo2d`/`avbd-demo3d` packets predate explicit public AVBD selection, so +their rigid contacts ran DART's default sequential-impulse path at capture +time. Their native-runner timing ratios remain historical whole-pipeline +`World::step` comparisons, not AVBD-contact-solver comparisons: pure-contact +rows timed no AVBD rows; joint-plus-contact rows timed AVBD +point-joint/motor/spring rows while ordinary contacts ran sequential impulse; +and incidental chain contacts also ran sequential impulse. Seven current +source-row scenes now explicitly select public AVBD; that fail-closed runtime +change does not retroactively reclassify the old packets. Refreshed AVBD +evidence packets must machine-record `resolved_solver_identity` and +`rigid_contact_selection` at current schema version 4, enforced by `pixi run check-avbd-packets`. Public empty-scene corpus baseline coverage is now visible through `avbd_empty_baseline`, a focused Python smoke that checks source revisions, @@ -1852,8 +1945,11 @@ CPU performance resolution and GPU parity remain open. The next revision, scene index, 11 chain links, two dynamic supports, 15 falling blocks, 10 breakable fixed joints, and 29 collision shapes, plus `BM_AvbdDemo2dFractureStep`. Focused integration coverage now also verifies -that the source-row fixed joints fracture, reset at a high break force, stay -unbroken, and reduce their anchor residuals again. The tracked +that the source-configured fixed joints eventually fracture under DART's +combined-row load contract, reset at a high break force, stay unbroken, and +reduce their anchor residuals again. It does not claim parity with the +source's torque-arm-scaled angular-dual fracture predicate or timing. The +tracked `avbd-demo2d-fracture-packet.json` adds headless visual capture, DART benchmark JSON, and native source timing; after a refreshed same-source timing run, it records DART about 1.20x faster than the native Fracture @@ -2094,8 +2190,11 @@ non-empty source row after that is visible through `avbd-demo3d` Breakable source revision, scene index, 19 rigid bodies, 10 breakable fixed joints, and 19 collision shapes, plus `BM_AvbdDemo3dBreakableStep`. Focused integration coverage now also verifies -that the source-row fixed joints fracture, reset at a high break force, stay -unbroken, and reduce their anchor residuals again. The tracked +that the source-configured fixed joints eventually fracture under DART's +combined-row load contract, reset at a high break force, stay unbroken, and +reduce their anchor residuals again. It does not claim parity with the +source's torque-arm-scaled angular-dual fracture predicate or timing. The +tracked `avbd-demo3d-breakable-packet.json` adds headless visual capture, DART benchmark JSON, and native source timing; it records DART about 1.42x faster than the native Breakable runner on this host. The next `avbd-demo3d` source diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md index ba705e4c07aa9..7bd120219d4a7 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-demo-corpus.md @@ -40,200 +40,208 @@ Status values: ## Solver-Identity Relabel (PLAN-091 WP-091.1) -The historical `avbd-demo2d`/`avbd-demo3d` packets in this matrix neither -selected the public AVBD rigid-body family nor emplaced the compatibility-only -internal AVBD rigid-contact opt-in (`comps::RigidAvbdContactConfig`). Their -rigid contacts therefore ran DART's default sequential-impulse path, and their -native-runner timing ratios remain whole-pipeline `World::step` comparisons, -not AVBD-contact-solver comparisons. Pure-contact rows timed no AVBD rows; -joint-plus-contact rows timed AVBD point-joint/motor/spring rows while -ordinary contacts ran sequential impulse; incidental chain contacts also ran -sequential impulse. A `Complete` status below covers only this matrix's -historical local artifact contract. New packets must machine-record -`resolved_solver_identity` and `rigid_contact_selection` at AVBD packet schema -version 3, enforced by `pixi run check-avbd-packets`. +The checked-in historical `avbd-demo2d`/`avbd-demo3d` packets in this matrix +were captured before their scenes selected the public AVBD rigid-body family +or emplaced the compatibility-only internal AVBD rigid-contact opt-in +(`comps::RigidAvbdContactConfig`). Their rigid contacts therefore ran DART's +default sequential-impulse path at capture time, and their recorded +native-runner timing ratios remain historical whole-pipeline `World::step` +comparisons, not AVBD-contact-solver comparisons. Pure-contact rows timed no +AVBD rows; joint-plus-contact rows timed AVBD point-joint/motor/spring rows +while ordinary contacts ran sequential impulse; incidental chain contacts +also ran sequential impulse. Seven current source-row scenes now explicitly +select public AVBD so they cannot silently exercise SI, but that runtime change +does not retroactively reclassify the old packets. A `Complete` status below +covers only this matrix's historical local artifact contract. Refreshed +packets must machine-record `resolved_solver_identity` and +`rigid_contact_selection` at current AVBD packet schema version 4, enforced by +`pixi run check-avbd-packets`. The separate `avbd_paper_breakable_wall` surface selects the public AVBD -family, and the matched `vbd_paper_breakable_wall` surface selects the public -fixed-penalty VBD family. Both record solver identity and share one -reconstructed scene fingerprint. They are paper-shaped comparison rows, not a -retroactive solver-identity change to the source-demo rows. +family, the matched `vbd_paper_breakable_wall` surface selects the public +fixed-penalty VBD family, and `sequential_impulse_paper_breakable_wall` +selects the public Sequential Impulse family. All three record solver identity +and share one reconstructed scene fingerprint. They are paper-shaped +comparison rows, not a retroactive solver-identity change to the source-demo +rows. ## Current Narrow DART Surfaces These surfaces are useful foundations, but they do not complete the overall paper/source-demo corpus: -| DART surface | Coverage | Status | -| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------- | -| `avbd_empty_baseline` py-demo | Empty variational-integrator World smoke with source-row metadata for the 2D/3D empty rows | Partial | -| `avbd-empty-baseline-packet.json` | Visual-capture hash packet and `BM_AvbdEmptyWorldStep` benchmark JSON row for the empty baseline | Complete | -| `avbd_paper_breakable_wall` py-demo | Public AVBD Figure 13 reconstruction with 252 bricks, 712 breakable attachments, three impacts, and a frame-120 outcome oracle | Partial | -| `avbd-paper-breakable-wall-packet.json` | Solver-bound numeric, allocation, assessed visual, pinned-paper, and absolute CPU benchmark evidence for the AVBD wall row | Partial | -| `vbd_paper_breakable_wall` py-demo | Matched public fixed-penalty VBD Figure 13 reconstruction with bend and retained-wall oracles | Partial | -| `avbd-paper-vbd-comparison-packet.json` | Same-fingerprint AVBD/VBD solver identity, numeric, assessed visual, allocation, and descriptive CPU timing evidence | Partial | -| `avbd_demo2d_ground` py-demo | Source `avbd-demo2d` Ground row port with one static slab and no dynamic bodies | Complete | -| `avbd-demo2d-ground-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo2d` Ground row | Complete | -| `avbd_demo2d_motor` py-demo | Source `avbd-demo2d` Motor row port using a pinned rigid bar and public AVBD revolute motor | Partial | -| `avbd-demo2d-motor-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo2d` Motor row | Complete | -| `avbd_demo2d_hanging_rope` py-demo | Source `avbd-demo2d` Hanging Rope row port with 50 rigid bodies and 49 linear-only point joints | Partial | -| `avbd-demo2d-hanging-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Hanging Rope row | Complete | -| `avbd_demo2d_fracture` py-demo | Source `avbd-demo2d` Fracture row port with 29 rigid bodies and 10 breakable fixed joints | Complete | -| `avbd-demo2d-fracture-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Fracture row | Complete | -| `avbd_demo2d_dynamic_friction` py-demo | Source `avbd-demo2d` Dynamic Friction row port with 11 sliding boxes over rigid ground | Complete | -| `avbd-demo2d-dynamic-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Dynamic Friction row | Complete | -| `avbd_demo2d_static_friction` py-demo | Source `avbd-demo2d` Static Friction row port with rotated boxes on an inclined rigid slab | Complete | -| `avbd-demo2d-static-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Static Friction row | Complete | -| `avbd_demo2d_pyramid` py-demo | Source `avbd-demo2d` Pyramid row port with a 210-box triangular rigid pile | Complete | -| `avbd-demo2d-pyramid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Pyramid row | Complete | -| `avbd_demo2d_cards` py-demo | Source `avbd-demo2d` Cards row port with a 40-card thin rigid contact tower | Partial | -| `avbd-demo2d-cards-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Cards row | Complete | -| `avbd_demo2d_stack` py-demo | Source `avbd-demo2d` Stack row port with twenty vertical rigid boxes over ground | Complete | -| `avbd-demo2d-stack-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack row | Complete | -| `avbd_demo2d_stack_ratio` py-demo | Source `avbd-demo2d` Stack Ratio row port with six geometric-size rigid boxes over ground | Complete | -| `avbd-demo2d-stack-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack Ratio row | Complete | -| `avbd_demo2d_rod` py-demo | Source `avbd-demo2d` Rod row port with 20 rigid links and 19 all-axis fixed joints | Partial | -| `avbd-demo2d-rod-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rod row | Complete | -| `avbd_demo2d_soft_body` py-demo | Source `avbd-demo2d` Soft Body row port with two finite-stiffness 15x5 rigid-box lattices | Partial | -| `avbd-demo2d-soft-body-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 2D Soft Body row | Complete | -| `avbd_demo2d_joint_grid` py-demo | Source `avbd-demo2d` Joint Grid row port with 625 boxes and 1200 all-axis fixed joints | Partial | -| `avbd-demo2d-joint-grid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Joint Grid row | Complete | -| `avbd_demo2d_rope` py-demo | Source `avbd-demo2d` Rope row port with 20 rigid links and 19 linear-only point joints | Partial | -| `avbd-demo2d-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rope row | Complete | -| `avbd_demo2d_heavy_rope` py-demo | Source `avbd-demo2d` Heavy Rope row port with a 30 m endpoint block on point joints | Partial | -| `avbd-demo2d-heavy-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 2D Heavy Rope row | Complete | -| `avbd_demo2d_spring` py-demo | Source `avbd-demo2d` Spring row port with one radial rigid distance spring | Partial | -| `avbd-demo2d-spring-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring row | Complete | -| `avbd_demo2d_spring_ratio` py-demo | Source `avbd-demo2d` Spring Ratio row port with seven alternating-stiffness radial springs | Partial | -| `avbd-demo2d-spring-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring Ratio row | Complete | -| `avbd_demo2d_net` py-demo | Source `avbd-demo2d` Net row port with 91 rigid bodies and 39 linear-only point joints | Partial | -| `avbd-demo2d-net-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Net row | Complete | -| `avbd_demo3d_ground` py-demo | Source `avbd-demo3d` Ground row port with a static floor, falling rigid box, and rigid contacts | Complete | -| `avbd-demo3d-ground-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo3d` Ground row | Complete | -| `avbd_demo3d_dynamic_friction` py-demo | Source `avbd-demo3d` Dynamic Friction row port with 11 sliding boxes over rigid ground | Complete | -| `avbd-demo3d-dynamic-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Dynamic Friction row | Complete | -| `avbd_demo3d_static_friction` py-demo | Source `avbd-demo3d` Static Friction row port with boxes sliding on an inclined rigid ramp | Complete | -| `avbd-demo3d-static-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Static Friction row | Complete | -| `avbd_demo3d_pyramid` py-demo | Source `avbd-demo3d` Pyramid row port with a 136-box triangular rigid pile | Complete | -| `avbd-demo3d-pyramid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Pyramid row | Complete | -| `avbd_demo3d_rope` py-demo | Source `avbd-demo3d` Rope row port with 20 rigid links and 19 linear-only point joints | Partial | -| `avbd-demo3d-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rope row | Complete | -| `avbd_demo3d_heavy_rope` py-demo | Source `avbd-demo3d` Heavy Rope row port with a 5 m endpoint block on linear-only point joints | Partial | -| `avbd-demo3d-heavy-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Heavy Rope row | Complete | -| `avbd_demo3d_spring` py-demo | Source `avbd-demo3d` Spring row port with one radial rigid distance spring | Partial | -| `avbd-demo3d-spring-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring row | Complete | -| `avbd_demo3d_spring_ratio` py-demo | Source `avbd-demo3d` Spring Ratio row port with seven alternating-stiffness radial springs | Partial | -| `avbd-demo3d-spring-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring Ratio row | Complete | -| `avbd_demo3d_stack` py-demo | Source `avbd-demo3d` Stack row port with ten vertical rigid boxes over ground | Complete | -| `avbd-demo3d-stack-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack row | Complete | -| `avbd_demo3d_stack_ratio` py-demo | Source `avbd-demo3d` Stack Ratio row port with four geometric-size rigid boxes over ground | Complete | -| `avbd-demo3d-stack-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack Ratio row | Complete | -| `avbd_demo3d_soft_body` py-demo | Source `avbd-demo3d` Soft Body row port with three finite-stiffness 4x4x4 rigid-box lattices | Partial | -| `avbd-demo3d-soft-body-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 3D Soft Body row | Complete | -| `avbd_demo3d_bridge` py-demo | Source `avbd-demo3d` Bridge row port with paired plank point joints and load boxes | Partial | -| `avbd-demo3d-bridge-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Bridge row | Complete | -| `avbd_demo3d_breakable` py-demo | Source `avbd-demo3d` Breakable row port with 19 rigid bodies, 10 breakable joints, and contacts | Complete | -| `avbd-demo3d-breakable-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo3d` Breakable row | Complete | -| `avbd_rigid_fixed_joint_contact` py-demo | Public free-rigid fixed joint rows plus ordinary rigid contact on a payload | Partial | -| `avbd_rigid_revolute_motor` py-demo | Public free-rigid revolute velocity actuator extracting to AVBD motor rows | Partial | -| `avbd-rigid-revolute-motor-packet.json` | Visual/benchmark packet for the public free-rigid revolute velocity motor row | Partial | -| `avbd_rigid_prismatic_motor` py-demo | Public free-rigid prismatic velocity actuator extracting to AVBD linear motor rows | Partial | -| `avbd-rigid-prismatic-motor-packet.json` | Visual/benchmark packet for the public free-rigid prismatic velocity motor row | Partial | -| `avbd_articulated_revolute_motor` py-demo | Public articulated revolute velocity motor command updates through the bridge | Partial | -| `avbd-articulated-revolute-motor-packet.json` | Visual/benchmark packet for the public articulated revolute velocity motor row | Partial | -| `avbd_articulated_prismatic_motor` py-demo | Public articulated prismatic velocity motor command updates through the bridge | Partial | -| `avbd-articulated-prismatic-motor-packet.json` | Visual/benchmark packet for the public articulated prismatic velocity motor row | Partial | -| `avbd_articulated_motor_breakable_joint` py-demo | Public same-multibody articulated revolute motor break/reset through the bridge | Partial | -| `avbd-articulated-breakable-motor-packet.json` | Visual/benchmark packet for the public same-multibody articulated revolute motor break/reset row | Partial | -| `avbd_articulated_prismatic_pair_motor_breakable_joint` py-demo | Public same-multibody articulated prismatic motor break/reset through the bridge | Partial | -| `avbd-articulated-prismatic-pair-breakable-motor-packet.json` | Visual/benchmark packet for the public same-multibody articulated prismatic motor break/reset row | Partial | -| `avbd_articulated_prismatic_motor_breakable_joint` py-demo | Public world-anchored articulated prismatic motor break/reset through the bridge | Partial | -| `avbd-articulated-world-prismatic-breakable-motor-packet.json` | Visual/benchmark packet for the public world-anchored articulated prismatic motor break/reset row | Partial | -| `avbd_articulated_world_revolute_motor_breakable_joint` py-demo | Public world-anchored articulated revolute motor break/reset through the bridge | Partial | -| `avbd-articulated-world-revolute-breakable-motor-packet.json` | Visual/benchmark packet for the public world-anchored articulated revolute motor break/reset row | Partial | -| `avbd-breakable-motor-scale-packet.json` | Benchmark-only scale packet for four public articulated breakable motor rows over 1/8/32 motors | Partial | -| `avbd_rigid_breakable_joint` py-demo | Public free-rigid break-force lifecycle, broken-state reset/accessors, captured-pose re-engagement, and weak re-arm breakage | Partial | -| `avbd-rigid-breakable-joint-packet.json` | Visual/benchmark packet for the public free-rigid fixed point-joint break/reset row | Partial | -| `avbd_rigid_spherical_breakable_joint` py-demo | Public free-rigid spherical point-joint break/reset and weak re-arm breakage while orientation stays free | Partial | -| `avbd-rigid-spherical-breakable-joint-packet.json` | Visual/benchmark packet for the public free-rigid spherical point-joint break/reset row | Partial | -| `avbd_articulated_breakable_joint` py-demo | Public world-link articulated fixed point-joint break/reset and weak re-arm breakage through the bridge | Partial | -| `avbd-articulated-breakable-joint-packet.json` | Visual/benchmark packet for the public articulated fixed point-joint break/reset row | Partial | -| `avbd_articulated_fixed_pair_breakable_joint` py-demo | Public same-multibody articulated fixed point-joint break/reset and weak re-arm breakage through the bridge | Partial | -| `avbd-articulated-fixed-pair-breakable-joint-packet.json` | Visual/benchmark packet for the public same-multibody articulated fixed point-joint break/reset row | Partial | -| `avbd_articulated_spherical_breakable_joint` py-demo | Public world-link articulated spherical break/reset and weak re-arm breakage while orientation stays free | Partial | -| `avbd-articulated-spherical-breakable-joint-packet.json` | Visual/benchmark packet for the public world-link articulated spherical point-joint break/reset row | Partial | -| `avbd_articulated_spherical_pair_breakable_joint` py-demo | Public same-multibody articulated spherical break/reset and weak re-arm breakage while orientation stays free | Partial | -| `avbd-articulated-spherical-pair-breakable-joint-packet.json` | Visual/benchmark packet for the public same-multibody articulated spherical point-joint break/reset row | Partial | -| `avbd-breakable-joint-scale-packet.json` | Benchmark-only scale packet for five public fixed/spherical breakable point-joint rows over 1/8/32 joints | Partial | -| `avbd_articulated_compliant_joints` py-demo | Public passive finite-stiffness world-link spherical/revolute/prismatic rows with visible constrained and free coordinates | Partial | -| `BM_AvbdArticulatedCompliantJointStep` dashboard row | Candidate-only CPU cost/scaling row over 3/12/48 passive finite articulated point joints | Partial | -| `avbd-articulated-compliant-joints-packet.json` | Mutation-sensitive correctness, allocation, visual, solver-identity, and candidate-only scale evidence for the finite rows | Partial | -| `avbd_articulated_compliant_motors` py-demo | Public bounded finite-stiffness revolute/prismatic motors between same-multibody movable-link pairs | Partial | -| `BM_AvbdArticulatedCompliantMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 bounded finite articulated motors | Partial | -| `avbd-articulated-compliant-motors-packet.json` | Mutation-sensitive motor/pair correctness, allocation, docked visual, solver-identity, and candidate-only scale evidence | Partial | -| `avbd_articulated_compliant_breakable_motor` py-demo | Finite-plus-motor physical-load fracture, strong reset, and weak re-arm lifecycle for one same-multibody prismatic pair | Partial | -| `BM_AvbdArticulatedCompliantBreakableMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 finite articulated motors with armed break thresholds | Partial | -| `avbd-articulated-compliant-fracture-packet.json` | Mutation-sensitive load/lifecycle/serialization, allocation, docked visual, solver-identity, and scale evidence | Partial | -| `avbd_articulated_high_ratio_chain` py-demo | Five-link articulated variational-chain smoke with a 200:1 heavy tip | Partial | -| `BM_AvbdArticulatedHighRatioChainStep` dashboard row | Narrow CPU dashboard row for the five-link 200:1 articulated high-ratio chain smoke | Partial | -| `avbd_paper_scale_high_ratio_chain` py-demo | 50-link articulated variational-chain smoke with a 50,000:1 heavy tip | Partial | -| `BM_AvbdPaperScaleHighRatioChainStep` dashboard row | Paper-scale CPU dashboard row for the 50-link/50,000:1 articulated high-ratio chain smoke | Partial | -| `BM_AvbdPaperScaleHighRatioChainIterationSweep` dashboard row | Paper-scale CPU dashboard sweep over 25/50/100/200 max-iteration budgets for the 50-link/50,000:1 articulated chain | Partial | -| `avbd-articulated-high-ratio-chain-packet.json` | Visual/benchmark packet for the narrow five-link 200:1 high-ratio articulated-chain smoke | Partial | -| `avbd-paper-scale-high-ratio-chain-packet.json` | Visual/benchmark packet for the 50-link/50,000:1 paper-scale articulated high-ratio chain smoke | Partial | -| `avbd-paper-scale-high-ratio-iteration-sweep-packet.json` | Benchmark/stability-plot packet for the 25/50/100/200 max-iteration paper-scale high-ratio chain sweep | Partial | -| `avbd-paper-scale-high-ratio-iteration-sweep-plot.svg` | Rendered timing/stability plot for the 25/50/100/200 max-iteration paper-scale high-ratio chain sweep | Partial | -| `variational_endpoint_loop_closure` py-demo | Public multibody link closure preview, not the private AVBD config extractor | Partial | -| `BM_AvbdRigidRevoluteMotorStep` dashboard row | Narrow CPU benchmark for the public free-rigid revolute motor path | Partial | -| `BM_AvbdRigidPrismaticMotorStep` dashboard row | Narrow CPU benchmark for the public free-rigid prismatic motor path | Partial | -| `BM_AvbdDemo2dGroundStep` row | Narrow CPU benchmark for the static `avbd-demo2d` Ground source-row port | Complete | -| `BM_AvbdDemo2dMotorStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Motor source-row port | Partial | -| `BM_AvbdDemo2dHangingRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Hanging Rope source-row port | Partial | -| `BM_AvbdDemo2dFractureStep` row | Narrow CPU benchmark for the `avbd-demo2d` Fracture source-row port | Complete | -| `BM_AvbdDemo2dDynamicFrictionStep` row | Narrow CPU benchmark for the `avbd-demo2d` Dynamic Friction source-row port | Complete | -| `BM_AvbdDemo2dFrictionCoefficientSweep` row | Benchmark sweep for the Dynamic Friction scene over maximum Coulomb friction 0/0.5/1/2.5/5 | Partial | -| `avbd-friction-coefficient-sweep-packet.json` | Validated benchmark/native-reference/visual-capture packet for the Dynamic Friction maximum-friction sweep | Partial | -| `avbd-friction-coefficient-sweep-plot.svg` | Rendered DART-vs-native CPU-time plot for the Dynamic Friction maximum-friction sweep | Partial | -| `BM_AvbdDemo2dStaticFrictionStep` row | Narrow CPU benchmark for the `avbd-demo2d` Static Friction source-row port | Complete | -| `BM_AvbdDemo2dPyramidStep` row | Narrow CPU benchmark for the `avbd-demo2d` Pyramid source-row port | Complete | -| `BM_AvbdDemo2dCardsStep` row | Narrow CPU benchmark for the `avbd-demo2d` Cards source-row port | Partial | -| `BM_AvbdDemo2dStackStep` row | Narrow CPU benchmark for the `avbd-demo2d` Stack source-row port | Complete | -| `BM_AvbdDemo2dStackRatioStep` row | Narrow CPU benchmark for the `avbd-demo2d` Stack Ratio source-row port | Complete | -| `BM_AvbdDemo2dRodStep` row | Narrow CPU benchmark for the `avbd-demo2d` Rod source-row port | Partial | -| `BM_AvbdDemo2dSoftBodyStep` row | Narrow CPU benchmark for the `avbd-demo2d` Soft Body source-row port | Partial | -| `BM_AvbdDemo2dJointGridStep` row | Narrow CPU benchmark for the `avbd-demo2d` Joint Grid source-row port | Partial | -| `BM_AvbdDemo2dRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Rope source-row port | Partial | -| `BM_AvbdDemo2dHeavyRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Heavy Rope source-row port | Partial | -| `BM_AvbdDemo3dGroundStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Ground source-row port | Complete | -| `BM_AvbdDemo3dDynamicFrictionStep` row | Narrow CPU benchmark for the `avbd-demo3d` Dynamic Friction source-row port | Complete | -| `BM_AvbdDemo3dStaticFrictionStep` row | Narrow CPU benchmark for the `avbd-demo3d` Static Friction source-row port | Complete | -| `BM_AvbdDemo3dPyramidStep` row | Narrow CPU benchmark for the `avbd-demo3d` Pyramid source-row port | Complete | -| `BM_AvbdDemo3dRopeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Rope source-row port | Partial | -| `BM_AvbdDemo3dHeavyRopeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Heavy Rope source-row port | Partial | -| `BM_AvbdDemo3dStackStep` row | Narrow CPU benchmark for the `avbd-demo3d` Stack source-row port | Complete | -| `BM_AvbdDemo3dStackRatioStep` row | Narrow CPU benchmark for the `avbd-demo3d` Stack Ratio source-row port | Complete | -| `BM_AvbdDemo3dSoftBodyStep` row | Narrow CPU benchmark for the `avbd-demo3d` Soft Body source-row port | Partial | -| `BM_AvbdDemo3dBridgeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Bridge source-row port | Partial | -| `BM_AvbdDemo3dBreakableStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Breakable source-row port | Complete | -| `BM_AvbdArticulatedRevoluteMotorStep` row | Narrow CPU benchmark for the public articulated revolute motor path | Partial | -| `BM_AvbdArticulatedPrismaticMotorStep` row | Narrow CPU benchmark for the public articulated prismatic motor path | Partial | -| `BM_AvbdArticulatedPrismaticBreakableMotorStep` row | Narrow CPU benchmark for the public articulated prismatic breakable motor path | Partial | -| `BM_AvbdArticulatedWorldPrismaticBreakableMotorStep` row | Narrow CPU benchmark for the public world-anchored articulated prismatic breakable motor path | Partial | -| `BM_AvbdArticulatedWorldRevoluteBreakableMotorStep` row | Narrow CPU benchmark for the public world-anchored articulated revolute breakable motor path | Partial | -| `BM_AvbdRigidBreakableJointStep` row | Narrow CPU benchmark for the public free-rigid breakable fixed point-joint path | Partial | -| `BM_AvbdArticulatedBreakableJointStep` row | Narrow CPU benchmark for the public articulated breakable fixed point-joint path | Partial | -| `BM_AvbdRigidSphericalBreakableJointStep` row | Narrow CPU benchmark for the public free-rigid breakable spherical point-joint path | Partial | -| `BM_AvbdArticulatedWorldSphericalBreakableJointStep` row | Narrow CPU benchmark for the public world-link articulated spherical breakable point-joint path | Partial | -| `BM_AvbdArticulatedSphericalPairBreakableJointStep` row | Narrow CPU benchmark for the public same-multibody articulated spherical breakable point-joint path | Partial | -| `BM_AvbdEmptyWorldStep` dashboard row | Narrow CPU benchmark for the empty source-demo baseline World | Partial | -| `BM_AvbdDemo2dSpringStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Spring source row using rigid distance springs | Partial | -| `BM_AvbdDemo2dSpringRatioStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Spring Ratio source row | Partial | -| `BM_AvbdDemo3dSpringStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Spring source row using rigid distance springs | Partial | -| `BM_AvbdDemo3dSpringRatioStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Spring Ratio source row | Partial | -| `AvbdRigidBlock.RigidWorldContactSnapshot*` | Private rigid feature identity tests, including local-transform shape inference | Partial | -| `AvbdRigidBlock.PointPairDistanceSpring*` | Private finite-stiffness rigid radial distance-spring primitive and serial row-driver coverage | Partial | -| `World::addRigidBodyDistanceSpring` | First free-rigid radial distance-spring API and contact-stage extraction path | Partial | -| AVBD point-joint finite material stiffness | Public C++/dartpy point-joint controls for AVBD start, linear, and angular stiffness feed finite linear/angular point rows | Partial | +| DART surface | Coverage | Status | +| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | +| `avbd_empty_baseline` py-demo | Empty variational-integrator World smoke with source-row metadata for the 2D/3D empty rows | Partial | +| `avbd-empty-baseline-packet.json` | Visual-capture hash packet and `BM_AvbdEmptyWorldStep` benchmark JSON row for the empty baseline | Complete | +| `avbd_paper_breakable_wall` py-demo | Public AVBD Figure 13 reconstruction with 252 bricks, 712 breakable attachments, three impacts, and a frame-120 outcome oracle | Partial | +| `avbd-paper-breakable-wall-packet.json` | Solver-bound numeric, allocation, assessed visual, pinned-paper, and absolute CPU benchmark evidence for the AVBD wall row | Partial | +| `vbd_paper_breakable_wall` py-demo | Matched public fixed-penalty VBD Figure 13 reconstruction with bend and retained-wall oracles | Partial | +| `avbd-paper-vbd-comparison-packet.json` | Same-fingerprint AVBD/VBD solver identity, numeric, assessed visual, allocation, and descriptive CPU timing evidence | Partial | +| `sequential_impulse_paper_breakable_wall` py-demo | Matched public SI Figure 13 reconstruction with exact impact-local fracture identities and outside-impact retained-row residual/collapse oracles | Partial | +| `avbd-paper-sequential-impulse-comparison-packet.json` | Same-fingerprint SI/VBD/AVBD solver identity, numeric, assessed visual, and descriptive CPU timing evidence | Partial | +| `avbd_demo2d_ground` py-demo | Source `avbd-demo2d` Ground row port with one static slab and no dynamic bodies | Complete | +| `avbd-demo2d-ground-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo2d` Ground row | Complete | +| `avbd_demo2d_motor` py-demo | Source `avbd-demo2d` Motor row port using a pinned rigid bar and public AVBD revolute motor | Partial | +| `avbd-demo2d-motor-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo2d` Motor row | Complete | +| `avbd_demo2d_hanging_rope` py-demo | Source `avbd-demo2d` Hanging Rope row port with 50 rigid bodies and 49 linear-only point joints | Partial | +| `avbd-demo2d-hanging-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Hanging Rope row | Complete | +| `avbd_demo2d_fracture` py-demo | Source `avbd-demo2d` Fracture row port with 29 rigid bodies and 10 breakable fixed joints | Complete | +| `avbd-demo2d-fracture-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Fracture row | Complete | +| `avbd_demo2d_dynamic_friction` py-demo | Source `avbd-demo2d` Dynamic Friction row port with 11 sliding boxes over rigid ground | Complete | +| `avbd-demo2d-dynamic-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Dynamic Friction row | Complete | +| `avbd_demo2d_static_friction` py-demo | Source `avbd-demo2d` Static Friction row port with rotated boxes on an inclined rigid slab | Complete | +| `avbd-demo2d-static-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Static Friction row | Complete | +| `avbd_demo2d_pyramid` py-demo | Source `avbd-demo2d` Pyramid row port with a 210-box triangular rigid pile | Complete | +| `avbd-demo2d-pyramid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Pyramid row | Complete | +| `avbd_demo2d_cards` py-demo | Source `avbd-demo2d` Cards row port with a 40-card thin rigid contact tower | Partial | +| `avbd-demo2d-cards-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Cards row | Complete | +| `avbd_demo2d_stack` py-demo | Source `avbd-demo2d` Stack row port with twenty vertical rigid boxes over ground | Complete | +| `avbd-demo2d-stack-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack row | Complete | +| `avbd_demo2d_stack_ratio` py-demo | Source `avbd-demo2d` Stack Ratio row port with six geometric-size rigid boxes over ground | Complete | +| `avbd-demo2d-stack-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack Ratio row | Complete | +| `avbd_demo2d_rod` py-demo | Source `avbd-demo2d` Rod row port with 20 rigid links and 19 all-axis fixed joints | Partial | +| `avbd-demo2d-rod-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rod row | Complete | +| `avbd_demo2d_soft_body` py-demo | Source `avbd-demo2d` Soft Body row port with two finite-stiffness 15x5 rigid-box lattices | Partial | +| `avbd-demo2d-soft-body-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 2D Soft Body row | Complete | +| `avbd_demo2d_joint_grid` py-demo | Source `avbd-demo2d` Joint Grid row port with 625 boxes and 1200 all-axis fixed joints | Partial | +| `avbd-demo2d-joint-grid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Joint Grid row | Complete | +| `avbd_demo2d_rope` py-demo | Source `avbd-demo2d` Rope row port with 20 rigid links and 19 linear-only point joints | Partial | +| `avbd-demo2d-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rope row | Complete | +| `avbd_demo2d_heavy_rope` py-demo | Source `avbd-demo2d` Heavy Rope row port with a 30 m endpoint block on point joints | Partial | +| `avbd-demo2d-heavy-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 2D Heavy Rope row | Complete | +| `avbd_demo2d_spring` py-demo | Source `avbd-demo2d` Spring row port with one radial rigid distance spring | Partial | +| `avbd-demo2d-spring-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring row | Complete | +| `avbd_demo2d_spring_ratio` py-demo | Source `avbd-demo2d` Spring Ratio row port with seven alternating-stiffness radial springs | Partial | +| `avbd-demo2d-spring-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring Ratio row | Complete | +| `avbd_demo2d_net` py-demo | Source `avbd-demo2d` Net row port with 91 rigid bodies and 39 linear-only point joints | Partial | +| `avbd-demo2d-net-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Net row | Complete | +| `avbd_demo3d_ground` py-demo | Source `avbd-demo3d` Ground row port with a static floor, falling rigid box, and rigid contacts | Complete | +| `avbd-demo3d-ground-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo3d` Ground row | Complete | +| `avbd_demo3d_dynamic_friction` py-demo | Source `avbd-demo3d` Dynamic Friction row port with 11 sliding boxes over rigid ground | Complete | +| `avbd-demo3d-dynamic-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Dynamic Friction row | Complete | +| `avbd_demo3d_static_friction` py-demo | Source `avbd-demo3d` Static Friction row port with boxes sliding on an inclined rigid ramp | Complete | +| `avbd-demo3d-static-friction-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Static Friction row | Complete | +| `avbd_demo3d_pyramid` py-demo | Source `avbd-demo3d` Pyramid row port with a 136-box triangular rigid pile | Complete | +| `avbd-demo3d-pyramid-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Pyramid row | Complete | +| `avbd_demo3d_rope` py-demo | Source `avbd-demo3d` Rope row port with 20 rigid links and 19 linear-only point joints | Partial | +| `avbd-demo3d-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Rope row | Complete | +| `avbd_demo3d_heavy_rope` py-demo | Source `avbd-demo3d` Heavy Rope row port with a 5 m endpoint block on linear-only point joints | Partial | +| `avbd-demo3d-heavy-rope-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Heavy Rope row | Complete | +| `avbd_demo3d_spring` py-demo | Source `avbd-demo3d` Spring row port with one radial rigid distance spring | Partial | +| `avbd-demo3d-spring-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring row | Complete | +| `avbd_demo3d_spring_ratio` py-demo | Source `avbd-demo3d` Spring Ratio row port with seven alternating-stiffness radial springs | Partial | +| `avbd-demo3d-spring-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Spring Ratio row | Complete | +| `avbd_demo3d_stack` py-demo | Source `avbd-demo3d` Stack row port with ten vertical rigid boxes over ground | Complete | +| `avbd-demo3d-stack-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack row | Complete | +| `avbd_demo3d_stack_ratio` py-demo | Source `avbd-demo3d` Stack Ratio row port with four geometric-size rigid boxes over ground | Complete | +| `avbd-demo3d-stack-ratio-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Stack Ratio row | Complete | +| `avbd_demo3d_soft_body` py-demo | Source `avbd-demo3d` Soft Body row port with three finite-stiffness 4x4x4 rigid-box lattices | Partial | +| `avbd-demo3d-soft-body-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the 3D Soft Body row | Complete | +| `avbd_demo3d_bridge` py-demo | Source `avbd-demo3d` Bridge row port with paired plank point joints and load boxes | Partial | +| `avbd-demo3d-bridge-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the Bridge row | Complete | +| `avbd_demo3d_breakable` py-demo | Source `avbd-demo3d` Breakable row port with 19 rigid bodies, 10 breakable joints, and contacts | Complete | +| `avbd-demo3d-breakable-packet.json` | Visual-capture, DART benchmark, and native source timing packet for the `avbd-demo3d` Breakable row | Complete | +| `avbd_rigid_fixed_joint_contact` py-demo | Public free-rigid fixed joint rows plus ordinary rigid contact on a payload | Partial | +| `avbd_rigid_revolute_motor` py-demo | Public free-rigid revolute velocity actuator extracting to AVBD motor rows | Partial | +| `avbd-rigid-revolute-motor-packet.json` | Visual/benchmark packet for the public free-rigid revolute velocity motor row | Partial | +| `avbd_rigid_prismatic_motor` py-demo | Public free-rigid prismatic velocity actuator extracting to AVBD linear motor rows | Partial | +| `avbd-rigid-prismatic-motor-packet.json` | Visual/benchmark packet for the public free-rigid prismatic velocity motor row | Partial | +| `avbd_articulated_revolute_motor` py-demo | Public articulated revolute velocity motor command updates through the bridge | Partial | +| `avbd-articulated-revolute-motor-packet.json` | Visual/benchmark packet for the public articulated revolute velocity motor row | Partial | +| `avbd_articulated_prismatic_motor` py-demo | Public articulated prismatic velocity motor command updates through the bridge | Partial | +| `avbd-articulated-prismatic-motor-packet.json` | Visual/benchmark packet for the public articulated prismatic velocity motor row | Partial | +| `avbd_articulated_motor_breakable_joint` py-demo | Public same-multibody articulated revolute motor break/reset through the bridge | Partial | +| `avbd-articulated-breakable-motor-packet.json` | Visual/benchmark packet for the public same-multibody articulated revolute motor break/reset row | Partial | +| `avbd_articulated_prismatic_pair_motor_breakable_joint` py-demo | Public same-multibody articulated prismatic motor break/reset through the bridge | Partial | +| `avbd-articulated-prismatic-pair-breakable-motor-packet.json` | Visual/benchmark packet for the public same-multibody articulated prismatic motor break/reset row | Partial | +| `avbd_articulated_prismatic_motor_breakable_joint` py-demo | Public world-anchored articulated prismatic motor break/reset through the bridge | Partial | +| `avbd-articulated-world-prismatic-breakable-motor-packet.json` | Visual/benchmark packet for the public world-anchored articulated prismatic motor break/reset row | Partial | +| `avbd_articulated_world_revolute_motor_breakable_joint` py-demo | Public world-anchored articulated revolute motor break/reset through the bridge | Partial | +| `avbd-articulated-world-revolute-breakable-motor-packet.json` | Visual/benchmark packet for the public world-anchored articulated revolute motor break/reset row | Partial | +| `avbd-breakable-motor-scale-packet.json` | Benchmark-only scale packet for four public articulated breakable motor rows over 1/8/32 motors | Partial | +| `avbd_rigid_breakable_joint` py-demo | Public free-rigid break-force lifecycle, broken-state reset/accessors, captured-pose re-engagement, and weak re-arm breakage | Partial | +| `avbd-rigid-breakable-joint-packet.json` | Visual/benchmark packet for the public free-rigid fixed point-joint break/reset row | Partial | +| `avbd_rigid_spherical_breakable_joint` py-demo | Public free-rigid spherical point-joint break/reset and weak re-arm breakage while orientation stays free | Partial | +| `avbd-rigid-spherical-breakable-joint-packet.json` | Visual/benchmark packet for the public free-rigid spherical point-joint break/reset row | Partial | +| `avbd_articulated_breakable_joint` py-demo | Public world-link articulated fixed point-joint break/reset and weak re-arm breakage through the bridge | Partial | +| `avbd-articulated-breakable-joint-packet.json` | Visual/benchmark packet for the public articulated fixed point-joint break/reset row | Partial | +| `avbd_articulated_fixed_pair_breakable_joint` py-demo | Public same-multibody articulated fixed point-joint break/reset and weak re-arm breakage through the bridge | Partial | +| `avbd-articulated-fixed-pair-breakable-joint-packet.json` | Visual/benchmark packet for the public same-multibody articulated fixed point-joint break/reset row | Partial | +| `avbd_articulated_spherical_breakable_joint` py-demo | Public world-link articulated spherical break/reset and weak re-arm breakage while orientation stays free | Partial | +| `avbd-articulated-spherical-breakable-joint-packet.json` | Visual/benchmark packet for the public world-link articulated spherical point-joint break/reset row | Partial | +| `avbd_articulated_spherical_pair_breakable_joint` py-demo | Public same-multibody articulated spherical break/reset and weak re-arm breakage while orientation stays free | Partial | +| `avbd-articulated-spherical-pair-breakable-joint-packet.json` | Visual/benchmark packet for the public same-multibody articulated spherical point-joint break/reset row | Partial | +| `avbd-breakable-joint-scale-packet.json` | Benchmark-only scale packet for five public fixed/spherical breakable point-joint rows over 1/8/32 joints | Partial | +| `avbd_articulated_compliant_joints` py-demo | Public passive finite-stiffness world-link spherical/revolute/prismatic rows with visible constrained and free coordinates | Partial | +| `BM_AvbdArticulatedCompliantJointStep` dashboard row | Candidate-only CPU cost/scaling row over 3/12/48 passive finite articulated point joints | Partial | +| `avbd-articulated-compliant-joints-packet.json` | Mutation-sensitive correctness, allocation, visual, solver-identity, and candidate-only scale evidence for the finite rows | Partial | +| `avbd_articulated_compliant_motors` py-demo | Public bounded finite-stiffness revolute/prismatic motors between same-multibody movable-link pairs | Partial | +| `BM_AvbdArticulatedCompliantMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 bounded finite articulated motors | Partial | +| `avbd-articulated-compliant-motors-packet.json` | Mutation-sensitive motor/pair correctness, allocation, docked visual, solver-identity, and candidate-only scale evidence | Partial | +| `avbd_articulated_compliant_breakable_motor` py-demo | Finite-plus-motor physical-load fracture, strong reset, and weak re-arm lifecycle for one same-multibody prismatic pair | Partial | +| `BM_AvbdArticulatedCompliantBreakableMotorStep` dashboard row | Candidate-only CPU cost/scaling row over 2/8/32 finite articulated motors with armed break thresholds | Partial | +| `avbd-articulated-compliant-fracture-packet.json` | Mutation-sensitive load/lifecycle/serialization, allocation, docked visual, solver-identity, and scale evidence | Partial | +| `avbd_articulated_high_ratio_chain` py-demo | Five-link articulated variational-chain smoke with a 200:1 heavy tip | Partial | +| `BM_AvbdArticulatedHighRatioChainStep` dashboard row | Narrow CPU dashboard row for the five-link 200:1 articulated high-ratio chain smoke | Partial | +| `avbd_paper_scale_high_ratio_chain` py-demo | 50-link articulated variational-chain smoke with a 50,000:1 heavy tip | Partial | +| `BM_AvbdPaperScaleHighRatioChainStep` dashboard row | Paper-scale CPU dashboard row for the 50-link/50,000:1 articulated high-ratio chain smoke | Partial | +| `BM_AvbdPaperScaleHighRatioChainIterationSweep` dashboard row | Paper-scale CPU dashboard sweep over 25/50/100/200 max-iteration budgets for the 50-link/50,000:1 articulated chain | Partial | +| `avbd-articulated-high-ratio-chain-packet.json` | Visual/benchmark packet for the narrow five-link 200:1 high-ratio articulated-chain smoke | Partial | +| `avbd-paper-scale-high-ratio-chain-packet.json` | Visual/benchmark packet for the 50-link/50,000:1 paper-scale articulated high-ratio chain smoke | Partial | +| `avbd-paper-scale-high-ratio-iteration-sweep-packet.json` | Benchmark/stability-plot packet for the 25/50/100/200 max-iteration paper-scale high-ratio chain sweep | Partial | +| `avbd-paper-scale-high-ratio-iteration-sweep-plot.svg` | Rendered timing/stability plot for the 25/50/100/200 max-iteration paper-scale high-ratio chain sweep | Partial | +| `variational_endpoint_loop_closure` py-demo | Public multibody link closure preview, not the private AVBD config extractor | Partial | +| `BM_AvbdRigidRevoluteMotorStep` dashboard row | Narrow CPU benchmark for the public free-rigid revolute motor path | Partial | +| `BM_AvbdRigidPrismaticMotorStep` dashboard row | Narrow CPU benchmark for the public free-rigid prismatic motor path | Partial | +| `BM_AvbdDemo2dGroundStep` row | Narrow CPU benchmark for the static `avbd-demo2d` Ground source-row port | Complete | +| `BM_AvbdDemo2dMotorStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Motor source-row port | Partial | +| `BM_AvbdDemo2dHangingRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Hanging Rope source-row port | Partial | +| `BM_AvbdDemo2dFractureStep` row | Narrow CPU benchmark for the `avbd-demo2d` Fracture source-row port | Complete | +| `BM_AvbdDemo2dDynamicFrictionStep` row | Narrow CPU benchmark for the `avbd-demo2d` Dynamic Friction source-row port | Complete | +| `BM_AvbdDemo2dFrictionCoefficientSweep` row | Benchmark sweep for the Dynamic Friction scene over maximum Coulomb friction 0/0.5/1/2.5/5 | Partial | +| `avbd-friction-coefficient-sweep-packet.json` | Validated benchmark/native-reference/visual-capture packet for the Dynamic Friction maximum-friction sweep | Partial | +| `avbd-friction-coefficient-sweep-plot.svg` | Rendered DART-vs-native CPU-time plot for the Dynamic Friction maximum-friction sweep | Partial | +| `BM_AvbdDemo2dStaticFrictionStep` row | Narrow CPU benchmark for the `avbd-demo2d` Static Friction source-row port | Complete | +| `BM_AvbdDemo2dPyramidStep` row | Narrow CPU benchmark for the `avbd-demo2d` Pyramid source-row port | Complete | +| `BM_AvbdDemo2dCardsStep` row | Narrow CPU benchmark for the `avbd-demo2d` Cards source-row port | Partial | +| `BM_AvbdDemo2dStackStep` row | Narrow CPU benchmark for the `avbd-demo2d` Stack source-row port | Complete | +| `BM_AvbdDemo2dStackRatioStep` row | Narrow CPU benchmark for the `avbd-demo2d` Stack Ratio source-row port | Complete | +| `BM_AvbdDemo2dRodStep` row | Narrow CPU benchmark for the `avbd-demo2d` Rod source-row port | Partial | +| `BM_AvbdDemo2dSoftBodyStep` row | Narrow CPU benchmark for the `avbd-demo2d` Soft Body source-row port | Partial | +| `BM_AvbdDemo2dJointGridStep` row | Narrow CPU benchmark for the `avbd-demo2d` Joint Grid source-row port | Partial | +| `BM_AvbdDemo2dRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Rope source-row port | Partial | +| `BM_AvbdDemo2dHeavyRopeStep` row | Narrow CPU benchmark for the `avbd-demo2d` Heavy Rope source-row port | Partial | +| `BM_AvbdDemo3dGroundStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Ground source-row port | Complete | +| `BM_AvbdDemo3dDynamicFrictionStep` row | Narrow CPU benchmark for the `avbd-demo3d` Dynamic Friction source-row port | Complete | +| `BM_AvbdDemo3dStaticFrictionStep` row | Narrow CPU benchmark for the `avbd-demo3d` Static Friction source-row port | Complete | +| `BM_AvbdDemo3dPyramidStep` row | Narrow CPU benchmark for the `avbd-demo3d` Pyramid source-row port | Complete | +| `BM_AvbdDemo3dRopeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Rope source-row port | Partial | +| `BM_AvbdDemo3dHeavyRopeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Heavy Rope source-row port | Partial | +| `BM_AvbdDemo3dStackStep` row | Narrow CPU benchmark for the `avbd-demo3d` Stack source-row port | Complete | +| `BM_AvbdDemo3dStackRatioStep` row | Narrow CPU benchmark for the `avbd-demo3d` Stack Ratio source-row port | Complete | +| `BM_AvbdDemo3dSoftBodyStep` row | Narrow CPU benchmark for the `avbd-demo3d` Soft Body source-row port | Partial | +| `BM_AvbdDemo3dBridgeStep` row | Narrow CPU benchmark for the `avbd-demo3d` Bridge source-row port | Partial | +| `BM_AvbdDemo3dBreakableStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Breakable source-row port | Complete | +| `BM_AvbdArticulatedRevoluteMotorStep` row | Narrow CPU benchmark for the public articulated revolute motor path | Partial | +| `BM_AvbdArticulatedPrismaticMotorStep` row | Narrow CPU benchmark for the public articulated prismatic motor path | Partial | +| `BM_AvbdArticulatedPrismaticBreakableMotorStep` row | Narrow CPU benchmark for the public articulated prismatic breakable motor path | Partial | +| `BM_AvbdArticulatedWorldPrismaticBreakableMotorStep` row | Narrow CPU benchmark for the public world-anchored articulated prismatic breakable motor path | Partial | +| `BM_AvbdArticulatedWorldRevoluteBreakableMotorStep` row | Narrow CPU benchmark for the public world-anchored articulated revolute breakable motor path | Partial | +| `BM_AvbdRigidBreakableJointStep` row | Narrow CPU benchmark for the public free-rigid breakable fixed point-joint path | Partial | +| `BM_AvbdArticulatedBreakableJointStep` row | Narrow CPU benchmark for the public articulated breakable fixed point-joint path | Partial | +| `BM_AvbdRigidSphericalBreakableJointStep` row | Narrow CPU benchmark for the public free-rigid breakable spherical point-joint path | Partial | +| `BM_AvbdArticulatedWorldSphericalBreakableJointStep` row | Narrow CPU benchmark for the public world-link articulated spherical breakable point-joint path | Partial | +| `BM_AvbdArticulatedSphericalPairBreakableJointStep` row | Narrow CPU benchmark for the public same-multibody articulated spherical breakable point-joint path | Partial | +| `BM_AvbdEmptyWorldStep` dashboard row | Narrow CPU benchmark for the empty source-demo baseline World | Partial | +| `BM_AvbdDemo2dSpringStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Spring source row using rigid distance springs | Partial | +| `BM_AvbdDemo2dSpringRatioStep` dashboard row | Narrow CPU benchmark for the `avbd-demo2d` Spring Ratio source row | Partial | +| `BM_AvbdDemo3dSpringStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Spring source row using rigid distance springs | Partial | +| `BM_AvbdDemo3dSpringRatioStep` dashboard row | Narrow CPU benchmark for the `avbd-demo3d` Spring Ratio source row | Partial | +| `AvbdRigidBlock.RigidWorldContactSnapshot*` | Private rigid feature identity tests, including local-transform shape inference | Partial | +| `AvbdRigidBlock.PointPairDistanceSpring*` | Private finite-stiffness rigid radial distance-spring primitive and serial row-driver coverage | Partial | +| `World::addRigidBodyDistanceSpring` | First free-rigid radial distance-spring API and contact-stage extraction path | Partial | +| AVBD point-joint finite material stiffness | Public C++/dartpy point-joint controls for AVBD start, linear, and angular stiffness feed finite linear/angular point rows | Partial | ## Source Demo Corpus @@ -283,24 +291,24 @@ paper/source-demo corpus: ## Paper, Website, And Video Scenes -| Target | Required DART artifact | Current DART evidence | Status | Next implementation dependency | -| ------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| High-stiffness ratio chains | Correctness test, py-demo, and benchmark sweep | Finite-stiffness ramp rows exist narrowly, and `BM_AvbdPaperScaleHighRatioChainIterationSweep`, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json), and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) now provide a first dashboard-selected 25/50/100/200 max-iteration sweep with finite replay counters and tracked timing/stability plot evidence for the 50-link/50,000:1 chain fixture | Partial | Same-hardware paper-number comparison and GPU parity | -| Flag/pole attachment | Cloth/attachment visual plus invariant | Attachment rows exist narrowly | Partial | Unified cloth AVBD scene | -| Card tower | Visual stability scene and benchmark packet | None | Missing | Broad rigid contact and performance path | -| 50-body pendulum with 50,000:1 mass ratio | Articulated high-ratio test, py-demo, benchmark packet | `avbd_articulated_high_ratio_chain`, `BM_AvbdArticulatedHighRatioChainStep`, and [`avbd-articulated-high-ratio-chain-packet.json`](avbd-articulated-high-ratio-chain-packet.json) provide a narrow five-link 200:1 variational-chain smoke; `avbd_paper_scale_high_ratio_chain`, `PaperScaleHighRatioChainStaysFiniteAndResets`, `BM_AvbdPaperScaleHighRatioChainStep`, and [`avbd-paper-scale-high-ratio-chain-packet.json`](avbd-paper-scale-high-ratio-chain-packet.json) provide 50-link/50,000:1 finite/reset plus visual/CPU benchmark evidence without a paper-number comparison or GPU parity | Partial | Broad articulated hard constraints and high-ratio benchmark | -| Two-heavy-ball chain | Articulated chain visual and invariant | `avbd_articulated_high_ratio_chain` and its packet provide a narrow heavy-tip articulated-chain smoke | Partial | Broad articulated hard constraints | -| Box stack | Rigid stack visual and benchmark packet | Narrow source-row ports now cover Pyramid, Cards, Stack, and Stack Ratio; no paper/site/video box-stack packet exists | Partial | Broad rigid contact candidate generation | -| Friction coefficient comparison | Parameter sweep with visual/JSON output | Narrow friction switching tests exist, and `BM_AvbdDemo2dFrictionCoefficientSweep`, [`avbd-friction-coefficient-sweep-packet.json`](avbd-friction-coefficient-sweep-packet.json), and [`avbd-friction-coefficient-sweep-plot.svg`](avbd-friction-coefficient-sweep-plot.svg) now provide benchmark/plot evidence over maximum Coulomb friction values 0, 0.5, 1, 2.5, and 5 for the source-shaped Dynamic Friction scene | Partial | Source/reference sweep timing, per-coefficient visual capture, and GPU parity | -| Chain mail impact | Cloth/contact impact visual and benchmark packet | None | Missing | Unified soft/rigid AVBD coupling | -| Breakable wall | Fracture visual and benchmark packet | `avbd_paper_breakable_wall`, matched `vbd_paper_breakable_wall`, [`avbd-paper-breakable-wall-packet.json`](avbd-paper-breakable-wall-packet.json), and [`avbd-paper-vbd-comparison-packet.json`](avbd-paper-vbd-comparison-packet.json) provide public AVBD fracture/localization and fixed-penalty VBD bend/retention rows on one 252-brick/712-attachment/three-impact fingerprint, with independent oracles, assessed frames, allocation gates, and descriptive CPU timing | Partial | Exact unpublished source constants, Sequential Impulse/XPBD rows, source-matched four-method edit, broad fracture corpus, achieved-accuracy reference performance, and CUDA | -| Large cloth plus articulated-rigid coupling | Coupled cloth/articulated rigid visual and benchmark packet | None | Missing | Unified soft/rigid coupling and GPU path | -| `beta` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | -| `alpha` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | -| `gamma` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | -| Iteration-count sweep | Benchmark JSON and convergence/stability plot data | `BM_AvbdPaperScaleHighRatioChainIterationSweep` is selected by the dashboard runner over 25/50/100/200 max-iteration budgets for the paper-scale high-ratio chain, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json) records validated benchmark JSON plus finite replay plot data, and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) renders the timing/stability sweep; no paper-number comparison or GPU parity exists yet | Partial | Paper-number comparison and GPU parity | -| Rigid stacking/piling website/video scenes | Visual artifact and benchmark packet | None | Missing | Broad rigid contact plus performance path | -| Large block-pile/sphere-smash GPU scenes | GPU visual artifact and benchmark packet | None | Missing | GPU AVBD row parity and broad rigid contact | +| Target | Required DART artifact | Current DART evidence | Status | Next implementation dependency | +| ------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| High-stiffness ratio chains | Correctness test, py-demo, and benchmark sweep | Finite-stiffness ramp rows exist narrowly, and `BM_AvbdPaperScaleHighRatioChainIterationSweep`, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json), and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) now provide a first dashboard-selected 25/50/100/200 max-iteration sweep with finite replay counters and tracked timing/stability plot evidence for the 50-link/50,000:1 chain fixture | Partial | Same-hardware paper-number comparison and GPU parity | +| Flag/pole attachment | Cloth/attachment visual plus invariant | Attachment rows exist narrowly | Partial | Unified cloth AVBD scene | +| Card tower | Visual stability scene and benchmark packet | None | Missing | Broad rigid contact and performance path | +| 50-body pendulum with 50,000:1 mass ratio | Articulated high-ratio test, py-demo, benchmark packet | `avbd_articulated_high_ratio_chain`, `BM_AvbdArticulatedHighRatioChainStep`, and [`avbd-articulated-high-ratio-chain-packet.json`](avbd-articulated-high-ratio-chain-packet.json) provide a narrow five-link 200:1 variational-chain smoke; `avbd_paper_scale_high_ratio_chain`, `PaperScaleHighRatioChainStaysFiniteAndResets`, `BM_AvbdPaperScaleHighRatioChainStep`, and [`avbd-paper-scale-high-ratio-chain-packet.json`](avbd-paper-scale-high-ratio-chain-packet.json) provide 50-link/50,000:1 finite/reset plus visual/CPU benchmark evidence without a paper-number comparison or GPU parity | Partial | Broad articulated hard constraints and high-ratio benchmark | +| Two-heavy-ball chain | Articulated chain visual and invariant | `avbd_articulated_high_ratio_chain` and its packet provide a narrow heavy-tip articulated-chain smoke | Partial | Broad articulated hard constraints | +| Box stack | Rigid stack visual and benchmark packet | Narrow source-row ports now cover Pyramid, Cards, Stack, and Stack Ratio; no paper/site/video box-stack packet exists | Partial | Broad rigid contact candidate generation | +| Friction coefficient comparison | Parameter sweep with visual/JSON output | Narrow friction switching tests exist, and `BM_AvbdDemo2dFrictionCoefficientSweep`, [`avbd-friction-coefficient-sweep-packet.json`](avbd-friction-coefficient-sweep-packet.json), and [`avbd-friction-coefficient-sweep-plot.svg`](avbd-friction-coefficient-sweep-plot.svg) now provide benchmark/plot evidence over maximum Coulomb friction values 0, 0.5, 1, 2.5, and 5 for the source-shaped Dynamic Friction scene | Partial | Source/reference sweep timing, per-coefficient visual capture, and GPU parity | +| Chain mail impact | Cloth/contact impact visual and benchmark packet | None | Missing | Unified soft/rigid AVBD coupling | +| Breakable wall | Fracture visual and benchmark packet | `avbd_paper_breakable_wall`, matched `vbd_paper_breakable_wall` and `sequential_impulse_paper_breakable_wall`, [`avbd-paper-breakable-wall-packet.json`](avbd-paper-breakable-wall-packet.json), [`avbd-paper-vbd-comparison-packet.json`](avbd-paper-vbd-comparison-packet.json), and [`avbd-paper-sequential-impulse-comparison-packet.json`](avbd-paper-sequential-impulse-comparison-packet.json) provide public AVBD fracture/retention, fixed-penalty VBD bend/retention, and SI initial-fracture/collapse rows on one 252-brick/712-attachment/three-impact fingerprint, with independent oracles, assessed frames, allocation gates where applicable, and descriptive CPU timing | Partial | Exact unpublished source constants, XPBD, source-matched four-method edit, broad fracture corpus, achieved-accuracy reference performance, and CUDA | +| Large cloth plus articulated-rigid coupling | Coupled cloth/articulated rigid visual and benchmark packet | None | Missing | Unified soft/rigid coupling and GPU path | +| `beta` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | +| `alpha` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | +| `gamma` parameter sweep | Benchmark JSON and convergence/stability plot data | None | Missing | Parameter-sweep harness | +| Iteration-count sweep | Benchmark JSON and convergence/stability plot data | `BM_AvbdPaperScaleHighRatioChainIterationSweep` is selected by the dashboard runner over 25/50/100/200 max-iteration budgets for the paper-scale high-ratio chain, [`avbd-paper-scale-high-ratio-iteration-sweep-packet.json`](avbd-paper-scale-high-ratio-iteration-sweep-packet.json) records validated benchmark JSON plus finite replay plot data, and [`avbd-paper-scale-high-ratio-iteration-sweep-plot.svg`](avbd-paper-scale-high-ratio-iteration-sweep-plot.svg) renders the timing/stability sweep; no paper-number comparison or GPU parity exists yet | Partial | Paper-number comparison and GPU parity | +| Rigid stacking/piling website/video scenes | Visual artifact and benchmark packet | None | Missing | Broad rigid contact plus performance path | +| Large block-pile/sphere-smash GPU scenes | GPU visual artifact and benchmark packet | None | Missing | GPU AVBD row parity and broad rigid contact | ## Performance Packets diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json index 320a2195626d4..84c90d560a4a9 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json @@ -2,6 +2,8 @@ "benchmark": { "benchmark": "BM_AvbdPaperBreakableWallStep", "context": { + "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", + "capture_source_provenance_digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12", "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", "host_name": "mark26", "json_schema_version": 1, @@ -10,18 +12,18 @@ "mhz_per_cpu": 5300, "num_cpus": 32 }, - "json_sha256": "d5aa41fe4ae4c2bbcfe23b12c87b031d4ec839123e5e104cbdfd3401a52ccbb9", + "json_sha256": "c2c81c26ca0fa96d1cf11da4c5a166a3a2eb7ead9b80aafac207c20f03cf7c67", "rows": [ { "aggregate_name": "mean", "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 7434849.3500000015, + "cpu_time": 8598657.814999996, "impacting_balls": 3.0, "iterations": 5, "public_avbd_family": 1.0, - "real_time": 7436581.405102817, + "real_time": 8599444.653373212, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -33,8 +35,8 @@ "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "runtime_contract_passed": 1.0, - "scene_spec_fingerprint_hi": 712271905.0, - "scene_spec_fingerprint_lo": 3423664878.0, + "scene_spec_fingerprint_hi": 2359557114.0, + "scene_spec_fingerprint_lo": 12836073.0, "time_unit": "ns", "trajectory_frames": 120.0 }, @@ -43,11 +45,11 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 7467247.275000003, + "cpu_time": 8612602.108333336, "impacting_balls": 3.0, "iterations": 5, "public_avbd_family": 1.0, - "real_time": 7467605.933440305, + "real_time": 8614304.249931592, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -59,17 +61,17 @@ "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "runtime_contract_passed": 1.0, - "scene_spec_fingerprint_hi": 712271905.0, - "scene_spec_fingerprint_lo": 3423664878.0, + "scene_spec_fingerprint_hi": 2359557114.0, + "scene_spec_fingerprint_lo": 12836073.0, "time_unit": "ns", "trajectory_frames": 120.0 }, { "aggregate_name": "stddev", "aggregate_unit": "time", - "cpu_time": 352941.77407079434, + "cpu_time": 175085.7497607501, "iterations": 5, - "real_time": 353435.6344223652, + "real_time": 175125.93686717955, "repetitions": 5, "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", @@ -78,26 +80,30 @@ { "aggregate_name": "cv", "aggregate_unit": "percentage", - "cpu_time": 0.047471274461101796, + "cpu_time": 0.020361985966614506, "iterations": 5, - "real_time": 0.04752662751460576, + "real_time": 0.02036479609162724, "repetitions": 5, "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "time_unit": "ns" } ], - "scene_spec_fingerprint": "2a746821cc10faee", + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", + "source_provenance": { + "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", + "capture_source_provenance_digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12" + }, "stability": { - "cpu_time_cv_fraction": 0.047471274461101796, - "real_time_cv_fraction": 0.04752662751460576, + "cpu_time_cv_fraction": 0.020361985966614506, + "real_time_cv_fraction": 0.02036479609162724, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 7434849.3500000015, - "mean_real_time_per_step_ns": 7436581.405102817, - "median_cpu_time_per_step_ns": 7467247.275000003, - "median_real_time_per_step_ns": 7467605.933440305 + "mean_cpu_time_per_step_ns": 8598657.814999996, + "mean_real_time_per_step_ns": 8599444.653373212, + "median_cpu_time_per_step_ns": 8612602.108333336, + "median_real_time_per_step_ns": 8614304.249931592 } }, "claim_boundary": "This packet covers the AVBD row of the publication-shaped Figure 13 breakable-wall outcome on the public CPU solver family. It does not claim exact source-scene replay, reproduce the Sequential Impulse, XPBD, and VBD comparison rows, or establish public CUDA AVBD parity.", @@ -115,7 +121,7 @@ "rigid_constraint_options": { "iterations": 20 }, - "scene_spec_fingerprint": "2a746821cc10faee", + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "time_step": 0.016666666666666666 } }, @@ -142,11 +148,11 @@ "time_step": 0.016666666666666666 }, "reconstructed_because_unpublished": { - "ball_mass": 100.0, + "ball_mass": 40.0, "ball_radius": 0.48, "ball_speed": 24.0, - "break_force": 8500.0, - "brick_density": 4.0, + "break_force": 5000.0, + "brick_density": 200.0, "brick_size": [ 0.6, 0.3, @@ -158,7 +164,7 @@ 1.55 ], [ - 0.0, + -0.31, 1.75 ], [ @@ -174,14 +180,14 @@ } }, "reproduction": { - "benchmark_command": "pixi run bm -- bm_avbd_rigid_fixed_joint -- --benchmark_filter='^BM_AvbdPaperBreakableWallStep/iterations:120$' --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out= --benchmark_out_format=json", + "benchmark_command": "capture_source_digest=$(pixi run python scripts/capture_source_provenance.py --digest-only) && benchmark_source_digest=$(sha256sum tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp | cut -d' ' -f1) && pixi run bm -- bm_avbd_rigid_fixed_joint -- --benchmark_filter='^BM_AvbdPaperBreakableWallStep/iterations:120$' --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_context=capture_source_provenance_digest=$capture_source_digest --benchmark_context=benchmark_source_sha256=$benchmark_source_digest --benchmark_out= --benchmark_out_format=json", "capture_commands": [ - "pixi run py-demo-capture -- --scene avbd_paper_breakable_wall --frames 60 --width 1280 --height 720 --view front --camera-distance 11 --camera-target 0,0,1.4 --capture-label impact --output-dir ", - "pixi run py-demo-capture -- --scene avbd_paper_breakable_wall --frames 120 --width 1280 --height 720 --view front --camera-distance 11 --camera-target 0,0,1.4 --capture-label outcome --output-dir " + "pixi run py-demo-capture -- --scene avbd_paper_breakable_wall --frames 60 --width 1280 --height 720 --view front --camera-azimuth -112.5 --camera-elevation 35.52338329811104 --camera-distance 22 --camera-target 0,0.45,1.6 --capture-label impact --output-dir ", + "pixi run py-demo-capture -- --scene avbd_paper_breakable_wall --frames 120 --width 1280 --height 720 --view front --camera-azimuth -112.5 --camera-elevation 35.52338329811104 --camera-distance 22 --camera-target 0,0.45,1.6 --capture-label outcome --output-dir " ], "image_verdict_commands": [ - "pixi run image-verdict -- /avbd_paper_breakable_wall_impact.png --meta scene=avbd_paper_breakable_wall --meta frame=60 --meta view=front --out /image_verdict.json", - "pixi run image-verdict -- /avbd_paper_breakable_wall_outcome.png --meta scene=avbd_paper_breakable_wall --meta frame=120 --meta view=front --out /image_verdict.json" + "pixi run image-verdict -- /avbd_paper_breakable_wall_impact.png --meta scene=avbd_paper_breakable_wall --meta frame=60 --meta view=front-oblique --out /image_verdict.json", + "pixi run image-verdict -- /avbd_paper_breakable_wall_outcome.png --meta scene=avbd_paper_breakable_wall --meta frame=120 --meta view=front-oblique --out /image_verdict.json" ] }, "resolved_solver_identity": { @@ -192,10 +198,10 @@ "rigid_point_joint_solver": "avbd" }, "scene": "avbd_paper_breakable_wall", - "schema_version": 3, + "schema_version": 4, "source_provenance": { "algorithm": "sha256-length-prefixed-path-and-content-v1", - "digest": "ab55ac1c20fdcd75e0d29bced2f62b37fb8b9f56ae68dffb64ec70ac3eff38ab", + "digest": "c74dfeb782dbd1d51d9648e699d8afcb5f6cb01838b3c19cf7861b0c4cbdb53d", "files": [ { "path": "dart/gui/view_quality.cpp", @@ -207,7 +213,7 @@ }, { "path": "dart/simulation/world.cpp", - "sha256": "df7b4d1dfb4140190a43e65f0dc6d46e8121b489bda796f65afb77dac190bf50" + "sha256": "23502cd1a7366c83afe8f7c9d7b7d0a0ebeaf32d27d75a35f65b57ccd9d0e09c" }, { "path": "dart/simulation/world.hpp", @@ -215,11 +221,23 @@ }, { "path": "dart/simulation/world_options.hpp", - "sha256": "6306b5aa84cc523d8edde96385cbba5a6b461e26f4eb8fdba9e659d704756b88" + "sha256": "bcc8e950d73534a2d5dc953431939b2fa776235d188fb685bf65589b4d556b34" + }, + { + "path": "dart/simulation/comps/joint.hpp", + "sha256": "dbbab333dfeb086f280675a31badc7756bc74606100e72081a6c6140d4b61617" + }, + { + "path": "dart/simulation/compute/detail/world_step_stages.hpp", + "sha256": "e03ffa5b25dc6d96f088936f8838a9f1be6c658d34d08589347564436862ce05" }, { "path": "dart/simulation/compute/rigid_body_contact_stage.cpp", - "sha256": "125a7d11510749db22d28d3b9dfe08888aeb31b8711238e4ff81c90dca5559ff" + "sha256": "755238810875628ce13315b113ac2d766992d0c305783c26d194bbd84fd0b89c" + }, + { + "path": "dart/simulation/detail/rigid_pair_constraint.hpp", + "sha256": "735a62ae1b673ea68d7d26002cc0b72412f239216718e49ffab38b42b40cfffe" }, { "path": "dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp", @@ -227,7 +245,11 @@ }, { "path": "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp", - "sha256": "1184d6f71fd9705bd03760cefdf6e471f3c0ff0d73c4886f7014ff8812ae0ced" + "sha256": "79b20d53e40bddce50ee3158f76fdd902aea5ea3d165dd75d4862b6e537b7c22" + }, + { + "path": "dart/simulation/detail/world_step_schedule.hpp", + "sha256": "547ba1920a79a2e860fafb1fe59bba644a01ddb1caff0d50ef8440f2774aa386" }, { "path": "python/dartpy/_view_quality.py", @@ -235,19 +257,35 @@ }, { "path": "python/examples/demos/scenes/avbd_paper_breakable_wall.py", - "sha256": "bb418854c79e6a2f5ca3d01059c9ce94ae0f135b9acecb46e2a46d34bfd5ebac" + "sha256": "40675bcd66d2ae543dc1bd60a435926dd6969893eb0a92aa451fe66f9b8bda12" + }, + { + "path": "python/tests/integration/test_demos_cycle.py", + "sha256": "dd33a2c2190b6b13c7c8a408863fa20dbfc24174fd64587ee9c2ed88bb23c5ba" }, { "path": "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", - "sha256": "24e5a9658898806ab9cfa0d765076884630c8cb100a52cf168d53e91a4ade01f" + "sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370" + }, + { + "path": "tests/unit/simulation/world/test_world.cpp", + "sha256": "59f5d6d12b3b6ce021279b254807623cff56432107906cfda56204145d81a507" }, { "path": "scripts/avbd_packet_schema.py", - "sha256": "f3bb031ca321abea4a49d8fc1392f33dfffca482c6797618f94f680f3543c04b" + "sha256": "d2cf699fd9f19fe97fa992977f56c00a43745a0659bda4deb8e10e6fe91e021c" + }, + { + "path": "scripts/capture_py_demo.py", + "sha256": "669e72ccf230a0c89f5d1505e648b7e4c7f189b6f79738790be9c4fe101a95ee" + }, + { + "path": "scripts/capture_source_provenance.py", + "sha256": "f81a59a95a97a3efb5901d1eb6507dd1272f75cd2a4320db6d863cb48186b612" }, { "path": "scripts/write_avbd_paper_breakable_wall_packet.py", - "sha256": "4d2b0938a2bc98f7ac2740bb20ee5b02721039d0c388997e4e090ff2dc50220e" + "sha256": "fa4704b68dffeb11b0357d91642c4ca2917cb22b1d182f21d6353cb1c6db7aaa" } ] }, @@ -263,13 +301,16 @@ "visual_evidence": { "impact": { "camera": { - "distance": 11.0, + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, + "preset": "front", "target": [ 0.0, - 0.0, - 1.4 + 0.45, + 1.6 ], - "view": "front" + "view": "front-oblique" }, "capture": { "height": 720, @@ -279,11 +320,11 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 8938, - "dark_pixels": 39301, - "mid_pixels": 159216, - "p05": 41.231, - "p95": 183.438, + "bright_pixels": 1541, + "dark_pixels": 12069, + "mid_pixels": 208629, + "p05": 54.018, + "p95": 151.511, "pass": true, "reasons": [], "region": { @@ -293,7 +334,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 142.208, + "spread": 97.492, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -318,15 +359,15 @@ "metadata": { "frame": "60", "scene": "avbd_paper_breakable_wall", - "view": "front" + "view": "front-oblique" }, "pass": true, - "sha256": "5c3f79a686fe64ab03f100b54bf8797efda665d06a9e3a92c7fb05bd3d7f0b5a" + "sha256": "cc5daf9f72befce2554bbbc63a18d8dd58e212a10b8f74b3560a5496adc53dc0" }, "label": "impact", "manifest": { "file": "manifest.json", - "sha256": "a8c962a3ca5abcc8380339f12d51a72dee6e74dfac4f39ba13f1cd6cbff9b62a" + "sha256": "29bec091ee0fedc245a9349ead665b4e1634880fe72a4279ddf408a1098f05d8" }, "scene_metrics": { "event_count": 60, @@ -334,76 +375,3963 @@ "outcome": { "ball_positions": [ [ - -3.245476738059697, - -8.316470486067592, - 0.18572547975873185 + -3.1012133262386232, + -2.1025922366410152, + 0.4721622356638989 ], [ - 0.25991338471716524, - -7.831053875324325, - 0.20018292957178857 + -0.2631930019827167, + -11.107828780298306, + -0.3271939566005798 ], [ - 3.1293491912508444, - -8.592539704372337, - 0.16392762547215264 + 3.139065020488925, + -1.5520749891132208, + 0.445359799488415 ] ], "ball_velocities": [ [ - 0.3636271631581067, - -12.359977834589131, - -0.8863237577953503 + -0.0012477159325996467, + -2.7337074661684735, + 0.054784759350337 ], [ - 0.3945062146016032, - -11.042056294088507, - -0.022211005673976048 + 0.060298102544033005, + -15.565578936693036, + -3.842936119449672 ], [ - 0.0858339357374227, - -13.650571792657864, - -1.672605541210307 + 0.04709308519039723, + -1.6283170655687673, + -2.178620755983311 ] ], - "broken_joints": 359, - "contact_count": 70, + "bent_brick_count": 193, + "broken_joint_identity_count": 154, + "broken_joint_ids_sha256": "31b187538ac1549563be368a4d7e304d1caef6ea11ba65b624d48f5f27468503", + "broken_joint_impact_region_counts": [ + 18, + 44, + 13 + ], + "broken_joint_records": [ + { + "angular_residual_radians": 9.008270450772535e-05, + "child": { + "body": "brick", + "column": 7, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_06", + "initial_anchor": [ + -1.8599999999999999, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.00013506686156543045, + "nearest_impact_distance": 1.8739330297531982, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 6.510064080520916e-05, + "child": { + "body": "brick", + "column": 8, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_07", + "initial_anchor": [ + -1.24, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 6.006186009135834e-05, + "nearest_impact_distance": 1.85497304562627, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.01102544834992277, + "child": { + "body": "brick", + "column": 9, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_08", + "initial_anchor": [ + -0.62, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.18973618678198773, + "nearest_impact_distance": 1.6346635739503097, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04598206505925256, + "child": { + "body": "brick", + "column": 10, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_09", + "initial_anchor": [ + 0.0, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.005170540082269753, + "nearest_impact_distance": 1.6346635739503097, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0310243964600298, + "child": { + "body": "brick", + "column": 11, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_10", + "initial_anchor": [ + 0.62, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.1354760270718724, + "nearest_impact_distance": 1.85497304562627, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00010697895963601206, + "child": { + "body": "brick", + "column": 12, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_11", + "initial_anchor": [ + 1.24, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.00013539061614908297, + "nearest_impact_distance": 2.231260854315335, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 6.106228955031032e-05, + "child": { + "body": "brick", + "column": 13, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_12", + "initial_anchor": [ + 1.8599999999999999, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.00016285911070091646, + "nearest_impact_distance": 2.5297480111663297, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 6.283408529496536e-05, + "child": { + "body": "brick", + "column": 6, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_05", + "initial_anchor": [ + -2.17, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 5.2468008151842855e-05, + "nearest_impact_distance": 1.467353059082919, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 3.875081277607625e-05, + "child": { + "body": "brick", + "column": 7, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_06", + "initial_anchor": [ + -1.55, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.0003796977106043445, + "nearest_impact_distance": 1.8220386933322794, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 6, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 4.7975093225973e-05, + "child": { + "body": "brick", + "column": 8, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_07", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 2.2626977578183788e-05, + "nearest_impact_distance": 1.4719459908570014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0247032956461123, + "child": { + "body": "brick", + "column": 9, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_08", + "initial_anchor": [ + -0.31, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.07960853126676115, + "nearest_impact_distance": 1.335, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.08038028044239365, + "child": { + "body": "brick", + "column": 10, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_09", + "initial_anchor": [ + 0.31, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.0239371617882439, + "nearest_impact_distance": 1.4719459908570014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00020530256017475026, + "child": { + "body": "brick", + "column": 11, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_10", + "initial_anchor": [ + 0.9299999999999999, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.00041025862749632876, + "nearest_impact_distance": 1.8220386933322794, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 3.786334919570361e-05, + "child": { + "body": "brick", + "column": 12, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_11", + "initial_anchor": [ + 1.55, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 8.653444253069326e-05, + "nearest_impact_distance": 2.2895032212250763, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 4.1857327315410435e-05, + "child": { + "body": "brick", + "column": 13, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_12", + "initial_anchor": [ + 2.17, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.00012409985395642694, + "nearest_impact_distance": 2.146887281624259, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 6.743519799852505e-05, + "child": { + "body": "brick", + "column": 6, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_05", + "initial_anchor": [ + -2.48, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.00010117485115157575, + "nearest_impact_distance": 1.0642485611923562, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 7.353385917530804e-05, + "child": { + "body": "brick", + "column": 7, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_06", + "initial_anchor": [ + -1.8599999999999999, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.0007063759132234673, + "nearest_impact_distance": 1.5118945069018541, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.2134816221894333e-05, + "child": { + "body": "brick", + "column": 8, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_07", + "initial_anchor": [ + -1.24, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 8.603966517165427e-05, + "nearest_impact_distance": 1.4139041693127579, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 4.7063727120990364e-05, + "child": { + "body": "brick", + "column": 9, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_08", + "initial_anchor": [ + -0.62, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.0002683513956401836, + "nearest_impact_distance": 1.1092001622791081, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07262146089389412, + "child": { + "body": "brick", + "column": 10, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_09", + "initial_anchor": [ + 0.0, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.03938049131554624, + "nearest_impact_distance": 1.1092001622791081, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0003244258229772193, + "child": { + "body": "brick", + "column": 11, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_10", + "initial_anchor": [ + 0.62, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.000878951173741062, + "nearest_impact_distance": 1.4139041693127579, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 5.569298182354712e-05, + "child": { + "body": "brick", + "column": 12, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_11", + "initial_anchor": [ + 1.24, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 8.764717096801269e-05, + "nearest_impact_distance": 1.8806182494062957, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 5.143300219161312e-05, + "child": { + "body": "brick", + "column": 13, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_12", + "initial_anchor": [ + 1.8599999999999999, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 9.010960826926186e-05, + "nearest_impact_distance": 2.0760118015078817, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0001285384599346121, + "child": { + "body": "brick", + "column": 5, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_04", + "initial_anchor": [ + -2.79, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 0.0006287781896776788, + "nearest_impact_distance": 0.6709135562797938, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.02414982388840903, + "child": { + "body": "brick", + "column": 9, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_08", + "initial_anchor": [ + -0.31, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 0.028182542973039376, + "nearest_impact_distance": 0.7949999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0490076295117099, + "child": { + "body": "brick", + "column": 10, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_09", + "initial_anchor": [ + 0.31, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 0.048819779936561634, + "nearest_impact_distance": 1.0081790515578073, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 3 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 2.9193196483077777e-05, + "child": { + "body": "brick", + "column": 11, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_10", + "initial_anchor": [ + 0.9299999999999999, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 8.624632397346434e-05, + "nearest_impact_distance": 1.472964697472414, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 3 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 3.587177800032414e-05, + "child": { + "body": "brick", + "column": 12, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_11", + "initial_anchor": [ + 1.55, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 1.8526694973751994e-05, + "nearest_impact_distance": 2.0227765571115364, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 3 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 3.088497794175149e-05, + "child": { + "body": "brick", + "column": 13, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_12", + "initial_anchor": [ + 2.17, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 7.152944803632844e-05, + "nearest_impact_distance": 1.6765813430907552, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 3 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00015998461697143825, + "child": { + "body": "brick", + "column": 5, + "row": 4 + }, + "id": "avbd_paper_wall_horizontal_04_04", + "initial_anchor": [ + -3.1, + 0.0, + 1.225 + ], + "kind": "horizontal", + "linear_residual": 0.0007394754409834038, + "nearest_impact_distance": 0.32499999999999996, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 9.780059049891404e-05, + "child": { + "body": "brick", + "column": 6, + "row": 4 + }, + "id": "avbd_paper_wall_horizontal_04_05", + "initial_anchor": [ + -2.48, + 0.0, + 1.225 + ], + "kind": "horizontal", + "linear_residual": 0.00031079477888061694, + "nearest_impact_distance": 0.7000178569150933, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07263623201566669, + "child": { + "body": "brick", + "column": 10, + "row": 4 + }, + "id": "avbd_paper_wall_horizontal_04_09", + "initial_anchor": [ + 0.0, + 0.0, + 1.225 + ], + "kind": "horizontal", + "linear_residual": 0.03870892811353124, + "nearest_impact_distance": 0.6096925454686156, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 8.25041918986283e-05, + "child": { + "body": "brick", + "column": 4, + "row": 5 + }, + "id": "avbd_paper_wall_horizontal_05_03", + "initial_anchor": [ + -3.41, + 0.0, + 1.495 + ], + "kind": "horizontal", + "linear_residual": 0.0004197401898301414, + "nearest_impact_distance": 0.31484122982862334, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 3, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 7.395002676843497e-05, + "child": { + "body": "brick", + "column": 6, + "row": 5 + }, + "id": "avbd_paper_wall_horizontal_05_05", + "initial_anchor": [ + -2.17, + 0.0, + 1.495 + ], + "kind": "horizontal", + "linear_residual": 0.0002437682713769076, + "nearest_impact_distance": 0.9316249245270332, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 5 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.6634483959101813, + "child": { + "body": "brick", + "column": 9, + "row": 5 + }, + "id": "avbd_paper_wall_horizontal_05_08", + "initial_anchor": [ + -0.31, + 0.0, + 1.495 + ], + "kind": "horizontal", + "linear_residual": 2.645498925521424, + "nearest_impact_distance": 0.2549999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.6672896734903764, + "child": { + "body": "brick", + "column": 10, + "row": 5 + }, + "id": "avbd_paper_wall_horizontal_05_09", + "initial_anchor": [ + 0.31, + 0.0, + 1.495 + ], + "kind": "horizontal", + "linear_residual": 2.580374011945656, + "nearest_impact_distance": 0.6703916765593081, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0726535345824024, + "child": { + "body": "brick", + "column": 10, + "row": 6 + }, + "id": "avbd_paper_wall_horizontal_06_09", + "initial_anchor": [ + 0.0, + 0.0, + 1.7650000000000001 + ], + "kind": "horizontal", + "linear_residual": 0.03800142559023461, + "nearest_impact_distance": 0.3103626910567699, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 3.922629290230062e-05, + "child": { + "body": "brick", + "column": 15, + "row": 6 + }, + "id": "avbd_paper_wall_horizontal_06_14", + "initial_anchor": [ + 3.1, + 0.0, + 1.7650000000000001 + ], + "kind": "horizontal", + "linear_residual": 6.497989096378679e-05, + "nearest_impact_distance": 0.585, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 14, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 4.822129960701657e-05, + "child": { + "body": "brick", + "column": 8, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_07", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 4.72754923027254e-05, + "nearest_impact_distance": 0.682367203197809, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.015976219012942335, + "child": { + "body": "brick", + "column": 9, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_08", + "initial_anchor": [ + -0.31, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 0.030633770930807908, + "nearest_impact_distance": 0.28500000000000014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07022418660219892, + "child": { + "body": "brick", + "column": 10, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_09", + "initial_anchor": [ + 0.31, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 0.07460618392160766, + "nearest_impact_distance": 0.6823672031978091, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 2.741850102568932e-05, + "child": { + "body": "brick", + "column": 11, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_10", + "initial_anchor": [ + 0.9299999999999999, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 3.100637740178804e-05, + "nearest_impact_distance": 1.2723305388145016, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 7 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 2.4268260044692098e-05, + "child": { + "body": "brick", + "column": 12, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_11", + "initial_anchor": [ + 1.55, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 2.294249983051726e-05, + "nearest_impact_distance": 1.5816842289154938, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 11, + "row": 7 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 4.6974955258618465e-05, + "child": { + "body": "brick", + "column": 14, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_13", + "initial_anchor": [ + 2.79, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 0.00011976863480239783, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 13, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 3.106625877849102e-05, + "child": { + "body": "brick", + "column": 15, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_14", + "initial_anchor": [ + 3.41, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 5.1666432925296655e-05, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 14, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 5.4460097238652206e-05, + "child": { + "body": "brick", + "column": 16, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_15", + "initial_anchor": [ + 4.03, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 0.0001287192889278576, + "nearest_impact_distance": 0.9818986709431887, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 7 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 4.42681423582625e-05, + "child": { + "body": "brick", + "column": 8, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_07", + "initial_anchor": [ + -1.24, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 2.1608921342319353e-05, + "nearest_impact_distance": 1.0830166203710818, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 8 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 4.128807118272723e-05, + "child": { + "body": "brick", + "column": 9, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_08", + "initial_anchor": [ + -0.62, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 0.0001651215370275108, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07271534517095131, + "child": { + "body": "brick", + "column": 10, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_09", + "initial_anchor": [ + 0.0, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 0.03742658416459293, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00015169306579249173, + "child": { + "body": "brick", + "column": 11, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_10", + "initial_anchor": [ + 0.62, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 0.000610271873351864, + "nearest_impact_distance": 1.0830166203710818, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 8 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 3.854175477080523e-05, + "child": { + "body": "brick", + "column": 12, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_11", + "initial_anchor": [ + 1.24, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 4.071159300251569e-05, + "nearest_impact_distance": 1.646367212987431, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 8 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 2.466499149416702e-05, + "child": { + "body": "brick", + "column": 13, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_12", + "initial_anchor": [ + 1.8599999999999999, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 2.7510418220036885e-05, + "nearest_impact_distance": 1.2408162635942521, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 8 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 4.313031387721661e-05, + "child": { + "body": "brick", + "column": 15, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_14", + "initial_anchor": [ + 3.1, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 8.211412638646673e-05, + "nearest_impact_distance": 0.04499999999999993, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 14, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 5.3098790360922514e-05, + "child": { + "body": "brick", + "column": 7, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_06", + "initial_anchor": [ + -1.55, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 4.537946580685748e-05, + "nearest_impact_distance": 1.4893706724653875, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 6, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 2.8335732486594476e-05, + "child": { + "body": "brick", + "column": 8, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_07", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 6.497958067738822e-05, + "nearest_impact_distance": 1.0320004844960105, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.015906349114872403, + "child": { + "body": "brick", + "column": 9, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_08", + "initial_anchor": [ + -0.31, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 0.035886159941097576, + "nearest_impact_distance": 0.8250000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 9 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0589633049842094, + "child": { + "body": "brick", + "column": 10, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_09", + "initial_anchor": [ + 0.31, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 0.08344579622826602, + "nearest_impact_distance": 1.0320004844960105, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00010973539675590143, + "child": { + "body": "brick", + "column": 11, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_10", + "initial_anchor": [ + 0.9299999999999999, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 0.00046982398419106417, + "nearest_impact_distance": 1.4893706724653875, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 3.8112090321123875e-05, + "child": { + "body": "brick", + "column": 12, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_11", + "initial_anchor": [ + 1.55, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 3.0719260210775834e-05, + "nearest_impact_distance": 1.5662455107677087, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 11, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.9595882493501508e-05, + "child": { + "body": "brick", + "column": 13, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_12", + "initial_anchor": [ + 2.17, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 2.9491822655254355e-05, + "nearest_impact_distance": 0.9568307060290239, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 4.189793705242109e-05, + "child": { + "body": "brick", + "column": 15, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_14", + "initial_anchor": [ + 3.41, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 5.321710790461711e-05, + "nearest_impact_distance": 0.3830469945058962, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 14, + "row": 9 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 5.603964783428274e-05, + "child": { + "body": "brick", + "column": 7, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_06", + "initial_anchor": [ + -1.8599999999999999, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 5.25049217483827e-05, + "nearest_impact_distance": 1.7929375337696516, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 4.926086801063021e-05, + "child": { + "body": "brick", + "column": 8, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_07", + "initial_anchor": [ + -1.24, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 6.912182607612475e-05, + "nearest_impact_distance": 1.436636697289889, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.015935264781480146, + "child": { + "body": "brick", + "column": 9, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_08", + "initial_anchor": [ + -0.62, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 0.04176230901628236, + "nearest_impact_distance": 1.1380355881957298, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.052439580924800236, + "child": { + "body": "brick", + "column": 10, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_09", + "initial_anchor": [ + 0.0, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 0.02610770110318816, + "nearest_impact_distance": 1.1380355881957298, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.050117428722611726, + "child": { + "body": "brick", + "column": 11, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_10", + "initial_anchor": [ + 0.62, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 0.05668900494441234, + "nearest_impact_distance": 1.436636697289889, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 6.637884118559688e-05, + "child": { + "body": "brick", + "column": 12, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_11", + "initial_anchor": [ + 1.24, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 0.0002837055059475099, + "nearest_impact_distance": 1.897768426336575, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.999728059644764e-05, + "child": { + "body": "brick", + "column": 13, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_12", + "initial_anchor": [ + 1.8599999999999999, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 5.600693662387255e-05, + "nearest_impact_distance": 1.3351498043290875, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.9815351024898052e-05, + "child": { + "body": "brick", + "column": 14, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_13", + "initial_anchor": [ + 2.48, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 1.7965441600830954e-05, + "nearest_impact_distance": 0.7933630946798573, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 13, + "row": 10 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 7.184156886055738e-05, + "child": { + "body": "brick", + "column": 7, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_06", + "initial_anchor": [ + -1.55, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.00010100219485806207, + "nearest_impact_distance": 1.8441325874242342, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 6, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.06321041995832818, + "child": { + "body": "brick", + "column": 8, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_07", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.02095706488970011, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.1710540477476002, + "child": { + "body": "brick", + "column": 9, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_08", + "initial_anchor": [ + -0.31, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.11402981753881476, + "nearest_impact_distance": 1.3650000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.14553901466788452, + "child": { + "body": "brick", + "column": 10, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_09", + "initial_anchor": [ + 0.31, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.12468088925864777, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.09996299682655989, + "child": { + "body": "brick", + "column": 11, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_10", + "initial_anchor": [ + 0.9299999999999999, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.12385838040191634, + "nearest_impact_distance": 1.8441325874242342, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 4.478370898917277e-05, + "child": { + "body": "brick", + "column": 12, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_11", + "initial_anchor": [ + 1.55, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.0001858561156024408, + "nearest_impact_distance": 1.728503688164998, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 11, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 2.1157738942319417e-05, + "child": { + "body": "brick", + "column": 13, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_12", + "initial_anchor": [ + 2.17, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 4.489461526100755e-05, + "nearest_impact_distance": 1.2042113601855782, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 9.2457575809838e-05, + "child": { + "body": "brick", + "column": 0, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_00_01_00", + "initial_anchor": [ + -5.89, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.00041296793205710655, + "nearest_impact_distance": 3.012030046330879, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 0, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 8.985060410473774e-05, + "child": { + "body": "brick", + "column": 0, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_01_01_00", + "initial_anchor": [ + -5.89, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.0004131305426413244, + "nearest_impact_distance": 3.012030046330879, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 1, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.01101273871068856, + "child": { + "body": "brick", + "column": 8, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_09_01_08", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.18708131640852269, + "nearest_impact_distance": 1.4719459908570014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.015325779641057122, + "child": { + "body": "brick", + "column": 9, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_09_01_09", + "initial_anchor": [ + -0.31, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.11178082358382412, + "nearest_impact_distance": 1.335, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.05553162084326819, + "child": { + "body": "brick", + "column": 9, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_10_01_09", + "initial_anchor": [ + -0.31, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.10660121039869302, + "nearest_impact_distance": 1.335, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.031032814708823235, + "child": { + "body": "brick", + "column": 10, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_10_01_10", + "initial_anchor": [ + 0.31, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.1456460874122751, + "nearest_impact_distance": 1.4719459908570014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 5.425575612411068e-05, + "child": { + "body": "brick", + "column": 18, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_19_01_18", + "initial_anchor": [ + 5.27, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.00039052081952315495, + "nearest_impact_distance": 2.9074258374032516, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 19, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 5.6010977388764555e-05, + "child": { + "body": "brick", + "column": 7, + "row": 2 + }, + "id": "avbd_paper_wall_vertical_01_06_02_07", + "initial_anchor": [ + -1.8599999999999999, + 0.0, + 0.685 + ], + "kind": "vertical", + "linear_residual": 0.000691974565839963, + "nearest_impact_distance": 1.5118945069018541, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.02470336856503768, + "child": { + "body": "brick", + "column": 9, + "row": 2 + }, + "id": "avbd_paper_wall_vertical_01_09_02_09", + "initial_anchor": [ + -0.62, + 0.0, + 0.685 + ], + "kind": "vertical", + "linear_residual": 0.07636651406382058, + "nearest_impact_distance": 1.1092001622791081, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.08038014908502034, + "child": { + "body": "brick", + "column": 10, + "row": 2 + }, + "id": "avbd_paper_wall_vertical_01_09_02_10", + "initial_anchor": [ + 0.0, + 0.0, + 0.685 + ], + "kind": "vertical", + "linear_residual": 0.03985839548018708, + "nearest_impact_distance": 1.1092001622791081, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0003243409700776921, + "child": { + "body": "brick", + "column": 11, + "row": 2 + }, + "id": "avbd_paper_wall_vertical_01_10_02_11", + "initial_anchor": [ + 0.62, + 0.0, + 0.685 + ], + "kind": "vertical", + "linear_residual": 0.0008773113511702443, + "nearest_impact_distance": 1.4139041693127579, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 2.484001670654834e-05, + "child": { + "body": "brick", + "column": 4, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_04_03_04", + "initial_anchor": [ + -3.41, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.00017244252206544982, + "nearest_impact_distance": 0.6709135562797938, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 2 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00013050578371253787, + "child": { + "body": "brick", + "column": 5, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_05_03_05", + "initial_anchor": [ + -2.79, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0007968003680646558, + "nearest_impact_distance": 0.6709135562797938, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 2 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 8.422692052555017e-05, + "child": { + "body": "brick", + "column": 5, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_06_03_05", + "initial_anchor": [ + -2.79, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0008913898846153393, + "nearest_impact_distance": 0.6709135562797938, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 2 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 8.669217688784061e-05, + "child": { + "body": "brick", + "column": 6, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_06_03_06", + "initial_anchor": [ + -2.17, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0008551847063234204, + "nearest_impact_distance": 1.1040493648383665, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 6.264017492959096e-05, + "child": { + "body": "brick", + "column": 8, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_09_03_08", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0005121550097388601, + "nearest_impact_distance": 1.0081790515578073, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.024148713367201587, + "child": { + "body": "brick", + "column": 9, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_09_03_09", + "initial_anchor": [ + -0.31, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.028579107098452683, + "nearest_impact_distance": 0.7949999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 2 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.049039081715606496, + "child": { + "body": "brick", + "column": 9, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_10_03_09", + "initial_anchor": [ + -0.31, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.03228241838040859, + "nearest_impact_distance": 0.7949999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 2 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00037837855349583647, + "child": { + "body": "brick", + "column": 10, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_10_03_10", + "initial_anchor": [ + 0.31, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0012369979316494198, + "nearest_impact_distance": 1.0081790515578073, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00019815419523617003, + "child": { + "body": "brick", + "column": 5, + "row": 4 + }, + "id": "avbd_paper_wall_vertical_03_04_04_05", + "initial_anchor": [ + -3.1, + 0.0, + 1.225 + ], + "kind": "vertical", + "linear_residual": 0.0008999073830111205, + "nearest_impact_distance": 0.32499999999999996, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0001124651911225517, + "child": { + "body": "brick", + "column": 5, + "row": 4 + }, + "id": "avbd_paper_wall_vertical_03_05_04_05", + "initial_anchor": [ + -3.1, + 0.0, + 1.225 + ], + "kind": "vertical", + "linear_residual": 0.00032793708255464177, + "nearest_impact_distance": 0.32499999999999996, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.8407319440486942e-05, + "child": { + "body": "brick", + "column": 6, + "row": 4 + }, + "id": "avbd_paper_wall_vertical_03_06_04_06", + "initial_anchor": [ + -2.48, + 0.0, + 1.225 + ], + "kind": "vertical", + "linear_residual": 0.0001340474763491908, + "nearest_impact_distance": 0.7000178569150933, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.02415759931108204, + "child": { + "body": "brick", + "column": 9, + "row": 4 + }, + "id": "avbd_paper_wall_vertical_03_09_04_09", + "initial_anchor": [ + -0.62, + 0.0, + 1.225 + ], + "kind": "vertical", + "linear_residual": 0.024276572271384453, + "nearest_impact_distance": 0.6096925454686156, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.049004613523441945, + "child": { + "body": "brick", + "column": 10, + "row": 4 + }, + "id": "avbd_paper_wall_vertical_03_09_04_10", + "initial_anchor": [ + 0.0, + 0.0, + 1.225 + ], + "kind": "vertical", + "linear_residual": 0.03842389661943548, + "nearest_impact_distance": 0.6096925454686156, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.000160985369430635, + "child": { + "body": "brick", + "column": 4, + "row": 5 + }, + "id": "avbd_paper_wall_vertical_04_04_05_04", + "initial_anchor": [ + -3.41, + 0.0, + 1.495 + ], + "kind": "vertical", + "linear_residual": 0.0006802709571445407, + "nearest_impact_distance": 0.31484122982862334, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 9.648684486172502e-05, + "child": { + "body": "brick", + "column": 5, + "row": 5 + }, + "id": "avbd_paper_wall_vertical_04_06_05_05", + "initial_anchor": [ + -2.79, + 0.0, + 1.495 + ], + "kind": "vertical", + "linear_residual": 0.00028383025801083877, + "nearest_impact_distance": 0.31484122982862334, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.6634674965470846, + "child": { + "body": "brick", + "column": 9, + "row": 5 + }, + "id": "avbd_paper_wall_vertical_04_09_05_09", + "initial_anchor": [ + -0.31, + 0.0, + 1.495 + ], + "kind": "vertical", + "linear_residual": 2.6455489416519957, + "nearest_impact_distance": 0.2549999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.6673104394541303, + "child": { + "body": "brick", + "column": 9, + "row": 5 + }, + "id": "avbd_paper_wall_vertical_04_10_05_09", + "initial_anchor": [ + -0.31, + 0.0, + 1.495 + ], + "kind": "vertical", + "linear_residual": 2.633469294495132, + "nearest_impact_distance": 0.2549999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 7.358617065201637e-05, + "child": { + "body": "brick", + "column": 4, + "row": 6 + }, + "id": "avbd_paper_wall_vertical_05_04_06_04", + "initial_anchor": [ + -3.7199999999999998, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 0.00017626852611989376, + "nearest_impact_distance": 0.6562202374203341, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 5.417525316125329e-05, + "child": { + "body": "brick", + "column": 5, + "row": 6 + }, + "id": "avbd_paper_wall_vertical_05_05_06_05", + "initial_anchor": [ + -3.1, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 6.449885666797137e-05, + "nearest_impact_distance": 0.21500000000000008, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 9.31687212470271e-05, + "child": { + "body": "brick", + "column": 6, + "row": 6 + }, + "id": "avbd_paper_wall_vertical_05_06_06_06", + "initial_anchor": [ + -2.48, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0002026031668261461, + "nearest_impact_distance": 0.6562202374203345, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.663426969229742, + "child": { + "body": "brick", + "column": 9, + "row": 6 + }, + "id": "avbd_paper_wall_vertical_05_09_06_09", + "initial_anchor": [ + -0.62, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 3.021703409839472, + "nearest_impact_distance": 0.3103626910567699, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.6672711593472294, + "child": { + "body": "brick", + "column": 10, + "row": 6 + }, + "id": "avbd_paper_wall_vertical_05_09_06_10", + "initial_anchor": [ + 0.0, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 2.978359636559713, + "nearest_impact_distance": 0.3103626910567699, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.015946444718080547, + "child": { + "body": "brick", + "column": 9, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_09_07_09", + "initial_anchor": [ + -0.31, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.0306935100652017, + "nearest_impact_distance": 0.28500000000000014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07022454079203219, + "child": { + "body": "brick", + "column": 9, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_10_07_09", + "initial_anchor": [ + -0.31, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.037604710592893054, + "nearest_impact_distance": 0.28500000000000014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00010268425538519828, + "child": { + "body": "brick", + "column": 14, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_14_07_14", + "initial_anchor": [ + 2.79, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.0001816373932765264, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 14, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00013585675429806106, + "child": { + "body": "brick", + "column": 14, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_15_07_14", + "initial_anchor": [ + 2.79, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.0001905978407125089, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00011452824664959399, + "child": { + "body": "brick", + "column": 15, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_15_07_15", + "initial_anchor": [ + 3.41, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.00026072033507468706, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 9.348900896095467e-05, + "child": { + "body": "brick", + "column": 15, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_16_07_15", + "initial_anchor": [ + 3.41, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.00022280267768413624, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 16, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00010873709265849146, + "child": { + "body": "brick", + "column": 9, + "row": 8 + }, + "id": "avbd_paper_wall_vertical_07_08_08_09", + "initial_anchor": [ + -0.62, + 0.0, + 2.305 + ], + "kind": "vertical", + "linear_residual": 0.0003019598193152386, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.016077214414690056, + "child": { + "body": "brick", + "column": 9, + "row": 8 + }, + "id": "avbd_paper_wall_vertical_07_09_08_09", + "initial_anchor": [ + -0.62, + 0.0, + 2.305 + ], + "kind": "vertical", + "linear_residual": 0.033312364707545485, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07025608266173489, + "child": { + "body": "brick", + "column": 10, + "row": 8 + }, + "id": "avbd_paper_wall_vertical_07_09_08_10", + "initial_anchor": [ + 0.0, + 0.0, + 2.305 + ], + "kind": "vertical", + "linear_residual": 0.05815997726932891, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00018333460561330707, + "child": { + "body": "brick", + "column": 10, + "row": 8 + }, + "id": "avbd_paper_wall_vertical_07_10_08_10", + "initial_anchor": [ + 0.0, + 0.0, + 2.305 + ], + "kind": "vertical", + "linear_residual": 0.0007465307256725726, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.015906574248306545, + "child": { + "body": "brick", + "column": 9, + "row": 9 + }, + "id": "avbd_paper_wall_vertical_08_09_09_09", + "initial_anchor": [ + -0.31, + 0.0, + 2.575 + ], + "kind": "vertical", + "linear_residual": 0.035861042570630226, + "nearest_impact_distance": 0.8250000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.058965251891271483, + "child": { + "body": "brick", + "column": 9, + "row": 9 + }, + "id": "avbd_paper_wall_vertical_08_10_09_09", + "initial_anchor": [ + -0.31, + 0.0, + 2.575 + ], + "kind": "vertical", + "linear_residual": 0.05159606360857688, + "nearest_impact_distance": 0.8250000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00014433753954982536, + "child": { + "body": "brick", + "column": 10, + "row": 9 + }, + "id": "avbd_paper_wall_vertical_08_11_09_10", + "initial_anchor": [ + 0.31, + 0.0, + 2.575 + ], + "kind": "vertical", + "linear_residual": 0.0005930305347739416, + "nearest_impact_distance": 1.0320004844960105, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 8 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 5.251186542051928e-05, + "child": { + "body": "brick", + "column": 14, + "row": 9 + }, + "id": "avbd_paper_wall_vertical_08_15_09_14", + "initial_anchor": [ + 2.79, + 0.0, + 2.575 + ], + "kind": "vertical", + "linear_residual": 6.47521444861135e-05, + "nearest_impact_distance": 0.3830469945058962, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.015910673729649147, + "child": { + "body": "brick", + "column": 9, + "row": 10 + }, + "id": "avbd_paper_wall_vertical_09_08_10_09", + "initial_anchor": [ + -0.62, + 0.0, + 2.845 + ], + "kind": "vertical", + "linear_residual": 0.04176396995042668, + "nearest_impact_distance": 1.1380355881957298, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.052441893252761725, + "child": { + "body": "brick", + "column": 10, + "row": 10 + }, + "id": "avbd_paper_wall_vertical_09_09_10_10", + "initial_anchor": [ + 0.0, + 0.0, + 2.845 + ], + "kind": "vertical", + "linear_residual": 0.0261213421278227, + "nearest_impact_distance": 1.1380355881957298, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.05012976212871341, + "child": { + "body": "brick", + "column": 10, + "row": 10 + }, + "id": "avbd_paper_wall_vertical_09_10_10_10", + "initial_anchor": [ + 0.0, + 0.0, + 2.845 + ], + "kind": "vertical", + "linear_residual": 0.051692355331499816, + "nearest_impact_distance": 1.1380355881957298, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 9.49362861837052e-05, + "child": { + "body": "brick", + "column": 11, + "row": 10 + }, + "id": "avbd_paper_wall_vertical_09_11_10_11", + "initial_anchor": [ + 0.62, + 0.0, + 2.845 + ], + "kind": "vertical", + "linear_residual": 0.0003967737168043517, + "nearest_impact_distance": 1.436636697289889, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.06321099171489016, + "child": { + "body": "brick", + "column": 8, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_08_11_08", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.02096319242495268, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.06819482709536104, + "child": { + "body": "brick", + "column": 8, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_09_11_08", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.03476186785950846, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.13644494448229424, + "child": { + "body": "brick", + "column": 9, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_09_11_09", + "initial_anchor": [ + -0.31, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.08725756930920449, + "nearest_impact_distance": 1.3650000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.15202665854196346, + "child": { + "body": "brick", + "column": 9, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_10_11_09", + "initial_anchor": [ + -0.31, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.09270894741743284, + "nearest_impact_distance": 1.3650000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.053681610702223374, + "child": { + "body": "brick", + "column": 10, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_10_11_10", + "initial_anchor": [ + 0.31, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.047170042669295933, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.09998221956718378, + "child": { + "body": "brick", + "column": 10, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_11_11_10", + "initial_anchor": [ + 0.31, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.09203001781720652, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.040593076311306805, + "child": { + "body": "brick", + "column": 0, + "row": 0 + }, + "id": "avbd_paper_wall_base_00", + "initial_anchor": [ + -6.2, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.020328803692527975, + "nearest_impact_distance": 3.403531254447357, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04060219674747863, + "child": { + "body": "brick", + "column": 1, + "row": 0 + }, + "id": "avbd_paper_wall_base_01", + "initial_anchor": [ + -5.58, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.031116502920502456, + "nearest_impact_distance": 2.850337699291086, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.040613882090306114, + "child": { + "body": "brick", + "column": 2, + "row": 0 + }, + "id": "avbd_paper_wall_base_02", + "initial_anchor": [ + -4.96, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.052815086080305926, + "nearest_impact_distance": 2.331013727973304, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04061184713233091, + "child": { + "body": "brick", + "column": 3, + "row": 0 + }, + "id": "avbd_paper_wall_base_03", + "initial_anchor": [ + -4.34, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.07665531857179181, + "nearest_impact_distance": 1.8739330297531978, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.040603284924578216, + "child": { + "body": "brick", + "column": 4, + "row": 0 + }, + "id": "avbd_paper_wall_base_04", + "initial_anchor": [ + -3.7199999999999998, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.10114206827939436, + "nearest_impact_distance": 1.5357164451812058, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.040590719352350854, + "child": { + "body": "brick", + "column": 5, + "row": 0 + }, + "id": "avbd_paper_wall_base_05", + "initial_anchor": [ + -3.1, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.12588885027083405, + "nearest_impact_distance": 1.405, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04057834613427499, + "child": { + "body": "brick", + "column": 6, + "row": 0 + }, + "id": "avbd_paper_wall_base_06", + "initial_anchor": [ + -2.48, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.15075717113305584, + "nearest_impact_distance": 1.5357164451812062, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04056361825551725, + "child": { + "body": "brick", + "column": 7, + "row": 0 + }, + "id": "avbd_paper_wall_base_07", + "initial_anchor": [ + -1.8599999999999999, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.1756180655909506, + "nearest_impact_distance": 1.8739330297531982, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04060105083763989, + "child": { + "body": "brick", + "column": 8, + "row": 0 + }, + "id": "avbd_paper_wall_base_08", + "initial_anchor": [ + -1.24, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.20057867190345638, + "nearest_impact_distance": 1.85497304562627, + "nearest_impact_index": 1, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04324302288844467, + "child": { + "body": "brick", + "column": 9, + "row": 0 + }, + "id": "avbd_paper_wall_base_09", + "initial_anchor": [ + -0.62, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.04012146360384805, + "nearest_impact_distance": 1.6346635739503097, + "nearest_impact_index": 1, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.007638277822521518, + "child": { + "body": "brick", + "column": 10, + "row": 0 + }, + "id": "avbd_paper_wall_base_10", + "initial_anchor": [ + 0.0, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.06021890247438712, + "nearest_impact_distance": 1.6346635739503097, + "nearest_impact_index": 1, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03211139813713698, + "child": { + "body": "brick", + "column": 11, + "row": 0 + }, + "id": "avbd_paper_wall_base_11", + "initial_anchor": [ + 0.62, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.191497874893805, + "nearest_impact_distance": 1.85497304562627, + "nearest_impact_index": 1, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.032114079857064125, + "child": { + "body": "brick", + "column": 12, + "row": 0 + }, + "id": "avbd_paper_wall_base_12", + "initial_anchor": [ + 1.24, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.171819845231917, + "nearest_impact_distance": 2.231260854315335, + "nearest_impact_index": 1, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03212098737970918, + "child": { + "body": "brick", + "column": 13, + "row": 0 + }, + "id": "avbd_paper_wall_base_13", + "initial_anchor": [ + 1.8599999999999999, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.1521516133142405, + "nearest_impact_distance": 2.5297480111663297, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03212518695564741, + "child": { + "body": "brick", + "column": 14, + "row": 0 + }, + "id": "avbd_paper_wall_base_14", + "initial_anchor": [ + 2.48, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.13250175693563027, + "nearest_impact_distance": 2.2905075856674215, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.032128012992103265, + "child": { + "body": "brick", + "column": 15, + "row": 0 + }, + "id": "avbd_paper_wall_base_15", + "initial_anchor": [ + 3.1, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.11290727794857369, + "nearest_impact_distance": 2.205, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03212414914248217, + "child": { + "body": "brick", + "column": 16, + "row": 0 + }, + "id": "avbd_paper_wall_base_16", + "initial_anchor": [ + 3.7199999999999998, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.09340832273951045, + "nearest_impact_distance": 2.2905075856674215, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.032123202533898436, + "child": { + "body": "brick", + "column": 17, + "row": 0 + }, + "id": "avbd_paper_wall_base_17", + "initial_anchor": [ + 4.34, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.0741409539355621, + "nearest_impact_distance": 2.5297480111663293, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03210241255959834, + "child": { + "body": "brick", + "column": 18, + "row": 0 + }, + "id": "avbd_paper_wall_base_18", + "initial_anchor": [ + 4.96, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.055154931814145824, + "nearest_impact_distance": 2.8847226903118433, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.032083157604207706, + "child": { + "body": "brick", + "column": 19, + "row": 0 + }, + "id": "avbd_paper_wall_base_19", + "initial_anchor": [ + 5.58, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.037332808050066384, + "nearest_impact_distance": 3.3184974009331394, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.032081482275064216, + "child": { + "body": "brick", + "column": 20, + "row": 0 + }, + "id": "avbd_paper_wall_base_20", + "initial_anchor": [ + 6.2, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.023156134782952312, + "nearest_impact_distance": 3.804211482028832, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + } + ], + "broken_joints": 154, + "broken_joints_outside_impact_regions": 79, + "checkpoint": "outcome", + "contact_count": 79, "evaluated": false, "frame": 60, "impact_band_displaced_counts": [ - 3, - 9, - 4 + 13, + 14, + 12 ], + "impact_damage_displacement_threshold": 0.1, + "joint_residuals_finite": true, "last_step_iterations": 20, - "max_brick_displacement": 12.754398322625777, - "outside_brick_count": 181, - "outside_retained_fraction": 0.9337016574585635, + "max_brick_displacement": 2.439401549037924, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": 0.0001782705975382865, + "maximum_outside_impact_unbroken_joint_linear_residual": 0.0004056405274649969, + "maximum_unbroken_joint_angular_residual_radians": 0.0001782705975382865, + "maximum_unbroken_joint_linear_residual": 0.0004056405274649969, + "maximum_wall_normal_displacement": 2.1751270248435737, + "outside_brick_count": 176, + "outside_impact_unbroken_joint_residual_count": 405, + "outside_retained_fraction": 1.0, + "retained_displacement_threshold": 0.5, + "rms_outside_impact_unbroken_joint_angular_residual_radians": 3.806214892815846e-05, + "rms_outside_impact_unbroken_joint_linear_residual": 0.00010861121673878482, + "rms_unbroken_joint_angular_residual_radians": 3.6622500892251954e-05, + "rms_unbroken_joint_linear_residual": 0.00010166345059557479, + "rms_wall_normal_displacement": 0.19089147584905478, "status": "pre-evaluation", "threshold_checks": { - "damage_in_three_impact_bands": false, + "damage_in_three_impact_bands": true, "finite_state": true, "fracture_activated": true, - "fracture_localized": true, + "fracture_count_bounded": true, + "fracture_identity_matches": true, "outside_wall_retained": true, + "retained_joint_rows_satisfied": true, "total_wall_retained": true }, "thresholds_pass": false, - "total_retained_fraction": 0.8611111111111112, - "unbroken_joints": 353, + "total_retained_fraction": 0.996031746031746, + "unbroken_joint_residual_count": 558, + "unbroken_joints": 558, "world_time": 1.0000000000000013 }, "outcome_oracle": { - "displacement_threshold": 0.5, "evaluation_frame": 120, + "expected_broken_joint_ids_sha256": "31b187538ac1549563be368a4d7e304d1caef6ea11ba65b624d48f5f27468503", "impact_band_radius": 0.85, - "maximum_broken_joints": 500, - "minimum_broken_joints": 300, + "impact_damage_displacement_threshold": 0.1, + "joint_evidence_frames": [ + 60, + 120 + ], + "maximum_broken_joints": 250, + "maximum_unbroken_joint_angular_residual_radians": 0.001, + "maximum_unbroken_joint_linear_residual": 0.002, + "minimum_broken_joints": 150, "minimum_displaced_bricks_per_impact_band": 4, - "minimum_outside_retained_fraction": 0.8, - "minimum_total_retained_fraction": 0.7, - "minimum_unbroken_joints": 200, - "outside_radius": 1.15 + "minimum_outside_retained_fraction": 0.95, + "minimum_total_retained_fraction": 0.95, + "minimum_unbroken_joints": 450, + "outside_radius": 1.15, + "retained_displacement_threshold": 0.5 }, "resolved_configuration": [ { @@ -445,7 +4373,7 @@ ], "scene_contract": { "ball_count": 3, - "break_force": 8500.0, + "break_force": 5000.0, "breakable_joints": 712, "brick_count": 252, "collision_shapes": 256, @@ -459,35 +4387,283 @@ "solver": "public_avbd", "time_step_ms": 16.666666666666668 }, - "scene_spec_fingerprint": "2a746821cc10faee", + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "view_report": { "camera": { - "azimuth": -1.5707963267948966, - "distance": 11.0, - "elevation": 0.0, + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, "target": [ 0.0, - 0.0, - 1.4 + 0.45, + 1.6 ] }, "focus": [ "avbd_paper_wall_brick_00_00_visual", + "avbd_paper_wall_brick_00_01_visual", + "avbd_paper_wall_brick_00_02_visual", + "avbd_paper_wall_brick_00_03_visual", + "avbd_paper_wall_brick_00_04_visual", + "avbd_paper_wall_brick_00_05_visual", + "avbd_paper_wall_brick_00_06_visual", + "avbd_paper_wall_brick_00_07_visual", + "avbd_paper_wall_brick_00_08_visual", + "avbd_paper_wall_brick_00_09_visual", + "avbd_paper_wall_brick_00_10_visual", + "avbd_paper_wall_brick_00_11_visual", + "avbd_paper_wall_brick_00_12_visual", + "avbd_paper_wall_brick_00_13_visual", + "avbd_paper_wall_brick_00_14_visual", + "avbd_paper_wall_brick_00_15_visual", + "avbd_paper_wall_brick_00_16_visual", + "avbd_paper_wall_brick_00_17_visual", + "avbd_paper_wall_brick_00_18_visual", + "avbd_paper_wall_brick_00_19_visual", "avbd_paper_wall_brick_00_20_visual", + "avbd_paper_wall_brick_01_00_visual", + "avbd_paper_wall_brick_01_01_visual", + "avbd_paper_wall_brick_01_02_visual", + "avbd_paper_wall_brick_01_03_visual", + "avbd_paper_wall_brick_01_04_visual", + "avbd_paper_wall_brick_01_05_visual", + "avbd_paper_wall_brick_01_06_visual", + "avbd_paper_wall_brick_01_07_visual", + "avbd_paper_wall_brick_01_08_visual", + "avbd_paper_wall_brick_01_09_visual", + "avbd_paper_wall_brick_01_10_visual", + "avbd_paper_wall_brick_01_11_visual", + "avbd_paper_wall_brick_01_12_visual", + "avbd_paper_wall_brick_01_13_visual", + "avbd_paper_wall_brick_01_14_visual", + "avbd_paper_wall_brick_01_15_visual", + "avbd_paper_wall_brick_01_16_visual", + "avbd_paper_wall_brick_01_17_visual", + "avbd_paper_wall_brick_01_18_visual", + "avbd_paper_wall_brick_01_19_visual", + "avbd_paper_wall_brick_01_20_visual", + "avbd_paper_wall_brick_02_00_visual", + "avbd_paper_wall_brick_02_01_visual", + "avbd_paper_wall_brick_02_02_visual", + "avbd_paper_wall_brick_02_03_visual", + "avbd_paper_wall_brick_02_04_visual", + "avbd_paper_wall_brick_02_05_visual", + "avbd_paper_wall_brick_02_06_visual", + "avbd_paper_wall_brick_02_07_visual", + "avbd_paper_wall_brick_02_08_visual", + "avbd_paper_wall_brick_02_09_visual", + "avbd_paper_wall_brick_02_10_visual", + "avbd_paper_wall_brick_02_11_visual", + "avbd_paper_wall_brick_02_12_visual", + "avbd_paper_wall_brick_02_13_visual", + "avbd_paper_wall_brick_02_14_visual", + "avbd_paper_wall_brick_02_15_visual", + "avbd_paper_wall_brick_02_16_visual", + "avbd_paper_wall_brick_02_17_visual", + "avbd_paper_wall_brick_02_18_visual", + "avbd_paper_wall_brick_02_19_visual", + "avbd_paper_wall_brick_02_20_visual", + "avbd_paper_wall_brick_03_00_visual", + "avbd_paper_wall_brick_03_01_visual", + "avbd_paper_wall_brick_03_02_visual", + "avbd_paper_wall_brick_03_03_visual", + "avbd_paper_wall_brick_03_04_visual", + "avbd_paper_wall_brick_03_05_visual", + "avbd_paper_wall_brick_03_06_visual", + "avbd_paper_wall_brick_03_07_visual", + "avbd_paper_wall_brick_03_08_visual", + "avbd_paper_wall_brick_03_09_visual", + "avbd_paper_wall_brick_03_10_visual", + "avbd_paper_wall_brick_03_11_visual", + "avbd_paper_wall_brick_03_12_visual", + "avbd_paper_wall_brick_03_13_visual", + "avbd_paper_wall_brick_03_14_visual", + "avbd_paper_wall_brick_03_15_visual", + "avbd_paper_wall_brick_03_16_visual", + "avbd_paper_wall_brick_03_17_visual", + "avbd_paper_wall_brick_03_18_visual", + "avbd_paper_wall_brick_03_19_visual", + "avbd_paper_wall_brick_03_20_visual", + "avbd_paper_wall_brick_04_00_visual", + "avbd_paper_wall_brick_04_01_visual", + "avbd_paper_wall_brick_04_02_visual", + "avbd_paper_wall_brick_04_03_visual", + "avbd_paper_wall_brick_04_04_visual", + "avbd_paper_wall_brick_04_05_visual", + "avbd_paper_wall_brick_04_06_visual", + "avbd_paper_wall_brick_04_07_visual", + "avbd_paper_wall_brick_04_08_visual", + "avbd_paper_wall_brick_04_09_visual", + "avbd_paper_wall_brick_04_10_visual", + "avbd_paper_wall_brick_04_11_visual", + "avbd_paper_wall_brick_04_12_visual", + "avbd_paper_wall_brick_04_13_visual", + "avbd_paper_wall_brick_04_14_visual", + "avbd_paper_wall_brick_04_15_visual", + "avbd_paper_wall_brick_04_16_visual", + "avbd_paper_wall_brick_04_17_visual", + "avbd_paper_wall_brick_04_18_visual", + "avbd_paper_wall_brick_04_19_visual", + "avbd_paper_wall_brick_04_20_visual", + "avbd_paper_wall_brick_05_00_visual", + "avbd_paper_wall_brick_05_01_visual", + "avbd_paper_wall_brick_05_02_visual", + "avbd_paper_wall_brick_05_03_visual", + "avbd_paper_wall_brick_05_04_visual", + "avbd_paper_wall_brick_05_05_visual", + "avbd_paper_wall_brick_05_06_visual", + "avbd_paper_wall_brick_05_07_visual", + "avbd_paper_wall_brick_05_08_visual", + "avbd_paper_wall_brick_05_09_visual", + "avbd_paper_wall_brick_05_10_visual", + "avbd_paper_wall_brick_05_11_visual", + "avbd_paper_wall_brick_05_12_visual", + "avbd_paper_wall_brick_05_13_visual", + "avbd_paper_wall_brick_05_14_visual", + "avbd_paper_wall_brick_05_15_visual", + "avbd_paper_wall_brick_05_16_visual", + "avbd_paper_wall_brick_05_17_visual", + "avbd_paper_wall_brick_05_18_visual", + "avbd_paper_wall_brick_05_19_visual", + "avbd_paper_wall_brick_05_20_visual", + "avbd_paper_wall_brick_06_00_visual", + "avbd_paper_wall_brick_06_01_visual", + "avbd_paper_wall_brick_06_02_visual", + "avbd_paper_wall_brick_06_03_visual", + "avbd_paper_wall_brick_06_04_visual", + "avbd_paper_wall_brick_06_05_visual", + "avbd_paper_wall_brick_06_06_visual", + "avbd_paper_wall_brick_06_07_visual", + "avbd_paper_wall_brick_06_08_visual", + "avbd_paper_wall_brick_06_09_visual", + "avbd_paper_wall_brick_06_10_visual", + "avbd_paper_wall_brick_06_11_visual", + "avbd_paper_wall_brick_06_12_visual", + "avbd_paper_wall_brick_06_13_visual", + "avbd_paper_wall_brick_06_14_visual", + "avbd_paper_wall_brick_06_15_visual", + "avbd_paper_wall_brick_06_16_visual", + "avbd_paper_wall_brick_06_17_visual", + "avbd_paper_wall_brick_06_18_visual", + "avbd_paper_wall_brick_06_19_visual", + "avbd_paper_wall_brick_06_20_visual", + "avbd_paper_wall_brick_07_00_visual", + "avbd_paper_wall_brick_07_01_visual", + "avbd_paper_wall_brick_07_02_visual", + "avbd_paper_wall_brick_07_03_visual", + "avbd_paper_wall_brick_07_04_visual", + "avbd_paper_wall_brick_07_05_visual", + "avbd_paper_wall_brick_07_06_visual", + "avbd_paper_wall_brick_07_07_visual", + "avbd_paper_wall_brick_07_08_visual", + "avbd_paper_wall_brick_07_09_visual", + "avbd_paper_wall_brick_07_10_visual", + "avbd_paper_wall_brick_07_11_visual", + "avbd_paper_wall_brick_07_12_visual", + "avbd_paper_wall_brick_07_13_visual", + "avbd_paper_wall_brick_07_14_visual", + "avbd_paper_wall_brick_07_15_visual", + "avbd_paper_wall_brick_07_16_visual", + "avbd_paper_wall_brick_07_17_visual", + "avbd_paper_wall_brick_07_18_visual", + "avbd_paper_wall_brick_07_19_visual", + "avbd_paper_wall_brick_07_20_visual", + "avbd_paper_wall_brick_08_00_visual", + "avbd_paper_wall_brick_08_01_visual", + "avbd_paper_wall_brick_08_02_visual", + "avbd_paper_wall_brick_08_03_visual", + "avbd_paper_wall_brick_08_04_visual", + "avbd_paper_wall_brick_08_05_visual", + "avbd_paper_wall_brick_08_06_visual", + "avbd_paper_wall_brick_08_07_visual", + "avbd_paper_wall_brick_08_08_visual", + "avbd_paper_wall_brick_08_09_visual", + "avbd_paper_wall_brick_08_10_visual", + "avbd_paper_wall_brick_08_11_visual", + "avbd_paper_wall_brick_08_12_visual", + "avbd_paper_wall_brick_08_13_visual", + "avbd_paper_wall_brick_08_14_visual", + "avbd_paper_wall_brick_08_15_visual", + "avbd_paper_wall_brick_08_16_visual", + "avbd_paper_wall_brick_08_17_visual", + "avbd_paper_wall_brick_08_18_visual", + "avbd_paper_wall_brick_08_19_visual", + "avbd_paper_wall_brick_08_20_visual", + "avbd_paper_wall_brick_09_00_visual", + "avbd_paper_wall_brick_09_01_visual", + "avbd_paper_wall_brick_09_02_visual", + "avbd_paper_wall_brick_09_03_visual", + "avbd_paper_wall_brick_09_04_visual", + "avbd_paper_wall_brick_09_05_visual", + "avbd_paper_wall_brick_09_06_visual", + "avbd_paper_wall_brick_09_07_visual", + "avbd_paper_wall_brick_09_08_visual", + "avbd_paper_wall_brick_09_09_visual", + "avbd_paper_wall_brick_09_10_visual", + "avbd_paper_wall_brick_09_11_visual", + "avbd_paper_wall_brick_09_12_visual", + "avbd_paper_wall_brick_09_13_visual", + "avbd_paper_wall_brick_09_14_visual", + "avbd_paper_wall_brick_09_15_visual", + "avbd_paper_wall_brick_09_16_visual", + "avbd_paper_wall_brick_09_17_visual", + "avbd_paper_wall_brick_09_18_visual", + "avbd_paper_wall_brick_09_19_visual", + "avbd_paper_wall_brick_09_20_visual", + "avbd_paper_wall_brick_10_00_visual", + "avbd_paper_wall_brick_10_01_visual", + "avbd_paper_wall_brick_10_02_visual", + "avbd_paper_wall_brick_10_03_visual", + "avbd_paper_wall_brick_10_04_visual", + "avbd_paper_wall_brick_10_05_visual", + "avbd_paper_wall_brick_10_06_visual", + "avbd_paper_wall_brick_10_07_visual", + "avbd_paper_wall_brick_10_08_visual", + "avbd_paper_wall_brick_10_09_visual", + "avbd_paper_wall_brick_10_10_visual", + "avbd_paper_wall_brick_10_11_visual", + "avbd_paper_wall_brick_10_12_visual", + "avbd_paper_wall_brick_10_13_visual", + "avbd_paper_wall_brick_10_14_visual", + "avbd_paper_wall_brick_10_15_visual", + "avbd_paper_wall_brick_10_16_visual", + "avbd_paper_wall_brick_10_17_visual", + "avbd_paper_wall_brick_10_18_visual", + "avbd_paper_wall_brick_10_19_visual", + "avbd_paper_wall_brick_10_20_visual", "avbd_paper_wall_brick_11_00_visual", + "avbd_paper_wall_brick_11_01_visual", + "avbd_paper_wall_brick_11_02_visual", + "avbd_paper_wall_brick_11_03_visual", + "avbd_paper_wall_brick_11_04_visual", + "avbd_paper_wall_brick_11_05_visual", + "avbd_paper_wall_brick_11_06_visual", + "avbd_paper_wall_brick_11_07_visual", + "avbd_paper_wall_brick_11_08_visual", + "avbd_paper_wall_brick_11_09_visual", + "avbd_paper_wall_brick_11_10_visual", + "avbd_paper_wall_brick_11_11_visual", + "avbd_paper_wall_brick_11_12_visual", + "avbd_paper_wall_brick_11_13_visual", + "avbd_paper_wall_brick_11_14_visual", + "avbd_paper_wall_brick_11_15_visual", + "avbd_paper_wall_brick_11_16_visual", + "avbd_paper_wall_brick_11_17_visual", + "avbd_paper_wall_brick_11_18_visual", + "avbd_paper_wall_brick_11_19_visual", "avbd_paper_wall_brick_11_20_visual" ], "issues": [], "metrics": { - "ambiguity_iou": 0.0, + "ambiguity_iou": 0.06536179557067642, "center_visible": true, "corner_coverage": 1.0, - "occlusion_fraction": 0.21212121212121213, - "subject_fraction": 0.30204201619716886 + "occlusion_fraction": 0.0009915716410510659, + "subject_fraction": 0.1307035666410234 }, "pass": true, "schema_version": "dart.view_report/v1", - "score": 0.7157334066947348, + "score": 0.9709558598086462, "size": [ 1280, 720 @@ -497,22 +4673,44 @@ "scene_metrics_events": { "event_count": 60, "file": "scene_metrics.jsonl", - "sha256": "13e84122e0b4806aabb0dde2bb777027b8c25d8d730e3c091a529e33c25b2e19" + "sha256": "f97e1e85dce074e6065c2fa29c3a62af282284bca379772606e840773f024845" }, "screenshot": { "file": "avbd_paper_breakable_wall_impact.png", - "sha256": "a3ab663cdbbd030bccddbee838e134fa3326fdec459f3a8b734fa0da044f63a3" + "sha256": "1d72ad775b690494372585ba9f91eb751aaefb2ba6c94a2466c9009ace94c3d5" + }, + "source_provenance": { + "algorithm": "sha256-length-prefixed-capture-source-tree-v1", + "digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12", + "file_count": 1546, + "git_head": "d9af744a9cb0763033d76e01381dbf7c1e6ecaa9", + "roots": [ + "CMakeLists.txt", + "cmake", + "dart", + "pixi.lock", + "pixi.toml", + "pyproject.toml", + "python/CMakeLists.txt", + "python/dartpy", + "python/examples/demos", + "scripts/capture_py_demo.py", + "scripts/capture_source_provenance.py" + ] } }, "outcome": { "camera": { - "distance": 11.0, + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, + "preset": "front", "target": [ 0.0, - 0.0, - 1.4 + 0.45, + 1.6 ], - "view": "front" + "view": "front-oblique" }, "capture": { "height": 720, @@ -522,15 +4720,13 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 1019, - "dark_pixels": 37181, - "mid_pixels": 176501, - "p05": 41.941, - "p95": 147.723, - "pass": false, - "reasons": [ - "bright pixels 1019 < 1180" - ], + "bright_pixels": 1842, + "dark_pixels": 12034, + "mid_pixels": 208097, + "p05": 54.082, + "p95": 155.511, + "pass": true, + "reasons": [], "region": { "x0": 512, "x1": 1241, @@ -538,7 +4734,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 105.782, + "spread": 101.428, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -563,15 +4759,15 @@ "metadata": { "frame": "120", "scene": "avbd_paper_breakable_wall", - "view": "front" + "view": "front-oblique" }, "pass": true, - "sha256": "88bf68c2b1dd3c1354f6946b6decc5ce4718ff8745bf06c852f080f404e5feb6" + "sha256": "acecae4947c7d6ff07e7cc627902e7f0eece0430a66c8b2f9982196ed43b67c2" }, "label": "outcome", "manifest": { "file": "manifest.json", - "sha256": "a0fabf42f8775adc2b3b13653a372df2241ea6e4005b4cd2b8dd73537c429634" + "sha256": "212b4a2aedda95ff07669ce2e22a12e5e225b1001328f56d5230ff868da50906" }, "scene_metrics": { "event_count": 120, @@ -579,76 +4775,3963 @@ "outcome": { "ball_positions": [ [ - -2.9391256382534756, - -21.08003336875553, - -6.375045249617675 + -3.1024666775111682, + -6.00817877058376, + 0.47419610418624586 ], [ - 0.5623042091274508, - -19.939284947339758, - -5.898275564310961 + -0.20289489943868372, + -26.67340771699127, + -9.156880076050262 ], [ - 3.215183126988267, - -22.243111497030156, - -6.495427915738158 + 3.1947932273225335, + -5.122922604075589, + 0.47407028164691734 ] ], "ball_velocities": [ [ - 0.3063510998062213, - -12.7635628826879, - -11.384020729376422 + -0.001403079142763275, + -4.438017187112138, + -0.0028788581863192775 ], [ - 0.2994058396764654, - -12.148325923939325, - -10.96994746883128 + 0.060298102544033005, + -15.565578936693036, + -13.652936119449702 ], [ - 0.0858339357374227, - -13.650571792657864, - -11.482605541210326 + 0.06431078294097503, + -4.48451178219317, + -0.0029426176370017654 ] ], - "broken_joints": 359, - "contact_count": 137, + "bent_brick_count": 190, + "broken_joint_identity_count": 154, + "broken_joint_ids_sha256": "31b187538ac1549563be368a4d7e304d1caef6ea11ba65b624d48f5f27468503", + "broken_joint_impact_region_counts": [ + 18, + 44, + 13 + ], + "broken_joint_records": [ + { + "angular_residual_radians": 0.0003916329236904025, + "child": { + "body": "brick", + "column": 7, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_06", + "initial_anchor": [ + -1.8599999999999999, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.0014014526780370769, + "nearest_impact_distance": 1.8739330297531982, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00020232924226678477, + "child": { + "body": "brick", + "column": 8, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_07", + "initial_anchor": [ + -1.24, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.0007659227091683734, + "nearest_impact_distance": 1.85497304562627, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03497393994903002, + "child": { + "body": "brick", + "column": 9, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_08", + "initial_anchor": [ + -0.62, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.23813373958366033, + "nearest_impact_distance": 1.6346635739503097, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.08675867325873537, + "child": { + "body": "brick", + "column": 10, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_09", + "initial_anchor": [ + 0.0, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.017562643986244214, + "nearest_impact_distance": 1.6346635739503097, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.043350059061479214, + "child": { + "body": "brick", + "column": 11, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_10", + "initial_anchor": [ + 0.62, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.17723073629334585, + "nearest_impact_distance": 1.85497304562627, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00014496763913046253, + "child": { + "body": "brick", + "column": 12, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_11", + "initial_anchor": [ + 1.24, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.0001873985687874558, + "nearest_impact_distance": 2.231260854315335, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 3.485085032846123e-05, + "child": { + "body": "brick", + "column": 13, + "row": 0 + }, + "id": "avbd_paper_wall_horizontal_00_12", + "initial_anchor": [ + 1.8599999999999999, + 0.0, + 0.145 + ], + "kind": "horizontal", + "linear_residual": 0.00011156798238914091, + "nearest_impact_distance": 2.5297480111663297, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.000210056966362162, + "child": { + "body": "brick", + "column": 6, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_05", + "initial_anchor": [ + -2.17, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.0005106649991121578, + "nearest_impact_distance": 1.467353059082919, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 7.838246485402156e-05, + "child": { + "body": "brick", + "column": 7, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_06", + "initial_anchor": [ + -1.55, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.001594142050033551, + "nearest_impact_distance": 1.8220386933322794, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 6, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 2.7443052327854684e-05, + "child": { + "body": "brick", + "column": 8, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_07", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.0002408708064039265, + "nearest_impact_distance": 1.4719459908570014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.146999391784544, + "child": { + "body": "brick", + "column": 9, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_08", + "initial_anchor": [ + -0.31, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.16428012859517602, + "nearest_impact_distance": 1.335, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.1521315179280704, + "child": { + "body": "brick", + "column": 10, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_09", + "initial_anchor": [ + 0.31, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.23293780141767098, + "nearest_impact_distance": 1.4719459908570014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00023642111590318031, + "child": { + "body": "brick", + "column": 11, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_10", + "initial_anchor": [ + 0.9299999999999999, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.00026889779698057006, + "nearest_impact_distance": 1.8220386933322794, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 5.90829444195749e-05, + "child": { + "body": "brick", + "column": 12, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_11", + "initial_anchor": [ + 1.55, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 0.00011984047364803642, + "nearest_impact_distance": 2.2895032212250763, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 2.0618372261920157e-05, + "child": { + "body": "brick", + "column": 13, + "row": 1 + }, + "id": "avbd_paper_wall_horizontal_01_12", + "initial_anchor": [ + 2.17, + 0.0, + 0.41500000000000004 + ], + "kind": "horizontal", + "linear_residual": 8.26803444857736e-05, + "nearest_impact_distance": 2.146887281624259, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0002011563083746235, + "child": { + "body": "brick", + "column": 6, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_05", + "initial_anchor": [ + -2.48, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.00016831806467675222, + "nearest_impact_distance": 1.0642485611923562, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00011456991887264507, + "child": { + "body": "brick", + "column": 7, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_06", + "initial_anchor": [ + -1.8599999999999999, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.002227657398544884, + "nearest_impact_distance": 1.5118945069018541, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 3.26405097559217e-05, + "child": { + "body": "brick", + "column": 8, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_07", + "initial_anchor": [ + -1.24, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 4.499659907628777e-05, + "nearest_impact_distance": 1.4139041693127579, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00015420019840611332, + "child": { + "body": "brick", + "column": 9, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_08", + "initial_anchor": [ + -0.62, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.000251569902374987, + "nearest_impact_distance": 1.1092001622791081, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07180482502586738, + "child": { + "body": "brick", + "column": 10, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_09", + "initial_anchor": [ + 0.0, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.039396107482401625, + "nearest_impact_distance": 1.1092001622791081, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00034159899071498155, + "child": { + "body": "brick", + "column": 11, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_10", + "initial_anchor": [ + 0.62, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.0005247905203317344, + "nearest_impact_distance": 1.4139041693127579, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 2.0964996499314293e-05, + "child": { + "body": "brick", + "column": 12, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_11", + "initial_anchor": [ + 1.24, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 3.275935764985709e-05, + "nearest_impact_distance": 1.8806182494062957, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 3.355038055419389e-05, + "child": { + "body": "brick", + "column": 13, + "row": 2 + }, + "id": "avbd_paper_wall_horizontal_02_12", + "initial_anchor": [ + 1.8599999999999999, + 0.0, + 0.685 + ], + "kind": "horizontal", + "linear_residual": 0.00010318184844691341, + "nearest_impact_distance": 2.0760118015078817, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0001748613954708965, + "child": { + "body": "brick", + "column": 5, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_04", + "initial_anchor": [ + -2.79, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 0.002181568553146309, + "nearest_impact_distance": 0.6709135562797938, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.03409219612062713, + "child": { + "body": "brick", + "column": 9, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_08", + "initial_anchor": [ + -0.31, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 0.019604932697237728, + "nearest_impact_distance": 0.7949999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.05051893202155339, + "child": { + "body": "brick", + "column": 10, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_09", + "initial_anchor": [ + 0.31, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 0.045045223504013444, + "nearest_impact_distance": 1.0081790515578073, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 3 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.2637484962747299e-05, + "child": { + "body": "brick", + "column": 11, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_10", + "initial_anchor": [ + 0.9299999999999999, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 1.7154199693961276e-05, + "nearest_impact_distance": 1.472964697472414, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 3 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.725216004284337e-05, + "child": { + "body": "brick", + "column": 12, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_11", + "initial_anchor": [ + 1.55, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 6.27193900223898e-05, + "nearest_impact_distance": 2.0227765571115364, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 3 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.3655416313085434e-05, + "child": { + "body": "brick", + "column": 13, + "row": 3 + }, + "id": "avbd_paper_wall_horizontal_03_12", + "initial_anchor": [ + 2.17, + 0.0, + 0.9550000000000001 + ], + "kind": "horizontal", + "linear_residual": 8.312017736848934e-05, + "nearest_impact_distance": 1.6765813430907552, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 3 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0002283144135941531, + "child": { + "body": "brick", + "column": 5, + "row": 4 + }, + "id": "avbd_paper_wall_horizontal_04_04", + "initial_anchor": [ + -3.1, + 0.0, + 1.225 + ], + "kind": "horizontal", + "linear_residual": 0.0018785479580566316, + "nearest_impact_distance": 0.32499999999999996, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00016147714832582245, + "child": { + "body": "brick", + "column": 6, + "row": 4 + }, + "id": "avbd_paper_wall_horizontal_04_05", + "initial_anchor": [ + -2.48, + 0.0, + 1.225 + ], + "kind": "horizontal", + "linear_residual": 0.0003177049259907387, + "nearest_impact_distance": 0.7000178569150933, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07198986491425562, + "child": { + "body": "brick", + "column": 10, + "row": 4 + }, + "id": "avbd_paper_wall_horizontal_04_09", + "initial_anchor": [ + 0.0, + 0.0, + 1.225 + ], + "kind": "horizontal", + "linear_residual": 0.03917980494929332, + "nearest_impact_distance": 0.6096925454686156, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 7.412282743581463e-05, + "child": { + "body": "brick", + "column": 4, + "row": 5 + }, + "id": "avbd_paper_wall_horizontal_05_03", + "initial_anchor": [ + -3.41, + 0.0, + 1.495 + ], + "kind": "horizontal", + "linear_residual": 0.0010864253672803604, + "nearest_impact_distance": 0.31484122982862334, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 3, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00013975551481633518, + "child": { + "body": "brick", + "column": 6, + "row": 5 + }, + "id": "avbd_paper_wall_horizontal_05_05", + "initial_anchor": [ + -2.17, + 0.0, + 1.495 + ], + "kind": "horizontal", + "linear_residual": 0.00024146488299568486, + "nearest_impact_distance": 0.9316249245270332, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 5 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.986794622766347, + "child": { + "body": "brick", + "column": 9, + "row": 5 + }, + "id": "avbd_paper_wall_horizontal_05_08", + "initial_anchor": [ + -0.31, + 0.0, + 1.495 + ], + "kind": "horizontal", + "linear_residual": 2.9977048350433066, + "nearest_impact_distance": 0.2549999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.9862041234669918, + "child": { + "body": "brick", + "column": 10, + "row": 5 + }, + "id": "avbd_paper_wall_horizontal_05_09", + "initial_anchor": [ + 0.31, + 0.0, + 1.495 + ], + "kind": "horizontal", + "linear_residual": 2.944538808766031, + "nearest_impact_distance": 0.6703916765593081, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07200823246793243, + "child": { + "body": "brick", + "column": 10, + "row": 6 + }, + "id": "avbd_paper_wall_horizontal_06_09", + "initial_anchor": [ + 0.0, + 0.0, + 1.7650000000000001 + ], + "kind": "horizontal", + "linear_residual": 0.03920317645279754, + "nearest_impact_distance": 0.3103626910567699, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 5.96625263178113e-05, + "child": { + "body": "brick", + "column": 15, + "row": 6 + }, + "id": "avbd_paper_wall_horizontal_06_14", + "initial_anchor": [ + 3.1, + 0.0, + 1.7650000000000001 + ], + "kind": "horizontal", + "linear_residual": 8.350948683667934e-05, + "nearest_impact_distance": 0.585, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 14, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 6.727606780152505e-05, + "child": { + "body": "brick", + "column": 8, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_07", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 8.33528112915882e-05, + "nearest_impact_distance": 0.682367203197809, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0508006785959202, + "child": { + "body": "brick", + "column": 9, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_08", + "initial_anchor": [ + -0.31, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 0.03005413889551728, + "nearest_impact_distance": 0.28500000000000014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.032685781169600976, + "child": { + "body": "brick", + "column": 10, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_09", + "initial_anchor": [ + 0.31, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 0.05958023034239836, + "nearest_impact_distance": 0.6823672031978091, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 2.5422202032871222e-05, + "child": { + "body": "brick", + "column": 11, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_10", + "initial_anchor": [ + 0.9299999999999999, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 4.502942708499516e-05, + "nearest_impact_distance": 1.2723305388145016, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 7 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 5.863766543172099e-05, + "child": { + "body": "brick", + "column": 12, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_11", + "initial_anchor": [ + 1.55, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 5.48092682857919e-05, + "nearest_impact_distance": 1.5816842289154938, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 11, + "row": 7 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00012452399630945808, + "child": { + "body": "brick", + "column": 14, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_13", + "initial_anchor": [ + 2.79, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 4.178953678905234e-05, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 13, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.9031670673778535e-05, + "child": { + "body": "brick", + "column": 15, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_14", + "initial_anchor": [ + 3.41, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 1.2352286744896522e-05, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 14, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.1199231369680444e-05, + "child": { + "body": "brick", + "column": 16, + "row": 7 + }, + "id": "avbd_paper_wall_horizontal_07_15", + "initial_anchor": [ + 4.03, + 0.0, + 2.035 + ], + "kind": "horizontal", + "linear_residual": 0.0001340031677654748, + "nearest_impact_distance": 0.9818986709431887, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 7 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00011351066523310188, + "child": { + "body": "brick", + "column": 8, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_07", + "initial_anchor": [ + -1.24, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 0.00013915682593449488, + "nearest_impact_distance": 1.0830166203710818, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 8 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 9.659802995913795e-05, + "child": { + "body": "brick", + "column": 9, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_08", + "initial_anchor": [ + -0.62, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 8.602494121808291e-05, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07210042693396757, + "child": { + "body": "brick", + "column": 10, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_09", + "initial_anchor": [ + 0.0, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 0.03950056624335752, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0001344389667789005, + "child": { + "body": "brick", + "column": 11, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_10", + "initial_anchor": [ + 0.62, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 0.0004413669186352705, + "nearest_impact_distance": 1.0830166203710818, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 8 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 5.659510849433544e-05, + "child": { + "body": "brick", + "column": 12, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_11", + "initial_anchor": [ + 1.24, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 6.197960528519895e-05, + "nearest_impact_distance": 1.646367212987431, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 8 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 9.57635503397769e-05, + "child": { + "body": "brick", + "column": 13, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_12", + "initial_anchor": [ + 1.8599999999999999, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 4.073743815118093e-05, + "nearest_impact_distance": 1.2408162635942521, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 8 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 3.8745151060062634e-05, + "child": { + "body": "brick", + "column": 15, + "row": 8 + }, + "id": "avbd_paper_wall_horizontal_08_14", + "initial_anchor": [ + 3.1, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 4.4160657326908905e-05, + "nearest_impact_distance": 0.04499999999999993, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 14, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00013165979145726695, + "child": { + "body": "brick", + "column": 7, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_06", + "initial_anchor": [ + -1.55, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 0.0001348881228114202, + "nearest_impact_distance": 1.4893706724653875, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 6, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00010174506756935057, + "child": { + "body": "brick", + "column": 8, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_07", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 0.00032538676482942916, + "nearest_impact_distance": 1.0320004844960105, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0034775546135505796, + "child": { + "body": "brick", + "column": 9, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_08", + "initial_anchor": [ + -0.31, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 0.035754588185518243, + "nearest_impact_distance": 0.8250000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 9 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.06996119257068341, + "child": { + "body": "brick", + "column": 10, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_09", + "initial_anchor": [ + 0.31, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 0.09022402909448464, + "nearest_impact_distance": 1.0320004844960105, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00012491038257009013, + "child": { + "body": "brick", + "column": 11, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_10", + "initial_anchor": [ + 0.9299999999999999, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 0.0003401780088690345, + "nearest_impact_distance": 1.4893706724653875, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 8.894993279095074e-05, + "child": { + "body": "brick", + "column": 12, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_11", + "initial_anchor": [ + 1.55, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 5.995169287079955e-05, + "nearest_impact_distance": 1.5662455107677087, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 11, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00012338460575725245, + "child": { + "body": "brick", + "column": 13, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_12", + "initial_anchor": [ + 2.17, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 2.0662568610052424e-05, + "nearest_impact_distance": 0.9568307060290239, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 4.501354454797378e-05, + "child": { + "body": "brick", + "column": 15, + "row": 9 + }, + "id": "avbd_paper_wall_horizontal_09_14", + "initial_anchor": [ + 3.41, + 0.0, + 2.575 + ], + "kind": "horizontal", + "linear_residual": 2.5201043254053637e-05, + "nearest_impact_distance": 0.3830469945058962, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 14, + "row": 9 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 9.446726032214755e-05, + "child": { + "body": "brick", + "column": 7, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_06", + "initial_anchor": [ + -1.8599999999999999, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 8.558721498277468e-05, + "nearest_impact_distance": 1.7929375337696516, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 9.246703996673871e-05, + "child": { + "body": "brick", + "column": 8, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_07", + "initial_anchor": [ + -1.24, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 0.00020675313960114654, + "nearest_impact_distance": 1.436636697289889, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.003390794086335125, + "child": { + "body": "brick", + "column": 9, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_08", + "initial_anchor": [ + -0.62, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 0.03703699049461585, + "nearest_impact_distance": 1.1380355881957298, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.14319540244890616, + "child": { + "body": "brick", + "column": 10, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_09", + "initial_anchor": [ + 0.0, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 0.09660983537754142, + "nearest_impact_distance": 1.1380355881957298, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.09585560105846824, + "child": { + "body": "brick", + "column": 11, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_10", + "initial_anchor": [ + 0.62, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 0.07573649179545794, + "nearest_impact_distance": 1.436636697289889, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00010528820551709924, + "child": { + "body": "brick", + "column": 12, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_11", + "initial_anchor": [ + 1.24, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 0.0001869566506650867, + "nearest_impact_distance": 1.897768426336575, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00012322251247160276, + "child": { + "body": "brick", + "column": 13, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_12", + "initial_anchor": [ + 1.8599999999999999, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 4.050536392714558e-05, + "nearest_impact_distance": 1.3351498043290875, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0001314130424367796, + "child": { + "body": "brick", + "column": 14, + "row": 10 + }, + "id": "avbd_paper_wall_horizontal_10_13", + "initial_anchor": [ + 2.48, + 0.0, + 2.845 + ], + "kind": "horizontal", + "linear_residual": 3.2734614604267465e-05, + "nearest_impact_distance": 0.7933630946798573, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 13, + "row": 10 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00012307669640165242, + "child": { + "body": "brick", + "column": 7, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_06", + "initial_anchor": [ + -1.55, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.00017107311913809516, + "nearest_impact_distance": 1.8441325874242342, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 6, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.06323981333789779, + "child": { + "body": "brick", + "column": 8, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_07", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.028240107892692237, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 7, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.2236689133019107, + "child": { + "body": "brick", + "column": 9, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_08", + "initial_anchor": [ + -0.31, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.3106040524460991, + "nearest_impact_distance": 1.3650000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.6450970791045859, + "child": { + "body": "brick", + "column": 10, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_09", + "initial_anchor": [ + 0.31, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.20402989642159053, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.6181447419658666, + "child": { + "body": "brick", + "column": 11, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_10", + "initial_anchor": [ + 0.9299999999999999, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 0.22400217238775755, + "nearest_impact_distance": 1.8441325874242342, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00010838872349518585, + "child": { + "body": "brick", + "column": 12, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_11", + "initial_anchor": [ + 1.55, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 7.445379288211318e-05, + "nearest_impact_distance": 1.728503688164998, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 11, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00016594092358494523, + "child": { + "body": "brick", + "column": 13, + "row": 11 + }, + "id": "avbd_paper_wall_horizontal_11_12", + "initial_anchor": [ + 2.17, + 0.0, + 3.115 + ], + "kind": "horizontal", + "linear_residual": 6.632164643489436e-05, + "nearest_impact_distance": 1.2042113601855782, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 12, + "row": 11 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0007486251229563333, + "child": { + "body": "brick", + "column": 0, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_00_01_00", + "initial_anchor": [ + -5.89, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.0017110466659398871, + "nearest_impact_distance": 3.012030046330879, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 0, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00044031573362472666, + "child": { + "body": "brick", + "column": 0, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_01_01_00", + "initial_anchor": [ + -5.89, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.0014216976895881622, + "nearest_impact_distance": 3.012030046330879, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 1, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03498512379835136, + "child": { + "body": "brick", + "column": 8, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_09_01_08", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.25036308919608247, + "nearest_impact_distance": 1.4719459908570014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.15184196163105, + "child": { + "body": "brick", + "column": 9, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_09_01_09", + "initial_anchor": [ + -0.31, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.3379535838441382, + "nearest_impact_distance": 1.335, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.1192942701526698, + "child": { + "body": "brick", + "column": 9, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_10_01_09", + "initial_anchor": [ + -0.31, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.3195441713339893, + "nearest_impact_distance": 1.335, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0434212054146851, + "child": { + "body": "brick", + "column": 10, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_10_01_10", + "initial_anchor": [ + 0.31, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 0.1773692479259052, + "nearest_impact_distance": 1.4719459908570014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 5.096719949244395e-06, + "child": { + "body": "brick", + "column": 18, + "row": 1 + }, + "id": "avbd_paper_wall_vertical_00_19_01_18", + "initial_anchor": [ + 5.27, + 0.0, + 0.41500000000000004 + ], + "kind": "vertical", + "linear_residual": 1.7549379217433853e-05, + "nearest_impact_distance": 2.9074258374032516, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 19, + "row": 0 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.00016367832145728695, + "child": { + "body": "brick", + "column": 7, + "row": 2 + }, + "id": "avbd_paper_wall_vertical_01_06_02_07", + "initial_anchor": [ + -1.8599999999999999, + 0.0, + 0.685 + ], + "kind": "vertical", + "linear_residual": 0.0019476964572850653, + "nearest_impact_distance": 1.5118945069018541, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.1470012524532196, + "child": { + "body": "brick", + "column": 9, + "row": 2 + }, + "id": "avbd_paper_wall_vertical_01_09_02_09", + "initial_anchor": [ + -0.62, + 0.0, + 0.685 + ], + "kind": "vertical", + "linear_residual": 0.4256329077416848, + "nearest_impact_distance": 1.1092001622791081, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.1521771539694348, + "child": { + "body": "brick", + "column": 10, + "row": 2 + }, + "id": "avbd_paper_wall_vertical_01_09_02_10", + "initial_anchor": [ + 0.0, + 0.0, + 0.685 + ], + "kind": "vertical", + "linear_residual": 0.48608813375635546, + "nearest_impact_distance": 1.1092001622791081, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0002656435325824436, + "child": { + "body": "brick", + "column": 11, + "row": 2 + }, + "id": "avbd_paper_wall_vertical_01_10_02_11", + "initial_anchor": [ + 0.62, + 0.0, + 0.685 + ], + "kind": "vertical", + "linear_residual": 0.0003019005547107454, + "nearest_impact_distance": 1.4139041693127579, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 1 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 4.303001277156293e-05, + "child": { + "body": "brick", + "column": 4, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_04_03_04", + "initial_anchor": [ + -3.41, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0004707966141173212, + "nearest_impact_distance": 0.6709135562797938, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 2 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00016218404556997057, + "child": { + "body": "brick", + "column": 5, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_05_03_05", + "initial_anchor": [ + -2.79, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0026014818223025413, + "nearest_impact_distance": 0.6709135562797938, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 2 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00014742873641197037, + "child": { + "body": "brick", + "column": 5, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_06_03_05", + "initial_anchor": [ + -2.79, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0025632182571769186, + "nearest_impact_distance": 0.6709135562797938, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 2 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00013235244935609863, + "child": { + "body": "brick", + "column": 6, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_06_03_06", + "initial_anchor": [ + -2.17, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.002428555800558254, + "nearest_impact_distance": 1.1040493648383665, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00021846579205969476, + "child": { + "body": "brick", + "column": 8, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_09_03_08", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.00037979683047264433, + "nearest_impact_distance": 1.0081790515578073, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0340294884759851, + "child": { + "body": "brick", + "column": 9, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_09_03_09", + "initial_anchor": [ + -0.31, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.019723798363170335, + "nearest_impact_distance": 0.7949999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 2 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.05054477577524699, + "child": { + "body": "brick", + "column": 9, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_10_03_09", + "initial_anchor": [ + -0.31, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.028931842625104433, + "nearest_impact_distance": 0.7949999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 2 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.000363834747988628, + "child": { + "body": "brick", + "column": 10, + "row": 3 + }, + "id": "avbd_paper_wall_vertical_02_10_03_10", + "initial_anchor": [ + 0.31, + 0.0, + 0.9550000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0005775773352096748, + "nearest_impact_distance": 1.0081790515578073, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 2 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00028391203673519436, + "child": { + "body": "brick", + "column": 5, + "row": 4 + }, + "id": "avbd_paper_wall_vertical_03_04_04_05", + "initial_anchor": [ + -3.1, + 0.0, + 1.225 + ], + "kind": "vertical", + "linear_residual": 0.0022953270360515717, + "nearest_impact_distance": 0.32499999999999996, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00016742938636451295, + "child": { + "body": "brick", + "column": 5, + "row": 4 + }, + "id": "avbd_paper_wall_vertical_03_05_04_05", + "initial_anchor": [ + -3.1, + 0.0, + 1.225 + ], + "kind": "vertical", + "linear_residual": 0.00032741369637028184, + "nearest_impact_distance": 0.32499999999999996, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 2.8560453567728285e-05, + "child": { + "body": "brick", + "column": 6, + "row": 4 + }, + "id": "avbd_paper_wall_vertical_03_06_04_06", + "initial_anchor": [ + -2.48, + 0.0, + 1.225 + ], + "kind": "vertical", + "linear_residual": 0.00014616911134801629, + "nearest_impact_distance": 0.7000178569150933, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.034094456536643204, + "child": { + "body": "brick", + "column": 9, + "row": 4 + }, + "id": "avbd_paper_wall_vertical_03_09_04_09", + "initial_anchor": [ + -0.62, + 0.0, + 1.225 + ], + "kind": "vertical", + "linear_residual": 0.01916135141969149, + "nearest_impact_distance": 0.6096925454686156, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.05051899812433119, + "child": { + "body": "brick", + "column": 10, + "row": 4 + }, + "id": "avbd_paper_wall_vertical_03_09_04_10", + "initial_anchor": [ + 0.0, + 0.0, + 1.225 + ], + "kind": "vertical", + "linear_residual": 0.0388732851197865, + "nearest_impact_distance": 0.6096925454686156, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 3 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00023258293712806726, + "child": { + "body": "brick", + "column": 4, + "row": 5 + }, + "id": "avbd_paper_wall_vertical_04_04_05_04", + "initial_anchor": [ + -3.41, + 0.0, + 1.495 + ], + "kind": "vertical", + "linear_residual": 0.001771271291211973, + "nearest_impact_distance": 0.31484122982862334, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00015192326013366048, + "child": { + "body": "brick", + "column": 5, + "row": 5 + }, + "id": "avbd_paper_wall_vertical_04_06_05_05", + "initial_anchor": [ + -2.79, + 0.0, + 1.495 + ], + "kind": "vertical", + "linear_residual": 0.0002629470712519172, + "nearest_impact_distance": 0.31484122982862334, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.9868068257219826, + "child": { + "body": "brick", + "column": 9, + "row": 5 + }, + "id": "avbd_paper_wall_vertical_04_09_05_09", + "initial_anchor": [ + -0.31, + 0.0, + 1.495 + ], + "kind": "vertical", + "linear_residual": 2.9977234546485305, + "nearest_impact_distance": 0.2549999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.9862153839683574, + "child": { + "body": "brick", + "column": 9, + "row": 5 + }, + "id": "avbd_paper_wall_vertical_04_10_05_09", + "initial_anchor": [ + -0.31, + 0.0, + 1.495 + ], + "kind": "vertical", + "linear_residual": 2.9843022059215305, + "nearest_impact_distance": 0.2549999999999999, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 8.66871335429737e-05, + "child": { + "body": "brick", + "column": 4, + "row": 6 + }, + "id": "avbd_paper_wall_vertical_05_04_06_04", + "initial_anchor": [ + -3.7199999999999998, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 0.0004491868160841388, + "nearest_impact_distance": 0.6562202374203341, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 5.827107040199133e-05, + "child": { + "body": "brick", + "column": 5, + "row": 6 + }, + "id": "avbd_paper_wall_vertical_05_05_06_05", + "initial_anchor": [ + -3.1, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 0.00013970802289606175, + "nearest_impact_distance": 0.21500000000000008, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00012493907735383971, + "child": { + "body": "brick", + "column": 6, + "row": 6 + }, + "id": "avbd_paper_wall_vertical_05_06_06_06", + "initial_anchor": [ + -2.48, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 0.00017887559549445686, + "nearest_impact_distance": 0.6562202374203345, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 6, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.9867769026666884, + "child": { + "body": "brick", + "column": 9, + "row": 6 + }, + "id": "avbd_paper_wall_vertical_05_09_06_09", + "initial_anchor": [ + -0.62, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 2.9100609171723484, + "nearest_impact_distance": 0.3103626910567699, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 1.9861937504555238, + "child": { + "body": "brick", + "column": 10, + "row": 6 + }, + "id": "avbd_paper_wall_vertical_05_09_06_10", + "initial_anchor": [ + 0.0, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 2.8777000639489425, + "nearest_impact_distance": 0.3103626910567699, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.050791892660706, + "child": { + "body": "brick", + "column": 9, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_09_07_09", + "initial_anchor": [ + -0.31, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.030040927632883574, + "nearest_impact_distance": 0.28500000000000014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.032682984163640925, + "child": { + "body": "brick", + "column": 9, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_10_07_09", + "initial_anchor": [ + -0.31, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.04231460811052817, + "nearest_impact_distance": 0.28500000000000014, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0001791559727919049, + "child": { + "body": "brick", + "column": 14, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_14_07_14", + "initial_anchor": [ + 2.79, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 8.039931584768365e-05, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 14, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00012000439773828316, + "child": { + "body": "brick", + "column": 14, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_15_07_14", + "initial_anchor": [ + 2.79, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.0001565681077551789, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0001268610811978417, + "child": { + "body": "brick", + "column": 15, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_15_07_15", + "initial_anchor": [ + 3.41, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.00022977023580359002, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 6.316170718969104e-05, + "child": { + "body": "brick", + "column": 15, + "row": 7 + }, + "id": "avbd_paper_wall_vertical_06_16_07_15", + "initial_anchor": [ + 3.41, + 0.0, + 2.035 + ], + "kind": "vertical", + "linear_residual": 0.0002275100209183278, + "nearest_impact_distance": 0.441955880150949, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 16, + "row": 6 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 9.350820007577673e-05, + "child": { + "body": "brick", + "column": 9, + "row": 8 + }, + "id": "avbd_paper_wall_vertical_07_08_08_09", + "initial_anchor": [ + -0.62, + 0.0, + 2.305 + ], + "kind": "vertical", + "linear_residual": 0.000243274904269079, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.05079521931520533, + "child": { + "body": "brick", + "column": 9, + "row": 8 + }, + "id": "avbd_paper_wall_vertical_07_09_08_09", + "initial_anchor": [ + -0.62, + 0.0, + 2.305 + ], + "kind": "vertical", + "linear_residual": 0.038348671859416116, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.03279129424811508, + "child": { + "body": "brick", + "column": 10, + "row": 8 + }, + "id": "avbd_paper_wall_vertical_07_09_08_10", + "initial_anchor": [ + 0.0, + 0.0, + 2.305 + ], + "kind": "vertical", + "linear_residual": 0.04765913240436689, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00012909125276191637, + "child": { + "body": "brick", + "column": 10, + "row": 8 + }, + "id": "avbd_paper_wall_vertical_07_10_08_10", + "initial_anchor": [ + 0.0, + 0.0, + 2.305 + ], + "kind": "vertical", + "linear_residual": 0.0004964805087161573, + "nearest_impact_distance": 0.6357082664241517, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 7 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.003433393648785143, + "child": { + "body": "brick", + "column": 9, + "row": 9 + }, + "id": "avbd_paper_wall_vertical_08_09_09_09", + "initial_anchor": [ + -0.31, + 0.0, + 2.575 + ], + "kind": "vertical", + "linear_residual": 0.0359038347330867, + "nearest_impact_distance": 0.8250000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.06996766286068944, + "child": { + "body": "brick", + "column": 9, + "row": 9 + }, + "id": "avbd_paper_wall_vertical_08_10_09_09", + "initial_anchor": [ + -0.31, + 0.0, + 2.575 + ], + "kind": "vertical", + "linear_residual": 0.05511482177383603, + "nearest_impact_distance": 0.8250000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.00012604542320528947, + "child": { + "body": "brick", + "column": 10, + "row": 9 + }, + "id": "avbd_paper_wall_vertical_08_11_09_10", + "initial_anchor": [ + 0.31, + 0.0, + 2.575 + ], + "kind": "vertical", + "linear_residual": 0.00040751608126051104, + "nearest_impact_distance": 1.0320004844960105, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 8 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 7.494758820140924e-05, + "child": { + "body": "brick", + "column": 14, + "row": 9 + }, + "id": "avbd_paper_wall_vertical_08_15_09_14", + "initial_anchor": [ + 2.79, + 0.0, + 2.575 + ], + "kind": "vertical", + "linear_residual": 3.903494926072097e-05, + "nearest_impact_distance": 0.3830469945058962, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0033436326577510433, + "child": { + "body": "brick", + "column": 9, + "row": 10 + }, + "id": "avbd_paper_wall_vertical_09_08_10_09", + "initial_anchor": [ + -0.62, + 0.0, + 2.845 + ], + "kind": "vertical", + "linear_residual": 0.03691705255392752, + "nearest_impact_distance": 1.1380355881957298, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.14315995907195284, + "child": { + "body": "brick", + "column": 10, + "row": 10 + }, + "id": "avbd_paper_wall_vertical_09_09_10_10", + "initial_anchor": [ + 0.0, + 0.0, + 2.845 + ], + "kind": "vertical", + "linear_residual": 0.09627147739786351, + "nearest_impact_distance": 1.1380355881957298, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.09582958501924162, + "child": { + "body": "brick", + "column": 10, + "row": 10 + }, + "id": "avbd_paper_wall_vertical_09_10_10_10", + "initial_anchor": [ + 0.0, + 0.0, + 2.845 + ], + "kind": "vertical", + "linear_residual": 0.04929392053208221, + "nearest_impact_distance": 1.1380355881957298, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": true + }, + { + "angular_residual_radians": 9.256818423091092e-05, + "child": { + "body": "brick", + "column": 11, + "row": 10 + }, + "id": "avbd_paper_wall_vertical_09_11_10_11", + "initial_anchor": [ + 0.62, + 0.0, + 2.845 + ], + "kind": "vertical", + "linear_residual": 0.00026172548097310285, + "nearest_impact_distance": 1.436636697289889, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 9 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.06321262899379812, + "child": { + "body": "brick", + "column": 8, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_08_11_08", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.028239499884958396, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 8, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.06580490319499595, + "child": { + "body": "brick", + "column": 8, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_09_11_08", + "initial_anchor": [ + -0.9299999999999999, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.01676667018711138, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.2071050398782088, + "child": { + "body": "brick", + "column": 9, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_09_11_09", + "initial_anchor": [ + -0.31, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.3401300061553407, + "nearest_impact_distance": 1.3650000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 1.275087522117806, + "child": { + "body": "brick", + "column": 9, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_10_11_09", + "initial_anchor": [ + -0.31, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.4066721394402155, + "nearest_impact_distance": 1.3650000000000002, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.6655993175318041, + "child": { + "body": "brick", + "column": 10, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_10_11_10", + "initial_anchor": [ + 0.31, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.3160956755753448, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 10, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.6181309936159962, + "child": { + "body": "brick", + "column": 10, + "row": 11 + }, + "id": "avbd_paper_wall_vertical_10_11_11_10", + "initial_anchor": [ + 0.31, + 0.0, + 3.115 + ], + "kind": "vertical", + "linear_residual": 0.24488813826232786, + "nearest_impact_distance": 1.4992081243109645, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 11, + "row": 10 + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04017648018274321, + "child": { + "body": "brick", + "column": 0, + "row": 0 + }, + "id": "avbd_paper_wall_base_00", + "initial_anchor": [ + -6.2, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.020756199680989722, + "nearest_impact_distance": 3.403531254447357, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04018603527064022, + "child": { + "body": "brick", + "column": 1, + "row": 0 + }, + "id": "avbd_paper_wall_base_01", + "initial_anchor": [ + -5.58, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.030569187726726194, + "nearest_impact_distance": 2.850337699291086, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.040194718343749075, + "child": { + "body": "brick", + "column": 2, + "row": 0 + }, + "id": "avbd_paper_wall_base_02", + "initial_anchor": [ + -4.96, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.05163045320197632, + "nearest_impact_distance": 2.331013727973304, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04020183536655964, + "child": { + "body": "brick", + "column": 3, + "row": 0 + }, + "id": "avbd_paper_wall_base_03", + "initial_anchor": [ + -4.34, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.07506581736102096, + "nearest_impact_distance": 1.8739330297531978, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04020581511098696, + "child": { + "body": "brick", + "column": 4, + "row": 0 + }, + "id": "avbd_paper_wall_base_04", + "initial_anchor": [ + -3.7199999999999998, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.09923919582316344, + "nearest_impact_distance": 1.5357164451812058, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.040205582171706125, + "child": { + "body": "brick", + "column": 5, + "row": 0 + }, + "id": "avbd_paper_wall_base_05", + "initial_anchor": [ + -3.1, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.12371781273607542, + "nearest_impact_distance": 1.405, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04019670020483021, + "child": { + "body": "brick", + "column": 6, + "row": 0 + }, + "id": "avbd_paper_wall_base_06", + "initial_anchor": [ + -2.48, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.14834137253694382, + "nearest_impact_distance": 1.5357164451812062, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04018409038026448, + "child": { + "body": "brick", + "column": 7, + "row": 0 + }, + "id": "avbd_paper_wall_base_07", + "initial_anchor": [ + -1.8599999999999999, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.17323976645125555, + "nearest_impact_distance": 1.8739330297531982, + "nearest_impact_index": 0, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.04015992552839139, + "child": { + "body": "brick", + "column": 8, + "row": 0 + }, + "id": "avbd_paper_wall_base_08", + "initial_anchor": [ + -1.24, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.19809022347232627, + "nearest_impact_distance": 1.85497304562627, + "nearest_impact_index": 1, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0748337877062595, + "child": { + "body": "brick", + "column": 9, + "row": 0 + }, + "id": "avbd_paper_wall_base_09", + "initial_anchor": [ + -0.62, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.02470494534359371, + "nearest_impact_distance": 1.6346635739503097, + "nearest_impact_index": 1, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03309073747756907, + "child": { + "body": "brick", + "column": 10, + "row": 0 + }, + "id": "avbd_paper_wall_base_10", + "initial_anchor": [ + 0.0, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.02402553163963154, + "nearest_impact_distance": 1.6346635739503097, + "nearest_impact_index": 1, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03175058523804859, + "child": { + "body": "brick", + "column": 11, + "row": 0 + }, + "id": "avbd_paper_wall_base_11", + "initial_anchor": [ + 0.62, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.191369391962739, + "nearest_impact_distance": 1.85497304562627, + "nearest_impact_index": 1, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03181293346306328, + "child": { + "body": "brick", + "column": 12, + "row": 0 + }, + "id": "avbd_paper_wall_base_12", + "initial_anchor": [ + 1.24, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.17180506370456072, + "nearest_impact_distance": 2.231260854315335, + "nearest_impact_index": 1, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03180659592265201, + "child": { + "body": "brick", + "column": 13, + "row": 0 + }, + "id": "avbd_paper_wall_base_13", + "initial_anchor": [ + 1.8599999999999999, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.15224251631010338, + "nearest_impact_distance": 2.5297480111663297, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03180919551051811, + "child": { + "body": "brick", + "column": 14, + "row": 0 + }, + "id": "avbd_paper_wall_base_14", + "initial_anchor": [ + 2.48, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.13274931127407896, + "nearest_impact_distance": 2.2905075856674215, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03181676543602551, + "child": { + "body": "brick", + "column": 15, + "row": 0 + }, + "id": "avbd_paper_wall_base_15", + "initial_anchor": [ + 3.1, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.11333385433435407, + "nearest_impact_distance": 2.205, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03182483048492695, + "child": { + "body": "brick", + "column": 16, + "row": 0 + }, + "id": "avbd_paper_wall_base_16", + "initial_anchor": [ + 3.7199999999999998, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.09404521936212536, + "nearest_impact_distance": 2.2905075856674215, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03183399448165867, + "child": { + "body": "brick", + "column": 17, + "row": 0 + }, + "id": "avbd_paper_wall_base_17", + "initial_anchor": [ + 4.34, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.0749860002803718, + "nearest_impact_distance": 2.5297480111663293, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.031844100162697946, + "child": { + "body": "brick", + "column": 18, + "row": 0 + }, + "id": "avbd_paper_wall_base_18", + "initial_anchor": [ + 4.96, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.05639143741211812, + "nearest_impact_distance": 2.8847226903118433, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.03185351562056426, + "child": { + "body": "brick", + "column": 19, + "row": 0 + }, + "id": "avbd_paper_wall_base_19", + "initial_anchor": [ + 5.58, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.03893695438019177, + "nearest_impact_distance": 3.3184974009331394, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + }, + { + "angular_residual_radians": 0.0318572650648186, + "child": { + "body": "brick", + "column": 20, + "row": 0 + }, + "id": "avbd_paper_wall_base_20", + "initial_anchor": [ + 6.2, + 0.0, + 0.145 + ], + "kind": "base", + "linear_residual": 0.02514344564417163, + "nearest_impact_distance": 3.804211482028832, + "nearest_impact_index": 2, + "parent": { + "body": "ground", + "column": null, + "row": null + }, + "within_impact_band": false, + "within_impact_region": false + } + ], + "broken_joints": 154, + "broken_joints_outside_impact_regions": 79, + "checkpoint": "outcome", + "contact_count": 57, "evaluated": true, "frame": 120, "impact_band_displaced_counts": [ - 4, - 10, - 6 + 11, + 14, + 11 ], + "impact_damage_displacement_threshold": 0.1, + "joint_residuals_finite": true, "last_step_iterations": 20, - "max_brick_displacement": 32.96448949589009, - "outside_brick_count": 181, - "outside_retained_fraction": 0.9116022099447514, + "max_brick_displacement": 2.7828067966709646, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": 0.0003165408465541643, + "maximum_outside_impact_unbroken_joint_linear_residual": 0.000771892469771506, + "maximum_unbroken_joint_angular_residual_radians": 0.0003165408465541643, + "maximum_unbroken_joint_linear_residual": 0.000771892469771506, + "maximum_wall_normal_displacement": 2.6220805695002185, + "outside_brick_count": 176, + "outside_impact_unbroken_joint_residual_count": 405, + "outside_retained_fraction": 0.9943181818181818, + "retained_displacement_threshold": 0.5, + "rms_outside_impact_unbroken_joint_angular_residual_radians": 5.1690091989685266e-05, + "rms_outside_impact_unbroken_joint_linear_residual": 0.00016543468622431408, + "rms_unbroken_joint_angular_residual_radians": 5.081266303012909e-05, + "rms_unbroken_joint_linear_residual": 0.00016120628730705022, + "rms_wall_normal_displacement": 0.2131903054899529, "status": "pass", "threshold_checks": { "damage_in_three_impact_bands": true, "finite_state": true, "fracture_activated": true, - "fracture_localized": true, + "fracture_count_bounded": true, + "fracture_identity_matches": true, "outside_wall_retained": true, + "retained_joint_rows_satisfied": true, "total_wall_retained": true }, "thresholds_pass": true, - "total_retained_fraction": 0.8253968253968254, - "unbroken_joints": 353, + "total_retained_fraction": 0.9920634920634921, + "unbroken_joint_residual_count": 558, + "unbroken_joints": 558, "world_time": 1.9999999999999978 }, "outcome_oracle": { - "displacement_threshold": 0.5, "evaluation_frame": 120, + "expected_broken_joint_ids_sha256": "31b187538ac1549563be368a4d7e304d1caef6ea11ba65b624d48f5f27468503", "impact_band_radius": 0.85, - "maximum_broken_joints": 500, - "minimum_broken_joints": 300, + "impact_damage_displacement_threshold": 0.1, + "joint_evidence_frames": [ + 60, + 120 + ], + "maximum_broken_joints": 250, + "maximum_unbroken_joint_angular_residual_radians": 0.001, + "maximum_unbroken_joint_linear_residual": 0.002, + "minimum_broken_joints": 150, "minimum_displaced_bricks_per_impact_band": 4, - "minimum_outside_retained_fraction": 0.8, - "minimum_total_retained_fraction": 0.7, - "minimum_unbroken_joints": 200, - "outside_radius": 1.15 + "minimum_outside_retained_fraction": 0.95, + "minimum_total_retained_fraction": 0.95, + "minimum_unbroken_joints": 450, + "outside_radius": 1.15, + "retained_displacement_threshold": 0.5 }, "resolved_configuration": [ { @@ -690,7 +8773,7 @@ ], "scene_contract": { "ball_count": 3, - "break_force": 8500.0, + "break_force": 5000.0, "breakable_joints": 712, "brick_count": 252, "collision_shapes": 256, @@ -704,35 +8787,283 @@ "solver": "public_avbd", "time_step_ms": 16.666666666666668 }, - "scene_spec_fingerprint": "2a746821cc10faee", + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "view_report": { "camera": { - "azimuth": -1.5707963267948966, - "distance": 11.0, - "elevation": 0.0, + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, "target": [ 0.0, - 0.0, - 1.4 + 0.45, + 1.6 ] }, "focus": [ "avbd_paper_wall_brick_00_00_visual", + "avbd_paper_wall_brick_00_01_visual", + "avbd_paper_wall_brick_00_02_visual", + "avbd_paper_wall_brick_00_03_visual", + "avbd_paper_wall_brick_00_04_visual", + "avbd_paper_wall_brick_00_05_visual", + "avbd_paper_wall_brick_00_06_visual", + "avbd_paper_wall_brick_00_07_visual", + "avbd_paper_wall_brick_00_08_visual", + "avbd_paper_wall_brick_00_09_visual", + "avbd_paper_wall_brick_00_10_visual", + "avbd_paper_wall_brick_00_11_visual", + "avbd_paper_wall_brick_00_12_visual", + "avbd_paper_wall_brick_00_13_visual", + "avbd_paper_wall_brick_00_14_visual", + "avbd_paper_wall_brick_00_15_visual", + "avbd_paper_wall_brick_00_16_visual", + "avbd_paper_wall_brick_00_17_visual", + "avbd_paper_wall_brick_00_18_visual", + "avbd_paper_wall_brick_00_19_visual", "avbd_paper_wall_brick_00_20_visual", + "avbd_paper_wall_brick_01_00_visual", + "avbd_paper_wall_brick_01_01_visual", + "avbd_paper_wall_brick_01_02_visual", + "avbd_paper_wall_brick_01_03_visual", + "avbd_paper_wall_brick_01_04_visual", + "avbd_paper_wall_brick_01_05_visual", + "avbd_paper_wall_brick_01_06_visual", + "avbd_paper_wall_brick_01_07_visual", + "avbd_paper_wall_brick_01_08_visual", + "avbd_paper_wall_brick_01_09_visual", + "avbd_paper_wall_brick_01_10_visual", + "avbd_paper_wall_brick_01_11_visual", + "avbd_paper_wall_brick_01_12_visual", + "avbd_paper_wall_brick_01_13_visual", + "avbd_paper_wall_brick_01_14_visual", + "avbd_paper_wall_brick_01_15_visual", + "avbd_paper_wall_brick_01_16_visual", + "avbd_paper_wall_brick_01_17_visual", + "avbd_paper_wall_brick_01_18_visual", + "avbd_paper_wall_brick_01_19_visual", + "avbd_paper_wall_brick_01_20_visual", + "avbd_paper_wall_brick_02_00_visual", + "avbd_paper_wall_brick_02_01_visual", + "avbd_paper_wall_brick_02_02_visual", + "avbd_paper_wall_brick_02_03_visual", + "avbd_paper_wall_brick_02_04_visual", + "avbd_paper_wall_brick_02_05_visual", + "avbd_paper_wall_brick_02_06_visual", + "avbd_paper_wall_brick_02_07_visual", + "avbd_paper_wall_brick_02_08_visual", + "avbd_paper_wall_brick_02_09_visual", + "avbd_paper_wall_brick_02_10_visual", + "avbd_paper_wall_brick_02_11_visual", + "avbd_paper_wall_brick_02_12_visual", + "avbd_paper_wall_brick_02_13_visual", + "avbd_paper_wall_brick_02_14_visual", + "avbd_paper_wall_brick_02_15_visual", + "avbd_paper_wall_brick_02_16_visual", + "avbd_paper_wall_brick_02_17_visual", + "avbd_paper_wall_brick_02_18_visual", + "avbd_paper_wall_brick_02_19_visual", + "avbd_paper_wall_brick_02_20_visual", + "avbd_paper_wall_brick_03_00_visual", + "avbd_paper_wall_brick_03_01_visual", + "avbd_paper_wall_brick_03_02_visual", + "avbd_paper_wall_brick_03_03_visual", + "avbd_paper_wall_brick_03_04_visual", + "avbd_paper_wall_brick_03_05_visual", + "avbd_paper_wall_brick_03_06_visual", + "avbd_paper_wall_brick_03_07_visual", + "avbd_paper_wall_brick_03_08_visual", + "avbd_paper_wall_brick_03_09_visual", + "avbd_paper_wall_brick_03_10_visual", + "avbd_paper_wall_brick_03_11_visual", + "avbd_paper_wall_brick_03_12_visual", + "avbd_paper_wall_brick_03_13_visual", + "avbd_paper_wall_brick_03_14_visual", + "avbd_paper_wall_brick_03_15_visual", + "avbd_paper_wall_brick_03_16_visual", + "avbd_paper_wall_brick_03_17_visual", + "avbd_paper_wall_brick_03_18_visual", + "avbd_paper_wall_brick_03_19_visual", + "avbd_paper_wall_brick_03_20_visual", + "avbd_paper_wall_brick_04_00_visual", + "avbd_paper_wall_brick_04_01_visual", + "avbd_paper_wall_brick_04_02_visual", + "avbd_paper_wall_brick_04_03_visual", + "avbd_paper_wall_brick_04_04_visual", + "avbd_paper_wall_brick_04_05_visual", + "avbd_paper_wall_brick_04_06_visual", + "avbd_paper_wall_brick_04_07_visual", + "avbd_paper_wall_brick_04_08_visual", + "avbd_paper_wall_brick_04_09_visual", + "avbd_paper_wall_brick_04_10_visual", + "avbd_paper_wall_brick_04_11_visual", + "avbd_paper_wall_brick_04_12_visual", + "avbd_paper_wall_brick_04_13_visual", + "avbd_paper_wall_brick_04_14_visual", + "avbd_paper_wall_brick_04_15_visual", + "avbd_paper_wall_brick_04_16_visual", + "avbd_paper_wall_brick_04_17_visual", + "avbd_paper_wall_brick_04_18_visual", + "avbd_paper_wall_brick_04_19_visual", + "avbd_paper_wall_brick_04_20_visual", + "avbd_paper_wall_brick_05_00_visual", + "avbd_paper_wall_brick_05_01_visual", + "avbd_paper_wall_brick_05_02_visual", + "avbd_paper_wall_brick_05_03_visual", + "avbd_paper_wall_brick_05_04_visual", + "avbd_paper_wall_brick_05_05_visual", + "avbd_paper_wall_brick_05_06_visual", + "avbd_paper_wall_brick_05_07_visual", + "avbd_paper_wall_brick_05_08_visual", + "avbd_paper_wall_brick_05_09_visual", + "avbd_paper_wall_brick_05_10_visual", + "avbd_paper_wall_brick_05_11_visual", + "avbd_paper_wall_brick_05_12_visual", + "avbd_paper_wall_brick_05_13_visual", + "avbd_paper_wall_brick_05_14_visual", + "avbd_paper_wall_brick_05_15_visual", + "avbd_paper_wall_brick_05_16_visual", + "avbd_paper_wall_brick_05_17_visual", + "avbd_paper_wall_brick_05_18_visual", + "avbd_paper_wall_brick_05_19_visual", + "avbd_paper_wall_brick_05_20_visual", + "avbd_paper_wall_brick_06_00_visual", + "avbd_paper_wall_brick_06_01_visual", + "avbd_paper_wall_brick_06_02_visual", + "avbd_paper_wall_brick_06_03_visual", + "avbd_paper_wall_brick_06_04_visual", + "avbd_paper_wall_brick_06_05_visual", + "avbd_paper_wall_brick_06_06_visual", + "avbd_paper_wall_brick_06_07_visual", + "avbd_paper_wall_brick_06_08_visual", + "avbd_paper_wall_brick_06_09_visual", + "avbd_paper_wall_brick_06_10_visual", + "avbd_paper_wall_brick_06_11_visual", + "avbd_paper_wall_brick_06_12_visual", + "avbd_paper_wall_brick_06_13_visual", + "avbd_paper_wall_brick_06_14_visual", + "avbd_paper_wall_brick_06_15_visual", + "avbd_paper_wall_brick_06_16_visual", + "avbd_paper_wall_brick_06_17_visual", + "avbd_paper_wall_brick_06_18_visual", + "avbd_paper_wall_brick_06_19_visual", + "avbd_paper_wall_brick_06_20_visual", + "avbd_paper_wall_brick_07_00_visual", + "avbd_paper_wall_brick_07_01_visual", + "avbd_paper_wall_brick_07_02_visual", + "avbd_paper_wall_brick_07_03_visual", + "avbd_paper_wall_brick_07_04_visual", + "avbd_paper_wall_brick_07_05_visual", + "avbd_paper_wall_brick_07_06_visual", + "avbd_paper_wall_brick_07_07_visual", + "avbd_paper_wall_brick_07_08_visual", + "avbd_paper_wall_brick_07_09_visual", + "avbd_paper_wall_brick_07_10_visual", + "avbd_paper_wall_brick_07_11_visual", + "avbd_paper_wall_brick_07_12_visual", + "avbd_paper_wall_brick_07_13_visual", + "avbd_paper_wall_brick_07_14_visual", + "avbd_paper_wall_brick_07_15_visual", + "avbd_paper_wall_brick_07_16_visual", + "avbd_paper_wall_brick_07_17_visual", + "avbd_paper_wall_brick_07_18_visual", + "avbd_paper_wall_brick_07_19_visual", + "avbd_paper_wall_brick_07_20_visual", + "avbd_paper_wall_brick_08_00_visual", + "avbd_paper_wall_brick_08_01_visual", + "avbd_paper_wall_brick_08_02_visual", + "avbd_paper_wall_brick_08_03_visual", + "avbd_paper_wall_brick_08_04_visual", + "avbd_paper_wall_brick_08_05_visual", + "avbd_paper_wall_brick_08_06_visual", + "avbd_paper_wall_brick_08_07_visual", + "avbd_paper_wall_brick_08_08_visual", + "avbd_paper_wall_brick_08_09_visual", + "avbd_paper_wall_brick_08_10_visual", + "avbd_paper_wall_brick_08_11_visual", + "avbd_paper_wall_brick_08_12_visual", + "avbd_paper_wall_brick_08_13_visual", + "avbd_paper_wall_brick_08_14_visual", + "avbd_paper_wall_brick_08_15_visual", + "avbd_paper_wall_brick_08_16_visual", + "avbd_paper_wall_brick_08_17_visual", + "avbd_paper_wall_brick_08_18_visual", + "avbd_paper_wall_brick_08_19_visual", + "avbd_paper_wall_brick_08_20_visual", + "avbd_paper_wall_brick_09_00_visual", + "avbd_paper_wall_brick_09_01_visual", + "avbd_paper_wall_brick_09_02_visual", + "avbd_paper_wall_brick_09_03_visual", + "avbd_paper_wall_brick_09_04_visual", + "avbd_paper_wall_brick_09_05_visual", + "avbd_paper_wall_brick_09_06_visual", + "avbd_paper_wall_brick_09_07_visual", + "avbd_paper_wall_brick_09_08_visual", + "avbd_paper_wall_brick_09_09_visual", + "avbd_paper_wall_brick_09_10_visual", + "avbd_paper_wall_brick_09_11_visual", + "avbd_paper_wall_brick_09_12_visual", + "avbd_paper_wall_brick_09_13_visual", + "avbd_paper_wall_brick_09_14_visual", + "avbd_paper_wall_brick_09_15_visual", + "avbd_paper_wall_brick_09_16_visual", + "avbd_paper_wall_brick_09_17_visual", + "avbd_paper_wall_brick_09_18_visual", + "avbd_paper_wall_brick_09_19_visual", + "avbd_paper_wall_brick_09_20_visual", + "avbd_paper_wall_brick_10_00_visual", + "avbd_paper_wall_brick_10_01_visual", + "avbd_paper_wall_brick_10_02_visual", + "avbd_paper_wall_brick_10_03_visual", + "avbd_paper_wall_brick_10_04_visual", + "avbd_paper_wall_brick_10_05_visual", + "avbd_paper_wall_brick_10_06_visual", + "avbd_paper_wall_brick_10_07_visual", + "avbd_paper_wall_brick_10_08_visual", + "avbd_paper_wall_brick_10_09_visual", + "avbd_paper_wall_brick_10_10_visual", + "avbd_paper_wall_brick_10_11_visual", + "avbd_paper_wall_brick_10_12_visual", + "avbd_paper_wall_brick_10_13_visual", + "avbd_paper_wall_brick_10_14_visual", + "avbd_paper_wall_brick_10_15_visual", + "avbd_paper_wall_brick_10_16_visual", + "avbd_paper_wall_brick_10_17_visual", + "avbd_paper_wall_brick_10_18_visual", + "avbd_paper_wall_brick_10_19_visual", + "avbd_paper_wall_brick_10_20_visual", "avbd_paper_wall_brick_11_00_visual", + "avbd_paper_wall_brick_11_01_visual", + "avbd_paper_wall_brick_11_02_visual", + "avbd_paper_wall_brick_11_03_visual", + "avbd_paper_wall_brick_11_04_visual", + "avbd_paper_wall_brick_11_05_visual", + "avbd_paper_wall_brick_11_06_visual", + "avbd_paper_wall_brick_11_07_visual", + "avbd_paper_wall_brick_11_08_visual", + "avbd_paper_wall_brick_11_09_visual", + "avbd_paper_wall_brick_11_10_visual", + "avbd_paper_wall_brick_11_11_visual", + "avbd_paper_wall_brick_11_12_visual", + "avbd_paper_wall_brick_11_13_visual", + "avbd_paper_wall_brick_11_14_visual", + "avbd_paper_wall_brick_11_15_visual", + "avbd_paper_wall_brick_11_16_visual", + "avbd_paper_wall_brick_11_17_visual", + "avbd_paper_wall_brick_11_18_visual", + "avbd_paper_wall_brick_11_19_visual", "avbd_paper_wall_brick_11_20_visual" ], "issues": [], "metrics": { - "ambiguity_iou": 0.0, + "ambiguity_iou": 0.23182617829869864, "center_visible": true, "corner_coverage": 1.0, - "occlusion_fraction": 0.21212121212121213, - "subject_fraction": 0.3023708268457204 + "occlusion_fraction": 0.014873574615765989, + "subject_fraction": 0.13073046725143303 }, "pass": true, "schema_version": "dart.view_report/v1", - "score": 0.7156583983313981, + "score": 0.9328476608176819, "size": [ 1280, 720 @@ -742,26 +9073,45 @@ "scene_metrics_events": { "event_count": 120, "file": "scene_metrics.jsonl", - "sha256": "e91b96f7dc868f4090208d937b6b02eb6664afcae9e38665b414b578449ac3c1" + "sha256": "65fb684596687a8c028e393be78376df52ab31f69fc4e10d151d478b65e48531" }, "screenshot": { "file": "avbd_paper_breakable_wall_outcome.png", - "sha256": "6587784a2ea32578d46b155e4aa3b51579c5be3ec2936e168f0b43367eb94a34" + "sha256": "199c4f6d676a65c59403da39adce607ace101ac55674f1766c0be6deaf4954d7" + }, + "source_provenance": { + "algorithm": "sha256-length-prefixed-capture-source-tree-v1", + "digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12", + "file_count": 1546, + "git_head": "d9af744a9cb0763033d76e01381dbf7c1e6ecaa9", + "roots": [ + "CMakeLists.txt", + "cmake", + "dart", + "pixi.lock", + "pixi.toml", + "pyproject.toml", + "python/CMakeLists.txt", + "python/dartpy", + "python/examples/demos", + "scripts/capture_py_demo.py", + "scripts/capture_source_provenance.py" + ] } }, "semantic_review": { - "claim_and_expected_observation": "The public AVBD Figure 13 scene should show localized damage across the three impact bands while substantial wall regions outside those bands remain attached and standing.", - "file": "visual_review.json", + "claim_and_expected_observation": "The reconstructed AVBD Figure 13 row should fracture under the three high-momentum impacts while the attachment rows retained after fracture remain tightly satisfied and the wall stays globally standing.", + "file": "avbd-visual-review.json", "inspected_images": [ { "file": "avbd_paper_breakable_wall_impact.png", "role": "impact_frame_60", - "sha256": "a3ab663cdbbd030bccddbee838e134fa3326fdec459f3a8b734fa0da044f63a3" + "sha256": "1d72ad775b690494372585ba9f91eb751aaefb2ba6c94a2466c9009ace94c3d5" }, { "file": "avbd_paper_breakable_wall_outcome.png", "role": "outcome_frame_120", - "sha256": "6587784a2ea32578d46b155e4aa3b51579c5be3ec2936e168f0b43367eb94a34" + "sha256": "199c4f6d676a65c59403da39adce607ace101ac55674f1766c0be6deaf4954d7" }, { "file": "paper-10.png", @@ -769,13 +9119,13 @@ "sha256": "040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b" } ], - "not_proven_and_limitations": "The images do not prove exact unpublished Figure 13 constants, identical fracture geometry, exact joint counts without the text oracle, other solver comparison rows, CUDA parity, reference timing parity, or a paper-speedup claim. The fixed front view is intentionally assessed for this scene and is not the paper camera.", - "reconciliation_and_verdict": "PASS: both engine ViewReports pass with no issues, the frame-60 image is consistent with a pre-evaluation impact state rather than a final three-band threshold claim, and the frame-120 numeric oracle and visible retained-wall fracture agree. The paper image supports only the publication-shaped qualitative comparison.", - "reviewer_capability": "native image input inspected at original 1280x720 detail", - "sha256": "07060971349b4d1af35fdedfb3bfae880597f5b53b4aefe3c301f59f7b4c10fe", - "text_oracle": "At frame 60 the scene is intentionally pre-evaluation: fracture is active and retention checks pass, but displaced-brick counts [3, 9, 4] leave one impact band below the final threshold. At frame 120 all finite-state, fracture, three-band damage, localization, outside-retention, and total-retention checks pass with counts [4, 10, 6], 359 broken and 353 unbroken attachments, 91.1602% outside retention, and 82.5397% total retention.", + "not_proven_and_limitations": "The checked identity evidence records 154 broken joints, including 79 whose initial anchors lie outside the selected 1.15 m impact regions, so this reconstruction does not claim that broken topology itself is spatially localized. The visible and numeric claim is narrower: the wall fractures, displacement damage reaches all three impact bands, and all 558 surviving joint rows remain tightly satisfied. This row does not prove exact unpublished source constants, XPBD, CUDA parity, or published/source timing parity.", + "reconciliation_and_verdict": "PASS for the scoped AVBD observation: both engine ViewReports pass with all 252 bricks in focus. Frames 60 and 120 record the same checked 154-member broken-joint identity digest and 558 surviving rows. The maximum retained-row residuals are respectively 0.000406 m and 0.000178 rad, then 0.000772 m and 0.000317 rad. The images independently show a broad fractured opening around the impacts while the left, right, and upper wall regions remain upright without global collapse. The broad broken-topology distribution is disclosed rather than described as localized.", + "reviewer_capability": "Codex native image input with original-resolution inspection of the six regenerated captures and pinned paper figure", + "sha256": "9d8317aa351a607a902167eb9e51284669067cfe5f0681dde542c47d7c43f8c2", + "text_oracle": "Figure 13 states that AVBD forms plausible fractures and maintains the attachment constraints retained after the initial impacts; this reconstruction verifies every retained-row residual and separately records every broken identity.", "verdict": "pass", - "visible_observation": "The frame-60 front view shows active debris and localized loss across the left, center, and right target regions while broad side and upper wall sections remain standing. By frame 120 the debris has settled, the damaged central and lower bands remain visibly separated, and large retained wall regions are still standing. The paper reference shows the same qualitative AVBD category of fractured masonry with retained wall structure, although its camera, wall dimensions, materials, and impact timing differ." + "visible_observation": "At frame 60 a broad central opening and loose bricks are visible around the impacts while substantial left, right, and upper wall regions remain upright. At frame 120 the same overall structure is still globally standing with no broad folding or collapse." } } } diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json index 27074b2393ff9..0626ac6c34286 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-coverage-contract.json @@ -295,7 +295,7 @@ {"id": "avbd.paper.fig.10", "status": "partial", "source_locator": "Figure 10, PDF page 9: high-mass-ratio box stack", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-stack-packet.json"], "blockers": ["The narrow stack port uses sequential-impulse contacts and lacks the paper sequential-impulse/AVBD comparison."]}, {"id": "avbd.paper.fig.11", "status": "partial", "source_locator": "Figure 11, PDF page 9: friction-coefficient blocks versus sequential impulse", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json"], "blockers": ["The DART sweep does not establish AVBD-contact solver identity, paper trajectory equivalence, GPU parity, or the matched visual pair."]}, {"id": "avbd.paper.fig.12", "status": "missing", "source_locator": "Figure 12, PDF page 10: heavy ball dropped on chain mail", "evidence": [], "blockers": ["The chain-mail scene, four-method comparison, hard-contact oracle, and performance packet are missing."]}, - {"id": "avbd.paper.fig.13", "status": "partial", "source_locator": "Figure 13, PDF page 10: breakable brick wall hit by three balls", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json"], "blockers": ["Matched public fixed-penalty VBD and public AVBD rows now share the reconstructed 252-brick, 712-attachment, three-ball scene fingerprint and bind independent paper-shaped outcome oracles plus assessed renders. Exact wall geometry, masses, impact speed, targets, and break threshold remain unpublished; matched Sequential Impulse and XPBD rows, CUDA, a source edit, and achieved-accuracy reference performance are still missing."]}, + {"id": "avbd.paper.fig.13", "status": "partial", "source_locator": "Figure 13, PDF page 10: breakable brick wall hit by three balls", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json"], "blockers": ["Matched public Sequential Impulse, fixed-penalty VBD, and AVBD CPU rows now share the reconstructed 252-brick, 712-attachment, three-ball scene fingerprint and bind independent paper-shaped outcome oracles plus assessed renders. Exact wall geometry, masses, impact speed, targets, and break threshold remain unpublished; XPBD, CUDA, a source-matched four-method edit, and achieved-accuracy reference performance are still missing."]}, {"id": "avbd.paper.fig.14", "status": "missing", "source_locator": "Figure 14, PDF page 10: 35K rigid bodies, 72K joints, and 10K-vertex cloth", "evidence": [], "blockers": ["Unified rigid/soft coupling and the 16 ms inclusive performance row are missing."]}, {"id": "avbd.paper.fig.15", "status": "partial", "source_locator": "Figure 15, PDF page 11: beta, alpha, gamma, and iteration sweeps", "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["Only a DART iteration-budget sweep exists; the exact beta/alpha/gamma trajectories and defaults are missing."]} ] @@ -354,7 +354,7 @@ {"id": "avbd.video.09_box_stack", "status": "partial", "source_locator": "02:51.567-03:02.033 sequential-impulse/AVBD box stack", "source_seconds": [171.567, 182.033], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-stack-packet.json"], "blockers": ["The current port does not run AVBD contacts and lacks the paper comparison/edit."]}, {"id": "avbd.video.10_friction", "status": "partial", "source_locator": "03:02.033-03:11.800 friction coefficient comparison", "source_seconds": [182.033, 191.8], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-friction-coefficient-sweep-packet.json"], "blockers": ["The current sweep lacks AVBD-contact solver identity and the matched source visual edit."]}, {"id": "avbd.video.11_chain_mail", "status": "missing", "source_locator": "03:11.800-03:19.200 heavy-ball chain-mail comparison", "source_seconds": [191.8, 199.2], "evidence": [], "blockers": ["No chain-mail source scene, four-method outcome, or validated DART edit exists."]}, - {"id": "avbd.video.12_breakable_wall", "status": "partial", "source_locator": "03:19.200-03:59.667 four-method breakable-wall comparison", "source_seconds": [199.2, 239.667], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json"], "blockers": ["The publication-shaped DART AVBD and matched fixed-penalty VBD rows bind identical reconstructed scene topology, distinct quantitative outcomes, and assessed current-build renders. The Sequential Impulse and XPBD rows, source-matched four-method video edit, exact unpublished scene constants, CUDA evidence, and achieved-accuracy comparable performance remain missing."]}, + {"id": "avbd.video.12_breakable_wall", "status": "partial", "source_locator": "03:19.200-03:59.667 four-method breakable-wall comparison", "source_seconds": [199.2, 239.667], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-demo3d-breakable-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json", "docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json"], "blockers": ["The publication-shaped DART Sequential Impulse, fixed-penalty VBD, and AVBD CPU rows bind identical reconstructed scene topology, distinct quantitative outcomes, and assessed current-build renders. The XPBD row, source-matched four-method video edit, exact unpublished scene constants, CUDA evidence, and achieved-accuracy comparable performance remain missing."]}, {"id": "avbd.video.13_parameter_sweeps", "status": "partial", "source_locator": "03:59.667-04:32.633 beta, alpha, gamma, and iteration sweeps", "source_seconds": [239.667, 272.633], "evidence": ["docs/plans/104-vertex-block-descent-solver/avbd-paper-scale-high-ratio-iteration-sweep-packet.json"], "blockers": ["Only a non-source iteration sweep exists; beta/alpha/gamma trajectories and the full edit are missing."]}, {"id": "avbd.video.14_closing", "status": "missing", "source_locator": "04:32.633-04:39.000 closing card", "source_seconds": [272.633, 279], "evidence": [], "blockers": ["No approved DART replacement closing edit exists."]} ] diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md index e7e7dc2d2aeb3..269a4fb9bd923 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-gap-audit.md @@ -54,19 +54,21 @@ paper-inspired row update. For this paper, "implemented" means: ## Solver-Identity Relabel (PLAN-091 WP-091.1) -The historical `avbd-demo2d`/`avbd-demo3d` benchmark and py-demo packets cited -by this audit neither selected the new public AVBD rigid-body family nor -emplaced the compatibility-only internal AVBD rigid-contact opt-in -(`comps::RigidAvbdContactConfig`). Every rigid contact in those scenes -therefore ran DART's default sequential-impulse path. Their native-runner -timing ratios remain whole-pipeline `World::step` comparisons, not -AVBD-contact-solver comparisons: pure-contact rows timed no AVBD rows; -joint-plus-contact rows timed AVBD point-joint/motor/spring rows while -ordinary contacts ran sequential impulse; and incidental chain contacts also -ran sequential impulse. Faster-than-native rows below retain this historical -classification. New packets must machine-record `resolved_solver_identity` -and `rigid_contact_selection` at AVBD packet schema version 3, enforced by -`pixi run check-avbd-packets`. +The checked-in historical `avbd-demo2d`/`avbd-demo3d` benchmark and py-demo +packets cited by this audit were captured before their scenes selected the +public AVBD rigid-body family or emplaced the compatibility-only internal AVBD +rigid-contact opt-in (`comps::RigidAvbdContactConfig`). Every rigid contact in +those scenes therefore ran DART's default sequential-impulse path at capture +time. Their recorded native-runner timing ratios remain historical +whole-pipeline `World::step` comparisons, not AVBD-contact-solver comparisons: +pure-contact rows timed no AVBD rows; joint-plus-contact rows timed AVBD +point-joint/motor/spring rows while ordinary contacts ran sequential impulse; +and incidental chain contacts also ran sequential impulse. Seven current +source-row scenes now explicitly select public AVBD so they cannot silently +exercise SI. Faster-than-native rows below retain their historical +classification until refreshed. New packets must machine-record +`resolved_solver_identity` and `rigid_contact_selection` at AVBD packet schema +version 3, enforced by `pixi run check-avbd-packets`. The public `RigidBodySolver::Avbd` family now provides an explicit, reported world-level AVBD contact/joint selection without the private body opt-in. Its @@ -486,14 +488,26 @@ the source-matched video edit, CUDA path, broad fracture corpus, and comparable performance remain open. [`avbd-paper-vbd-comparison-packet.json`](avbd-paper-vbd-comparison-packet.json) adds a matched public fixed-penalty VBD row over that exact reconstructed scene -fingerprint. Its independent frame-14 bend and frame-120 retention oracles, +fingerprint. Its independent frame-18 bend and frame-120 retention oracles, assessed renders, solver identity, allocation gates, and same-host five-repetition benchmark are bound to the current AVBD packet and pinned paper/video sources. The timing ratio is descriptive cost only because the methods intentionally produce different outcomes and no achieved-accuracy -reference denominator exists. Sequential Impulse, XPBD, exact unpublished -constants, a source-matched four-method edit, CUDA, and achieved-accuracy -reference performance remain open. +reference denominator exists. +[`avbd-paper-sequential-impulse-comparison-packet.json`](avbd-paper-sequential-impulse-comparison-packet.json) +adds the honest public Sequential Impulse row on that same fingerprint. Its +frame-14 oracle records exactly five broken identities across all three impact +regions and none outside, with the wall still retained. The frame-120 identity +digest is unchanged, while the 484 retained rows outside all impacts have +large measured linear and angular residuals and the wall globally collapses. +Both captures pass engine ViewReports, pixel integrity, and separate semantic +review, while benchmark runtime counters prove that public SI contact and pair +rows both ran 20 sweeps. The AVBD row separately discloses that 79 of its 154 +broken identities lie outside the selected impact regions; it therefore +claims localized displacement damage and tightly satisfied retained rows, not +localized broken topology. XPBD, exact unpublished constants, a source-matched +four-method edit, CUDA, and achieved-accuracy reference performance remain +open. Note: `avbd_demo2d_net`, `BM_AvbdDemo2dNetStep`, and `avbd-demo2d-net-packet.json` now cover the source Net row shape/count, @@ -683,8 +697,12 @@ gates, and absolute CPU timing. The linked [`avbd-paper-vbd-comparison-packet.json`](avbd-paper-vbd-comparison-packet.json) adds the matched fixed-penalty VBD bend-without-fracture row on the same scene fingerprint, with independent numeric and assessed visual evidence. Exact -unpublished source constants, Sequential Impulse and XPBD rows, a source edit, -broad joint/fracture corpus, unified rows, CUDA, and comparable paper/reference +unpublished source constants and XPBD remain open. The adjacent +[`avbd-paper-sequential-impulse-comparison-packet.json`](avbd-paper-sequential-impulse-comparison-packet.json) +adds solver-owned hard SI pair rows and the matched +initial-fracture-to-collapse outcome, linked to the same current AVBD/VBD +evidence and benchmark JSON. A source-matched four-method edit, broad +joint/fracture corpus, unified rows, CUDA, and comparable paper/reference performance remain open. Non-topology multibody-link fixed/revolute/prismatic point-joint entities can also generate hard private configs from simulation-entry current @@ -816,10 +834,9 @@ renders the timing/stability sweep. No same-hardware paper-number comparison or GPU parity exists yet. DART still cannot reproduce the paper's full hard-constrained articulated chains, same-hardware numbers, or cloth-plus-articulated-rigid coupling scenes. The next fracture slice should -extend the matched publication-shaped AVBD/VBD wall into honest Sequential -Impulse and XPBD rows, a source-matched four-method edit, CUDA, and the -remaining joint/attachment corpus before treating the A5 joint-family surface -as broad. +extend the matched publication-shaped SI/VBD/AVBD wall with honest XPBD, then +a source-matched four-method edit, CUDA, and the remaining joint/attachment +corpus before treating the A5 joint-family surface as broad. The next implementation slice should either optimize the measured `avbd_demo2d_ground`, `avbd_demo2d_motor`, `avbd_demo2d_hanging_rope`, `avbd_demo2d_rod`, diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json new file mode 100644 index 0000000000000..5a0aedc0071bc --- /dev/null +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json @@ -0,0 +1,1787 @@ +{ + "benchmark": { + "comparison": { + "basis": "same benchmark JSON, executable, host, reconstructed scene fingerprint, trajectory length, time step, and 20-sweep budget", + "sequential_impulse_to_avbd_median_cpu_cost_ratio": 1.6599077466380054, + "sequential_impulse_to_vbd_median_cpu_cost_ratio": 1.721873569759987 + }, + "context": { + "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", + "capture_source_provenance_digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12", + "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", + "host_name": "mark26", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "v1.9.5", + "mhz_per_cpu": 5300, + "num_cpus": 32 + }, + "json_sha256": "c2c81c26ca0fa96d1cf11da4c5a166a3a2eb7ead9b80aafac207c20f03cf7c67", + "method": { + "benchmark": "BM_SequentialImpulsePaperBreakableWallStep", + "rows": [ + { + "aggregate_name": "mean", + "aggregate_unit": "time", + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "cpu_time": 14264147.031666646, + "family_index": 2, + "impacting_balls": 3.0, + "iterations": 5, + "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_mean", + "per_family_instance_index": 0, + "public_sequential-impulse_family": 1.0, + "real_time": 14266776.791773736, + "repetitions": 5, + "resolved_rigid_body_sequential-impulse": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + "resolved_rigid_contact_sequential-impulse": 1.0, + "resolved_rigid_pair_constraint_sequential-impulse": 1.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "rigid_constraint_iterations": 20.0, + "run_name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": 2359557114.0, + "scene_spec_fingerprint_lo": 12836073.0, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 120.0 + }, + { + "aggregate_name": "median", + "aggregate_unit": "time", + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "cpu_time": 14296124.958333323, + "family_index": 2, + "impacting_balls": 3.0, + "iterations": 5, + "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_median", + "per_family_instance_index": 0, + "public_sequential-impulse_family": 1.0, + "real_time": 14296776.22509189, + "repetitions": 5, + "resolved_rigid_body_sequential-impulse": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + "resolved_rigid_contact_sequential-impulse": 1.0, + "resolved_rigid_pair_constraint_sequential-impulse": 1.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "rigid_constraint_iterations": 20.0, + "run_name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": 2359557114.0, + "scene_spec_fingerprint_lo": 12836073.0, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 120.0 + }, + { + "aggregate_name": "stddev", + "aggregate_unit": "time", + "breakable_joints": 0.0, + "collision_shapes": 0.0, + "cpu_time": 302014.5610465137, + "family_index": 2, + "impacting_balls": 0.0, + "iterations": 5, + "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_stddev", + "per_family_instance_index": 0, + "public_sequential-impulse_family": 0.0, + "real_time": 302252.06561056466, + "repetitions": 5, + "resolved_rigid_body_sequential-impulse": 0.0, + "resolved_rigid_constraint_iterations": 0.0, + "resolved_rigid_contact_sequential-impulse": 0.0, + "resolved_rigid_pair_constraint_sequential-impulse": 0.0, + "rigid_bodies": 0.0, + "rigid_body_joints": 0.0, + "rigid_constraint_iterations": 0.0, + "run_name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 0.0, + "scene_spec_fingerprint_hi": 0.0, + "scene_spec_fingerprint_lo": 0.0, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 0.0 + }, + { + "aggregate_name": "cv", + "aggregate_unit": "percentage", + "breakable_joints": 0.0, + "collision_shapes": 0.0, + "cpu_time": 0.02117298429243868, + "family_index": 2, + "impacting_balls": 0.0, + "iterations": 5, + "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_cv", + "per_family_instance_index": 0, + "public_sequential-impulse_family": 0.0, + "real_time": 0.02118572891564716, + "repetitions": 5, + "resolved_rigid_body_sequential-impulse": 0.0, + "resolved_rigid_constraint_iterations": 0.0, + "resolved_rigid_contact_sequential-impulse": 0.0, + "resolved_rigid_pair_constraint_sequential-impulse": 0.0, + "rigid_bodies": 0.0, + "rigid_body_joints": 0.0, + "rigid_constraint_iterations": 0.0, + "run_name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120", + "run_type": "aggregate", + "runtime_contract_passed": 0.0, + "scene_spec_fingerprint_hi": 0.0, + "scene_spec_fingerprint_lo": 0.0, + "threads": 1, + "time_unit": "ns", + "trajectory_frames": 0.0 + } + ], + "stability": { + "cpu_time_cv_fraction": 0.02117298429243868, + "real_time_cv_fraction": 0.02118572891564716, + "repetitions": 5 + }, + "timing": { + "mean_cpu_time_per_step_ns": 14264147.031666646, + "mean_real_time_per_step_ns": 14266776.791773736, + "median_cpu_time_per_step_ns": 14296124.958333323, + "median_real_time_per_step_ns": 14296776.22509189 + } + }, + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", + "source_provenance": { + "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", + "capture_source_provenance_digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12" + } + }, + "claim_boundary": "This packet covers the matched Sequential Impulse Figure 13 row and links the same-host AVBD/VBD comparison packet. Together they establish three of the four published comparison rows. It does not reproduce XPBD, claim exact unpublished source-scene constants, establish CUDA parity, or compare against a published/source timing.", + "correctness": { + "determinism_test": "test_sequential_impulse_paper_breakable_wall_is_deterministic", + "outcome_test": "test_sequential_impulse_paper_breakable_wall_matches_figure13_contract", + "public_configuration": { + "rigid_body_solver": "SEQUENTIAL_IMPULSE", + "rigid_constraint_options": { + "iterations": 20 + }, + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", + "time_step": 0.016666666666666666 + }, + "solver_kernel_tests": [ + "SequentialImpulseContact.HardFixedJointCancelsRelativeVelocity", + "SequentialImpulseContact.ContactAndFixedJointRowsConvergeTogether", + "SequentialImpulseContact.FixedJointBreaksFromImpulseLoadAndStaysExcluded", + "SequentialImpulseContact.PublicFixedJointPostStabilizesWithoutVelocityInjection" + ] + }, + "linked_avbd_vbd_evidence": { + "benchmark_method_timings": { + "avbd": { + "median_cpu_time_per_step_ns": 8612602.108333336 + }, + "vbd": { + "median_cpu_time_per_step_ns": 8302656.600000003 + } + }, + "file": "avbd-paper-vbd-comparison-packet.json", + "nested_avbd_resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": false, + "recorded_from": "engine World.resolved_configuration in both captures plus benchmark runtime resolved-configuration counters", + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "avbd", + "rigid_point_joint_solver": "avbd" + }, + "nested_avbd_sha256": "3e15adb9093c10567c0f8c22c5f8e8ad4e5d47a7acd5aca7a107fb97de7fc54c", + "nested_avbd_source_provenance_digest": "c74dfeb782dbd1d51d9648e699d8afcb5f6cb01838b3c19cf7861b0c4cbdb53d", + "resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": false, + "recorded_from": "engine World.resolved_configuration in both VBD captures plus benchmark runtime resolved-configuration counters", + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "vbd", + "rigid_point_joint_solver": "vbd" + }, + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", + "sha256": "9f8e6dbc9cb2f37af5d268fa83c13e67f3185cb39e14cebf413a288b6ee298d8", + "source_provenance_digest": "567be3b170e62e1bab54061878b6a1e31d5d42839ecac0b59cca85ba4904f9a9", + "visual_evidence": { + "bend_screenshot": { + "file": "vbd_paper_breakable_wall_bend.png", + "sha256": "a94339bf5f07c337133ceb5790b96db33e90471cf24abca7a15f6efccb4d0676" + }, + "retention_screenshot": { + "file": "vbd_paper_breakable_wall_retention.png", + "sha256": "039ee1fe05fc7fc0c350da8818be4acc7d812d7d13fcab481b18562adc84751e" + }, + "semantic_review": { + "sha256": "3e8e2020f849d57b0908092f5a83b4fe1675d451190d1d05a5c09a71c37d1ab4", + "verdict": "pass" + } + } + }, + "packet": "avbd_paper_sequential_impulse_comparison", + "paper_reference": { + "figure": { + "file": "paper-10.png", + "height": 1760, + "sha256": "040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b", + "width": 1360 + }, + "locator": "Section 5.4 and Figure 13, PDF page 10", + "paper_pdf": { + "file": "avbd.pdf", + "sha256": "7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6" + } + }, + "performance_claim_boundary": "The ratios are descriptive same-host CPU costs for three public DART families running the identical reconstructed scene and 20-sweep budget. They are not paper/reference speedup claims.", + "publication_observation": "The Figure 13 Sequential Impulse row shows a clean initial fracture followed by retained-constraint failure, slow bending, and collapse.", + "reconstruction": { + "matched_with_avbd_and_vbd": { + "break_force": 5000.0, + "breakable_joints": 712, + "bricks": 252, + "impacting_balls": 3, + "scene_spec_fingerprint": "8ca3fbfa00c3dce9" + }, + "published": { + "constraint_iterations_or_substeps": 20, + "paper_locator": "Section 5.4 and Figure 13, PDF page 10", + "time_step": 0.016666666666666666 + }, + "unpublished": "wall dimensions, body materials, ball properties and launch conditions, break threshold, and exact source scene" + }, + "reproduction": { + "benchmark_command": "capture_source_digest=$(pixi run python scripts/capture_source_provenance.py --digest-only) && benchmark_source_digest=$(sha256sum tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp | cut -d' ' -f1) && pixi run bm -- bm_avbd_rigid_fixed_joint -- --benchmark_filter='^BM_(Avbd|Vbd|SequentialImpulse)PaperBreakableWallStep/iterations:120$' --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_context=capture_source_provenance_digest=$capture_source_digest --benchmark_context=benchmark_source_sha256=$benchmark_source_digest --benchmark_out= --benchmark_out_format=json", + "capture_commands": [ + "pixi run py-demo-capture -- --scene sequential_impulse_paper_breakable_wall --frames 14 --width 1280 --height 720 --view front --camera-azimuth -112.5 --camera-elevation 35.52338329811104 --camera-distance 22 --camera-target 0,0.45,1.6 --capture-label fracture --output-dir ", + "pixi run py-demo-capture -- --scene sequential_impulse_paper_breakable_wall --frames 120 --width 1280 --height 720 --view front --camera-azimuth -112.5 --camera-elevation 35.52338329811104 --camera-distance 22 --camera-target 0,0.45,1.6 --capture-label collapse --output-dir " + ], + "image_verdict_commands": [ + "pixi run image-verdict -- /sequential_impulse_paper_breakable_wall_fracture.png --meta scene=sequential_impulse_paper_breakable_wall --meta frame=14 --meta view=front-oblique --out /image_verdict.json", + "pixi run image-verdict -- /sequential_impulse_paper_breakable_wall_collapse.png --meta scene=sequential_impulse_paper_breakable_wall --meta frame=120 --meta view=front-oblique --out /image_verdict.json" + ] + }, + "resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": false, + "recorded_from": "engine World.resolved_configuration in both Sequential Impulse captures plus benchmark runtime resolved-configuration counters", + "rigid_contact_selection": "contact_solver_method", + "rigid_contact_solver": "sequential_impulse", + "rigid_point_joint_solver": "sequential_impulse" + }, + "scene": "sequential_impulse_paper_breakable_wall", + "schema_version": 4, + "source_provenance": { + "algorithm": "sha256-length-prefixed-path-and-content-v1", + "digest": "2bdeeb389535dfae885c5a94d76090daf51c51280461488940aec8ff7ad5b62c", + "files": [ + { + "path": "dart/simulation/world.cpp", + "sha256": "23502cd1a7366c83afe8f7c9d7b7d0a0ebeaf32d27d75a35f65b57ccd9d0e09c" + }, + { + "path": "dart/simulation/world.hpp", + "sha256": "a95470a832553e59a19b8a25f38e524e1f449d17a65d31c3185969d2275323da" + }, + { + "path": "dart/simulation/world_options.hpp", + "sha256": "bcc8e950d73534a2d5dc953431939b2fa776235d188fb685bf65589b4d556b34" + }, + { + "path": "dart/simulation/comps/joint.hpp", + "sha256": "dbbab333dfeb086f280675a31badc7756bc74606100e72081a6c6140d4b61617" + }, + { + "path": "dart/simulation/compute/detail/world_step_stages.hpp", + "sha256": "e03ffa5b25dc6d96f088936f8838a9f1be6c658d34d08589347564436862ce05" + }, + { + "path": "dart/simulation/compute/rigid_body_contact_stage.cpp", + "sha256": "755238810875628ce13315b113ac2d766992d0c305783c26d194bbd84fd0b89c" + }, + { + "path": "dart/simulation/detail/rigid_pair_constraint.hpp", + "sha256": "735a62ae1b673ea68d7d26002cc0b72412f239216718e49ffab38b42b40cfffe" + }, + { + "path": "dart/simulation/detail/world_step_schedule.hpp", + "sha256": "547ba1920a79a2e860fafb1fe59bba644a01ddb1caff0d50ef8440f2774aa386" + }, + { + "path": "python/examples/demos/scenes/avbd_paper_breakable_wall.py", + "sha256": "40675bcd66d2ae543dc1bd60a435926dd6969893eb0a92aa451fe66f9b8bda12" + }, + { + "path": "python/examples/demos/scenes/vbd_paper_breakable_wall.py", + "sha256": "5de40f443b26a7695593a97bd77eecd0d9c2a43bfe0083c95503008a66601acc" + }, + { + "path": "python/examples/demos/scenes/sequential_impulse_paper_breakable_wall.py", + "sha256": "b81de2437cb3f10ded0722d1cedd4e791cc7395cb0addffae932cd5b9df239d1" + }, + { + "path": "python/tests/integration/test_demos_cycle.py", + "sha256": "dd33a2c2190b6b13c7c8a408863fa20dbfc24174fd64587ee9c2ed88bb23c5ba" + }, + { + "path": "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", + "sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370" + }, + { + "path": "tests/unit/simulation/contact/test_boxed_lcp_contact.cpp", + "sha256": "1967493f533b813068e12e57fb83b1c37dff4c8c01500934b4e57c9d58f19fdc" + }, + { + "path": "tests/unit/simulation/world/test_world.cpp", + "sha256": "59f5d6d12b3b6ce021279b254807623cff56432107906cfda56204145d81a507" + }, + { + "path": "tests/unit/simulation/world/test_world_resolved_configuration.cpp", + "sha256": "09f3935b94bb3489aa32edb5f8e9ed71b72c165a5c58e7e2a5e7a5e0d156958c" + }, + { + "path": "tests/test_avbd_packet_schema.py", + "sha256": "1e0892f2721b458e945762653c59461333a7eccef782c6a9d2f984a869047579" + }, + { + "path": "scripts/avbd_packet_schema.py", + "sha256": "d2cf699fd9f19fe97fa992977f56c00a43745a0659bda4deb8e10e6fe91e021c" + }, + { + "path": "scripts/capture_py_demo.py", + "sha256": "669e72ccf230a0c89f5d1505e648b7e4c7f189b6f79738790be9c4fe101a95ee" + }, + { + "path": "scripts/capture_source_provenance.py", + "sha256": "f81a59a95a97a3efb5901d1eb6507dd1272f75cd2a4320db6d863cb48186b612" + }, + { + "path": "scripts/write_avbd_paper_breakable_wall_packet.py", + "sha256": "fa4704b68dffeb11b0357d91642c4ca2917cb22b1d182f21d6353cb1c6db7aaa" + }, + { + "path": "scripts/write_avbd_paper_vbd_comparison_packet.py", + "sha256": "b64a04d92d44256e065921f352867a0f73b94a7b3304055590f9d920a3c1515c" + }, + { + "path": "scripts/write_avbd_paper_sequential_impulse_comparison_packet.py", + "sha256": "164454810354c0ca838108ec64908bb4f55915461510e9008d8083d8f50effc7" + } + ] + }, + "target": { + "complete_paper_reproduction": false, + "contract_rows": [ + "avbd.paper.fig.13" + ], + "covered_slice": "matched CPU Sequential Impulse fracture/collapse row plus linked AVBD and VBD numeric, performance, and assessed visual evidence" + }, + "visual_evidence": { + "collapse": { + "camera": { + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, + "preset": "front", + "target": [ + 0.0, + 0.45, + 1.6 + ], + "view": "front-oblique" + }, + "capture": { + "height": 720, + "requested_frames": 120, + "width": 1280 + }, + "image_verdict": { + "checks": { + "contrast": { + "bright_pixels": 2140, + "dark_pixels": 1821, + "mid_pixels": 222190, + "p05": 89.727, + "p95": 156.511, + "pass": false, + "reasons": [ + "luminance spread 66.8 < 90.0" + ], + "region": { + "x0": 512, + "x1": 1241, + "y0": 324, + "y1": 648 + }, + "sample_count": 236196, + "spread": 66.783, + "thresholds": { + "bright_luminance_gt": 190.0, + "dark_luminance_lt": 55.0, + "mid_luminance_max": 170.0, + "mid_luminance_min": 70.0, + "min_bright_pixels": 1180, + "min_dark_pixels": 1180, + "min_mid_pixels": 590, + "min_spread": 90.0 + } + }, + "non_blank": { + "nonzero_pixels": 921600, + "pass": true, + "reasons": [], + "total_pixels": 921600, + "unique_colors_seen": 2 + } + }, + "file": "image_verdict.json", + "machine_scope": "pixel-integrity", + "metadata": { + "frame": "120", + "scene": "sequential_impulse_paper_breakable_wall", + "view": "front-oblique" + }, + "pass": true, + "sha256": "437869fed1bcbfe98b29deefe356b681cd940123cfccc72db6fd23014fc4598c" + }, + "label": "collapse", + "manifest": { + "file": "manifest.json", + "sha256": "e0a4dbb060e94e8dc33e4725b338ffd8b269a11e3eb7a115ba6d82344bc49191" + }, + "scene_metrics": { + "event_count": 120, + "frame": 120, + "outcome": { + "ball_positions": [ + [ + -2.823407270204711, + -1.9964783776457302, + 0.4798999933213929 + ], + [ + -0.4030899465597143, + -1.7901292504238324, + 0.4798926016051247 + ], + [ + 2.792525803850125, + -1.9820853128063278, + 0.4798992077416022 + ] + ], + "ball_velocities": [ + [ + 0.18993701566694202, + -1.5788668529469876, + 0.0 + ], + [ + -0.09200454541823079, + -2.0647076465525855, + 0.0 + ], + [ + -0.2503186510668152, + -1.8806071817353243, + 0.0 + ] + ], + "bent_brick_count": 231, + "broken_joint_identity_count": 5, + "broken_joint_ids_sha256": "c85184879b1b9036ff582731031fc49c56b4149e93ded45780b06352bd94d61d", + "broken_joint_impact_region_counts": [ + 2, + 1, + 2 + ], + "broken_joint_records": [ + { + "angular_residual_radians": 0.007639439172825061, + "child": { + "body": "brick", + "column": 16, + "row": 8 + }, + "id": "sequential_impulse_paper_wall_horizontal_08_15", + "initial_anchor": [ + 3.7199999999999998, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 0.0031360180359977156, + "nearest_impact_distance": 0.6216309194369273, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.0154271084015484, + "child": { + "body": "brick", + "column": 5, + "row": 5 + }, + "id": "sequential_impulse_paper_wall_vertical_04_05_05_05", + "initial_anchor": [ + -2.79, + 0.0, + 1.495 + ], + "kind": "vertical", + "linear_residual": 0.037736875998829376, + "nearest_impact_distance": 0.31484122982862334, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07714932310673095, + "child": { + "body": "brick", + "column": 5, + "row": 6 + }, + "id": "sequential_impulse_paper_wall_vertical_05_04_06_05", + "initial_anchor": [ + -3.1, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 0.018153176120324972, + "nearest_impact_distance": 0.21500000000000008, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.07234892308213017, + "child": { + "body": "brick", + "column": 10, + "row": 6 + }, + "id": "sequential_impulse_paper_wall_vertical_05_09_06_10", + "initial_anchor": [ + 0.0, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 0.014267758734447411, + "nearest_impact_distance": 0.3103626910567699, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.023975617988176143, + "child": { + "body": "brick", + "column": 15, + "row": 9 + }, + "id": "sequential_impulse_paper_wall_vertical_08_15_09_15", + "initial_anchor": [ + 3.41, + 0.0, + 2.575 + ], + "kind": "vertical", + "linear_residual": 0.00781340209082128, + "nearest_impact_distance": 0.3830469945058962, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + } + ], + "broken_joints": 5, + "broken_joints_outside_impact_regions": 0, + "checkpoint": "collapse", + "contact_count": 348, + "evaluated": true, + "frame": 120, + "impact_band_displaced_counts": [ + 13, + 14, + 13 + ], + "impact_damage_displacement_threshold": 0.1, + "joint_residuals_finite": true, + "last_step_iterations": 20, + "max_brick_displacement": 4.132287697782797, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": 0.7270477152430048, + "maximum_outside_impact_unbroken_joint_linear_residual": 0.06345950838997977, + "maximum_unbroken_joint_angular_residual_radians": 0.7270477152430048, + "maximum_unbroken_joint_linear_residual": 0.06345950838997977, + "maximum_wall_normal_displacement": 2.877607267738441, + "outside_brick_count": 176, + "outside_impact_unbroken_joint_residual_count": 484, + "outside_retained_fraction": 0.2727272727272727, + "retained_displacement_threshold": 0.5, + "rms_outside_impact_unbroken_joint_angular_residual_radians": 0.21636200813390374, + "rms_outside_impact_unbroken_joint_linear_residual": 0.020763053093004316, + "rms_unbroken_joint_angular_residual_radians": 0.18745704364465998, + "rms_unbroken_joint_linear_residual": 0.020576676770653134, + "rms_wall_normal_displacement": 1.6472793285561675, + "status": "pass", + "threshold_checks": { + "damage_in_three_impact_bands": true, + "finite_state": true, + "fracture_identity_unchanged": true, + "initial_fracture_remains_visible": true, + "outside_wall_collapses": true, + "retained_rows_fail_outside_impacts": true, + "wall_collapses": true + }, + "thresholds_pass": true, + "total_retained_fraction": 0.19047619047619047, + "unbroken_joint_residual_count": 707, + "unbroken_joints": 707, + "world_time": 1.9999999999999978 + }, + "outcome_oracle": { + "collapse_evaluation_frame": 120, + "evaluation_frame": 14, + "expected_broken_joint_ids_sha256": "c85184879b1b9036ff582731031fc49c56b4149e93ded45780b06352bd94d61d", + "expected_broken_joints": 5, + "expected_outside_impact_unbroken_joint_count": 484, + "impact_band_radius": 0.85, + "impact_damage_displacement_threshold": 0.1, + "joint_evidence_frames": [ + 14, + 120 + ], + "maximum_collapse_outside_retained_fraction": 0.35, + "maximum_collapse_total_retained_fraction": 0.25, + "maximum_final_broken_joints": 30, + "maximum_initial_broken_joints": 20, + "maximum_initial_outside_joint_angular_residual_radians": 0.13, + "maximum_initial_outside_joint_linear_residual": 0.04, + "minimum_collapse_outside_joint_maximum_angular_residual_radians": 0.6, + "minimum_collapse_outside_joint_maximum_linear_residual": 0.05, + "minimum_collapse_outside_joint_rms_angular_residual_radians": 0.18, + "minimum_collapse_outside_joint_rms_linear_residual": 0.015, + "minimum_collapse_wall_normal_displacement": 2.0, + "minimum_displaced_bricks_per_impact_band": 10, + "minimum_final_broken_joints": 3, + "minimum_final_unbroken_joints": 680, + "minimum_initial_broken_joints": 3, + "minimum_initial_broken_joints_per_impact_region": 1, + "minimum_initial_total_retained_fraction": 0.95, + "minimum_initial_unbroken_joints": 690, + "outside_radius": 1.15, + "retained_displacement_threshold": 0.5 + }, + "resolved_configuration": [ + { + "domain": "rigid-body", + "reason": "as requested", + "requested": "sequential-impulse", + "resolved": "sequential-impulse" + }, + { + "domain": "rigid-contact", + "reason": "as requested", + "requested": "sequential-impulse", + "resolved": "sequential-impulse" + }, + { + "domain": "rigid-pair-constraint", + "reason": "hard public rigid pair constraints use solver-owned sequential-impulse rows", + "requested": "sequential-impulse", + "resolved": "sequential-impulse" + }, + { + "domain": "rigid-constraint-iterations", + "reason": "as requested", + "requested": "20", + "resolved": "20" + }, + { + "domain": "multibody", + "reason": "as requested", + "requested": "semi-implicit", + "resolved": "semi-implicit" + }, + { + "domain": "deformable-psd", + "reason": "as requested", + "requested": "cpu", + "resolved": "cpu" + } + ], + "scene_contract": { + "ball_count": 3, + "break_force": 5000.0, + "breakable_joints": 712, + "brick_count": 252, + "collision_shapes": 256, + "executor": "World.step default", + "paper_locator": "Section 5.4 and Figure 13, PDF page 10", + "rigid_bodies": 256, + "rigid_body_solver": "SEQUENTIAL_IMPULSE", + "rigid_constraint_options": { + "iterations": 20 + }, + "solver": "public_sequential-impulse", + "time_step_ms": 16.666666666666668 + }, + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", + "view_report": { + "camera": { + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, + "target": [ + 0.0, + 0.45, + 1.6 + ] + }, + "focus": [ + "sequential_impulse_paper_wall_brick_00_00_visual", + "sequential_impulse_paper_wall_brick_00_01_visual", + "sequential_impulse_paper_wall_brick_00_02_visual", + "sequential_impulse_paper_wall_brick_00_03_visual", + "sequential_impulse_paper_wall_brick_00_04_visual", + "sequential_impulse_paper_wall_brick_00_05_visual", + "sequential_impulse_paper_wall_brick_00_06_visual", + "sequential_impulse_paper_wall_brick_00_07_visual", + "sequential_impulse_paper_wall_brick_00_08_visual", + "sequential_impulse_paper_wall_brick_00_09_visual", + "sequential_impulse_paper_wall_brick_00_10_visual", + "sequential_impulse_paper_wall_brick_00_11_visual", + "sequential_impulse_paper_wall_brick_00_12_visual", + "sequential_impulse_paper_wall_brick_00_13_visual", + "sequential_impulse_paper_wall_brick_00_14_visual", + "sequential_impulse_paper_wall_brick_00_15_visual", + "sequential_impulse_paper_wall_brick_00_16_visual", + "sequential_impulse_paper_wall_brick_00_17_visual", + "sequential_impulse_paper_wall_brick_00_18_visual", + "sequential_impulse_paper_wall_brick_00_19_visual", + "sequential_impulse_paper_wall_brick_00_20_visual", + "sequential_impulse_paper_wall_brick_01_00_visual", + "sequential_impulse_paper_wall_brick_01_01_visual", + "sequential_impulse_paper_wall_brick_01_02_visual", + "sequential_impulse_paper_wall_brick_01_03_visual", + "sequential_impulse_paper_wall_brick_01_04_visual", + "sequential_impulse_paper_wall_brick_01_05_visual", + "sequential_impulse_paper_wall_brick_01_06_visual", + "sequential_impulse_paper_wall_brick_01_07_visual", + "sequential_impulse_paper_wall_brick_01_08_visual", + "sequential_impulse_paper_wall_brick_01_09_visual", + "sequential_impulse_paper_wall_brick_01_10_visual", + "sequential_impulse_paper_wall_brick_01_11_visual", + "sequential_impulse_paper_wall_brick_01_12_visual", + "sequential_impulse_paper_wall_brick_01_13_visual", + "sequential_impulse_paper_wall_brick_01_14_visual", + "sequential_impulse_paper_wall_brick_01_15_visual", + "sequential_impulse_paper_wall_brick_01_16_visual", + "sequential_impulse_paper_wall_brick_01_17_visual", + "sequential_impulse_paper_wall_brick_01_18_visual", + "sequential_impulse_paper_wall_brick_01_19_visual", + "sequential_impulse_paper_wall_brick_01_20_visual", + "sequential_impulse_paper_wall_brick_02_00_visual", + "sequential_impulse_paper_wall_brick_02_01_visual", + "sequential_impulse_paper_wall_brick_02_02_visual", + "sequential_impulse_paper_wall_brick_02_03_visual", + "sequential_impulse_paper_wall_brick_02_04_visual", + "sequential_impulse_paper_wall_brick_02_05_visual", + "sequential_impulse_paper_wall_brick_02_06_visual", + "sequential_impulse_paper_wall_brick_02_07_visual", + "sequential_impulse_paper_wall_brick_02_08_visual", + "sequential_impulse_paper_wall_brick_02_09_visual", + "sequential_impulse_paper_wall_brick_02_10_visual", + "sequential_impulse_paper_wall_brick_02_11_visual", + "sequential_impulse_paper_wall_brick_02_12_visual", + "sequential_impulse_paper_wall_brick_02_13_visual", + "sequential_impulse_paper_wall_brick_02_14_visual", + "sequential_impulse_paper_wall_brick_02_15_visual", + "sequential_impulse_paper_wall_brick_02_16_visual", + "sequential_impulse_paper_wall_brick_02_17_visual", + "sequential_impulse_paper_wall_brick_02_18_visual", + "sequential_impulse_paper_wall_brick_02_19_visual", + "sequential_impulse_paper_wall_brick_02_20_visual", + "sequential_impulse_paper_wall_brick_03_00_visual", + "sequential_impulse_paper_wall_brick_03_01_visual", + "sequential_impulse_paper_wall_brick_03_02_visual", + "sequential_impulse_paper_wall_brick_03_03_visual", + "sequential_impulse_paper_wall_brick_03_04_visual", + "sequential_impulse_paper_wall_brick_03_05_visual", + "sequential_impulse_paper_wall_brick_03_06_visual", + "sequential_impulse_paper_wall_brick_03_07_visual", + "sequential_impulse_paper_wall_brick_03_08_visual", + "sequential_impulse_paper_wall_brick_03_09_visual", + "sequential_impulse_paper_wall_brick_03_10_visual", + "sequential_impulse_paper_wall_brick_03_11_visual", + "sequential_impulse_paper_wall_brick_03_12_visual", + "sequential_impulse_paper_wall_brick_03_13_visual", + "sequential_impulse_paper_wall_brick_03_14_visual", + "sequential_impulse_paper_wall_brick_03_15_visual", + "sequential_impulse_paper_wall_brick_03_16_visual", + "sequential_impulse_paper_wall_brick_03_17_visual", + "sequential_impulse_paper_wall_brick_03_18_visual", + "sequential_impulse_paper_wall_brick_03_19_visual", + "sequential_impulse_paper_wall_brick_03_20_visual", + "sequential_impulse_paper_wall_brick_04_00_visual", + "sequential_impulse_paper_wall_brick_04_01_visual", + "sequential_impulse_paper_wall_brick_04_02_visual", + "sequential_impulse_paper_wall_brick_04_03_visual", + "sequential_impulse_paper_wall_brick_04_04_visual", + "sequential_impulse_paper_wall_brick_04_05_visual", + "sequential_impulse_paper_wall_brick_04_06_visual", + "sequential_impulse_paper_wall_brick_04_07_visual", + "sequential_impulse_paper_wall_brick_04_08_visual", + "sequential_impulse_paper_wall_brick_04_09_visual", + "sequential_impulse_paper_wall_brick_04_10_visual", + "sequential_impulse_paper_wall_brick_04_11_visual", + "sequential_impulse_paper_wall_brick_04_12_visual", + "sequential_impulse_paper_wall_brick_04_13_visual", + "sequential_impulse_paper_wall_brick_04_14_visual", + "sequential_impulse_paper_wall_brick_04_15_visual", + "sequential_impulse_paper_wall_brick_04_16_visual", + "sequential_impulse_paper_wall_brick_04_17_visual", + "sequential_impulse_paper_wall_brick_04_18_visual", + "sequential_impulse_paper_wall_brick_04_19_visual", + "sequential_impulse_paper_wall_brick_04_20_visual", + "sequential_impulse_paper_wall_brick_05_00_visual", + "sequential_impulse_paper_wall_brick_05_01_visual", + "sequential_impulse_paper_wall_brick_05_02_visual", + "sequential_impulse_paper_wall_brick_05_03_visual", + "sequential_impulse_paper_wall_brick_05_04_visual", + "sequential_impulse_paper_wall_brick_05_05_visual", + "sequential_impulse_paper_wall_brick_05_06_visual", + "sequential_impulse_paper_wall_brick_05_07_visual", + "sequential_impulse_paper_wall_brick_05_08_visual", + "sequential_impulse_paper_wall_brick_05_09_visual", + "sequential_impulse_paper_wall_brick_05_10_visual", + "sequential_impulse_paper_wall_brick_05_11_visual", + "sequential_impulse_paper_wall_brick_05_12_visual", + "sequential_impulse_paper_wall_brick_05_13_visual", + "sequential_impulse_paper_wall_brick_05_14_visual", + "sequential_impulse_paper_wall_brick_05_15_visual", + "sequential_impulse_paper_wall_brick_05_16_visual", + "sequential_impulse_paper_wall_brick_05_17_visual", + "sequential_impulse_paper_wall_brick_05_18_visual", + "sequential_impulse_paper_wall_brick_05_19_visual", + "sequential_impulse_paper_wall_brick_05_20_visual", + "sequential_impulse_paper_wall_brick_06_00_visual", + "sequential_impulse_paper_wall_brick_06_01_visual", + "sequential_impulse_paper_wall_brick_06_02_visual", + "sequential_impulse_paper_wall_brick_06_03_visual", + "sequential_impulse_paper_wall_brick_06_04_visual", + "sequential_impulse_paper_wall_brick_06_05_visual", + "sequential_impulse_paper_wall_brick_06_06_visual", + "sequential_impulse_paper_wall_brick_06_07_visual", + "sequential_impulse_paper_wall_brick_06_08_visual", + "sequential_impulse_paper_wall_brick_06_09_visual", + "sequential_impulse_paper_wall_brick_06_10_visual", + "sequential_impulse_paper_wall_brick_06_11_visual", + "sequential_impulse_paper_wall_brick_06_12_visual", + "sequential_impulse_paper_wall_brick_06_13_visual", + "sequential_impulse_paper_wall_brick_06_14_visual", + "sequential_impulse_paper_wall_brick_06_15_visual", + "sequential_impulse_paper_wall_brick_06_16_visual", + "sequential_impulse_paper_wall_brick_06_17_visual", + "sequential_impulse_paper_wall_brick_06_18_visual", + "sequential_impulse_paper_wall_brick_06_19_visual", + "sequential_impulse_paper_wall_brick_06_20_visual", + "sequential_impulse_paper_wall_brick_07_00_visual", + "sequential_impulse_paper_wall_brick_07_01_visual", + "sequential_impulse_paper_wall_brick_07_02_visual", + "sequential_impulse_paper_wall_brick_07_03_visual", + "sequential_impulse_paper_wall_brick_07_04_visual", + "sequential_impulse_paper_wall_brick_07_05_visual", + "sequential_impulse_paper_wall_brick_07_06_visual", + "sequential_impulse_paper_wall_brick_07_07_visual", + "sequential_impulse_paper_wall_brick_07_08_visual", + "sequential_impulse_paper_wall_brick_07_09_visual", + "sequential_impulse_paper_wall_brick_07_10_visual", + "sequential_impulse_paper_wall_brick_07_11_visual", + "sequential_impulse_paper_wall_brick_07_12_visual", + "sequential_impulse_paper_wall_brick_07_13_visual", + "sequential_impulse_paper_wall_brick_07_14_visual", + "sequential_impulse_paper_wall_brick_07_15_visual", + "sequential_impulse_paper_wall_brick_07_16_visual", + "sequential_impulse_paper_wall_brick_07_17_visual", + "sequential_impulse_paper_wall_brick_07_18_visual", + "sequential_impulse_paper_wall_brick_07_19_visual", + "sequential_impulse_paper_wall_brick_07_20_visual", + "sequential_impulse_paper_wall_brick_08_00_visual", + "sequential_impulse_paper_wall_brick_08_01_visual", + "sequential_impulse_paper_wall_brick_08_02_visual", + "sequential_impulse_paper_wall_brick_08_03_visual", + "sequential_impulse_paper_wall_brick_08_04_visual", + "sequential_impulse_paper_wall_brick_08_05_visual", + "sequential_impulse_paper_wall_brick_08_06_visual", + "sequential_impulse_paper_wall_brick_08_07_visual", + "sequential_impulse_paper_wall_brick_08_08_visual", + "sequential_impulse_paper_wall_brick_08_09_visual", + "sequential_impulse_paper_wall_brick_08_10_visual", + "sequential_impulse_paper_wall_brick_08_11_visual", + "sequential_impulse_paper_wall_brick_08_12_visual", + "sequential_impulse_paper_wall_brick_08_13_visual", + "sequential_impulse_paper_wall_brick_08_14_visual", + "sequential_impulse_paper_wall_brick_08_15_visual", + "sequential_impulse_paper_wall_brick_08_16_visual", + "sequential_impulse_paper_wall_brick_08_17_visual", + "sequential_impulse_paper_wall_brick_08_18_visual", + "sequential_impulse_paper_wall_brick_08_19_visual", + "sequential_impulse_paper_wall_brick_08_20_visual", + "sequential_impulse_paper_wall_brick_09_00_visual", + "sequential_impulse_paper_wall_brick_09_01_visual", + "sequential_impulse_paper_wall_brick_09_02_visual", + "sequential_impulse_paper_wall_brick_09_03_visual", + "sequential_impulse_paper_wall_brick_09_04_visual", + "sequential_impulse_paper_wall_brick_09_05_visual", + "sequential_impulse_paper_wall_brick_09_06_visual", + "sequential_impulse_paper_wall_brick_09_07_visual", + "sequential_impulse_paper_wall_brick_09_08_visual", + "sequential_impulse_paper_wall_brick_09_09_visual", + "sequential_impulse_paper_wall_brick_09_10_visual", + "sequential_impulse_paper_wall_brick_09_11_visual", + "sequential_impulse_paper_wall_brick_09_12_visual", + "sequential_impulse_paper_wall_brick_09_13_visual", + "sequential_impulse_paper_wall_brick_09_14_visual", + "sequential_impulse_paper_wall_brick_09_15_visual", + "sequential_impulse_paper_wall_brick_09_16_visual", + "sequential_impulse_paper_wall_brick_09_17_visual", + "sequential_impulse_paper_wall_brick_09_18_visual", + "sequential_impulse_paper_wall_brick_09_19_visual", + "sequential_impulse_paper_wall_brick_09_20_visual", + "sequential_impulse_paper_wall_brick_10_00_visual", + "sequential_impulse_paper_wall_brick_10_01_visual", + "sequential_impulse_paper_wall_brick_10_02_visual", + "sequential_impulse_paper_wall_brick_10_03_visual", + "sequential_impulse_paper_wall_brick_10_04_visual", + "sequential_impulse_paper_wall_brick_10_05_visual", + "sequential_impulse_paper_wall_brick_10_06_visual", + "sequential_impulse_paper_wall_brick_10_07_visual", + "sequential_impulse_paper_wall_brick_10_08_visual", + "sequential_impulse_paper_wall_brick_10_09_visual", + "sequential_impulse_paper_wall_brick_10_10_visual", + "sequential_impulse_paper_wall_brick_10_11_visual", + "sequential_impulse_paper_wall_brick_10_12_visual", + "sequential_impulse_paper_wall_brick_10_13_visual", + "sequential_impulse_paper_wall_brick_10_14_visual", + "sequential_impulse_paper_wall_brick_10_15_visual", + "sequential_impulse_paper_wall_brick_10_16_visual", + "sequential_impulse_paper_wall_brick_10_17_visual", + "sequential_impulse_paper_wall_brick_10_18_visual", + "sequential_impulse_paper_wall_brick_10_19_visual", + "sequential_impulse_paper_wall_brick_10_20_visual", + "sequential_impulse_paper_wall_brick_11_00_visual", + "sequential_impulse_paper_wall_brick_11_01_visual", + "sequential_impulse_paper_wall_brick_11_02_visual", + "sequential_impulse_paper_wall_brick_11_03_visual", + "sequential_impulse_paper_wall_brick_11_04_visual", + "sequential_impulse_paper_wall_brick_11_05_visual", + "sequential_impulse_paper_wall_brick_11_06_visual", + "sequential_impulse_paper_wall_brick_11_07_visual", + "sequential_impulse_paper_wall_brick_11_08_visual", + "sequential_impulse_paper_wall_brick_11_09_visual", + "sequential_impulse_paper_wall_brick_11_10_visual", + "sequential_impulse_paper_wall_brick_11_11_visual", + "sequential_impulse_paper_wall_brick_11_12_visual", + "sequential_impulse_paper_wall_brick_11_13_visual", + "sequential_impulse_paper_wall_brick_11_14_visual", + "sequential_impulse_paper_wall_brick_11_15_visual", + "sequential_impulse_paper_wall_brick_11_16_visual", + "sequential_impulse_paper_wall_brick_11_17_visual", + "sequential_impulse_paper_wall_brick_11_18_visual", + "sequential_impulse_paper_wall_brick_11_19_visual", + "sequential_impulse_paper_wall_brick_11_20_visual" + ], + "issues": [], + "metrics": { + "ambiguity_iou": 0.3631713252270891, + "center_visible": true, + "corner_coverage": 1.0, + "occlusion_fraction": 0.002478929102627665, + "subject_fraction": 0.09977762464977616 + }, + "pass": true, + "schema_version": "dart.view_report/v1", + "score": 0.9378458674227612, + "size": [ + 1280, + 720 + ] + } + }, + "scene_metrics_events": { + "event_count": 120, + "file": "scene_metrics.jsonl", + "sha256": "9fa7d418275d6d4e4a3a6a32101ae6c61828eb131a11eaf06e420cdcee56c8c9" + }, + "screenshot": { + "file": "sequential_impulse_paper_breakable_wall_collapse.png", + "sha256": "e1f9ecfc683fb60974a6ca76905a4f40b6b0a981e53463af230b7241cc40c97f" + }, + "source_provenance": { + "algorithm": "sha256-length-prefixed-capture-source-tree-v1", + "digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12", + "file_count": 1546, + "git_head": "d9af744a9cb0763033d76e01381dbf7c1e6ecaa9", + "roots": [ + "CMakeLists.txt", + "cmake", + "dart", + "pixi.lock", + "pixi.toml", + "pyproject.toml", + "python/CMakeLists.txt", + "python/dartpy", + "python/examples/demos", + "scripts/capture_py_demo.py", + "scripts/capture_source_provenance.py" + ] + } + }, + "fracture": { + "camera": { + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, + "preset": "front", + "target": [ + 0.0, + 0.45, + 1.6 + ], + "view": "front-oblique" + }, + "capture": { + "height": 720, + "requested_frames": 14, + "width": 1280 + }, + "image_verdict": { + "checks": { + "contrast": { + "bright_pixels": 1354, + "dark_pixels": 11703, + "mid_pixels": 209744, + "p05": 55.585, + "p95": 151.795, + "pass": true, + "reasons": [], + "region": { + "x0": 512, + "x1": 1241, + "y0": 324, + "y1": 648 + }, + "sample_count": 236196, + "spread": 96.21, + "thresholds": { + "bright_luminance_gt": 190.0, + "dark_luminance_lt": 55.0, + "mid_luminance_max": 170.0, + "mid_luminance_min": 70.0, + "min_bright_pixels": 1180, + "min_dark_pixels": 1180, + "min_mid_pixels": 590, + "min_spread": 90.0 + } + }, + "non_blank": { + "nonzero_pixels": 921600, + "pass": true, + "reasons": [], + "total_pixels": 921600, + "unique_colors_seen": 2 + } + }, + "file": "image_verdict.json", + "machine_scope": "pixel-integrity", + "metadata": { + "frame": "14", + "scene": "sequential_impulse_paper_breakable_wall", + "view": "front-oblique" + }, + "pass": true, + "sha256": "3c774ee16efc44b1875ffd2945d3b3851f23fa7714c4b3a6c38353404450f54b" + }, + "label": "fracture", + "manifest": { + "file": "manifest.json", + "sha256": "795afc2ef2aa7b2bd1163c9fd383e06d6dfb7fbb28887cb15c6571274a0d4262" + }, + "scene_metrics": { + "event_count": 14, + "frame": 14, + "outcome": { + "ball_positions": [ + [ + -3.098073202241845, + -0.3946344148621828, + 1.3412557393001772 + ], + [ + -0.3086359994805316, + -0.39824512953783375, + 1.534804675378108 + ], + [ + 3.10224859745802, + -0.406645026821888, + 2.147290409119712 + ] + ], + "ball_velocities": [ + [ + 0.030836577109298536, + 3.1484833175766425, + -0.6835865124940816 + ], + [ + 0.034479351944689325, + 3.141941145300331, + -0.4509172813565596 + ], + [ + 0.036651272283550064, + 2.8244248296310204, + -0.6478121173212148 + ] + ], + "bent_brick_count": 106, + "broken_joint_identity_count": 5, + "broken_joint_ids_sha256": "c85184879b1b9036ff582731031fc49c56b4149e93ded45780b06352bd94d61d", + "broken_joint_impact_region_counts": [ + 2, + 1, + 2 + ], + "broken_joint_records": [ + { + "angular_residual_radians": 0.0754340704026891, + "child": { + "body": "brick", + "column": 16, + "row": 8 + }, + "id": "sequential_impulse_paper_wall_horizontal_08_15", + "initial_anchor": [ + 3.7199999999999998, + 0.0, + 2.305 + ], + "kind": "horizontal", + "linear_residual": 0.031481230621811435, + "nearest_impact_distance": 0.6216309194369273, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.11010883219954927, + "child": { + "body": "brick", + "column": 5, + "row": 5 + }, + "id": "sequential_impulse_paper_wall_vertical_04_05_05_05", + "initial_anchor": [ + -2.79, + 0.0, + 1.495 + ], + "kind": "vertical", + "linear_residual": 0.01980640585113358, + "nearest_impact_distance": 0.31484122982862334, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 5, + "row": 4 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.09398507568890756, + "child": { + "body": "brick", + "column": 5, + "row": 6 + }, + "id": "sequential_impulse_paper_wall_vertical_05_04_06_05", + "initial_anchor": [ + -3.1, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 0.01836765941609077, + "nearest_impact_distance": 0.21500000000000008, + "nearest_impact_index": 0, + "parent": { + "body": "brick", + "column": 4, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.09015324708896295, + "child": { + "body": "brick", + "column": 10, + "row": 6 + }, + "id": "sequential_impulse_paper_wall_vertical_05_09_06_10", + "initial_anchor": [ + 0.0, + 0.0, + 1.7650000000000001 + ], + "kind": "vertical", + "linear_residual": 0.017099728436564012, + "nearest_impact_distance": 0.3103626910567699, + "nearest_impact_index": 1, + "parent": { + "body": "brick", + "column": 9, + "row": 5 + }, + "within_impact_band": true, + "within_impact_region": true + }, + { + "angular_residual_radians": 0.08771045680720489, + "child": { + "body": "brick", + "column": 15, + "row": 9 + }, + "id": "sequential_impulse_paper_wall_vertical_08_15_09_15", + "initial_anchor": [ + 3.41, + 0.0, + 2.575 + ], + "kind": "vertical", + "linear_residual": 0.015224522322355988, + "nearest_impact_distance": 0.3830469945058962, + "nearest_impact_index": 2, + "parent": { + "body": "brick", + "column": 15, + "row": 8 + }, + "within_impact_band": true, + "within_impact_region": true + } + ], + "broken_joints": 5, + "broken_joints_outside_impact_regions": 0, + "checkpoint": "fracture", + "contact_count": 19, + "evaluated": true, + "frame": 14, + "impact_band_displaced_counts": [ + 13, + 14, + 13 + ], + "impact_damage_displacement_threshold": 0.1, + "joint_residuals_finite": true, + "last_step_iterations": 20, + "max_brick_displacement": 0.22102721837884676, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": 0.09605372662014049, + "maximum_outside_impact_unbroken_joint_linear_residual": 0.02066703337305443, + "maximum_unbroken_joint_angular_residual_radians": 0.10666325747892752, + "maximum_unbroken_joint_linear_residual": 0.025157513585462494, + "maximum_wall_normal_displacement": 0.20004560951685946, + "outside_brick_count": 176, + "outside_impact_unbroken_joint_residual_count": 484, + "outside_retained_fraction": 1.0, + "retained_displacement_threshold": 0.5, + "rms_outside_impact_unbroken_joint_angular_residual_radians": 0.024846694726072444, + "rms_outside_impact_unbroken_joint_linear_residual": 0.010877496165289583, + "rms_unbroken_joint_angular_residual_radians": 0.03155688687435937, + "rms_unbroken_joint_linear_residual": 0.011631942282383598, + "rms_wall_normal_displacement": 0.08081101502561881, + "status": "pass", + "threshold_checks": { + "finite_state": true, + "fracture_activated": true, + "initial_fracture_confined_to_impact_regions": true, + "initial_fracture_covers_three_impacts": true, + "initial_fracture_identity_matches": true, + "initial_retained_joint_rows_bounded": true, + "wall_initially_retained": true + }, + "thresholds_pass": true, + "total_retained_fraction": 1.0, + "unbroken_joint_residual_count": 707, + "unbroken_joints": 707, + "world_time": 0.2333333333333333 + }, + "outcome_oracle": { + "collapse_evaluation_frame": 120, + "evaluation_frame": 14, + "expected_broken_joint_ids_sha256": "c85184879b1b9036ff582731031fc49c56b4149e93ded45780b06352bd94d61d", + "expected_broken_joints": 5, + "expected_outside_impact_unbroken_joint_count": 484, + "impact_band_radius": 0.85, + "impact_damage_displacement_threshold": 0.1, + "joint_evidence_frames": [ + 14, + 120 + ], + "maximum_collapse_outside_retained_fraction": 0.35, + "maximum_collapse_total_retained_fraction": 0.25, + "maximum_final_broken_joints": 30, + "maximum_initial_broken_joints": 20, + "maximum_initial_outside_joint_angular_residual_radians": 0.13, + "maximum_initial_outside_joint_linear_residual": 0.04, + "minimum_collapse_outside_joint_maximum_angular_residual_radians": 0.6, + "minimum_collapse_outside_joint_maximum_linear_residual": 0.05, + "minimum_collapse_outside_joint_rms_angular_residual_radians": 0.18, + "minimum_collapse_outside_joint_rms_linear_residual": 0.015, + "minimum_collapse_wall_normal_displacement": 2.0, + "minimum_displaced_bricks_per_impact_band": 10, + "minimum_final_broken_joints": 3, + "minimum_final_unbroken_joints": 680, + "minimum_initial_broken_joints": 3, + "minimum_initial_broken_joints_per_impact_region": 1, + "minimum_initial_total_retained_fraction": 0.95, + "minimum_initial_unbroken_joints": 690, + "outside_radius": 1.15, + "retained_displacement_threshold": 0.5 + }, + "resolved_configuration": [ + { + "domain": "rigid-body", + "reason": "as requested", + "requested": "sequential-impulse", + "resolved": "sequential-impulse" + }, + { + "domain": "rigid-contact", + "reason": "as requested", + "requested": "sequential-impulse", + "resolved": "sequential-impulse" + }, + { + "domain": "rigid-pair-constraint", + "reason": "hard public rigid pair constraints use solver-owned sequential-impulse rows", + "requested": "sequential-impulse", + "resolved": "sequential-impulse" + }, + { + "domain": "rigid-constraint-iterations", + "reason": "as requested", + "requested": "20", + "resolved": "20" + }, + { + "domain": "multibody", + "reason": "as requested", + "requested": "semi-implicit", + "resolved": "semi-implicit" + }, + { + "domain": "deformable-psd", + "reason": "as requested", + "requested": "cpu", + "resolved": "cpu" + } + ], + "scene_contract": { + "ball_count": 3, + "break_force": 5000.0, + "breakable_joints": 712, + "brick_count": 252, + "collision_shapes": 256, + "executor": "World.step default", + "paper_locator": "Section 5.4 and Figure 13, PDF page 10", + "rigid_bodies": 256, + "rigid_body_solver": "SEQUENTIAL_IMPULSE", + "rigid_constraint_options": { + "iterations": 20 + }, + "solver": "public_sequential-impulse", + "time_step_ms": 16.666666666666668 + }, + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", + "view_report": { + "camera": { + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, + "target": [ + 0.0, + 0.45, + 1.6 + ] + }, + "focus": [ + "sequential_impulse_paper_wall_brick_00_00_visual", + "sequential_impulse_paper_wall_brick_00_01_visual", + "sequential_impulse_paper_wall_brick_00_02_visual", + "sequential_impulse_paper_wall_brick_00_03_visual", + "sequential_impulse_paper_wall_brick_00_04_visual", + "sequential_impulse_paper_wall_brick_00_05_visual", + "sequential_impulse_paper_wall_brick_00_06_visual", + "sequential_impulse_paper_wall_brick_00_07_visual", + "sequential_impulse_paper_wall_brick_00_08_visual", + "sequential_impulse_paper_wall_brick_00_09_visual", + "sequential_impulse_paper_wall_brick_00_10_visual", + "sequential_impulse_paper_wall_brick_00_11_visual", + "sequential_impulse_paper_wall_brick_00_12_visual", + "sequential_impulse_paper_wall_brick_00_13_visual", + "sequential_impulse_paper_wall_brick_00_14_visual", + "sequential_impulse_paper_wall_brick_00_15_visual", + "sequential_impulse_paper_wall_brick_00_16_visual", + "sequential_impulse_paper_wall_brick_00_17_visual", + "sequential_impulse_paper_wall_brick_00_18_visual", + "sequential_impulse_paper_wall_brick_00_19_visual", + "sequential_impulse_paper_wall_brick_00_20_visual", + "sequential_impulse_paper_wall_brick_01_00_visual", + "sequential_impulse_paper_wall_brick_01_01_visual", + "sequential_impulse_paper_wall_brick_01_02_visual", + "sequential_impulse_paper_wall_brick_01_03_visual", + "sequential_impulse_paper_wall_brick_01_04_visual", + "sequential_impulse_paper_wall_brick_01_05_visual", + "sequential_impulse_paper_wall_brick_01_06_visual", + "sequential_impulse_paper_wall_brick_01_07_visual", + "sequential_impulse_paper_wall_brick_01_08_visual", + "sequential_impulse_paper_wall_brick_01_09_visual", + "sequential_impulse_paper_wall_brick_01_10_visual", + "sequential_impulse_paper_wall_brick_01_11_visual", + "sequential_impulse_paper_wall_brick_01_12_visual", + "sequential_impulse_paper_wall_brick_01_13_visual", + "sequential_impulse_paper_wall_brick_01_14_visual", + "sequential_impulse_paper_wall_brick_01_15_visual", + "sequential_impulse_paper_wall_brick_01_16_visual", + "sequential_impulse_paper_wall_brick_01_17_visual", + "sequential_impulse_paper_wall_brick_01_18_visual", + "sequential_impulse_paper_wall_brick_01_19_visual", + "sequential_impulse_paper_wall_brick_01_20_visual", + "sequential_impulse_paper_wall_brick_02_00_visual", + "sequential_impulse_paper_wall_brick_02_01_visual", + "sequential_impulse_paper_wall_brick_02_02_visual", + "sequential_impulse_paper_wall_brick_02_03_visual", + "sequential_impulse_paper_wall_brick_02_04_visual", + "sequential_impulse_paper_wall_brick_02_05_visual", + "sequential_impulse_paper_wall_brick_02_06_visual", + "sequential_impulse_paper_wall_brick_02_07_visual", + "sequential_impulse_paper_wall_brick_02_08_visual", + "sequential_impulse_paper_wall_brick_02_09_visual", + "sequential_impulse_paper_wall_brick_02_10_visual", + "sequential_impulse_paper_wall_brick_02_11_visual", + "sequential_impulse_paper_wall_brick_02_12_visual", + "sequential_impulse_paper_wall_brick_02_13_visual", + "sequential_impulse_paper_wall_brick_02_14_visual", + "sequential_impulse_paper_wall_brick_02_15_visual", + "sequential_impulse_paper_wall_brick_02_16_visual", + "sequential_impulse_paper_wall_brick_02_17_visual", + "sequential_impulse_paper_wall_brick_02_18_visual", + "sequential_impulse_paper_wall_brick_02_19_visual", + "sequential_impulse_paper_wall_brick_02_20_visual", + "sequential_impulse_paper_wall_brick_03_00_visual", + "sequential_impulse_paper_wall_brick_03_01_visual", + "sequential_impulse_paper_wall_brick_03_02_visual", + "sequential_impulse_paper_wall_brick_03_03_visual", + "sequential_impulse_paper_wall_brick_03_04_visual", + "sequential_impulse_paper_wall_brick_03_05_visual", + "sequential_impulse_paper_wall_brick_03_06_visual", + "sequential_impulse_paper_wall_brick_03_07_visual", + "sequential_impulse_paper_wall_brick_03_08_visual", + "sequential_impulse_paper_wall_brick_03_09_visual", + "sequential_impulse_paper_wall_brick_03_10_visual", + "sequential_impulse_paper_wall_brick_03_11_visual", + "sequential_impulse_paper_wall_brick_03_12_visual", + "sequential_impulse_paper_wall_brick_03_13_visual", + "sequential_impulse_paper_wall_brick_03_14_visual", + "sequential_impulse_paper_wall_brick_03_15_visual", + "sequential_impulse_paper_wall_brick_03_16_visual", + "sequential_impulse_paper_wall_brick_03_17_visual", + "sequential_impulse_paper_wall_brick_03_18_visual", + "sequential_impulse_paper_wall_brick_03_19_visual", + "sequential_impulse_paper_wall_brick_03_20_visual", + "sequential_impulse_paper_wall_brick_04_00_visual", + "sequential_impulse_paper_wall_brick_04_01_visual", + "sequential_impulse_paper_wall_brick_04_02_visual", + "sequential_impulse_paper_wall_brick_04_03_visual", + "sequential_impulse_paper_wall_brick_04_04_visual", + "sequential_impulse_paper_wall_brick_04_05_visual", + "sequential_impulse_paper_wall_brick_04_06_visual", + "sequential_impulse_paper_wall_brick_04_07_visual", + "sequential_impulse_paper_wall_brick_04_08_visual", + "sequential_impulse_paper_wall_brick_04_09_visual", + "sequential_impulse_paper_wall_brick_04_10_visual", + "sequential_impulse_paper_wall_brick_04_11_visual", + "sequential_impulse_paper_wall_brick_04_12_visual", + "sequential_impulse_paper_wall_brick_04_13_visual", + "sequential_impulse_paper_wall_brick_04_14_visual", + "sequential_impulse_paper_wall_brick_04_15_visual", + "sequential_impulse_paper_wall_brick_04_16_visual", + "sequential_impulse_paper_wall_brick_04_17_visual", + "sequential_impulse_paper_wall_brick_04_18_visual", + "sequential_impulse_paper_wall_brick_04_19_visual", + "sequential_impulse_paper_wall_brick_04_20_visual", + "sequential_impulse_paper_wall_brick_05_00_visual", + "sequential_impulse_paper_wall_brick_05_01_visual", + "sequential_impulse_paper_wall_brick_05_02_visual", + "sequential_impulse_paper_wall_brick_05_03_visual", + "sequential_impulse_paper_wall_brick_05_04_visual", + "sequential_impulse_paper_wall_brick_05_05_visual", + "sequential_impulse_paper_wall_brick_05_06_visual", + "sequential_impulse_paper_wall_brick_05_07_visual", + "sequential_impulse_paper_wall_brick_05_08_visual", + "sequential_impulse_paper_wall_brick_05_09_visual", + "sequential_impulse_paper_wall_brick_05_10_visual", + "sequential_impulse_paper_wall_brick_05_11_visual", + "sequential_impulse_paper_wall_brick_05_12_visual", + "sequential_impulse_paper_wall_brick_05_13_visual", + "sequential_impulse_paper_wall_brick_05_14_visual", + "sequential_impulse_paper_wall_brick_05_15_visual", + "sequential_impulse_paper_wall_brick_05_16_visual", + "sequential_impulse_paper_wall_brick_05_17_visual", + "sequential_impulse_paper_wall_brick_05_18_visual", + "sequential_impulse_paper_wall_brick_05_19_visual", + "sequential_impulse_paper_wall_brick_05_20_visual", + "sequential_impulse_paper_wall_brick_06_00_visual", + "sequential_impulse_paper_wall_brick_06_01_visual", + "sequential_impulse_paper_wall_brick_06_02_visual", + "sequential_impulse_paper_wall_brick_06_03_visual", + "sequential_impulse_paper_wall_brick_06_04_visual", + "sequential_impulse_paper_wall_brick_06_05_visual", + "sequential_impulse_paper_wall_brick_06_06_visual", + "sequential_impulse_paper_wall_brick_06_07_visual", + "sequential_impulse_paper_wall_brick_06_08_visual", + "sequential_impulse_paper_wall_brick_06_09_visual", + "sequential_impulse_paper_wall_brick_06_10_visual", + "sequential_impulse_paper_wall_brick_06_11_visual", + "sequential_impulse_paper_wall_brick_06_12_visual", + "sequential_impulse_paper_wall_brick_06_13_visual", + "sequential_impulse_paper_wall_brick_06_14_visual", + "sequential_impulse_paper_wall_brick_06_15_visual", + "sequential_impulse_paper_wall_brick_06_16_visual", + "sequential_impulse_paper_wall_brick_06_17_visual", + "sequential_impulse_paper_wall_brick_06_18_visual", + "sequential_impulse_paper_wall_brick_06_19_visual", + "sequential_impulse_paper_wall_brick_06_20_visual", + "sequential_impulse_paper_wall_brick_07_00_visual", + "sequential_impulse_paper_wall_brick_07_01_visual", + "sequential_impulse_paper_wall_brick_07_02_visual", + "sequential_impulse_paper_wall_brick_07_03_visual", + "sequential_impulse_paper_wall_brick_07_04_visual", + "sequential_impulse_paper_wall_brick_07_05_visual", + "sequential_impulse_paper_wall_brick_07_06_visual", + "sequential_impulse_paper_wall_brick_07_07_visual", + "sequential_impulse_paper_wall_brick_07_08_visual", + "sequential_impulse_paper_wall_brick_07_09_visual", + "sequential_impulse_paper_wall_brick_07_10_visual", + "sequential_impulse_paper_wall_brick_07_11_visual", + "sequential_impulse_paper_wall_brick_07_12_visual", + "sequential_impulse_paper_wall_brick_07_13_visual", + "sequential_impulse_paper_wall_brick_07_14_visual", + "sequential_impulse_paper_wall_brick_07_15_visual", + "sequential_impulse_paper_wall_brick_07_16_visual", + "sequential_impulse_paper_wall_brick_07_17_visual", + "sequential_impulse_paper_wall_brick_07_18_visual", + "sequential_impulse_paper_wall_brick_07_19_visual", + "sequential_impulse_paper_wall_brick_07_20_visual", + "sequential_impulse_paper_wall_brick_08_00_visual", + "sequential_impulse_paper_wall_brick_08_01_visual", + "sequential_impulse_paper_wall_brick_08_02_visual", + "sequential_impulse_paper_wall_brick_08_03_visual", + "sequential_impulse_paper_wall_brick_08_04_visual", + "sequential_impulse_paper_wall_brick_08_05_visual", + "sequential_impulse_paper_wall_brick_08_06_visual", + "sequential_impulse_paper_wall_brick_08_07_visual", + "sequential_impulse_paper_wall_brick_08_08_visual", + "sequential_impulse_paper_wall_brick_08_09_visual", + "sequential_impulse_paper_wall_brick_08_10_visual", + "sequential_impulse_paper_wall_brick_08_11_visual", + "sequential_impulse_paper_wall_brick_08_12_visual", + "sequential_impulse_paper_wall_brick_08_13_visual", + "sequential_impulse_paper_wall_brick_08_14_visual", + "sequential_impulse_paper_wall_brick_08_15_visual", + "sequential_impulse_paper_wall_brick_08_16_visual", + "sequential_impulse_paper_wall_brick_08_17_visual", + "sequential_impulse_paper_wall_brick_08_18_visual", + "sequential_impulse_paper_wall_brick_08_19_visual", + "sequential_impulse_paper_wall_brick_08_20_visual", + "sequential_impulse_paper_wall_brick_09_00_visual", + "sequential_impulse_paper_wall_brick_09_01_visual", + "sequential_impulse_paper_wall_brick_09_02_visual", + "sequential_impulse_paper_wall_brick_09_03_visual", + "sequential_impulse_paper_wall_brick_09_04_visual", + "sequential_impulse_paper_wall_brick_09_05_visual", + "sequential_impulse_paper_wall_brick_09_06_visual", + "sequential_impulse_paper_wall_brick_09_07_visual", + "sequential_impulse_paper_wall_brick_09_08_visual", + "sequential_impulse_paper_wall_brick_09_09_visual", + "sequential_impulse_paper_wall_brick_09_10_visual", + "sequential_impulse_paper_wall_brick_09_11_visual", + "sequential_impulse_paper_wall_brick_09_12_visual", + "sequential_impulse_paper_wall_brick_09_13_visual", + "sequential_impulse_paper_wall_brick_09_14_visual", + "sequential_impulse_paper_wall_brick_09_15_visual", + "sequential_impulse_paper_wall_brick_09_16_visual", + "sequential_impulse_paper_wall_brick_09_17_visual", + "sequential_impulse_paper_wall_brick_09_18_visual", + "sequential_impulse_paper_wall_brick_09_19_visual", + "sequential_impulse_paper_wall_brick_09_20_visual", + "sequential_impulse_paper_wall_brick_10_00_visual", + "sequential_impulse_paper_wall_brick_10_01_visual", + "sequential_impulse_paper_wall_brick_10_02_visual", + "sequential_impulse_paper_wall_brick_10_03_visual", + "sequential_impulse_paper_wall_brick_10_04_visual", + "sequential_impulse_paper_wall_brick_10_05_visual", + "sequential_impulse_paper_wall_brick_10_06_visual", + "sequential_impulse_paper_wall_brick_10_07_visual", + "sequential_impulse_paper_wall_brick_10_08_visual", + "sequential_impulse_paper_wall_brick_10_09_visual", + "sequential_impulse_paper_wall_brick_10_10_visual", + "sequential_impulse_paper_wall_brick_10_11_visual", + "sequential_impulse_paper_wall_brick_10_12_visual", + "sequential_impulse_paper_wall_brick_10_13_visual", + "sequential_impulse_paper_wall_brick_10_14_visual", + "sequential_impulse_paper_wall_brick_10_15_visual", + "sequential_impulse_paper_wall_brick_10_16_visual", + "sequential_impulse_paper_wall_brick_10_17_visual", + "sequential_impulse_paper_wall_brick_10_18_visual", + "sequential_impulse_paper_wall_brick_10_19_visual", + "sequential_impulse_paper_wall_brick_10_20_visual", + "sequential_impulse_paper_wall_brick_11_00_visual", + "sequential_impulse_paper_wall_brick_11_01_visual", + "sequential_impulse_paper_wall_brick_11_02_visual", + "sequential_impulse_paper_wall_brick_11_03_visual", + "sequential_impulse_paper_wall_brick_11_04_visual", + "sequential_impulse_paper_wall_brick_11_05_visual", + "sequential_impulse_paper_wall_brick_11_06_visual", + "sequential_impulse_paper_wall_brick_11_07_visual", + "sequential_impulse_paper_wall_brick_11_08_visual", + "sequential_impulse_paper_wall_brick_11_09_visual", + "sequential_impulse_paper_wall_brick_11_10_visual", + "sequential_impulse_paper_wall_brick_11_11_visual", + "sequential_impulse_paper_wall_brick_11_12_visual", + "sequential_impulse_paper_wall_brick_11_13_visual", + "sequential_impulse_paper_wall_brick_11_14_visual", + "sequential_impulse_paper_wall_brick_11_15_visual", + "sequential_impulse_paper_wall_brick_11_16_visual", + "sequential_impulse_paper_wall_brick_11_17_visual", + "sequential_impulse_paper_wall_brick_11_18_visual", + "sequential_impulse_paper_wall_brick_11_19_visual", + "sequential_impulse_paper_wall_brick_11_20_visual" + ], + "issues": [], + "metrics": { + "ambiguity_iou": 0.17348599324478636, + "center_visible": true, + "corner_coverage": 1.0, + "occlusion_fraction": 0.07535944471988101, + "subject_fraction": 0.1269169976036998 + }, + "pass": true, + "schema_version": "dart.view_report/v1", + "score": 0.8860583889711418, + "size": [ + 1280, + 720 + ] + } + }, + "scene_metrics_events": { + "event_count": 14, + "file": "scene_metrics.jsonl", + "sha256": "9b17ad2d2cda342ac68b1059bfc965549c9b342b39849d493ece66a95d81ccc1" + }, + "screenshot": { + "file": "sequential_impulse_paper_breakable_wall_fracture.png", + "sha256": "24cff2be00f71a3ed24216e577d7a6f3d9519e69d076c724a59f4a1ac108ddee" + }, + "source_provenance": { + "algorithm": "sha256-length-prefixed-capture-source-tree-v1", + "digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12", + "file_count": 1546, + "git_head": "d9af744a9cb0763033d76e01381dbf7c1e6ecaa9", + "roots": [ + "CMakeLists.txt", + "cmake", + "dart", + "pixi.lock", + "pixi.toml", + "pyproject.toml", + "python/CMakeLists.txt", + "python/dartpy", + "python/examples/demos", + "scripts/capture_py_demo.py", + "scripts/capture_source_provenance.py" + ] + } + }, + "semantic_review": { + "claim_and_expected_observation": "The matched Sequential Impulse Figure 13 row should break a small impact-local set of joints while the wall is initially standing, then accumulate large residuals on retained rows outside the impacts and collapse without further fracture.", + "file": "si-visual-review.json", + "inspected_images": [ + { + "file": "sequential_impulse_paper_breakable_wall_fracture.png", + "role": "fracture_frame_14", + "sha256": "24cff2be00f71a3ed24216e577d7a6f3d9519e69d076c724a59f4a1ac108ddee" + }, + { + "file": "sequential_impulse_paper_breakable_wall_collapse.png", + "role": "collapse_frame_120", + "sha256": "e1f9ecfc683fb60974a6ca76905a4f40b6b0a981e53463af230b7241cc40c97f" + }, + { + "file": "paper-10.png", + "role": "paper_figure_13_reference", + "sha256": "040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b" + } + ], + "not_proven_and_limitations": "The images cannot reveal joint identity or residual magnitude, so those claims rely on the checked text oracle and digest. This three-method packet does not reproduce XPBD, establish CUDA parity, prove exact unpublished source constants, or establish parity with published/source timing.", + "reconciliation_and_verdict": "PASS: both engine ViewReports pass with all 252 bricks in focus. At frame 14 exactly five broken identities cover the three impact regions in counts 2, 1, and 2, with none outside; all 484 outside-impact retained rows remain present. At frame 120 the same identity digest remains with no additional joint breaks, but those outside-impact rows reach 0.063460 m maximum and 0.020763 m RMS linear residual plus 0.727048 rad maximum and 0.216362 rad RMS angular residual. The images independently progress from a standing wall around the three impacts to a slab collapsed across the ground.", + "reviewer_capability": "Codex native image input with original-resolution inspection of the six regenerated captures and pinned paper figure", + "sha256": "1c4b2eecbe1faa12767f1b401c227f3b79d7f216f00823bc5e4162d387736d70", + "text_oracle": "Figure 13 states that Sequential Impulse fractures first but cannot maintain retained constraints, causing slow bending and final collapse; the DART row directly measures retained rows outside the impacts.", + "verdict": "pass", + "visible_observation": "At frame 14 the wall is still globally upright and continuous around the three balls. At frame 120 almost the entire wall lies flat across the ground in a coherent collapsed slab while the same five joints, and no additional joints, are recorded as broken." + } + } +} diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json index a525180630b59..f9f583e2978eb 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json @@ -2,9 +2,11 @@ "benchmark": { "comparison": { "basis": "same executable, host, reconstructed scene fingerprint, trajectory length, time step, and 20-sweep budget", - "vbd_to_avbd_median_cpu_cost_ratio": 1.234459059747689 + "vbd_to_avbd_median_cpu_cost_ratio": 0.9640125592202341 }, "context": { + "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", + "capture_source_provenance_digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12", "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", "host_name": "mark26", "json_schema_version": 1, @@ -13,7 +15,7 @@ "mhz_per_cpu": 5300, "num_cpus": 32 }, - "json_sha256": "d5aa41fe4ae4c2bbcfe23b12c87b031d4ec839123e5e104cbdfd3401a52ccbb9", + "json_sha256": "c2c81c26ca0fa96d1cf11da4c5a166a3a2eb7ead9b80aafac207c20f03cf7c67", "methods": { "avbd": { "benchmark": "BM_AvbdPaperBreakableWallStep", @@ -23,14 +25,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 7434849.3500000015, + "cpu_time": 8598657.814999996, "family_index": 0, "impacting_balls": 3.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_mean", "per_family_instance_index": 0, "public_avbd_family": 1.0, - "real_time": 7436581.405102817, + "real_time": 8599444.653373212, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -42,8 +44,8 @@ "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "runtime_contract_passed": 1.0, - "scene_spec_fingerprint_hi": 712271905.0, - "scene_spec_fingerprint_lo": 3423664878.0, + "scene_spec_fingerprint_hi": 2359557114.0, + "scene_spec_fingerprint_lo": 12836073.0, "threads": 1, "time_unit": "ns", "trajectory_frames": 120.0 @@ -53,14 +55,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 7467247.275000003, + "cpu_time": 8612602.108333336, "family_index": 0, "impacting_balls": 3.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_median", "per_family_instance_index": 0, "public_avbd_family": 1.0, - "real_time": 7467605.933440305, + "real_time": 8614304.249931592, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -72,8 +74,8 @@ "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "runtime_contract_passed": 1.0, - "scene_spec_fingerprint_hi": 712271905.0, - "scene_spec_fingerprint_lo": 3423664878.0, + "scene_spec_fingerprint_hi": 2359557114.0, + "scene_spec_fingerprint_lo": 12836073.0, "threads": 1, "time_unit": "ns", "trajectory_frames": 120.0 @@ -83,14 +85,14 @@ "aggregate_unit": "time", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 352941.77407079434, + "cpu_time": 175085.7497607501, "family_index": 0, "impacting_balls": 0.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_stddev", "per_family_instance_index": 0, "public_avbd_family": 0.0, - "real_time": 353435.6344223652, + "real_time": 175125.93686717955, "repetitions": 5, "resolved_rigid_body_avbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -102,8 +104,8 @@ "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "runtime_contract_passed": 0.0, - "scene_spec_fingerprint_hi": 8.94427190999916, - "scene_spec_fingerprint_lo": 50.59644256269407, + "scene_spec_fingerprint_hi": 0.0, + "scene_spec_fingerprint_lo": 0.0, "threads": 1, "time_unit": "ns", "trajectory_frames": 0.0 @@ -113,14 +115,14 @@ "aggregate_unit": "percentage", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 0.047471274461101796, + "cpu_time": 0.020361985966614506, "family_index": 0, "impacting_balls": 0.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_cv", "per_family_instance_index": 0, "public_avbd_family": 0.0, - "real_time": 0.04752662751460576, + "real_time": 0.02036479609162724, "repetitions": 5, "resolved_rigid_body_avbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -132,23 +134,23 @@ "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "runtime_contract_passed": 0.0, - "scene_spec_fingerprint_hi": 1.2557384121446091e-08, - "scene_spec_fingerprint_lo": 1.4778444843658577e-08, + "scene_spec_fingerprint_hi": 0.0, + "scene_spec_fingerprint_lo": 0.0, "threads": 1, "time_unit": "ns", "trajectory_frames": 0.0 } ], "stability": { - "cpu_time_cv_fraction": 0.047471274461101796, - "real_time_cv_fraction": 0.04752662751460576, + "cpu_time_cv_fraction": 0.020361985966614506, + "real_time_cv_fraction": 0.02036479609162724, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 7434849.3500000015, - "mean_real_time_per_step_ns": 7436581.405102817, - "median_cpu_time_per_step_ns": 7467247.275000003, - "median_real_time_per_step_ns": 7467605.933440305 + "mean_cpu_time_per_step_ns": 8598657.814999996, + "mean_real_time_per_step_ns": 8599444.653373212, + "median_cpu_time_per_step_ns": 8612602.108333336, + "median_real_time_per_step_ns": 8614304.249931592 } }, "vbd": { @@ -159,14 +161,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9283993.19666667, + "cpu_time": 8292024.418333335, "family_index": 1, "impacting_balls": 3.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_mean", "per_family_instance_index": 0, "public_vbd_family": 1.0, - "real_time": 9285653.501719933, + "real_time": 8292807.493320045, "repetitions": 5, "resolved_rigid_body_vbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -178,8 +180,8 @@ "run_name": "BM_VbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "runtime_contract_passed": 1.0, - "scene_spec_fingerprint_hi": 712271905.0, - "scene_spec_fingerprint_lo": 3423664878.0, + "scene_spec_fingerprint_hi": 2359557114.0, + "scene_spec_fingerprint_lo": 12836073.0, "threads": 1, "time_unit": "ns", "trajectory_frames": 120.0 @@ -189,14 +191,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9218011.049999997, + "cpu_time": 8302656.600000003, "family_index": 1, "impacting_balls": 3.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_median", "per_family_instance_index": 0, "public_vbd_family": 1.0, - "real_time": 9218644.316812666, + "real_time": 8303211.016755086, "repetitions": 5, "resolved_rigid_body_vbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -208,8 +210,8 @@ "run_name": "BM_VbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "runtime_contract_passed": 1.0, - "scene_spec_fingerprint_hi": 712271905.0, - "scene_spec_fingerprint_lo": 3423664878.0, + "scene_spec_fingerprint_hi": 2359557114.0, + "scene_spec_fingerprint_lo": 12836073.0, "threads": 1, "time_unit": "ns", "trajectory_frames": 120.0 @@ -219,14 +221,14 @@ "aggregate_unit": "time", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 315697.4492439518, + "cpu_time": 160779.40977443795, "family_index": 1, "impacting_balls": 0.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_stddev", "per_family_instance_index": 0, "public_vbd_family": 0.0, - "real_time": 316183.0099676496, + "real_time": 160409.40949144252, "repetitions": 5, "resolved_rigid_body_vbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -238,8 +240,8 @@ "run_name": "BM_VbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "runtime_contract_passed": 0.0, - "scene_spec_fingerprint_hi": 8.94427190999916, - "scene_spec_fingerprint_lo": 50.59644256269407, + "scene_spec_fingerprint_hi": 0.0, + "scene_spec_fingerprint_lo": 0.0, "threads": 1, "time_unit": "ns", "trajectory_frames": 0.0 @@ -249,14 +251,14 @@ "aggregate_unit": "percentage", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 0.0340044895075214, + "cpu_time": 0.019389644996577804, "family_index": 1, "impacting_balls": 0.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_cv", "per_family_instance_index": 0, "public_vbd_family": 0.0, - "real_time": 0.03405070089133572, + "real_time": 0.019343197056081937, "repetitions": 5, "resolved_rigid_body_vbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -268,27 +270,31 @@ "run_name": "BM_VbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", "runtime_contract_passed": 0.0, - "scene_spec_fingerprint_hi": 1.2557384121446091e-08, - "scene_spec_fingerprint_lo": 1.4778444843658577e-08, + "scene_spec_fingerprint_hi": 0.0, + "scene_spec_fingerprint_lo": 0.0, "threads": 1, "time_unit": "ns", "trajectory_frames": 0.0 } ], "stability": { - "cpu_time_cv_fraction": 0.0340044895075214, - "real_time_cv_fraction": 0.03405070089133572, + "cpu_time_cv_fraction": 0.019389644996577804, + "real_time_cv_fraction": 0.019343197056081937, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 9283993.19666667, - "mean_real_time_per_step_ns": 9285653.501719933, - "median_cpu_time_per_step_ns": 9218011.049999997, - "median_real_time_per_step_ns": 9218644.316812666 + "mean_cpu_time_per_step_ns": 8292024.418333335, + "mean_real_time_per_step_ns": 8292807.493320045, + "median_cpu_time_per_step_ns": 8302656.600000003, + "median_real_time_per_step_ns": 8303211.016755086 } } }, - "scene_spec_fingerprint": "2a746821cc10faee" + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", + "source_provenance": { + "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", + "capture_source_provenance_digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12" + } }, "claim_boundary": "This packet covers the matched fixed-penalty VBD row and its same-host CPU comparison with the linked public AVBD Figure 13 packet. It does not reproduce the Sequential Impulse or XPBD rows, claim exact unpublished source constants, establish CUDA parity, or compare against a published/source timing.", "correctness": { @@ -305,7 +311,7 @@ "rigid_constraint_options": { "iterations": 20 }, - "scene_spec_fingerprint": "2a746821cc10faee", + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "time_step": 0.016666666666666666 } }, @@ -318,20 +324,20 @@ "rigid_contact_solver": "avbd", "rigid_point_joint_solver": "avbd" }, - "scene_spec_fingerprint": "2a746821cc10faee", - "sha256": "ffb8c76fa9708ce7e6660d18087cfe6602acfd960b6a88898f1a88e0a099d0c0", + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", + "sha256": "3e15adb9093c10567c0f8c22c5f8e8ad4e5d47a7acd5aca7a107fb97de7fc54c", "visual_evidence": { "impact_screenshot": { "file": "avbd_paper_breakable_wall_impact.png", - "sha256": "a3ab663cdbbd030bccddbee838e134fa3326fdec459f3a8b734fa0da044f63a3" + "sha256": "1d72ad775b690494372585ba9f91eb751aaefb2ba6c94a2466c9009ace94c3d5" }, "outcome_screenshot": { "file": "avbd_paper_breakable_wall_outcome.png", - "sha256": "6587784a2ea32578d46b155e4aa3b51579c5be3ec2936e168f0b43367eb94a34" + "sha256": "199c4f6d676a65c59403da39adce607ace101ac55674f1766c0be6deaf4954d7" }, "semantic_review": { - "file": "visual_review.json", - "sha256": "07060971349b4d1af35fdedfb3bfae880597f5b53b4aefe3c301f59f7b4c10fe", + "file": "avbd-visual-review.json", + "sha256": "9d8317aa351a607a902167eb9e51284669067cfe5f0681dde542c47d7c43f8c2", "verdict": "pass" } } @@ -354,11 +360,11 @@ "publication_observation": "The Figure 13 VBD row bends under the three impacts but does not break.", "reconstruction": { "matched_with_avbd": { - "break_force": 8500.0, + "break_force": 5000.0, "breakable_joints": 712, "bricks": 252, "impacting_balls": 3, - "scene_spec_fingerprint": "2a746821cc10faee" + "scene_spec_fingerprint": "8ca3fbfa00c3dce9" }, "published": { "constraint_iterations_or_substeps": 20, @@ -368,10 +374,14 @@ "unpublished": "wall dimensions, body materials, ball properties and launch conditions, break threshold, and exact source scene" }, "reproduction": { - "benchmark_command": "pixi run bm -- bm_avbd_rigid_fixed_joint -- --benchmark_filter='^BM_(Avbd|Vbd)PaperBreakableWallStep/iterations:120$' --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_out= --benchmark_out_format=json", + "benchmark_command": "capture_source_digest=$(pixi run python scripts/capture_source_provenance.py --digest-only) && benchmark_source_digest=$(sha256sum tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp | cut -d' ' -f1) && pixi run bm -- bm_avbd_rigid_fixed_joint -- --benchmark_filter='^BM_(Avbd|Vbd)PaperBreakableWallStep/iterations:120$' --benchmark_repetitions=5 --benchmark_report_aggregates_only=true --benchmark_context=capture_source_provenance_digest=$capture_source_digest --benchmark_context=benchmark_source_sha256=$benchmark_source_digest --benchmark_out= --benchmark_out_format=json", "capture_commands": [ - "pixi run py-demo-capture -- --scene vbd_paper_breakable_wall --frames 14 --width 1280 --height 720 --view front --camera-distance 11 --camera-target 0,0,1.4 --capture-label bend --output-dir ", - "pixi run py-demo-capture -- --scene vbd_paper_breakable_wall --frames 120 --width 1280 --height 720 --view front --camera-distance 11 --camera-target 0,0,1.4 --capture-label retention --output-dir " + "pixi run py-demo-capture -- --scene vbd_paper_breakable_wall --frames 18 --width 1280 --height 720 --view front --camera-azimuth -112.5 --camera-elevation 35.52338329811104 --camera-distance 22 --camera-target 0,0.45,1.6 --capture-label bend --output-dir ", + "pixi run py-demo-capture -- --scene vbd_paper_breakable_wall --frames 120 --width 1280 --height 720 --view front --camera-azimuth -112.5 --camera-elevation 35.52338329811104 --camera-distance 22 --camera-target 0,0.45,1.6 --capture-label retention --output-dir " + ], + "image_verdict_commands": [ + "pixi run image-verdict -- /vbd_paper_breakable_wall_bend.png --meta scene=vbd_paper_breakable_wall --meta frame=18 --meta view=front-oblique --out /image_verdict.json", + "pixi run image-verdict -- /vbd_paper_breakable_wall_retention.png --meta scene=vbd_paper_breakable_wall --meta frame=120 --meta view=front-oblique --out /image_verdict.json" ] }, "resolved_solver_identity": { @@ -382,14 +392,14 @@ "rigid_point_joint_solver": "vbd" }, "scene": "vbd_paper_breakable_wall", - "schema_version": 3, + "schema_version": 4, "source_provenance": { "algorithm": "sha256-length-prefixed-path-and-content-v1", - "digest": "5dfbf32dbc165a1e83dd3e23e1a6d1aa95ffbf9749fbac3a49d7a305253d49b0", + "digest": "567be3b170e62e1bab54061878b6a1e31d5d42839ecac0b59cca85ba4904f9a9", "files": [ { "path": "dart/simulation/world.cpp", - "sha256": "df7b4d1dfb4140190a43e65f0dc6d46e8121b489bda796f65afb77dac190bf50" + "sha256": "23502cd1a7366c83afe8f7c9d7b7d0a0ebeaf32d27d75a35f65b57ccd9d0e09c" }, { "path": "dart/simulation/world.hpp", @@ -397,11 +407,23 @@ }, { "path": "dart/simulation/world_options.hpp", - "sha256": "6306b5aa84cc523d8edde96385cbba5a6b461e26f4eb8fdba9e659d704756b88" + "sha256": "bcc8e950d73534a2d5dc953431939b2fa776235d188fb685bf65589b4d556b34" + }, + { + "path": "dart/simulation/comps/joint.hpp", + "sha256": "dbbab333dfeb086f280675a31badc7756bc74606100e72081a6c6140d4b61617" + }, + { + "path": "dart/simulation/compute/detail/world_step_stages.hpp", + "sha256": "e03ffa5b25dc6d96f088936f8838a9f1be6c658d34d08589347564436862ce05" }, { "path": "dart/simulation/compute/rigid_body_contact_stage.cpp", - "sha256": "125a7d11510749db22d28d3b9dfe08888aeb31b8711238e4ff81c90dca5559ff" + "sha256": "755238810875628ce13315b113ac2d766992d0c305783c26d194bbd84fd0b89c" + }, + { + "path": "dart/simulation/detail/rigid_pair_constraint.hpp", + "sha256": "735a62ae1b673ea68d7d26002cc0b72412f239216718e49ffab38b42b40cfffe" }, { "path": "dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp", @@ -409,39 +431,47 @@ }, { "path": "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp", - "sha256": "1184d6f71fd9705bd03760cefdf6e471f3c0ff0d73c4886f7014ff8812ae0ced" + "sha256": "79b20d53e40bddce50ee3158f76fdd902aea5ea3d165dd75d4862b6e537b7c22" }, { "path": "dart/simulation/detail/world_step_schedule.hpp", - "sha256": "715a42595c19e8dba821d865cb477086f55600d54cf870902be73e253f1d1bec" + "sha256": "547ba1920a79a2e860fafb1fe59bba644a01ddb1caff0d50ef8440f2774aa386" }, { "path": "python/examples/demos/scenes/avbd_paper_breakable_wall.py", - "sha256": "bb418854c79e6a2f5ca3d01059c9ce94ae0f135b9acecb46e2a46d34bfd5ebac" + "sha256": "40675bcd66d2ae543dc1bd60a435926dd6969893eb0a92aa451fe66f9b8bda12" }, { "path": "python/examples/demos/scenes/vbd_paper_breakable_wall.py", - "sha256": "9ec3f058fb63cc896b1c27e141c3dee34d3890c5c03f57ce72a5cb16a692bce4" + "sha256": "5de40f443b26a7695593a97bd77eecd0d9c2a43bfe0083c95503008a66601acc" }, { "path": "python/tests/integration/test_demos_cycle.py", - "sha256": "f792e19e4d3da4497122b0f55d7baca7c24f7f8e58fd4e5d266d7290c5f8e447" + "sha256": "dd33a2c2190b6b13c7c8a408863fa20dbfc24174fd64587ee9c2ed88bb23c5ba" }, { "path": "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", - "sha256": "24e5a9658898806ab9cfa0d765076884630c8cb100a52cf168d53e91a4ade01f" + "sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370" }, { "path": "tests/unit/simulation/world/test_world.cpp", - "sha256": "d4dde65145b8d3628489e597dd1b813b1a69b6ab1a60a666187fbe3dae7cb460" + "sha256": "59f5d6d12b3b6ce021279b254807623cff56432107906cfda56204145d81a507" }, { "path": "scripts/avbd_packet_schema.py", - "sha256": "f3bb031ca321abea4a49d8fc1392f33dfffca482c6797618f94f680f3543c04b" + "sha256": "d2cf699fd9f19fe97fa992977f56c00a43745a0659bda4deb8e10e6fe91e021c" + }, + { + "path": "scripts/capture_py_demo.py", + "sha256": "669e72ccf230a0c89f5d1505e648b7e4c7f189b6f79738790be9c4fe101a95ee" + }, + { + "path": "scripts/capture_source_provenance.py", + "sha256": "f81a59a95a97a3efb5901d1eb6507dd1272f75cd2a4320db6d863cb48186b612" }, { "path": "scripts/write_avbd_paper_vbd_comparison_packet.py", - "sha256": "7924bbd7e1f61b4e8812678c7566a8bcf04d0cb3d4c8f827d7b8dc96f26a0513" + "sha256": "b64a04d92d44256e065921f352867a0f73b94a7b3304055590f9d920a3c1515c" } ] }, @@ -455,27 +485,30 @@ "visual_evidence": { "bend": { "camera": { - "distance": 11.0, + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, + "preset": "front", "target": [ 0.0, - 0.0, - 1.4 + 0.45, + 1.6 ], - "view": "front" + "view": "front-oblique" }, "capture": { "height": 720, - "requested_frames": 14, + "requested_frames": 18, "width": 1280 }, "image_verdict": { "checks": { "contrast": { - "bright_pixels": 1259, - "dark_pixels": 57821, - "mid_pixels": 151043, - "p05": 35.09, - "p95": 147.723, + "bright_pixels": 1546, + "dark_pixels": 10931, + "mid_pixels": 210151, + "p05": 57.583, + "p95": 152.127, "pass": true, "reasons": [], "region": { @@ -485,7 +518,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 112.633, + "spread": 94.544, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -508,96 +541,129 @@ "file": "image_verdict.json", "machine_scope": "pixel-integrity", "metadata": { - "frame": "14", + "frame": "18", "scene": "vbd_paper_breakable_wall", - "view": "front" + "view": "front-oblique" }, "pass": true, - "sha256": "035aff2135867535ca55ff658fc6d9b56701edb3e497c29f209ee6282bebda70" + "sha256": "3b1eba7942e4543a3021416f3969593c82a79f774ced5b1a15e1659ff457aafd" }, "label": "bend", "manifest": { "file": "manifest.json", - "sha256": "afce8eb0b0e2b4a9b12327184473888b8dc97a5e899463effdd6af249db6f10c" + "sha256": "60cd81a038ee2ab1296ec39a01d191f282d2db652315d6d59c989c96afa601e7" }, "scene_metrics": { - "event_count": 14, - "frame": 14, + "event_count": 18, + "frame": 18, "outcome": { "ball_positions": [ [ - -3.095194390778397, - -0.31337254836840883, - 1.3152668021613967 + -3.1014592569930888, + -0.7592547969945558, + 1.1637751320531748 ], [ - -0.00018660146588299803, - -0.2732814645230808, - 1.4713295069252923 + -0.30467918316160736, + -0.7021908461343801, + 1.1966110030946036 ], [ - 3.095554987013543, - -0.2789269932584263, - 2.0800199857034722 + 3.1028599580996845, + -0.7536264280595334, + 2.089741239350687 ] ], "ball_velocities": [ [ - 0.19974399132345333, - -2.8630967401358034, - -0.5913280598170401 + -0.01804950435968955, + -3.572823032438268, + -2.293639086634111 ], [ - -0.008003948397866957, - -1.5926396369675018, - -1.9674643237228473 + 0.05703043209988379, + -2.8109339691265744, + -3.674069111342015 ], [ - -0.17792006977352948, - -1.598896714370408, - -1.849455289044748 + 0.031175380466725855, + -3.4852412474697902, + -0.9705517803070292 ] ], - "bent_brick_count": 131, + "bent_brick_count": 120, + "broken_joint_identity_count": 0, + "broken_joint_ids_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "broken_joint_impact_region_counts": [ + 0, + 0, + 0 + ], + "broken_joint_records": [], "broken_joints": 0, + "broken_joints_outside_impact_regions": 0, "checkpoint": "bend", - "contact_count": 26, + "contact_count": 0, "evaluated": true, - "frame": 14, + "frame": 18, "impact_band_displaced_counts": [ 0, - 0, - 0 + 4, + 5 ], + "impact_damage_displacement_threshold": 0.1, + "joint_residuals_finite": true, "last_step_iterations": 20, - "max_brick_displacement": 0.22874158419119156, - "maximum_wall_normal_displacement": 0.2287292903493461, - "outside_brick_count": 181, + "max_brick_displacement": 0.13854382726065478, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": 0.013274727518221521, + "maximum_outside_impact_unbroken_joint_linear_residual": 0.016387334256405626, + "maximum_unbroken_joint_angular_residual_radians": 0.013274727518221521, + "maximum_unbroken_joint_linear_residual": 0.016387334256405626, + "maximum_wall_normal_displacement": 0.13170492944493803, + "outside_brick_count": 176, + "outside_impact_unbroken_joint_residual_count": 484, "outside_retained_fraction": 1.0, - "rms_wall_normal_displacement": 0.12227322736028712, + "retained_displacement_threshold": 0.5, + "rms_outside_impact_unbroken_joint_angular_residual_radians": 0.003082940458206736, + "rms_outside_impact_unbroken_joint_linear_residual": 0.004537795682318528, + "rms_unbroken_joint_angular_residual_radians": 0.0027849527460258924, + "rms_unbroken_joint_linear_residual": 0.0043428509866939155, + "rms_wall_normal_displacement": 0.06240700588016252, "status": "pass", "threshold_checks": { "bend_is_spatially_resolved": true, "finite_state": true, "no_fracture": true, + "retained_joint_rows_satisfied": true, "topology_retained": true, "wall_bend_is_distributed": true, "wall_bends": true }, "thresholds_pass": true, "total_retained_fraction": 1.0, + "unbroken_joint_residual_count": 712, "unbroken_joints": 712, - "world_time": 0.2333333333333333 + "world_time": 0.3 }, "outcome_oracle": { - "bent_brick_displacement_threshold": 0.1, - "evaluation_frame": 14, + "bent_brick_displacement_threshold": 0.05, + "evaluation_frame": 18, + "impact_band_radius": 0.85, + "impact_damage_displacement_threshold": 0.1, + "joint_evidence_frames": [ + 18, + 120 + ], "maximum_broken_joints": 0, + "maximum_unbroken_joint_angular_residual_radians": 0.02, + "maximum_unbroken_joint_linear_residual": 0.025, "minimum_bent_bricks": 100, - "minimum_maximum_wall_normal_displacement": 0.2, - "minimum_rms_wall_normal_displacement": 0.1, + "minimum_maximum_wall_normal_displacement": 0.1, + "minimum_rms_wall_normal_displacement": 0.05, "minimum_total_retained_fraction": 0.99, "minimum_unbroken_joints": 712, + "outside_radius": 1.15, + "retained_displacement_threshold": 0.5, "retention_evaluation_frame": 120 }, "resolved_configuration": [ @@ -640,7 +706,7 @@ ], "scene_contract": { "ball_count": 3, - "break_force": 8500.0, + "break_force": 5000.0, "breakable_joints": 712, "brick_count": 252, "collision_shapes": 256, @@ -654,35 +720,283 @@ "solver": "public_vbd", "time_step_ms": 16.666666666666668 }, - "scene_spec_fingerprint": "2a746821cc10faee", + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "view_report": { "camera": { - "azimuth": -1.5707963267948966, - "distance": 11.0, - "elevation": 0.0, + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, "target": [ 0.0, - 0.0, - 1.4 + 0.45, + 1.6 ] }, "focus": [ "vbd_paper_wall_brick_00_00_visual", + "vbd_paper_wall_brick_00_01_visual", + "vbd_paper_wall_brick_00_02_visual", + "vbd_paper_wall_brick_00_03_visual", + "vbd_paper_wall_brick_00_04_visual", + "vbd_paper_wall_brick_00_05_visual", + "vbd_paper_wall_brick_00_06_visual", + "vbd_paper_wall_brick_00_07_visual", + "vbd_paper_wall_brick_00_08_visual", + "vbd_paper_wall_brick_00_09_visual", + "vbd_paper_wall_brick_00_10_visual", + "vbd_paper_wall_brick_00_11_visual", + "vbd_paper_wall_brick_00_12_visual", + "vbd_paper_wall_brick_00_13_visual", + "vbd_paper_wall_brick_00_14_visual", + "vbd_paper_wall_brick_00_15_visual", + "vbd_paper_wall_brick_00_16_visual", + "vbd_paper_wall_brick_00_17_visual", + "vbd_paper_wall_brick_00_18_visual", + "vbd_paper_wall_brick_00_19_visual", "vbd_paper_wall_brick_00_20_visual", + "vbd_paper_wall_brick_01_00_visual", + "vbd_paper_wall_brick_01_01_visual", + "vbd_paper_wall_brick_01_02_visual", + "vbd_paper_wall_brick_01_03_visual", + "vbd_paper_wall_brick_01_04_visual", + "vbd_paper_wall_brick_01_05_visual", + "vbd_paper_wall_brick_01_06_visual", + "vbd_paper_wall_brick_01_07_visual", + "vbd_paper_wall_brick_01_08_visual", + "vbd_paper_wall_brick_01_09_visual", + "vbd_paper_wall_brick_01_10_visual", + "vbd_paper_wall_brick_01_11_visual", + "vbd_paper_wall_brick_01_12_visual", + "vbd_paper_wall_brick_01_13_visual", + "vbd_paper_wall_brick_01_14_visual", + "vbd_paper_wall_brick_01_15_visual", + "vbd_paper_wall_brick_01_16_visual", + "vbd_paper_wall_brick_01_17_visual", + "vbd_paper_wall_brick_01_18_visual", + "vbd_paper_wall_brick_01_19_visual", + "vbd_paper_wall_brick_01_20_visual", + "vbd_paper_wall_brick_02_00_visual", + "vbd_paper_wall_brick_02_01_visual", + "vbd_paper_wall_brick_02_02_visual", + "vbd_paper_wall_brick_02_03_visual", + "vbd_paper_wall_brick_02_04_visual", + "vbd_paper_wall_brick_02_05_visual", + "vbd_paper_wall_brick_02_06_visual", + "vbd_paper_wall_brick_02_07_visual", + "vbd_paper_wall_brick_02_08_visual", + "vbd_paper_wall_brick_02_09_visual", + "vbd_paper_wall_brick_02_10_visual", + "vbd_paper_wall_brick_02_11_visual", + "vbd_paper_wall_brick_02_12_visual", + "vbd_paper_wall_brick_02_13_visual", + "vbd_paper_wall_brick_02_14_visual", + "vbd_paper_wall_brick_02_15_visual", + "vbd_paper_wall_brick_02_16_visual", + "vbd_paper_wall_brick_02_17_visual", + "vbd_paper_wall_brick_02_18_visual", + "vbd_paper_wall_brick_02_19_visual", + "vbd_paper_wall_brick_02_20_visual", + "vbd_paper_wall_brick_03_00_visual", + "vbd_paper_wall_brick_03_01_visual", + "vbd_paper_wall_brick_03_02_visual", + "vbd_paper_wall_brick_03_03_visual", + "vbd_paper_wall_brick_03_04_visual", + "vbd_paper_wall_brick_03_05_visual", + "vbd_paper_wall_brick_03_06_visual", + "vbd_paper_wall_brick_03_07_visual", + "vbd_paper_wall_brick_03_08_visual", + "vbd_paper_wall_brick_03_09_visual", + "vbd_paper_wall_brick_03_10_visual", + "vbd_paper_wall_brick_03_11_visual", + "vbd_paper_wall_brick_03_12_visual", + "vbd_paper_wall_brick_03_13_visual", + "vbd_paper_wall_brick_03_14_visual", + "vbd_paper_wall_brick_03_15_visual", + "vbd_paper_wall_brick_03_16_visual", + "vbd_paper_wall_brick_03_17_visual", + "vbd_paper_wall_brick_03_18_visual", + "vbd_paper_wall_brick_03_19_visual", + "vbd_paper_wall_brick_03_20_visual", + "vbd_paper_wall_brick_04_00_visual", + "vbd_paper_wall_brick_04_01_visual", + "vbd_paper_wall_brick_04_02_visual", + "vbd_paper_wall_brick_04_03_visual", + "vbd_paper_wall_brick_04_04_visual", + "vbd_paper_wall_brick_04_05_visual", + "vbd_paper_wall_brick_04_06_visual", + "vbd_paper_wall_brick_04_07_visual", + "vbd_paper_wall_brick_04_08_visual", + "vbd_paper_wall_brick_04_09_visual", + "vbd_paper_wall_brick_04_10_visual", + "vbd_paper_wall_brick_04_11_visual", + "vbd_paper_wall_brick_04_12_visual", + "vbd_paper_wall_brick_04_13_visual", + "vbd_paper_wall_brick_04_14_visual", + "vbd_paper_wall_brick_04_15_visual", + "vbd_paper_wall_brick_04_16_visual", + "vbd_paper_wall_brick_04_17_visual", + "vbd_paper_wall_brick_04_18_visual", + "vbd_paper_wall_brick_04_19_visual", + "vbd_paper_wall_brick_04_20_visual", + "vbd_paper_wall_brick_05_00_visual", + "vbd_paper_wall_brick_05_01_visual", + "vbd_paper_wall_brick_05_02_visual", + "vbd_paper_wall_brick_05_03_visual", + "vbd_paper_wall_brick_05_04_visual", + "vbd_paper_wall_brick_05_05_visual", + "vbd_paper_wall_brick_05_06_visual", + "vbd_paper_wall_brick_05_07_visual", + "vbd_paper_wall_brick_05_08_visual", + "vbd_paper_wall_brick_05_09_visual", + "vbd_paper_wall_brick_05_10_visual", + "vbd_paper_wall_brick_05_11_visual", + "vbd_paper_wall_brick_05_12_visual", + "vbd_paper_wall_brick_05_13_visual", + "vbd_paper_wall_brick_05_14_visual", + "vbd_paper_wall_brick_05_15_visual", + "vbd_paper_wall_brick_05_16_visual", + "vbd_paper_wall_brick_05_17_visual", + "vbd_paper_wall_brick_05_18_visual", + "vbd_paper_wall_brick_05_19_visual", + "vbd_paper_wall_brick_05_20_visual", + "vbd_paper_wall_brick_06_00_visual", + "vbd_paper_wall_brick_06_01_visual", + "vbd_paper_wall_brick_06_02_visual", + "vbd_paper_wall_brick_06_03_visual", + "vbd_paper_wall_brick_06_04_visual", + "vbd_paper_wall_brick_06_05_visual", + "vbd_paper_wall_brick_06_06_visual", + "vbd_paper_wall_brick_06_07_visual", + "vbd_paper_wall_brick_06_08_visual", + "vbd_paper_wall_brick_06_09_visual", + "vbd_paper_wall_brick_06_10_visual", + "vbd_paper_wall_brick_06_11_visual", + "vbd_paper_wall_brick_06_12_visual", + "vbd_paper_wall_brick_06_13_visual", + "vbd_paper_wall_brick_06_14_visual", + "vbd_paper_wall_brick_06_15_visual", + "vbd_paper_wall_brick_06_16_visual", + "vbd_paper_wall_brick_06_17_visual", + "vbd_paper_wall_brick_06_18_visual", + "vbd_paper_wall_brick_06_19_visual", + "vbd_paper_wall_brick_06_20_visual", + "vbd_paper_wall_brick_07_00_visual", + "vbd_paper_wall_brick_07_01_visual", + "vbd_paper_wall_brick_07_02_visual", + "vbd_paper_wall_brick_07_03_visual", + "vbd_paper_wall_brick_07_04_visual", + "vbd_paper_wall_brick_07_05_visual", + "vbd_paper_wall_brick_07_06_visual", + "vbd_paper_wall_brick_07_07_visual", + "vbd_paper_wall_brick_07_08_visual", + "vbd_paper_wall_brick_07_09_visual", + "vbd_paper_wall_brick_07_10_visual", + "vbd_paper_wall_brick_07_11_visual", + "vbd_paper_wall_brick_07_12_visual", + "vbd_paper_wall_brick_07_13_visual", + "vbd_paper_wall_brick_07_14_visual", + "vbd_paper_wall_brick_07_15_visual", + "vbd_paper_wall_brick_07_16_visual", + "vbd_paper_wall_brick_07_17_visual", + "vbd_paper_wall_brick_07_18_visual", + "vbd_paper_wall_brick_07_19_visual", + "vbd_paper_wall_brick_07_20_visual", + "vbd_paper_wall_brick_08_00_visual", + "vbd_paper_wall_brick_08_01_visual", + "vbd_paper_wall_brick_08_02_visual", + "vbd_paper_wall_brick_08_03_visual", + "vbd_paper_wall_brick_08_04_visual", + "vbd_paper_wall_brick_08_05_visual", + "vbd_paper_wall_brick_08_06_visual", + "vbd_paper_wall_brick_08_07_visual", + "vbd_paper_wall_brick_08_08_visual", + "vbd_paper_wall_brick_08_09_visual", + "vbd_paper_wall_brick_08_10_visual", + "vbd_paper_wall_brick_08_11_visual", + "vbd_paper_wall_brick_08_12_visual", + "vbd_paper_wall_brick_08_13_visual", + "vbd_paper_wall_brick_08_14_visual", + "vbd_paper_wall_brick_08_15_visual", + "vbd_paper_wall_brick_08_16_visual", + "vbd_paper_wall_brick_08_17_visual", + "vbd_paper_wall_brick_08_18_visual", + "vbd_paper_wall_brick_08_19_visual", + "vbd_paper_wall_brick_08_20_visual", + "vbd_paper_wall_brick_09_00_visual", + "vbd_paper_wall_brick_09_01_visual", + "vbd_paper_wall_brick_09_02_visual", + "vbd_paper_wall_brick_09_03_visual", + "vbd_paper_wall_brick_09_04_visual", + "vbd_paper_wall_brick_09_05_visual", + "vbd_paper_wall_brick_09_06_visual", + "vbd_paper_wall_brick_09_07_visual", + "vbd_paper_wall_brick_09_08_visual", + "vbd_paper_wall_brick_09_09_visual", + "vbd_paper_wall_brick_09_10_visual", + "vbd_paper_wall_brick_09_11_visual", + "vbd_paper_wall_brick_09_12_visual", + "vbd_paper_wall_brick_09_13_visual", + "vbd_paper_wall_brick_09_14_visual", + "vbd_paper_wall_brick_09_15_visual", + "vbd_paper_wall_brick_09_16_visual", + "vbd_paper_wall_brick_09_17_visual", + "vbd_paper_wall_brick_09_18_visual", + "vbd_paper_wall_brick_09_19_visual", + "vbd_paper_wall_brick_09_20_visual", + "vbd_paper_wall_brick_10_00_visual", + "vbd_paper_wall_brick_10_01_visual", + "vbd_paper_wall_brick_10_02_visual", + "vbd_paper_wall_brick_10_03_visual", + "vbd_paper_wall_brick_10_04_visual", + "vbd_paper_wall_brick_10_05_visual", + "vbd_paper_wall_brick_10_06_visual", + "vbd_paper_wall_brick_10_07_visual", + "vbd_paper_wall_brick_10_08_visual", + "vbd_paper_wall_brick_10_09_visual", + "vbd_paper_wall_brick_10_10_visual", + "vbd_paper_wall_brick_10_11_visual", + "vbd_paper_wall_brick_10_12_visual", + "vbd_paper_wall_brick_10_13_visual", + "vbd_paper_wall_brick_10_14_visual", + "vbd_paper_wall_brick_10_15_visual", + "vbd_paper_wall_brick_10_16_visual", + "vbd_paper_wall_brick_10_17_visual", + "vbd_paper_wall_brick_10_18_visual", + "vbd_paper_wall_brick_10_19_visual", + "vbd_paper_wall_brick_10_20_visual", "vbd_paper_wall_brick_11_00_visual", + "vbd_paper_wall_brick_11_01_visual", + "vbd_paper_wall_brick_11_02_visual", + "vbd_paper_wall_brick_11_03_visual", + "vbd_paper_wall_brick_11_04_visual", + "vbd_paper_wall_brick_11_05_visual", + "vbd_paper_wall_brick_11_06_visual", + "vbd_paper_wall_brick_11_07_visual", + "vbd_paper_wall_brick_11_08_visual", + "vbd_paper_wall_brick_11_09_visual", + "vbd_paper_wall_brick_11_10_visual", + "vbd_paper_wall_brick_11_11_visual", + "vbd_paper_wall_brick_11_12_visual", + "vbd_paper_wall_brick_11_13_visual", + "vbd_paper_wall_brick_11_14_visual", + "vbd_paper_wall_brick_11_15_visual", + "vbd_paper_wall_brick_11_16_visual", + "vbd_paper_wall_brick_11_17_visual", + "vbd_paper_wall_brick_11_18_visual", + "vbd_paper_wall_brick_11_19_visual", "vbd_paper_wall_brick_11_20_visual" ], "issues": [], "metrics": { - "ambiguity_iou": 0.0, + "ambiguity_iou": 0.07285040741785634, "center_visible": true, "corner_coverage": 1.0, - "occlusion_fraction": 0.3333333333333333, - "subject_fraction": 0.29777577849388215 + "occlusion_fraction": 0.07238472979672782, + "subject_fraction": 0.12988832614544665 }, "pass": true, "schema_version": "dart.view_report/v1", - "score": 0.606450387098188, + "score": 0.9010333145535903, "size": [ 1280, 720 @@ -690,24 +1004,46 @@ } }, "scene_metrics_events": { - "event_count": 14, + "event_count": 18, "file": "scene_metrics.jsonl", - "sha256": "bde75666cd05199605562416a7fd994dacbf8a78d152e6b7c6736b57937cd4f8" + "sha256": "5ea70b2b5c1f77932252c404c4707f13fa68f4ac78f3de2fcee191715adb18cc" }, "screenshot": { "file": "vbd_paper_breakable_wall_bend.png", - "sha256": "aae6cec800f888dff30f09c6c9209e7a7030ebc6505d646e68f44c2466cfdd24" + "sha256": "a94339bf5f07c337133ceb5790b96db33e90471cf24abca7a15f6efccb4d0676" + }, + "source_provenance": { + "algorithm": "sha256-length-prefixed-capture-source-tree-v1", + "digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12", + "file_count": 1546, + "git_head": "d9af744a9cb0763033d76e01381dbf7c1e6ecaa9", + "roots": [ + "CMakeLists.txt", + "cmake", + "dart", + "pixi.lock", + "pixi.toml", + "pyproject.toml", + "python/CMakeLists.txt", + "python/dartpy", + "python/examples/demos", + "scripts/capture_py_demo.py", + "scripts/capture_source_provenance.py" + ] } }, "retention": { "camera": { - "distance": 11.0, + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, + "preset": "front", "target": [ 0.0, - 0.0, - 1.4 + 0.45, + 1.6 ], - "view": "front" + "view": "front-oblique" }, "capture": { "height": 720, @@ -717,14 +1053,14 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 1086, - "dark_pixels": 61703, - "mid_pixels": 146859, - "p05": 34.878, - "p95": 147.723, + "bright_pixels": 839, + "dark_pixels": 12607, + "mid_pixels": 208236, + "p05": 53.585, + "p95": 151.511, "pass": false, "reasons": [ - "bright pixels 1086 < 1180" + "bright pixels 839 < 1180" ], "region": { "x0": 512, @@ -733,7 +1069,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 112.845, + "spread": 97.926, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -758,15 +1094,15 @@ "metadata": { "frame": "120", "scene": "vbd_paper_breakable_wall", - "view": "front" + "view": "front-oblique" }, "pass": true, - "sha256": "6fab3e582e99401f21a67d468a61aaf8c0307f6677f366f7b90be8804359ea55" + "sha256": "5e42f94faa22d53e178493d84fcc587711233908174751c08f5fd77c63b62b83" }, "label": "retention", "manifest": { "file": "manifest.json", - "sha256": "840d694d7fe98e236e01bd3a6c8eaa407e8d17b3c8426f5348fc2bdf512acbc4" + "sha256": "448c89fcbda9cb679b3e93b4ba11545d634d3f96b8b32f94599631a322790c0d" }, "scene_metrics": { "event_count": 120, @@ -774,42 +1110,51 @@ "outcome": { "ball_positions": [ [ - -2.650841152312106, - -10.500981184136311, - -0.3106405050823772 + -3.125555460728934, + -7.671374895680189, + 0.4686195633730729 ], [ - -0.020969640339728923, - -11.800473594287705, - -0.8858731141943095 + -0.19942612148175146, + -8.118870102980527, + 0.46447257482942617 ], [ - 2.6902750342446717, - -10.912281727032484, - -0.6842234485751176 + 3.151867589584558, + -6.806155197651065, + 0.4708517705330658 ] ], "ball_velocities": [ [ - 0.27289846885341973, - -6.292490243031423, - -3.797196084793277 + -0.016267998128833838, + -4.967081942646114, + -0.003748731241216552 ], [ - -0.013530988922874528, - -7.289482888726795, - -5.0532410859474455 + 0.07524817011433371, + -5.4520079780169795, + -0.15651438834559106 ], [ - -0.25247807940752764, - -6.8264214493861175, - -4.676213287271364 + 0.03238103370306611, + -4.1726156681591675, + -0.0026075142400872053 ] ], "bent_brick_count": 0, + "broken_joint_identity_count": 0, + "broken_joint_ids_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "broken_joint_impact_region_counts": [ + 0, + 0, + 0 + ], + "broken_joint_records": [], "broken_joints": 0, + "broken_joints_outside_impact_regions": 0, "checkpoint": "retention", - "contact_count": 0, + "contact_count": 3, "evaluated": true, "frame": 120, "impact_band_displaced_counts": [ @@ -817,33 +1162,57 @@ 0, 0 ], + "impact_damage_displacement_threshold": 0.1, + "joint_residuals_finite": true, "last_step_iterations": 20, - "max_brick_displacement": 0.001208511643710914, - "maximum_wall_normal_displacement": 0.0008573600302032246, - "outside_brick_count": 181, + "max_brick_displacement": 0.04258107111139716, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": 0.0013102017627858748, + "maximum_outside_impact_unbroken_joint_linear_residual": 0.011095423045945942, + "maximum_unbroken_joint_angular_residual_radians": 0.0013102017627858748, + "maximum_unbroken_joint_linear_residual": 0.011095423045945942, + "maximum_wall_normal_displacement": 2.768451341265596e-07, + "outside_brick_count": 176, + "outside_impact_unbroken_joint_residual_count": 484, "outside_retained_fraction": 1.0, - "rms_wall_normal_displacement": 0.0004662711877188896, + "retained_displacement_threshold": 0.5, + "rms_outside_impact_unbroken_joint_angular_residual_radians": 0.00012405458809986383, + "rms_outside_impact_unbroken_joint_linear_residual": 0.0033561626121136412, + "rms_unbroken_joint_angular_residual_radians": 0.00010376313731970173, + "rms_unbroken_joint_linear_residual": 0.0030478012212411237, + "rms_wall_normal_displacement": 1.0811876990727442e-07, "status": "pass", "threshold_checks": { "finite_state": true, "no_fracture": true, + "retained_joint_rows_satisfied": true, "topology_retained": true, "wall_retained": true }, "thresholds_pass": true, "total_retained_fraction": 1.0, + "unbroken_joint_residual_count": 712, "unbroken_joints": 712, "world_time": 1.9999999999999978 }, "outcome_oracle": { - "bent_brick_displacement_threshold": 0.1, - "evaluation_frame": 14, + "bent_brick_displacement_threshold": 0.05, + "evaluation_frame": 18, + "impact_band_radius": 0.85, + "impact_damage_displacement_threshold": 0.1, + "joint_evidence_frames": [ + 18, + 120 + ], "maximum_broken_joints": 0, + "maximum_unbroken_joint_angular_residual_radians": 0.02, + "maximum_unbroken_joint_linear_residual": 0.025, "minimum_bent_bricks": 100, - "minimum_maximum_wall_normal_displacement": 0.2, - "minimum_rms_wall_normal_displacement": 0.1, + "minimum_maximum_wall_normal_displacement": 0.1, + "minimum_rms_wall_normal_displacement": 0.05, "minimum_total_retained_fraction": 0.99, "minimum_unbroken_joints": 712, + "outside_radius": 1.15, + "retained_displacement_threshold": 0.5, "retention_evaluation_frame": 120 }, "resolved_configuration": [ @@ -886,7 +1255,7 @@ ], "scene_contract": { "ball_count": 3, - "break_force": 8500.0, + "break_force": 5000.0, "breakable_joints": 712, "brick_count": 252, "collision_shapes": 256, @@ -900,35 +1269,283 @@ "solver": "public_vbd", "time_step_ms": 16.666666666666668 }, - "scene_spec_fingerprint": "2a746821cc10faee", + "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "view_report": { "camera": { - "azimuth": -1.5707963267948966, - "distance": 11.0, - "elevation": 0.0, + "azimuth": -1.9634954084936207, + "distance": 22.0, + "elevation": 0.62, "target": [ 0.0, - 0.0, - 1.4 + 0.45, + 1.6 ] }, "focus": [ "vbd_paper_wall_brick_00_00_visual", + "vbd_paper_wall_brick_00_01_visual", + "vbd_paper_wall_brick_00_02_visual", + "vbd_paper_wall_brick_00_03_visual", + "vbd_paper_wall_brick_00_04_visual", + "vbd_paper_wall_brick_00_05_visual", + "vbd_paper_wall_brick_00_06_visual", + "vbd_paper_wall_brick_00_07_visual", + "vbd_paper_wall_brick_00_08_visual", + "vbd_paper_wall_brick_00_09_visual", + "vbd_paper_wall_brick_00_10_visual", + "vbd_paper_wall_brick_00_11_visual", + "vbd_paper_wall_brick_00_12_visual", + "vbd_paper_wall_brick_00_13_visual", + "vbd_paper_wall_brick_00_14_visual", + "vbd_paper_wall_brick_00_15_visual", + "vbd_paper_wall_brick_00_16_visual", + "vbd_paper_wall_brick_00_17_visual", + "vbd_paper_wall_brick_00_18_visual", + "vbd_paper_wall_brick_00_19_visual", "vbd_paper_wall_brick_00_20_visual", + "vbd_paper_wall_brick_01_00_visual", + "vbd_paper_wall_brick_01_01_visual", + "vbd_paper_wall_brick_01_02_visual", + "vbd_paper_wall_brick_01_03_visual", + "vbd_paper_wall_brick_01_04_visual", + "vbd_paper_wall_brick_01_05_visual", + "vbd_paper_wall_brick_01_06_visual", + "vbd_paper_wall_brick_01_07_visual", + "vbd_paper_wall_brick_01_08_visual", + "vbd_paper_wall_brick_01_09_visual", + "vbd_paper_wall_brick_01_10_visual", + "vbd_paper_wall_brick_01_11_visual", + "vbd_paper_wall_brick_01_12_visual", + "vbd_paper_wall_brick_01_13_visual", + "vbd_paper_wall_brick_01_14_visual", + "vbd_paper_wall_brick_01_15_visual", + "vbd_paper_wall_brick_01_16_visual", + "vbd_paper_wall_brick_01_17_visual", + "vbd_paper_wall_brick_01_18_visual", + "vbd_paper_wall_brick_01_19_visual", + "vbd_paper_wall_brick_01_20_visual", + "vbd_paper_wall_brick_02_00_visual", + "vbd_paper_wall_brick_02_01_visual", + "vbd_paper_wall_brick_02_02_visual", + "vbd_paper_wall_brick_02_03_visual", + "vbd_paper_wall_brick_02_04_visual", + "vbd_paper_wall_brick_02_05_visual", + "vbd_paper_wall_brick_02_06_visual", + "vbd_paper_wall_brick_02_07_visual", + "vbd_paper_wall_brick_02_08_visual", + "vbd_paper_wall_brick_02_09_visual", + "vbd_paper_wall_brick_02_10_visual", + "vbd_paper_wall_brick_02_11_visual", + "vbd_paper_wall_brick_02_12_visual", + "vbd_paper_wall_brick_02_13_visual", + "vbd_paper_wall_brick_02_14_visual", + "vbd_paper_wall_brick_02_15_visual", + "vbd_paper_wall_brick_02_16_visual", + "vbd_paper_wall_brick_02_17_visual", + "vbd_paper_wall_brick_02_18_visual", + "vbd_paper_wall_brick_02_19_visual", + "vbd_paper_wall_brick_02_20_visual", + "vbd_paper_wall_brick_03_00_visual", + "vbd_paper_wall_brick_03_01_visual", + "vbd_paper_wall_brick_03_02_visual", + "vbd_paper_wall_brick_03_03_visual", + "vbd_paper_wall_brick_03_04_visual", + "vbd_paper_wall_brick_03_05_visual", + "vbd_paper_wall_brick_03_06_visual", + "vbd_paper_wall_brick_03_07_visual", + "vbd_paper_wall_brick_03_08_visual", + "vbd_paper_wall_brick_03_09_visual", + "vbd_paper_wall_brick_03_10_visual", + "vbd_paper_wall_brick_03_11_visual", + "vbd_paper_wall_brick_03_12_visual", + "vbd_paper_wall_brick_03_13_visual", + "vbd_paper_wall_brick_03_14_visual", + "vbd_paper_wall_brick_03_15_visual", + "vbd_paper_wall_brick_03_16_visual", + "vbd_paper_wall_brick_03_17_visual", + "vbd_paper_wall_brick_03_18_visual", + "vbd_paper_wall_brick_03_19_visual", + "vbd_paper_wall_brick_03_20_visual", + "vbd_paper_wall_brick_04_00_visual", + "vbd_paper_wall_brick_04_01_visual", + "vbd_paper_wall_brick_04_02_visual", + "vbd_paper_wall_brick_04_03_visual", + "vbd_paper_wall_brick_04_04_visual", + "vbd_paper_wall_brick_04_05_visual", + "vbd_paper_wall_brick_04_06_visual", + "vbd_paper_wall_brick_04_07_visual", + "vbd_paper_wall_brick_04_08_visual", + "vbd_paper_wall_brick_04_09_visual", + "vbd_paper_wall_brick_04_10_visual", + "vbd_paper_wall_brick_04_11_visual", + "vbd_paper_wall_brick_04_12_visual", + "vbd_paper_wall_brick_04_13_visual", + "vbd_paper_wall_brick_04_14_visual", + "vbd_paper_wall_brick_04_15_visual", + "vbd_paper_wall_brick_04_16_visual", + "vbd_paper_wall_brick_04_17_visual", + "vbd_paper_wall_brick_04_18_visual", + "vbd_paper_wall_brick_04_19_visual", + "vbd_paper_wall_brick_04_20_visual", + "vbd_paper_wall_brick_05_00_visual", + "vbd_paper_wall_brick_05_01_visual", + "vbd_paper_wall_brick_05_02_visual", + "vbd_paper_wall_brick_05_03_visual", + "vbd_paper_wall_brick_05_04_visual", + "vbd_paper_wall_brick_05_05_visual", + "vbd_paper_wall_brick_05_06_visual", + "vbd_paper_wall_brick_05_07_visual", + "vbd_paper_wall_brick_05_08_visual", + "vbd_paper_wall_brick_05_09_visual", + "vbd_paper_wall_brick_05_10_visual", + "vbd_paper_wall_brick_05_11_visual", + "vbd_paper_wall_brick_05_12_visual", + "vbd_paper_wall_brick_05_13_visual", + "vbd_paper_wall_brick_05_14_visual", + "vbd_paper_wall_brick_05_15_visual", + "vbd_paper_wall_brick_05_16_visual", + "vbd_paper_wall_brick_05_17_visual", + "vbd_paper_wall_brick_05_18_visual", + "vbd_paper_wall_brick_05_19_visual", + "vbd_paper_wall_brick_05_20_visual", + "vbd_paper_wall_brick_06_00_visual", + "vbd_paper_wall_brick_06_01_visual", + "vbd_paper_wall_brick_06_02_visual", + "vbd_paper_wall_brick_06_03_visual", + "vbd_paper_wall_brick_06_04_visual", + "vbd_paper_wall_brick_06_05_visual", + "vbd_paper_wall_brick_06_06_visual", + "vbd_paper_wall_brick_06_07_visual", + "vbd_paper_wall_brick_06_08_visual", + "vbd_paper_wall_brick_06_09_visual", + "vbd_paper_wall_brick_06_10_visual", + "vbd_paper_wall_brick_06_11_visual", + "vbd_paper_wall_brick_06_12_visual", + "vbd_paper_wall_brick_06_13_visual", + "vbd_paper_wall_brick_06_14_visual", + "vbd_paper_wall_brick_06_15_visual", + "vbd_paper_wall_brick_06_16_visual", + "vbd_paper_wall_brick_06_17_visual", + "vbd_paper_wall_brick_06_18_visual", + "vbd_paper_wall_brick_06_19_visual", + "vbd_paper_wall_brick_06_20_visual", + "vbd_paper_wall_brick_07_00_visual", + "vbd_paper_wall_brick_07_01_visual", + "vbd_paper_wall_brick_07_02_visual", + "vbd_paper_wall_brick_07_03_visual", + "vbd_paper_wall_brick_07_04_visual", + "vbd_paper_wall_brick_07_05_visual", + "vbd_paper_wall_brick_07_06_visual", + "vbd_paper_wall_brick_07_07_visual", + "vbd_paper_wall_brick_07_08_visual", + "vbd_paper_wall_brick_07_09_visual", + "vbd_paper_wall_brick_07_10_visual", + "vbd_paper_wall_brick_07_11_visual", + "vbd_paper_wall_brick_07_12_visual", + "vbd_paper_wall_brick_07_13_visual", + "vbd_paper_wall_brick_07_14_visual", + "vbd_paper_wall_brick_07_15_visual", + "vbd_paper_wall_brick_07_16_visual", + "vbd_paper_wall_brick_07_17_visual", + "vbd_paper_wall_brick_07_18_visual", + "vbd_paper_wall_brick_07_19_visual", + "vbd_paper_wall_brick_07_20_visual", + "vbd_paper_wall_brick_08_00_visual", + "vbd_paper_wall_brick_08_01_visual", + "vbd_paper_wall_brick_08_02_visual", + "vbd_paper_wall_brick_08_03_visual", + "vbd_paper_wall_brick_08_04_visual", + "vbd_paper_wall_brick_08_05_visual", + "vbd_paper_wall_brick_08_06_visual", + "vbd_paper_wall_brick_08_07_visual", + "vbd_paper_wall_brick_08_08_visual", + "vbd_paper_wall_brick_08_09_visual", + "vbd_paper_wall_brick_08_10_visual", + "vbd_paper_wall_brick_08_11_visual", + "vbd_paper_wall_brick_08_12_visual", + "vbd_paper_wall_brick_08_13_visual", + "vbd_paper_wall_brick_08_14_visual", + "vbd_paper_wall_brick_08_15_visual", + "vbd_paper_wall_brick_08_16_visual", + "vbd_paper_wall_brick_08_17_visual", + "vbd_paper_wall_brick_08_18_visual", + "vbd_paper_wall_brick_08_19_visual", + "vbd_paper_wall_brick_08_20_visual", + "vbd_paper_wall_brick_09_00_visual", + "vbd_paper_wall_brick_09_01_visual", + "vbd_paper_wall_brick_09_02_visual", + "vbd_paper_wall_brick_09_03_visual", + "vbd_paper_wall_brick_09_04_visual", + "vbd_paper_wall_brick_09_05_visual", + "vbd_paper_wall_brick_09_06_visual", + "vbd_paper_wall_brick_09_07_visual", + "vbd_paper_wall_brick_09_08_visual", + "vbd_paper_wall_brick_09_09_visual", + "vbd_paper_wall_brick_09_10_visual", + "vbd_paper_wall_brick_09_11_visual", + "vbd_paper_wall_brick_09_12_visual", + "vbd_paper_wall_brick_09_13_visual", + "vbd_paper_wall_brick_09_14_visual", + "vbd_paper_wall_brick_09_15_visual", + "vbd_paper_wall_brick_09_16_visual", + "vbd_paper_wall_brick_09_17_visual", + "vbd_paper_wall_brick_09_18_visual", + "vbd_paper_wall_brick_09_19_visual", + "vbd_paper_wall_brick_09_20_visual", + "vbd_paper_wall_brick_10_00_visual", + "vbd_paper_wall_brick_10_01_visual", + "vbd_paper_wall_brick_10_02_visual", + "vbd_paper_wall_brick_10_03_visual", + "vbd_paper_wall_brick_10_04_visual", + "vbd_paper_wall_brick_10_05_visual", + "vbd_paper_wall_brick_10_06_visual", + "vbd_paper_wall_brick_10_07_visual", + "vbd_paper_wall_brick_10_08_visual", + "vbd_paper_wall_brick_10_09_visual", + "vbd_paper_wall_brick_10_10_visual", + "vbd_paper_wall_brick_10_11_visual", + "vbd_paper_wall_brick_10_12_visual", + "vbd_paper_wall_brick_10_13_visual", + "vbd_paper_wall_brick_10_14_visual", + "vbd_paper_wall_brick_10_15_visual", + "vbd_paper_wall_brick_10_16_visual", + "vbd_paper_wall_brick_10_17_visual", + "vbd_paper_wall_brick_10_18_visual", + "vbd_paper_wall_brick_10_19_visual", + "vbd_paper_wall_brick_10_20_visual", "vbd_paper_wall_brick_11_00_visual", + "vbd_paper_wall_brick_11_01_visual", + "vbd_paper_wall_brick_11_02_visual", + "vbd_paper_wall_brick_11_03_visual", + "vbd_paper_wall_brick_11_04_visual", + "vbd_paper_wall_brick_11_05_visual", + "vbd_paper_wall_brick_11_06_visual", + "vbd_paper_wall_brick_11_07_visual", + "vbd_paper_wall_brick_11_08_visual", + "vbd_paper_wall_brick_11_09_visual", + "vbd_paper_wall_brick_11_10_visual", + "vbd_paper_wall_brick_11_11_visual", + "vbd_paper_wall_brick_11_12_visual", + "vbd_paper_wall_brick_11_13_visual", + "vbd_paper_wall_brick_11_14_visual", + "vbd_paper_wall_brick_11_15_visual", + "vbd_paper_wall_brick_11_16_visual", + "vbd_paper_wall_brick_11_17_visual", + "vbd_paper_wall_brick_11_18_visual", + "vbd_paper_wall_brick_11_19_visual", "vbd_paper_wall_brick_11_20_visual" ], "issues": [], "metrics": { - "ambiguity_iou": 0.0, + "ambiguity_iou": 0.07837771736585745, "center_visible": true, "corner_coverage": 1.0, - "occlusion_fraction": 0.30303030303030304, - "subject_fraction": 0.29831663683376924 + "occlusion_fraction": 0.0, + "subject_fraction": 0.12977805280679702 }, "pass": true, "schema_version": "dart.view_report/v1", - "score": 0.633905645800401, + "score": 0.9705889922670318, "size": [ 1280, 720 @@ -938,26 +1555,45 @@ "scene_metrics_events": { "event_count": 120, "file": "scene_metrics.jsonl", - "sha256": "229207ff8e2c10f80b6d020bfa078f03b086cd17a5f9353b7ca2f5da247cb55d" + "sha256": "67c1fc3952360c4b915d1fe935562c13e7b01cc1e94ee42e84ce5f0a117db3bb" }, "screenshot": { "file": "vbd_paper_breakable_wall_retention.png", - "sha256": "162d40cce657b9f474aff35d8533852be24185592c90eb518fdaee024a2f5ec7" + "sha256": "039ee1fe05fc7fc0c350da8818be4acc7d812d7d13fcab481b18562adc84751e" + }, + "source_provenance": { + "algorithm": "sha256-length-prefixed-capture-source-tree-v1", + "digest": "556f97640ab079316a2e8833307405eb677c836b86fe95d9eaa90eae9508de12", + "file_count": 1546, + "git_head": "d9af744a9cb0763033d76e01381dbf7c1e6ecaa9", + "roots": [ + "CMakeLists.txt", + "cmake", + "dart", + "pixi.lock", + "pixi.toml", + "pyproject.toml", + "python/CMakeLists.txt", + "python/dartpy", + "python/examples/demos", + "scripts/capture_py_demo.py", + "scripts/capture_source_provenance.py" + ] } }, "semantic_review": { - "claim_and_expected_observation": "For the matched Figure 13 VBD row, the wall should remain one connected structure while distributing the three impacts as a broad transient bend; at the later retention checkpoint it should still have no detached bricks or fracture holes.", - "file": "vbd-paper-breakable-wall-visual-review-fixed.json", + "claim_and_expected_observation": "The matched VBD Figure 13 row should bend in a spatially distributed way under impact without fracture, then retain every brick and all 712 attachment rows.", + "file": "vbd-visual-review.json", "inspected_images": [ { "file": "vbd_paper_breakable_wall_bend.png", - "role": "bend_frame_14", - "sha256": "aae6cec800f888dff30f09c6c9209e7a7030ebc6505d646e68f44c2466cfdd24" + "role": "bend_frame_18", + "sha256": "a94339bf5f07c337133ceb5790b96db33e90471cf24abca7a15f6efccb4d0676" }, { "file": "vbd_paper_breakable_wall_retention.png", "role": "retention_frame_120", - "sha256": "162d40cce657b9f474aff35d8533852be24185592c90eb518fdaee024a2f5ec7" + "sha256": "039ee1fe05fc7fc0c350da8818be4acc7d812d7d13fcab481b18562adc84751e" }, { "file": "paper-10.png", @@ -965,13 +1601,13 @@ "sha256": "040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b" } ], - "not_proven_and_limitations": "The fixed front projection does not itself measure wall-normal depth, so the numeric displacement oracle supplies that quantitative check. These images do not prove exact unpublished source constants, the Sequential Impulse or XPBD comparison rows, CUDA parity, source-scene replay, or a published/reference timing match.", - "reconciliation_and_verdict": "PASS: both engine ViewReports pass, both pixel-integrity verdicts pass, frame 14 records a distributed 0.2287 m maximum and 0.1223 m RMS wall-normal bend across 131 bricks with 0 of 712 joints broken, and the image shows the connected courses displaced around all three balls. At frame 120 the image shows the wall intact with no holes or debris, agreeing with 712 retained joints and 100% retained bricks. Together those observations agree with Figure 13(c) and its bends-but-does-not-break text oracle.", - "reviewer_capability": "native image input inspected at original 1280x720 and 1360x1760 detail", - "sha256": "4844a9e6345073f5666af845e2a9cfe4e0dfc8f0b697903319f7fa5a29b6e475", - "text_oracle": "AVBD paper Figure 13 and its surrounding text state that VBD entirely prevents fracture but bends the wall, smoothly distributing constraint error.", + "not_proven_and_limitations": "The front-oblique image makes depth-wise bending intentionally subtle, so the measured magnitude comes from the numeric oracle rather than visual estimation. This linked VBD/AVBD packet does not prove exact unpublished constants, Sequential Impulse, XPBD, CUDA parity, or published/source timing parity.", + "reconciliation_and_verdict": "PASS: both engine ViewReports pass with all 252 bricks in focus. At frame 18 the oracle records zero broken joints, 120 spatially bent bricks, 0.131705 m maximum wall-normal displacement, and maximum retained-row residuals of 0.016387 m and 0.013275 rad. At frame 120 all 712 identities remain unbroken and the maximum residuals fall to 0.011095 m and 0.001310 rad. The inspected images show the corresponding continuous wall during impact and the later intact standing wall.", + "reviewer_capability": "Codex native image input with original-resolution inspection of the six regenerated captures and pinned paper figure", + "sha256": "3e8e2020f849d57b0908092f5a83b4fe1675d451190d1d05a5c09a71c37d1ab4", + "text_oracle": "Figure 13 states that VBD bends the wall but does not break it; the DART row additionally verifies every retained joint identity and residual.", "verdict": "pass", - "visible_observation": "At frame 14 the three balls are aligned with three visibly displaced wall bands, yet every course remains continuous and no brick is detached. By frame 120 the balls have left the view and the same wall is standing as one complete structure without fracture holes, rubble, or a collapsed section." + "visible_observation": "At frame 18 the full wall remains continuous with no missing bricks or tears while the three balls load its center courses. At frame 120 the wall is again nearly planar, continuous, and fully standing." } } } diff --git a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md index d7bcaa3dfa99f..697d4498197e5 100644 --- a/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md +++ b/docs/plans/104-vertex-block-descent-solver/paper-parity-matrix.md @@ -46,12 +46,13 @@ below, so neither solver family has a paper-parity claim. the row still records an explicit blocker. It is negative evidence against a complete claim, not permission to generalize from the artifact. -The public-AVBD breakable-wall packet and matched fixed-penalty VBD comparison -packet strengthen the evidence for Figure 13 and official-video row 12 without -changing these counts. Both rows remain partial because the shared scene is a -publication-shaped reconstruction, the exact source constants are unpublished, -and the Sequential Impulse, XPBD, source-edit, CUDA, and achieved-accuracy -reference-performance evidence is still absent. +The public AVBD breakable-wall packet and matched fixed-penalty VBD plus +Sequential Impulse comparison packets strengthen the evidence for Figure 13 +and official-video row 12 without changing these counts. Both canonical rows +remain partial because the shared scene is a publication-shaped +reconstruction, the exact source constants are unpublished, and XPBD, the +source-matched four-method edit, CUDA, and achieved-accuracy reference- +performance evidence are still absent. ## Authoritative Sources diff --git a/docs/readthedocs/papers.md b/docs/readthedocs/papers.md index 924f83dcac547..94606a1f87d33 100644 --- a/docs/readthedocs/papers.md +++ b/docs/readthedocs/papers.md @@ -780,9 +780,12 @@ Related public resources: correction, warm-started dual/stiffness state, rigid-body 6-DOF blocks, articulated joints, fracture limits, and unified soft/rigid interactions. DART now has one publication-shaped Figure 13 reconstruction with matched - public fixed-penalty VBD and public AVBD CPU rows, independent numeric and - assessed visual outcomes, and descriptive same-host timing; it is not an - exact source replay or an achieved-accuracy performance comparison. + public Sequential Impulse, fixed-penalty VBD, and AVBD CPU rows. Independent + numeric and assessed visual outcomes record exact broken-joint identities + and retained-row residuals as well as wall displacement; the same-host + timing remains descriptive. This is not an exact source replay, a + four-method result (XPBD is still missing), or an achieved-accuracy + performance comparison. Full completion requires CPU and GPU implementations, all paper/site/video experiments and demos integrated into DART tests/benchmarks/`py-demos`, and benchmark packets proving DART beats the reference demo repositories and the diff --git a/python/examples/demos/README.md b/python/examples/demos/README.md index 9530ca494218f..e7c7187897bd1 100644 --- a/python/examples/demos/README.md +++ b/python/examples/demos/README.md @@ -1401,6 +1401,7 @@ constraint/contact/motor signal instead of relying on screenshots alone. | `avbd_demo3d_bridge` | The `avbd-demo3d` Bridge source scene with paired point-jointed planks and load boxes | Source-demo bridge row with matched anchors, geometry, and metadata | | `avbd_demo3d_breakable` | The `avbd-demo3d` Breakable source scene as rigid boxes, contact shapes, and weak joints | Source-demo breakable fixed-joint row with public AVBD break thresholds | | `avbd_paper_breakable_wall` | A publication-shaped Figure 13 brick wall hit simultaneously by three heavy balls | Public AVBD contacts, 20 sweeps, and a quantitative retained-wall outcome oracle | +| `sequential_impulse_paper_breakable_wall` | The matched Figure 13 reconstruction using public Sequential Impulse | Initial-fracture and retained-row-collapse oracles over the shared wall scene | | `vbd_paper_breakable_wall` | The matched Figure 13 reconstruction using public fixed-penalty VBD | Bend-without-fracture and retained-wall oracles over the shared AVBD scene | | `avbd_rigid_fixed_joint_contact` | A fixed rigid payload sliding against static contact | Public fixed-joint rows plus ordinary contact | | `avbd_rigid_revolute_motor` | A bounded revolute motor driving a free hinge | Public velocity actuator mapped to AVBD motor rows | diff --git a/python/examples/demos/registry.py b/python/examples/demos/registry.py index 2079411c84929..debd18e102492 100644 --- a/python/examples/demos/registry.py +++ b/python/examples/demos/registry.py @@ -46,6 +46,9 @@ from .scenes.avbd_demo3d_stack_ratio import SCENE as AVBD_DEMO3D_STACK_RATIO from .scenes.avbd_demo3d_soft_body import SCENE as AVBD_DEMO3D_SOFT_BODY from .scenes.avbd_paper_breakable_wall import SCENE as AVBD_PAPER_BREAKABLE_WALL +from .scenes.sequential_impulse_paper_breakable_wall import ( + SCENE as SEQUENTIAL_IMPULSE_PAPER_BREAKABLE_WALL, +) from .scenes.vbd_paper_breakable_wall import SCENE as VBD_PAPER_BREAKABLE_WALL from .scenes.avbd_articulated_breakable_joint import ( SCENE as AVBD_ARTICULATED_BREAKABLE_JOINT, @@ -311,6 +314,7 @@ def make_demo_scenes() -> list[PythonDemoScene]: AVBD_DEMO3D_BRIDGE, AVBD_DEMO3D_BREAKABLE, AVBD_PAPER_BREAKABLE_WALL, + SEQUENTIAL_IMPULSE_PAPER_BREAKABLE_WALL, VBD_PAPER_BREAKABLE_WALL, AVBD_RIGID_FIXED_JOINT_CONTACT, AVBD_RIGID_REVOLUTE_MOTOR, diff --git a/python/examples/demos/scenes/avbd_demo2d_fracture.py b/python/examples/demos/scenes/avbd_demo2d_fracture.py index 390cee1a307db..882d0acb836de 100644 --- a/python/examples/demos/scenes/avbd_demo2d_fracture.py +++ b/python/examples/demos/scenes/avbd_demo2d_fracture.py @@ -176,7 +176,11 @@ def _add_source_box( def build() -> SceneSetup: - world = sx.World(time_step=_TIME_STEP, gravity=(0.0, _GRAVITY, 0.0)) + world = sx.World( + time_step=_TIME_STEP, + gravity=(0.0, _GRAVITY, 0.0), + rigid_body_solver=sx.RigidBodySolver.AVBD, + ) ground = _add_source_box( world, @@ -206,11 +210,15 @@ def build() -> SceneSetup: joint = world.add_joint( parent, child, - sx.JointSpec( + sx.JointSpec( name=f"avbd_demo2d_fracture_joint_{index:02d}", type=sx.JointType.FIXED, ) ) + # Preserve the source scene's numeric control. The source breaks only + # its torque-arm-scaled angular dual row, whereas DART's public + # break_force covers the combined physical fixed-joint row load. This + # is a source-shaped lifecycle row, not a source-trajectory oracle. joint.break_force = _BREAK_FORCE joints.append(joint) @@ -340,7 +348,7 @@ def build_panel(builder: object, context: object) -> None: builder.text(f"rigid bodies: {world.num_rigid_bodies}") builder.text(f"breakable joints: {len(joints)}") builder.text(f"broken joints: {broken}") - builder.text(f"break force: {_BREAK_FORCE:.1f} N") + builder.text(f"source numeric break threshold: {_BREAK_FORCE:.1f}") builder.text(f"world time: {world.time:.3f} s") builder.text(f"min chain y: {min(chain_y):.3f} m") builder.text(f"top block y: {max(falling_y):.3f} m") diff --git a/python/examples/demos/scenes/avbd_demo2d_hanging_rope.py b/python/examples/demos/scenes/avbd_demo2d_hanging_rope.py index 10e7a175b805c..dff990c5abc0f 100644 --- a/python/examples/demos/scenes/avbd_demo2d_hanging_rope.py +++ b/python/examples/demos/scenes/avbd_demo2d_hanging_rope.py @@ -161,7 +161,11 @@ def _anchor_world_position(body: sx.RigidBody, local_anchor: np.ndarray) -> np.n def build() -> SceneSetup: - world = sx.World(time_step=_TIME_STEP, gravity=(0.0, _GRAVITY, 0.0)) + world = sx.World( + time_step=_TIME_STEP, + gravity=(0.0, _GRAVITY, 0.0), + rigid_body_solver=sx.RigidBodySolver.AVBD, + ) links: list[sx.RigidBody] = [] link_sizes: list[np.ndarray] = [] diff --git a/python/examples/demos/scenes/avbd_demo2d_heavy_rope.py b/python/examples/demos/scenes/avbd_demo2d_heavy_rope.py index 0d1870f5c9060..a7bc3ddf0286e 100644 --- a/python/examples/demos/scenes/avbd_demo2d_heavy_rope.py +++ b/python/examples/demos/scenes/avbd_demo2d_heavy_rope.py @@ -160,7 +160,11 @@ def _anchor_world_position(body: sx.RigidBody, local_anchor: np.ndarray) -> np.n def build() -> SceneSetup: - world = sx.World(time_step=_TIME_STEP, gravity=(0.0, _GRAVITY, 0.0)) + world = sx.World( + time_step=_TIME_STEP, + gravity=(0.0, _GRAVITY, 0.0), + rigid_body_solver=sx.RigidBodySolver.AVBD, + ) links: list[sx.RigidBody] = [] link_sizes: list[np.ndarray] = [] diff --git a/python/examples/demos/scenes/avbd_demo2d_rod.py b/python/examples/demos/scenes/avbd_demo2d_rod.py index b33f73f7b1e80..2f21e7e258392 100644 --- a/python/examples/demos/scenes/avbd_demo2d_rod.py +++ b/python/examples/demos/scenes/avbd_demo2d_rod.py @@ -138,7 +138,11 @@ def _max_fixed_joint_pose_error(links: list[sx.RigidBody]) -> float: def build() -> SceneSetup: - world = sx.World(time_step=_TIME_STEP, gravity=(0.0, _GRAVITY, 0.0)) + world = sx.World( + time_step=_TIME_STEP, + gravity=(0.0, _GRAVITY, 0.0), + rigid_body_solver=sx.RigidBodySolver.AVBD, + ) links: list[sx.RigidBody] = [] for index in range(_ROD_LINKS): diff --git a/python/examples/demos/scenes/avbd_demo2d_soft_body.py b/python/examples/demos/scenes/avbd_demo2d_soft_body.py index 7d9769fe2f12c..ee61a7e1bb869 100644 --- a/python/examples/demos/scenes/avbd_demo2d_soft_body.py +++ b/python/examples/demos/scenes/avbd_demo2d_soft_body.py @@ -204,7 +204,11 @@ def _max_soft_body_pose_error( def build() -> SceneSetup: - world = sx.World(time_step=_TIME_STEP, gravity=(0.0, _GRAVITY, 0.0)) + world = sx.World( + time_step=_TIME_STEP, + gravity=(0.0, _GRAVITY, 0.0), + rigid_body_solver=sx.RigidBodySolver.AVBD, + ) ground = _add_source_box( world, diff --git a/python/examples/demos/scenes/avbd_demo3d_breakable.py b/python/examples/demos/scenes/avbd_demo3d_breakable.py index 84314be72f491..8fbe04822b180 100644 --- a/python/examples/demos/scenes/avbd_demo3d_breakable.py +++ b/python/examples/demos/scenes/avbd_demo3d_breakable.py @@ -151,7 +151,11 @@ def _add_source_box( def build() -> SceneSetup: - world = sx.World(time_step=_TIME_STEP, gravity=(0.0, 0.0, _GRAVITY)) + world = sx.World( + time_step=_TIME_STEP, + gravity=(0.0, 0.0, _GRAVITY), + rigid_body_solver=sx.RigidBodySolver.AVBD, + ) ground = _add_source_box( world, @@ -179,11 +183,15 @@ def build() -> SceneSetup: joint = world.add_joint( parent, child, - sx.JointSpec( + sx.JointSpec( name=f"avbd_demo3d_breakable_joint_{index:02d}", type=sx.JointType.FIXED, ) ) + # Preserve the source scene's numeric control. The source breaks only + # its torque-arm-scaled angular dual vector, whereas DART's public + # break_force covers the combined physical fixed-joint row load. This + # is a source-shaped lifecycle row, not a source-trajectory oracle. joint.break_force = _BREAK_FORCE joints.append(joint) @@ -312,7 +320,7 @@ def build_panel(builder: object, context: object) -> None: builder.text(f"rigid bodies: {world.num_rigid_bodies}") builder.text(f"breakable joints: {len(joints)}") builder.text(f"broken joints: {broken}") - builder.text(f"break force: {_BREAK_FORCE:.1f} N") + builder.text(f"source numeric break threshold: {_BREAK_FORCE:.1f}") builder.text(f"world time: {world.time:.3f} s") builder.text(f"min chain z: {min(chain_z):.3f} m") builder.text(f"top block z: {max(falling_z):.3f} m") diff --git a/python/examples/demos/scenes/avbd_demo3d_soft_body.py b/python/examples/demos/scenes/avbd_demo3d_soft_body.py index 70a38677f1053..8dbde535aa569 100644 --- a/python/examples/demos/scenes/avbd_demo3d_soft_body.py +++ b/python/examples/demos/scenes/avbd_demo3d_soft_body.py @@ -181,7 +181,11 @@ def _configure_finite_joint(joint: sx.Joint) -> sx.Joint: def build() -> SceneSetup: - world = sx.World(time_step=_TIME_STEP, gravity=(0.0, 0.0, _GRAVITY)) + world = sx.World( + time_step=_TIME_STEP, + gravity=(0.0, 0.0, _GRAVITY), + rigid_body_solver=sx.RigidBodySolver.AVBD, + ) ground = _add_source_box( world, diff --git a/python/examples/demos/scenes/avbd_paper_breakable_wall.py b/python/examples/demos/scenes/avbd_paper_breakable_wall.py index cadcf8ebfb15a..b867a75e84124 100644 --- a/python/examples/demos/scenes/avbd_paper_breakable_wall.py +++ b/python/examples/demos/scenes/avbd_paper_breakable_wall.py @@ -13,6 +13,8 @@ from collections import deque from collections.abc import Sequence +from dataclasses import dataclass +import hashlib import math import struct from typing import Any @@ -32,39 +34,42 @@ _WALL_ROWS = 12 _BRICK_COUNT = _WALL_COLUMNS * _WALL_ROWS _BRICK_SIZE = np.array([0.60, 0.30, 0.25]) -_BRICK_DENSITY = 4.0 +_BRICK_DENSITY = 200.0 _BRICK_FRICTION = 0.50 _BRICK_SPACING_X = 0.62 _BRICK_SPACING_Z = 0.27 _BRICK_BASE_CLEARANCE = 0.02 _BALL_RADIUS = 0.48 -_BALL_MASS = 100.0 +_BALL_MASS = 40.0 _BALL_FRICTION = 0.30 _BALL_START_Y = -5.0 _BALL_LAUNCH_SPEED = 24.0 _IMPACT_TARGETS_XZ = ( (-3.10, 1.55), - (0.00, 1.75), + (-0.31, 1.75), (3.10, 2.35), ) _GROUND_SIZE = np.array([16.0, 16.0, 0.50]) _GROUND_FRICTION = 0.60 -_BREAK_FORCE = 8500.0 +_BREAK_FORCE = 5000.0 _HORIZONTAL_JOINTS = _WALL_ROWS * (_WALL_COLUMNS - 1) _VERTICAL_JOINTS = (_WALL_ROWS - 1) * (2 * _WALL_COLUMNS - 1) _BASE_JOINTS = _WALL_COLUMNS _BREAKABLE_JOINTS = _HORIZONTAL_JOINTS + _VERTICAL_JOINTS + _BASE_JOINTS _OUTCOME_FRAME = 120 -_DISPLACEMENT_THRESHOLD = 0.50 +_IMPACT_DAMAGE_DISPLACEMENT_THRESHOLD = 0.10 +_RETAINED_DISPLACEMENT_THRESHOLD = 0.50 _IMPACT_BAND_RADIUS = 0.85 _OUTSIDE_RADIUS = 1.15 _CAPTURE_SIZE = (1280, 720) -_CAPTURE_CAMERA_DISTANCE = 11.0 -_CAPTURE_CAMERA_TARGET = (0.0, 0.0, 1.4) +_CAPTURE_CAMERA_AZIMUTH = -5.0 * math.pi / 8.0 +_CAPTURE_CAMERA_ELEVATION = 0.62 +_CAPTURE_CAMERA_DISTANCE = 22.0 +_CAPTURE_CAMERA_TARGET = (0.0, 0.45, 1.6) _FNV1A_64_OFFSET_BASIS = 14695981039346656037 _FNV1A_64_PRIME = 1099511628211 _FNV1A_64_MASK = (1 << 64) - 1 -_SCENE_SPEC_FINGERPRINT_TAG = "avbd-paper-breakable-wall/v1" +_SCENE_SPEC_FINGERPRINT_TAG = "avbd-paper-breakable-wall/v3" PAPER_REFERENCE: dict[str, Any] = { "paper": { @@ -86,8 +91,12 @@ "AVBD", ), "avbd_outcome": ( - "localized damage in three impact bands while large retained wall " - "regions remain attached and standing" + "localized displacement damage in three impact bands while large " + "retained wall regions remain attached and standing" + ), + "sequential_impulse_outcome": ( + "clean initial fracture followed by retained-constraint failure, " + "slow wall bending, and eventual collapse" ), }, "unpublished_scene_constants": ( @@ -113,20 +122,47 @@ }, } -OUTCOME_ORACLE: dict[str, Any] = { - "evaluation_frame": _OUTCOME_FRAME, - "displacement_threshold": _DISPLACEMENT_THRESHOLD, +OUTCOME_METRIC_THRESHOLDS: dict[str, Any] = { + "impact_damage_displacement_threshold": ( + _IMPACT_DAMAGE_DISPLACEMENT_THRESHOLD + ), + "retained_displacement_threshold": _RETAINED_DISPLACEMENT_THRESHOLD, "impact_band_radius": _IMPACT_BAND_RADIUS, "outside_radius": _OUTSIDE_RADIUS, +} + +OUTCOME_ORACLE: dict[str, Any] = { + **OUTCOME_METRIC_THRESHOLDS, + "evaluation_frame": _OUTCOME_FRAME, + "joint_evidence_frames": (60, _OUTCOME_FRAME), "minimum_displaced_bricks_per_impact_band": 4, - "minimum_outside_retained_fraction": 0.80, - "minimum_total_retained_fraction": 0.70, - "minimum_broken_joints": 300, - "maximum_broken_joints": 500, - "minimum_unbroken_joints": 200, + "minimum_outside_retained_fraction": 0.95, + "minimum_total_retained_fraction": 0.95, + "minimum_broken_joints": 150, + "maximum_broken_joints": 250, + "minimum_unbroken_joints": 450, + "expected_broken_joint_ids_sha256": ( + "31b187538ac1549563be368a4d7e304d1caef6ea11ba65b624d48f5f27468503" + ), + "maximum_unbroken_joint_linear_residual": 0.002, + "maximum_unbroken_joint_angular_residual_radians": 0.001, } +@dataclass(frozen=True) +class _FixedJointEvidence: + joint: sx.Joint + kind: str + parent: sx.RigidBody + child: sx.RigidBody + parent_index: int + child_index: int + local_anchor_parent: np.ndarray + local_anchor_child: np.ndarray + target_relative_rotation: np.ndarray + initial_anchor: np.ndarray + + def _fnv1a_64_update(state: int, payload: bytes) -> int: for value in payload: state ^= value @@ -299,6 +335,208 @@ def _positions(bodies: Sequence[sx.RigidBody]) -> np.ndarray: ) +def _fixed_joint_evidence( + joints: Sequence[sx.Joint], + topology_records: Sequence[tuple[int, int, int]], +) -> tuple[_FixedJointEvidence, ...]: + kind_names = {1: "horizontal", 2: "vertical", 3: "base"} + evidence = [] + for joint, (kind, parent_index, child_index) in zip( + joints, + topology_records, + strict=True, + ): + parent = joint.parent_rigid_body + child = joint.child_rigid_body + parent_transform = np.asarray(parent.transform, dtype=float).reshape(4, 4) + child_transform = np.asarray(child.transform, dtype=float).reshape(4, 4) + initial_anchor = child_transform[:3, 3].copy() + local_anchor_parent = ( + parent_transform[:3, :3].T + @ (initial_anchor - parent_transform[:3, 3]) + ) + local_anchor_child = ( + child_transform[:3, :3].T + @ (initial_anchor - child_transform[:3, 3]) + ) + evidence.append( + _FixedJointEvidence( + joint=joint, + kind=kind_names[kind], + parent=parent, + child=child, + parent_index=parent_index, + child_index=child_index, + local_anchor_parent=local_anchor_parent, + local_anchor_child=local_anchor_child, + target_relative_rotation=( + parent_transform[:3, :3].T + @ child_transform[:3, :3] + ), + initial_anchor=initial_anchor, + ) + ) + return tuple(evidence) + + +def _endpoint_grid_location(index: int) -> dict[str, Any]: + if index == 0: + return {"body": "ground", "column": None, "row": None} + brick_index = index - 1 + return { + "body": "brick", + "column": int(brick_index % _WALL_COLUMNS), + "row": int(brick_index // _WALL_COLUMNS), + } + + +def _rotation_angle(rotation: np.ndarray) -> float: + cosine = float(np.clip(0.5 * (np.trace(rotation) - 1.0), -1.0, 1.0)) + return float(math.acos(cosine)) + + +def _joint_constraint_evidence( + records: Sequence[_FixedJointEvidence], + *, + include_broken_records: bool, +) -> dict[str, Any]: + unbroken_linear_residuals = [] + unbroken_angular_residuals = [] + outside_unbroken_linear_residuals = [] + outside_unbroken_angular_residuals = [] + broken_records = [] + broken_joint_ids = [] + broken_impact_band_counts = [0] * len(_IMPACT_TARGETS_XZ) + broken_outside_impact_regions = 0 + + for record in records: + parent_rotation = np.asarray( + record.parent.rotation, dtype=float + ).reshape(3, 3) + child_rotation = np.asarray( + record.child.rotation, dtype=float + ).reshape(3, 3) + parent_anchor = ( + np.asarray(record.parent.translation, dtype=float).reshape(3) + + parent_rotation @ record.local_anchor_parent + ) + child_anchor = ( + np.asarray(record.child.translation, dtype=float).reshape(3) + + child_rotation @ record.local_anchor_child + ) + linear_residual = float(np.linalg.norm(child_anchor - parent_anchor)) + current_relative_rotation = parent_rotation.T @ child_rotation + angular_residual = _rotation_angle( + record.target_relative_rotation.T @ current_relative_rotation + ) + impact_distances = [ + float( + np.linalg.norm( + record.initial_anchor[[0, 2]] - np.asarray(target) + ) + ) + for target in _IMPACT_TARGETS_XZ + ] + nearest_impact_index = int(np.argmin(impact_distances)) + nearest_impact_distance = impact_distances[nearest_impact_index] + within_impact_band = nearest_impact_distance <= _IMPACT_BAND_RADIUS + within_impact_region = nearest_impact_distance <= _OUTSIDE_RADIUS + + if not record.joint.is_broken: + unbroken_linear_residuals.append(linear_residual) + unbroken_angular_residuals.append(angular_residual) + if not within_impact_region: + outside_unbroken_linear_residuals.append(linear_residual) + outside_unbroken_angular_residuals.append(angular_residual) + continue + + broken_joint_ids.append(record.joint.name) + if within_impact_region: + broken_impact_band_counts[nearest_impact_index] += 1 + else: + broken_outside_impact_regions += 1 + if include_broken_records: + broken_records.append( + { + "angular_residual_radians": angular_residual, + "child": _endpoint_grid_location(record.child_index), + "id": record.joint.name, + "initial_anchor": record.initial_anchor.tolist(), + "kind": record.kind, + "linear_residual": linear_residual, + "nearest_impact_distance": nearest_impact_distance, + "nearest_impact_index": nearest_impact_index, + "parent": _endpoint_grid_location(record.parent_index), + "within_impact_band": within_impact_band, + "within_impact_region": within_impact_region, + } + ) + + def maximum(values: Sequence[float]) -> float: + return float(max(values, default=0.0)) + + def rms(values: Sequence[float]) -> float: + return ( + float(np.sqrt(np.mean(np.square(values)))) + if values + else 0.0 + ) + + broken_joint_ids_digest = hashlib.sha256() + for joint_id in sorted(broken_joint_ids): + encoded_id = joint_id.encode("utf-8") + broken_joint_ids_digest.update(struct.pack(" dict[str, Any]: @@ -317,6 +556,18 @@ def compute_outcome_metrics( brick_positions[:, 1] - initial_brick_positions[:, 1] ) initial_xz = initial_brick_positions[:, (0, 2)] + impact_damage_displacement_threshold = float( + outcome_oracle.get( + "impact_damage_displacement_threshold", + _IMPACT_DAMAGE_DISPLACEMENT_THRESHOLD, + ) + ) + retained_displacement_threshold = float( + outcome_oracle.get( + "retained_displacement_threshold", + _RETAINED_DISPLACEMENT_THRESHOLD, + ) + ) impact_band_displaced_counts = [] for target in _IMPACT_TARGETS_XZ: @@ -325,7 +576,7 @@ def compute_outcome_metrics( int( np.count_nonzero( (distances <= _IMPACT_BAND_RADIUS) - & (displacements > _DISPLACEMENT_THRESHOLD) + & (displacements > impact_damage_displacement_threshold) ) ) ) @@ -336,22 +587,60 @@ def compute_outcome_metrics( np.linalg.norm(initial_xz - np.asarray(target), axis=1) > _OUTSIDE_RADIUS ) - retained_mask = displacements < _DISPLACEMENT_THRESHOLD + retained_mask = displacements < retained_displacement_threshold outside_count = int(np.count_nonzero(outside_mask)) outside_retained_fraction = float( np.count_nonzero(retained_mask & outside_mask) / outside_count ) total_retained_fraction = float(np.count_nonzero(retained_mask) / _BRICK_COUNT) + frame = int(round(float(world.time) / _TIME_STEP)) + joint_constraint_evidence = _joint_constraint_evidence( + joint_evidence, + include_broken_records=frame + in { + int(value) + for value in outcome_oracle.get("joint_evidence_frames", ()) + }, + ) broken_joints = sum(1 for joint in joints if joint.is_broken) unbroken_joints = len(joints) - broken_joints - frame = int(round(float(world.time) / _TIME_STEP)) + include_broken_records = frame in { + int(value) + for value in outcome_oracle.get("joint_evidence_frames", ()) + } + if ( + joint_constraint_evidence["unbroken_joint_residual_count"] + != unbroken_joints + ): + raise RuntimeError( + "breakable-wall joint evidence count drifted: " + f"expected {unbroken_joints} unbroken records, got " + f"{joint_constraint_evidence['unbroken_joint_residual_count']}" + ) + if joint_constraint_evidence["broken_joint_identity_count"] != broken_joints: + raise RuntimeError( + "breakable-wall broken-joint identity count drifted: " + f"expected {broken_joints} identities, got " + f"{joint_constraint_evidence['broken_joint_identity_count']}" + ) + if ( + include_broken_records + and len(joint_constraint_evidence["broken_joint_records"]) + != broken_joints + ): + raise RuntimeError( + "breakable-wall detailed broken-joint evidence count drifted: " + f"expected {broken_joints} records, got " + f"{len(joint_constraint_evidence['broken_joint_records'])}" + ) bent_brick_displacement_threshold = float( outcome_oracle.get("bent_brick_displacement_threshold", 0.05) ) finite_state = bool( np.all(np.isfinite(brick_positions)) and np.all(np.isfinite(ball_positions)) + and joint_constraint_evidence["joint_residuals_finite"] and all( np.all( np.isfinite( @@ -387,10 +676,28 @@ def compute_outcome_metrics( "fracture_activated": ( broken_joints >= outcome_oracle["minimum_broken_joints"] ), - "fracture_localized": ( + "fracture_count_bounded": ( broken_joints <= outcome_oracle["maximum_broken_joints"] and unbroken_joints >= outcome_oracle["minimum_unbroken_joints"] ), + "fracture_identity_matches": ( + joint_constraint_evidence["broken_joint_ids_sha256"] + == outcome_oracle["expected_broken_joint_ids_sha256"] + ), + "retained_joint_rows_satisfied": ( + joint_constraint_evidence[ + "maximum_unbroken_joint_linear_residual" + ] + <= outcome_oracle[ + "maximum_unbroken_joint_linear_residual" + ] + and joint_constraint_evidence[ + "maximum_unbroken_joint_angular_residual_radians" + ] + <= outcome_oracle[ + "maximum_unbroken_joint_angular_residual_radians" + ] + ), } evaluated = frame >= evaluation_frame elif solver_family == "vbd": @@ -405,6 +712,20 @@ def compute_outcome_metrics( "topology_retained": ( unbroken_joints >= outcome_oracle["minimum_unbroken_joints"] ), + "retained_joint_rows_satisfied": ( + joint_constraint_evidence[ + "maximum_unbroken_joint_linear_residual" + ] + <= outcome_oracle[ + "maximum_unbroken_joint_linear_residual" + ] + and joint_constraint_evidence[ + "maximum_unbroken_joint_angular_residual_radians" + ] + <= outcome_oracle[ + "maximum_unbroken_joint_angular_residual_radians" + ] + ), "wall_retained": ( total_retained_fraction >= outcome_oracle["minimum_total_retained_fraction"] @@ -421,6 +742,20 @@ def compute_outcome_metrics( "topology_retained": ( unbroken_joints >= outcome_oracle["minimum_unbroken_joints"] ), + "retained_joint_rows_satisfied": ( + joint_constraint_evidence[ + "maximum_unbroken_joint_linear_residual" + ] + <= outcome_oracle[ + "maximum_unbroken_joint_linear_residual" + ] + and joint_constraint_evidence[ + "maximum_unbroken_joint_angular_residual_radians" + ] + <= outcome_oracle[ + "maximum_unbroken_joint_angular_residual_radians" + ] + ), "wall_bends": ( float(np.max(wall_normal_displacements)) >= outcome_oracle[ @@ -444,6 +779,147 @@ def compute_outcome_metrics( ), } evaluated = frame == evaluation_frame + elif solver_family == "sequential_impulse": + collapse_frame = int(outcome_oracle["collapse_evaluation_frame"]) + if frame >= collapse_frame: + checkpoint = "collapse" + threshold_checks = { + "finite_state": finite_state, + "initial_fracture_remains_visible": ( + broken_joints + >= outcome_oracle["minimum_final_broken_joints"] + ), + "fracture_identity_unchanged": ( + broken_joints + == outcome_oracle["expected_broken_joints"] + and joint_constraint_evidence[ + "broken_joint_ids_sha256" + ] + == outcome_oracle["expected_broken_joint_ids_sha256"] + and joint_constraint_evidence[ + "broken_joints_outside_impact_regions" + ] + == 0 + and joint_constraint_evidence[ + "outside_impact_unbroken_joint_residual_count" + ] + == outcome_oracle[ + "expected_outside_impact_unbroken_joint_count" + ] + ), + "damage_in_three_impact_bands": all( + count + >= outcome_oracle[ + "minimum_displaced_bricks_per_impact_band" + ] + for count in impact_band_displaced_counts + ), + "retained_rows_fail_outside_impacts": ( + joint_constraint_evidence[ + "maximum_outside_impact_unbroken_joint_linear_residual" + ] + >= outcome_oracle[ + "minimum_collapse_outside_joint_maximum_linear_residual" + ] + and joint_constraint_evidence[ + "rms_outside_impact_unbroken_joint_linear_residual" + ] + >= outcome_oracle[ + "minimum_collapse_outside_joint_rms_linear_residual" + ] + and joint_constraint_evidence[ + "maximum_outside_impact_unbroken_joint_angular_residual_radians" + ] + >= outcome_oracle[ + "minimum_collapse_outside_joint_maximum_angular_residual_radians" + ] + and joint_constraint_evidence[ + "rms_outside_impact_unbroken_joint_angular_residual_radians" + ] + >= outcome_oracle[ + "minimum_collapse_outside_joint_rms_angular_residual_radians" + ] + ), + "outside_wall_collapses": ( + outside_retained_fraction + <= outcome_oracle[ + "maximum_collapse_outside_retained_fraction" + ] + ), + "wall_collapses": ( + total_retained_fraction + <= outcome_oracle[ + "maximum_collapse_total_retained_fraction" + ] + and float(np.max(wall_normal_displacements)) + >= outcome_oracle[ + "minimum_collapse_wall_normal_displacement" + ] + ), + } + evaluated = True + else: + checkpoint = "fracture" + threshold_checks = { + "finite_state": finite_state, + "fracture_activated": ( + broken_joints + >= outcome_oracle["minimum_initial_broken_joints"] + ), + "initial_fracture_confined_to_impact_regions": ( + broken_joints + <= outcome_oracle["maximum_initial_broken_joints"] + and unbroken_joints + >= outcome_oracle["minimum_initial_unbroken_joints"] + and joint_constraint_evidence[ + "broken_joints_outside_impact_regions" + ] + == 0 + ), + "initial_fracture_covers_three_impacts": all( + count + >= outcome_oracle[ + "minimum_initial_broken_joints_per_impact_region" + ] + for count in joint_constraint_evidence[ + "broken_joint_impact_region_counts" + ] + ), + "initial_fracture_identity_matches": ( + broken_joints == outcome_oracle["expected_broken_joints"] + and joint_constraint_evidence[ + "broken_joint_ids_sha256" + ] + == outcome_oracle["expected_broken_joint_ids_sha256"] + and joint_constraint_evidence[ + "outside_impact_unbroken_joint_residual_count" + ] + == outcome_oracle[ + "expected_outside_impact_unbroken_joint_count" + ] + ), + "initial_retained_joint_rows_bounded": ( + joint_constraint_evidence[ + "maximum_outside_impact_unbroken_joint_linear_residual" + ] + <= outcome_oracle[ + "maximum_initial_outside_joint_linear_residual" + ] + and joint_constraint_evidence[ + "maximum_outside_impact_unbroken_joint_angular_residual_radians" + ] + <= outcome_oracle[ + "maximum_initial_outside_joint_angular_residual_radians" + ] + ), + "wall_initially_retained": ( + total_retained_fraction + >= outcome_oracle[ + "minimum_initial_total_retained_fraction" + ] + ), + } + evaluated = frame == evaluation_frame else: raise ValueError(f"unsupported breakable-wall solver family: {solver_family}") thresholds_pass = evaluated and all(threshold_checks.values()) @@ -464,12 +940,17 @@ def compute_outcome_metrics( ), "thresholds_pass": thresholds_pass, "threshold_checks": threshold_checks, + "impact_damage_displacement_threshold": ( + impact_damage_displacement_threshold + ), + "retained_displacement_threshold": retained_displacement_threshold, "impact_band_displaced_counts": impact_band_displaced_counts, "outside_brick_count": outside_count, "outside_retained_fraction": outside_retained_fraction, "total_retained_fraction": total_retained_fraction, "broken_joints": broken_joints, "unbroken_joints": unbroken_joints, + **joint_constraint_evidence, "max_brick_displacement": float(np.max(displacements)), "maximum_wall_normal_displacement": float( np.max(wall_normal_displacements) @@ -500,6 +981,8 @@ def build_solver_variant( solver_display: str, paper_reference: dict[str, Any], outcome_oracle: dict[str, Any], + capture_assessment_frames: tuple[int, ...], + resolved_solver_key: str | None = None, ) -> SceneSetup: """Build one matched Figure 13 solver row from the shared reconstruction.""" scene_id = f"{solver_key}_paper_breakable_wall" @@ -622,31 +1105,24 @@ def build_solver_variant( raise RuntimeError("breakable-wall topology fingerprint record drifted") initial_brick_positions = _positions(bricks) + joint_evidence = _fixed_joint_evidence(joints, topology_records) world.enter_simulation_mode() resolved_configuration = _serialize_and_validate_resolved_configuration( - world, solver_key + world, resolved_solver_key or solver_key ) scene_spec_fingerprint = _scene_spec_fingerprint(topology_records) view_assessment_available = _view_assessment_available() capture_camera = ( dart.gui.orbit_camera( - azimuth=-0.5 * math.pi, - elevation=0.0, + azimuth=_CAPTURE_CAMERA_AZIMUTH, + elevation=_CAPTURE_CAMERA_ELEVATION, distance=_CAPTURE_CAMERA_DISTANCE, target=_CAPTURE_CAMERA_TARGET, ) if view_assessment_available else None ) - capture_focus = tuple( - f"{brick_rows[row][column].name}_visual" - for row, column in ( - (0, 0), - (0, _WALL_COLUMNS - 1), - (_WALL_ROWS - 1, 0), - (_WALL_ROWS - 1, _WALL_COLUMNS - 1), - ) - ) + capture_focus = tuple(f"{brick.name}_visual" for brick in bricks) bridge = WorldRenderBridge(world, name=f"{scene_id}_render") bridge.add_rigid_body_visual( @@ -696,6 +1172,7 @@ def sample_metrics() -> dict[str, Any]: joints=joints, balls=balls, initial_brick_positions=initial_brick_positions, + joint_evidence=joint_evidence, solver_family=solver_key, outcome_oracle=outcome_oracle, ) @@ -721,16 +1198,7 @@ def capture_metrics() -> dict[str, Any]: if note["domain"] == "rigid-body" ) view_report = None - assessed_frames = { - 60, - int(outcome_oracle["evaluation_frame"]), - int( - outcome_oracle.get( - "retention_evaluation_frame", - outcome_oracle["evaluation_frame"], - ) - ), - } + assessed_frames = {int(frame) for frame in capture_assessment_frames} if ( view_assessment_available and metrics["frame"] in assessed_frames @@ -850,6 +1318,7 @@ def build() -> SceneSetup: solver_display="AVBD", paper_reference=PAPER_REFERENCE, outcome_oracle=OUTCOME_ORACLE, + capture_assessment_frames=(60, _OUTCOME_FRAME), ) diff --git a/python/examples/demos/scenes/sequential_impulse_paper_breakable_wall.py b/python/examples/demos/scenes/sequential_impulse_paper_breakable_wall.py new file mode 100644 index 0000000000000..8c3efad90971c --- /dev/null +++ b/python/examples/demos/scenes/sequential_impulse_paper_breakable_wall.py @@ -0,0 +1,74 @@ +"""Matched sequential-impulse row for the Figure 13 breakable wall.""" + +from __future__ import annotations + +from typing import Any + +import dartpy as sx + +from ..runner import PythonDemoScene, SceneSetup +from .avbd_paper_breakable_wall import ( + OUTCOME_METRIC_THRESHOLDS, + PAPER_REFERENCE, + build_solver_variant, +) + +SEQUENTIAL_IMPULSE_OUTCOME_ORACLE: dict[str, Any] = { + **OUTCOME_METRIC_THRESHOLDS, + "evaluation_frame": 14, + "collapse_evaluation_frame": 120, + "joint_evidence_frames": (14, 120), + "minimum_initial_broken_joints": 3, + "maximum_initial_broken_joints": 20, + "minimum_initial_unbroken_joints": 690, + "minimum_initial_total_retained_fraction": 0.95, + "minimum_initial_broken_joints_per_impact_region": 1, + "maximum_initial_outside_joint_linear_residual": 0.04, + "maximum_initial_outside_joint_angular_residual_radians": 0.13, + "expected_broken_joints": 5, + "expected_broken_joint_ids_sha256": ( + "c85184879b1b9036ff582731031fc49c56b4149e93ded45780b06352bd94d61d" + ), + "expected_outside_impact_unbroken_joint_count": 484, + "minimum_final_broken_joints": 3, + "maximum_final_broken_joints": 30, + "minimum_final_unbroken_joints": 680, + "minimum_displaced_bricks_per_impact_band": 10, + "minimum_collapse_outside_joint_maximum_linear_residual": 0.05, + "minimum_collapse_outside_joint_rms_linear_residual": 0.015, + "minimum_collapse_outside_joint_maximum_angular_residual_radians": 0.60, + "minimum_collapse_outside_joint_rms_angular_residual_radians": 0.18, + "maximum_collapse_outside_retained_fraction": 0.35, + "maximum_collapse_total_retained_fraction": 0.25, + "minimum_collapse_wall_normal_displacement": 2.0, +} + + +def build() -> SceneSetup: + """Build the matched public sequential-impulse comparison row.""" + return build_solver_variant( + rigid_body_solver=sx.RigidBodySolver.SEQUENTIAL_IMPULSE, + solver_key="sequential_impulse", + solver_display="Sequential Impulse", + paper_reference=PAPER_REFERENCE, + outcome_oracle=SEQUENTIAL_IMPULSE_OUTCOME_ORACLE, + capture_assessment_frames=( + int(SEQUENTIAL_IMPULSE_OUTCOME_ORACLE["evaluation_frame"]), + int( + SEQUENTIAL_IMPULSE_OUTCOME_ORACLE[ + "collapse_evaluation_frame" + ] + ), + ), + resolved_solver_key="sequential-impulse", + ) + + +SCENE = PythonDemoScene( + id="sequential_impulse_paper_breakable_wall", + title="Sequential Impulse Paper Breakable Wall (sx)", + category="Sequential Impulse Rigid Constraints (sx)", + summary="The matched Figure 13 sequential-impulse row: 20 PGS sweeps, " + "impulse-derived fracture, and non-velocity post-stabilization.", + build=build, +) diff --git a/python/examples/demos/scenes/vbd_paper_breakable_wall.py b/python/examples/demos/scenes/vbd_paper_breakable_wall.py index ed6d4c5851abd..c01ca7390276a 100644 --- a/python/examples/demos/scenes/vbd_paper_breakable_wall.py +++ b/python/examples/demos/scenes/vbd_paper_breakable_wall.py @@ -8,6 +8,7 @@ from ..runner import PythonDemoScene, SceneSetup from .avbd_paper_breakable_wall import ( + OUTCOME_METRIC_THRESHOLDS, PAPER_REFERENCE as _SHARED_PAPER_REFERENCE, build_solver_variant, ) @@ -22,15 +23,19 @@ } OUTCOME_ORACLE: dict[str, Any] = { - "evaluation_frame": 14, + **OUTCOME_METRIC_THRESHOLDS, + "evaluation_frame": 18, "retention_evaluation_frame": 120, + "joint_evidence_frames": (18, 120), "maximum_broken_joints": 0, "minimum_unbroken_joints": 712, - "minimum_maximum_wall_normal_displacement": 0.20, - "minimum_rms_wall_normal_displacement": 0.10, - "bent_brick_displacement_threshold": 0.10, + "minimum_maximum_wall_normal_displacement": 0.10, + "minimum_rms_wall_normal_displacement": 0.05, + "bent_brick_displacement_threshold": 0.05, "minimum_bent_bricks": 100, "minimum_total_retained_fraction": 0.99, + "maximum_unbroken_joint_linear_residual": 0.025, + "maximum_unbroken_joint_angular_residual_radians": 0.02, } @@ -41,6 +46,10 @@ def build() -> SceneSetup: solver_display="VBD", paper_reference=PAPER_REFERENCE, outcome_oracle=OUTCOME_ORACLE, + capture_assessment_frames=( + int(OUTCOME_ORACLE["evaluation_frame"]), + int(OUTCOME_ORACLE["retention_evaluation_frame"]), + ), ) diff --git a/python/tests/integration/test_demos_cycle.py b/python/tests/integration/test_demos_cycle.py index 33eb1bad79a9a..b85758d8ab564 100644 --- a/python/tests/integration/test_demos_cycle.py +++ b/python/tests/integration/test_demos_cycle.py @@ -950,6 +950,9 @@ def test_world_scenes_use_solver_focused_categories() -> None: "VBD Rigid Constraints (sx)": { "vbd_paper_breakable_wall", }, + "Sequential Impulse Rigid Constraints (sx)": { + "sequential_impulse_paper_breakable_wall", + }, "Planned World Ports": { "planned_inverse_kinematics", "planned_simbicon_walking", @@ -8895,6 +8898,7 @@ def test_avbd_demo2d_rod_scene_matches_source_row() -> None: } assert sx_world.num_rigid_bodies == 20 assert sx_world.num_joints == 19 + assert sx_world.rigid_body_solver == sx.RigidBodySolver.AVBD assert _fixed_joint_count(sx_world) == 19 assert sx_world.time_step == pytest.approx(1.0 / 60.0) assert sx_world.gravity.tolist() == pytest.approx([0.0, -10.0, 0.0]) @@ -9169,6 +9173,7 @@ def test_avbd_demo2d_soft_body_scene_matches_source_row() -> None: } assert sx_world.num_rigid_bodies == 151 assert sx_world.num_joints == 260 + assert sx_world.rigid_body_solver == sx.RigidBodySolver.AVBD assert _fixed_joint_count(sx_world) == 260 assert sx_world.time_step == pytest.approx(1.0 / 60.0) assert sx_world.gravity.tolist() == pytest.approx([0.0, -10.0, 0.0]) @@ -9278,6 +9283,7 @@ def test_avbd_demo2d_hanging_rope_scene_matches_source_row() -> None: } assert sx_world.num_rigid_bodies == 50 assert sx_world.num_joints == 49 + assert sx_world.rigid_body_solver == sx.RigidBodySolver.AVBD assert _fixed_joint_count(sx_world) == 0 assert sx_world.time_step == pytest.approx(1.0 / 60.0) assert sx_world.gravity.tolist() == pytest.approx([0.0, -10.0, 0.0]) @@ -9900,6 +9906,7 @@ def test_avbd_demo2d_heavy_rope_scene_matches_source_row() -> None: assert np.asarray(link_sizes[-1], dtype=float).tolist() == pytest.approx( [30.0, 30.0, 0.2] ) + assert sx_world.rigid_body_solver == sx.RigidBodySolver.AVBD initial_positions = np.array( [np.asarray(link.translation, dtype=float).reshape(3) for link in links] @@ -10304,12 +10311,16 @@ def test_avbd_paper_breakable_wall_matches_figure13_contract() -> None: "AVBD", ), "avbd_outcome": ( - "localized damage in three impact bands while large retained wall " - "regions remain attached and standing" + "localized displacement damage in three impact bands while large " + "retained wall regions remain attached and standing" + ), + "sequential_impulse_outcome": ( + "clean initial fracture followed by retained-constraint failure, " + "slow wall bending, and eventual collapse" ), } assert "break force" in reference["unpublished_scene_constants"] - assert reference["dart_reconstruction"]["break_force"] == pytest.approx(8500.0) + assert reference["dart_reconstruction"]["break_force"] == pytest.approx(5000.0) assert reference["dart_reconstruction"]["selection"].startswith( "visual adjudication" ) @@ -10327,10 +10338,10 @@ def test_avbd_paper_breakable_wall_matches_figure13_contract() -> None: assert ground.is_static assert not any(brick.is_static for brick in bricks) assert not any(ball.is_static for ball in balls) - assert all(joint.break_force == pytest.approx(8500.0) for joint in joints) + assert all(joint.break_force == pytest.approx(5000.0) for joint in joints) assert sum(len(body.collision_shapes) for body in (ground, *bricks, *balls)) == 256 - assert [brick.mass for brick in bricks] == pytest.approx([0.18] * 252) - assert [ball.mass for ball in balls] == pytest.approx([100.0] * 3) + assert [brick.mass for brick in bricks] == pytest.approx([9.0] * 252) + assert [ball.mass for ball in balls] == pytest.approx([40.0] * 3) brick_positions = np.asarray( [np.asarray(brick.translation, dtype=float).reshape(3) for brick in bricks] @@ -10351,11 +10362,11 @@ def test_avbd_paper_breakable_wall_matches_figure13_contract() -> None: np.testing.assert_allclose( ball_positions, np.array( - [ - [-3.10, -5.0, 1.55], - [0.00, -5.0, 1.75], - [3.10, -5.0, 2.35], - ] + [ + [-3.10, -5.0, 1.55], + [-0.31, -5.0, 1.75], + [3.10, -5.0, 2.35], + ] ), ) ball_velocities = np.asarray( @@ -10398,6 +10409,32 @@ def test_avbd_paper_breakable_wall_matches_figure13_contract() -> None: <= OUTCOME_ORACLE["maximum_broken_joints"] ) assert outcome["unbroken_joints"] >= OUTCOME_ORACLE["minimum_unbroken_joints"] + assert ( + outcome["broken_joint_identity_count"] == outcome["broken_joints"] + ) + assert ( + outcome["broken_joint_ids_sha256"] + == OUTCOME_ORACLE["expected_broken_joint_ids_sha256"] + ) + assert len(outcome["broken_joint_records"]) == outcome["broken_joints"] + assert len( + {record["id"] for record in outcome["broken_joint_records"]} + ) == outcome["broken_joints"] + assert ( + sum(outcome["broken_joint_impact_region_counts"]) + + outcome["broken_joints_outside_impact_regions"] + == outcome["broken_joints"] + ) + assert ( + outcome["maximum_unbroken_joint_linear_residual"] + <= OUTCOME_ORACLE["maximum_unbroken_joint_linear_residual"] + ) + assert ( + outcome["maximum_unbroken_joint_angular_residual_radians"] + <= OUTCOME_ORACLE[ + "maximum_unbroken_joint_angular_residual_radians" + ] + ) assert outcome["last_step_iterations"] == 20 capture_metrics = setup.info[CAPTURE_METRICS_INFO_KEY]() @@ -10423,10 +10460,9 @@ def test_avbd_paper_breakable_wall_matches_figure13_contract() -> None: assert capture_metrics["view_report"]["pass"] is True assert capture_metrics["view_report"]["issues"] == [] assert capture_metrics["view_report"]["focus"] == [ - "avbd_paper_wall_brick_00_00_visual", - "avbd_paper_wall_brick_00_20_visual", - "avbd_paper_wall_brick_11_00_visual", - "avbd_paper_wall_brick_11_20_visual", + f"avbd_paper_wall_brick_{row:02d}_{column:02d}_visual" + for row in range(12) + for column in range(21) ] else: assert capture_metrics["view_report"] is None @@ -10452,12 +10488,25 @@ def test_avbd_paper_breakable_wall_outcome_is_deterministic() -> None: "broken_joints", "unbroken_joints", "impact_band_displaced_counts", + "broken_joint_impact_region_counts", + "broken_joints_outside_impact_regions", "outside_retained_fraction", "total_retained_fraction", + "maximum_unbroken_joint_linear_residual", + "maximum_unbroken_joint_angular_residual_radians", + "rms_unbroken_joint_linear_residual", + "rms_unbroken_joint_angular_residual_radians", "last_step_iterations", "contact_count", ): assert outcomes[0][key] == pytest.approx(outcomes[1][key]) + assert ( + outcomes[0]["broken_joint_ids_sha256"] + == outcomes[1]["broken_joint_ids_sha256"] + ) + assert outcomes[0]["broken_joint_records"] == outcomes[1][ + "broken_joint_records" + ] for collection in ("bricks", "balls"): positions = [ @@ -10475,6 +10524,279 @@ def test_avbd_paper_breakable_wall_outcome_is_deterministic() -> None: ] == [joint.is_broken for joint in setups[1].info["joints"]] +def test_sequential_impulse_paper_breakable_wall_matches_figure13_contract() -> None: + sx = _require_simulation_experimental_symbols("World") + + from examples.demos.scenes.avbd_paper_breakable_wall import ( + build as build_avbd, + ) + from examples.demos.scenes.sequential_impulse_paper_breakable_wall import ( + SEQUENTIAL_IMPULSE_OUTCOME_ORACLE, + build, + ) + + setup = build() + sx_world = setup.info["sx_world"] + reference = setup.info["paper_reference"] + outcome_oracle = SEQUENTIAL_IMPULSE_OUTCOME_ORACLE + + assert sx_world.rigid_body_solver == sx.RigidBodySolver.SEQUENTIAL_IMPULSE + assert sx_world.rigid_constraint_options.iterations == 20 + assert sx_world.time_step == pytest.approx(1.0 / 60.0) + assert sx_world.num_rigid_bodies == 256 + assert sx_world.num_joints == 712 + assert reference["published_facts"]["sequential_impulse_outcome"] == ( + "clean initial fracture followed by retained-constraint failure, " + "slow wall bending, and eventual collapse" + ) + assert ( + setup.info["scene_spec_fingerprint"] + == build_avbd().info["scene_spec_fingerprint"] + ) + + initial = setup.info["outcome_metrics"]() + assert initial["status"] == "pre-evaluation" + assert initial["broken_joints"] == 0 + + sx_world.step(n=outcome_oracle["evaluation_frame"]) + fracture = setup.info["outcome_metrics"]() + assert fracture["frame"] == outcome_oracle["evaluation_frame"] + assert fracture["checkpoint"] == "fracture" + assert fracture["status"] == "pass" + assert fracture["thresholds_pass"] is True + assert all(fracture["threshold_checks"].values()) + assert ( + outcome_oracle["minimum_initial_broken_joints"] + <= fracture["broken_joints"] + <= outcome_oracle["maximum_initial_broken_joints"] + ) + assert ( + fracture["unbroken_joints"] + >= outcome_oracle["minimum_initial_unbroken_joints"] + ) + assert ( + fracture["total_retained_fraction"] + >= outcome_oracle["minimum_initial_total_retained_fraction"] + ) + assert fracture["broken_joints"] == outcome_oracle["expected_broken_joints"] + assert ( + fracture["broken_joint_ids_sha256"] + == outcome_oracle["expected_broken_joint_ids_sha256"] + ) + assert ( + fracture["broken_joints_outside_impact_regions"] == 0 + ) + assert all( + count + >= outcome_oracle[ + "minimum_initial_broken_joints_per_impact_region" + ] + for count in fracture["broken_joint_impact_region_counts"] + ) + assert len(fracture["broken_joint_records"]) == fracture["broken_joints"] + assert { + record["id"] for record in fracture["broken_joint_records"] + } == { + "sequential_impulse_paper_wall_horizontal_08_15", + "sequential_impulse_paper_wall_vertical_04_05_05_05", + "sequential_impulse_paper_wall_vertical_05_04_06_05", + "sequential_impulse_paper_wall_vertical_05_09_06_10", + "sequential_impulse_paper_wall_vertical_08_15_09_15", + } + assert ( + fracture[ + "maximum_outside_impact_unbroken_joint_linear_residual" + ] + <= outcome_oracle[ + "maximum_initial_outside_joint_linear_residual" + ] + ) + assert ( + fracture[ + "maximum_outside_impact_unbroken_joint_angular_residual_radians" + ] + <= outcome_oracle[ + "maximum_initial_outside_joint_angular_residual_radians" + ] + ) + assert fracture["last_step_iterations"] == 20 + + fracture_capture = setup.info[CAPTURE_METRICS_INFO_KEY]() + assert fracture_capture["solver"] == "public_sequential-impulse" + assert fracture_capture["rigid_body_solver"] == "SEQUENTIAL_IMPULSE" + assert fracture_capture["outcome"]["checkpoint"] == "fracture" + assert fracture_capture["outcome"]["thresholds_pass"] is True + + sx_world.step( + n=( + outcome_oracle["collapse_evaluation_frame"] + - outcome_oracle["evaluation_frame"] + ) + ) + collapse = setup.info["outcome_metrics"]() + assert collapse["frame"] == outcome_oracle["collapse_evaluation_frame"] + assert collapse["checkpoint"] == "collapse" + assert collapse["status"] == "pass" + assert collapse["thresholds_pass"] is True + assert all(collapse["threshold_checks"].values()) + assert ( + outcome_oracle["minimum_final_broken_joints"] + <= collapse["broken_joints"] + <= outcome_oracle["maximum_final_broken_joints"] + ) + assert ( + collapse["unbroken_joints"] + >= outcome_oracle["minimum_final_unbroken_joints"] + ) + assert ( + collapse["outside_retained_fraction"] + <= outcome_oracle["maximum_collapse_outside_retained_fraction"] + ) + assert ( + collapse["total_retained_fraction"] + <= outcome_oracle["maximum_collapse_total_retained_fraction"] + ) + assert ( + collapse["maximum_wall_normal_displacement"] + >= outcome_oracle["minimum_collapse_wall_normal_displacement"] + ) + assert ( + collapse["broken_joint_ids_sha256"] + == fracture["broken_joint_ids_sha256"] + ) + assert { + record["id"] for record in collapse["broken_joint_records"] + } == { + record["id"] for record in fracture["broken_joint_records"] + } + assert ( + collapse[ + "maximum_outside_impact_unbroken_joint_linear_residual" + ] + >= outcome_oracle[ + "minimum_collapse_outside_joint_maximum_linear_residual" + ] + ) + assert ( + collapse["rms_outside_impact_unbroken_joint_linear_residual"] + >= outcome_oracle[ + "minimum_collapse_outside_joint_rms_linear_residual" + ] + ) + assert ( + collapse[ + "maximum_outside_impact_unbroken_joint_angular_residual_radians" + ] + >= outcome_oracle[ + "minimum_collapse_outside_joint_maximum_angular_residual_radians" + ] + ) + assert ( + collapse[ + "rms_outside_impact_unbroken_joint_angular_residual_radians" + ] + >= outcome_oracle[ + "minimum_collapse_outside_joint_rms_angular_residual_radians" + ] + ) + assert collapse["last_step_iterations"] == 20 + + collapse_capture = setup.info[CAPTURE_METRICS_INFO_KEY]() + assert collapse_capture["outcome"]["checkpoint"] == "collapse" + assert collapse_capture["outcome"]["thresholds_pass"] is True + assert ( + collapse_capture["resolved_configuration"] + == list(setup.info["resolved_configuration"]) + ) + + +def test_sequential_impulse_paper_breakable_wall_is_deterministic() -> None: + import numpy as np + + _require_simulation_experimental_symbols("World") + + from examples.demos.scenes.sequential_impulse_paper_breakable_wall import ( + SEQUENTIAL_IMPULSE_OUTCOME_ORACLE, + build, + ) + + outcome_oracle = SEQUENTIAL_IMPULSE_OUTCOME_ORACLE + setups = [build(), build()] + for setup in setups: + setup.info["sx_world"].step(n=outcome_oracle["evaluation_frame"]) + + fracture_outcomes = [setup.info["outcome_metrics"]() for setup in setups] + for key in ( + "broken_joints", + "unbroken_joints", + "impact_band_displaced_counts", + "broken_joint_impact_region_counts", + "broken_joints_outside_impact_regions", + "outside_retained_fraction", + "total_retained_fraction", + "maximum_outside_impact_unbroken_joint_linear_residual", + "maximum_outside_impact_unbroken_joint_angular_residual_radians", + "rms_outside_impact_unbroken_joint_linear_residual", + "rms_outside_impact_unbroken_joint_angular_residual_radians", + "last_step_iterations", + "contact_count", + ): + assert fracture_outcomes[0][key] == pytest.approx( + fracture_outcomes[1][key] + ) + + for setup in setups: + setup.info["sx_world"].step( + n=( + outcome_oracle["collapse_evaluation_frame"] + - outcome_oracle["evaluation_frame"] + ) + ) + collapse_outcomes = [setup.info["outcome_metrics"]() for setup in setups] + for key in ( + "broken_joints", + "unbroken_joints", + "impact_band_displaced_counts", + "broken_joint_impact_region_counts", + "broken_joints_outside_impact_regions", + "outside_retained_fraction", + "total_retained_fraction", + "maximum_outside_impact_unbroken_joint_linear_residual", + "maximum_outside_impact_unbroken_joint_angular_residual_radians", + "rms_outside_impact_unbroken_joint_linear_residual", + "rms_outside_impact_unbroken_joint_angular_residual_radians", + "maximum_wall_normal_displacement", + "last_step_iterations", + "contact_count", + ): + assert collapse_outcomes[0][key] == pytest.approx( + collapse_outcomes[1][key] + ) + for checkpoint_outcomes in (fracture_outcomes, collapse_outcomes): + assert ( + checkpoint_outcomes[0]["broken_joint_ids_sha256"] + == checkpoint_outcomes[1]["broken_joint_ids_sha256"] + ) + assert checkpoint_outcomes[0]["broken_joint_records"] == ( + checkpoint_outcomes[1]["broken_joint_records"] + ) + + for collection in ("bricks", "balls"): + positions = [ + np.asarray( + [ + np.asarray(body.translation, dtype=float).reshape(3) + for body in setup.info[collection] + ] + ) + for setup in setups + ] + np.testing.assert_allclose(positions[0], positions[1], rtol=0.0, atol=1e-12) + assert [ + joint.is_broken for joint in setups[0].info["joints"] + ] == [joint.is_broken for joint in setups[1].info["joints"]] + + def test_vbd_paper_breakable_wall_matches_figure13_contract() -> None: sx = _require_simulation_experimental_symbols("World") @@ -10521,6 +10843,16 @@ def test_vbd_paper_breakable_wall_matches_figure13_contract() -> None: >= OUTCOME_ORACLE["minimum_rms_wall_normal_displacement"] ) assert bend["bent_brick_count"] >= OUTCOME_ORACLE["minimum_bent_bricks"] + assert ( + bend["maximum_unbroken_joint_linear_residual"] + <= OUTCOME_ORACLE["maximum_unbroken_joint_linear_residual"] + ) + assert ( + bend["maximum_unbroken_joint_angular_residual_radians"] + <= OUTCOME_ORACLE[ + "maximum_unbroken_joint_angular_residual_radians" + ] + ) assert bend["last_step_iterations"] == 20 bend_capture = setup.info[CAPTURE_METRICS_INFO_KEY]() @@ -10547,6 +10879,16 @@ def test_vbd_paper_breakable_wall_matches_figure13_contract() -> None: retention["total_retained_fraction"] >= OUTCOME_ORACLE["minimum_total_retained_fraction"] ) + assert ( + retention["maximum_unbroken_joint_linear_residual"] + <= OUTCOME_ORACLE["maximum_unbroken_joint_linear_residual"] + ) + assert ( + retention["maximum_unbroken_joint_angular_residual_radians"] + <= OUTCOME_ORACLE[ + "maximum_unbroken_joint_angular_residual_radians" + ] + ) def test_vbd_paper_breakable_wall_checkpoints_are_deterministic() -> None: @@ -10570,6 +10912,8 @@ def test_vbd_paper_breakable_wall_checkpoints_are_deterministic() -> None: "maximum_wall_normal_displacement", "rms_wall_normal_displacement", "bent_brick_count", + "maximum_unbroken_joint_linear_residual", + "maximum_unbroken_joint_angular_residual_radians", "last_step_iterations", "contact_count", ): @@ -10587,6 +10931,8 @@ def test_vbd_paper_breakable_wall_checkpoints_are_deterministic() -> None: "broken_joints", "unbroken_joints", "total_retained_fraction", + "maximum_unbroken_joint_linear_residual", + "maximum_unbroken_joint_angular_residual_radians", "last_step_iterations", "contact_count", ): @@ -10627,20 +10973,30 @@ def _fixed_chain_anchor_errors(chain: tuple[Any, ...]) -> list[float]: return errors -def _assert_source_fixed_joint_fracture_resets(setup: Any) -> None: +def _assert_source_fixed_joint_fracture_resets( + setup: Any, + *, + fracture_evaluation_frame: int, +) -> None: joints = setup.info["joints"] chain = setup.info["chain"] + rigid_body_solver = setup.info["sx_world"].rigid_body_solver + assert rigid_body_solver == type(rigid_body_solver).AVBD assert len(joints) == 10 assert max(_fixed_chain_anchor_errors(chain)) < 1.0e-12 - for _ in range(45): + # These ports preserve source topology and the source's numeric threshold, + # but DART evaluates a combined physical row-load norm instead of the + # source's torque-arm-scaled angular dual predicate. Use a deterministic + # DART horizon without claiming source-trajectory equivalence. + for _ in range(fracture_evaluation_frame): assert setup.pre_step is not None setup.pre_step() setup.world.step() broken_count = sum(joint.is_broken for joint in joints) - assert broken_count >= len(joints) // 2 + assert broken_count >= 1 broken_anchor_error = max(_fixed_chain_anchor_errors(chain)) assert broken_anchor_error > 1.0e-2 @@ -10673,7 +11029,10 @@ def test_avbd_demo2d_fracture_scene_breaks_and_resets_source_joints() -> None: assert setup.info["source_demo_row"] == "avbd-demo2d fracture" assert setup.info["break_force"] == pytest.approx(500.0) - _assert_source_fixed_joint_fracture_resets(setup) + _assert_source_fixed_joint_fracture_resets( + setup, + fracture_evaluation_frame=360, + ) def test_avbd_demo3d_breakable_scene_breaks_and_resets_source_joints() -> None: @@ -10685,7 +11044,10 @@ def test_avbd_demo3d_breakable_scene_breaks_and_resets_source_joints() -> None: assert setup.info["source_demo_row"] == "avbd-demo3d breakable" assert setup.info["break_force"] == pytest.approx(90.0) - _assert_source_fixed_joint_fracture_resets(setup) + _assert_source_fixed_joint_fracture_resets( + setup, + fracture_evaluation_frame=60, + ) def test_avbd_demo3d_ground_scene_matches_source_row() -> None: @@ -11433,6 +11795,7 @@ def test_avbd_demo3d_soft_body_scene_matches_source_row() -> None: } assert sx_world.num_rigid_bodies == 193 assert sx_world.num_joints == 432 + assert sx_world.rigid_body_solver == sx.RigidBodySolver.AVBD assert _fixed_joint_count(sx_world) == 432 assert sx_world.time_step == pytest.approx(1.0 / 60.0) assert sx_world.gravity.tolist() == pytest.approx([0.0, 0.0, -10.0]) diff --git a/python/tests/unit/simulation/test_world.py b/python/tests/unit/simulation/test_world.py index 78e4c091209d3..979dbdb9f6043 100644 --- a/python/tests/unit/simulation/test_world.py +++ b/python/tests/unit/simulation/test_world.py @@ -929,8 +929,9 @@ def test_simulation_world_rigid_body_fixed_joint_projects_captured_pose(): world.enter_simulation_mode() world.step() + assert world.rigid_body_solver == sx.RigidBodySolver.SEQUENTIAL_IMPULSE assert float(link.translation[0]) == pytest.approx(1.0, abs=0.05) - assert float(link.linear_velocity[0]) < 0.0 + assert float(link.linear_velocity[0]) == pytest.approx(0.0, abs=1.0e-12) with pytest.raises(Exception, match="simulation mode"): world.add_joint( base, diff --git a/python/tests/unit/test_capture_py_demo.py b/python/tests/unit/test_capture_py_demo.py index cfb1f1f662f43..78e5ae2391ab5 100644 --- a/python/tests/unit/test_capture_py_demo.py +++ b/python/tests/unit/test_capture_py_demo.py @@ -3,6 +3,7 @@ import argparse import html from html.parser import HTMLParser +from hashlib import sha256 import importlib.util import json import pathlib @@ -334,6 +335,22 @@ def fake_run_demo(demo_args: list[str]) -> int: } assert pathlib.Path(manifest["artifacts"]["screenshot"]).is_file() assert pathlib.Path(manifest["artifacts"]["scene_metrics_events"]).is_file() + source_provenance = manifest["capture_source_provenance"] + assert source_provenance["algorithm"] == ( + "sha256-length-prefixed-capture-source-tree-v1" + ) + assert source_provenance["file_count"] > 0 + assert len(source_provenance["digest"]) == 64 + artifact_provenance = manifest["capture_artifact_provenance"] + assert artifact_provenance["algorithm"] == "sha256-v1" + assert artifact_provenance["screenshot_sha256"] == sha256( + pathlib.Path(manifest["artifacts"]["screenshot"]).read_bytes() + ).hexdigest() + assert artifact_provenance["scene_metrics_events_sha256"] == sha256( + pathlib.Path( + manifest["artifacts"]["scene_metrics_events"] + ).read_bytes() + ).hexdigest() assert len(list((output / "png_frames").glob("frame_*.png"))) == 2 assert manifest["resolved_solver_identity"] == { "executor": "Sequential", diff --git a/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py b/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py index abfef448c145e..ffa83bb538c6d 100644 --- a/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py +++ b/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py @@ -345,7 +345,7 @@ def test_packet_records_finite_load_fracture_evidence(tmp_path: Path) -> None: ) packet = json.loads(output.read_text(encoding="utf-8")) - assert packet["schema_version"] == 3 + assert packet["schema_version"] == module.AVBD_PACKET_SCHEMA_VERSION assert ( packet["resolved_solver_identity"]["rigid_contact_selection"] == "not_applicable" diff --git a/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py b/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py index d0d844740057b..a0604c4872887 100644 --- a/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py +++ b/python/tests/unit/test_write_avbd_articulated_compliant_joints_packet.py @@ -276,7 +276,7 @@ def test_avbd_articulated_compliant_joints_packet_records_evidence( ) packet = json.loads(output.read_text()) - assert packet["schema_version"] == 3 + assert packet["schema_version"] == module.AVBD_PACKET_SCHEMA_VERSION assert packet["scene"] == "avbd_articulated_compliant_joints" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, diff --git a/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py b/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py index 8472557dad813..f47fc42d6cffa 100644 --- a/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py +++ b/python/tests/unit/test_write_avbd_articulated_compliant_motors_packet.py @@ -277,7 +277,7 @@ def test_avbd_articulated_compliant_motors_packet_records_evidence( ) packet = json.loads(output.read_text()) - assert packet["schema_version"] == 3 + assert packet["schema_version"] == module.AVBD_PACKET_SCHEMA_VERSION assert packet["scene"] == "avbd_articulated_compliant_motors" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, diff --git a/python/tests/unit/test_write_avbd_breakable_joint_scale_packet.py b/python/tests/unit/test_write_avbd_breakable_joint_scale_packet.py index 92e45afab8e22..36182ed138afa 100644 --- a/python/tests/unit/test_write_avbd_breakable_joint_scale_packet.py +++ b/python/tests/unit/test_write_avbd_breakable_joint_scale_packet.py @@ -128,7 +128,7 @@ def test_avbd_breakable_joint_scale_packet_records_scale_data( ) packet = json.loads(output.read_text()) - assert packet["schema_version"] == 3 + assert packet["schema_version"] == module.AVBD_PACKET_SCHEMA_VERSION assert packet["packet"] == "avbd_breakable_joint_scale" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, diff --git a/python/tests/unit/test_write_avbd_breakable_motor_scale_packet.py b/python/tests/unit/test_write_avbd_breakable_motor_scale_packet.py index a0b1aee2acbdb..f987d37df7ab4 100644 --- a/python/tests/unit/test_write_avbd_breakable_motor_scale_packet.py +++ b/python/tests/unit/test_write_avbd_breakable_motor_scale_packet.py @@ -134,7 +134,7 @@ def test_avbd_breakable_motor_scale_packet_records_scale_data( ) packet = json.loads(output.read_text()) - assert packet["schema_version"] == 3 + assert packet["schema_version"] == module.AVBD_PACKET_SCHEMA_VERSION assert packet["packet"] == "avbd_breakable_motor_scale" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, diff --git a/python/tests/unit/test_write_avbd_friction_coefficient_sweep_packet.py b/python/tests/unit/test_write_avbd_friction_coefficient_sweep_packet.py index e14fef4a6e9cb..474634aa042e3 100644 --- a/python/tests/unit/test_write_avbd_friction_coefficient_sweep_packet.py +++ b/python/tests/unit/test_write_avbd_friction_coefficient_sweep_packet.py @@ -219,7 +219,7 @@ def test_avbd_friction_coefficient_sweep_packet_records_rows( ) packet = json.loads(output.read_text(encoding="utf-8")) - assert packet["schema_version"] == 3 + assert packet["schema_version"] == module.AVBD_PACKET_SCHEMA_VERSION assert packet["packet"] == "avbd_friction_coefficient_sweep" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, diff --git a/python/tests/unit/test_write_avbd_paper_breakable_wall_packet.py b/python/tests/unit/test_write_avbd_paper_breakable_wall_packet.py index f4178e0e4207e..6b085d76a3fe5 100644 --- a/python/tests/unit/test_write_avbd_paper_breakable_wall_packet.py +++ b/python/tests/unit/test_write_avbd_paper_breakable_wall_packet.py @@ -52,29 +52,83 @@ def _sha256(path: Path) -> str: return sha256(path.read_bytes()).hexdigest() -def _outcome(frame: int) -> dict[str, Any]: +def _broken_joint_records( + *, + broken_count: int, + impact_region_counts: tuple[int, int, int], +) -> list[dict[str, Any]]: + records = [] + impact_region_count = sum(impact_region_counts) + impact_indices = [ + impact_index + for impact_index, count in enumerate(impact_region_counts) + for _ in range(count) + ] + for index in range(broken_count): + within_region = index < impact_region_count + impact_index = impact_indices[index] if within_region else index % 3 + records.append( + { + "angular_residual_radians": 0.01 + 0.0001 * index, + "child": { + "body": "brick", + "column": (index + 1) % 21, + "row": (index // 21) % 12, + }, + "id": f"fixture_joint_{index:03d}", + "initial_anchor": [ + float(index % 21), + 0.0, + float((index // 21) % 12), + ], + "kind": "horizontal" if index % 2 == 0 else "vertical", + "linear_residual": 0.001 + 0.000001 * index, + "nearest_impact_distance": 0.5 if within_region else 2.0, + "nearest_impact_index": impact_index, + "parent": { + "body": "brick", + "column": index % 21, + "row": (index // 21) % 12, + }, + "within_impact_band": within_region, + "within_impact_region": within_region, + } + ) + return records + + +def _broken_joint_ids_sha256(records: list[dict[str, Any]]) -> str: + digest = sha256() + for joint_id in sorted(record["id"] for record in records): + encoded_id = joint_id.encode("utf-8") + digest.update(struct.pack(" dict[str, Any]: expected = { 60: { - "bands": [3, 9, 4], - "broken": 359, - "damage": False, + "bands": [13, 14, 12], + "broken": 154, + "damage": True, "evaluated": False, - "outside": 0.9337016574585635, + "outside": 1.0, "status": "pre-evaluation", "thresholds_pass": False, - "total": 0.8611111111111112, - "unbroken": 353, + "total": 0.996031746031746, + "unbroken": 558, }, 120: { - "bands": [4, 10, 6], - "broken": 359, + "bands": [11, 14, 11], + "broken": 154, "damage": True, "evaluated": True, - "outside": 0.9116022099447514, + "outside": 0.9943181818181818, "status": "pass", "thresholds_pass": True, - "total": 0.8253968253968254, - "unbroken": 353, + "total": 0.9920634920634921, + "unbroken": 558, }, }.get( frame, @@ -90,30 +144,72 @@ def _outcome(frame: int) -> dict[str, Any]: "unbroken": 712, }, ) + records = ( + _broken_joint_records( + broken_count=expected["broken"], + impact_region_counts=(18, 44, 13), + ) + if frame in (60, 120) + else [] + ) + identity_digest = _broken_joint_ids_sha256( + _broken_joint_records( + broken_count=expected["broken"], + impact_region_counts=(18, 44, 13), + ) + ) return { "ball_positions": [[0.0, 1.0, 2.0]] * 3, "ball_velocities": [[3.0, 4.0, 5.0]] * 3, + "broken_joint_identity_count": expected["broken"], + "broken_joint_ids_sha256": identity_digest, + "broken_joint_impact_region_counts": ( + [18, 44, 13] if expected["broken"] else [0, 0, 0] + ), + "broken_joint_records": records, "broken_joints": expected["broken"], + "broken_joints_outside_impact_regions": ( + expected["broken"] - 75 + if expected["broken"] + else 0 + ), "contact_count": 12, "evaluated": expected["evaluated"], "frame": frame, "impact_band_displaced_counts": expected["bands"], "last_step_iterations": 20, "max_brick_displacement": 3.0, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": ( + 0.0003 + ), + "maximum_outside_impact_unbroken_joint_linear_residual": 0.001, + "maximum_unbroken_joint_angular_residual_radians": 0.0003, + "maximum_unbroken_joint_linear_residual": 0.001, "outside_brick_count": 181, + "outside_impact_unbroken_joint_residual_count": ( + 405 if expected["broken"] else 484 + ), "outside_retained_fraction": expected["outside"], + "joint_residuals_finite": True, + "rms_outside_impact_unbroken_joint_angular_residual_radians": 0.0001, + "rms_outside_impact_unbroken_joint_linear_residual": 0.0005, + "rms_unbroken_joint_angular_residual_radians": 0.0001, + "rms_unbroken_joint_linear_residual": 0.0005, "status": expected["status"], "threshold_checks": { + "damage_in_three_impact_bands": expected["damage"], "finite_state": True, "fracture_activated": True, - "fracture_localized": True, + "fracture_count_bounded": True, + "fracture_identity_matches": True, "outside_wall_retained": True, - "damage_in_three_impact_bands": expected["damage"], + "retained_joint_rows_satisfied": True, "total_wall_retained": True, }, "thresholds_pass": expected["thresholds_pass"], "total_retained_fraction": expected["total"], "unbroken_joints": expected["unbroken"], + "unbroken_joint_residual_count": expected["unbroken"], "world_time": frame / 60.0, } @@ -121,12 +217,12 @@ def _outcome(frame: int) -> dict[str, Any]: def _metrics(module, frame: int) -> dict[str, Any]: return { "ball_count": 3, - "break_force": 8500.0, + "break_force": module.BREAK_FORCE, "breakable_joints": 712, "brick_count": 252, "collision_shapes": 256, "executor": "World.step default", - "outcome": _outcome(frame), + "outcome": _outcome(module, frame), "outcome_oracle": dict(module.OUTCOME_ORACLE), "paper_locator": module.PAPER_LOCATOR, "rigid_bodies": 256, @@ -164,17 +260,12 @@ def _metrics(module, frame: int) -> dict[str, Any]: "time_step_ms": 1000.0 / 60.0, "view_report": { "camera": { - "azimuth": -1.5707963267948966, - "distance": 11.0, - "elevation": 0.0, - "target": [0.0, 0.0, 1.4], + "azimuth": module.CAMERA_AZIMUTH, + "distance": module.CAMERA_DISTANCE, + "elevation": module.CAMERA_ELEVATION, + "target": list(module.CAMERA_TARGET), }, - "focus": [ - "avbd_paper_wall_brick_00_00_visual", - "avbd_paper_wall_brick_00_20_visual", - "avbd_paper_wall_brick_11_00_visual", - "avbd_paper_wall_brick_11_20_visual", - ], + "focus": list(module.VIEW_FOCUS), "issues": [], "metrics": { "ambiguity_iou": 0.0, @@ -221,9 +312,11 @@ def _write_capture(module, tmp_path: Path, frame: int, label: str) -> tuple[Path "video": None, }, "camera": { - "distance": 11.0, - "target": [0.0, 0.0, 1.4], - "view": "front", + "azimuth": module.math.degrees(module.CAMERA_AZIMUTH), + "distance": module.CAMERA_DISTANCE, + "elevation": module.math.degrees(module.CAMERA_ELEVATION), + "target": list(module.CAMERA_TARGET), + "view": module.CAMERA_PRESET, }, "capture": { "converted_frames": frame, @@ -231,6 +324,14 @@ def _write_capture(module, tmp_path: Path, frame: int, label: str) -> tuple[Path "requested_frames": frame, "width": 8, }, + "capture_artifact_provenance": { + "algorithm": module.CAPTURE_ARTIFACT_PROVENANCE_ALGORITHM, + "scene_metrics_events_sha256": _sha256(metrics_log), + "screenshot_sha256": _sha256(screenshot), + }, + "capture_source_provenance": ( + module.compute_capture_source_provenance(module.REPO_ROOT) + ), "capture_label": label, "force_drag": None, "resolved_solver_identity": { @@ -262,7 +363,7 @@ def _write_capture(module, tmp_path: Path, frame: int, label: str) -> tuple[Path "metadata": { "frame": str(frame), "scene": module.SCENE_ID, - "view": "front", + "view": module.CAMERA_VIEW, }, "pass": True, "schema_version": "dart.image_verdict/v1", @@ -326,6 +427,14 @@ def _write_benchmark(module, tmp_path: Path) -> Path: { "benchmarks": rows, "context": { + "benchmark_source_sha256": _sha256( + module.REPO_ROOT / module.BENCHMARK_SOURCE_PATH + ), + "capture_source_provenance_digest": ( + module.compute_capture_source_provenance( + module.REPO_ROOT + )["digest"] + ), "executable": ( "build/default/cpp/Release/bin/" "bm_avbd_rigid_fixed_joint" @@ -345,6 +454,13 @@ def _write_benchmark(module, tmp_path: Path) -> Path: def _write_inputs(module, tmp_path: Path, monkeypatch) -> dict[str, Path]: + fixture_records = _broken_joint_records( + broken_count=154, + impact_region_counts=(18, 44, 13), + ) + module.OUTCOME_ORACLE["expected_broken_joint_ids_sha256"] = ( + _broken_joint_ids_sha256(fixture_records) + ) impact_manifest, impact_verdict = _write_capture( module, tmp_path, 60, "impact" ) @@ -403,7 +519,7 @@ def _write_inputs(module, tmp_path: Path, monkeypatch) -> dict[str, Path]: ), "verdict": "pass", "visible_observation": ( - "The fixed front views show damage at each target band and " + "The fixed front-oblique views show damage at each target band and " "substantial upper and side wall regions still standing." ), } @@ -430,7 +546,7 @@ def test_make_packet_records_public_avbd_figure13_evidence( packet = module.make_packet(**inputs) - assert packet["schema_version"] == 3 + assert packet["schema_version"] == module.AVBD_PACKET_SCHEMA_VERSION assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, "recorded_from": ( @@ -459,10 +575,18 @@ def test_make_packet_records_public_avbd_figure13_evidence( ("mutation", "message"), [ ("camera", "serialized camera"), + ("capture_source", "capture source provenance digest"), + ("capture_artifact", "capture artifact screenshot_sha256"), ("view_report_gate", "ambiguity_iou fails"), ("solver", "resolve public_avbd"), ("oracle", "outcome threshold_checks"), + ("joint_identity", "broken-joint identity digest"), + ("joint_residual", "retained-joint linear residual exceeds"), ("benchmark", "public_avbd_family"), + ( + "benchmark_source", + "benchmark capture_source_provenance_digest", + ), ("events_empty", "expected 60 scene metric events"), ("events_unrelated", "event must be"), ("events_count", "expected 60 scene metric events"), @@ -488,6 +612,16 @@ def test_make_packet_fails_closed_on_mismatched_evidence( data = json.loads(path.read_text()) del data["camera"] path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "capture_source": + path = inputs["impact_capture_manifest"] + data = json.loads(path.read_text()) + data["capture_source_provenance"]["digest"] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "capture_artifact": + path = inputs["impact_capture_manifest"] + data = json.loads(path.read_text()) + data["capture_artifact_provenance"]["screenshot_sha256"] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") elif mutation == "view_report_gate": path = inputs["impact_capture_manifest"] data = json.loads(path.read_text()) @@ -515,7 +649,7 @@ def test_make_packet_fails_closed_on_mismatched_evidence( path = inputs["outcome_capture_manifest"] data = json.loads(path.read_text()) data["scene_metrics"]["latest"]["metrics"]["outcome"]["threshold_checks"][ - "fracture_localized" + "fracture_count_bounded" ] = False path.write_text(json.dumps(data), encoding="utf-8") events_path = Path(data["artifacts"]["scene_metrics_events"]) @@ -525,7 +659,7 @@ def test_make_packet_fails_closed_on_mismatched_evidence( if line ] events[-1]["metrics"]["outcome"]["threshold_checks"][ - "fracture_localized" + "fracture_count_bounded" ] = False events_path.write_text( "".join(json.dumps(event) + "\n" for event in events), @@ -536,6 +670,37 @@ def test_make_packet_fails_closed_on_mismatched_evidence( data = json.loads(path.read_text()) data["benchmarks"][0]["public_avbd_family"] = 0.0 path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "benchmark_source": + path = inputs["benchmark_json"] + data = json.loads(path.read_text()) + data["context"]["capture_source_provenance_digest"] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation in {"joint_identity", "joint_residual"}: + path = inputs["outcome_capture_manifest"] + data = json.loads(path.read_text()) + latest_outcome = data["scene_metrics"]["latest"]["metrics"]["outcome"] + events_path = Path(data["artifacts"]["scene_metrics_events"]) + events = [ + json.loads(line) + for line in events_path.read_text().splitlines() + if line + ] + event_outcome = events[-1]["metrics"]["outcome"] + if mutation == "joint_identity": + latest_outcome["broken_joint_records"][0]["id"] = "mutated" + event_outcome["broken_joint_records"][0]["id"] = "mutated" + else: + latest_outcome[ + "maximum_unbroken_joint_linear_residual" + ] = 0.1 + event_outcome[ + "maximum_unbroken_joint_linear_residual" + ] = 0.1 + path.write_text(json.dumps(data), encoding="utf-8") + events_path.write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) elif mutation.startswith("events_"): manifest = json.loads(inputs["impact_capture_manifest"].read_text()) events_path = Path(manifest["artifacts"]["scene_metrics_events"]) diff --git a/python/tests/unit/test_write_avbd_paper_scale_high_ratio_iteration_sweep_packet.py b/python/tests/unit/test_write_avbd_paper_scale_high_ratio_iteration_sweep_packet.py index 5639cec84fb05..0ae2b0dad2fb2 100644 --- a/python/tests/unit/test_write_avbd_paper_scale_high_ratio_iteration_sweep_packet.py +++ b/python/tests/unit/test_write_avbd_paper_scale_high_ratio_iteration_sweep_packet.py @@ -129,7 +129,7 @@ def test_avbd_paper_scale_high_ratio_iteration_sweep_packet_records_plot_data( ) packet = json.loads(output.read_text()) - assert packet["schema_version"] == 3 + assert packet["schema_version"] == module.AVBD_PACKET_SCHEMA_VERSION assert packet["packet"] == "avbd_paper_scale_high_ratio_iteration_sweep" assert packet["resolved_solver_identity"] == { "avbd_rigid_contact_config_emplaced": False, diff --git a/python/tests/unit/test_write_avbd_paper_sequential_impulse_comparison_packet.py b/python/tests/unit/test_write_avbd_paper_sequential_impulse_comparison_packet.py new file mode 100644 index 0000000000000..77521de3cab43 --- /dev/null +++ b/python/tests/unit/test_write_avbd_paper_sequential_impulse_comparison_packet.py @@ -0,0 +1,926 @@ +from __future__ import annotations + +from hashlib import sha256 +import importlib.util +import json +from pathlib import Path +import struct +import sys +from typing import Any +import zlib + +import pytest + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ( + ROOT + / "scripts" + / "write_avbd_paper_sequential_impulse_comparison_packet.py" +) + + +def _load_packet_module(): + spec = importlib.util.spec_from_file_location( + "write_avbd_paper_sequential_impulse_comparison_packet", + SCRIPT, + ) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _write_png(path: Path, width: int = 8, height: int = 6) -> None: + def chunk(kind: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + kind + + payload + + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF) + ) + + rows = b"".join( + b"\x00" + bytes((32 + row, 64, 128)) * width + for row in range(height) + ) + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + chunk( + b"IHDR", + struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0), + ) + + chunk(b"IDAT", zlib.compress(rows)) + + chunk(b"IEND", b"") + ) + + +def _sha256(path: Path) -> str: + return sha256(path.read_bytes()).hexdigest() + + +def _si_broken_joint_records() -> list[dict[str, Any]]: + impact_indices = (0, 0, 1, 2, 2) + records = [] + for index, impact_index in enumerate(impact_indices): + records.append( + { + "angular_residual_radians": 0.01 + index * 0.001, + "child": { + "body": "brick", + "column": index + 1, + "row": index + 2, + }, + "id": f"fixture_si_joint_{index}", + "initial_anchor": [float(index), 0.0, float(index + 1)], + "kind": "horizontal" if index < 2 else "vertical", + "linear_residual": 0.001 + index * 0.0001, + "nearest_impact_distance": 0.3 + index * 0.05, + "nearest_impact_index": impact_index, + "parent": { + "body": "brick", + "column": index, + "row": index + 2, + }, + "within_impact_band": True, + "within_impact_region": True, + } + ) + return records + + +def _joint_ids_sha256(records: list[dict[str, Any]]) -> str: + digest = sha256() + for joint_id in sorted(record["id"] for record in records): + encoded = joint_id.encode("utf-8") + digest.update(struct.pack(" dict[str, Any]: + return { + "broken_joint_identity_count": broken, + "broken_joint_ids_sha256": _joint_ids_sha256(records), + "broken_joint_impact_region_counts": ( + [2, 1, 2] if broken else [0, 0, 0] + ), + "broken_joint_records": records, + "broken_joints_outside_impact_regions": 0, + "joint_residuals_finite": True, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": ( + maximum_angular + ), + "maximum_outside_impact_unbroken_joint_linear_residual": maximum_linear, + "maximum_unbroken_joint_angular_residual_radians": maximum_angular, + "maximum_unbroken_joint_linear_residual": maximum_linear, + "outside_impact_unbroken_joint_residual_count": 484, + "rms_outside_impact_unbroken_joint_angular_residual_radians": ( + rms_angular + ), + "rms_outside_impact_unbroken_joint_linear_residual": rms_linear, + "rms_unbroken_joint_angular_residual_radians": rms_angular, + "rms_unbroken_joint_linear_residual": rms_linear, + "unbroken_joint_residual_count": 712 - broken, + } + + +def _outcome(module, frame: int) -> dict[str, Any]: + generic = { + "ball_positions": [[0.0, 1.0, 2.0]] * 3, + "ball_velocities": [[3.0, 4.0, 5.0]] * 3, + "bent_brick_count": 0, + "broken_joints": 0, + "checkpoint": "fracture", + "contact_count": 0, + "evaluated": False, + "frame": frame, + "impact_band_displaced_counts": [0, 0, 0], + "impact_damage_displacement_threshold": 0.1, + "last_step_iterations": 20, + "max_brick_displacement": 0.0, + "maximum_wall_normal_displacement": 0.0, + "outside_brick_count": 181, + "outside_retained_fraction": 1.0, + "retained_displacement_threshold": 0.5, + "rms_wall_normal_displacement": 0.0, + "status": "pre-evaluation", + "threshold_checks": {"finite_state": True}, + "thresholds_pass": False, + "total_retained_fraction": 1.0, + "unbroken_joints": 712, + "world_time": frame / 60.0, + } | _joint_evidence( + broken=0, + records=[], + maximum_angular=0.0, + maximum_linear=0.0, + rms_angular=0.0, + rms_linear=0.0, + ) + if frame == 14: + expected = module.EXPECTED_OUTCOMES[14] + return generic | expected | { + "bent_brick_count": 103, + "contact_count": 17, + "evaluated": True, + "max_brick_displacement": 0.22658181580080475, + "maximum_wall_normal_displacement": 0.22658181580080475, + "rms_wall_normal_displacement": 0.08221218581583968, + "status": "pass", + "threshold_checks": dict( + module.CHECKPOINTS[14]["threshold_checks"] + ), + "thresholds_pass": True, + } | _joint_evidence( + broken=5, + records=_si_broken_joint_records(), + maximum_angular=0.095, + maximum_linear=0.025, + rms_angular=0.025, + rms_linear=0.012, + ) + if frame == 120: + expected = module.EXPECTED_OUTCOMES[120] + return generic | expected | { + "bent_brick_count": 230, + "checkpoint": "collapse", + "contact_count": 298, + "evaluated": True, + "max_brick_displacement": 2.9062896239039815, + "maximum_wall_normal_displacement": 2.9062896239039815, + "rms_wall_normal_displacement": 1.564993114358864, + "status": "pass", + "threshold_checks": dict( + module.CHECKPOINTS[120]["threshold_checks"] + ), + "thresholds_pass": True, + } | _joint_evidence( + broken=5, + records=_si_broken_joint_records(), + maximum_angular=0.88, + maximum_linear=0.12, + rms_angular=0.24, + rms_linear=0.04, + ) + return generic + + +def _metrics(module, frame: int) -> dict[str, Any]: + return { + "ball_count": 3, + "break_force": module.BREAK_FORCE, + "breakable_joints": 712, + "brick_count": 252, + "collision_shapes": 256, + "executor": "World.step default", + "outcome": _outcome(module, frame), + "outcome_oracle": dict(module.OUTCOME_ORACLE), + "paper_locator": module.PAPER_LOCATOR, + "rigid_bodies": 256, + "rigid_body_solver": "SEQUENTIAL_IMPULSE", + "rigid_constraint_options": {"iterations": 20}, + "resolved_configuration": [ + { + "domain": "rigid-body", + "reason": "as requested", + "requested": "sequential-impulse", + "resolved": "sequential-impulse", + }, + { + "domain": "rigid-contact", + "reason": "as requested", + "requested": "sequential-impulse", + "resolved": "sequential-impulse", + }, + { + "domain": "rigid-pair-constraint", + "reason": "as requested", + "requested": "sequential-impulse", + "resolved": "sequential-impulse", + }, + { + "domain": "rigid-constraint-iterations", + "reason": "public rigid constraint iteration budget", + "requested": "20", + "resolved": "20", + }, + ], + "row": module.SCENE_ID, + "scene_spec_fingerprint": "0123456789abcdef", + "solver": "public_sequential-impulse", + "time_step_ms": 1000.0 / 60.0, + "view_report": { + "camera": { + "azimuth": module.shared.CAMERA_AZIMUTH, + "distance": module.shared.CAMERA_DISTANCE, + "elevation": module.shared.CAMERA_ELEVATION, + "target": list(module.shared.CAMERA_TARGET), + }, + "focus": list(module.VIEW_FOCUS), + "issues": [], + "metrics": { + "ambiguity_iou": 0.0, + "center_visible": True, + "corner_coverage": 1.0, + "occlusion_fraction": 0.0, + "subject_fraction": 0.5, + }, + "pass": True, + "schema_version": "dart.view_report/v1", + "score": 1.0, + "size": [8, 6], + }, + } + + +def _write_capture( + module, + tmp_path: Path, + frame: int, + label: str, +) -> tuple[Path, Path]: + directory = tmp_path / label + directory.mkdir() + screenshot = directory / f"{module.SCENE_ID}_{label}.png" + _write_png(screenshot) + metrics_log = directory / "scene_metrics.jsonl" + events = [ + { + "event": "scene_capture_metrics", + "frame": event_frame, + "metrics": _metrics(module, event_frame), + "scene": module.SCENE_ID, + "source": "py-demo-scene", + } + for event_frame in range(1, frame + 1) + ] + metrics_log.write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + manifest = { + "artifacts": { + "events": None, + "frames": str(directory / "png_frames"), + "scene_metrics_events": str(metrics_log), + "screenshot": str(screenshot), + "video": None, + }, + "camera": { + "azimuth": module.shared.math.degrees( + module.shared.CAMERA_AZIMUTH + ), + "distance": module.shared.CAMERA_DISTANCE, + "elevation": module.shared.math.degrees( + module.shared.CAMERA_ELEVATION + ), + "target": list(module.shared.CAMERA_TARGET), + "view": module.shared.CAMERA_PRESET, + }, + "capture": { + "converted_frames": frame, + "height": 6, + "requested_frames": frame, + "width": 8, + }, + "capture_artifact_provenance": { + "algorithm": module.shared.CAPTURE_ARTIFACT_PROVENANCE_ALGORITHM, + "scene_metrics_events_sha256": _sha256(metrics_log), + "screenshot_sha256": _sha256(screenshot), + }, + "capture_source_provenance": ( + module.shared.compute_capture_source_provenance( + module.shared.REPO_ROOT + ) + ), + "capture_label": label, + "force_drag": None, + "resolved_solver_identity": { + "executor": "World.step default", + "solver": "public_sequential-impulse", + "source": "scene_capture_metrics.latest.metrics", + }, + "scene": module.SCENE_ID, + "scene_metrics": { + "event_count": frame, + "latest": events[-1], + }, + "schema_version": 1, + } + manifest_path = directory / "manifest.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + + verdict = { + "checks": {"non_blank": {"pass": True}}, + "image": { + "height": 6, + "path": str(screenshot), + "width": 8, + }, + "machine_scope": "pixel-integrity", + "metadata": { + "frame": str(frame), + "scene": module.SCENE_ID, + "view": module.CAMERA_VIEW, + }, + "pass": True, + "schema_version": "dart.image_verdict/v1", + "semantic_review": { + "performed_by_this_tool": False, + "required": True, + }, + } + verdict_path = directory / "image_verdict.json" + verdict_path.write_text(json.dumps(verdict), encoding="utf-8") + return manifest_path, verdict_path + + +def _write_benchmark(module, tmp_path: Path) -> tuple[Path, dict[str, Any]]: + fingerprint = int("0123456789abcdef", 16) + rows = [] + scales = {"avbd": 1.0, "vbd": 1.25, "sequential-impulse": 0.5} + for solver_key, scale in scales.items(): + run = module.comparison.BENCHMARK_RUNS[solver_key] + for aggregate, real_time, cpu_time in ( + ("mean", 7_800_000.0 * scale, 7_790_000.0 * scale), + ("median", 7_750_000.0 * scale, 7_740_000.0 * scale), + ("stddev", 110_000.0, 109_000.0), + ("cv", 0.014, 0.014), + ): + row = { + "aggregate_name": aggregate, + "aggregate_unit": ( + "percentage" if aggregate == "cv" else "time" + ), + "cpu_time": cpu_time, + "iterations": 5, + "name": f"{run}_{aggregate}", + "real_time": real_time, + "repetitions": 5, + "run_name": run, + "run_type": "aggregate", + "time_unit": "ns", + } + if aggregate in ("mean", "median"): + row.update( + { + "breakable_joints": 712.0, + "collision_shapes": 256.0, + "impacting_balls": 3.0, + f"public_{solver_key}_family": 1.0, + f"resolved_rigid_body_{solver_key}": 1.0, + "resolved_rigid_constraint_iterations": 1.0, + f"resolved_rigid_contact_{solver_key}": 1.0, + ( + f"resolved_rigid_pair_constraint_{solver_key}" + ): 1.0, + "rigid_constraint_iterations": 20.0, + "rigid_bodies": 256.0, + "rigid_body_joints": 712.0, + "runtime_contract_passed": 1.0, + "scene_spec_fingerprint_hi": float( + fingerprint >> 32 + ), + "scene_spec_fingerprint_lo": float( + fingerprint & 0xFFFFFFFF + ), + "trajectory_frames": 120.0, + } + ) + rows.append(row) + data = { + "benchmarks": rows, + "context": { + "benchmark_source_sha256": _sha256( + module.shared.REPO_ROOT + / module.shared.BENCHMARK_SOURCE_PATH + ), + "capture_source_provenance_digest": ( + module.shared.compute_capture_source_provenance( + module.shared.REPO_ROOT + )["digest"] + ), + "executable": ( + "build/default/cpp/Release/bin/" + "bm_avbd_rigid_fixed_joint" + ), + "host_name": "test-host", + "json_schema_version": 1, + "library_build_type": "release", + "library_version": "test", + "mhz_per_cpu": 5000, + "num_cpus": 8, + }, + } + path = tmp_path / "benchmark.json" + path.write_text(json.dumps(data), encoding="utf-8") + return path, data + + +def _linked_capture( + module, + *, + frame: int, + label: str, + screenshot_hash: str, +) -> dict[str, Any]: + return { + "image_verdict": {"pass": True, "sha256": "a" * 64}, + "scene_metrics": { + "frame": frame, + "outcome": { + "broken_joints": 0, + "broken_joint_identity_count": 0, + "broken_joint_ids_sha256": sha256().hexdigest(), + "broken_joint_impact_region_counts": [0, 0, 0], + "broken_joint_records": [], + "broken_joints_outside_impact_regions": 0, + "evaluated": True, + "joint_residuals_finite": True, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": 0.013, + "maximum_outside_impact_unbroken_joint_linear_residual": 0.016, + "maximum_unbroken_joint_angular_residual_radians": 0.013, + "maximum_unbroken_joint_linear_residual": 0.016, + "outside_impact_unbroken_joint_residual_count": 484, + "rms_outside_impact_unbroken_joint_angular_residual_radians": 0.003, + "rms_outside_impact_unbroken_joint_linear_residual": 0.004, + "rms_unbroken_joint_angular_residual_radians": 0.003, + "rms_unbroken_joint_linear_residual": 0.004, + "status": "pass", + "threshold_checks": dict( + module.comparison.CHECKPOINTS[frame][ + "threshold_checks" + ] + ), + "thresholds_pass": True, + "unbroken_joints": 712, + "unbroken_joint_residual_count": 712, + }, + "scene_spec_fingerprint": "0123456789abcdef", + }, + "screenshot": { + "file": f"{label}.png", + "sha256": screenshot_hash, + }, + } + + +def _write_linked_packet( + module, + tmp_path: Path, + benchmark: Path, + benchmark_data: dict[str, Any], + paper_figure: Path, +) -> Path: + bend_hash = "1" * 64 + retention_hash = "2" * 64 + paper_hash = _sha256(paper_figure) + avbd_identity = { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": "test", + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "avbd", + "rigid_point_joint_solver": "avbd", + } + avbd_packet = { + "benchmark": { + "scene_spec_fingerprint": "0123456789abcdef", + }, + "packet": "avbd_paper_breakable_wall", + "resolved_solver_identity": avbd_identity, + "scene": module.shared.SCENE_ID, + "schema_version": module.AVBD_PACKET_SCHEMA_VERSION, + "source_provenance": module.shared._source_provenance(), + } + avbd_packet_path = ( + tmp_path / "avbd-paper-breakable-wall-packet.json" + ) + avbd_packet_path.write_text(json.dumps(avbd_packet), encoding="utf-8") + methods = { + solver_key: module.comparison._benchmark_rows( + benchmark_data, + solver_key=solver_key, + expected_fingerprint="0123456789abcdef", + ) + for solver_key in ("avbd", "vbd") + } + packet = { + "benchmark": { + "json_sha256": _sha256(benchmark), + "methods": methods, + "scene_spec_fingerprint": "0123456789abcdef", + }, + "correctness": { + "determinism_test": ( + "test_vbd_paper_breakable_wall_checkpoints_are_deterministic" + ), + "outcome_test": ( + "test_vbd_paper_breakable_wall_matches_figure13_contract" + ), + }, + "linked_avbd_evidence": { + "file": avbd_packet_path.name, + "resolved_solver_identity": avbd_identity, + "scene_spec_fingerprint": "0123456789abcdef", + "sha256": _sha256(avbd_packet_path), + }, + "packet": module.LINKED_PACKET_ID, + "paper_reference": { + "figure": { + "file": paper_figure.name, + "sha256": paper_hash, + } + }, + "resolved_solver_identity": { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": "test", + "rigid_contact_selection": "world_solver_family", + "rigid_contact_solver": "vbd", + "rigid_point_joint_solver": "vbd", + }, + "scene": module.LINKED_SCENE_ID, + "schema_version": module.AVBD_PACKET_SCHEMA_VERSION, + "source_provenance": module.comparison._source_provenance(), + "visual_evidence": { + "bend": _linked_capture( + module, + frame=18, + label="bend", + screenshot_hash=bend_hash, + ), + "retention": _linked_capture( + module, + frame=120, + label="retention", + screenshot_hash=retention_hash, + ), + "semantic_review": { + "inspected_images": [ + { + "file": "bend.png", + "role": "bend_frame_18", + "sha256": bend_hash, + }, + { + "file": "retention.png", + "role": "retention_frame_120", + "sha256": retention_hash, + }, + { + "file": paper_figure.name, + "role": "paper_figure_13_reference", + "sha256": paper_hash, + }, + ], + "sha256": "3" * 64, + "verdict": "pass", + }, + }, + } + path = tmp_path / "avbd-vbd-packet.json" + path.write_text(json.dumps(packet), encoding="utf-8") + return path + + +def _write_inputs(module, tmp_path: Path, monkeypatch) -> dict[str, Path]: + module.OUTCOME_ORACLE["expected_broken_joint_ids_sha256"] = ( + _joint_ids_sha256(_si_broken_joint_records()) + ) + fracture_manifest, fracture_verdict = _write_capture( + module, + tmp_path, + 14, + "fracture", + ) + collapse_manifest, collapse_verdict = _write_capture( + module, + tmp_path, + 120, + "collapse", + ) + benchmark, benchmark_data = _write_benchmark(module, tmp_path) + + paper_pdf = tmp_path / "avbd.pdf" + paper_pdf.write_bytes(b"test AVBD paper") + paper_figure = tmp_path / "paper-10.png" + _write_png(paper_figure, width=10, height=12) + monkeypatch.setattr( + module.shared, + "PAPER_PDF_SHA256", + _sha256(paper_pdf), + ) + monkeypatch.setattr( + module.shared, + "PAPER_FIGURE_SHA256", + _sha256(paper_figure), + ) + linked_packet = _write_linked_packet( + module, + tmp_path, + benchmark, + benchmark_data, + paper_figure, + ) + + fracture_screenshot = ( + fracture_manifest.parent / f"{module.SCENE_ID}_fracture.png" + ) + collapse_screenshot = ( + collapse_manifest.parent / f"{module.SCENE_ID}_collapse.png" + ) + review = { + "claim_and_expected_observation": ( + "Sequential Impulse first fractures locally and later collapses." + ), + "inspected_images": [ + { + "file": str(fracture_screenshot), + "role": "fracture_frame_14", + "sha256": _sha256(fracture_screenshot), + }, + { + "file": str(collapse_screenshot), + "role": "collapse_frame_120", + "sha256": _sha256(collapse_screenshot), + }, + { + "file": str(paper_figure), + "role": "paper_figure_13_reference", + "sha256": _sha256(paper_figure), + }, + ], + "not_proven_and_limitations": ( + "Exact unpublished constants, XPBD, CUDA, and published/source " + "timing remain unproven." + ), + "reconciliation_and_verdict": ( + "PASS: both ViewReports and both numeric checkpoints agree with " + "the inspected images." + ), + "reviewer_capability": "native image input at original detail", + "scene": module.SCENE_ID, + "schema_version": "dart.visual_semantic_review/v1", + "text_oracle": ( + "Figure 13 shows initial fracture, slow bending, and collapse." + ), + "verdict": "pass", + "visible_observation": ( + "The early wall remains mostly placed around a local fracture; " + "the final wall has collapsed." + ), + } + review_path = tmp_path / "visual_review.json" + review_path.write_text(json.dumps(review), encoding="utf-8") + + return { + "benchmark_json": benchmark, + "fracture_capture_manifest": fracture_manifest, + "fracture_image_verdict_json": fracture_verdict, + "collapse_capture_manifest": collapse_manifest, + "collapse_image_verdict_json": collapse_verdict, + "visual_review_json": review_path, + "paper_pdf": paper_pdf, + "paper_figure_image": paper_figure, + "avbd_vbd_packet": linked_packet, + } + + +def _mutate_latest_capture( + manifest_path: Path, + mutation, +) -> None: + manifest = json.loads(manifest_path.read_text()) + mutation(manifest["scene_metrics"]["latest"]) + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + events_path = Path(manifest["artifacts"]["scene_metrics_events"]) + events = [ + json.loads(line) + for line in events_path.read_text().splitlines() + if line + ] + mutation(events[-1]) + events_path.write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) + + +def test_make_packet_records_matched_three_method_evidence( + tmp_path: Path, + monkeypatch, +) -> None: + module = _load_packet_module() + inputs = _write_inputs(module, tmp_path, monkeypatch) + + packet = module.make_packet(**inputs) + + identity = packet["resolved_solver_identity"] + assert identity["rigid_contact_solver"] == "sequential_impulse" + assert identity["rigid_point_joint_solver"] == "sequential_impulse" + assert identity["rigid_contact_selection"] == "contact_solver_method" + assert packet["linked_avbd_vbd_evidence"]["resolved_solver_identity"][ + "rigid_contact_solver" + ] == "vbd" + assert packet["linked_avbd_vbd_evidence"][ + "nested_avbd_resolved_solver_identity" + ]["rigid_contact_solver"] == "avbd" + assert packet["visual_evidence"]["fracture"]["scene_metrics"]["outcome"][ + "broken_joints" + ] == 5 + assert packet["visual_evidence"]["collapse"]["scene_metrics"]["outcome"][ + "total_retained_fraction" + ] == pytest.approx(0.19047619047619047) + comparison = packet["benchmark"]["comparison"] + assert comparison[ + "sequential_impulse_to_avbd_median_cpu_cost_ratio" + ] == pytest.approx(0.5) + assert comparison[ + "sequential_impulse_to_vbd_median_cpu_cost_ratio" + ] == pytest.approx(0.4) + assert packet["target"]["contract_rows"] == ["avbd.paper.fig.13"] + assert packet["target"]["complete_paper_reproduction"] is False + provenance_paths = { + entry["path"] for entry in packet["source_provenance"]["files"] + } + assert { + "scripts/avbd_packet_schema.py", + ( + "python/examples/demos/scenes/" + "sequential_impulse_paper_breakable_wall.py" + ), + "tests/unit/simulation/contact/test_boxed_lcp_contact.cpp", + } <= provenance_paths + assert str(tmp_path) not in json.dumps(packet) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("solver", "capture resolved_solver_identity"), + ("capture_source", "capture source provenance digest"), + ("fracture", "broken_joints"), + ( + "retained_residual", + "rms_outside_impact_unbroken_joint_angular_residual_radians " + "must be", + ), + ("benchmark", "expected aggregates"), + ("linked_hash", "linked AVBD/VBD benchmark JSON hash"), + ("linked_outcome", "retention outcome status"), + ("linked_source", "source provenance"), + ("nested_link_hash", "nested linked AVBD current file sha256"), + ("nested_link_source", "nested linked AVBD source provenance"), + ("linked_review", "paper figure sha256"), + ("limitations", "limitations must name xpbd"), + ], +) +def test_make_packet_fails_closed_on_mismatched_evidence( + tmp_path: Path, + monkeypatch, + mutation: str, + message: str, +) -> None: + module = _load_packet_module() + inputs = _write_inputs(module, tmp_path, monkeypatch) + + if mutation == "solver": + path = inputs["fracture_capture_manifest"] + data = json.loads(path.read_text()) + data["resolved_solver_identity"]["solver"] = "public_avbd" + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "capture_source": + path = inputs["fracture_capture_manifest"] + data = json.loads(path.read_text()) + data["capture_source_provenance"]["digest"] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "fracture": + _mutate_latest_capture( + inputs["fracture_capture_manifest"], + lambda event: event["metrics"]["outcome"].__setitem__( + "broken_joints", + 6, + ), + ) + elif mutation == "retained_residual": + _mutate_latest_capture( + inputs["collapse_capture_manifest"], + lambda event: event["metrics"]["outcome"].__setitem__( + "rms_outside_impact_unbroken_joint_angular_residual_radians", + 0.1, + ), + ) + elif mutation == "benchmark": + path = inputs["benchmark_json"] + data = json.loads(path.read_text()) + data["benchmarks"] = [ + row + for row in data["benchmarks"] + if not row["run_name"].startswith("BM_SequentialImpulse") + ] + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "linked_hash": + path = inputs["avbd_vbd_packet"] + data = json.loads(path.read_text()) + data["benchmark"]["json_sha256"] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "linked_outcome": + path = inputs["avbd_vbd_packet"] + data = json.loads(path.read_text()) + data["visual_evidence"]["retention"]["scene_metrics"]["outcome"][ + "status" + ] = "fail" + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "linked_source": + path = inputs["avbd_vbd_packet"] + data = json.loads(path.read_text()) + data["source_provenance"]["digest"] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "nested_link_hash": + path = inputs["avbd_vbd_packet"] + data = json.loads(path.read_text()) + data["linked_avbd_evidence"]["sha256"] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "nested_link_source": + path = inputs["avbd_vbd_packet"] + data = json.loads(path.read_text()) + nested_path = path.parent / data["linked_avbd_evidence"]["file"] + nested = json.loads(nested_path.read_text()) + nested["source_provenance"]["digest"] = "0" * 64 + nested_path.write_text(json.dumps(nested), encoding="utf-8") + data["linked_avbd_evidence"]["sha256"] = _sha256(nested_path) + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "linked_review": + path = inputs["avbd_vbd_packet"] + data = json.loads(path.read_text()) + data["visual_evidence"]["semantic_review"]["inspected_images"][2][ + "sha256" + ] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "limitations": + path = inputs["visual_review_json"] + data = json.loads(path.read_text()) + data["not_proven_and_limitations"] = ( + "Exact unpublished constants, CUDA, and published/source timing " + "remain unproven." + ) + path.write_text(json.dumps(data), encoding="utf-8") + + with pytest.raises( + ( + module.AvbdPaperSequentialImpulseComparisonPacketError, + module.comparison.AvbdPaperVbdComparisonPacketError, + module.shared.AvbdPaperBreakableWallPacketError, + ), + match=message, + ): + module.make_packet(**inputs) diff --git a/python/tests/unit/test_write_avbd_paper_vbd_comparison_packet.py b/python/tests/unit/test_write_avbd_paper_vbd_comparison_packet.py index f856a37e41e74..f14be70ed4e7b 100644 --- a/python/tests/unit/test_write_avbd_paper_vbd_comparison_packet.py +++ b/python/tests/unit/test_write_avbd_paper_vbd_comparison_packet.py @@ -80,6 +80,109 @@ def _source_provenance(module) -> dict[str, Any]: } +def _empty_joint_evidence( + *, + maximum_angular: float, + maximum_linear: float, + rms_angular: float, + rms_linear: float, +) -> dict[str, Any]: + return { + "broken_joint_identity_count": 0, + "broken_joint_ids_sha256": sha256().hexdigest(), + "broken_joint_impact_region_counts": [0, 0, 0], + "broken_joint_records": [], + "broken_joints_outside_impact_regions": 0, + "joint_residuals_finite": True, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": ( + maximum_angular + ), + "maximum_outside_impact_unbroken_joint_linear_residual": maximum_linear, + "maximum_unbroken_joint_angular_residual_radians": maximum_angular, + "maximum_unbroken_joint_linear_residual": maximum_linear, + "outside_impact_unbroken_joint_residual_count": 484, + "rms_outside_impact_unbroken_joint_angular_residual_radians": ( + rms_angular + ), + "rms_outside_impact_unbroken_joint_linear_residual": rms_linear, + "rms_unbroken_joint_angular_residual_radians": rms_angular, + "rms_unbroken_joint_linear_residual": rms_linear, + "unbroken_joint_residual_count": 712, + } + + +def _avbd_joint_records() -> list[dict[str, Any]]: + impact_indices = [0] * 18 + [1] * 44 + [2] * 13 + records = [] + for index in range(154): + within_region = index < len(impact_indices) + records.append( + { + "angular_residual_radians": 0.01 + index * 0.0001, + "child": { + "body": "brick", + "column": (index + 1) % 21, + "row": (index // 21) % 12, + }, + "id": f"fixture_joint_{index:03d}", + "initial_anchor": [ + float(index % 21), + 0.0, + float((index // 21) % 12), + ], + "kind": "horizontal" if index % 2 == 0 else "vertical", + "linear_residual": 0.001 + index * 0.000001, + "nearest_impact_distance": 0.5 if within_region else 2.0, + "nearest_impact_index": ( + impact_indices[index] if within_region else index % 3 + ), + "parent": { + "body": "brick", + "column": index % 21, + "row": (index // 21) % 12, + }, + "within_impact_band": within_region, + "within_impact_region": within_region, + } + ) + return records + + +def _joint_ids_sha256(records: list[dict[str, Any]]) -> str: + digest = sha256() + for joint_id in sorted(record["id"] for record in records): + encoded = joint_id.encode("utf-8") + digest.update(struct.pack(" dict[str, Any]: + records = _avbd_joint_records() + return dict(module.shared.EXPECTED_OUTCOMES[frame]) | { + "broken_joint_identity_count": 154, + "broken_joint_ids_sha256": _joint_ids_sha256(records), + "broken_joint_impact_region_counts": [18, 44, 13], + "broken_joint_records": records, + "broken_joints_outside_impact_regions": 79, + "frame": frame, + "joint_residuals_finite": True, + "last_step_iterations": 20, + "maximum_outside_impact_unbroken_joint_angular_residual_radians": ( + 0.0003 + ), + "maximum_outside_impact_unbroken_joint_linear_residual": 0.001, + "maximum_unbroken_joint_angular_residual_radians": 0.0003, + "maximum_unbroken_joint_linear_residual": 0.001, + "outside_impact_unbroken_joint_residual_count": 405, + "rms_outside_impact_unbroken_joint_angular_residual_radians": 0.0001, + "rms_outside_impact_unbroken_joint_linear_residual": 0.0005, + "rms_unbroken_joint_angular_residual_radians": 0.0001, + "rms_unbroken_joint_linear_residual": 0.0005, + "unbroken_joint_residual_count": 558, + } + + def _outcome(module, frame: int) -> dict[str, Any]: generic = { "ball_positions": [[0.0, 1.0, 2.0]] * 3, @@ -103,21 +206,30 @@ def _outcome(module, frame: int) -> dict[str, Any]: "total_retained_fraction": 1.0, "unbroken_joints": 712, "world_time": frame / 60.0, - } - if frame == 14: + } | _empty_joint_evidence( + maximum_angular=0.0, + maximum_linear=0.0, + rms_angular=0.0, + rms_linear=0.0, + ) + if frame == 18: return generic | { - "bent_brick_count": 131, - "contact_count": 26, + "bent_brick_count": 120, "evaluated": True, - "max_brick_displacement": 0.22961765643338428, - "maximum_wall_normal_displacement": 0.22961612378147464, - "rms_wall_normal_displacement": 0.12274369318326252, + "max_brick_displacement": 0.13266478096099812, + "maximum_wall_normal_displacement": 0.13266478096099812, + "rms_wall_normal_displacement": 0.06264039944471685, "status": "pass", "threshold_checks": dict( - module.CHECKPOINTS[14]["threshold_checks"] + module.CHECKPOINTS[18]["threshold_checks"] ), "thresholds_pass": True, - } + } | _empty_joint_evidence( + maximum_angular=0.013, + maximum_linear=0.016, + rms_angular=0.003, + rms_linear=0.004, + ) if frame == 120: return generic | { "checkpoint": "retention", @@ -130,14 +242,19 @@ def _outcome(module, frame: int) -> dict[str, Any]: module.CHECKPOINTS[120]["threshold_checks"] ), "thresholds_pass": True, - } + } | _empty_joint_evidence( + maximum_angular=0.0013, + maximum_linear=0.011, + rms_angular=0.00012, + rms_linear=0.0033, + ) return generic def _metrics(module, frame: int) -> dict[str, Any]: return { "ball_count": 3, - "break_force": 8500.0, + "break_force": module.BREAK_FORCE, "breakable_joints": 712, "brick_count": 252, "collision_shapes": 256, @@ -180,10 +297,10 @@ def _metrics(module, frame: int) -> dict[str, Any]: "time_step_ms": 1000.0 / 60.0, "view_report": { "camera": { - "azimuth": -1.5707963267948966, - "distance": 11.0, - "elevation": 0.0, - "target": [0.0, 0.0, 1.4], + "azimuth": module.shared.CAMERA_AZIMUTH, + "distance": module.shared.CAMERA_DISTANCE, + "elevation": module.shared.CAMERA_ELEVATION, + "target": list(module.shared.CAMERA_TARGET), }, "focus": list(module.VIEW_FOCUS), "issues": [], @@ -236,9 +353,11 @@ def _write_capture( "video": None, }, "camera": { - "distance": 11.0, - "target": [0.0, 0.0, 1.4], - "view": "front", + "azimuth": module.math.degrees(module.shared.CAMERA_AZIMUTH), + "distance": module.shared.CAMERA_DISTANCE, + "elevation": module.math.degrees(module.shared.CAMERA_ELEVATION), + "target": list(module.shared.CAMERA_TARGET), + "view": module.shared.CAMERA_PRESET, }, "capture": { "converted_frames": frame, @@ -246,6 +365,16 @@ def _write_capture( "requested_frames": frame, "width": 8, }, + "capture_artifact_provenance": { + "algorithm": module.shared.CAPTURE_ARTIFACT_PROVENANCE_ALGORITHM, + "scene_metrics_events_sha256": _sha256(metrics_log), + "screenshot_sha256": _sha256(screenshot), + }, + "capture_source_provenance": ( + module.shared.compute_capture_source_provenance( + module.shared.REPO_ROOT + ) + ), "capture_label": label, "force_drag": None, "resolved_solver_identity": { @@ -274,7 +403,7 @@ def _write_capture( "metadata": { "frame": str(frame), "scene": module.SCENE_ID, - "view": "front", + "view": module.CAMERA_VIEW, }, "pass": True, "schema_version": "dart.image_verdict/v1", @@ -347,6 +476,15 @@ def _write_benchmark(module, tmp_path: Path) -> Path: { "benchmarks": rows, "context": { + "benchmark_source_sha256": _sha256( + module.shared.REPO_ROOT + / module.shared.BENCHMARK_SOURCE_PATH + ), + "capture_source_provenance_digest": ( + module.shared.compute_capture_source_provenance( + module.shared.REPO_ROOT + )["digest"] + ), "executable": ( "build/default/cpp/Release/bin/" "bm_avbd_rigid_fixed_joint" @@ -366,10 +504,13 @@ def _write_benchmark(module, tmp_path: Path) -> Path: def _write_inputs(module, tmp_path: Path, monkeypatch) -> dict[str, Path]: + module.shared.OUTCOME_ORACLE["expected_broken_joint_ids_sha256"] = ( + _joint_ids_sha256(_avbd_joint_records()) + ) bend_manifest, bend_verdict = _write_capture( module, tmp_path, - 14, + 18, "bend", ) retention_manifest, retention_verdict = _write_capture( @@ -406,7 +547,7 @@ def _write_inputs(module, tmp_path: Path, monkeypatch) -> dict[str, Path]: "inspected_images": [ { "file": str(bend_screenshot), - "role": "bend_frame_14", + "role": "bend_frame_18", "sha256": _sha256(bend_screenshot), }, { @@ -446,11 +587,7 @@ def _write_inputs(module, tmp_path: Path, monkeypatch) -> dict[str, Path]: "image_verdict": {"pass": True, "sha256": "4" * 64}, "scene_metrics": { "frame": 60, - "outcome": dict(module.shared.EXPECTED_OUTCOMES[60]) - | { - "frame": 60, - "last_step_iterations": 20, - }, + "outcome": _linked_avbd_outcome(module, 60), "outcome_oracle": dict(module.shared.OUTCOME_ORACLE), "scene_spec_fingerprint": "0123456789abcdef", }, @@ -460,11 +597,7 @@ def _write_inputs(module, tmp_path: Path, monkeypatch) -> dict[str, Path]: "image_verdict": {"pass": True, "sha256": "5" * 64}, "scene_metrics": { "frame": 120, - "outcome": dict(module.shared.EXPECTED_OUTCOMES[120]) - | { - "frame": 120, - "last_step_iterations": 20, - }, + "outcome": _linked_avbd_outcome(module, 120), "outcome_oracle": dict(module.shared.OUTCOME_ORACLE), "scene_spec_fingerprint": "0123456789abcdef", }, @@ -550,7 +683,7 @@ def test_make_packet_records_matched_vbd_and_avbd_evidence( packet = module.make_packet(**inputs) - assert packet["schema_version"] == 3 + assert packet["schema_version"] == module.AVBD_PACKET_SCHEMA_VERSION assert packet["resolved_solver_identity"]["rigid_contact_solver"] == "vbd" assert packet["linked_avbd_evidence"]["resolved_solver_identity"][ "rigid_contact_solver" @@ -559,7 +692,7 @@ def test_make_packet_records_matched_vbd_and_avbd_evidence( packet["visual_evidence"]["bend"]["scene_metrics"]["outcome"][ "bent_brick_count" ] - == 131 + == 120 ) assert packet["visual_evidence"]["retention"]["scene_metrics"]["outcome"][ "unbroken_joints" @@ -588,7 +721,9 @@ def test_make_packet_records_matched_vbd_and_avbd_evidence( ("mutation", "message"), [ ("solver", "capture resolved_solver_identity"), + ("capture_source", "capture source provenance digest"), ("fracture", "broken_joints"), + ("joint_residual", "retained-joint linear residual exceeds"), ("benchmark", "expected aggregates"), ("linked_hash", "linked AVBD benchmark JSON hash"), ("linked_outcome", "frame 120 outcome status"), @@ -611,6 +746,11 @@ def test_make_packet_fails_closed_on_mismatched_comparison_evidence( data = json.loads(path.read_text()) data["resolved_solver_identity"]["solver"] = "public_avbd" path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "capture_source": + path = inputs["bend_capture_manifest"] + data = json.loads(path.read_text()) + data["capture_source_provenance"]["digest"] = "0" * 64 + path.write_text(json.dumps(data), encoding="utf-8") elif mutation == "fracture": path = inputs["bend_capture_manifest"] data = json.loads(path.read_text()) @@ -638,6 +778,26 @@ def test_make_packet_fails_closed_on_mismatched_comparison_evidence( if not row["run_name"].startswith("BM_Avbd") ] path.write_text(json.dumps(data), encoding="utf-8") + elif mutation == "joint_residual": + path = inputs["bend_capture_manifest"] + data = json.loads(path.read_text()) + data["scene_metrics"]["latest"]["metrics"]["outcome"][ + "maximum_unbroken_joint_linear_residual" + ] = 0.03 + path.write_text(json.dumps(data), encoding="utf-8") + events_path = Path(data["artifacts"]["scene_metrics_events"]) + events = [ + json.loads(line) + for line in events_path.read_text().splitlines() + if line + ] + events[-1]["metrics"]["outcome"][ + "maximum_unbroken_joint_linear_residual" + ] = 0.03 + events_path.write_text( + "".join(json.dumps(event) + "\n" for event in events), + encoding="utf-8", + ) elif mutation == "linked_hash": path = inputs["avbd_packet"] data = json.loads(path.read_text()) diff --git a/scripts/avbd_packet_schema.py b/scripts/avbd_packet_schema.py index 79c6e015dc3b7..bd46b058c6fda 100644 --- a/scripts/avbd_packet_schema.py +++ b/scripts/avbd_packet_schema.py @@ -24,7 +24,88 @@ # Current AVBD packet schema version. Packets written at this version or # newer must carry RESOLVED_SOLVER_IDENTITY_KEY and record how rigid contact # selected the reported solver family. -AVBD_PACKET_SCHEMA_VERSION = 3 +AVBD_PACKET_SCHEMA_VERSION = 4 + +# Exact ordered source-provenance contracts for the current Figure 13 packet +# chain. Writers and the committed-packet checker consume this one mapping so +# deleting, adding, or reordering a bound source cannot be hidden by +# recomputing the packet digest. +PAPER_PACKET_SOURCE_PATHS: dict[str, tuple[str, ...]] = { + "avbd-paper-breakable-wall-packet.json": ( + "dart/gui/view_quality.cpp", + "dart/gui/view_quality.hpp", + "dart/simulation/world.cpp", + "dart/simulation/world.hpp", + "dart/simulation/world_options.hpp", + "dart/simulation/comps/joint.hpp", + "dart/simulation/compute/detail/world_step_stages.hpp", + "dart/simulation/compute/rigid_body_contact_stage.cpp", + "dart/simulation/detail/rigid_pair_constraint.hpp", + "dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp", + "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp", + "dart/simulation/detail/world_step_schedule.hpp", + "python/dartpy/_view_quality.py", + "python/examples/demos/scenes/avbd_paper_breakable_wall.py", + "python/tests/integration/test_demos_cycle.py", + "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", + "tests/unit/simulation/world/test_world.cpp", + "scripts/avbd_packet_schema.py", + "scripts/capture_py_demo.py", + "scripts/capture_source_provenance.py", + "scripts/write_avbd_paper_breakable_wall_packet.py", + ), + "avbd-paper-vbd-comparison-packet.json": ( + "dart/simulation/world.cpp", + "dart/simulation/world.hpp", + "dart/simulation/world_options.hpp", + "dart/simulation/comps/joint.hpp", + "dart/simulation/compute/detail/world_step_stages.hpp", + "dart/simulation/compute/rigid_body_contact_stage.cpp", + "dart/simulation/detail/rigid_pair_constraint.hpp", + "dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp", + "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp", + "dart/simulation/detail/world_step_schedule.hpp", + "python/examples/demos/scenes/avbd_paper_breakable_wall.py", + "python/examples/demos/scenes/vbd_paper_breakable_wall.py", + "python/tests/integration/test_demos_cycle.py", + "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", + "tests/unit/simulation/world/test_world.cpp", + "scripts/avbd_packet_schema.py", + "scripts/capture_py_demo.py", + "scripts/capture_source_provenance.py", + "scripts/write_avbd_paper_vbd_comparison_packet.py", + ), + "avbd-paper-sequential-impulse-comparison-packet.json": ( + "dart/simulation/world.cpp", + "dart/simulation/world.hpp", + "dart/simulation/world_options.hpp", + "dart/simulation/comps/joint.hpp", + "dart/simulation/compute/detail/world_step_stages.hpp", + "dart/simulation/compute/rigid_body_contact_stage.cpp", + "dart/simulation/detail/rigid_pair_constraint.hpp", + "dart/simulation/detail/world_step_schedule.hpp", + "python/examples/demos/scenes/avbd_paper_breakable_wall.py", + "python/examples/demos/scenes/vbd_paper_breakable_wall.py", + "python/examples/demos/scenes/sequential_impulse_paper_breakable_wall.py", + "python/tests/integration/test_demos_cycle.py", + "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", + "tests/unit/simulation/contact/test_boxed_lcp_contact.cpp", + "tests/unit/simulation/world/test_world.cpp", + "tests/unit/simulation/world/test_world_resolved_configuration.cpp", + "tests/test_avbd_packet_schema.py", + "scripts/avbd_packet_schema.py", + "scripts/capture_py_demo.py", + "scripts/capture_source_provenance.py", + "scripts/write_avbd_paper_breakable_wall_packet.py", + "scripts/write_avbd_paper_vbd_comparison_packet.py", + "scripts/write_avbd_paper_sequential_impulse_comparison_packet.py", + ), +} + +# Schema version 4 narrowed 'contact_solver_method' point-joint identities to +# 'none' or 'sequential_impulse'. Older packets legitimately recorded 'avbd' +# there because public pair constraints then ran the private AVBD projection. +CONTACT_METHOD_POINT_JOINT_MIN_SCHEMA_VERSION = 4 # First schema version that requires the resolved-solver-identity field. SOLVER_IDENTITY_MIN_SCHEMA_VERSION = 2 @@ -49,7 +130,12 @@ # The solver family that resolved rigid-body point-joint/motor/distance-spring # rows; joint-free scenes record "none". -ALLOWED_RIGID_POINT_JOINT_SOLVERS = ("avbd", "none", "vbd") +ALLOWED_RIGID_POINT_JOINT_SOLVERS = ( + "avbd", + "none", + "sequential_impulse", + "vbd", +) ALLOWED_RIGID_CONTACT_SELECTIONS = ( "body_opt_in", @@ -73,6 +159,11 @@ def packet_schema_version_errors( return [f"{packet_name}: schema_version must be an integer"] if version < 1: return [f"{packet_name}: schema_version must be >= 1"] + if version > AVBD_PACKET_SCHEMA_VERSION: + return [ + f"{packet_name}: schema_version {version} is newer than the " + f"supported version {AVBD_PACKET_SCHEMA_VERSION}" + ] return [] @@ -155,6 +246,15 @@ def resolved_solver_identity_errors( f"{packet_name}: VBD solver identities require schema_version " f"{RIGID_CONTACT_SELECTION_MIN_SCHEMA_VERSION} or newer" ) + if ( + version < RIGID_CONTACT_SELECTION_MIN_SCHEMA_VERSION + and rigid_point_joint_solver == "sequential_impulse" + ): + errors.append( + f"{packet_name}: Sequential Impulse point-joint solver identities " + "require schema_version " + f"{RIGID_CONTACT_SELECTION_MIN_SCHEMA_VERSION} or newer" + ) if selection is None: if ( isinstance(emplaced, bool) @@ -184,8 +284,8 @@ def resolved_solver_identity_errors( errors.append( f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." "rigid_contact_selection 'world_solver_family' requires " - "rigid_contact_solver 'vbd' or 'avbd' without a private body " - "config" + "rigid_contact_solver 'avbd' or 'vbd' " + "without a private body config" ) if rigid_point_joint_solver not in ("none", rigid_contact_solver): errors.append( @@ -205,11 +305,20 @@ def resolved_solver_identity_errors( "a boxed_lcp or sequential_impulse contact solver without " "a private AVBD body config" ) - if identity.get("rigid_point_joint_solver") == "vbd": + if version >= CONTACT_METHOD_POINT_JOINT_MIN_SCHEMA_VERSION: + if rigid_point_joint_solver not in ("none", "sequential_impulse"): + errors.append( + f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." + "rigid_contact_selection 'contact_solver_method' requires " + "rigid_point_joint_solver 'none' or 'sequential_impulse' " + "from schema_version " + f"{CONTACT_METHOD_POINT_JOINT_MIN_SCHEMA_VERSION} onward" + ) + elif rigid_point_joint_solver == "vbd": errors.append( f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." - "rigid_point_joint_solver 'vbd' requires " - "rigid_contact_selection 'world_solver_family'" + "rigid_contact_selection 'contact_solver_method' cannot carry " + "rigid_point_joint_solver 'vbd'" ) elif selection == "not_applicable" and rigid_contact_solver != "none": errors.append( diff --git a/scripts/capture_py_demo.py b/scripts/capture_py_demo.py index df45886e16c56..4a06b5b0c439f 100644 --- a/scripts/capture_py_demo.py +++ b/scripts/capture_py_demo.py @@ -19,6 +19,15 @@ import time import zlib +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from capture_source_provenance import ( # noqa: E402 + capture_artifact_provenance, + compute_capture_source_provenance, +) + def read_ppm(path: pathlib.Path) -> tuple[int, int, bytes]: data = path.read_bytes() @@ -4287,6 +4296,7 @@ def main(argv: list[str] | None = None) -> int: "width": args.width, "height": args.height, } + capture_source_provenance = compute_capture_source_provenance(_repo_root()) workflow_guidance = _rigid_workflow_guidance_by_scene().get(args.scene) visual_evidence: dict[str, object] = { "first_frame": None, @@ -4325,6 +4335,8 @@ def main(argv: list[str] | None = None) -> int: }, "camera": camera_options, "capture": capture_metadata, + "capture_artifact_provenance": None, + "capture_source_provenance": capture_source_provenance, "resolved_solver_identity": None, "scene_metrics": None, "scene_metadata": None, @@ -4381,6 +4393,18 @@ def main(argv: list[str] | None = None) -> int: "dropped_warmup_frames": dropped_warmup_frames, "required": require_docked_ui, } + final_capture_source_provenance = compute_capture_source_provenance(_repo_root()) + if final_capture_source_provenance["digest"] != capture_source_provenance["digest"]: + raise SystemExit( + "capture source state changed while the demo was running; " + "discarding the evidence" + ) + manifest_payload["capture_artifact_provenance"] = capture_artifact_provenance( + scene_metrics_events=( + scene_metrics_events if scene_metrics_events.is_file() else None + ), + screenshot=screenshot_png, + ) _write_json(manifest, manifest_payload) print(f"screenshot: {screenshot_png}") diff --git a/scripts/capture_source_provenance.py b/scripts/capture_source_provenance.py new file mode 100644 index 0000000000000..16da0052c5f3e --- /dev/null +++ b/scripts/capture_source_provenance.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Deterministic source and artifact provenance for visual captures.""" + +from __future__ import annotations + +import argparse +import json +import os +import struct +import subprocess +import sys +from hashlib import sha256 +from pathlib import Path +from typing import Any + +CAPTURE_SOURCE_PROVENANCE_ALGORITHM = "sha256-length-prefixed-capture-source-tree-v1" +CAPTURE_ARTIFACT_PROVENANCE_ALGORITHM = "sha256-v1" +CAPTURE_SOURCE_ROOTS = ( + "CMakeLists.txt", + "cmake", + "dart", + "pixi.lock", + "pixi.toml", + "pyproject.toml", + "python/CMakeLists.txt", + "python/dartpy", + "python/examples/demos", + "scripts/capture_py_demo.py", + "scripts/capture_source_provenance.py", +) + + +class CaptureSourceProvenanceError(RuntimeError): + """Raised when the capture source snapshot cannot be resolved.""" + + +def sha256_file(path: Path) -> str: + digest = sha256() + try: + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + raise CaptureSourceProvenanceError( + f"cannot hash capture artifact {path}: {exc}" + ) from exc + return digest.hexdigest() + + +def _run_git(repo_root: Path, *args: str) -> bytes: + try: + result = subprocess.run( + ["git", "-C", str(repo_root), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + except (OSError, subprocess.CalledProcessError) as exc: + detail = "" + if isinstance(exc, subprocess.CalledProcessError): + detail = exc.stderr.decode("utf-8", errors="replace").strip() + suffix = f": {detail}" if detail else "" + raise CaptureSourceProvenanceError( + f"cannot resolve capture source state with git{suffix}" + ) from exc + return result.stdout + + +def _capture_source_paths(repo_root: Path) -> list[str]: + output = _run_git( + repo_root, + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + "--", + *CAPTURE_SOURCE_ROOTS, + ) + paths = sorted({os.fsdecode(encoded) for encoded in output.split(b"\0") if encoded}) + if not paths: + raise CaptureSourceProvenanceError( + "capture source state did not resolve any files" + ) + return paths + + +def _path_payload(path: Path) -> tuple[bytes, bytes]: + if path.is_symlink(): + try: + return b"L", os.fsencode(os.readlink(path)) + except OSError as exc: + raise CaptureSourceProvenanceError( + f"cannot read capture source symlink {path}: {exc}" + ) from exc + if not path.exists(): + raise CaptureSourceProvenanceError( + f"capture source path is listed by git but missing from the " + f"working tree: {path}" + ) + if not path.is_file(): + raise CaptureSourceProvenanceError( + f"capture source path is not a regular file: {path}" + ) + try: + return b"F", path.read_bytes() + except OSError as exc: + raise CaptureSourceProvenanceError( + f"cannot read capture source file {path}: {exc}" + ) from exc + + +def compute_capture_source_provenance(repo_root: Path) -> dict[str, Any]: + """Hash the source tree that can affect a Python demo capture.""" + + repo_root = repo_root.resolve() + digest = sha256() + for root in CAPTURE_SOURCE_ROOTS: + encoded_root = root.encode("utf-8") + digest.update(struct.pack(" dict[str, Any]: + return { + "algorithm": CAPTURE_ARTIFACT_PROVENANCE_ALGORITHM, + "scene_metrics_events_sha256": ( + sha256_file(scene_metrics_events) + if scene_metrics_events is not None and scene_metrics_events.is_file() + else None + ), + "screenshot_sha256": sha256_file(screenshot), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo-root", + type=Path, + default=Path(__file__).resolve().parents[1], + ) + parser.add_argument( + "--digest-only", + action="store_true", + help="print only the capture source digest", + ) + args = parser.parse_args(sys.argv[1:] if argv is None else argv) + provenance = compute_capture_source_provenance(args.repo_root) + if args.digest_only: + print(provenance["digest"]) + else: + print(json.dumps(provenance, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_avbd_packets.py b/scripts/check_avbd_packets.py index b4aee8df3544e..9879af26a7f1c 100644 --- a/scripts/check_avbd_packets.py +++ b/scripts/check_avbd_packets.py @@ -12,8 +12,12 @@ from __future__ import annotations import argparse +import hashlib import json +import math +import struct import sys +from collections.abc import Mapping from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent @@ -22,19 +26,57 @@ from avbd_packet_schema import ( # noqa: E402 AVBD_PACKET_SCHEMA_VERSION, + PAPER_PACKET_SOURCE_PATHS, packet_schema_version_errors, resolved_solver_identity_errors, ) +__all__ = [ + "AVBD_PACKET_SCHEMA_VERSION", + "packet_errors", +] +from capture_source_provenance import ( # noqa: E402 + CAPTURE_SOURCE_PROVENANCE_ALGORITHM, + CaptureSourceProvenanceError, + compute_capture_source_provenance, +) + REPO_ROOT = SCRIPT_DIR.parent DEFAULT_PACKET_DIR = REPO_ROOT / "docs" / "plans" / "104-vertex-block-descent-solver" PACKET_GLOB = "avbd-*-packet.json" +SOURCE_PROVENANCE_ALGORITHM = "sha256-length-prefixed-path-and-content-v1" +LINKED_PACKET_KEYS = ("linked_avbd_evidence", "linked_avbd_vbd_evidence") +BENCHMARK_SOURCE_PATH = Path("tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp") +PAPER_CAPTURE_ROLES = { + "avbd-paper-breakable-wall-packet.json": ("impact", "outcome"), + "avbd-paper-vbd-comparison-packet.json": ("bend", "retention"), + "avbd-paper-sequential-impulse-comparison-packet.json": ( + "fracture", + "collapse", + ), +} +PAPER_PACKET_SELF_METHOD_NAMES = { + "avbd-paper-breakable-wall-packet.json": "avbd", + "avbd-paper-vbd-comparison-packet.json": "vbd", + "avbd-paper-sequential-impulse-comparison-packet.json": ("sequential_impulse"), +} +PAPER_REQUIRED_MEDIAN_RATIO_KEYS = { + "avbd-paper-vbd-comparison-packet.json": frozenset( + {"vbd_to_avbd_median_cpu_cost_ratio"} + ), + "avbd-paper-sequential-impulse-comparison-packet.json": frozenset( + { + "sequential_impulse_to_avbd_median_cpu_cost_ratio", + "sequential_impulse_to_vbd_median_cpu_cost_ratio", + } + ), +} # Packets committed before the resolved-solver-identity contract # (WP-091.1). They remain readable at schema_version 1; their # sequential-impulse contact rows are relabeled in prose instead of -# being rewritten. Do not add new packets here: new packet files must -# be written at AVBD_PACKET_SCHEMA_VERSION with a recorded identity. +# being rewritten. Do not add new packets here: new packet files must use the +# current AVBD_PACKET_SCHEMA_VERSION with a recorded identity. LEGACY_IDENTITY_EXEMPT_PACKETS = frozenset( { "avbd-articulated-breakable-joint-packet.json", @@ -88,6 +130,33 @@ } ) +# These schema-version 3 packets were committed before schema version 4 moved +# public hard pair rows from the private AVBD compatibility projection into the +# Sequential Impulse family. Their writers now emit the current schema when +# regenerated. Keep only these exact historical names readable; a new filename +# must use AVBD_PACKET_SCHEMA_VERSION so it cannot claim the retired v3 solver +# identity contract. +LEGACY_PRE_SI_PAIR_ROW_PACKETS = frozenset( + { + "avbd-articulated-compliant-fracture-packet.json", + "avbd-articulated-compliant-joints-packet.json", + "avbd-articulated-compliant-motors-packet.json", + "avbd-breakable-joint-scale-packet.json", + "avbd-breakable-motor-scale-packet.json", + "avbd-friction-coefficient-sweep-packet.json", + "avbd-paper-scale-high-ratio-iteration-sweep-packet.json", + } +) + +# Pin each historical filename to the one legacy version it was committed +# with. A legacy filename may move directly to the current schema when its +# packet is regenerated, but it may not claim any other retired contract. +LEGACY_PACKET_SCHEMA_VERSIONS = { + **{name: 1 for name in LEGACY_IDENTITY_EXEMPT_PACKETS}, + **{name: 3 for name in LEGACY_PRE_SI_PAIR_ROW_PACKETS}, +} +LEGACY_SCHEMA_EXEMPT_PACKETS = frozenset(LEGACY_PACKET_SCHEMA_VERSIONS) + def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) @@ -108,7 +177,249 @@ def parse_args(argv: list[str]) -> argparse.Namespace: return parser.parse_args(argv) -def packet_errors(path: Path) -> list[str]: +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _safe_relative_path(value: object) -> Path | None: + if not isinstance(value, str) or not value: + return None + path = Path(value) + if path.is_absolute() or ".." in path.parts or path.as_posix() != value: + return None + return path + + +def _source_provenance_errors( + packet: Mapping[str, object], packet_name: str +) -> list[str]: + provenance = packet.get("source_provenance") + if provenance is None: + if packet_name in PAPER_CAPTURE_ROLES: + return [ + f"{packet_name}: source_provenance must be an object for a " + "current paper packet" + ] + return [] + if not isinstance(provenance, Mapping): + return [f"{packet_name}: source_provenance must be an object"] + + errors: list[str] = [] + if provenance.get("algorithm") != SOURCE_PROVENANCE_ALGORITHM: + errors.append( + f"{packet_name}: source_provenance.algorithm must be " + f"{SOURCE_PROVENANCE_ALGORITHM!r}" + ) + files = provenance.get("files") + if not isinstance(files, list) or not files: + return errors + [ + f"{packet_name}: source_provenance.files must be a non-empty list" + ] + + required_paths = PAPER_PACKET_SOURCE_PATHS.get(packet_name) + actual_paths = [ + entry.get("path") if isinstance(entry, Mapping) else None for entry in files + ] + if required_paths is not None and actual_paths != list(required_paths): + errors.append( + f"{packet_name}: source_provenance.files paths must exactly match " + "the canonical ordered paper-packet source contract" + ) + + combined = hashlib.sha256() + seen: set[str] = set() + for index, entry in enumerate(files): + label = f"{packet_name}: source_provenance.files[{index}]" + if not isinstance(entry, Mapping): + errors.append(f"{label} must be an object") + continue + relative = _safe_relative_path(entry.get("path")) + if relative is None: + errors.append(f"{label}.path must be a safe repository-relative path") + continue + relative_text = relative.as_posix() + if relative_text in seen: + errors.append(f"{label}.path duplicates {relative_text!r}") + continue + seen.add(relative_text) + source_path = REPO_ROOT / relative + try: + payload = source_path.read_bytes() + except FileNotFoundError: + errors.append(f"{label}.path does not exist: {relative_text}") + continue + + current_hash = hashlib.sha256(payload).hexdigest() + if entry.get("sha256") != current_hash: + errors.append( + f"{label}.sha256 drifted for {relative_text}: expected " + f"{current_hash}" + ) + encoded_path = relative_text.encode("utf-8") + combined.update(struct.pack(" list[str]: + roles = PAPER_CAPTURE_ROLES.get(packet_name) + if roles is None: + return [] + + try: + current = compute_capture_source_provenance(REPO_ROOT) + except CaptureSourceProvenanceError as exc: + return [ + f"{packet_name}: cannot resolve current capture source provenance: " + f"{exc}" + ] + + errors: list[str] = [] + visual = packet.get("visual_evidence") + if not isinstance(visual, Mapping): + return [f"{packet_name}: visual_evidence must be an object"] + for role in roles: + capture = visual.get(role) + label = f"{packet_name}: visual_evidence.{role}.source_provenance" + if not isinstance(capture, Mapping): + errors.append(f"{packet_name}: visual_evidence.{role} must be an object") + continue + provenance = capture.get("source_provenance") + if not isinstance(provenance, Mapping): + errors.append(f"{label} must be an object") + continue + if provenance.get("algorithm") != CAPTURE_SOURCE_PROVENANCE_ALGORITHM: + errors.append( + f"{label}.algorithm must be " f"{CAPTURE_SOURCE_PROVENANCE_ALGORITHM!r}" + ) + for key in ("digest", "file_count", "roots"): + if provenance.get(key) != current[key]: + errors.append(f"{label}.{key} does not match current source state") + git_head = provenance.get("git_head") + if ( + not isinstance(git_head, str) + or len(git_head) != 40 + or any(character not in "0123456789abcdef" for character in git_head) + ): + errors.append(f"{label}.git_head must be a lowercase Git object ID") + + benchmark = packet.get("benchmark") + if not isinstance(benchmark, Mapping): + return errors + [f"{packet_name}: benchmark must be an object"] + benchmark_provenance = benchmark.get("source_provenance") + if not isinstance(benchmark_provenance, Mapping): + errors.append(f"{packet_name}: benchmark.source_provenance must be an object") + return errors + if ( + benchmark_provenance.get("capture_source_provenance_digest") + != current["digest"] + ): + errors.append( + f"{packet_name}: benchmark source capture digest does not match " + "current source state" + ) + try: + benchmark_source_hash = _sha256(REPO_ROOT / BENCHMARK_SOURCE_PATH) + except FileNotFoundError: + errors.append( + f"{packet_name}: benchmark source file not found: " + f"{BENCHMARK_SOURCE_PATH.as_posix()}" + ) + return errors + if benchmark_provenance.get("benchmark_source_sha256") != benchmark_source_hash: + errors.append( + f"{packet_name}: benchmark source hash does not match current " + "benchmark translation unit" + ) + + context = benchmark.get("context") + if not isinstance(context, Mapping): + errors.append(f"{packet_name}: benchmark.context must be an object") + return errors + for key in ( + "capture_source_provenance_digest", + "benchmark_source_sha256", + ): + if context.get(key) != benchmark_provenance.get(key): + errors.append( + f"{packet_name}: benchmark.context.{key} does not match " + "benchmark.source_provenance" + ) + return errors + + +def _linked_packet_errors( + packet: Mapping[str, object], + path: Path, + visited: set[Path], +) -> list[str]: + errors: list[str] = [] + for key in LINKED_PACKET_KEYS: + link = packet.get(key) + if link is None: + continue + if not isinstance(link, Mapping): + errors.append(f"{path.name}: {key} must be an object") + continue + relative = _safe_relative_path(link.get("file")) + if relative is None or len(relative.parts) != 1: + errors.append(f"{path.name}: {key}.file must name one sibling packet file") + continue + linked_path = (path.parent / relative).resolve() + try: + linked_hash = _sha256(linked_path) + except FileNotFoundError: + errors.append(f"{path.name}: {key}.file not found: {relative.as_posix()}") + continue + if link.get("sha256") != linked_hash: + errors.append( + f"{path.name}: {key}.sha256 drifted for {relative.as_posix()}" + ) + try: + linked_packet = json.loads(linked_path.read_text()) + except json.JSONDecodeError as exc: + errors.append(f"{path.name}: {key}.file has invalid JSON ({exc})") + continue + if not isinstance(linked_packet, dict): + errors.append(f"{path.name}: {key}.file must contain a JSON object") + continue + expected_provenance_digest = link.get("source_provenance_digest") + if expected_provenance_digest is not None: + linked_provenance = linked_packet.get("source_provenance") + actual_provenance_digest = ( + linked_provenance.get("digest") + if isinstance(linked_provenance, Mapping) + else None + ) + if expected_provenance_digest != actual_provenance_digest: + errors.append( + f"{path.name}: {key}.source_provenance_digest does not " + f"match {relative.as_posix()}" + ) + errors.extend(_packet_errors(linked_path, visited)) + return errors + + +def _packet_errors(path: Path, visited: set[Path]) -> list[str]: + path = path.resolve() + if path in visited: + return [] + visited.add(path) name = path.name if not path.is_file(): return [f"{name}: packet file not found at {path}"] @@ -124,20 +435,175 @@ def packet_errors(path: Path) -> list[str]: return errors version = packet["schema_version"] - if ( - version < AVBD_PACKET_SCHEMA_VERSION - and name not in LEGACY_IDENTITY_EXEMPT_PACKETS - ): + if version < AVBD_PACKET_SCHEMA_VERSION: + expected_legacy_version = LEGACY_PACKET_SCHEMA_VERSIONS.get(name) + if expected_legacy_version is None: + errors.append( + f"{name}: new AVBD packets must be written at schema_version " + f"{AVBD_PACKET_SCHEMA_VERSION} with a " + "recorded resolved_solver_identity (legacy allowlist covers " + "only packets committed before the current schema contract)" + ) + elif version != expected_legacy_version: + errors.append( + f"{name}: legacy allowlist requires schema_version " + f"{expected_legacy_version}, got {version}; otherwise regenerate " + f"the packet at current schema_version {AVBD_PACKET_SCHEMA_VERSION}" + ) + errors.extend(resolved_solver_identity_errors(packet, name)) + errors.extend(_source_provenance_errors(packet, name)) + errors.extend(_paper_capture_source_binding_errors(packet, name)) + errors.extend(_paper_benchmark_timing_errors(packet, name)) + errors.extend(_linked_packet_errors(packet, path, visited)) + return errors + + +_MEDIAN_CPU_TIME_KEY = "median_cpu_time_per_step_ns" +_MEDIAN_RATIO_SUFFIX = "_median_cpu_cost_ratio" +_MEDIAN_RATIO_RELATIVE_TOLERANCE = 1e-9 + + +def _finite_positive_number(value: object) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + and value > 0.0 + ) + + +def _paper_benchmark_timing_errors( + packet: Mapping[str, object], + packet_name: str, +) -> list[str]: + """Fail closed when a paper packet's recorded medians and ratios disagree. + + The raw benchmark JSON referenced by ``benchmark.json_sha256`` is a + temporary artifact, so the packet's own embedded medians are the only + durable denominators. Every recorded median must be a positive finite + number and every ``*_to_*_median_cpu_cost_ratio`` must equal the ratio of + the embedded medians it names. + """ + if packet_name not in PAPER_CAPTURE_ROLES: + return [] + + benchmark = packet.get("benchmark") + if not isinstance(benchmark, Mapping): + return [] + + errors: list[str] = [] + medians: dict[str, float] = {} + + def record_median(label: str, key: str, timing: object) -> None: + if not isinstance(timing, Mapping): + errors.append(f"{packet_name}: {label} must be an object") + return + median = timing.get(_MEDIAN_CPU_TIME_KEY) + if not _finite_positive_number(median): + errors.append( + f"{packet_name}: {label}.{_MEDIAN_CPU_TIME_KEY} must be a " + "positive finite number" + ) + return + medians[key] = float(median) + + self_method_name = PAPER_PACKET_SELF_METHOD_NAMES[packet_name] + timing = benchmark.get("timing") + if timing is not None: + record_median("benchmark.timing", self_method_name, timing) + method = benchmark.get("method") + if isinstance(method, Mapping) and method.get("timing") is not None: + record_median("benchmark.method.timing", self_method_name, method.get("timing")) + methods = benchmark.get("methods") + if isinstance(methods, Mapping): + for method_name, entry in methods.items(): + if isinstance(entry, Mapping): + record_median( + f"benchmark.methods.{method_name}.timing", + str(method_name), + entry.get("timing"), + ) + linked = packet.get("linked_avbd_vbd_evidence") + if isinstance(linked, Mapping): + linked_timings = linked.get("benchmark_method_timings") + if isinstance(linked_timings, Mapping): + for method_name, entry in linked_timings.items(): + record_median( + f"linked_avbd_vbd_evidence.benchmark_method_timings." + f"{method_name}", + str(method_name), + entry, + ) + + if self_method_name not in medians: errors.append( - f"{name}: new AVBD packets must be written at schema_version " - f"{AVBD_PACKET_SCHEMA_VERSION} with a recorded " - "resolved_solver_identity (legacy allowlist covers only packets " - "committed before WP-091.1)" + f"{packet_name}: benchmark must embed a positive finite " + f"{self_method_name} {_MEDIAN_CPU_TIME_KEY}" ) - errors.extend(resolved_solver_identity_errors(packet, name)) + + comparison = benchmark.get("comparison") + required_ratio_keys = PAPER_REQUIRED_MEDIAN_RATIO_KEYS.get(packet_name, frozenset()) + if not isinstance(comparison, Mapping): + if required_ratio_keys: + errors.append( + f"{packet_name}: benchmark.comparison must contain exactly " + f"the required median ratio keys {sorted(required_ratio_keys)!r}" + ) + return errors + actual_ratio_keys = { + key + for key in comparison + if isinstance(key, str) and key.endswith(_MEDIAN_RATIO_SUFFIX) + } + if actual_ratio_keys != required_ratio_keys: + errors.append( + f"{packet_name}: benchmark.comparison median ratio keys must be " + f"exactly {sorted(required_ratio_keys)!r}, got " + f"{sorted(actual_ratio_keys)!r}" + ) + for key, value in comparison.items(): + if not isinstance(key, str) or not key.endswith(_MEDIAN_RATIO_SUFFIX): + continue + if not _finite_positive_number(value): + errors.append( + f"{packet_name}: benchmark.comparison.{key} must be a " + "positive finite number" + ) + continue + stem = key[: -len(_MEDIAN_RATIO_SUFFIX)] + if "_to_" not in stem: + errors.append( + f"{packet_name}: benchmark.comparison.{key} does not name a " + "'_to_' median pair" + ) + continue + numerator_name, denominator_name = stem.rsplit("_to_", 1) + numerator = medians.get(numerator_name) + denominator = medians.get(denominator_name) + if numerator is None or denominator is None: + errors.append( + f"{packet_name}: benchmark.comparison.{key} references a " + "median the packet does not embed" + ) + continue + expected = numerator / denominator + if not math.isclose( + float(value), + expected, + rel_tol=_MEDIAN_RATIO_RELATIVE_TOLERANCE, + abs_tol=0.0, + ): + errors.append( + f"{packet_name}: benchmark.comparison.{key} is {value!r} but " + f"the embedded medians record {expected!r}" + ) return errors +def packet_errors(path: Path) -> list[str]: + return _packet_errors(path, set()) + + def collect_packets(args: argparse.Namespace) -> list[Path]: if args.packet: return list(args.packet) diff --git a/scripts/write_avbd_friction_coefficient_sweep_plot.py b/scripts/write_avbd_friction_coefficient_sweep_plot.py index 643e4f0cde2fe..6903af4297404 100644 --- a/scripts/write_avbd_friction_coefficient_sweep_plot.py +++ b/scripts/write_avbd_friction_coefficient_sweep_plot.py @@ -11,6 +11,12 @@ from pathlib import Path from typing import Any +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +from avbd_packet_schema import AVBD_PACKET_SCHEMA_VERSION # noqa: E402 + DEFAULT_PACKET = Path( "docs/plans/104-vertex-block-descent-solver/" "avbd-friction-coefficient-sweep-packet.json" @@ -25,7 +31,7 @@ PLOT_RIGHT = 682 PLOT_TOP = 84 PLOT_BOTTOM = 292 -SUPPORTED_SCHEMA_VERSIONS = (1, 2, 3) +SUPPORTED_SCHEMA_VERSIONS = tuple(range(1, AVBD_PACKET_SCHEMA_VERSION + 1)) class AvbdFrictionCoefficientSweepPlotError(RuntimeError): @@ -68,8 +74,9 @@ def _finite(row: dict[str, Any], key: str) -> float: def _validate_packet(packet_path: Path) -> list[dict[str, float]]: packet = _load_json(packet_path) if packet.get("schema_version") not in SUPPORTED_SCHEMA_VERSIONS: + supported = ", ".join(str(value) for value in SUPPORTED_SCHEMA_VERSIONS) raise AvbdFrictionCoefficientSweepPlotError( - "packet schema_version must be 1, 2, or 3" + f"packet schema_version must be one of {supported}" ) if packet.get("packet") != "avbd_friction_coefficient_sweep": raise AvbdFrictionCoefficientSweepPlotError( diff --git a/scripts/write_avbd_paper_breakable_wall_packet.py b/scripts/write_avbd_paper_breakable_wall_packet.py index fe3f56393d36e..1e2e7143789cd 100644 --- a/scripts/write_avbd_paper_breakable_wall_packet.py +++ b/scripts/write_avbd_paper_breakable_wall_packet.py @@ -18,8 +18,16 @@ from avbd_packet_schema import ( # noqa: E402 AVBD_PACKET_SCHEMA_VERSION, + PAPER_PACKET_SOURCE_PATHS, make_resolved_solver_identity, ) +from capture_source_provenance import ( # noqa: E402 + CAPTURE_ARTIFACT_PROVENANCE_ALGORITHM, + CAPTURE_SOURCE_PROVENANCE_ALGORITHM, + CAPTURE_SOURCE_ROOTS, + CaptureSourceProvenanceError, + compute_capture_source_provenance, +) DEFAULT_OUTPUT = Path( "docs/plans/104-vertex-block-descent-solver/" @@ -28,6 +36,7 @@ SCENE_ID = "avbd_paper_breakable_wall" BENCHMARK_NAME = "BM_AvbdPaperBreakableWallStep" BENCHMARK_RUN = f"{BENCHMARK_NAME}/iterations:120" +BENCHMARK_SOURCE_PATH = Path("tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp") PAPER_LOCATOR = "Section 5.4 and Figure 13, PDF page 10" PAPER_PDF_SHA256 = "7957d116b9130cfb0aa5a48ab7cd0d74a64ad79f75c99acd291bdece2be3f2d6" PAPER_FIGURE_SHA256 = "040361603d4e986de4d2da570593b9dc8295f8def2f57fd200de9a3e3836c61b" @@ -39,66 +48,79 @@ COLLISION_SHAPES = 256 BREAKABLE_JOINTS = 712 IMPACTING_BALLS = 3 -BREAK_FORCE = 8500.0 -CAMERA_DISTANCE = 11.0 -CAMERA_TARGET = (0.0, 0.0, 1.4) -CAMERA_VIEW = "front" -VIEW_FOCUS = ( - "avbd_paper_wall_brick_00_00_visual", - "avbd_paper_wall_brick_00_20_visual", - "avbd_paper_wall_brick_11_00_visual", - "avbd_paper_wall_brick_11_20_visual", +BREAK_FORCE = 5000.0 +CAMERA_AZIMUTH = -5.0 * math.pi / 8.0 +CAMERA_ELEVATION = 0.62 +CAMERA_DISTANCE = 22.0 +CAMERA_TARGET = (0.0, 0.45, 1.6) +CAMERA_PRESET = "front" +CAMERA_VIEW = "front-oblique" +VIEW_FOCUS = tuple( + f"avbd_paper_wall_brick_{row:02d}_{column:02d}_visual" + for row in range(12) + for column in range(21) ) OUTCOME_ORACLE = { - "displacement_threshold": 0.5, "evaluation_frame": 120, "impact_band_radius": 0.85, - "maximum_broken_joints": 500, - "minimum_broken_joints": 300, + "impact_damage_displacement_threshold": 0.1, + "joint_evidence_frames": [60, 120], + "maximum_broken_joints": 250, + "maximum_unbroken_joint_angular_residual_radians": 0.001, + "maximum_unbroken_joint_linear_residual": 0.002, + "minimum_broken_joints": 150, "minimum_displaced_bricks_per_impact_band": 4, - "minimum_outside_retained_fraction": 0.8, - "minimum_total_retained_fraction": 0.7, - "minimum_unbroken_joints": 200, + "minimum_outside_retained_fraction": 0.95, + "minimum_total_retained_fraction": 0.95, + "minimum_unbroken_joints": 450, "outside_radius": 1.15, + "retained_displacement_threshold": 0.5, + "expected_broken_joint_ids_sha256": ( + "31b187538ac1549563be368a4d7e304d1caef6ea11ba65b624d48f5f27468503" + ), } EXPECTED_OUTCOMES = { 60: { - "broken_joints": 359, + "broken_joints": 154, "evaluated": False, - "impact_band_displaced_counts": [3, 9, 4], - "outside_retained_fraction": 0.9337016574585635, + "impact_band_displaced_counts": [13, 14, 12], + "outside_retained_fraction": 1.0, "status": "pre-evaluation", "threshold_checks": { - "damage_in_three_impact_bands": False, + "damage_in_three_impact_bands": True, "finite_state": True, "fracture_activated": True, - "fracture_localized": True, + "fracture_count_bounded": True, + "fracture_identity_matches": True, "outside_wall_retained": True, + "retained_joint_rows_satisfied": True, "total_wall_retained": True, }, "thresholds_pass": False, - "total_retained_fraction": 0.8611111111111112, - "unbroken_joints": 353, + "total_retained_fraction": 0.996031746031746, + "unbroken_joints": 558, }, 120: { - "broken_joints": 359, + "broken_joints": 154, "evaluated": True, - "impact_band_displaced_counts": [4, 10, 6], - "outside_retained_fraction": 0.9116022099447514, + "impact_band_displaced_counts": [11, 14, 11], + "outside_retained_fraction": 0.9943181818181818, "status": "pass", "threshold_checks": { "damage_in_three_impact_bands": True, "finite_state": True, "fracture_activated": True, - "fracture_localized": True, + "fracture_count_bounded": True, + "fracture_identity_matches": True, "outside_wall_retained": True, + "retained_joint_rows_satisfied": True, "total_wall_retained": True, }, "thresholds_pass": True, - "total_retained_fraction": 0.8253968253968254, - "unbroken_joints": 353, + "total_retained_fraction": 0.9920634920634921, + "unbroken_joints": 558, }, } @@ -114,20 +136,8 @@ ) REPO_ROOT = SCRIPT_DIR.parent -SOURCE_PATHS = ( - Path("dart/gui/view_quality.cpp"), - Path("dart/gui/view_quality.hpp"), - Path("dart/simulation/world.cpp"), - Path("dart/simulation/world.hpp"), - Path("dart/simulation/world_options.hpp"), - Path("dart/simulation/compute/rigid_body_contact_stage.cpp"), - Path("dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp"), - Path("dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp"), - Path("python/dartpy/_view_quality.py"), - Path("python/examples/demos/scenes/avbd_paper_breakable_wall.py"), - Path("tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp"), - Path("scripts/avbd_packet_schema.py"), - Path("scripts/write_avbd_paper_breakable_wall_packet.py"), +SOURCE_PATHS = tuple( + Path(path) for path in PAPER_PACKET_SOURCE_PATHS[DEFAULT_OUTPUT.name] ) @@ -149,6 +159,25 @@ def parse_args(argv: list[str]) -> argparse.Namespace: return parser.parse_args(argv) +def _benchmark_reproduction_command(filter_pattern: str) -> str: + return ( + "capture_source_digest=$(pixi run python " + "scripts/capture_source_provenance.py --digest-only) && " + "benchmark_source_digest=$(sha256sum " + f"{BENCHMARK_SOURCE_PATH.as_posix()} | cut -d' ' -f1) && " + "pixi run bm -- bm_avbd_rigid_fixed_joint -- " + f"--benchmark_filter='{filter_pattern}' " + "--benchmark_repetitions=5 " + "--benchmark_report_aggregates_only=true " + "--benchmark_context=capture_source_provenance_digest=" + "$capture_source_digest " + "--benchmark_context=benchmark_source_sha256=" + "$benchmark_source_digest " + "--benchmark_out= " + "--benchmark_out_format=json" + ) + + def _load_json(path: Path) -> dict[str, Any]: try: data = json.loads(path.read_text(encoding="utf-8")) @@ -285,13 +314,384 @@ def _validate_finite_tree(value: object, label: str) -> None: ) +def _validate_sha256_hex(value: object, label: str) -> str: + if not isinstance(value, str) or len(value) != 64: + raise AvbdPaperBreakableWallPacketError( + f"{label} must be a 64-character lowercase hexadecimal string" + ) + try: + parsed = int(value, 16) + except ValueError as exc: + raise AvbdPaperBreakableWallPacketError(f"{label} must be hexadecimal") from exc + if f"{parsed:064x}" != value: + raise AvbdPaperBreakableWallPacketError( + f"{label} must use canonical lowercase hexadecimal" + ) + return value + + +def _validate_capture_provenance( + manifest: dict[str, Any], + *, + metrics_events: Path, + screenshot: Path, +) -> dict[str, Any]: + recorded_source = manifest.get("capture_source_provenance") + if not isinstance(recorded_source, dict): + raise AvbdPaperBreakableWallPacketError( + "capture manifest missing capture_source_provenance" + ) + _require_exact( + recorded_source.get("algorithm"), + CAPTURE_SOURCE_PROVENANCE_ALGORITHM, + "capture source provenance algorithm", + ) + _require_exact( + recorded_source.get("roots"), + list(CAPTURE_SOURCE_ROOTS), + "capture source provenance roots", + ) + recorded_digest = _validate_sha256_hex( + recorded_source.get("digest"), + "capture source provenance digest", + ) + recorded_file_count = recorded_source.get("file_count") + if ( + not isinstance(recorded_file_count, int) + or isinstance(recorded_file_count, bool) + or recorded_file_count < 1 + ): + raise AvbdPaperBreakableWallPacketError( + "capture source provenance file_count must be a positive integer" + ) + recorded_head = recorded_source.get("git_head") + if ( + not isinstance(recorded_head, str) + or len(recorded_head) != 40 + or any(character not in "0123456789abcdef" for character in recorded_head) + ): + raise AvbdPaperBreakableWallPacketError( + "capture source provenance git_head must be a lowercase Git object ID" + ) + try: + current_source = compute_capture_source_provenance(REPO_ROOT) + except CaptureSourceProvenanceError as exc: + raise AvbdPaperBreakableWallPacketError( + f"cannot validate capture source provenance: {exc}" + ) from exc + _require_exact( + recorded_file_count, + current_source["file_count"], + "capture source provenance file_count", + ) + _require_exact( + recorded_digest, + current_source["digest"], + "capture source provenance digest", + ) + + recorded_artifacts = manifest.get("capture_artifact_provenance") + if not isinstance(recorded_artifacts, dict): + raise AvbdPaperBreakableWallPacketError( + "capture manifest missing capture_artifact_provenance" + ) + _require_exact( + recorded_artifacts.get("algorithm"), + CAPTURE_ARTIFACT_PROVENANCE_ALGORITHM, + "capture artifact provenance algorithm", + ) + _require_exact( + _validate_sha256_hex( + recorded_artifacts.get("scene_metrics_events_sha256"), + "capture artifact scene_metrics_events_sha256", + ), + _sha256(metrics_events), + "capture artifact scene_metrics_events_sha256", + ) + _require_exact( + _validate_sha256_hex( + recorded_artifacts.get("screenshot_sha256"), + "capture artifact screenshot_sha256", + ), + _sha256(screenshot), + "capture artifact screenshot_sha256", + ) + return { + "algorithm": CAPTURE_SOURCE_PROVENANCE_ALGORITHM, + "digest": recorded_digest, + "file_count": recorded_file_count, + "git_head": recorded_head, + "roots": list(CAPTURE_SOURCE_ROOTS), + } + + +def _validate_benchmark_source_provenance( + context: dict[str, Any], +) -> dict[str, str]: + try: + current_source = compute_capture_source_provenance(REPO_ROOT) + except CaptureSourceProvenanceError as exc: + raise AvbdPaperBreakableWallPacketError( + f"cannot validate benchmark source provenance: {exc}" + ) from exc + capture_digest = _validate_sha256_hex( + context.get("capture_source_provenance_digest"), + "benchmark capture_source_provenance_digest", + ) + _require_exact( + capture_digest, + current_source["digest"], + "benchmark capture_source_provenance_digest", + ) + benchmark_source_hash = _validate_sha256_hex( + context.get("benchmark_source_sha256"), + "benchmark benchmark_source_sha256", + ) + _require_exact( + benchmark_source_hash, + _sha256(REPO_ROOT / BENCHMARK_SOURCE_PATH), + "benchmark benchmark_source_sha256", + ) + return { + "benchmark_source_sha256": benchmark_source_hash, + "capture_source_provenance_digest": capture_digest, + } + + +def _validate_joint_endpoint(value: object, label: str) -> None: + if not isinstance(value, dict): + raise AvbdPaperBreakableWallPacketError(f"{label} must be an object") + if set(value) != {"body", "column", "row"}: + raise AvbdPaperBreakableWallPacketError( + f"{label} must contain only body, column, and row" + ) + body = value.get("body") + column = value.get("column") + row = value.get("row") + if body == "ground": + if column is not None or row is not None: + raise AvbdPaperBreakableWallPacketError( + f"{label} ground endpoint must use null grid coordinates" + ) + return + if body != "brick": + raise AvbdPaperBreakableWallPacketError(f"{label}.body must be ground or brick") + if ( + not isinstance(column, int) + or isinstance(column, bool) + or not 0 <= column < 21 + or not isinstance(row, int) + or isinstance(row, bool) + or not 0 <= row < 12 + ): + raise AvbdPaperBreakableWallPacketError( + f"{label} brick grid coordinates are out of range" + ) + + +def _validate_joint_evidence( + outcome: dict[str, Any], + *, + expected_broken_count: int, + expected_broken_ids_sha256: str, + expected_outside_unbroken_count: int, + label: str, +) -> None: + _require_exact( + outcome.get("joint_residuals_finite"), + True, + f"{label} joint_residuals_finite", + ) + _require_exact( + outcome.get("broken_joint_identity_count"), + expected_broken_count, + f"{label} broken_joint_identity_count", + ) + _require_exact( + outcome.get("unbroken_joint_residual_count"), + BREAKABLE_JOINTS - expected_broken_count, + f"{label} unbroken_joint_residual_count", + ) + _require_exact( + outcome.get("outside_impact_unbroken_joint_residual_count"), + expected_outside_unbroken_count, + f"{label} outside_impact_unbroken_joint_residual_count", + ) + + records = outcome.get("broken_joint_records") + if not isinstance(records, list): + raise AvbdPaperBreakableWallPacketError( + f"{label} broken_joint_records must be a list" + ) + _require_exact( + len(records), + expected_broken_count, + f"{label} broken_joint_records count", + ) + record_ids = [] + derived_impact_counts = [0, 0, 0] + derived_outside_count = 0 + for index, record in enumerate(records): + record_label = f"{label} broken_joint_records[{index}]" + if not isinstance(record, dict): + raise AvbdPaperBreakableWallPacketError(f"{record_label} must be an object") + expected_keys = { + "angular_residual_radians", + "child", + "id", + "initial_anchor", + "kind", + "linear_residual", + "nearest_impact_distance", + "nearest_impact_index", + "parent", + "within_impact_band", + "within_impact_region", + } + if set(record) != expected_keys: + raise AvbdPaperBreakableWallPacketError( + f"{record_label} has an unexpected field set" + ) + joint_id = record.get("id") + if not isinstance(joint_id, str) or not joint_id: + raise AvbdPaperBreakableWallPacketError( + f"{record_label}.id must be non-empty" + ) + record_ids.append(joint_id) + _require_exact( + record.get("kind") in {"horizontal", "vertical", "base"}, + True, + f"{record_label}.kind validity", + ) + _validate_joint_endpoint(record.get("parent"), f"{record_label}.parent") + _validate_joint_endpoint(record.get("child"), f"{record_label}.child") + anchor = record.get("initial_anchor") + if not isinstance(anchor, list) or len(anchor) != 3: + raise AvbdPaperBreakableWallPacketError( + f"{record_label}.initial_anchor must have three coordinates" + ) + for coordinate_index, coordinate in enumerate(anchor): + _finite_number( + coordinate, + f"{record_label}.initial_anchor[{coordinate_index}]", + ) + for residual_key in ( + "angular_residual_radians", + "linear_residual", + "nearest_impact_distance", + ): + if ( + _finite_number( + record.get(residual_key), + f"{record_label}.{residual_key}", + ) + < 0.0 + ): + raise AvbdPaperBreakableWallPacketError( + f"{record_label}.{residual_key} must be non-negative" + ) + impact_index = record.get("nearest_impact_index") + if ( + not isinstance(impact_index, int) + or isinstance(impact_index, bool) + or not 0 <= impact_index < 3 + ): + raise AvbdPaperBreakableWallPacketError( + f"{record_label}.nearest_impact_index must be 0, 1, or 2" + ) + within_band = record.get("within_impact_band") + within_region = record.get("within_impact_region") + if not isinstance(within_band, bool) or not isinstance(within_region, bool): + raise AvbdPaperBreakableWallPacketError( + f"{record_label} impact-membership fields must be booleans" + ) + if within_band and not within_region: + raise AvbdPaperBreakableWallPacketError( + f"{record_label} impact-band membership must imply region membership" + ) + if within_region: + derived_impact_counts[impact_index] += 1 + else: + derived_outside_count += 1 + + if len(set(record_ids)) != len(record_ids): + raise AvbdPaperBreakableWallPacketError( + f"{label} broken joint IDs must be unique" + ) + identity_digest = sha256() + for joint_id in sorted(record_ids): + encoded_id = joint_id.encode("utf-8") + identity_digest.update(struct.pack(" maximum: + raise AvbdPaperBreakableWallPacketError( + f"{label} {maximum_key}/{rms_key} residual summary is invalid" + ) + + def _validate_camera(manifest: dict[str, Any]) -> dict[str, Any]: camera = manifest.get("camera") if not isinstance(camera, dict): raise AvbdPaperBreakableWallPacketError( "capture manifest missing serialized camera" ) - _require_exact(camera.get("view"), CAMERA_VIEW, "camera view") + _require_exact(camera.get("view"), CAMERA_PRESET, "camera preset") + _require_close( + camera.get("azimuth"), + math.degrees(CAMERA_AZIMUTH), + "camera azimuth", + ) + _require_close( + camera.get("elevation"), + math.degrees(CAMERA_ELEVATION), + "camera elevation", + ) _require_close(camera.get("distance"), CAMERA_DISTANCE, "camera distance") target = camera.get("target") if not isinstance(target, list) or len(target) != 3: @@ -301,7 +701,10 @@ def _validate_camera(manifest: dict[str, Any]) -> dict[str, Any]: for index, expected in enumerate(CAMERA_TARGET): _require_close(target[index], expected, f"camera target[{index}]") return { + "azimuth": CAMERA_AZIMUTH, "distance": CAMERA_DISTANCE, + "elevation": CAMERA_ELEVATION, + "preset": CAMERA_PRESET, "target": list(CAMERA_TARGET), "view": CAMERA_VIEW, } @@ -400,13 +803,13 @@ def _validate_view_report( raise AvbdPaperBreakableWallPacketError("ViewReport missing camera") _require_close( camera.get("azimuth"), - -0.5 * math.pi, - "ViewReport front-camera azimuth", + CAMERA_AZIMUTH, + "ViewReport camera azimuth", ) _require_close( camera.get("elevation"), - 0.0, - "ViewReport front-camera elevation", + CAMERA_ELEVATION, + "ViewReport camera elevation", ) _require_close(camera.get("distance"), CAMERA_DISTANCE, "ViewReport distance") target = camera.get("target") @@ -677,6 +1080,47 @@ def _validate_scene_metrics( expected_outcome["threshold_checks"], f"frame {expected_frame} outcome threshold_checks", ) + _validate_joint_evidence( + outcome, + expected_broken_count=expected_outcome["broken_joints"], + expected_broken_ids_sha256=OUTCOME_ORACLE["expected_broken_joint_ids_sha256"], + expected_outside_unbroken_count=405, + label=f"frame {expected_frame} outcome", + ) + _require_exact( + outcome.get("broken_joint_impact_region_counts"), + [18, 44, 13], + f"frame {expected_frame} broken_joint_impact_region_counts", + ) + _require_exact( + outcome.get("broken_joints_outside_impact_regions"), + 79, + f"frame {expected_frame} broken_joints_outside_impact_regions", + ) + maximum_linear_residual = _finite_number( + outcome.get("maximum_unbroken_joint_linear_residual"), + f"frame {expected_frame} maximum_unbroken_joint_linear_residual", + ) + if ( + maximum_linear_residual + > OUTCOME_ORACLE["maximum_unbroken_joint_linear_residual"] + ): + raise AvbdPaperBreakableWallPacketError( + f"frame {expected_frame} retained-joint linear residual exceeds " + "the AVBD oracle" + ) + maximum_angular_residual = _finite_number( + outcome.get("maximum_unbroken_joint_angular_residual_radians"), + f"frame {expected_frame} " "maximum_unbroken_joint_angular_residual_radians", + ) + if ( + maximum_angular_residual + > OUTCOME_ORACLE["maximum_unbroken_joint_angular_residual_radians"] + ): + raise AvbdPaperBreakableWallPacketError( + f"frame {expected_frame} retained-joint angular residual exceeds " + "the AVBD oracle" + ) if ( _finite_number( outcome.get("max_brick_displacement"), @@ -815,6 +1259,11 @@ def _validate_capture( logged_latest=metric_events[-1], width=width, ) + capture_source_provenance = _validate_capture_provenance( + manifest, + metrics_events=metrics_events, + screenshot=screenshot, + ) return ( { @@ -830,6 +1279,7 @@ def _validate_capture( "sha256": _sha256(manifest_path), }, "scene_metrics": scene_metrics, + "source_provenance": capture_source_provenance, "scene_metrics_events": { "event_count": len(metric_events), "file": metrics_events.name, @@ -1060,6 +1510,7 @@ def _validate_benchmark( "release", "benchmark library_build_type", ) + benchmark_source_provenance = _validate_benchmark_source_provenance(context) return { "benchmark": BENCHMARK_NAME, "context": { @@ -1072,9 +1523,12 @@ def _validate_benchmark( "library_version", "mhz_per_cpu", "num_cpus", + "benchmark_source_sha256", + "capture_source_provenance_digest", ) if key in context }, + "source_provenance": benchmark_source_provenance, "json_sha256": _sha256(benchmark_path), "rows": packet_rows, "scene_spec_fingerprint": expected_scene_spec_fingerprint, @@ -1321,15 +1775,15 @@ def make_packet( "time_step": TIME_STEP, }, "reconstructed_because_unpublished": { - "ball_mass": 100.0, + "ball_mass": 40.0, "ball_radius": 0.48, "ball_speed": 24.0, "break_force": BREAK_FORCE, - "brick_density": 4.0, + "brick_density": 200.0, "brick_size": [0.6, 0.3, 0.25], "target_coordinates_xz": [ [-3.1, 1.55], - [0.0, 1.75], + [-0.31, 1.75], [3.1, 2.35], ], "wall_layout": { @@ -1361,27 +1815,24 @@ def make_packet( "semantic_review": semantic_review, }, "reproduction": { - "benchmark_command": ( - "pixi run bm -- bm_avbd_rigid_fixed_joint -- " - f"--benchmark_filter='^{BENCHMARK_RUN}$' " - "--benchmark_repetitions=5 " - "--benchmark_report_aggregates_only=true " - "--benchmark_out= " - "--benchmark_out_format=json" - ), + "benchmark_command": _benchmark_reproduction_command(f"^{BENCHMARK_RUN}$"), "capture_commands": [ ( "pixi run py-demo-capture -- " f"--scene {SCENE_ID} --frames 60 --width 1280 " - "--height 720 --view front --camera-distance 11 " - "--camera-target 0,0,1.4 --capture-label impact " + "--height 720 --view front --camera-azimuth -112.5 " + "--camera-elevation 35.52338329811104 " + "--camera-distance 22 --camera-target 0,0.45,1.6 " + "--capture-label impact " "--output-dir " ), ( "pixi run py-demo-capture -- " f"--scene {SCENE_ID} --frames 120 --width 1280 " - "--height 720 --view front --camera-distance 11 " - "--camera-target 0,0,1.4 --capture-label outcome " + "--height 720 --view front --camera-azimuth -112.5 " + "--camera-elevation 35.52338329811104 " + "--camera-distance 22 --camera-target 0,0.45,1.6 " + "--capture-label outcome " "--output-dir " ), ], @@ -1390,14 +1841,14 @@ def make_packet( "pixi run image-verdict -- " f"/{SCENE_ID}_impact.png " f"--meta scene={SCENE_ID} --meta frame=60 " - "--meta view=front " + f"--meta view={CAMERA_VIEW} " "--out /image_verdict.json" ), ( "pixi run image-verdict -- " f"/{SCENE_ID}_outcome.png " f"--meta scene={SCENE_ID} --meta frame=120 " - "--meta view=front " + f"--meta view={CAMERA_VIEW} " "--out /image_verdict.json" ), ], diff --git a/scripts/write_avbd_paper_sequential_impulse_comparison_packet.py b/scripts/write_avbd_paper_sequential_impulse_comparison_packet.py new file mode 100644 index 0000000000000..2575bba40c979 --- /dev/null +++ b/scripts/write_avbd_paper_sequential_impulse_comparison_packet.py @@ -0,0 +1,1547 @@ +#!/usr/bin/env python3 +"""Write the matched Sequential Impulse row for AVBD paper Figure 13.""" + +from __future__ import annotations + +import argparse +import json +import struct +import sys +from hashlib import sha256 +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +import write_avbd_paper_breakable_wall_packet as shared # noqa: E402 +import write_avbd_paper_vbd_comparison_packet as comparison # noqa: E402 +from avbd_packet_schema import ( # noqa: E402 + AVBD_PACKET_SCHEMA_VERSION, + PAPER_PACKET_SOURCE_PATHS, + make_resolved_solver_identity, +) + +DEFAULT_OUTPUT = Path( + "docs/plans/104-vertex-block-descent-solver/" + "avbd-paper-sequential-impulse-comparison-packet.json" +) +DEFAULT_AVBD_VBD_PACKET = Path( + "docs/plans/104-vertex-block-descent-solver/" + "avbd-paper-vbd-comparison-packet.json" +) + +SCENE_ID = "sequential_impulse_paper_breakable_wall" +LINKED_PACKET_ID = "avbd_paper_vbd_comparison" +LINKED_SCENE_ID = comparison.SCENE_ID +PAPER_LOCATOR = comparison.PAPER_LOCATOR +TIME_STEP = comparison.TIME_STEP +RIGID_CONSTRAINT_ITERATIONS = comparison.RIGID_CONSTRAINT_ITERATIONS +BRICK_COUNT = comparison.BRICK_COUNT +RIGID_BODIES = comparison.RIGID_BODIES +COLLISION_SHAPES = comparison.COLLISION_SHAPES +BREAKABLE_JOINTS = comparison.BREAKABLE_JOINTS +IMPACTING_BALLS = comparison.IMPACTING_BALLS +BREAK_FORCE = comparison.BREAK_FORCE +CAMERA_VIEW = shared.CAMERA_VIEW +VIEW_FOCUS = tuple( + f"sequential_impulse_paper_wall_brick_{row:02d}_{column:02d}_visual" + for row in range(12) + for column in range(21) +) + +CHECKPOINTS = { + 14: { + "label": "fracture", + "checkpoint": "fracture", + "threshold_checks": { + "finite_state": True, + "fracture_activated": True, + "initial_fracture_confined_to_impact_regions": True, + "initial_fracture_covers_three_impacts": True, + "initial_fracture_identity_matches": True, + "initial_retained_joint_rows_bounded": True, + "wall_initially_retained": True, + }, + }, + 120: { + "label": "collapse", + "checkpoint": "collapse", + "threshold_checks": { + "damage_in_three_impact_bands": True, + "finite_state": True, + "fracture_identity_unchanged": True, + "initial_fracture_remains_visible": True, + "outside_wall_collapses": True, + "retained_rows_fail_outside_impacts": True, + "wall_collapses": True, + }, + }, +} + +OUTCOME_ORACLE = { + "impact_damage_displacement_threshold": 0.1, + "retained_displacement_threshold": 0.5, + "impact_band_radius": 0.85, + "outside_radius": 1.15, + "evaluation_frame": 14, + "collapse_evaluation_frame": 120, + "joint_evidence_frames": [14, 120], + "minimum_initial_broken_joints": 3, + "minimum_initial_broken_joints_per_impact_region": 1, + "maximum_initial_broken_joints": 20, + "minimum_initial_unbroken_joints": 690, + "minimum_initial_total_retained_fraction": 0.95, + "maximum_initial_outside_joint_angular_residual_radians": 0.13, + "maximum_initial_outside_joint_linear_residual": 0.04, + "expected_broken_joints": 5, + "expected_broken_joint_ids_sha256": ( + "c85184879b1b9036ff582731031fc49c56b4149e93ded45780b06352bd94d61d" + ), + "expected_outside_impact_unbroken_joint_count": 484, + "minimum_final_broken_joints": 3, + "maximum_final_broken_joints": 30, + "minimum_final_unbroken_joints": 680, + "minimum_displaced_bricks_per_impact_band": 10, + "minimum_collapse_outside_joint_maximum_angular_residual_radians": 0.60, + "minimum_collapse_outside_joint_maximum_linear_residual": 0.05, + "minimum_collapse_outside_joint_rms_angular_residual_radians": 0.18, + "minimum_collapse_outside_joint_rms_linear_residual": 0.015, + "maximum_collapse_outside_retained_fraction": 0.35, + "maximum_collapse_total_retained_fraction": 0.25, + "minimum_collapse_wall_normal_displacement": 2.0, +} + +EXPECTED_OUTCOMES = { + 14: { + "broken_joints": 5, + "impact_band_displaced_counts": [13, 14, 13], + "outside_retained_fraction": 1.0, + "total_retained_fraction": 1.0, + "unbroken_joints": 707, + }, + 120: { + "broken_joints": 5, + "impact_band_displaced_counts": [13, 14, 13], + "outside_retained_fraction": 0.2727272727272727, + "total_retained_fraction": 0.19047619047619047, + "unbroken_joints": 707, + }, +} + +RESOLVED_SOLVER_IDENTITY = make_resolved_solver_identity( + resolved_rigid_contact_family="sequential-impulse", + rigid_point_joint_solver="sequential_impulse", + avbd_rigid_contact_config_emplaced=False, + rigid_contact_selection="contact_solver_method", + recorded_from=( + "engine World.resolved_configuration in both Sequential Impulse " + "captures plus benchmark runtime resolved-configuration counters" + ), +) + +REPO_ROOT = SCRIPT_DIR.parent +SOURCE_PATHS = tuple( + Path(path) for path in PAPER_PACKET_SOURCE_PATHS[DEFAULT_OUTPUT.name] +) + + +class AvbdPaperSequentialImpulseComparisonPacketError(RuntimeError): + """Raised when inputs cannot support the matched SI comparison claim.""" + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--benchmark-json", type=Path, required=True) + parser.add_argument( + "--fracture-capture-manifest", + type=Path, + required=True, + ) + parser.add_argument( + "--fracture-image-verdict-json", + type=Path, + required=True, + ) + parser.add_argument( + "--collapse-capture-manifest", + type=Path, + required=True, + ) + parser.add_argument( + "--collapse-image-verdict-json", + type=Path, + required=True, + ) + parser.add_argument("--visual-review-json", type=Path, required=True) + parser.add_argument("--paper-pdf", type=Path, required=True) + parser.add_argument("--paper-figure-image", type=Path, required=True) + parser.add_argument( + "--avbd-vbd-packet", + type=Path, + default=DEFAULT_AVBD_VBD_PACKET, + ) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + return parser.parse_args(argv) + + +def _source_provenance() -> dict[str, Any]: + combined = sha256() + files = [] + for relative_path in SOURCE_PATHS: + path = REPO_ROOT / relative_path + try: + payload = path.read_bytes() + except FileNotFoundError as exc: + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"{relative_path}: source file not found" + ) from exc + encoded_path = relative_path.as_posix().encode("utf-8") + combined.update(struct.pack(" list[dict[str, str]]: + notes = metrics.get("resolved_configuration") + if not isinstance(notes, list): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "scene metrics missing engine resolved_configuration" + ) + normalized = [] + for index, note in enumerate(notes): + if not isinstance(note, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"resolved_configuration[{index}] must be an object" + ) + normalized_note = {} + for key in ("domain", "requested", "resolved", "reason"): + value = note.get(key) + if not isinstance(value, str) or not value: + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"resolved_configuration[{index}].{key} must be non-empty" + ) + normalized_note[key] = value + normalized.append(normalized_note) + + expected = { + "rigid-body": ("sequential-impulse", "sequential-impulse"), + "rigid-contact": ("sequential-impulse", "sequential-impulse"), + "rigid-pair-constraint": ( + "sequential-impulse", + "sequential-impulse", + ), + "rigid-constraint-iterations": ( + str(RIGID_CONSTRAINT_ITERATIONS), + str(RIGID_CONSTRAINT_ITERATIONS), + ), + } + for domain, (requested, resolved) in expected.items(): + matching = [ + note + for note in normalized + if note["domain"] == domain + and note["requested"] == requested + and note["resolved"] == resolved + ] + if len(matching) != 1: + raise AvbdPaperSequentialImpulseComparisonPacketError( + "engine resolved_configuration must contain exactly one " + f"{domain} {requested}->{resolved} note" + ) + return normalized + + +def _validate_outcome( + outcome: dict[str, Any], + *, + expected_frame: int, +) -> dict[str, Any]: + checkpoint = CHECKPOINTS[expected_frame] + expected_outcome = EXPECTED_OUTCOMES[expected_frame] + shared._validate_finite_tree(outcome, f"frame {expected_frame} outcome") + for key, expected in ( + ("frame", expected_frame), + ("evaluated", True), + ("checkpoint", checkpoint["checkpoint"]), + ("status", "pass"), + ("thresholds_pass", True), + ("threshold_checks", checkpoint["threshold_checks"]), + ("broken_joints", expected_outcome["broken_joints"]), + ("unbroken_joints", expected_outcome["unbroken_joints"]), + ( + "impact_band_displaced_counts", + expected_outcome["impact_band_displaced_counts"], + ), + ("last_step_iterations", RIGID_CONSTRAINT_ITERATIONS), + ): + shared._require_exact( + outcome.get(key), + expected, + f"frame {expected_frame} outcome {key}", + ) + for key in ("outside_retained_fraction", "total_retained_fraction"): + shared._require_close( + outcome.get(key), + expected_outcome[key], + f"frame {expected_frame} outcome {key}", + ) + shared._require_close( + outcome.get("world_time"), + expected_frame * TIME_STEP, + f"frame {expected_frame} outcome world_time", + ) + shared._validate_joint_evidence( + outcome, + expected_broken_count=OUTCOME_ORACLE["expected_broken_joints"], + expected_broken_ids_sha256=OUTCOME_ORACLE["expected_broken_joint_ids_sha256"], + expected_outside_unbroken_count=OUTCOME_ORACLE[ + "expected_outside_impact_unbroken_joint_count" + ], + label=f"frame {expected_frame} outcome", + ) + shared._require_exact( + outcome.get("broken_joint_impact_region_counts"), + [2, 1, 2], + f"frame {expected_frame} broken_joint_impact_region_counts", + ) + shared._require_exact( + outcome.get("broken_joints_outside_impact_regions"), + 0, + f"frame {expected_frame} broken_joints_outside_impact_regions", + ) + + contact_count = shared._finite_number( + outcome.get("contact_count"), + f"frame {expected_frame} outcome contact_count", + ) + if contact_count < 0.0: + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"frame {expected_frame} outcome contact_count must be non-negative" + ) + if expected_frame == 14: + if ( + float(outcome["total_retained_fraction"]) + < OUTCOME_ORACLE["minimum_initial_total_retained_fraction"] + ): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "frame 14 initial wall-retention oracle failed" + ) + initial_maximum_linear = shared._finite_number( + outcome.get("maximum_outside_impact_unbroken_joint_linear_residual"), + "frame 14 outside-impact maximum linear residual", + ) + if ( + initial_maximum_linear + > OUTCOME_ORACLE["maximum_initial_outside_joint_linear_residual"] + ): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "frame 14 outside-impact retained-joint linear residual " + "exceeds the initial oracle" + ) + initial_maximum_angular = shared._finite_number( + outcome.get( + "maximum_outside_impact_unbroken_joint_angular_residual_radians" + ), + "frame 14 outside-impact maximum angular residual", + ) + if ( + initial_maximum_angular + > OUTCOME_ORACLE["maximum_initial_outside_joint_angular_residual_radians"] + ): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "frame 14 outside-impact retained-joint angular residual " + "exceeds the initial oracle" + ) + else: + wall_normal_displacement = shared._finite_number( + outcome.get("maximum_wall_normal_displacement"), + "frame 120 outcome maximum_wall_normal_displacement", + ) + if ( + wall_normal_displacement + < OUTCOME_ORACLE["minimum_collapse_wall_normal_displacement"] + ): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "frame 120 wall-collapse displacement oracle failed" + ) + collapse_minimums = { + "maximum_outside_impact_unbroken_joint_linear_residual": ( + OUTCOME_ORACLE["minimum_collapse_outside_joint_maximum_linear_residual"] + ), + "rms_outside_impact_unbroken_joint_linear_residual": ( + OUTCOME_ORACLE["minimum_collapse_outside_joint_rms_linear_residual"] + ), + ( + "maximum_outside_impact_unbroken_joint_angular_" "residual_radians" + ): OUTCOME_ORACLE[ + "minimum_collapse_outside_joint_maximum_angular_residual_radians" + ], + ( + "rms_outside_impact_unbroken_joint_angular_residual_radians" + ): OUTCOME_ORACLE[ + "minimum_collapse_outside_joint_rms_angular_residual_radians" + ], + } + for key, minimum in collapse_minimums.items(): + actual = shared._finite_number( + outcome.get(key), + f"frame 120 outcome {key}", + ) + if actual < minimum: + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"frame 120 outcome {key} must be >= {minimum}, " f"got {actual}" + ) + return outcome + + +def _validate_scene_metrics( + manifest: dict[str, Any], + *, + expected_frame: int, + height: int, + logged_latest: dict[str, Any], + width: int, +) -> dict[str, Any]: + summary = manifest.get("scene_metrics") + if not isinstance(summary, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "capture manifest missing scene_metrics" + ) + shared._require_exact( + summary.get("event_count"), + expected_frame, + "scene metric event_count", + ) + latest = summary.get("latest") + if not isinstance(latest, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "capture scene_metrics missing latest event" + ) + shared._require_exact( + latest, + logged_latest, + "manifest latest scene metric event", + ) + shared._require_exact(latest.get("scene"), SCENE_ID, "latest scene") + shared._require_exact( + latest.get("frame"), + expected_frame, + "latest scene frame", + ) + metrics = latest.get("metrics") + if not isinstance(metrics, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "capture latest scene metric payload must be an object" + ) + shared._validate_finite_tree(metrics, "scene metrics") + + exact_metrics = { + "ball_count": IMPACTING_BALLS, + "breakable_joints": BREAKABLE_JOINTS, + "brick_count": BRICK_COUNT, + "collision_shapes": COLLISION_SHAPES, + "executor": "World.step default", + "paper_locator": PAPER_LOCATOR, + "rigid_bodies": RIGID_BODIES, + "rigid_body_solver": "SEQUENTIAL_IMPULSE", + "row": SCENE_ID, + "solver": "public_sequential-impulse", + } + for key, expected in exact_metrics.items(): + shared._require_exact(metrics.get(key), expected, f"scene metrics {key}") + shared._require_close( + metrics.get("break_force"), + BREAK_FORCE, + "scene break_force", + ) + shared._require_close( + metrics.get("time_step_ms"), + TIME_STEP * 1000.0, + "scene time_step_ms", + ) + shared._require_exact( + metrics.get("rigid_constraint_options"), + {"iterations": RIGID_CONSTRAINT_ITERATIONS}, + "scene rigid_constraint_options", + ) + + oracle = metrics.get("outcome_oracle") + if not isinstance(oracle, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "scene metrics missing outcome_oracle" + ) + for key, expected in OUTCOME_ORACLE.items(): + if isinstance(expected, float): + shared._require_close(oracle.get(key), expected, f"oracle {key}") + else: + shared._require_exact(oracle.get(key), expected, f"oracle {key}") + + outcome = metrics.get("outcome") + if not isinstance(outcome, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "scene metrics missing outcome" + ) + _validate_outcome(outcome, expected_frame=expected_frame) + resolved = _validate_resolved_configuration(metrics) + view_report = shared._validate_view_report( + metrics, + expected_focus=VIEW_FOCUS, + width=width, + height=height, + ) + fingerprint = comparison._validate_scene_spec_fingerprint( + metrics.get("scene_spec_fingerprint"), + "scene metrics scene_spec_fingerprint", + ) + return { + "event_count": expected_frame, + "frame": expected_frame, + "outcome": outcome, + "outcome_oracle": oracle, + "resolved_configuration": resolved, + "scene_contract": { + key: metrics[key] + for key in ( + "ball_count", + "break_force", + "breakable_joints", + "brick_count", + "collision_shapes", + "executor", + "paper_locator", + "rigid_bodies", + "rigid_body_solver", + "solver", + "time_step_ms", + ) + } + | {"rigid_constraint_options": metrics["rigid_constraint_options"]}, + "scene_spec_fingerprint": fingerprint, + "view_report": view_report, + } + + +def _validate_capture( + manifest_path: Path, + *, + expected_frame: int, +) -> tuple[dict[str, Any], Path]: + expected_label = CHECKPOINTS[expected_frame]["label"] + manifest = shared._load_json(manifest_path) + shared._require_exact(manifest.get("schema_version"), 1, "capture schema") + shared._require_exact(manifest.get("scene"), SCENE_ID, "capture scene") + shared._require_exact( + manifest.get("capture_label"), + expected_label, + "capture label", + ) + shared._require_exact(manifest.get("force_drag"), None, "capture force_drag") + shared._require_exact( + manifest.get("resolved_solver_identity"), + { + "executor": "World.step default", + "solver": "public_sequential-impulse", + "source": "scene_capture_metrics.latest.metrics", + }, + "capture resolved_solver_identity", + ) + capture = manifest.get("capture") + if not isinstance(capture, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "capture manifest missing dimensions" + ) + for key in ("requested_frames", "converted_frames"): + shared._require_exact( + capture.get(key), + expected_frame, + f"capture {key}", + ) + width = capture.get("width") + height = capture.get("height") + if ( + not isinstance(width, int) + or isinstance(width, bool) + or not isinstance(height, int) + or isinstance(height, bool) + or width < 1 + or height < 1 + ): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "capture width and height must be positive integers" + ) + + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "capture manifest missing artifacts" + ) + screenshot = shared._artifact_path( + manifest_path, + artifacts.get("screenshot"), + "artifacts.screenshot", + ) + metrics_events = shared._artifact_path( + manifest_path, + artifacts.get("scene_metrics_events"), + "artifacts.scene_metrics_events", + ) + if not screenshot.is_file() or not metrics_events.is_file(): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "capture screenshot and scene metric log must both exist" + ) + if shared._png_dimensions(screenshot) != (width, height): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "capture screenshot dimensions do not match manifest" + ) + events = shared._read_scene_metric_events( + metrics_events, + expected_frame=expected_frame, + expected_scene_id=SCENE_ID, + ) + scene_metrics = _validate_scene_metrics( + manifest, + expected_frame=expected_frame, + height=height, + logged_latest=events[-1], + width=width, + ) + capture_source_provenance = shared._validate_capture_provenance( + manifest, + metrics_events=metrics_events, + screenshot=screenshot, + ) + return ( + { + "camera": shared._validate_camera(manifest), + "capture": { + "height": height, + "requested_frames": expected_frame, + "width": width, + }, + "label": expected_label, + "manifest": { + "file": manifest_path.name, + "sha256": shared._sha256(manifest_path), + }, + "scene_metrics": scene_metrics, + "source_provenance": capture_source_provenance, + "scene_metrics_events": { + "event_count": len(events), + "file": metrics_events.name, + "sha256": shared._sha256(metrics_events), + }, + "screenshot": { + "file": screenshot.name, + "sha256": shared._sha256(screenshot), + }, + }, + screenshot, + ) + + +def _validate_benchmark( + benchmark_path: Path, + *, + expected_fingerprint: str, +) -> dict[str, Any]: + data = shared._load_json(benchmark_path) + method = comparison._benchmark_rows( + data, + solver_key="sequential-impulse", + expected_fingerprint=expected_fingerprint, + ) + context = data.get("context") + if not isinstance(context, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "benchmark JSON missing context object" + ) + executable = context.get("executable") + if ( + not isinstance(executable, str) + or Path(executable).name != "bm_avbd_rigid_fixed_joint" + ): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "benchmark context identifies the wrong executable" + ) + shared._require_exact( + context.get("library_build_type"), + "release", + "benchmark library_build_type", + ) + benchmark_source_provenance = shared._validate_benchmark_source_provenance(context) + return { + "context": { + key: context[key] + for key in ( + "executable", + "host_name", + "json_schema_version", + "library_build_type", + "library_version", + "mhz_per_cpu", + "num_cpus", + "benchmark_source_sha256", + "capture_source_provenance_digest", + ) + if key in context + }, + "json_sha256": shared._sha256(benchmark_path), + "method": method, + "scene_spec_fingerprint": expected_fingerprint, + "source_provenance": benchmark_source_provenance, + } + + +def _validate_linked_source_provenance( + provenance: object, + *, + repo_root: Path = comparison.REPO_ROOT, + source_paths: tuple[Path, ...] = comparison.SOURCE_PATHS, + label: str = "linked AVBD/VBD", +) -> dict[str, Any]: + if not isinstance(provenance, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"{label} packet lacks source provenance" + ) + shared._require_exact( + provenance.get("algorithm"), + "sha256-length-prefixed-path-and-content-v1", + f"{label} source provenance algorithm", + ) + files = provenance.get("files") + if not isinstance(files, list): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"{label} source provenance must list files" + ) + expected_paths = [relative.as_posix() for relative in source_paths] + actual_paths = [ + entry.get("path") if isinstance(entry, dict) else None for entry in files + ] + shared._require_exact( + actual_paths, + expected_paths, + f"{label} source provenance paths", + ) + + combined = sha256() + normalized_files = [] + for entry, relative in zip(files, source_paths): + if not isinstance(entry, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"{label} source provenance entry must be an object" + ) + path = repo_root / relative + try: + payload = path.read_bytes() + except FileNotFoundError as exc: + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"{label} source provenance file not found: {relative}" + ) from exc + expected_hash = sha256(payload).hexdigest() + shared._require_exact( + entry.get("sha256"), + expected_hash, + f"{label} source provenance {relative} sha256", + ) + encoded_path = relative.as_posix().encode("utf-8") + combined.update(struct.pack(" dict[str, Any]: + if not isinstance(capture, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"linked AVBD/VBD packet lacks {label} visual evidence" + ) + screenshot = capture.get("screenshot") + if not isinstance(screenshot, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"linked AVBD/VBD {label} evidence lacks a screenshot" + ) + screenshot_file = screenshot.get("file") + if not isinstance(screenshot_file, str) or not screenshot_file: + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"linked AVBD/VBD {label} screenshot file must be non-empty" + ) + screenshot_hash = comparison._validate_sha256_value( + screenshot.get("sha256"), + f"linked AVBD/VBD {label} screenshot sha256", + ) + image_verdict = capture.get("image_verdict") + if not isinstance(image_verdict, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"linked AVBD/VBD {label} image verdict is missing" + ) + shared._require_exact( + image_verdict.get("pass"), + True, + f"linked AVBD/VBD {label} image verdict pass", + ) + comparison._validate_sha256_value( + image_verdict.get("sha256"), + f"linked AVBD/VBD {label} image verdict sha256", + ) + scene_metrics = capture.get("scene_metrics") + if not isinstance(scene_metrics, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"linked AVBD/VBD {label} evidence lacks scene metrics" + ) + shared._require_exact( + scene_metrics.get("frame"), + expected_frame, + f"linked AVBD/VBD {label} frame", + ) + shared._require_exact( + scene_metrics.get("scene_spec_fingerprint"), + expected_fingerprint, + f"linked AVBD/VBD {label} scene fingerprint", + ) + outcome = scene_metrics.get("outcome") + if not isinstance(outcome, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"linked AVBD/VBD {label} evidence lacks outcome" + ) + for key, expected in ( + ("broken_joints", 0), + ("unbroken_joints", BREAKABLE_JOINTS), + ("evaluated", True), + ("status", "pass"), + ("thresholds_pass", True), + ( + "threshold_checks", + comparison.CHECKPOINTS[expected_frame]["threshold_checks"], + ), + ): + shared._require_exact( + outcome.get(key), + expected, + f"linked AVBD/VBD {label} outcome {key}", + ) + shared._validate_joint_evidence( + outcome, + expected_broken_count=0, + expected_broken_ids_sha256=( + "e3b0c44298fc1c149afbf4c8996fb924" "27ae41e4649b934ca495991b7852b855" + ), + expected_outside_unbroken_count=484, + label=f"linked AVBD/VBD {label} outcome", + ) + return { + "screenshot": { + "file": screenshot_file, + "sha256": screenshot_hash, + }, + "frame": expected_frame, + } + + +def _validate_linked_avbd_vbd_packet( + packet_path: Path, + *, + benchmark_sha256: str, + expected_fingerprint: str, +) -> dict[str, Any]: + packet = shared._load_json(packet_path) + shared._require_exact( + packet.get("schema_version"), + AVBD_PACKET_SCHEMA_VERSION, + "linked AVBD/VBD packet schema_version", + ) + shared._require_exact( + packet.get("packet"), + LINKED_PACKET_ID, + "linked AVBD/VBD packet identity", + ) + shared._require_exact( + packet.get("scene"), + LINKED_SCENE_ID, + "linked AVBD/VBD packet scene", + ) + + identity = packet.get("resolved_solver_identity") + if not isinstance(identity, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD packet lacks resolved_solver_identity" + ) + shared._require_exact( + identity.get("rigid_contact_solver"), + "vbd", + "linked AVBD/VBD rigid contact solver", + ) + shared._require_exact( + identity.get("rigid_point_joint_solver"), + "vbd", + "linked AVBD/VBD point-joint solver", + ) + + benchmark = packet.get("benchmark") + if not isinstance(benchmark, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD packet lacks benchmark" + ) + shared._require_exact( + benchmark.get("json_sha256"), + benchmark_sha256, + "linked AVBD/VBD benchmark JSON hash", + ) + shared._require_exact( + benchmark.get("scene_spec_fingerprint"), + expected_fingerprint, + "linked AVBD/VBD scene fingerprint", + ) + methods = benchmark.get("methods") + if not isinstance(methods, dict) or set(methods) != {"avbd", "vbd"}: + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD benchmark must contain AVBD and VBD methods" + ) + method_timings = {} + for solver_key in ("avbd", "vbd"): + method = methods.get(solver_key) + if not isinstance(method, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"linked {solver_key.upper()} benchmark method is missing" + ) + shared._require_exact( + method.get("benchmark"), + comparison.BENCHMARK_RUNS[solver_key].removesuffix("/iterations:120"), + f"linked {solver_key.upper()} benchmark identity", + ) + timing = method.get("timing") + if not isinstance(timing, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"linked {solver_key.upper()} benchmark timing is missing" + ) + median_cpu = shared._finite_number( + timing.get("median_cpu_time_per_step_ns"), + f"linked {solver_key.upper()} median CPU time", + ) + if median_cpu <= 0.0: + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"linked {solver_key.upper()} median CPU time must be positive" + ) + method_timings[solver_key] = { + "median_cpu_time_per_step_ns": median_cpu, + } + + correctness = packet.get("correctness") + if not isinstance(correctness, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD packet lacks correctness evidence" + ) + shared._require_exact( + correctness.get("determinism_test"), + "test_vbd_paper_breakable_wall_checkpoints_are_deterministic", + "linked AVBD/VBD determinism test", + ) + shared._require_exact( + correctness.get("outcome_test"), + "test_vbd_paper_breakable_wall_matches_figure13_contract", + "linked AVBD/VBD outcome test", + ) + + linked_avbd = packet.get("linked_avbd_evidence") + if not isinstance(linked_avbd, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD packet lacks linked AVBD evidence" + ) + linked_avbd_file = linked_avbd.get("file") + if ( + not isinstance(linked_avbd_file, str) + or not linked_avbd_file + or Path(linked_avbd_file).name != linked_avbd_file + ): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "nested linked AVBD file must name one sibling packet" + ) + linked_avbd_path = packet_path.parent / linked_avbd_file + if not linked_avbd_path.is_file(): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"nested linked AVBD packet not found: {linked_avbd_file}" + ) + linked_avbd_hash = shared._sha256(linked_avbd_path) + shared._require_exact( + comparison._validate_sha256_value( + linked_avbd.get("sha256"), + "nested linked AVBD packet sha256", + ), + linked_avbd_hash, + "nested linked AVBD current file sha256", + ) + linked_avbd_packet = shared._load_json(linked_avbd_path) + shared._require_exact( + linked_avbd_packet.get("schema_version"), + AVBD_PACKET_SCHEMA_VERSION, + "nested linked AVBD packet schema_version", + ) + shared._require_exact( + linked_avbd_packet.get("packet"), + "avbd_paper_breakable_wall", + "nested linked AVBD packet identity", + ) + shared._require_exact( + linked_avbd_packet.get("scene"), + shared.SCENE_ID, + "nested linked AVBD scene", + ) + linked_avbd_benchmark = linked_avbd_packet.get("benchmark") + if not isinstance(linked_avbd_benchmark, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "nested linked AVBD packet lacks benchmark" + ) + shared._require_exact( + linked_avbd_benchmark.get("scene_spec_fingerprint"), + expected_fingerprint, + "nested linked AVBD scene fingerprint", + ) + shared._require_exact( + linked_avbd.get("scene_spec_fingerprint"), + expected_fingerprint, + "nested linked AVBD scene fingerprint", + ) + linked_avbd_identity = linked_avbd.get("resolved_solver_identity") + if not isinstance(linked_avbd_identity, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "nested linked AVBD evidence lacks resolved_solver_identity" + ) + shared._require_exact( + linked_avbd_identity.get("rigid_contact_solver"), + "avbd", + "nested linked AVBD rigid contact solver", + ) + shared._require_exact( + linked_avbd_identity.get("rigid_point_joint_solver"), + "avbd", + "nested linked AVBD point-joint solver", + ) + shared._require_exact( + linked_avbd_packet.get("resolved_solver_identity"), + linked_avbd_identity, + "nested linked AVBD packet resolved_solver_identity", + ) + linked_avbd_provenance = _validate_linked_source_provenance( + linked_avbd_packet.get("source_provenance"), + repo_root=shared.REPO_ROOT, + source_paths=shared.SOURCE_PATHS, + label="nested linked AVBD", + ) + + visual = packet.get("visual_evidence") + if not isinstance(visual, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD packet lacks visual evidence" + ) + validated_visuals = { + "bend": _validate_linked_visual_capture( + visual.get("bend"), + expected_frame=18, + expected_fingerprint=expected_fingerprint, + label="bend", + ), + "retention": _validate_linked_visual_capture( + visual.get("retention"), + expected_frame=120, + expected_fingerprint=expected_fingerprint, + label="retention", + ), + } + semantic = visual.get("semantic_review") + if not isinstance(semantic, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD semantic review is missing" + ) + shared._require_exact( + semantic.get("verdict"), + "pass", + "linked AVBD/VBD semantic review verdict", + ) + semantic_hash = comparison._validate_sha256_value( + semantic.get("sha256"), + "linked AVBD/VBD semantic review sha256", + ) + inspected_images = semantic.get("inspected_images") + if not isinstance(inspected_images, list): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD semantic review must list inspected images" + ) + by_role = { + entry.get("role"): entry + for entry in inspected_images + if isinstance(entry, dict) and isinstance(entry.get("role"), str) + } + paper_reference = packet.get("paper_reference") + paper_figure = ( + paper_reference.get("figure") if isinstance(paper_reference, dict) else None + ) + if not isinstance(paper_figure, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD packet lacks paper figure evidence" + ) + paper_figure_file = paper_figure.get("file") + if not isinstance(paper_figure_file, str) or not paper_figure_file: + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD paper figure file must be non-empty" + ) + paper_figure_hash = comparison._validate_sha256_value( + paper_figure.get("sha256"), + "linked AVBD/VBD paper figure sha256", + ) + expected_roles = { + "bend_frame_18", + "retention_frame_120", + "paper_figure_13_reference", + } + if set(by_role) != expected_roles: + raise AvbdPaperSequentialImpulseComparisonPacketError( + "linked AVBD/VBD semantic review must inspect bend, retention, " + "and paper Figure 13 images" + ) + for role, visual_key in ( + ("bend_frame_18", "bend"), + ("retention_frame_120", "retention"), + ): + expected_screenshot = validated_visuals[visual_key]["screenshot"] + entry = by_role.get(role) + if not isinstance(entry, dict): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"linked AVBD/VBD semantic review lacks {role}" + ) + shared._require_exact( + entry.get("file"), + expected_screenshot["file"], + f"linked AVBD/VBD semantic review {role} file", + ) + shared._require_exact( + entry.get("sha256"), + expected_screenshot["sha256"], + f"linked AVBD/VBD semantic review {role} sha256", + ) + paper_entry = by_role["paper_figure_13_reference"] + shared._require_exact( + paper_entry.get("file"), + paper_figure_file, + "linked AVBD/VBD semantic review paper figure file", + ) + shared._require_exact( + paper_entry.get("sha256"), + paper_figure_hash, + "linked AVBD/VBD semantic review paper figure sha256", + ) + + provenance = _validate_linked_source_provenance(packet.get("source_provenance")) + return { + "benchmark_method_timings": method_timings, + "file": packet_path.name, + "nested_avbd_resolved_solver_identity": linked_avbd_identity, + "nested_avbd_sha256": linked_avbd_hash, + "nested_avbd_source_provenance_digest": linked_avbd_provenance["digest"], + "resolved_solver_identity": identity, + "scene_spec_fingerprint": expected_fingerprint, + "sha256": shared._sha256(packet_path), + "source_provenance_digest": provenance["digest"], + "visual_evidence": { + "bend_screenshot": validated_visuals["bend"]["screenshot"], + "retention_screenshot": validated_visuals["retention"]["screenshot"], + "semantic_review": { + "sha256": semantic_hash, + "verdict": "pass", + }, + }, + } + + +def _validate_visual_review( + review_path: Path, + *, + fracture_screenshot: Path, + collapse_screenshot: Path, + paper_figure: Path, +) -> dict[str, Any]: + review = shared._load_json(review_path) + shared._require_exact( + review.get("schema_version"), + "dart.visual_semantic_review/v1", + "visual review schema_version", + ) + shared._require_exact(review.get("scene"), SCENE_ID, "visual review scene") + shared._require_exact( + review.get("verdict"), + "pass", + "visual review verdict", + ) + fields = ( + "reviewer_capability", + "claim_and_expected_observation", + "text_oracle", + "visible_observation", + "reconciliation_and_verdict", + "not_proven_and_limitations", + ) + for key in fields: + value = review.get(key) + if not isinstance(value, str) or not value.strip(): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"visual review {key} must be non-empty" + ) + if "image" not in review["reviewer_capability"].lower(): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "visual review must name an image-capable reviewer" + ) + if ( + "viewreport" + not in review["reconciliation_and_verdict"].replace(" ", "").lower() + ): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "visual review reconciliation must account for ViewReports" + ) + limitations = review["not_proven_and_limitations"].lower() + for required in ("cuda", "unpublished", "xpbd"): + if required not in limitations: + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"visual review limitations must name {required}" + ) + if "published" not in limitations or "timing" not in limitations: + raise AvbdPaperSequentialImpulseComparisonPacketError( + "visual review limitations must name published/source timing" + ) + + expected = { + "fracture_frame_14": fracture_screenshot, + "collapse_frame_120": collapse_screenshot, + "paper_figure_13_reference": paper_figure, + } + entries = review.get("inspected_images") + if not isinstance(entries, list): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "visual review inspected_images must be a list" + ) + by_role = { + entry.get("role"): entry + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("role"), str) + } + if set(by_role) != set(expected): + raise AvbdPaperSequentialImpulseComparisonPacketError( + "visual review must inspect fracture, collapse, and paper images" + ) + inspected = [] + for role, path in expected.items(): + entry = by_role[role] + file_value = entry.get("file") + if ( + not isinstance(file_value, str) + or Path(file_value).resolve() != path.resolve() + ): + raise AvbdPaperSequentialImpulseComparisonPacketError( + f"visual review {role} file does not match inspected image" + ) + expected_hash = shared._sha256(path) + shared._require_exact( + entry.get("sha256"), + expected_hash, + f"visual review {role} sha256", + ) + inspected.append( + { + "file": path.name, + "role": role, + "sha256": expected_hash, + } + ) + return { + "claim_and_expected_observation": review["claim_and_expected_observation"], + "file": review_path.name, + "inspected_images": inspected, + "not_proven_and_limitations": review["not_proven_and_limitations"], + "reconciliation_and_verdict": review["reconciliation_and_verdict"], + "reviewer_capability": review["reviewer_capability"], + "sha256": shared._sha256(review_path), + "text_oracle": review["text_oracle"], + "verdict": "pass", + "visible_observation": review["visible_observation"], + } + + +def make_packet( + *, + benchmark_json: Path, + fracture_capture_manifest: Path, + fracture_image_verdict_json: Path, + collapse_capture_manifest: Path, + collapse_image_verdict_json: Path, + visual_review_json: Path, + paper_pdf: Path, + paper_figure_image: Path, + avbd_vbd_packet: Path, +) -> dict[str, Any]: + fracture, fracture_screenshot = _validate_capture( + fracture_capture_manifest, + expected_frame=14, + ) + fracture["image_verdict"] = shared._validate_image_verdict( + fracture_image_verdict_json, + fracture_screenshot, + expected_frame=14, + expected_scene_id=SCENE_ID, + ) + collapse, collapse_screenshot = _validate_capture( + collapse_capture_manifest, + expected_frame=120, + ) + collapse["image_verdict"] = shared._validate_image_verdict( + collapse_image_verdict_json, + collapse_screenshot, + expected_frame=120, + expected_scene_id=SCENE_ID, + ) + fingerprint = fracture["scene_metrics"]["scene_spec_fingerprint"] + shared._require_exact( + collapse["scene_metrics"]["scene_spec_fingerprint"], + fingerprint, + "fracture/collapse scene fingerprint", + ) + benchmark = _validate_benchmark( + benchmark_json, + expected_fingerprint=fingerprint, + ) + linked_avbd_vbd = _validate_linked_avbd_vbd_packet( + avbd_vbd_packet, + benchmark_sha256=benchmark["json_sha256"], + expected_fingerprint=fingerprint, + ) + paper_reference = shared._validate_paper_artifacts( + paper_pdf, + paper_figure_image, + ) + semantic_review = _validate_visual_review( + visual_review_json, + fracture_screenshot=fracture_screenshot, + collapse_screenshot=collapse_screenshot, + paper_figure=paper_figure_image, + ) + + si_median = float(benchmark["method"]["timing"]["median_cpu_time_per_step_ns"]) + linked_timings = linked_avbd_vbd["benchmark_method_timings"] + benchmark["comparison"] = { + "basis": ( + "same benchmark JSON, executable, host, reconstructed scene " + "fingerprint, trajectory length, time step, and 20-sweep budget" + ), + "sequential_impulse_to_avbd_median_cpu_cost_ratio": ( + si_median / float(linked_timings["avbd"]["median_cpu_time_per_step_ns"]) + ), + "sequential_impulse_to_vbd_median_cpu_cost_ratio": ( + si_median / float(linked_timings["vbd"]["median_cpu_time_per_step_ns"]) + ), + } + + return { + "benchmark": benchmark, + "claim_boundary": ( + "This packet covers the matched Sequential Impulse Figure 13 row " + "and links the same-host AVBD/VBD comparison packet. Together " + "they establish three of the four published comparison rows. It " + "does not reproduce XPBD, claim exact unpublished source-scene " + "constants, establish CUDA parity, or compare against a " + "published/source timing." + ), + "correctness": { + "determinism_test": ( + "test_sequential_impulse_paper_breakable_wall_is_deterministic" + ), + "outcome_test": ( + "test_sequential_impulse_paper_breakable_wall_matches_" + "figure13_contract" + ), + "public_configuration": { + "rigid_body_solver": "SEQUENTIAL_IMPULSE", + "rigid_constraint_options": { + "iterations": RIGID_CONSTRAINT_ITERATIONS, + }, + "scene_spec_fingerprint": fingerprint, + "time_step": TIME_STEP, + }, + "solver_kernel_tests": [ + ("SequentialImpulseContact." "HardFixedJointCancelsRelativeVelocity"), + ( + "SequentialImpulseContact." + "ContactAndFixedJointRowsConvergeTogether" + ), + ( + "SequentialImpulseContact." + "FixedJointBreaksFromImpulseLoadAndStaysExcluded" + ), + ( + "SequentialImpulseContact." + "PublicFixedJointPostStabilizesWithoutVelocityInjection" + ), + ], + }, + "linked_avbd_vbd_evidence": linked_avbd_vbd, + "packet": "avbd_paper_sequential_impulse_comparison", + "paper_reference": paper_reference, + "performance_claim_boundary": ( + "The ratios are descriptive same-host CPU costs for three public " + "DART families running the identical reconstructed scene and " + "20-sweep budget. They are not paper/reference speedup claims." + ), + "publication_observation": ( + "The Figure 13 Sequential Impulse row shows a clean initial " + "fracture followed by retained-constraint failure, slow bending, " + "and collapse." + ), + "reconstruction": { + "matched_with_avbd_and_vbd": { + "break_force": BREAK_FORCE, + "breakable_joints": BREAKABLE_JOINTS, + "bricks": BRICK_COUNT, + "impacting_balls": IMPACTING_BALLS, + "scene_spec_fingerprint": fingerprint, + }, + "published": { + "constraint_iterations_or_substeps": (RIGID_CONSTRAINT_ITERATIONS), + "paper_locator": PAPER_LOCATOR, + "time_step": TIME_STEP, + }, + "unpublished": ( + "wall dimensions, body materials, ball properties and launch " + "conditions, break threshold, and exact source scene" + ), + }, + "reproduction": { + "benchmark_command": shared._benchmark_reproduction_command( + "^BM_(Avbd|Vbd|SequentialImpulse)" + "PaperBreakableWallStep/iterations:120$" + ), + "capture_commands": [ + ( + "pixi run py-demo-capture -- " + f"--scene {SCENE_ID} --frames 14 --width 1280 " + "--height 720 --view front --camera-azimuth -112.5 " + "--camera-elevation 35.52338329811104 " + "--camera-distance 22 --camera-target 0,0.45,1.6 " + "--capture-label fracture " + "--output-dir " + ), + ( + "pixi run py-demo-capture -- " + f"--scene {SCENE_ID} --frames 120 --width 1280 " + "--height 720 --view front --camera-azimuth -112.5 " + "--camera-elevation 35.52338329811104 " + "--camera-distance 22 --camera-target 0,0.45,1.6 " + "--capture-label collapse " + "--output-dir " + ), + ], + "image_verdict_commands": [ + ( + "pixi run image-verdict -- " + f"/{SCENE_ID}_fracture.png " + f"--meta scene={SCENE_ID} --meta frame=14 " + f"--meta view={CAMERA_VIEW} " + "--out /image_verdict.json" + ), + ( + "pixi run image-verdict -- " + f"/{SCENE_ID}_collapse.png " + f"--meta scene={SCENE_ID} --meta frame=120 " + f"--meta view={CAMERA_VIEW} " + "--out /image_verdict.json" + ), + ], + }, + "resolved_solver_identity": RESOLVED_SOLVER_IDENTITY, + "schema_version": AVBD_PACKET_SCHEMA_VERSION, + "scene": SCENE_ID, + "source_provenance": _source_provenance(), + "target": { + "complete_paper_reproduction": False, + "contract_rows": ["avbd.paper.fig.13"], + "covered_slice": ( + "matched CPU Sequential Impulse fracture/collapse row plus " + "linked AVBD and VBD numeric, performance, and assessed " + "visual evidence" + ), + }, + "visual_evidence": { + "collapse": collapse, + "fracture": fracture, + "semantic_review": semantic_review, + }, + } + + +def main(argv: list[str]) -> int: + args = parse_args(argv) + try: + packet = make_packet( + benchmark_json=args.benchmark_json, + fracture_capture_manifest=args.fracture_capture_manifest, + fracture_image_verdict_json=args.fracture_image_verdict_json, + collapse_capture_manifest=args.collapse_capture_manifest, + collapse_image_verdict_json=args.collapse_image_verdict_json, + visual_review_json=args.visual_review_json, + paper_pdf=args.paper_pdf, + paper_figure_image=args.paper_figure_image, + avbd_vbd_packet=args.avbd_vbd_packet, + ) + except ( + OSError, + ValueError, + shared.AvbdPaperBreakableWallPacketError, + comparison.AvbdPaperVbdComparisonPacketError, + AvbdPaperSequentialImpulseComparisonPacketError, + ) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(packet, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + print(f"Wrote {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/write_avbd_paper_vbd_comparison_packet.py b/scripts/write_avbd_paper_vbd_comparison_packet.py index face0693afa7b..a008fae6e4177 100644 --- a/scripts/write_avbd_paper_vbd_comparison_packet.py +++ b/scripts/write_avbd_paper_vbd_comparison_packet.py @@ -19,6 +19,7 @@ import write_avbd_paper_breakable_wall_packet as shared # noqa: E402 from avbd_packet_schema import ( # noqa: E402 AVBD_PACKET_SCHEMA_VERSION, + PAPER_PACKET_SOURCE_PATHS, make_resolved_solver_identity, ) @@ -41,25 +42,25 @@ COLLISION_SHAPES = 256 BREAKABLE_JOINTS = 712 IMPACTING_BALLS = 3 -BREAK_FORCE = 8500.0 -CAMERA_DISTANCE = 11.0 -CAMERA_TARGET = (0.0, 0.0, 1.4) -CAMERA_VIEW = "front" -VIEW_FOCUS = ( - "vbd_paper_wall_brick_00_00_visual", - "vbd_paper_wall_brick_00_20_visual", - "vbd_paper_wall_brick_11_00_visual", - "vbd_paper_wall_brick_11_20_visual", +BREAK_FORCE = shared.BREAK_FORCE +CAMERA_DISTANCE = shared.CAMERA_DISTANCE +CAMERA_TARGET = shared.CAMERA_TARGET +CAMERA_VIEW = shared.CAMERA_VIEW +VIEW_FOCUS = tuple( + f"vbd_paper_wall_brick_{row:02d}_{column:02d}_visual" + for row in range(12) + for column in range(21) ) CHECKPOINTS = { - 14: { + 18: { "label": "bend", "checkpoint": "bend", "threshold_checks": { "bend_is_spatially_resolved": True, "finite_state": True, "no_fracture": True, + "retained_joint_rows_satisfied": True, "topology_retained": True, "wall_bend_is_distributed": True, "wall_bends": True, @@ -71,6 +72,7 @@ "threshold_checks": { "finite_state": True, "no_fracture": True, + "retained_joint_rows_satisfied": True, "topology_retained": True, "wall_retained": True, }, @@ -78,13 +80,20 @@ } OUTCOME_ORACLE = { - "evaluation_frame": 14, + "impact_damage_displacement_threshold": 0.1, + "retained_displacement_threshold": 0.5, + "impact_band_radius": 0.85, + "outside_radius": 1.15, + "evaluation_frame": 18, "retention_evaluation_frame": 120, + "joint_evidence_frames": [18, 120], "maximum_broken_joints": 0, + "maximum_unbroken_joint_angular_residual_radians": 0.02, + "maximum_unbroken_joint_linear_residual": 0.025, "minimum_unbroken_joints": BREAKABLE_JOINTS, - "minimum_maximum_wall_normal_displacement": 0.20, - "minimum_rms_wall_normal_displacement": 0.10, - "bent_brick_displacement_threshold": 0.10, + "minimum_maximum_wall_normal_displacement": 0.10, + "minimum_rms_wall_normal_displacement": 0.05, + "bent_brick_displacement_threshold": 0.05, "minimum_bent_bricks": 100, "minimum_total_retained_fraction": 0.99, } @@ -92,6 +101,7 @@ BENCHMARK_RUNS = { "avbd": "BM_AvbdPaperBreakableWallStep/iterations:120", "vbd": "BM_VbdPaperBreakableWallStep/iterations:120", + "sequential-impulse": ("BM_SequentialImpulsePaperBreakableWallStep/iterations:120"), } RESOLVED_SOLVER_IDENTITY = make_resolved_solver_identity( @@ -106,21 +116,8 @@ ) REPO_ROOT = SCRIPT_DIR.parent -SOURCE_PATHS = ( - Path("dart/simulation/world.cpp"), - Path("dart/simulation/world.hpp"), - Path("dart/simulation/world_options.hpp"), - Path("dart/simulation/compute/rigid_body_contact_stage.cpp"), - Path("dart/simulation/detail/rigid_avbd/rigid_block_kernel.hpp"), - Path("dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp"), - Path("dart/simulation/detail/world_step_schedule.hpp"), - Path("python/examples/demos/scenes/avbd_paper_breakable_wall.py"), - Path("python/examples/demos/scenes/vbd_paper_breakable_wall.py"), - Path("python/tests/integration/test_demos_cycle.py"), - Path("tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp"), - Path("tests/unit/simulation/world/test_world.cpp"), - Path("scripts/avbd_packet_schema.py"), - Path("scripts/write_avbd_paper_vbd_comparison_packet.py"), +SOURCE_PATHS = tuple( + Path(path) for path in PAPER_PACKET_SOURCE_PATHS[DEFAULT_OUTPUT.name] ) @@ -289,8 +286,51 @@ def _validate_outcome( expected_frame * TIME_STEP, f"frame {expected_frame} outcome world_time", ) + shared._validate_joint_evidence( + outcome, + expected_broken_count=0, + expected_broken_ids_sha256=( + "e3b0c44298fc1c149afbf4c8996fb924" "27ae41e4649b934ca495991b7852b855" + ), + expected_outside_unbroken_count=484, + label=f"frame {expected_frame} outcome", + ) + shared._require_exact( + outcome.get("broken_joint_impact_region_counts"), + [0, 0, 0], + f"frame {expected_frame} broken_joint_impact_region_counts", + ) + shared._require_exact( + outcome.get("broken_joints_outside_impact_regions"), + 0, + f"frame {expected_frame} broken_joints_outside_impact_regions", + ) + maximum_linear_residual = shared._finite_number( + outcome.get("maximum_unbroken_joint_linear_residual"), + f"frame {expected_frame} maximum_unbroken_joint_linear_residual", + ) + if ( + maximum_linear_residual + > OUTCOME_ORACLE["maximum_unbroken_joint_linear_residual"] + ): + raise AvbdPaperVbdComparisonPacketError( + f"frame {expected_frame} retained-joint linear residual exceeds " + "the VBD oracle" + ) + maximum_angular_residual = shared._finite_number( + outcome.get("maximum_unbroken_joint_angular_residual_radians"), + f"frame {expected_frame} " "maximum_unbroken_joint_angular_residual_radians", + ) + if ( + maximum_angular_residual + > OUTCOME_ORACLE["maximum_unbroken_joint_angular_residual_radians"] + ): + raise AvbdPaperVbdComparisonPacketError( + f"frame {expected_frame} retained-joint angular residual exceeds " + "the VBD oracle" + ) - if expected_frame == 14: + if expected_frame == 18: minimums = { "maximum_wall_normal_displacement": ( OUTCOME_ORACLE["minimum_maximum_wall_normal_displacement"] @@ -310,15 +350,13 @@ def _validate_outcome( f"frame {expected_frame} outcome {key} must be >= " f"{minimum}, got {value}" ) - if ( - shared._finite_number( - outcome.get("contact_count"), - "frame 14 outcome contact_count", - ) - <= 0.0 - ): + contact_count = shared._finite_number( + outcome.get("contact_count"), + "frame 18 outcome contact_count", + ) + if contact_count < 0.0: raise AvbdPaperVbdComparisonPacketError( - "frame 14 must record active impact contact" + "frame 18 outcome contact_count must be non-negative" ) else: retained = shared._finite_number( @@ -535,6 +573,11 @@ def _validate_capture( logged_latest=events[-1], width=width, ) + capture_source_provenance = shared._validate_capture_provenance( + manifest, + metrics_events=metrics_events, + screenshot=screenshot, + ) return ( { "camera": shared._validate_camera(manifest), @@ -549,6 +592,7 @@ def _validate_capture( "sha256": shared._sha256(manifest_path), }, "scene_metrics": scene_metrics, + "source_provenance": capture_source_provenance, "scene_metrics_events": { "event_count": len(events), "file": metrics_events.name, @@ -734,6 +778,7 @@ def _validate_benchmark( "release", "benchmark library_build_type", ) + benchmark_source_provenance = shared._validate_benchmark_source_provenance(context) avbd_median = float(methods["avbd"]["timing"]["median_cpu_time_per_step_ns"]) vbd_median = float(methods["vbd"]["timing"]["median_cpu_time_per_step_ns"]) return { @@ -747,12 +792,15 @@ def _validate_benchmark( "library_version", "mhz_per_cpu", "num_cpus", + "benchmark_source_sha256", + "capture_source_provenance_digest", ) if key in context }, "json_sha256": shared._sha256(benchmark_path), "methods": methods, "scene_spec_fingerprint": expected_fingerprint, + "source_provenance": benchmark_source_provenance, "comparison": { "basis": ( "same executable, host, reconstructed scene fingerprint, " @@ -941,6 +989,25 @@ def _validate_avbd_packet( shared.RIGID_CONSTRAINT_ITERATIONS, f"linked AVBD frame {expected_frame} outcome iterations", ) + shared._validate_joint_evidence( + outcome, + expected_broken_count=expected_outcome["broken_joints"], + expected_broken_ids_sha256=shared.OUTCOME_ORACLE[ + "expected_broken_joint_ids_sha256" + ], + expected_outside_unbroken_count=405, + label=f"linked AVBD frame {expected_frame} outcome", + ) + shared._require_exact( + outcome.get("broken_joint_impact_region_counts"), + [18, 44, 13], + f"linked AVBD frame {expected_frame} impact-region counts", + ) + shared._require_exact( + outcome.get("broken_joints_outside_impact_regions"), + 79, + f"linked AVBD frame {expected_frame} outside-region count", + ) semantic = visual.get("semantic_review") if not isinstance(semantic, dict) or semantic.get("verdict") != "pass": @@ -1121,7 +1188,7 @@ def _validate_visual_review( ) expected = { - "bend_frame_14": bend_screenshot, + "bend_frame_18": bend_screenshot, "retention_frame_120": retention_screenshot, "paper_figure_13_reference": paper_figure, } @@ -1185,12 +1252,12 @@ def make_packet( ) -> dict[str, Any]: bend, bend_screenshot = _validate_capture( bend_capture_manifest, - expected_frame=14, + expected_frame=18, ) bend["image_verdict"] = shared._validate_image_verdict( bend_image_verdict_json, bend_screenshot, - expected_frame=14, + expected_frame=18, expected_scene_id=SCENE_ID, ) retention, retention_screenshot = _validate_capture( @@ -1290,31 +1357,45 @@ def make_packet( ), }, "reproduction": { - "benchmark_command": ( - "pixi run bm -- bm_avbd_rigid_fixed_joint -- " - "--benchmark_filter=" - "'^BM_(Avbd|Vbd)PaperBreakableWallStep/iterations:120$' " - "--benchmark_repetitions=5 " - "--benchmark_report_aggregates_only=true " - "--benchmark_out= " - "--benchmark_out_format=json" + "benchmark_command": shared._benchmark_reproduction_command( + "^BM_(Avbd|Vbd)PaperBreakableWallStep/iterations:120$" ), "capture_commands": [ ( "pixi run py-demo-capture -- " - f"--scene {SCENE_ID} --frames 14 --width 1280 " - "--height 720 --view front --camera-distance 11 " - "--camera-target 0,0,1.4 --capture-label bend " + f"--scene {SCENE_ID} --frames 18 --width 1280 " + "--height 720 --view front --camera-azimuth -112.5 " + "--camera-elevation 35.52338329811104 " + "--camera-distance 22 --camera-target 0,0.45,1.6 " + "--capture-label bend " "--output-dir " ), ( "pixi run py-demo-capture -- " f"--scene {SCENE_ID} --frames 120 --width 1280 " - "--height 720 --view front --camera-distance 11 " - "--camera-target 0,0,1.4 --capture-label retention " + "--height 720 --view front --camera-azimuth -112.5 " + "--camera-elevation 35.52338329811104 " + "--camera-distance 22 --camera-target 0,0.45,1.6 " + "--capture-label retention " "--output-dir " ), ], + "image_verdict_commands": [ + ( + "pixi run image-verdict -- " + f"/{SCENE_ID}_bend.png " + f"--meta scene={SCENE_ID} --meta frame=18 " + f"--meta view={CAMERA_VIEW} " + "--out /image_verdict.json" + ), + ( + "pixi run image-verdict -- " + f"/{SCENE_ID}_retention.png " + f"--meta scene={SCENE_ID} --meta frame=120 " + f"--meta view={CAMERA_VIEW} " + "--out /image_verdict.json" + ), + ], }, "resolved_solver_identity": RESOLVED_SOLVER_IDENTITY, "schema_version": AVBD_PACKET_SCHEMA_VERSION, diff --git a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp index 99d4521b47c5b..d4d477ecb198c 100644 --- a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp +++ b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp @@ -2104,7 +2104,7 @@ struct PaperBreakableWallFixture std::uint64_t paperBreakableWallFingerprint( const std::vector>& topologyRecords) { - constexpr std::string_view kTag = "avbd-paper-breakable-wall/v1"; + constexpr std::string_view kTag = "avbd-paper-breakable-wall/v3"; constexpr double kTimeStep = 1.0 / 60.0; constexpr double kGravity = -9.81; constexpr std::uint64_t kConstraintIterations = 20; @@ -2112,15 +2112,15 @@ std::uint64_t paperBreakableWallFingerprint( constexpr std::uint64_t kRows = 12; constexpr std::array kBrickSize{0.60, 0.30, 0.25}; constexpr std::array kBodyParameters{ - 4.0, 0.50, 0.62, 0.27, 0.02, 0.48, 100.0, 0.30, -5.0, 24.0}; + 200.0, 0.50, 0.62, 0.27, 0.02, 0.48, 40.0, 0.30, -5.0, 24.0}; constexpr std::array, 3> kImpactTargets{{ {-3.10, 1.55}, - {0.00, 1.75}, + {-0.31, 1.75}, {3.10, 2.35}, }}; constexpr std::array kGroundSize{16.0, 16.0, 0.50}; constexpr double kGroundFriction = 0.60; - constexpr double kBreakForce = 8500.0; + constexpr double kBreakForce = 5000.0; Fnv1a64 fingerprint; fingerprint.updateString(kTag); @@ -2164,18 +2164,18 @@ PaperBreakableWallFixture makePaperBreakableWallWorld( constexpr int kVerticalJoints = (kRows - 1) * (2 * kColumns - 1); constexpr int kBaseJoints = kColumns; constexpr int kJointCount = kHorizontalJoints + kVerticalJoints + kBaseJoints; - constexpr double kBrickDensity = 4.0; + constexpr double kBrickDensity = 200.0; constexpr double kSpacingX = 0.62; constexpr double kSpacingZ = 0.27; constexpr double kBaseClearance = 0.02; - constexpr double kBreakForce = 8500.0; + constexpr double kBreakForce = 5000.0; constexpr double kBallRadius = 0.48; - constexpr double kBallMass = 100.0; + constexpr double kBallMass = 40.0; constexpr double kBallLaunchSpeed = 24.0; const Eigen::Vector3d brickSize(0.60, 0.30, 0.25); const std::array impactTargets{ Eigen::Vector2d(-3.10, 1.55), - Eigen::Vector2d(0.00, 1.75), + Eigen::Vector2d(-0.31, 1.75), Eigen::Vector2d(3.10, 2.35)}; sx::WorldOptions options; @@ -2185,7 +2185,9 @@ PaperBreakableWallFixture makePaperBreakableWallWorld( options.rigidConstraintOptions.iterations = 20; auto world = std::make_unique(options); const std::string solverPrefix - = solver == sx::RigidBodySolver::Vbd ? "vbd" : "avbd"; + = solver == sx::RigidBodySolver::SequentialImpulse ? "sequential_impulse" + : solver == sx::RigidBodySolver::Vbd ? "vbd" + : "avbd"; auto ground = addAvbdDemo3dSourceBox( *world, @@ -4049,7 +4051,9 @@ void runPaperBreakableWallStepBenchmark( constexpr std::size_t kTrajectoryFrames = 120; auto fixture = makePaperBreakableWallWorld(solver); fixture.world->enterSimulationMode(); - const std::string bodyPrefix = std::string(solverKey) + "_paper_wall_ball_"; + std::string objectKey(solverKey); + std::ranges::replace(objectKey, '-', '_'); + const std::string bodyPrefix = objectKey + "_paper_wall_ball_"; const auto bodyNames = fixture.world->getRigidBodyNames(); std::size_t collisionShapeCount = 0; @@ -4176,6 +4180,14 @@ static void BM_VbdPaperBreakableWallStep(benchmark::State& state) } BENCHMARK(BM_VbdPaperBreakableWallStep)->Iterations(120); +//============================================================================== +static void BM_SequentialImpulsePaperBreakableWallStep(benchmark::State& state) +{ + runPaperBreakableWallStepBenchmark( + state, sx::RigidBodySolver::SequentialImpulse, "sequential-impulse"); +} +BENCHMARK(BM_SequentialImpulsePaperBreakableWallStep)->Iterations(120); + //============================================================================== static void BM_AvbdRigidBreakableJointStep(benchmark::State& state) { diff --git a/tests/test_avbd_packet_schema.py b/tests/test_avbd_packet_schema.py index c5b11b1e77804..a15ba72f328ca 100644 --- a/tests/test_avbd_packet_schema.py +++ b/tests/test_avbd_packet_schema.py @@ -216,6 +216,18 @@ def test_public_vbd_family_records_matching_world_owned_rows(): assert identity["rigid_point_joint_solver"] == "vbd" +def test_public_sequential_impulse_family_records_matching_world_owned_rows(): + identity = schema.make_resolved_solver_identity( + resolved_rigid_contact_family="sequential-impulse", + rigid_point_joint_solver="sequential_impulse", + avbd_rigid_contact_config_emplaced=False, + recorded_from="World resolved-configuration report", + ) + assert identity["rigid_contact_solver"] == "sequential_impulse" + assert identity["rigid_point_joint_solver"] == "sequential_impulse" + assert identity["rigid_contact_selection"] == "contact_solver_method" + + def test_public_world_family_rejects_contradictory_point_joint_family(): packet = { "schema_version": schema.AVBD_PACKET_SCHEMA_VERSION, @@ -235,32 +247,39 @@ def test_public_world_family_rejects_contradictory_point_joint_family(): ) -@pytest.mark.parametrize( - ("selection", "contact_solver", "emplaced"), - [ - ("body_opt_in", "avbd", True), - ("contact_solver_method", "sequential_impulse", False), - ], -) -def test_vbd_point_rows_require_public_vbd_world_family( - selection, - contact_solver, - emplaced, -): +def test_vbd_point_rows_require_public_vbd_world_family(): packet = { "schema_version": schema.AVBD_PACKET_SCHEMA_VERSION, schema.RESOLVED_SOLVER_IDENTITY_KEY: { - "avbd_rigid_contact_config_emplaced": emplaced, + "avbd_rigid_contact_config_emplaced": False, "recorded_from": "impossible VBD point-row identity", - "rigid_contact_selection": selection, - "rigid_contact_solver": contact_solver, + "rigid_contact_selection": "contact_solver_method", + "rigid_contact_solver": "sequential_impulse", "rigid_point_joint_solver": "vbd", }, } errors = schema.resolved_solver_identity_errors(packet, "case") - assert any("rigid_point_joint_solver 'vbd' requires" in error for error in errors) + assert any( + "requires rigid_point_joint_solver 'none' or 'sequential_impulse'" in error + for error in errors + ) + + +@pytest.mark.parametrize("contact_solver", ["boxed_lcp", "sequential_impulse"]) +def test_contact_method_can_select_contacts_with_sequential_impulse_point_rows( + contact_solver, +): + identity = schema.make_resolved_solver_identity( + resolved_rigid_contact_family=contact_solver.replace("_", "-"), + rigid_point_joint_solver="sequential_impulse", + avbd_rigid_contact_config_emplaced=False, + recorded_from="World resolved-configuration report", + ) + assert identity["rigid_contact_selection"] == "contact_solver_method" + assert identity["rigid_contact_solver"] == contact_solver + assert identity["rigid_point_joint_solver"] == "sequential_impulse" def test_schema_v2_cannot_claim_vbd_without_selection_source_contract(): @@ -281,6 +300,26 @@ def test_schema_v2_cannot_claim_vbd_without_selection_source_contract(): ) +def test_schema_v2_cannot_claim_sequential_impulse_point_rows(): + packet = { + "schema_version": 2, + schema.RESOLVED_SOLVER_IDENTITY_KEY: { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": "pre-selection-source schema", + "rigid_contact_solver": "sequential_impulse", + "rigid_point_joint_solver": "sequential_impulse", + }, + } + + errors = schema.resolved_solver_identity_errors(packet, "case") + + assert any( + "Sequential Impulse point-joint solver identities require " + "schema_version 3" in error + for error in errors + ) + + def test_unrecognized_report_family_raises(): with pytest.raises(ValueError): schema.resolved_rigid_contact_solver_from_report("magic-solver") @@ -300,3 +339,49 @@ def test_invalid_point_joint_solver_rejected(): avbd_rigid_contact_config_emplaced=False, recorded_from="bad point-joint solver", ) + + +def _contact_method_identity_packet(version, point_joint_solver): + return { + "schema_version": version, + schema.RESOLVED_SOLVER_IDENTITY_KEY: { + "avbd_rigid_contact_config_emplaced": False, + "recorded_from": "resolved configuration report", + "rigid_contact_selection": "contact_solver_method", + "rigid_contact_solver": "sequential_impulse", + "rigid_point_joint_solver": point_joint_solver, + }, + } + + +def test_contact_method_v3_accepts_legacy_avbd_point_rows(): + # Pre-slice packets legitimately recorded 'avbd' point-joint identities + # under 'contact_solver_method' because public pair constraints then ran + # the private AVBD projection; version 3 packets must stay valid. + packet = _contact_method_identity_packet(3, "avbd") + assert schema.resolved_solver_identity_errors(packet, "case") == [] + + +def test_contact_method_current_version_rejects_avbd_point_rows(): + packet = _contact_method_identity_packet(schema.AVBD_PACKET_SCHEMA_VERSION, "avbd") + errors = schema.resolved_solver_identity_errors(packet, "case") + assert any( + "rigid_point_joint_solver 'none' or 'sequential_impulse'" in error + for error in errors + ) + + +def test_contact_method_v3_still_rejects_vbd_point_rows(): + packet = _contact_method_identity_packet(3, "vbd") + errors = schema.resolved_solver_identity_errors(packet, "case") + assert any( + "cannot carry rigid_point_joint_solver 'vbd'" in error for error in errors + ) + + +def test_future_schema_version_is_rejected_fail_closed(): + packet = {"schema_version": schema.AVBD_PACKET_SCHEMA_VERSION + 1} + + errors = schema.packet_schema_version_errors(packet, "case") + + assert any("newer than the supported version" in error for error in errors) diff --git a/tests/test_check_avbd_packets.py b/tests/test_check_avbd_packets.py index 936fc2ae1835e..daa3f3b3053e6 100644 --- a/tests/test_check_avbd_packets.py +++ b/tests/test_check_avbd_packets.py @@ -1,7 +1,9 @@ """Tests for scripts/check_avbd_packets.py (PLAN-091 WP-091.1).""" +import hashlib import importlib.util import json +import struct import sys from pathlib import Path @@ -23,7 +25,7 @@ def _load_module(): def _identity(**overrides): identity = { "rigid_contact_solver": "sequential_impulse", - "rigid_point_joint_solver": "avbd", + "rigid_point_joint_solver": "sequential_impulse", "avbd_rigid_contact_config_emplaced": False, "recorded_from": "scene-construction audit (WP-091.1)", "rigid_contact_selection": "contact_solver_method", @@ -38,6 +40,33 @@ def _write_packet(tmp_path, name, packet): return path +def _source_provenance(root: Path, *relative_paths: str): + combined = hashlib.sha256() + files = [] + for relative_text in relative_paths: + payload = (root / relative_text).read_bytes() + encoded_path = relative_text.encode("utf-8") + combined.update(struct.pack("getEntity())); - EXPECT_TRUE(restoredJointModel.hasRigidBodyFixedJointAnchors); + EXPECT_TRUE(restoredJointModel.hasRigidBodyPairConstraintGeometry); EXPECT_LT( - (restoredJointModel.rigidBodyFixedJointLocalAnchorParent - parentAnchor) + (restoredJointModel.rigidBodyPairConstraintLocalAnchorParent + - parentAnchor) .norm(), 1e-12); EXPECT_LT( - (restoredJointModel.rigidBodyFixedJointLocalAnchorChild - childAnchor) + (restoredJointModel.rigidBodyPairConstraintLocalAnchorChild - childAnchor) .norm(), 1e-12); EXPECT_LT( @@ -14584,13 +14585,14 @@ TEST( ASSERT_EQ(restoredJointModel.limits.effortUpper.size(), 1); EXPECT_DOUBLE_EQ(restoredJointModel.limits.effortLower[0], -1000.0); EXPECT_DOUBLE_EQ(restoredJointModel.limits.effortUpper[0], 1000.0); - EXPECT_TRUE(restoredJointModel.hasRigidBodyFixedJointAnchors); + EXPECT_TRUE(restoredJointModel.hasRigidBodyPairConstraintGeometry); EXPECT_LT( - (restoredJointModel.rigidBodyFixedJointLocalAnchorParent - parentAnchor) + (restoredJointModel.rigidBodyPairConstraintLocalAnchorParent + - parentAnchor) .norm(), 1e-12); EXPECT_LT( - (restoredJointModel.rigidBodyFixedJointLocalAnchorChild - childAnchor) + (restoredJointModel.rigidBodyPairConstraintLocalAnchorChild - childAnchor) .norm(), 1e-12); EXPECT_LT( @@ -14789,13 +14791,14 @@ TEST( ASSERT_EQ(restoredJointModel.limits.effortUpper.size(), 1); EXPECT_DOUBLE_EQ(restoredJointModel.limits.effortLower[0], -1000.0); EXPECT_DOUBLE_EQ(restoredJointModel.limits.effortUpper[0], 1000.0); - EXPECT_TRUE(restoredJointModel.hasRigidBodyFixedJointAnchors); + EXPECT_TRUE(restoredJointModel.hasRigidBodyPairConstraintGeometry); EXPECT_LT( - (restoredJointModel.rigidBodyFixedJointLocalAnchorParent - parentAnchor) + (restoredJointModel.rigidBodyPairConstraintLocalAnchorParent + - parentAnchor) .norm(), 1e-12); EXPECT_LT( - (restoredJointModel.rigidBodyFixedJointLocalAnchorChild - childAnchor) + (restoredJointModel.rigidBodyPairConstraintLocalAnchorChild - childAnchor) .norm(), 1e-12); EXPECT_LT( @@ -14991,13 +14994,14 @@ TEST( EXPECT_EQ( restoredJointModel.childLink, sx::detail::toRegistryEntity(restoredChild->getEntity())); - EXPECT_TRUE(restoredJointModel.hasRigidBodyFixedJointAnchors); + EXPECT_TRUE(restoredJointModel.hasRigidBodyPairConstraintGeometry); EXPECT_LT( - (restoredJointModel.rigidBodyFixedJointLocalAnchorParent - parentAnchor) + (restoredJointModel.rigidBodyPairConstraintLocalAnchorParent + - parentAnchor) .norm(), 1e-12); EXPECT_LT( - (restoredJointModel.rigidBodyFixedJointLocalAnchorChild - childAnchor) + (restoredJointModel.rigidBodyPairConstraintLocalAnchorChild - childAnchor) .norm(), 1e-12); EXPECT_LT( @@ -16455,3 +16459,64 @@ TEST(VariationalLinkContact, SphereContactStopsSlidingLink) // sphere (the overshoot is a bounded penalty penetration). EXPECT_LT(maxX, touchX + 0.08); } + +//============================================================================== +// The solver-neutral rigid pair-constraint helpers and the private AVBD +// kernel currently keep separate definitions of the same masks, basis +// construction, normalization, and orientation-error math. This pin turns +// silent drift between the two owners into a test failure until the +// definitions are consolidated into one. +TEST(RigidPairConstraintNeutralHelpers, MatchPrivateAvbdKernelDefinitions) +{ + namespace neutral = dart::simulation::detail; + + const Eigen::Vector3d axisSamples[] = { + Eigen::Vector3d::UnitX(), + Eigen::Vector3d::UnitZ(), + Eigen::Vector3d(0.3, -0.7, 0.648).normalized(), + Eigen::Vector3d(1.0, 1.0, 1.0), + Eigen::Vector3d::Zero(), + Eigen::Vector3d(std::numeric_limits::quiet_NaN(), 0.0, 0.0), + }; + for (const Eigen::Vector3d& axis : axisSamples) { + const Eigen::Matrix3d neutral + = neutral::rigidPairConstraintAxesFromFreeAxis(axis); + const Eigen::Matrix3d kernel = dvbd::avbdRigidJointAxesFromFreeAxis(axis); + EXPECT_TRUE(neutral.isApprox(kernel, 0.0)) << axis.transpose(); + } + + const Eigen::Quaterniond orientationSamples[] = { + Eigen::Quaterniond::Identity(), + Eigen::Quaterniond(0.2, 0.4, -0.3, 0.6), + Eigen::Quaterniond(0.0, 0.0, 0.0, 0.0), + Eigen::Quaterniond(2.0, 0.0, 0.0, 0.0), + }; + for (const Eigen::Quaterniond& orientation : orientationSamples) { + const Eigen::Quaterniond neutral + = neutral::normalizeRigidPairConstraintOrientation(orientation); + const Eigen::Quaterniond kernel + = dvbd::normalizeAvbdRigidOrientation(orientation); + EXPECT_TRUE(neutral.coeffs().isApprox(kernel.coeffs(), 0.0)) + << orientation.coeffs().transpose(); + } + + const Eigen::Quaterniond current( + Eigen::AngleAxisd(0.9, Eigen::Vector3d(0.2, 0.5, -0.4).normalized())); + const Eigen::Quaterniond target( + Eigen::AngleAxisd(-2.4, Eigen::Vector3d(0.7, -0.1, 0.3).normalized())); + EXPECT_TRUE( + neutral::rigidPairConstraintOrientationError(current, target) + .isApprox(dvbd::avbdRigidBodyOrientationError(current, target), 0.0)); + + static_assert( + neutral::kRigidPairConstraintAllAxesMask + == dvbd::kAvbdRigidJointAllAxesMask); + for (std::uint8_t axis = 0u; axis < 4u; ++axis) { + EXPECT_EQ( + dvbd::avbdRigidJointAxisBit(axis), + neutral::rigidPairConstraintAxisBit(axis)); + EXPECT_EQ( + dvbd::avbdRigidJointAllButAxisMask(axis), + neutral::rigidPairConstraintAllButAxisMask(axis)); + } +} diff --git a/tests/unit/simulation/contact/test_boxed_lcp_contact.cpp b/tests/unit/simulation/contact/test_boxed_lcp_contact.cpp index 59f4d7442fbe7..92de6868ad264 100644 --- a/tests/unit/simulation/contact/test_boxed_lcp_contact.cpp +++ b/tests/unit/simulation/contact/test_boxed_lcp_contact.cpp @@ -6667,6 +6667,7 @@ TEST(AvbdContact, PenetratingRigidBodyProjectsVelocity) TEST(AvbdContact, FixedJointRowsParticipateInProjection) { auto avbd = buildDropScene(sx::ContactSolverMethod::SequentialImpulse, 0.49); + avbd->setRigidBodySolver(sx::RigidBodySolver::Avbd); avbd->setGravity(Eigen::Vector3d::Zero()); auto ground = avbd->getRigidBody("ground"); @@ -6714,6 +6715,7 @@ TEST(AvbdContact, FixedJointRowsProjectWithoutContacts) sx::WorldOptions options; options.timeStep = 0.005; options.gravity = Eigen::Vector3d::Zero(); + options.rigidBodySolver = sx::RigidBodySolver::Avbd; sx::World world(options); sx::RigidBodyOptions baseOptions; @@ -6757,6 +6759,7 @@ TEST(AvbdContact, FixedJointPoseBridgeCapturesSimulationEntryPose) sx::WorldOptions options; options.timeStep = 0.005; options.gravity = Eigen::Vector3d::Zero(); + options.rigidBodySolver = sx::RigidBodySolver::Avbd; sx::World world(options); sx::RigidBodyOptions baseOptions; @@ -6921,6 +6924,7 @@ TEST(AvbdContact, PublicRigidBodyFixedJointProjectsFromCapturedPose) sx::WorldOptions options; options.timeStep = 0.005; options.gravity = Eigen::Vector3d::Zero(); + options.rigidBodySolver = sx::RigidBodySolver::Avbd; sx::World world(options); sx::RigidBodyOptions baseOptions; @@ -6989,6 +6993,7 @@ TEST(AvbdContact, PublicRigidBodyRevoluteJointProjectsAnchor) sx::WorldOptions options; options.timeStep = 0.005; options.gravity = Eigen::Vector3d::Zero(); + options.rigidBodySolver = sx::RigidBodySolver::Avbd; sx::World world(options); sx::RigidBodyOptions baseOptions; @@ -7041,6 +7046,7 @@ TEST(AvbdContact, PublicRigidBodyPrismaticJointProjectsOrthogonalDrift) sx::WorldOptions options; options.timeStep = 0.005; options.gravity = Eigen::Vector3d::Zero(); + options.rigidBodySolver = sx::RigidBodySolver::Avbd; sx::World world(options); sx::RigidBodyOptions baseOptions; @@ -7083,6 +7089,7 @@ TEST(AvbdContact, PublicRigidBodyFixedJointSurvivesSaveLoad) sx::WorldOptions options; options.timeStep = 0.005; options.gravity = Eigen::Vector3d::Zero(); + options.rigidBodySolver = sx::RigidBodySolver::Avbd; sx::World world(options); sx::RigidBodyOptions baseOptions; @@ -7193,6 +7200,7 @@ TEST(AvbdContact, PublicRigidBodyFixedJointSurvivesSimulationModeSaveLoad) sx::WorldOptions options; options.timeStep = 0.005; options.gravity = Eigen::Vector3d::Zero(); + options.rigidBodySolver = sx::RigidBodySolver::Avbd; sx::World world(options); sx::RigidBodyOptions baseOptions; @@ -7248,11 +7256,12 @@ TEST(AvbdContact, PublicRigidBodyFixedJointSurvivesSimulationModeSaveLoad) } //============================================================================== -// Public fixed joints should remain active when ordinary, non-AVBD-opted -// contacts involve the fixed body. The contact still falls through to the -// selected default contact solver while the fixed-joint rows project -// independently. -TEST(AvbdContact, PublicFixedJointProjectsWithDefaultContactOnFixedBody) +// Sequential Impulse owns hard public pair rows alongside ordinary contact +// rows. Its iteration-budgeted post stabilization reduces pre-existing pose +// drift without injecting correction velocity. +TEST( + SequentialImpulseContact, + PublicFixedJointPostStabilizesWithoutVelocityInjection) { sx::WorldOptions options; options.timeStep = 0.005; @@ -7295,11 +7304,335 @@ TEST(AvbdContact, PublicFixedJointProjectsWithDefaultContactOnFixedBody) world.step(); + EXPECT_LT(std::abs(link.getTranslation().x() - 1.0), 0.05); EXPECT_LT(std::abs(link.getTranslation().x() - 1.0), driftBeforeStep); - EXPECT_LT(link.getLinearVelocity().x(), 0.0); + EXPECT_NEAR(link.getLinearVelocity().x(), 0.0, 1e-12); EXPECT_TRUE(base.getTranslation().isApprox(Eigen::Vector3d::Zero())); } +//============================================================================== +// Hard equality rows participate in the velocity solve itself. A fixed body +// should therefore cancel both relative linear and angular velocity before +// semi-implicit position integration. +TEST(SequentialImpulseContact, HardFixedJointCancelsRelativeVelocity) +{ + sx::WorldOptions options; + options.timeStep = 0.01; + options.gravity = Eigen::Vector3d::Zero(); + options.rigidConstraintOptions.iterations = 20u; + sx::World world(options); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + + sx::RigidBodyOptions linkOptions; + linkOptions.position = Eigen::Vector3d::UnitX(); + linkOptions.linearVelocity = Eigen::Vector3d(3.0, -2.0, 1.0); + linkOptions.angularVelocity = Eigen::Vector3d(-1.0, 2.0, 3.0); + auto link = world.addRigidBody("link", linkOptions); + (void)world.addJoint( + base, link, makeJointSpec("base_to_link", sx::JointType::Fixed)); + + const Eigen::Isometry3d initialPose = link.getTransform(); + world.enterSimulationMode(); + world.step(); + + EXPECT_TRUE( + link.getTransform().matrix().isApprox(initialPose.matrix(), 1e-10)); + EXPECT_LT(link.getLinearVelocity().norm(), 1e-10); + EXPECT_LT(link.getAngularVelocity().norm(), 1e-10); + EXPECT_EQ(world.computeStepMetrics().lastStepIterations, 20u); +} + +// Position-level post stabilization is a split correction: it reduces angular +// drift after the velocity solve but must not write the correction back into +// angular velocity. +TEST( + SequentialImpulseContact, + HardFixedJointPostStabilizesAngularDriftWithoutVelocity) +{ + sx::WorldOptions options; + options.timeStep = 0.01; + options.gravity = Eigen::Vector3d::Zero(); + sx::World world(options); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + + sx::RigidBodyOptions linkOptions; + linkOptions.position = Eigen::Vector3d::UnitX(); + auto link = world.addRigidBody("link", linkOptions); + (void)world.addJoint( + base, link, makeJointSpec("base_to_link", sx::JointType::Fixed)); + world.enterSimulationMode(); + + Eigen::Isometry3d driftedPose = link.getTransform(); + driftedPose.linear() + = Eigen::AngleAxisd(0.5, Eigen::Vector3d::UnitZ()).toRotationMatrix(); + link.setTransform(driftedPose); + world.step(); + + const Eigen::AngleAxisd residual(link.getTransform().linear()); + EXPECT_LT(std::abs(residual.angle()), 0.1); + EXPECT_GT(std::abs(residual.angle()), 0.0); + EXPECT_LT(link.getAngularVelocity().norm(), 1e-12); +} + +// Contact and hard pair rows share each outer PGS sweep. An overlapping +// projectile can transfer an impulse into a fixed link while the joint rows +// converge that link back to zero velocity. +TEST(SequentialImpulseContact, ContactAndFixedJointRowsConvergeTogether) +{ + sx::WorldOptions options; + options.timeStep = 0.01; + options.gravity = Eigen::Vector3d::Zero(); + options.rigidConstraintOptions.iterations = 20u; + sx::World world(options); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + + auto link = world.addRigidBody("link"); + link.setCollisionShape(sx::CollisionShape::makeBox(Eigen::Vector3d::Ones())); + (void)world.addJoint( + base, link, makeJointSpec("base_to_link", sx::JointType::Fixed)); + + sx::RigidBodyOptions projectileOptions; + projectileOptions.position = Eigen::Vector3d(-0.9, 0.0, 0.0); + projectileOptions.linearVelocity = Eigen::Vector3d(5.0, 0.0, 0.0); + auto projectile = world.addRigidBody("projectile", projectileOptions); + projectile.setCollisionShape( + sx::CollisionShape::makeBox(Eigen::Vector3d::Ones())); + + world.enterSimulationMode(); + ASSERT_FALSE(world.collide().empty()); + world.step(); + + EXPECT_LT(link.getLinearVelocity().norm(), 1e-3); + EXPECT_LT(link.getAngularVelocity().norm(), 1e-3); + EXPECT_LT(projectile.getLinearVelocity().x(), 1e-6); + EXPECT_EQ(world.computeStepMetrics().lastStepIterations, 20u); +} + +// Contact-method selection is independent of hard pair-row ownership. The +// boxed-LCP contact solve may therefore feed a public fixed joint that remains +// owned by the Sequential Impulse rigid-body family. +TEST(SequentialImpulseContact, BoxedLcpContactsKeepHardFixedJointRows) +{ + sx::WorldOptions options; + options.timeStep = 0.01; + options.gravity = Eigen::Vector3d::Zero(); + options.rigidConstraintOptions.iterations = 20u; + options.contactSolverMethod = sx::ContactSolverMethod::BoxedLcp; + sx::World world(options); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + + auto link = world.addRigidBody("link"); + link.setCollisionShape(sx::CollisionShape::makeBox(Eigen::Vector3d::Ones())); + (void)world.addJoint( + base, link, makeJointSpec("base_to_link", sx::JointType::Fixed)); + + sx::RigidBodyOptions projectileOptions; + projectileOptions.position = Eigen::Vector3d(-0.9, 0.0, 0.0); + projectileOptions.linearVelocity = Eigen::Vector3d(5.0, 0.0, 0.0); + auto projectile = world.addRigidBody("projectile", projectileOptions); + projectile.setCollisionShape( + sx::CollisionShape::makeBox(Eigen::Vector3d::Ones())); + + world.enterSimulationMode(); + ASSERT_FALSE(world.collide().empty()); + world.step(); + + EXPECT_LT(link.getLinearVelocity().norm(), 1e-3); + EXPECT_LT(link.getAngularVelocity().norm(), 1e-3); + EXPECT_GT(projectile.getLinearVelocity().x(), 0.0); + EXPECT_LT(projectile.getLinearVelocity().x(), 5.0); +} + +// Break-force handling compares the converged equality-row impulse divided by +// the step size against the public physical load threshold. Once marked +// broken, the row is absent from the next step. +TEST(SequentialImpulseContact, FixedJointBreaksFromImpulseLoadAndStaysExcluded) +{ + sx::WorldOptions options; + options.timeStep = 0.01; + options.gravity = Eigen::Vector3d::Zero(); + options.rigidConstraintOptions.iterations = 20u; + sx::World world(options); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + + sx::RigidBodyOptions linkOptions; + linkOptions.position = Eigen::Vector3d::UnitX(); + auto link = world.addRigidBody("link", linkOptions); + auto joint = world.addJoint( + base, link, makeJointSpec("base_to_link", sx::JointType::Fixed)); + joint.setBreakForce(10.0); + + world.enterSimulationMode(); + world.step(); + EXPECT_FALSE(joint.isBroken()); + + link.setLinearVelocity(Eigen::Vector3d(10.0, 0.0, 0.0)); + world.step(); + ASSERT_TRUE(joint.isBroken()); + + const double positionBeforeFreeStep = link.getTranslation().x(); + link.setLinearVelocity(Eigen::Vector3d::UnitX()); + world.step(); + EXPECT_GT(link.getTranslation().x(), positionBeforeFreeStep + 0.009); +} + +// Sequential Impulse deliberately owns hard rows only. A finite material +// policy must fail before simulation starts and remain rejected on a live +// runtime-family switch; AVBD is the explicit supported route. +TEST(SequentialImpulseContact, FiniteJointRowsFailClosedBeforeStepping) +{ + sx::WorldOptions options; + options.timeStep = 0.01; + options.gravity = Eigen::Vector3d::Zero(); + sx::World world(options); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + auto link = world.addRigidBody("link"); + auto joint = world.addJoint( + base, link, makeJointSpec("base_to_link", sx::JointType::Fixed)); + + sx::JointConstraintProjectionPolicy policy; + policy.linearStiffness = 1000.0; + joint.setConstraintProjectionPolicy(policy); + + EXPECT_THROW(world.enterSimulationMode(), sx::InvalidOperationException); + EXPECT_FALSE(world.isSimulationMode()); + + world.setRigidBodySolver(sx::RigidBodySolver::Avbd); + world.enterSimulationMode(); + EXPECT_THROW( + world.setRigidBodySolver(sx::RigidBodySolver::SequentialImpulse), + sx::InvalidOperationException); + EXPECT_EQ(world.getRigidBodySolver(), sx::RigidBodySolver::Avbd); +} + +// The finite-stiffness rejection must also hold when the policy changes after +// entering simulation mode: the per-step pipeline validation throws before +// the next step instead of silently keeping the hard rows. +TEST(SequentialImpulseContact, InSimulationFinitePolicyFailsClosedOnNextStep) +{ + sx::WorldOptions options; + options.timeStep = 0.01; + options.gravity = Eigen::Vector3d::Zero(); + sx::World world(options); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + auto link = world.addRigidBody("link"); + auto joint = world.addJoint( + base, link, makeJointSpec("base_to_link", sx::JointType::Fixed)); + + world.enterSimulationMode(); + world.step(); + + sx::JointConstraintProjectionPolicy policy; + policy.linearStiffness = 1000.0; + joint.setConstraintProjectionPolicy(policy); + + EXPECT_THROW(world.step(), sx::InvalidOperationException); +} + +// A joint whose endpoint state degenerates mid-run is skipped by the shared +// eligibility rule in both the expected-row count and the extraction, so the +// step must not throw a row-coverage exception; the degenerate state +// propagates exactly as it does for jointless worlds. +TEST(SequentialImpulseContact, DegenerateJointStateSkipsRowsWithoutThrowing) +{ + sx::WorldOptions options; + options.timeStep = 0.01; + options.gravity = Eigen::Vector3d::Zero(); + sx::World world(options); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + + sx::RigidBodyOptions linkOptions; + linkOptions.position = Eigen::Vector3d::UnitX(); + auto link = world.addRigidBody("link", linkOptions); + world.addJoint( + base, link, makeJointSpec("base_to_link", sx::JointType::Fixed)); + + world.enterSimulationMode(); + world.step(); + + auto& registry = dart::simulation::detail::registryOf(world); + auto& transform = registry.get( + dart::simulation::detail::toRegistryEntity(link.getEntity())); + transform.position + = Eigen::Vector3d::Constant(std::numeric_limits::quiet_NaN()); + + EXPECT_NO_THROW(world.step()); +} + +// A bounded velocity motor loads the joint it drives, so its accepted force +// counts toward the public break threshold identically under Sequential +// Impulse and AVBD. The huge link inertia saturates the bounded motor, so +// the accepted motor force reaches the effort limit while the passive rows +// stay lightly loaded. +TEST(SequentialImpulseContact, MotorLoadCountsTowardBreakForceAcrossFamilies) +{ + const auto motorBreaksJoint = [](sx::RigidBodySolver solver, + double breakForce) { + sx::WorldOptions options; + options.timeStep = 0.01; + options.gravity = Eigen::Vector3d::Zero(); + options.rigidBodySolver = solver; + options.rigidConstraintOptions.iterations = 20u; + sx::World world(options); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + + sx::RigidBodyOptions linkOptions; + linkOptions.mass = 1.0e6; + linkOptions.position = Eigen::Vector3d::UnitX(); + auto link = world.addRigidBody("link", linkOptions); + + auto joint = world.addJoint( + base, + link, + makeJointSpec( + "motor_break", sx::JointType::Revolute, Eigen::Vector3d::UnitZ())); + joint.setActuatorType(sx::ActuatorType::Velocity); + joint.setCommandVelocity(Eigen::VectorXd::Constant(1, 50.0)); + joint.setEffortLimits( + Eigen::VectorXd::Constant(1, -200.0), + Eigen::VectorXd::Constant(1, 200.0)); + joint.setBreakForce(breakForce); + + world.enterSimulationMode(); + for (int i = 0; i < 5; ++i) { + world.step(); + } + return joint.isBroken(); + }; + + EXPECT_TRUE(motorBreaksJoint(sx::RigidBodySolver::SequentialImpulse, 150.0)); + EXPECT_TRUE(motorBreaksJoint(sx::RigidBodySolver::Avbd, 150.0)); + EXPECT_FALSE(motorBreaksJoint(sx::RigidBodySolver::SequentialImpulse, 1.0e5)); + EXPECT_FALSE(motorBreaksJoint(sx::RigidBodySolver::Avbd, 1.0e5)); +} + //============================================================================== // The no-contact fixed-joint path should also route angular rows through the // private AVBD projection instead of only correcting point-anchor drift. @@ -7308,6 +7641,7 @@ TEST(AvbdContact, FixedJointAngularRowsProjectWithoutContacts) sx::WorldOptions options; options.timeStep = 0.005; options.gravity = Eigen::Vector3d::Zero(); + options.rigidBodySolver = sx::RigidBodySolver::Avbd; sx::World world(options); sx::RigidBodyOptions baseOptions; @@ -7350,6 +7684,7 @@ TEST(AvbdContact, FixedJointAngularRowsProjectWithoutContacts) TEST(AvbdContact, FixedJointRowsProjectWithFallbackContacts) { auto world = buildDropScene(sx::ContactSolverMethod::SequentialImpulse, 0.49); + world->setRigidBodySolver(sx::RigidBodySolver::Avbd); world->setGravity(Eigen::Vector3d::Zero()); auto sphere = world->getRigidBody("sphere"); diff --git a/tests/unit/simulation/world/test_serialization.cpp b/tests/unit/simulation/world/test_serialization.cpp index 7ea120e0bd10f..761bf52666b21 100644 --- a/tests/unit/simulation/world/test_serialization.cpp +++ b/tests/unit/simulation/world/test_serialization.cpp @@ -2123,6 +2123,7 @@ TEST(Serialization, RigidBodyJointAvbdStiffnessRoundTripsDesignMode) namespace sx = dart::simulation; sx::World world1; + world1.setRigidBodySolver(sx::RigidBodySolver::Avbd); sx::RigidBodyOptions parentOptions; parentOptions.isStatic = true; diff --git a/tests/unit/simulation/world/test_world.cpp b/tests/unit/simulation/world/test_world.cpp index 097ccbb0eacc8..d16531dc962cf 100644 --- a/tests/unit/simulation/world/test_world.cpp +++ b/tests/unit/simulation/world/test_world.cpp @@ -3578,7 +3578,7 @@ void configureRigidBoxedLcpContactRowsScene(dart::simulation::World& world) sphere.setCollisionShape(sx::CollisionShape::makeSphere(0.5)); } -void configureRigidAvbdFixedJointRowsScene(dart::simulation::World& world) +void configureRigidFixedJointRowsScene(dart::simulation::World& world) { namespace sx = dart::simulation; @@ -3628,7 +3628,7 @@ void configureRigidAvbdDistanceSpringRowsScene(dart::simulation::World& world) /*stiffness=*/200.0); } -void configureRigidAvbdRevoluteMotorRowsScene(dart::simulation::World& world) +void configureRigidRevoluteMotorRowsScene(dart::simulation::World& world) { namespace sx = dart::simulation; @@ -3658,7 +3658,7 @@ void configureRigidAvbdRevoluteMotorRowsScene(dart::simulation::World& world) Eigen::VectorXd::Constant(1, 500.0)); } -void configureRigidAvbdPrismaticMotorRowsScene(dart::simulation::World& world) +void configureRigidPrismaticMotorRowsScene(dart::simulation::World& world) { namespace sx = dart::simulation; @@ -5603,7 +5603,7 @@ TEST(World, RigidBodyContactAvbdStageScratchUsesProvidedAllocator) namespace sx = dart::simulation; sx::World world; - configureRigidAvbdFixedJointRowsScene(world); + configureRigidFixedJointRowsScene(world); common::MemoryManager memoryManager; auto& freeList = memoryManager.getFreeListAllocator(); @@ -6816,22 +6816,22 @@ TEST(World, RigidAvbdRegistryStorageRebuildsAfterClear) false, true); expectRigidAvbdRegistryStorageRebuildsAfterClear( - "rigid AVBD fixed-joint rows", - configureRigidAvbdFixedJointRowsScene, + "rigid hard fixed-joint rows", + configureRigidFixedJointRowsScene, false, true, false, false); expectRigidAvbdRegistryStorageRebuildsAfterClear( - "rigid AVBD revolute motor rows", - configureRigidAvbdRevoluteMotorRowsScene, + "rigid hard revolute motor rows", + configureRigidRevoluteMotorRowsScene, false, true, false, false); expectRigidAvbdRegistryStorageRebuildsAfterClear( - "rigid AVBD prismatic motor rows", - configureRigidAvbdPrismaticMotorRowsScene, + "rigid hard prismatic motor rows", + configureRigidPrismaticMotorRowsScene, false, true, false, @@ -8600,13 +8600,11 @@ TEST(World, BakedStepsDoNotGrowWorldBaseAllocatorForReservedEcsPaths) configurePublicRigidVbdContactAndBreakableJointRowsScene, true); expectNoWorldBaseAllocatorActivityDuringBakedSteps( - "rigid AVBD fixed-joint rows", configureRigidAvbdFixedJointRowsScene); + "rigid hard fixed-joint rows", configureRigidFixedJointRowsScene); expectNoWorldBaseAllocatorActivityDuringBakedSteps( - "rigid AVBD revolute motor rows", - configureRigidAvbdRevoluteMotorRowsScene); + "rigid hard revolute motor rows", configureRigidRevoluteMotorRowsScene); expectNoWorldBaseAllocatorActivityDuringBakedSteps( - "rigid AVBD prismatic motor rows", - configureRigidAvbdPrismaticMotorRowsScene); + "rigid hard prismatic motor rows", configureRigidPrismaticMotorRowsScene); expectNoWorldBaseAllocatorActivityDuringBakedSteps( "rigid AVBD distance-spring rows", configureRigidAvbdDistanceSpringRowsScene); @@ -9961,21 +9959,63 @@ TEST(World, RigidAvbdRowsRemainActiveWithParallelExecutor) EXPECT_GT(parallelSphere->getLinearVelocity().z(), 0.0); } -TEST(World, RigidAvbdFixedJointRowsAreActiveWithoutContacts) +TEST(World, RigidSequentialImpulseFixedJointRowsAreActiveWithoutContacts) { namespace sx = dart::simulation; sx::World world; - configureRigidAvbdFixedJointRowsScene(world); + configureRigidFixedJointRowsScene(world); auto link = world.getRigidBody("rigid_avbd_joint_link"); ASSERT_TRUE(link.has_value()); world.enterSimulationMode(); + EXPECT_EQ(world.getRigidBodySolver(), sx::RigidBodySolver::SequentialImpulse); ASSERT_TRUE(world.collide().empty()); world.step(); EXPECT_LT(link->getTranslation().x(), 1.25); - EXPECT_LT(link->getLinearVelocity().x(), 0.0); + EXPECT_DOUBLE_EQ(link->getLinearVelocity().x(), 0.0); +} + +TEST(World, RigidSequentialImpulseFixedJointIgnoresPrescribedAngularVelocity) +{ + namespace sx = dart::simulation; + + for (const bool kinematic : {false, true}) { + SCOPED_TRACE(kinematic ? "kinematic parent" : "static parent"); + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.timeStep = 0.005; + options.rigidConstraintOptions.iterations = 1u; + sx::World world(options); + + const Eigen::Vector3d prescribedAngularVelocity(0.4, -0.7, 1.1); + sx::RigidBodyOptions parentOptions; + parentOptions.isStatic = !kinematic; + parentOptions.angularVelocity = prescribedAngularVelocity; + auto parent = world.addRigidBody("prescribed_fixed_parent", parentOptions); + if (kinematic) { + parent.setKinematic(true); + } + + sx::RigidBodyOptions childOptions; + childOptions.mass = 1.0; + auto child = world.addRigidBody("prescribed_fixed_child", childOptions); + (void)world.addJoint( + parent, + child, + makeJointSpec("prescribed_fixed_joint", sx::JointType::Fixed)); + + world.enterSimulationMode(); + ASSERT_TRUE(world.collide().empty()); + world.step(); + + EXPECT_TRUE(child.getAngularVelocity().isZero(0.0)) + << child.getAngularVelocity().transpose(); + EXPECT_TRUE( + parent.getAngularVelocity().isApprox(prescribedAngularVelocity, 0.0)); + } } TEST(World, RigidAvbdFixedJointRowsIgnoreDisconnectedContactPresence) @@ -10042,11 +10082,18 @@ TEST(World, RigidAvbdFixedJointRowsIgnoreDisconnectedContactPresence) const Outcome withoutContact = run(false); const Outcome withContact = run(true); - EXPECT_TRUE(withContact.transform.isApprox(withoutContact.transform, 1e-12)); - EXPECT_TRUE(withContact.linearVelocity.isApprox( - withoutContact.linearVelocity, 1e-12)); + EXPECT_TRUE(withContact.transform.isApprox(withoutContact.transform, 1e-12)) + << "without contact:\n" + << withoutContact.transform.matrix() << "\nwith contact:\n" + << withContact.transform.matrix(); + EXPECT_TRUE( + withContact.linearVelocity.isApprox(withoutContact.linearVelocity, 1e-12)) + << "without contact: " << withoutContact.linearVelocity.transpose() + << "\nwith contact: " << withContact.linearVelocity.transpose(); EXPECT_TRUE(withContact.angularVelocity.isApprox( - withoutContact.angularVelocity, 1e-12)); + withoutContact.angularVelocity, 1e-12)) + << "without contact: " << withoutContact.angularVelocity.transpose() + << "\nwith contact: " << withContact.angularVelocity.transpose(); } TEST(World, RigidAvbdDistanceSpringRowsAreActiveWithoutContacts) @@ -10067,12 +10114,12 @@ TEST(World, RigidAvbdDistanceSpringRowsAreActiveWithoutContacts) EXPECT_LT(link->getLinearVelocity().x(), 0.0); } -TEST(World, RigidAvbdRevoluteMotorRowsAreActiveWithoutContacts) +TEST(World, RigidSequentialImpulseRevoluteMotorRowsAreActiveWithoutContacts) { namespace sx = dart::simulation; sx::World world; - configureRigidAvbdRevoluteMotorRowsScene(world); + configureRigidRevoluteMotorRowsScene(world); auto link = world.getRigidBody("rigid_avbd_revolute_motor_link"); ASSERT_TRUE(link.has_value()); @@ -10084,12 +10131,63 @@ TEST(World, RigidAvbdRevoluteMotorRowsAreActiveWithoutContacts) EXPECT_LT(link->getAngularVelocity().z(), 1.25); } -TEST(World, RigidAvbdPrismaticMotorRowsAreActiveWithoutContacts) +TEST(World, RigidSequentialImpulseMotorIgnoresPrescribedAngularVelocity) +{ + namespace sx = dart::simulation; + + for (const bool kinematic : {false, true}) { + SCOPED_TRACE(kinematic ? "kinematic parent" : "static parent"); + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.timeStep = 0.005; + options.rigidConstraintOptions.iterations = 1u; + sx::World world(options); + + const Eigen::Vector3d prescribedAngularVelocity + = 1.25 * Eigen::Vector3d::UnitZ(); + sx::RigidBodyOptions parentOptions; + parentOptions.isStatic = !kinematic; + parentOptions.angularVelocity = prescribedAngularVelocity; + auto parent = world.addRigidBody("prescribed_motor_parent", parentOptions); + if (kinematic) { + parent.setKinematic(true); + } + + sx::RigidBodyOptions childOptions; + childOptions.mass = 1.0; + childOptions.position = Eigen::Vector3d::UnitX(); + auto child = world.addRigidBody("prescribed_motor_child", childOptions); + auto joint = world.addJoint( + parent, + child, + makeJointSpec( + "prescribed_revolute_motor", + sx::JointType::Revolute, + Eigen::Vector3d::UnitZ())); + joint.setActuatorType(sx::ActuatorType::Velocity); + joint.setCommandVelocity(Eigen::VectorXd::Zero(1)); + joint.setEffortLimits( + Eigen::VectorXd::Constant(1, -500.0), + Eigen::VectorXd::Constant(1, 500.0)); + + world.enterSimulationMode(); + ASSERT_TRUE(world.collide().empty()); + world.step(); + + EXPECT_TRUE(child.getAngularVelocity().isZero(0.0)) + << child.getAngularVelocity().transpose(); + EXPECT_TRUE( + parent.getAngularVelocity().isApprox(prescribedAngularVelocity, 0.0)); + } +} + +TEST(World, RigidSequentialImpulsePrismaticMotorRowsAreActiveWithoutContacts) { namespace sx = dart::simulation; sx::World world; - configureRigidAvbdPrismaticMotorRowsScene(world); + configureRigidPrismaticMotorRowsScene(world); auto link = world.getRigidBody("rigid_avbd_prismatic_motor_link"); ASSERT_TRUE(link.has_value()); @@ -10308,13 +10406,11 @@ TEST(World, BakedRigidBodyContactStepsDoNotAllocateGlobalHeap) configurePublicRigidVbdContactAndBreakableJointRowsScene, true); expectNoGlobalHeapAllocationsDuringBakedSteps( - "rigid AVBD fixed-joint rows", configureRigidAvbdFixedJointRowsScene); + "rigid hard fixed-joint rows", configureRigidFixedJointRowsScene); expectNoGlobalHeapAllocationsDuringBakedSteps( - "rigid AVBD revolute motor rows", - configureRigidAvbdRevoluteMotorRowsScene); + "rigid hard revolute motor rows", configureRigidRevoluteMotorRowsScene); expectNoGlobalHeapAllocationsDuringBakedSteps( - "rigid AVBD prismatic motor rows", - configureRigidAvbdPrismaticMotorRowsScene); + "rigid hard prismatic motor rows", configureRigidPrismaticMotorRowsScene); expectNoGlobalHeapAllocationsDuringBakedSteps( "rigid AVBD distance-spring rows", configureRigidAvbdDistanceSpringRowsScene); @@ -10937,13 +11033,11 @@ TEST(World, BakedAvbdVbdRowsDoNotMallocOnHeap) configurePublicRigidVbdContactAndBreakableJointRowsScene, true); expectNoRawHeapAllocationsDuringFirstPostBakeSteps( - "rigid AVBD fixed-joint rows", configureRigidAvbdFixedJointRowsScene); + "rigid hard fixed-joint rows", configureRigidFixedJointRowsScene); expectNoRawHeapAllocationsDuringFirstPostBakeSteps( - "rigid AVBD revolute motor rows", - configureRigidAvbdRevoluteMotorRowsScene); + "rigid hard revolute motor rows", configureRigidRevoluteMotorRowsScene); expectNoRawHeapAllocationsDuringFirstPostBakeSteps( - "rigid AVBD prismatic motor rows", - configureRigidAvbdPrismaticMotorRowsScene); + "rigid hard prismatic motor rows", configureRigidPrismaticMotorRowsScene); expectNoRawHeapAllocationsDuringFirstPostBakeSteps( "rigid AVBD distance-spring rows", configureRigidAvbdDistanceSpringRowsScene); @@ -25444,6 +25538,336 @@ TEST(World, ReplayRecordingRestoresRigidAvbdWarmStartState) child.getAngularVelocity().isApprox(expectedAngularVelocity, 1e-12)); } +// Crossing through Sequential Impulse invalidates AVBD's point-joint +// continuation state. Returning to AVBD must therefore match a world that +// reached the same state without a stale AVBD multiplier history. +TEST(World, SequentialImpulseInvalidatesPriorAvbdJointWarmStart) +{ + namespace sx = dart::simulation; + + sx::WorldOptions avbdOptions; + avbdOptions.gravity = Eigen::Vector3d::Zero(); + avbdOptions.timeStep = 0.05; + avbdOptions.rigidBodySolver = sx::RigidBodySolver::Avbd; + avbdOptions.rigidConstraintOptions.iterations = 1u; + sx::World warm(avbdOptions); + + sx::WorldOptions siOptions = avbdOptions; + siOptions.rigidBodySolver = sx::RigidBodySolver::SequentialImpulse; + sx::World cold(siOptions); + + const auto addFixedPair = [](sx::World& world) { + sx::RigidBodyOptions parentOptions; + parentOptions.isStatic = true; + auto parent = world.addRigidBody("parent", parentOptions); + + sx::RigidBodyOptions childOptions; + childOptions.mass = 1.0; + childOptions.position = Eigen::Vector3d::UnitX(); + auto child = world.addRigidBody("child", childOptions); + world.addJoint( + parent, + child, + sx::JointSpec{.name = "fixed", .type = sx::JointType::Fixed}); + return child; + }; + auto warmChild = addFixedPair(warm); + auto coldChild = addFixedPair(cold); + + warm.enterSimulationMode(); + cold.enterSimulationMode(); + ASSERT_TRUE(warm.collide().empty()); + ASSERT_TRUE(cold.collide().empty()); + + Eigen::Isometry3d seedPose = Eigen::Isometry3d::Identity(); + seedPose.translation() = Eigen::Vector3d(2.0, 0.0, 0.0); + warmChild.setTransform(seedPose); + warmChild.setLinearVelocity(Eigen::Vector3d::Zero()); + warm.step(); + cold.step(); + + warm.setRigidBodySolver(sx::RigidBodySolver::SequentialImpulse); + const auto setCommonSiInput = [](sx::RigidBody& child) { + Eigen::Isometry3d pose = Eigen::Isometry3d::Identity(); + pose.translation() = Eigen::Vector3d(3.0, 0.5, 0.0); + child.setTransform(pose); + child.setLinearVelocity(Eigen::Vector3d(2.0, -1.0, 0.0)); + child.setAngularVelocity(Eigen::Vector3d(0.0, 0.0, 0.7)); + }; + setCommonSiInput(warmChild); + setCommonSiInput(coldChild); + warm.step(); + cold.step(); + + ASSERT_TRUE((warmChild.getTransform().matrix().array() + == coldChild.getTransform().matrix().array()) + .all()); + ASSERT_TRUE((warmChild.getLinearVelocity().array() + == coldChild.getLinearVelocity().array()) + .all()); + ASSERT_TRUE((warmChild.getAngularVelocity().array() + == coldChild.getAngularVelocity().array()) + .all()); + + warm.setRigidBodySolver(sx::RigidBodySolver::Avbd); + cold.setRigidBodySolver(sx::RigidBodySolver::Avbd); + warm.step(); + cold.step(); + + EXPECT_TRUE(warmChild.getTransform().matrix().isApprox( + coldChild.getTransform().matrix(), 1e-12)); + EXPECT_TRUE(warmChild.getLinearVelocity().isApprox( + coldChild.getLinearVelocity(), 1e-12)); + EXPECT_TRUE(warmChild.getAngularVelocity().isApprox( + coldChild.getAngularVelocity(), 1e-12)); +} + +// Crossing through Sequential Impulse also invalidates AVBD contact +// continuation state when an unrelated hard joint keeps AVBD scratch storage +// allocated. Returning to AVBD from identical SI state must match a cold AVBD +// continuation rather than reusing contact multipliers from before the switch. +TEST( + World, + SequentialImpulseInvalidatesPriorAvbdContactWarmStartWithUnrelatedJoint) +{ + namespace sx = dart::simulation; + + sx::WorldOptions avbdOptions; + avbdOptions.gravity = Eigen::Vector3d::Zero(); + avbdOptions.timeStep = 0.05; + avbdOptions.rigidBodySolver = sx::RigidBodySolver::Avbd; + avbdOptions.rigidConstraintOptions.iterations = 1u; + sx::World warm(avbdOptions); + + sx::WorldOptions siOptions = avbdOptions; + siOptions.rigidBodySolver = sx::RigidBodySolver::SequentialImpulse; + sx::World cold(siOptions); + + struct SceneBodies + { + sx::RigidBody contact; + sx::RigidBody jointChild; + }; + + const auto addScene = [](sx::World& world) { + sx::RigidBodyOptions groundOptions; + groundOptions.isStatic = true; + groundOptions.position = Eigen::Vector3d(0.0, 0.0, -0.25); + auto ground = world.addRigidBody("ground", groundOptions); + ground.setCollisionShape( + sx::CollisionShape::makeBox(Eigen::Vector3d(2.0, 2.0, 0.25))); + + sx::RigidBodyOptions contactOptions; + contactOptions.mass = 1.0; + contactOptions.position = Eigen::Vector3d(0.0, 0.0, 0.4); + auto contact = world.addRigidBody("contact", contactOptions); + contact.setCollisionShape(sx::CollisionShape::makeSphere(0.5)); + contact.setFriction(0.8); + + sx::RigidBodyOptions parentOptions; + parentOptions.isStatic = true; + parentOptions.position = Eigen::Vector3d(3.0, 0.0, 0.0); + auto parent = world.addRigidBody("joint_parent", parentOptions); + + sx::RigidBodyOptions childOptions; + childOptions.mass = 1.0; + childOptions.position = Eigen::Vector3d(4.0, 0.0, 0.0); + auto jointChild = world.addRigidBody("joint_child", childOptions); + world.addJoint( + parent, + jointChild, + sx::JointSpec{.name = "fixed", .type = sx::JointType::Fixed}); + return SceneBodies{contact, jointChild}; + }; + + SceneBodies warmBodies = addScene(warm); + SceneBodies coldBodies = addScene(cold); + + const auto configureAvbdContact + = [](sx::World& world, const sx::RigidBody& body) { + auto& registry = sx::detail::registryOf(world); + auto& config = registry.emplace( + sx::detail::toRegistryEntity(body.getEntity())); + config.startStiffness = 10000.0; + config.alpha = 0.95; + config.gamma = 1.0; + config.maxStiffness = 10000.0; + }; + configureAvbdContact(warm, warmBodies.contact); + + warm.enterSimulationMode(); + cold.enterSimulationMode(); + + const auto setCommonInput = [](SceneBodies& bodies) { + Eigen::Isometry3d contactPose = Eigen::Isometry3d::Identity(); + contactPose.translation() = Eigen::Vector3d(0.0, 0.0, 0.4); + bodies.contact.setTransform(contactPose); + bodies.contact.setLinearVelocity(Eigen::Vector3d(0.6, 0.2, -1.0)); + bodies.contact.setAngularVelocity(Eigen::Vector3d(0.0, 0.4, 0.2)); + + Eigen::Isometry3d jointPose = Eigen::Isometry3d::Identity(); + jointPose.translation() = Eigen::Vector3d(4.3, 0.2, 0.0); + bodies.jointChild.setTransform(jointPose); + bodies.jointChild.setLinearVelocity(Eigen::Vector3d(0.4, -0.3, 0.0)); + bodies.jointChild.setAngularVelocity(Eigen::Vector3d(0.0, 0.0, 0.5)); + }; + + // Seed contact (and unrelated joint) continuation state only in the warm + // world. + setCommonInput(warmBodies); + setCommonInput(coldBodies); + ASSERT_FALSE(warm.collide().empty()); + ASSERT_FALSE(cold.collide().empty()); + warm.step(); + cold.step(); + + // The intervening SI step starts from bit-identical public state. AVBD + // scratch must not affect SI, so both worlds synchronize exactly. Remove + // the private compatibility opt-in first so this contact actually falls + // through to SI; the unrelated hard joint keeps AVBD scratch allocated. + auto& warmRegistry = sx::detail::registryOf(warm); + ASSERT_EQ( + warmRegistry.remove( + sx::detail::toRegistryEntity(warmBodies.contact.getEntity())), + 1u); + warm.setRigidBodySolver(sx::RigidBodySolver::SequentialImpulse); + setCommonInput(warmBodies); + setCommonInput(coldBodies); + warm.step(); + cold.step(); + + ASSERT_TRUE((warmBodies.contact.getTransform().matrix().array() + == coldBodies.contact.getTransform().matrix().array()) + .all()); + ASSERT_TRUE((warmBodies.contact.getLinearVelocity().array() + == coldBodies.contact.getLinearVelocity().array()) + .all()); + ASSERT_TRUE((warmBodies.contact.getAngularVelocity().array() + == coldBodies.contact.getAngularVelocity().array()) + .all()); + ASSERT_TRUE((warmBodies.jointChild.getTransform().matrix().array() + == coldBodies.jointChild.getTransform().matrix().array()) + .all()); + + // Recreate the same persistent contact so its key matches the seeded AVBD + // row, then require the first AVBD continuation to be cold-equivalent. + setCommonInput(warmBodies); + setCommonInput(coldBodies); + ASSERT_FALSE(warm.collide().empty()); + ASSERT_FALSE(cold.collide().empty()); + configureAvbdContact(warm, warmBodies.contact); + configureAvbdContact(cold, coldBodies.contact); + warm.setRigidBodySolver(sx::RigidBodySolver::Avbd); + cold.setRigidBodySolver(sx::RigidBodySolver::Avbd); + warm.step(); + cold.step(); + + EXPECT_TRUE(warmBodies.contact.getTransform().matrix().isApprox( + coldBodies.contact.getTransform().matrix(), 1e-12)); + EXPECT_TRUE(warmBodies.contact.getLinearVelocity().isApprox( + coldBodies.contact.getLinearVelocity(), 1e-12)); + EXPECT_TRUE(warmBodies.contact.getAngularVelocity().isApprox( + coldBodies.contact.getAngularVelocity(), 1e-12)); + EXPECT_TRUE(warmBodies.jointChild.getTransform().matrix().isApprox( + coldBodies.jointChild.getTransform().matrix(), 1e-12)); + EXPECT_TRUE(warmBodies.jointChild.getLinearVelocity().isApprox( + coldBodies.jointChild.getLinearVelocity(), 1e-12)); + EXPECT_TRUE(warmBodies.jointChild.getAngularVelocity().isApprox( + coldBodies.jointChild.getAngularVelocity(), 1e-12)); +} + +// A private AVBD contact opt-in may fail over to Sequential Impulse when the +// queried contact cannot be projected by the AVBD compatibility path. That +// fallback must not cold-start an unrelated AVBD distance spring. +TEST(World, SequentialImpulseFallbackPreservesAvbdDistanceSpringWarmStart) +{ + namespace sx = dart::simulation; + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.timeStep = 0.05; + options.rigidBodySolver = sx::RigidBodySolver::SequentialImpulse; + options.rigidConstraintOptions.iterations = 1u; + sx::World fallback(options); + sx::World control(options); + + const auto addScene = [](sx::World& world, bool addPrivateContactOptIn) { + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + baseOptions.position = Eigen::Vector3d(-3.0, 0.0, 0.0); + auto base = world.addRigidBody("spring_base", baseOptions); + + sx::RigidBodyOptions linkOptions; + linkOptions.mass = 1.0; + linkOptions.position = Eigen::Vector3d(-1.0, 0.0, 0.0); + auto link = world.addRigidBody("spring_link", linkOptions); + link.setCollisionShape(sx::CollisionShape::makeSphere(0.25)); + world.addRigidBodyDistanceSpring( + "spring", + base, + link, + /*restLength=*/1.0, + /*stiffness=*/200.0); + auto& registry = sx::detail::registryOf(world); + auto springConfigs = registry.view< + sx::detail::deformable_vbd::AvbdRigidWorldDistanceSpringConfig>(); + const entt::entity springEntity = *springConfigs.begin(); + auto& springConfig = springConfigs.get< + sx::detail::deformable_vbd::AvbdRigidWorldDistanceSpringConfig>( + springEntity); + springConfig.startStiffness = 1.0; + + sx::RigidBodyOptions firstStaticOptions; + firstStaticOptions.isStatic = true; + firstStaticOptions.position = Eigen::Vector3d(3.0, 0.0, 0.0); + auto firstStatic + = world.addRigidBody("overlapping_static_a", firstStaticOptions); + firstStatic.setCollisionShape( + sx::CollisionShape::makeBox(Eigen::Vector3d::Ones())); + + sx::RigidBodyOptions secondStaticOptions = firstStaticOptions; + secondStaticOptions.position = Eigen::Vector3d(3.4, 0.0, 0.0); + auto secondStatic + = world.addRigidBody("overlapping_static_b", secondStaticOptions); + secondStatic.setCollisionShape( + sx::CollisionShape::makeBox(Eigen::Vector3d::Ones())); + + if (addPrivateContactOptIn) { + registry.emplace( + sx::detail::toRegistryEntity(firstStatic.getEntity())); + } + + return link; + }; + + auto fallbackLink = addScene(fallback, true); + auto controlLink = addScene(control, false); + ASSERT_FALSE(fallback.collide().empty()); + ASSERT_FALSE(control.collide().empty()); + + fallback.enterSimulationMode(); + control.enterSimulationMode(); + fallback.step(); + control.step(); + + ASSERT_TRUE(fallbackLink.getTransform().matrix().isApprox( + controlLink.getTransform().matrix(), 1e-12)); + ASSERT_TRUE(fallbackLink.getLinearVelocity().isApprox( + controlLink.getLinearVelocity(), 1e-12)); + + fallback.step(); + control.step(); + + EXPECT_TRUE(fallbackLink.getTransform().matrix().isApprox( + controlLink.getTransform().matrix(), 1e-12)) + << "private AVBD contact fallback cold-started the distance spring"; + EXPECT_TRUE(fallbackLink.getLinearVelocity().isApprox( + controlLink.getLinearVelocity(), 1e-12)) + << "private AVBD contact fallback cold-started the distance spring"; + EXPECT_TRUE(fallbackLink.getAngularVelocity().isApprox( + controlLink.getAngularVelocity(), 1e-12)); +} + // Test that replay restores rigid IPC's adaptive barrier continuation state, so // a branch from an earlier contact frame matches an uninterrupted rollout. TEST(World, ReplayRecordingRestoresRigidIpcAdaptiveBranchState) diff --git a/tests/unit/simulation/world/test_world_resolved_configuration.cpp b/tests/unit/simulation/world/test_world_resolved_configuration.cpp index f235ea6a32ea4..c6be1fba5c0db 100644 --- a/tests/unit/simulation/world/test_world_resolved_configuration.cpp +++ b/tests/unit/simulation/world/test_world_resolved_configuration.cpp @@ -321,7 +321,9 @@ TEST(ResolvedConfiguration, UncoveredAvbdContactPairDoesNotClaimAvbd) EXPECT_NE(contact->reason.find("does not cover"), std::string::npos); } -TEST(ResolvedConfiguration, RecordsAvbdPairConstraintSubstitution) +TEST( + ResolvedConfiguration, + RecordsSequentialImpulsePairConstraintsWithoutSubstitution) { sx::World world; auto parent = world.addRigidBody("parent"); @@ -335,13 +337,16 @@ TEST(ResolvedConfiguration, RecordsAvbdPairConstraintSubstitution) world.enterSimulationMode(); const auto& config = world.getResolvedConfiguration(); - EXPECT_TRUE(config.hasSubstitution()); + EXPECT_FALSE(config.hasSubstitution()); const auto* pairConstraint = findNote(config, "rigid-pair-constraint"); ASSERT_NE(pairConstraint, nullptr); - EXPECT_TRUE(pairConstraint->isSubstitution()); + EXPECT_FALSE(pairConstraint->isSubstitution()); EXPECT_EQ(pairConstraint->requested, "sequential-impulse"); - EXPECT_EQ(pairConstraint->resolved, "avbd"); + EXPECT_EQ(pairConstraint->resolved, "sequential-impulse"); + EXPECT_NE( + pairConstraint->reason.find("solver-owned sequential-impulse rows"), + std::string::npos); } TEST(ResolvedConfiguration, RecordsIpcPairConstraintsWithoutAvbdSubstitution) From 404d66b98dc42bfecabf4ff94373f822674286e7 Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Sat, 1 Aug 2026 18:21:31 -0700 Subject: [PATCH 10/13] Refresh Figure 13 evidence after main merge --- docs/dev_tasks/avbd_solver/README.md | 95 +++++---- docs/dev_tasks/avbd_solver/RESUME.md | 188 ++++++++++-------- docs/plans/104-vertex-block-descent-solver.md | 47 +++-- .../avbd-paper-breakable-wall-packet.json | 78 ++++---- ...-sequential-impulse-comparison-packet.json | 106 +++++----- .../avbd-paper-vbd-comparison-packet.json | 116 +++++------ 6 files changed, 324 insertions(+), 306 deletions(-) diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index f4303d8362a9c..15e2eed7cd892 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -22,9 +22,10 @@ This folder is the temporary working surface; the durable owner is the plan. same-hardware paper-number match. - **Branch state:** active local branch `feature/vbd-avbd-paper-parity-contract`. The public SI closeout is committed - at `37178193ccf`; the branch-tip merge commit containing this handoff merges - local `main` snapshot `b15e7a652b7` into that closeout. The fail-closed - contract is committed at `710cbfc1152`. The Section 3.5 + at `37178193ccf`; merge `37a02b4d2c7` integrates local `main` snapshot + `b15e7a652b7`, and merge `52173fba3aa` integrates fetched `origin/main` + snapshot `ef10cb2633b`. The fail-closed contract is committed at + `710cbfc1152`. The Section 3.5 packet is committed at `a78f688a178`, and the Section 4 packet at `0b0154573b8`; the articulated finite-row packet at `761263bbd41`; the articulated finite-motor packet at @@ -32,10 +33,10 @@ This folder is the temporary working surface; the durable owner is the plan. the public AVBD/Figure 13 slice at `646b447d6cc`. The matched public fixed-penalty VBD/Figure 13 comparison is committed at `d9af744a9cb`. The SI closeout commit adds the public Sequential Impulse pair-row/Figure 13 - slice. The last-fetched feature upstream still points to `d9af744a9cb`, so - the SI commit and main merge remain local. No fetch, push, PR update, or - other GitHub mutation was performed while closing or integrating them. - Verify live local and remote state before any external action. + slice. Before the final evidence-refresh commit and publication, the fetched + feature upstream pointed to `37a02b4d2c7`. Verify live local, remote, PR, CI, + and review state before any external action; do not infer that a prior + session's mutation approval remains active. - **Current packet:** C++ and dartpy callers can explicitly select public Sequential Impulse, VBD, and AVBD rigid-body families with one positive contact/joint solve budget. Sequential Impulse now owns hard fixed, @@ -67,16 +68,16 @@ This folder is the temporary working surface; the durable owner is the plan. joint identities and retained-row residuals are recorded at both checkpoints. Six fresh current-source captures and semantic reviews are sealed to capture-source digest - `b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51`. + `10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d`. One uncontaminated quiet-host five-repeat Release run records median CPU - costs of 9.053841 ms AVBD, 8.953167 ms VBD, and 14.783755 ms Sequential - Impulse, with CPU CVs of 0.57%, 1.79%, and 0.48%, respectively. Its raw JSON + costs of 9.515544 ms AVBD, 9.292560 ms VBD, and 15.651049 ms Sequential + Impulse, with CPU CVs of 0.49%, 0.18%, and 0.34%, respectively. Its raw JSON SHA-256 is - `b377ef3e307a3f5ebbe935ff3c4abcace477cfbb92062296947b4b3537d0427d`. + `abab909f3693333c0bb43bdd54b15df9fd6f0c3e9d4b7dd838208fb76d12565a`. The regenerated AVBD, VBD, and SI packet SHA-256 values are - `494f07ff9b26e61e0e5aa9a8e487d1e94975dca0ea17b0216d1d80f411480391`, - `96652e9c625a4c6307a10ba6a763db26ff03e5f4f2c3749fb4911bb7b40a144b`, - and `6ee2b5242dbdbe39fa2f17b84259bcd54f4a6a492e61a279962d28d96be425ae`. + `d0098a6fac2f53c9b9f6d1898e27d46cfecb4a208f32f2c08c5703583f3c31bf`, + `5db4c77cad4c35f9a10abc2dc892e8849f6a96e1acbaf8162902ab5091503be7`, + and `60612553068dc5eae674a2fdfac7d5359a7b8a027cf636fabd9ba205842489eb`. The same-host ratios are descriptive costs, not speedup or achieved-accuracy equivalence claims. Figure 13 and video row 12 remain partial: exact source constants, XPBD, a @@ -91,27 +92,25 @@ This folder is the temporary working surface; the durable owner is the plan. - #3022 — bounded regression coverage: rigid-contact tangent-basis contract, articulated break→reset→break re-arm lifecycle, row-inventory replaced-key cold-start (`65ba05113c6`). -- **Current local gates:** the merged runtime/capture source tree passes an - uncached Release build and all 81 enabled simulation executables in 123.64 - seconds (the two aggregate wrappers remain intentionally disabled). The - complete Python phase also passes; no exact pytest count is claimed from the - aggregate runner's captured output. The exact current - checker/capture/writer surface passes all 192 focused tests, - including both live-corpus checks. Six fresh - 1280x720 captures bind the current source digest and pass engine ViewReports, - pixel-integrity checks, and separate original-resolution semantic review - against the pinned paper page. Rejected benchmark attempts remain recorded; - the strict quiet-host run and all three dependent packets are now sealed. The - post-reseal runtime/capture source tree passes uncached default `test-all` - with all six phases and uncached CUDA `test-all` with all seven phases. The - CUDA run passed 213 core C++ tests, 80 runnable simulation tests with two - aggregate wrappers intentionally disabled, all eight CUDA device tests, and - all nine benchmark-smoke commands. The later checker/docs-only review fixes - are covered by the 192 focused tests and leave the bound source digest - unchanged. This environment pass does not close the missing solver-specific - VBD/AVBD GPU-parity predicates. Independent code and architecture/claims - reviews approved the pre-merge SI tree; the merged tree received a final - local review after the `main` conflict resolutions and handoff corrections. +- **Current local gates:** after merging `origin/main` at `ef10cb2633b` and + resealing the evidence, all 192 focused tests, the 58-packet corpus checker, + the 176-row parity checker, and the 18-row allocation matrix pass. The + uncached default `test-all` passes all six phases, including 229/229 core C++ + tests and all 81 runnable simulation tests; its two aggregate wrappers remain + intentionally disabled. The uncached CUDA `test-all` passes all seven phases, + including 213/213 core C++ tests, all 80 runnable simulation tests plus two + disabled wrappers, all eight CUDA device tests, and all nine CUDA benchmark + smoke commands. Both aggregate runs also pass their complete Python and docs + phases; no exact pytest count is claimed from the aggregate runners' captured + output. Six fresh 1280x720 captures bind the current source digest and pass + engine ViewReports, pixel-integrity checks, and separate original-resolution + semantic review against the pinned paper page. Rejected benchmark attempts + remain recorded; the strict quiet-host run and all three dependent packets + are sealed. This green CUDA environment gate does not close the missing + solver-specific VBD/AVBD GPU-parity predicates. Independent code and + architecture/claims reviews approved the pre-merge SI tree; the merged tree + received a final local review after the `main` conflict resolutions and + handoff corrections. ## Goal @@ -143,14 +142,14 @@ numbers. ## Immediate Next Steps -Verify that the SI closeout at `37178193ccf` is followed by the clean branch-tip -merge of local `main` snapshot `b15e7a652b7`; if not, preserve the in-progress -merge and finish only its mandatory lint, gate, provenance, and local-commit -sequence. Do not extend this shared-foundation branch into the remaining parity -program. After maintainer approval and landing, complete all 88 VBD predicates -in one VBD PR from updated `main`, including an honest public XPBD comparator, -the VBD CPU/CUDA and corpus gaps, and achieved-accuracy performance. After that -lands, complete all 88 AVBD predicates in a second AVBD PR from updated `main`, +Verify that the SI closeout at `37178193ccf` is followed by merges +`37a02b4d2c7` and `52173fba3aa`, then the final evidence-refresh commit. If the +foundation PR is still open, manage only that PR through current-head CI and +review; do not extend its branch into the remaining parity program. After +maintainer approval and landing, complete all 88 VBD predicates in one VBD PR +from updated `main`, including an honest public XPBD comparator, the VBD +CPU/CUDA and corpus gaps, and achieved-accuracy performance. After that lands, +complete all 88 AVBD predicates in a second AVBD PR from updated `main`, including AVBD-specific CPU/CUDA, unified-row, corpus, four-method, and performance gaps. Keep every dependent figure/demo/performance row partial until its own source-matched CPU and CUDA evidence closes it. @@ -363,7 +362,7 @@ All linked canonical method rows remain partial. screenshot, and scene-metrics log; the benchmark JSON binds the same source tree plus its benchmark translation unit. - **Performance boundary:** the current-source five-repeat Release benchmark - records a 9.053841 ms median CPU cost per step with 0.57% CPU CV. This is an + records a 9.515544 ms median CPU cost per step with 0.49% CPU CV. This is an absolute DART timing only; the paper and source publish no directly comparable timing for this scene. - **Non-goals:** exact replay of unpublished source constants, the XPBD @@ -395,8 +394,8 @@ Figure 13 and official-video row 12 remain partial. current-build VBD captures pass their engine ViewReports, pixel-integrity checks, and paper-grounded semantic review. - **Performance boundary:** the current-source five-repeat medians are - 8.953167 ms VBD and 9.053841 ms AVBD, with 1.79% and 0.57% CPU CV. The - VBD/AVBD median CPU-cost ratio is 0.9889x and is descriptive only: the + 9.292560 ms VBD and 9.515544 ms AVBD, with 0.18% and 0.49% CPU CV. The + VBD/AVBD median CPU-cost ratio is 0.9766x and is descriptive only: the outcomes intentionally differ, and no source achieved-accuracy or same-hardware denominator exists. - **Remaining boundary:** exact unpublished constants, honest XPBD, a @@ -429,8 +428,8 @@ Figure 13 and official-video row 12 remain partial. wall-normal displacement. Both assessed images match the paper's initial fracture followed by retained-row failure and collapse. - **Performance boundary:** the current-source five-repeat median CPU costs - are 14.783755 ms SI, 8.953167 ms VBD, and 9.053841 ms AVBD, with 0.48%, - 1.79%, and 0.57% CPU CV. SI/AVBD is 1.6329x and SI/VBD is 1.6512x. These are + are 15.651049 ms SI, 9.292560 ms VBD, and 9.515544 ms AVBD, with 0.34%, + 0.18%, and 0.49% CPU CV. SI/AVBD is 1.6448x and SI/VBD is 1.6843x. These are descriptive costs for intentionally different outcomes, not paper speedups or achieved-accuracy comparisons. Frame 120 is the only shared quantitative checkpoint across the three rows; the earlier frame-14/18/60 checkpoints diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index e906a5a273311..c5611781c9f1d 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -9,18 +9,17 @@ This file owns only the exact branch/session stop point and takeover sequence. ## Stop Point -At the branch-tip merge commit containing this handoff, the public Sequential -Impulse (SI) pair-row closeout at `37178193ccf` is integrated with local `main` -snapshot `b15e7a652b7`. The two merge conflicts were reconciled without -changing solver-family ownership: the shared forward declarations follow -`main`, while SI keeps its bounds-safe span indexing and the boxed-LCP scratch -path keeps `main`'s allocator-backed implementation. The merge passes an -uncached Release build and all 81 enabled simulation executables. Six captures, -three semantic reviews, one strict quiet-host benchmark, and the AVBD -> VBD -> -SI packet chain are regenerated against the merged source digest. All 192 -focused tests, the 58-packet corpus checker, the 176-row parity checker, and the -18-row allocation matrix pass. The final default/CUDA aggregate gate results -are recorded below. +At the current pre-publication branch state, the public Sequential Impulse (SI) +pair-row closeout at `37178193ccf` is integrated with `origin/main` snapshot +`ef10cb2633b`. Merge conflicts were reconciled without changing solver-family +ownership: the shared forward declarations follow `main`, while SI keeps its +bounds-safe span indexing and the boxed-LCP scratch path keeps `main`'s +allocator-backed implementation. Six captures, three semantic reviews, one +strict quiet-host benchmark, and the AVBD -> VBD -> SI packet chain are +regenerated against the merged source digest. All 192 focused tests, the +58-packet corpus checker, the 176-row parity checker, the 18-row allocation +matrix, and both uncached default and CUDA aggregate gates pass. Exact aggregate +results are recorded below. The 88-row VBD and 88-row AVBD contracts remain fail-closed with no complete rows. The local three-method Figure 13 packet is useful partial evidence, not @@ -34,39 +33,43 @@ numbers. Do not reduce this to an MVP, a CPU-only lane, or one wall demo. - Worktree: `/home/js/dev/dartsim/dart/task_5` - Branch: `feature/vbd-avbd-paper-parity-contract` - Pre-merge local `HEAD`: `37178193ccf090843c5df6afeb1221d090f2b673` -- Resume tip: the branch-tip merge commit containing this handoff; verify its - live SHA -- Last-fetched feature upstream ref: +- Current foundation merge tip before the evidence-refresh commit: + `52173fba3aa415cdf830858693195a5fbc6397b8` +- Resume tip: the branch-tip evidence-refresh commit after `52173fba3aa`; verify + its live SHA and subject before acting +- Pre-publication feature upstream snapshot: `origin/feature/vbd-avbd-paper-parity-contract` at - `d9af744a9cb0763033d76e01381dbf7c1e6ecaa9` -- Merged local and remote-tracking `main` snapshot: - `b15e7a652b766897a8a69a121742ef5973c77f3c` + `37a02b4d2c71036bdc8f3c394468c4852ee10efd` +- Merged remote-tracking `main` snapshot: + `ef10cb2633b32d2fe061a19d1abd5f7d61561fe8` -No fetch, push, PR edit, review trigger, or other GitHub mutation was performed -while closing SI or integrating `main`. Treat the recorded remote-tracking SHAs -as local snapshots, not fresh remote assertions. Verify live state before any -external action, and do not push or mutate GitHub without explicit -maintainer/user approval. +Treat the recorded remote-tracking SHAs as pre-publication snapshots, not fresh +remote assertions. The foundation may already have been pushed or opened as a +PR after this handoff was committed. Verify live local, remote, PR, CI, and +review state before any external action, and do not infer that a prior session's +mutation approval remains active. -The merge commit containing this handoff should leave the tracked scope clean -and no task-created untracked files. If resuming before that commit exists, -preserve the in-progress merge exactly; do not reset, stash, rebase, switch -worktrees, abort the merge, or discard files. +The evidence-refresh commit after `52173fba3aa` should leave the tracked scope +clean and no task-created untracked files. If resuming before that commit +exists, preserve the working tree exactly; do not reset, stash, rebase, switch +worktrees, abort an in-progress merge, or discard files. The local commit ladder above `main` is: -| Commit | Local slice | -| ------------- | ------------------------------------------------- | -| `710cbfc1152` | Fail-closed VBD/AVBD paper-parity contracts | -| `a78f688a178` | AVBD Section 3.5 quasi-Newton geometric stiffness | -| `0b0154573b8` | AVBD Section 4 parallel dual/stiffness update | -| `761263bbd41` | Articulated finite masked rows | -| `9ebd9b895b1` | Articulated finite movable-pair motors | -| `131981788fa` | Articulated finite load and fracture accounting | -| `646b447d6cc` | Public AVBD solver and Figure 13 evidence | -| `d9af744a9cb` | Public fixed-penalty VBD comparison | -| `37178193ccf` | Public SI pair rows and Figure 13 comparison | -| branch tip | Merge local `main` snapshot `b15e7a652b7` | +| Commit | Local slice | +| ------------- | -------------------------------------------------- | +| `710cbfc1152` | Fail-closed VBD/AVBD paper-parity contracts | +| `a78f688a178` | AVBD Section 3.5 quasi-Newton geometric stiffness | +| `0b0154573b8` | AVBD Section 4 parallel dual/stiffness update | +| `761263bbd41` | Articulated finite masked rows | +| `9ebd9b895b1` | Articulated finite movable-pair motors | +| `131981788fa` | Articulated finite load and fracture accounting | +| `646b447d6cc` | Public AVBD solver and Figure 13 evidence | +| `d9af744a9cb` | Public fixed-penalty VBD comparison | +| `37178193ccf` | Public SI pair rows and Figure 13 comparison | +| `37a02b4d2c7` | Merge local `main` snapshot `b15e7a652b7` | +| `52173fba3aa` | Merge fetched `origin/main` snapshot `ef10cb2633b` | +| branch tip | Refresh Figure 13 evidence after the latest merge | ## Current SI Slice @@ -120,10 +123,16 @@ in AVBD -> VBD -> SI order. ### Correctness and aggregate gates -- The merged tree passes an uncached Release build and all 81 enabled - simulation executables in 123.64 seconds (the two aggregate wrappers remain - intentionally disabled). The final aggregate default/CUDA results are - recorded below. +- The resealed current tree passes the uncached default `test-all` in all six + phases: 229/229 core C++ tests, all 81 runnable simulation tests plus two + intentionally disabled aggregate wrappers, and the complete Python and docs + phases. No exact pytest count is claimed from the aggregate runner's captured + output. +- The same tree passes the uncached CUDA `test-all` in all seven phases: + 213/213 core C++ tests, all 80 runnable simulation tests plus two disabled + wrappers, the complete Python and docs phases, all eight CUDA device tests, + and all nine CUDA benchmark smoke commands. No exact pytest count is claimed + from the aggregate runner's captured output. - The exact current checker/capture/writer surface passes all 192 focused tests, including required top-level paper-capture provenance, exact ordered writer-owned source lists, required self-method medians, exact timing-ratio @@ -132,30 +141,31 @@ in AVBD -> VBD -> SI order. - Six regenerated captures and three semantic reviews bind the current source digest below. The accepted current-source benchmark and regenerated packet chain bind the same digest. -- The complete post-fix, post-reseal tree passes uncached default `test-all` - with all six phases and uncached CUDA `test-all` with all seven phases. The - CUDA run passed 213 core C++ tests, 80 runnable simulation tests with two - aggregate wrappers intentionally disabled, all eight CUDA device tests, and - all nine benchmark-smoke commands. Its complete Python and documentation - phases also passed; no exact pytest count is claimed from the aggregate - runner's captured output. Rerun affected gates if a review fix changes - covered source. A green CUDA environment gate still does **not** close the - missing VBD/AVBD solver GPU-parity rows. +- Merging `origin/main` at `ef10cb2633b` changed the capture-source digest and + made the prior aggregate results historical. The resealed current head passes + the focused 192-test surface, 58-packet corpus checker, 176-row parity + checker, 18-row allocation matrix, and both aggregate gates above. A green + CUDA environment gate still does **not** close the missing VBD/AVBD + solver GPU-parity rows. ### Source and benchmark provenance - Current capture-source digest: - `b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51` + `10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d` - Benchmark translation-unit digest: `f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370` - Accepted current-source benchmark: - `/tmp/plan104-figure13-fwd-merge.VfZPUI/figure13-three-method.json` + `/tmp/plan104-figure13-current-main.C9ZSFU/figure13-three-method.json` (SHA-256 - `b377ef3e307a3f5ebbe935ff3c4abcace477cfbb92062296947b4b3537d0427d`). - Median CPU costs/CVs are 9.053841 ms/0.57% AVBD, - 8.953167 ms/1.79% VBD, and 14.783755 ms/0.48% SI. The initial 1-minute load - average was 0.76; an armed concurrent watchdog observed no competing build/test - workload during the 18-second run. + `abab909f3693333c0bb43bdd54b15df9fd6f0c3e9d4b7dd838208fb76d12565a`). + Median CPU costs/CVs are 9.515544 ms/0.49% AVBD, + 9.292560 ms/0.18% VBD, and 15.651049 ms/0.34% SI. The initial 1-minute load + average was 0.62; an armed concurrent watchdog observed no competing + build/test workload during the run. +- The previously accepted forward-merge packet root + `/tmp/plan104-figure13-fwd-merge.VfZPUI` is preserved but source-stale after + merging `origin/main` at `ef10cb2633b`; its captures, benchmark, and packets + are historical non-evidence. - The earlier merged-source packet root `/tmp/plan104-figure13-main-merge.aVseEf` is preserved but rejected because the subsequent forward-declaration owner fix changed the capture-source @@ -173,10 +183,10 @@ in AVBD -> VBD -> SI order. rejected before packet generation. The regenerated AVBD/VBD/SI packet SHA-256 values are, in dependency order, -`494f07ff9b26e61e0e5aa9a8e487d1e94975dca0ea17b0216d1d80f411480391`, -`96652e9c625a4c6307a10ba6a763db26ff03e5f4f2c3749fb4911bb7b40a144b`, -and `6ee2b5242dbdbe39fa2f17b84259bcd54f4a6a492e61a279962d28d96be425ae`. -VBD/AVBD is 0.9889x, SI/AVBD is 1.6329x, and SI/VBD is 1.6512x. Outcomes +`d0098a6fac2f53c9b9f6d1898e27d46cfecb4a208f32f2c08c5703583f3c31bf`, +`5db4c77cad4c35f9a10abc2dc892e8849f6a96e1acbaf8162902ab5091503be7`, +and `60612553068dc5eae674a2fdfac7d5359a7b8a027cf636fabd9ba205842489eb`. +VBD/AVBD is 0.9766x, SI/AVBD is 1.6448x, and SI/VBD is 1.6843x. Outcomes intentionally differ, and there is no source achieved-accuracy or same-hardware denominator for this reconstructed scene; no raw cost ratio is a speedup, parity, or quality claim. @@ -184,7 +194,7 @@ speedup, parity, or quality claim. ### Visual evidence The temporary evidence root is -`/tmp/plan104-figure13-fwd-merge.VfZPUI`. +`/tmp/plan104-figure13-current-main.C9ZSFU`. All six 1280x720 captures passed engine ViewReports, pixel-integrity checks, and separate original-resolution semantic review. The review records are @@ -193,12 +203,12 @@ separate original-resolution semantic review. The review records are | Method/checkpoint | Image SHA-256 | Assessed observation | | ----------------- | ------------------------------------------------------------------ | ------------------------------------------------------- | -| AVBD frame 60 | `e0a998b1710f36e1e3b82bfeb3f20c18d99e42ad8f429f42321e364abcdc246e` | Globally standing wall with visible displacement damage | -| AVBD frame 120 | `a75dfc58f790864c9bad224d8c658eead3134a14362cf4bffd0e6a2744e77af9` | Standing retained structure after fracture | -| VBD frame 18 | `f42b029583607aea696edfa983bde6aaa9815952ff5fae798bfd858831770c92` | Bend without fracture | -| VBD frame 120 | `bd51bb4a2f35168b52fec76aa366567905239b31cc7c47a7c349072f1b11774e` | Recovery with all attachments retained | -| SI frame 14 | `41c4e781d0a2d864883bea0af2bf9c46380a3dfbc46c30422293b7ca6fd92019` | Initial fracture while placement remains | -| SI frame 120 | `665ec2b3b9c3c5a857666ab4304bb59322709cca9ebbaf9e1e4d3c3d26bc6e36` | Retained-row failure and collapse | +| AVBD frame 60 | `b44fc5d55df54955f53f67330689b8e9356201c417ce932793366368c15e2a3f` | Globally standing wall with visible displacement damage | +| AVBD frame 120 | `a0777940e033e4684ec7f2f747a1b5b2a2694f849aa88d04a08e82dbb7d0c087` | Standing retained structure after fracture | +| VBD frame 18 | `dc05890e8f09f42dfc9dd5a1c06662a15bac88bded3197eeda8c333fc40eca06` | Bend without fracture | +| VBD frame 120 | `69a2f2390ff4786f68f60866356291c67968aaf975e00853440c5794fdc6d590` | Recovery with all attachments retained | +| SI frame 14 | `678af175bd8cbbea575dd152884996dd36aacec63bf0b66d38a81ebd7d92fc43` | Initial fracture while placement remains | +| SI frame 120 | `83ca961c52b606a5a90c2232e3f3f4d60d970783cb22c1b0a40dcc97d22fce29` | Retained-row failure and collapse | The pinned paper inputs used for adjudication were `/tmp/dart-vbd-avbd-parity.teIxO8/avbd.pdf` (SHA-256 @@ -234,8 +244,8 @@ corrections received the final local review recorded above. 1. Stay in the exact worktree and verify the branch, upstream snapshot, status, and commit ladder. Expect the SI closeout at `37178193ccf` followed by the - clean branch-tip merge of local `main` snapshot `b15e7a652b7`. If the merge - commit is absent, preserve the recorded in-progress merge and complete only + merges at `37a02b4d2c7` and `52173fba3aa`, then the evidence-refresh commit. + If the refresh commit is absent, preserve the working tree and complete only its final lint/check/commit sequence. 2. Recompute the capture-source digest and run the parity, packet, and allocation checkers. The expected digest, accepted benchmark hash, and @@ -249,16 +259,19 @@ corrections received the final local review recorded above. docs/plans/104-vertex-block-descent-solver.md ``` -3. If the merge commit is absent, run `pixi run lint` immediately before it, - re-run packet/provenance checks, inspect `git diff --check` and the exact - staged scope, and commit locally with a plain descriptive message and no AI - attribution. Repeat review if conflict resolution or a post-merge fix - changes materially. -4. Do not extend this shared-foundation branch into the remaining parity +3. If the evidence-refresh commit is absent, run `pixi run lint` immediately + before it, re-run packet/provenance checks, inspect `git diff --check` and + the exact staged scope, and commit locally with a plain descriptive message + and no AI attribution. Repeat proportional gates if a post-merge fix changes + covered source. +4. Inspect live GitHub state. If the foundation PR is open, manage only its + current head through CI and review under fresh maintainer approval. Never + rebase a published PR branch; merge the latest base before every follow-up + push. Do not merge without separate explicit approval. +5. Do not extend this shared-foundation branch into the remaining parity program. After maintainer approval and landing, create one VBD-completion PR from updated `main`; only after that lands, create one AVBD-completion PR - from updated `main`. Never rebase a published PR branch, and do not push or - mutate GitHub without explicit approval. + from updated `main`. ## Resume Commands @@ -276,7 +289,7 @@ pixi run check-plan122-allocation-matrix ``` After any code or evidence change, select proportional focused gates first. -Before SI closeout, the authoritative broad gates are: +For any material source change, the authoritative broad gates are: ```bash DART_DISABLE_COMPILER_CACHE=ON pixi run test-simulation @@ -364,12 +377,13 @@ not reset, stash, rebase, switch trees, push, or mutate GitHub without explicit approval. First read AGENTS.md, docs/ai/principles.md, docs/ai/verification.md, the PLAN-104 owner, paper gap audit, demo corpus, parity matrix, and both dev-task files. Verify that the SI -closeout at `37178193ccf` is followed by the clean branch-tip merge of local -main snapshot `b15e7a652b7`; if it is not, preserve the in-progress merge and -finish only its final lint/check/commit sequence. Do not extend the shared -foundation branch into the remaining parity program. After maintainer approval -and landing, complete VBD first in one PR from updated main, including honest -XPBD comparison, all CPU/CUDA and corpus rows, and performance leadership. +closeout at `37178193ccf` is followed by merges `37a02b4d2c7` and +`52173fba3aa`, then the final evidence-refresh commit. If the foundation PR is +open, manage only its current head through CI and review; do not merge without +separate explicit approval. Do not extend the shared foundation branch into +the remaining parity program. After maintainer approval and landing, complete +VBD first in one PR from updated main, including honest XPBD comparison, all +CPU/CUDA and corpus rows, and performance leadership. After that PR lands, complete AVBD in a second PR from updated main, including all AVBD-specific CPU/CUDA, unified-row, corpus, four-method, and performance gaps. Keep all claims fail-closed until every one of the 176 canonical diff --git a/docs/plans/104-vertex-block-descent-solver.md b/docs/plans/104-vertex-block-descent-solver.md index 675916d651c2d..bf667ed1acd71 100644 --- a/docs/plans/104-vertex-block-descent-solver.md +++ b/docs/plans/104-vertex-block-descent-solver.md @@ -387,12 +387,12 @@ surviving rows stay below 0.772 mm linear and 0.000317 rad angular residual at frame 120. The frame-60 capture remains an explicit pre-evaluation state. Both engine ViewReports pass, the runtime scene and benchmark share fingerprint `8ca3fbfa00c3dce9`, and the six current captures bind source digest -`b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51`. +`10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d`. Capture manifests bind the capture-time source tree and artifacts; an accepted benchmark binds that same source tree plus its translation unit. The accepted -quiet-host five-repeat Release run records 9.053841 ms median CPU cost per step -with 0.57% CPU CV; its raw JSON SHA-256 is -`b377ef3e307a3f5ebbe935ff3c4abcace477cfbb92062296947b4b3537d0427d`. +quiet-host five-repeat Release run records 9.515544 ms median CPU cost per step +with 0.49% CPU CV; its raw JSON SHA-256 is +`abab909f3693333c0bb43bdd54b15df9fd6f0c3e9d4b7dd838208fb76d12565a`. The current evidence is sealed in [`avbd-paper-breakable-wall-packet.json`](104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json). The matched `vbd_paper_breakable_wall` row reuses that exact scene fingerprint @@ -402,8 +402,8 @@ attachments, 0.132 m peak wall-normal and 0.062 m RMS displacement, and 120 bricks beyond 0.05 m; its frame-120 oracle retains the full connected wall. All 712 retained VBD rows stay below 16.39 mm and 0.01328 rad at frame 18, then 11.10 mm and 0.001310 rad at frame 120. Both assessed VBD renders pass. -The same five-repeat benchmark records 8.953167 ms VBD and 9.053841 ms AVBD -median CPU cost per step with 1.79% and 0.57% CPU CV. Its 0.9889x VBD/AVBD +The same five-repeat benchmark records 9.292560 ms VBD and 9.515544 ms AVBD +median CPU cost per step with 0.18% and 0.49% CPU CV. Its 0.9766x VBD/AVBD ratio is descriptive only because the methods produce different physical outcomes and there is no achieved-accuracy reference denominator. The current cross-linked evidence is sealed in @@ -416,8 +416,8 @@ impact regions reach 0.06346 m maximum / 0.02076 m RMS linear residual and 0.7270 rad maximum / 0.2164 rad RMS angular residual. Only 27.27% of bricks outside impact bands and 19.05% overall remain placed, with 2.878 m maximum wall-normal displacement. Both assessed SI renders pass. The same benchmark -records 14.783755 ms SI median CPU cost per step with 0.48% CPU CV. Its -1.6329x SI/AVBD and 1.6512x SI/VBD ratios are descriptive only. Frame 120 is +records 15.651049 ms SI median CPU cost per step with 0.34% CPU CV. Its +1.6448x SI/AVBD and 1.6843x SI/VBD ratios are descriptive only. Frame 120 is the only shared quantitative checkpoint across the three method rows; the earlier frame-14, frame-18, and frame-60 checkpoints are per-family diagnostic oracles, not cross-method @@ -1429,22 +1429,27 @@ AVBD parity additionally requires: Relocated from the dashboard on 2026-07-03; newest first. On 2026-08-01, one strict quiet-host five-repeat Figure 13 run was accepted -after rejecting noisy, contaminated, or source-stale candidates. It binds +after merging `origin/main` at `ef10cb2633b` and rejecting noisy, +contaminated, or source-stale candidates. It binds capture-source digest -`b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51` and +`10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d` and benchmark translation-unit digest `f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370`. -Median CPU costs/CVs are 9.053841 ms/0.57% AVBD, 8.953167 ms/1.79% VBD, and -14.783755 ms/0.48% Sequential Impulse. The AVBD -> VBD -> SI packet chain was -regenerated from the one raw JSON and passes all 192 focused tests plus the -58-packet corpus checker. The exact post-reseal tree also passes uncached -default `test-all` with all six phases and CUDA `test-all` with all seven -phases. The CUDA run passed 213 core C++ tests, 80 runnable simulation tests -with two disabled aggregate wrappers, eight CUDA device tests, and all nine -benchmark-smoke commands; CUDA environment success does not close the open -solver-specific GPU-parity rows. Independent code and architecture/claims -reviews approved the pre-merge SI tree, and the `main`-merged tree received a -final local review after conflict resolution and handoff correction. The +Median CPU costs/CVs are 9.515544 ms/0.49% AVBD, 9.292560 ms/0.18% VBD, and +15.651049 ms/0.34% Sequential Impulse. The AVBD -> VBD -> SI packet chain was +regenerated from the one raw JSON. The resealed current head passes all 192 +focused tests, the 58-packet corpus checker, the 176-row parity checker, and the +18-row allocation matrix. Its uncached default `test-all` passes all six phases, +including 229/229 core C++ tests and all 81 runnable simulation tests plus two +disabled wrappers. Its uncached CUDA `test-all` passes all seven phases, +including 213/213 core C++ tests, all 80 runnable simulation tests plus two +disabled wrappers, all eight CUDA device tests, and all nine CUDA benchmark +smoke commands. Both aggregate runs pass their complete Python and docs phases; +no exact pytest count is claimed from the captured aggregate output. CUDA +environment success does not close the open solver-specific GPU-parity rows. +Independent code and architecture/claims reviews approved the pre-merge SI +tree, and the `main`-merged tree received a final local review after conflict +resolution and handoff correction. The canonical Figure 13/video rows link the SI packet, and the checker pins every legacy filename to its exact version-1 or version-3 schema. Ratios remain descriptive because the three outcomes differ and no achieved-accuracy diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json index d3facc6c049b1..a9b9ae048f183 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json @@ -3,7 +3,7 @@ "benchmark": "BM_AvbdPaperBreakableWallStep", "context": { "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51", + "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", "host_name": "mark26", "json_schema_version": 1, @@ -12,18 +12,18 @@ "mhz_per_cpu": 5300, "num_cpus": 32 }, - "json_sha256": "b377ef3e307a3f5ebbe935ff3c4abcace477cfbb92062296947b4b3537d0427d", + "json_sha256": "abab909f3693333c0bb43bdd54b15df9fd6f0c3e9d4b7dd838208fb76d12565a", "rows": [ { "aggregate_name": "mean", "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9065270.446666665, + "cpu_time": 9523638.290000001, "impacting_balls": 3.0, "iterations": 5, "public_avbd_family": 1.0, - "real_time": 9068783.145048654, + "real_time": 9526517.41836841, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -45,11 +45,11 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9053841.274999997, + "cpu_time": 9515543.591666665, "impacting_balls": 3.0, "iterations": 5, "public_avbd_family": 1.0, - "real_time": 9056136.558259215, + "real_time": 9516189.9337351, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -69,9 +69,9 @@ { "aggregate_name": "stddev", "aggregate_unit": "time", - "cpu_time": 51866.67046000096, + "cpu_time": 46344.68280297959, "iterations": 5, - "real_time": 52199.15238895366, + "real_time": 49349.39234928154, "repetitions": 5, "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", @@ -80,9 +80,9 @@ { "aggregate_name": "cv", "aggregate_unit": "percentage", - "cpu_time": 0.005721469730566343, + "cpu_time": 0.004866279187822828, "iterations": 5, - "real_time": 0.005755915821788416, + "real_time": 0.005180213312173162, "repetitions": 5, "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", @@ -92,18 +92,18 @@ "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "source_provenance": { "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51" + "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d" }, "stability": { - "cpu_time_cv_fraction": 0.005721469730566343, - "real_time_cv_fraction": 0.005755915821788416, + "cpu_time_cv_fraction": 0.004866279187822828, + "real_time_cv_fraction": 0.005180213312173162, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 9065270.446666665, - "mean_real_time_per_step_ns": 9068783.145048654, - "median_cpu_time_per_step_ns": 9053841.274999997, - "median_real_time_per_step_ns": 9056136.558259215 + "mean_cpu_time_per_step_ns": 9523638.290000001, + "mean_real_time_per_step_ns": 9526517.41836841, + "median_cpu_time_per_step_ns": 9515543.591666665, + "median_real_time_per_step_ns": 9516189.9337351 } }, "claim_boundary": "This packet covers the AVBD row of the publication-shaped Figure 13 breakable-wall outcome on the public CPU solver family. It does not claim exact source-scene replay, reproduce the Sequential Impulse, XPBD, and VBD comparison rows, or establish public CUDA AVBD parity.", @@ -321,8 +321,8 @@ "checks": { "contrast": { "bright_pixels": 1305, - "dark_pixels": 12101, - "mid_pixels": 208754, + "dark_pixels": 12088, + "mid_pixels": 208731, "p05": 53.87, "p95": 149.723, "pass": true, @@ -362,12 +362,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "c096baaca859ef706573e89e505251eda522b3eb6c6f57ba058c2679159b3065" + "sha256": "088b5d36b8743dda709b997afd71aed1009adafd897e6e332ca5bd10d6956711" }, "label": "impact", "manifest": { "file": "manifest.json", - "sha256": "a0029478be796a1b7bbaa5747f9a211d6e26afa7be5549736af7350d37cbfbc2" + "sha256": "3989652b27ada91b262b2529771fd5e34f41b7c9c062e7b3abb23047056e348b" }, "scene_metrics": { "event_count": 60, @@ -4677,13 +4677,13 @@ }, "screenshot": { "file": "avbd_paper_breakable_wall_impact.png", - "sha256": "e0a998b1710f36e1e3b82bfeb3f20c18d99e42ad8f429f42321e364abcdc246e" + "sha256": "b44fc5d55df54955f53f67330689b8e9356201c417ce932793366368c15e2a3f" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51", + "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", "file_count": 1556, - "git_head": "37178193ccf090843c5df6afeb1221d090f2b673", + "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", "roots": [ "CMakeLists.txt", "cmake", @@ -4720,10 +4720,10 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 1905, - "dark_pixels": 11909, - "mid_pixels": 208074, - "p05": 54.652, + "bright_pixels": 1918, + "dark_pixels": 11899, + "mid_pixels": 208076, + "p05": 54.665, "p95": 155.511, "pass": true, "reasons": [], @@ -4734,7 +4734,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 100.859, + "spread": 100.845, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -4762,12 +4762,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "ace64cf8f4179e64c47a20975795673b0ee25a52450ddfcacbd808f5bc57e3a7" + "sha256": "8769f651d1a5c0eb64d361fd03eabf7558de3c866ac21b783101d5525a61c39a" }, "label": "outcome", "manifest": { "file": "manifest.json", - "sha256": "7eb62ac870fc7a3675fef9357bb74ac5705ec2e060021e26e982f8ba5d410e5c" + "sha256": "5f314c665a413bb8cbc65efd2c543a9c0c2e78d859fbdcc76526c5c09bf5bc74" }, "scene_metrics": { "event_count": 120, @@ -9077,13 +9077,13 @@ }, "screenshot": { "file": "avbd_paper_breakable_wall_outcome.png", - "sha256": "a75dfc58f790864c9bad224d8c658eead3134a14362cf4bffd0e6a2744e77af9" + "sha256": "a0777940e033e4684ec7f2f747a1b5b2a2694f849aa88d04a08e82dbb7d0c087" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51", + "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", "file_count": 1556, - "git_head": "37178193ccf090843c5df6afeb1221d090f2b673", + "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", "roots": [ "CMakeLists.txt", "cmake", @@ -9106,12 +9106,12 @@ { "file": "avbd_paper_breakable_wall_impact.png", "role": "impact_frame_60", - "sha256": "e0a998b1710f36e1e3b82bfeb3f20c18d99e42ad8f429f42321e364abcdc246e" + "sha256": "b44fc5d55df54955f53f67330689b8e9356201c417ce932793366368c15e2a3f" }, { "file": "avbd_paper_breakable_wall_outcome.png", "role": "outcome_frame_120", - "sha256": "a75dfc58f790864c9bad224d8c658eead3134a14362cf4bffd0e6a2744e77af9" + "sha256": "a0777940e033e4684ec7f2f747a1b5b2a2694f849aa88d04a08e82dbb7d0c087" }, { "file": "paper-10.png", @@ -9120,9 +9120,9 @@ } ], "not_proven_and_limitations": "The checked identity evidence records 154 broken joints, including 79 whose initial anchors lie outside the selected 1.15 m impact regions, so this reconstruction does not claim that broken topology itself is spatially localized. The visible and numeric claim is narrower: the wall fractures, displacement damage reaches all three impact bands, and all 558 surviving joint rows remain tightly satisfied. This row does not prove exact unpublished source constants, XPBD, CUDA parity, or published/source timing parity.", - "reconciliation_and_verdict": "PASS for the scoped AVBD observation: both engine ViewReports pass with all 252 bricks in focus. Frames 60 and 120 record the same checked 154-member broken-joint identity digest and 558 surviving rows. The maximum retained-row residuals are respectively 0.000406 m and 0.000178 rad, then 0.000772 m and 0.000317 rad. The images independently show a broad fractured opening around the impacts while the left, right, and upper wall regions remain upright without global collapse. The broad broken-topology distribution is disclosed rather than described as localized.", - "reviewer_capability": "Codex native image input with original-resolution inspection of the six regenerated captures and pinned paper figure", - "sha256": "2ae97de397c42b6b595fef84d81c5f0f11cb2c48aa9ef138d26ba9bfbad38b12", + "reconciliation_and_verdict": "PASS for the scoped AVBD observation: both engine ViewReports pass with all 252 bricks in focus. Frames 60 and 120 reproduce the accepted text-oracle records exactly, including the same checked 154-member broken-joint identity digest and 558 surviving rows. The maximum retained-row residuals are respectively 0.000406 m and 0.000178 rad, then 0.000772 m and 0.000317 rad. The current-source images show a broad fractured opening around the impacts while the left, right, and upper wall regions remain upright without global collapse. The broad broken-topology distribution is disclosed rather than described as localized.", + "reviewer_capability": "Codex native image input with original-resolution inspection of the six current-source captures and pinned paper figure", + "sha256": "64a9f59dff9a0fe6045d33b7f0f4d11a6e058cc798eb7d66e907184f94c754cb", "text_oracle": "Figure 13 states that AVBD forms plausible fractures and maintains the attachment constraints retained after the initial impacts; this reconstruction verifies every retained-row residual and separately records every broken identity.", "verdict": "pass", "visible_observation": "At frame 60 a broad central opening and loose bricks are visible around the impacts while substantial left, right, and upper wall regions remain upright. At frame 120 the same overall structure is still globally standing with no broad folding or collapse." diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json index a21c92e4aa620..99faadc72c5a2 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json @@ -2,12 +2,12 @@ "benchmark": { "comparison": { "basis": "same benchmark JSON, executable, host, reconstructed scene fingerprint, trajectory length, time step, and 20-sweep budget", - "sequential_impulse_to_avbd_median_cpu_cost_ratio": 1.6328709891518784, - "sequential_impulse_to_vbd_median_cpu_cost_ratio": 1.651231851746857 + "sequential_impulse_to_avbd_median_cpu_cost_ratio": 1.6447876553305811, + "sequential_impulse_to_vbd_median_cpu_cost_ratio": 1.6842558689474314 }, "context": { "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51", + "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", "host_name": "mark26", "json_schema_version": 1, @@ -16,7 +16,7 @@ "mhz_per_cpu": 5300, "num_cpus": 32 }, - "json_sha256": "b377ef3e307a3f5ebbe935ff3c4abcace477cfbb92062296947b4b3537d0427d", + "json_sha256": "abab909f3693333c0bb43bdd54b15df9fd6f0c3e9d4b7dd838208fb76d12565a", "method": { "benchmark": "BM_SequentialImpulsePaperBreakableWallStep", "rows": [ @@ -25,14 +25,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 14816961.61666667, + "cpu_time": 15624216.650000006, "family_index": 2, "impacting_balls": 3.0, "iterations": 5, "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_mean", "per_family_instance_index": 0, "public_sequential-impulse_family": 1.0, - "real_time": 14821594.071690923, + "real_time": 15627557.041686183, "repetitions": 5, "resolved_rigid_body_sequential-impulse": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -55,14 +55,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 14783754.75833335, + "cpu_time": 15651048.633333351, "family_index": 2, "impacting_balls": 3.0, "iterations": 5, "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_median", "per_family_instance_index": 0, "public_sequential-impulse_family": 1.0, - "real_time": 14788653.299910948, + "real_time": 15652796.116773969, "repetitions": 5, "resolved_rigid_body_sequential-impulse": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -85,14 +85,14 @@ "aggregate_unit": "time", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 70779.65411100094, + "cpu_time": 52565.232515432945, "family_index": 2, "impacting_balls": 0.0, "iterations": 5, "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_stddev", "per_family_instance_index": 0, "public_sequential-impulse_family": 0.0, - "real_time": 69485.45080661465, + "real_time": 51872.38501883386, "repetitions": 5, "resolved_rigid_body_sequential-impulse": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -115,14 +115,14 @@ "aggregate_unit": "percentage", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 0.0047769344311039685, + "cpu_time": 0.003364343550332997, "family_index": 2, "impacting_balls": 0.0, "iterations": 5, "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_cv", "per_family_instance_index": 0, "public_sequential-impulse_family": 0.0, - "real_time": 0.004688122645278153, + "real_time": 0.0033192894372719517, "repetitions": 5, "resolved_rigid_body_sequential-impulse": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -142,21 +142,21 @@ } ], "stability": { - "cpu_time_cv_fraction": 0.0047769344311039685, - "real_time_cv_fraction": 0.004688122645278153, + "cpu_time_cv_fraction": 0.003364343550332997, + "real_time_cv_fraction": 0.0033192894372719517, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 14816961.61666667, - "mean_real_time_per_step_ns": 14821594.071690923, - "median_cpu_time_per_step_ns": 14783754.75833335, - "median_real_time_per_step_ns": 14788653.299910948 + "mean_cpu_time_per_step_ns": 15624216.650000006, + "mean_real_time_per_step_ns": 15627557.041686183, + "median_cpu_time_per_step_ns": 15651048.633333351, + "median_real_time_per_step_ns": 15652796.116773969 } }, "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "source_provenance": { "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51" + "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d" } }, "claim_boundary": "This packet covers the matched Sequential Impulse Figure 13 row and links the same-host AVBD/VBD comparison packet. Together they establish three of the four published comparison rows. It does not reproduce XPBD, claim exact unpublished source-scene constants, establish CUDA parity, or compare against a published/source timing.", @@ -181,10 +181,10 @@ "linked_avbd_vbd_evidence": { "benchmark_method_timings": { "avbd": { - "median_cpu_time_per_step_ns": 9053841.274999997 + "median_cpu_time_per_step_ns": 9515543.591666665 }, "vbd": { - "median_cpu_time_per_step_ns": 8953167.141666658 + "median_cpu_time_per_step_ns": 9292559.950000001 } }, "file": "avbd-paper-vbd-comparison-packet.json", @@ -195,7 +195,7 @@ "rigid_contact_solver": "avbd", "rigid_point_joint_solver": "avbd" }, - "nested_avbd_sha256": "494f07ff9b26e61e0e5aa9a8e487d1e94975dca0ea17b0216d1d80f411480391", + "nested_avbd_sha256": "d0098a6fac2f53c9b9f6d1898e27d46cfecb4a208f32f2c08c5703583f3c31bf", "nested_avbd_source_provenance_digest": "5f4742776b35ba6c0ff8e6a4b42b55a4bfecb4204121e24e1b4d5a41f8b39a26", "resolved_solver_identity": { "avbd_rigid_contact_config_emplaced": false, @@ -205,19 +205,19 @@ "rigid_point_joint_solver": "vbd" }, "scene_spec_fingerprint": "8ca3fbfa00c3dce9", - "sha256": "96652e9c625a4c6307a10ba6a763db26ff03e5f4f2c3749fb4911bb7b40a144b", + "sha256": "5db4c77cad4c35f9a10abc2dc892e8849f6a96e1acbaf8162902ab5091503be7", "source_provenance_digest": "640416abeb6333d8f8e1e9ddbc17c05be5e842841cd034fd709a0269a7ba6eaa", "visual_evidence": { "bend_screenshot": { "file": "vbd_paper_breakable_wall_bend.png", - "sha256": "f42b029583607aea696edfa983bde6aaa9815952ff5fae798bfd858831770c92" + "sha256": "dc05890e8f09f42dfc9dd5a1c06662a15bac88bded3197eeda8c333fc40eca06" }, "retention_screenshot": { "file": "vbd_paper_breakable_wall_retention.png", - "sha256": "bd51bb4a2f35168b52fec76aa366567905239b31cc7c47a7c349072f1b11774e" + "sha256": "69a2f2390ff4786f68f60866356291c67968aaf975e00853440c5794fdc6d590" }, "semantic_review": { - "sha256": "bcf2185e391a6d4c7bd0f9733efa7a2edbb317f09d37ad5cb8d06e304051bcac", + "sha256": "e651c5c2373880977d3e35b871213f4f29ad2a4e20972cbe4121519f05fa8dbe", "verdict": "pass" } } @@ -400,14 +400,14 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 2137, - "dark_pixels": 1819, - "mid_pixels": 222130, - "p05": 89.361, - "p95": 156.438, + "bright_pixels": 2135, + "dark_pixels": 1826, + "mid_pixels": 222077, + "p05": 89.096, + "p95": 156.431, "pass": false, "reasons": [ - "luminance spread 67.1 < 90.0" + "luminance spread 67.3 < 90.0" ], "region": { "x0": 512, @@ -416,7 +416,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 67.078, + "spread": 67.336, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -444,12 +444,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "81defd62ace3487aeba3c183c9f477042c1ad30461be6f75bc9409073c436db5" + "sha256": "9b56fc856e56bed7dd76394602f4835d050808aeef9c9b0c3b6e5ff66139d6e9" }, "label": "collapse", "manifest": { "file": "manifest.json", - "sha256": "638063b8e55394017b926a5d7a52fccd54472e88438ad079752b391260839908" + "sha256": "77910887ddf3e9d5cdaffdb455a17478d8fd23429752faa2630d02b809c269c3" }, "scene_metrics": { "event_count": 120, @@ -1046,13 +1046,13 @@ }, "screenshot": { "file": "sequential_impulse_paper_breakable_wall_collapse.png", - "sha256": "665ec2b3b9c3c5a857666ab4304bb59322709cca9ebbaf9e1e4d3c3d26bc6e36" + "sha256": "83ca961c52b606a5a90c2232e3f3f4d60d970783cb22c1b0a40dcc97d22fce29" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51", + "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", "file_count": 1556, - "git_head": "37178193ccf090843c5df6afeb1221d090f2b673", + "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", "roots": [ "CMakeLists.txt", "cmake", @@ -1089,10 +1089,10 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 1352, - "dark_pixels": 11704, - "mid_pixels": 209732, - "p05": 55.585, + "bright_pixels": 1354, + "dark_pixels": 11712, + "mid_pixels": 209742, + "p05": 55.58, "p95": 151.795, "pass": true, "reasons": [], @@ -1103,7 +1103,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 96.21, + "spread": 96.216, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -1131,12 +1131,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "0466458ee83894c90328886974d90b0733a3b972035e978cfb8c04ae6a7c96cf" + "sha256": "a5cf65cdf6fe31a3ab02c8bcdce69513bc1c48b9512f0d9339fb590741b4c9f5" }, "label": "fracture", "manifest": { "file": "manifest.json", - "sha256": "e2698a8fe6fcdc6713e8fd8c35e67b858606426f4b4ef94abeb63d6db6fc5cc5" + "sha256": "43e160adb2a530eeb1e7102eefe682e1122bcdd21d1c3860b57972d2593f6605" }, "scene_metrics": { "event_count": 14, @@ -1733,13 +1733,13 @@ }, "screenshot": { "file": "sequential_impulse_paper_breakable_wall_fracture.png", - "sha256": "41c4e781d0a2d864883bea0af2bf9c46380a3dfbc46c30422293b7ca6fd92019" + "sha256": "678af175bd8cbbea575dd152884996dd36aacec63bf0b66d38a81ebd7d92fc43" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51", + "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", "file_count": 1556, - "git_head": "37178193ccf090843c5df6afeb1221d090f2b673", + "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", "roots": [ "CMakeLists.txt", "cmake", @@ -1762,12 +1762,12 @@ { "file": "sequential_impulse_paper_breakable_wall_fracture.png", "role": "fracture_frame_14", - "sha256": "41c4e781d0a2d864883bea0af2bf9c46380a3dfbc46c30422293b7ca6fd92019" + "sha256": "678af175bd8cbbea575dd152884996dd36aacec63bf0b66d38a81ebd7d92fc43" }, { "file": "sequential_impulse_paper_breakable_wall_collapse.png", "role": "collapse_frame_120", - "sha256": "665ec2b3b9c3c5a857666ab4304bb59322709cca9ebbaf9e1e4d3c3d26bc6e36" + "sha256": "83ca961c52b606a5a90c2232e3f3f4d60d970783cb22c1b0a40dcc97d22fce29" }, { "file": "paper-10.png", @@ -1776,9 +1776,9 @@ } ], "not_proven_and_limitations": "The images cannot reveal joint identity or residual magnitude, so those claims rely on the checked text oracle and digest. This three-method packet does not reproduce XPBD, establish CUDA parity, prove exact unpublished source constants, or establish parity with published/source timing.", - "reconciliation_and_verdict": "PASS: both engine ViewReports pass with all 252 bricks in focus. At frame 14 exactly five broken identities cover the three impact regions in counts 2, 1, and 2, with none outside; all 484 outside-impact retained rows remain present. At frame 120 the same identity digest remains with no additional joint breaks, but those outside-impact rows reach 0.063460 m maximum and 0.020763 m RMS linear residual plus 0.727048 rad maximum and 0.216362 rad RMS angular residual. The images independently progress from a standing wall around the three impacts to a slab collapsed across the ground.", - "reviewer_capability": "Codex native image input with original-resolution inspection of the six regenerated captures and pinned paper figure", - "sha256": "48766fc569fc8918883ab2d3c0810c9b8633d827f44bacbf7084bc2f05d37956", + "reconciliation_and_verdict": "PASS: both engine ViewReports pass with all 252 bricks in focus, and the current-source text records exactly match the accepted oracle. At frame 14 exactly five broken identities cover the three impact regions in counts 2, 1, and 2, with none outside; all 484 outside-impact retained rows remain present. At frame 120 the same identity digest remains with no additional joint breaks, but those outside-impact rows reach 0.063460 m maximum and 0.020763 m RMS linear residual plus 0.727048 rad maximum and 0.216362 rad RMS angular residual. The images progress from a standing wall around the three impacts to a slab collapsed across the ground.", + "reviewer_capability": "Codex native image input with original-resolution inspection of the six current-source captures and pinned paper figure", + "sha256": "7ecf97930c4cf6798c363a19d440cbc45717017013b1142d8c1d475f99b8f198", "text_oracle": "Figure 13 states that Sequential Impulse fractures first but cannot maintain retained constraints, causing slow bending and final collapse; the DART row directly measures retained rows outside the impacts.", "verdict": "pass", "visible_observation": "At frame 14 the wall is still globally upright and continuous around the three balls. At frame 120 almost the entire wall lies flat across the ground in a coherent collapsed slab while the same five joints, and no additional joints, are recorded as broken." diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json index a7a5e92248558..ea2b693842614 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json @@ -2,11 +2,11 @@ "benchmark": { "comparison": { "basis": "same executable, host, reconstructed scene fingerprint, trajectory length, time step, and 20-sweep budget", - "vbd_to_avbd_median_cpu_cost_ratio": 0.988880506044288 + "vbd_to_avbd_median_cpu_cost_ratio": 0.9765663790493331 }, "context": { "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51", + "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", "host_name": "mark26", "json_schema_version": 1, @@ -15,7 +15,7 @@ "mhz_per_cpu": 5300, "num_cpus": 32 }, - "json_sha256": "b377ef3e307a3f5ebbe935ff3c4abcace477cfbb92062296947b4b3537d0427d", + "json_sha256": "abab909f3693333c0bb43bdd54b15df9fd6f0c3e9d4b7dd838208fb76d12565a", "methods": { "avbd": { "benchmark": "BM_AvbdPaperBreakableWallStep", @@ -25,14 +25,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9065270.446666665, + "cpu_time": 9523638.290000001, "family_index": 0, "impacting_balls": 3.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_mean", "per_family_instance_index": 0, "public_avbd_family": 1.0, - "real_time": 9068783.145048654, + "real_time": 9526517.41836841, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -55,14 +55,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9053841.274999997, + "cpu_time": 9515543.591666665, "family_index": 0, "impacting_balls": 3.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_median", "per_family_instance_index": 0, "public_avbd_family": 1.0, - "real_time": 9056136.558259215, + "real_time": 9516189.9337351, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -85,14 +85,14 @@ "aggregate_unit": "time", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 51866.67046000096, + "cpu_time": 46344.68280297959, "family_index": 0, "impacting_balls": 0.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_stddev", "per_family_instance_index": 0, "public_avbd_family": 0.0, - "real_time": 52199.15238895366, + "real_time": 49349.39234928154, "repetitions": 5, "resolved_rigid_body_avbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -115,14 +115,14 @@ "aggregate_unit": "percentage", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 0.005721469730566343, + "cpu_time": 0.004866279187822828, "family_index": 0, "impacting_balls": 0.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_cv", "per_family_instance_index": 0, "public_avbd_family": 0.0, - "real_time": 0.005755915821788416, + "real_time": 0.005180213312173162, "repetitions": 5, "resolved_rigid_body_avbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -142,15 +142,15 @@ } ], "stability": { - "cpu_time_cv_fraction": 0.005721469730566343, - "real_time_cv_fraction": 0.005755915821788416, + "cpu_time_cv_fraction": 0.004866279187822828, + "real_time_cv_fraction": 0.005180213312173162, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 9065270.446666665, - "mean_real_time_per_step_ns": 9068783.145048654, - "median_cpu_time_per_step_ns": 9053841.274999997, - "median_real_time_per_step_ns": 9056136.558259215 + "mean_cpu_time_per_step_ns": 9523638.290000001, + "mean_real_time_per_step_ns": 9526517.41836841, + "median_cpu_time_per_step_ns": 9515543.591666665, + "median_real_time_per_step_ns": 9516189.9337351 } }, "vbd": { @@ -161,14 +161,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9036311.241666658, + "cpu_time": 9292559.540000003, "family_index": 1, "impacting_balls": 3.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_mean", "per_family_instance_index": 0, "public_vbd_family": 1.0, - "real_time": 9037537.44981562, + "real_time": 9293226.2699469, "repetitions": 5, "resolved_rigid_body_vbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -191,14 +191,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 8953167.141666658, + "cpu_time": 9292559.950000001, "family_index": 1, "impacting_balls": 3.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_median", "per_family_instance_index": 0, "public_vbd_family": 1.0, - "real_time": 8953712.975198869, + "real_time": 9293373.77504756, "repetitions": 5, "resolved_rigid_body_vbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -221,14 +221,14 @@ "aggregate_unit": "time", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 161855.20381488634, + "cpu_time": 16911.492278430254, "family_index": 1, "impacting_balls": 0.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_stddev", "per_family_instance_index": 0, "public_vbd_family": 0.0, - "real_time": 162127.33122827957, + "real_time": 16977.790123070678, "repetitions": 5, "resolved_rigid_body_vbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -251,14 +251,14 @@ "aggregate_unit": "percentage", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 0.017911645525064242, + "cpu_time": 0.001819896036784527, "family_index": 1, "impacting_balls": 0.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_cv", "per_family_instance_index": 0, "public_vbd_family": 0.0, - "real_time": 0.01793932607511211, + "real_time": 0.0018268994674082856, "repetitions": 5, "resolved_rigid_body_vbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -278,22 +278,22 @@ } ], "stability": { - "cpu_time_cv_fraction": 0.017911645525064242, - "real_time_cv_fraction": 0.01793932607511211, + "cpu_time_cv_fraction": 0.001819896036784527, + "real_time_cv_fraction": 0.0018268994674082856, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 9036311.241666658, - "mean_real_time_per_step_ns": 9037537.44981562, - "median_cpu_time_per_step_ns": 8953167.141666658, - "median_real_time_per_step_ns": 8953712.975198869 + "mean_cpu_time_per_step_ns": 9292559.540000003, + "mean_real_time_per_step_ns": 9293226.2699469, + "median_cpu_time_per_step_ns": 9292559.950000001, + "median_real_time_per_step_ns": 9293373.77504756 } } }, "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "source_provenance": { "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51" + "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d" } }, "claim_boundary": "This packet covers the matched fixed-penalty VBD row and its same-host CPU comparison with the linked public AVBD Figure 13 packet. It does not reproduce the Sequential Impulse or XPBD rows, claim exact unpublished source constants, establish CUDA parity, or compare against a published/source timing.", @@ -325,19 +325,19 @@ "rigid_point_joint_solver": "avbd" }, "scene_spec_fingerprint": "8ca3fbfa00c3dce9", - "sha256": "494f07ff9b26e61e0e5aa9a8e487d1e94975dca0ea17b0216d1d80f411480391", + "sha256": "d0098a6fac2f53c9b9f6d1898e27d46cfecb4a208f32f2c08c5703583f3c31bf", "visual_evidence": { "impact_screenshot": { "file": "avbd_paper_breakable_wall_impact.png", - "sha256": "e0a998b1710f36e1e3b82bfeb3f20c18d99e42ad8f429f42321e364abcdc246e" + "sha256": "b44fc5d55df54955f53f67330689b8e9356201c417ce932793366368c15e2a3f" }, "outcome_screenshot": { "file": "avbd_paper_breakable_wall_outcome.png", - "sha256": "a75dfc58f790864c9bad224d8c658eead3134a14362cf4bffd0e6a2744e77af9" + "sha256": "a0777940e033e4684ec7f2f747a1b5b2a2694f849aa88d04a08e82dbb7d0c087" }, "semantic_review": { "file": "avbd-visual-review.json", - "sha256": "2ae97de397c42b6b595fef84d81c5f0f11cb2c48aa9ef138d26ba9bfbad38b12", + "sha256": "64a9f59dff9a0fe6045d33b7f0f4d11a6e058cc798eb7d66e907184f94c754cb", "verdict": "pass" } } @@ -504,9 +504,9 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 1480, - "dark_pixels": 10921, - "mid_pixels": 210208, + "bright_pixels": 1490, + "dark_pixels": 10920, + "mid_pixels": 210205, "p05": 57.583, "p95": 151.795, "pass": true, @@ -546,12 +546,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "74f39a059823741e103053da2bd7a946c3baa59a34bfb4020549540fbdb3027b" + "sha256": "3163fa7058523fa20d7b75a6e5cb74eb46607794b1a58242946e98dfa32124e4" }, "label": "bend", "manifest": { "file": "manifest.json", - "sha256": "49f55b8bc452bb403c48f0a4603c88156b101388c345dd292f6745348340805d" + "sha256": "c95b42248e7e04c1db95dfde7d01d73a9fddac79c55f3da3c2399933bb4162ed" }, "scene_metrics": { "event_count": 18, @@ -1010,13 +1010,13 @@ }, "screenshot": { "file": "vbd_paper_breakable_wall_bend.png", - "sha256": "f42b029583607aea696edfa983bde6aaa9815952ff5fae798bfd858831770c92" + "sha256": "dc05890e8f09f42dfc9dd5a1c06662a15bac88bded3197eeda8c333fc40eca06" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51", + "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", "file_count": 1556, - "git_head": "37178193ccf090843c5df6afeb1221d090f2b673", + "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", "roots": [ "CMakeLists.txt", "cmake", @@ -1053,14 +1053,14 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 841, - "dark_pixels": 12608, - "mid_pixels": 208242, + "bright_pixels": 840, + "dark_pixels": 12612, + "mid_pixels": 208237, "p05": 53.585, "p95": 151.511, "pass": false, "reasons": [ - "bright pixels 841 < 1180" + "bright pixels 840 < 1180" ], "region": { "x0": 512, @@ -1097,12 +1097,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "6dbc0329ab66b6d752823fc47f9bb89a2ff04bba2eaf7438379f708266488272" + "sha256": "00491d30910abab7b4e83a7425fbb7fa0089e684e569d70cf532fdea34cf1321" }, "label": "retention", "manifest": { "file": "manifest.json", - "sha256": "c8a2ac8fc27703a17e5c26f01e6b306392039ccbbc8f78845bc735d285e696b1" + "sha256": "07c8ab2f161662da9a00d550fbd86d50e3f22aa44b3519fb4fdb82ad8c9be253" }, "scene_metrics": { "event_count": 120, @@ -1559,13 +1559,13 @@ }, "screenshot": { "file": "vbd_paper_breakable_wall_retention.png", - "sha256": "bd51bb4a2f35168b52fec76aa366567905239b31cc7c47a7c349072f1b11774e" + "sha256": "69a2f2390ff4786f68f60866356291c67968aaf975e00853440c5794fdc6d590" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "b3f102b53c820809c0a8b34c0bf3002853b3b1e79ce17f56fb6380d10cccfd51", + "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", "file_count": 1556, - "git_head": "37178193ccf090843c5df6afeb1221d090f2b673", + "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", "roots": [ "CMakeLists.txt", "cmake", @@ -1588,12 +1588,12 @@ { "file": "vbd_paper_breakable_wall_bend.png", "role": "bend_frame_18", - "sha256": "f42b029583607aea696edfa983bde6aaa9815952ff5fae798bfd858831770c92" + "sha256": "dc05890e8f09f42dfc9dd5a1c06662a15bac88bded3197eeda8c333fc40eca06" }, { "file": "vbd_paper_breakable_wall_retention.png", "role": "retention_frame_120", - "sha256": "bd51bb4a2f35168b52fec76aa366567905239b31cc7c47a7c349072f1b11774e" + "sha256": "69a2f2390ff4786f68f60866356291c67968aaf975e00853440c5794fdc6d590" }, { "file": "paper-10.png", @@ -1602,9 +1602,9 @@ } ], "not_proven_and_limitations": "The front-oblique image makes depth-wise bending intentionally subtle, so the measured magnitude comes from the numeric oracle rather than visual estimation. This linked VBD/AVBD packet does not prove exact unpublished constants, Sequential Impulse, XPBD, CUDA parity, or published/source timing parity.", - "reconciliation_and_verdict": "PASS: both engine ViewReports pass with all 252 bricks in focus. At frame 18 the oracle records zero broken joints, 120 spatially bent bricks, 0.131705 m maximum wall-normal displacement, and maximum retained-row residuals of 0.016387 m and 0.013275 rad. At frame 120 all 712 identities remain unbroken and the maximum residuals fall to 0.011095 m and 0.001310 rad. The inspected images show the corresponding continuous wall during impact and the later intact standing wall.", - "reviewer_capability": "Codex native image input with original-resolution inspection of the six regenerated captures and pinned paper figure", - "sha256": "bcf2185e391a6d4c7bd0f9733efa7a2edbb317f09d37ad5cb8d06e304051bcac", + "reconciliation_and_verdict": "PASS: both engine ViewReports pass with all 252 bricks in focus. The current-source text records exactly match the accepted oracle. At frame 18 they record zero broken joints, 120 spatially bent bricks, 0.131705 m maximum wall-normal displacement, and maximum retained-row residuals of 0.016387 m and 0.013275 rad. At frame 120 all 712 identities remain unbroken and the maximum residuals fall to 0.011095 m and 0.001310 rad. The inspected images show the corresponding continuous wall during impact and the later intact standing wall.", + "reviewer_capability": "Codex native image input with original-resolution inspection of the six current-source captures and pinned paper figure", + "sha256": "e651c5c2373880977d3e35b871213f4f29ad2a4e20972cbe4121519f05fa8dbe", "text_oracle": "Figure 13 states that VBD bends the wall but does not break it; the DART row additionally verifies every retained joint identity and residual.", "verdict": "pass", "visible_observation": "At frame 18 the full wall remains continuous with no missing bricks or tears while the three balls load its center courses. At frame 120 the wall is again nearly planar, continuous, and fully standing." From 995be410b954b1d97d2ac98552478c71f0320671 Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Sat, 1 Aug 2026 19:57:25 -0700 Subject: [PATCH 11/13] Clarify fracture test names and replication follow-ups --- docs/dev_tasks/avbd_solver/README.md | 11 ++++--- docs/dev_tasks/avbd_solver/RESUME.md | 29 ++++++++++++------- ...d_articulated_compliant_fracture_packet.py | 14 +++++---- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/docs/dev_tasks/avbd_solver/README.md b/docs/dev_tasks/avbd_solver/README.md index 15e2eed7cd892..31e3b28ed8d9f 100644 --- a/docs/dev_tasks/avbd_solver/README.md +++ b/docs/dev_tasks/avbd_solver/README.md @@ -148,11 +148,14 @@ foundation PR is still open, manage only that PR through current-head CI and review; do not extend its branch into the remaining parity program. After maintainer approval and landing, complete all 88 VBD predicates in one VBD PR from updated `main`, including an honest public XPBD comparator, the VBD -CPU/CUDA and corpus gaps, and achieved-accuracy performance. After that lands, +CPU/CUDA and corpus gaps, achieved-accuracy performance, and PR-hosted images, +GIFs, and videos for every paper/site/video/source-demo row. After that lands, complete all 88 AVBD predicates in a second AVBD PR from updated `main`, -including AVBD-specific CPU/CUDA, unified-row, corpus, four-method, and -performance gaps. Keep every dependent figure/demo/performance row partial -until its own source-matched CPU and CUDA evidence closes it. +including AVBD-specific CPU/CUDA, unified-row, corpus, four-method, +performance, and PR-hosted visual evidence. These are the only two follow-up +PRs: do not split implementation, performance, or replication media into +separate PRs. Keep every dependent figure/demo/performance row partial until +its own source-matched CPU and CUDA evidence closes it. Deferred maintenance items remain valid but do not outrank the missing paper mechanism: diff --git a/docs/dev_tasks/avbd_solver/RESUME.md b/docs/dev_tasks/avbd_solver/RESUME.md index c5611781c9f1d..18df17010a9e9 100644 --- a/docs/dev_tasks/avbd_solver/RESUME.md +++ b/docs/dev_tasks/avbd_solver/RESUME.md @@ -307,7 +307,12 @@ or validate source-bound artifacts, so recheck provenance and packets after it. The maintainer directed the remaining work into two ordered completion PRs. Use multiple reviewable commits and durable work packets inside each PR if -needed, but do not mix the two completion claims. +needed, but do not mix the two completion claims or create separate media-only +PRs. Each method's completion PR owns its implementation, tests, CPU/GPU +benchmarks, text oracles, and current-head PR-hosted images, GIFs, and videos +for every paper, project-site, official-video, and source-demo row. Keep raw +and generated transient media out of git and expose it through GitHub +attachments, following the same repository-hygiene boundary as PR #3377. 1. **VBD completion PR first.** Start from updated `main` only after the shared SI/VBD/AVBD foundation lands. Close every one of the 88 VBD predicates, @@ -316,9 +321,10 @@ needed, but do not mix the two completion claims. contracts, and achieved-accuracy performance leadership. Implement the honest public XPBD substep comparison and source-matched Figure 13/video evidence in this PR because they are required comparators for the VBD - contract; do not relabel SI or fixed-penalty VBD as XPBD. Keep AVBD changes - limited to unavoidable shared-interface compatibility, with no AVBD - completion claim. + contract. Publish the complete VBD replication image/video set in this same + PR; do not defer it to a presentation follow-up. Do not relabel SI or + fixed-penalty VBD as XPBD. Keep AVBD changes limited to unavoidable + shared-interface compatibility, with no AVBD completion claim. 2. **Land and rebase the plan, not the published branch.** Manage the VBD PR through review and CI only with explicit approval. After it lands, refresh local `main` and the fail-closed matrices. Never rebase a published PR @@ -328,6 +334,7 @@ needed, but do not mix the two completion claims. dual/stiffness evolution, quasi-Newton terms, articulated/joint/fracture and rigid-contact breadth, unified soft/rigid coupling, all CPU/CUDA paths, the remaining corpus, four-method evidence, and matched performance leadership. + Publish the complete AVBD replication image/video set in this same PR. Reuse the landed VBD/XPBD evidence by hash rather than duplicating or weakening its acceptance boundary. 4. **Final 176-row audit.** After the AVBD PR is current, obtain clean code and @@ -383,11 +390,13 @@ open, manage only its current head through CI and review; do not merge without separate explicit approval. Do not extend the shared foundation branch into the remaining parity program. After maintainer approval and landing, complete VBD first in one PR from updated main, including honest XPBD comparison, all -CPU/CUDA and corpus rows, and performance leadership. +CPU/CUDA and corpus rows, performance leadership, and all current-head +paper/site/video/source-demo image and video attachments; do not create a +separate media PR. After that PR lands, complete AVBD in a second PR from updated main, including -all AVBD-specific CPU/CUDA, unified-row, corpus, four-method, and performance -gaps. Keep all claims fail-closed until every one of the 176 canonical -predicates passes. Do not stop at an MVP, CPU-only implementation, or -documentation checkpoint, and do not push or mutate GitHub without explicit -approval. +all AVBD-specific CPU/CUDA, unified-row, corpus, four-method, performance, and +current-head image/video evidence; do not create a separate media PR. Keep all +claims fail-closed until every one of the 176 canonical predicates passes. Do +not stop at an MVP, CPU-only implementation, or documentation checkpoint, and +do not push or mutate GitHub without explicit approval. ``` diff --git a/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py b/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py index ffa83bb538c6d..0be44d43acd08 100644 --- a/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py +++ b/python/tests/unit/test_write_avbd_articulated_compliant_fracture_packet.py @@ -272,11 +272,15 @@ def _write_exact_parent_mutation( "schema_version": "dart.exact_parent_mutation/v1", "parent_commit": parent_commit, "tests": [ - "VariationalIntegration." - "AvbdCompliantPublicArticulatedJointAggregatesFiniteAndMotor" - "BreakLoads", - "VariationalIntegration." - "AvbdCompliantPublicArticulatedJointBreakResetRearmsFiniteRows", + ( + "VariationalIntegration." + + "AvbdCompliantPublicArticulatedJointAggregatesFiniteAndMotor" + + "BreakLoads" + ), + ( + "VariationalIntegration." + + "AvbdCompliantPublicArticulatedJointBreakResetRearmsFiniteRows" + ), ], "mutation": ( "Apply the candidate finite-load tests to the exact parent " From 58bf1c3e85f702ea21a5417f2f6ae352c12dcb0a Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Sat, 1 Aug 2026 20:49:18 -0700 Subject: [PATCH 12/13] Permit world-family solver config refinements --- .../avbd-paper-breakable-wall-packet.json | 4 ++-- ...aper-sequential-impulse-comparison-packet.json | 14 +++++++------- .../avbd-paper-vbd-comparison-packet.json | 6 +++--- scripts/avbd_packet_schema.py | 5 ++--- tests/test_avbd_packet_schema.py | 15 +++++++++++++++ 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json index a9b9ae048f183..38ff315b226ea 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json @@ -201,7 +201,7 @@ "schema_version": 4, "source_provenance": { "algorithm": "sha256-length-prefixed-path-and-content-v1", - "digest": "5f4742776b35ba6c0ff8e6a4b42b55a4bfecb4204121e24e1b4d5a41f8b39a26", + "digest": "f4e41fb154bc6d51eb16de658167e8307436daaf511e1c86ae4ce4004494c640", "files": [ { "path": "dart/gui/view_quality.cpp", @@ -273,7 +273,7 @@ }, { "path": "scripts/avbd_packet_schema.py", - "sha256": "d2cf699fd9f19fe97fa992977f56c00a43745a0659bda4deb8e10e6fe91e021c" + "sha256": "95f3a7373d9ddf5bae975a7be9f822f8884cbe67e64f352954861ef8fc23aca4" }, { "path": "scripts/capture_py_demo.py", diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json index 99faadc72c5a2..053739008f329 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json @@ -195,8 +195,8 @@ "rigid_contact_solver": "avbd", "rigid_point_joint_solver": "avbd" }, - "nested_avbd_sha256": "d0098a6fac2f53c9b9f6d1898e27d46cfecb4a208f32f2c08c5703583f3c31bf", - "nested_avbd_source_provenance_digest": "5f4742776b35ba6c0ff8e6a4b42b55a4bfecb4204121e24e1b4d5a41f8b39a26", + "nested_avbd_sha256": "d7fe44e4708a0b24646ac01610d166e0167642bd1ee1c58f8e05591641b58f26", + "nested_avbd_source_provenance_digest": "f4e41fb154bc6d51eb16de658167e8307436daaf511e1c86ae4ce4004494c640", "resolved_solver_identity": { "avbd_rigid_contact_config_emplaced": false, "recorded_from": "engine World.resolved_configuration in both VBD captures plus benchmark runtime resolved-configuration counters", @@ -205,8 +205,8 @@ "rigid_point_joint_solver": "vbd" }, "scene_spec_fingerprint": "8ca3fbfa00c3dce9", - "sha256": "5db4c77cad4c35f9a10abc2dc892e8849f6a96e1acbaf8162902ab5091503be7", - "source_provenance_digest": "640416abeb6333d8f8e1e9ddbc17c05be5e842841cd034fd709a0269a7ba6eaa", + "sha256": "0302c036e390c6db2696d8a5e5ee3074eff05aacbce6d003c468aaef2e4560a3", + "source_provenance_digest": "73c70bd23e4ad8b419c32901b02a6e5928c4b36510e682c3413baaa5367b0b88", "visual_evidence": { "bend_screenshot": { "file": "vbd_paper_breakable_wall_bend.png", @@ -275,7 +275,7 @@ "schema_version": 4, "source_provenance": { "algorithm": "sha256-length-prefixed-path-and-content-v1", - "digest": "7a7f1ee86befcbb2afee50de15151f5b74412d56cc86760b481cf82133298c82", + "digest": "5386c5304d3b900392596134fe421a7b33f3aca328044ea7f15b0d0491255c5e", "files": [ { "path": "dart/simulation/world.cpp", @@ -343,11 +343,11 @@ }, { "path": "tests/test_avbd_packet_schema.py", - "sha256": "1e0892f2721b458e945762653c59461333a7eccef782c6a9d2f984a869047579" + "sha256": "f3a6325e7d67a73604bc4284fce66e0e55721d7929a31ab90ee23849559a4f4e" }, { "path": "scripts/avbd_packet_schema.py", - "sha256": "d2cf699fd9f19fe97fa992977f56c00a43745a0659bda4deb8e10e6fe91e021c" + "sha256": "95f3a7373d9ddf5bae975a7be9f822f8884cbe67e64f352954861ef8fc23aca4" }, { "path": "scripts/capture_py_demo.py", diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json index ea2b693842614..d3c34fb40cf5e 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json @@ -325,7 +325,7 @@ "rigid_point_joint_solver": "avbd" }, "scene_spec_fingerprint": "8ca3fbfa00c3dce9", - "sha256": "d0098a6fac2f53c9b9f6d1898e27d46cfecb4a208f32f2c08c5703583f3c31bf", + "sha256": "d7fe44e4708a0b24646ac01610d166e0167642bd1ee1c58f8e05591641b58f26", "visual_evidence": { "impact_screenshot": { "file": "avbd_paper_breakable_wall_impact.png", @@ -395,7 +395,7 @@ "schema_version": 4, "source_provenance": { "algorithm": "sha256-length-prefixed-path-and-content-v1", - "digest": "640416abeb6333d8f8e1e9ddbc17c05be5e842841cd034fd709a0269a7ba6eaa", + "digest": "73c70bd23e4ad8b419c32901b02a6e5928c4b36510e682c3413baaa5367b0b88", "files": [ { "path": "dart/simulation/world.cpp", @@ -459,7 +459,7 @@ }, { "path": "scripts/avbd_packet_schema.py", - "sha256": "d2cf699fd9f19fe97fa992977f56c00a43745a0659bda4deb8e10e6fe91e021c" + "sha256": "95f3a7373d9ddf5bae975a7be9f822f8884cbe67e64f352954861ef8fc23aca4" }, { "path": "scripts/capture_py_demo.py", diff --git a/scripts/avbd_packet_schema.py b/scripts/avbd_packet_schema.py index bd46b058c6fda..694c9f3b9a3aa 100644 --- a/scripts/avbd_packet_schema.py +++ b/scripts/avbd_packet_schema.py @@ -280,12 +280,11 @@ def resolved_solver_identity_errors( "rigid_contact_selection 'world_solver_family'" ) elif selection == "world_solver_family": - if rigid_contact_solver not in ("avbd", "vbd") or emplaced is not False: + if rigid_contact_solver not in ("avbd", "vbd"): errors.append( f"{packet_name}: {RESOLVED_SOLVER_IDENTITY_KEY}." "rigid_contact_selection 'world_solver_family' requires " - "rigid_contact_solver 'avbd' or 'vbd' " - "without a private body config" + "rigid_contact_solver 'avbd' or 'vbd'" ) if rigid_point_joint_solver not in ("none", rigid_contact_solver): errors.append( diff --git a/tests/test_avbd_packet_schema.py b/tests/test_avbd_packet_schema.py index a15ba72f328ca..94a8e90519161 100644 --- a/tests/test_avbd_packet_schema.py +++ b/tests/test_avbd_packet_schema.py @@ -216,6 +216,21 @@ def test_public_vbd_family_records_matching_world_owned_rows(): assert identity["rigid_point_joint_solver"] == "vbd" +@pytest.mark.parametrize("solver", ["avbd", "vbd"]) +def test_public_world_family_accepts_body_config_refinement(solver): + identity = schema.make_resolved_solver_identity( + resolved_rigid_contact_family=solver, + rigid_point_joint_solver=solver, + avbd_rigid_contact_config_emplaced=True, + recorded_from="World resolved-configuration report", + rigid_contact_selection="world_solver_family", + ) + + assert identity["rigid_contact_selection"] == "world_solver_family" + assert identity["rigid_contact_solver"] == solver + assert identity["avbd_rigid_contact_config_emplaced"] is True + + def test_public_sequential_impulse_family_records_matching_world_owned_rows(): identity = schema.make_resolved_solver_identity( resolved_rigid_contact_family="sequential-impulse", From 57bce9a5cedf3634f3e2d60513d24567e3e583ea Mon Sep 17 00:00:00 2001 From: Jeongseok Lee Date: Sat, 1 Aug 2026 22:10:08 -0700 Subject: [PATCH 13/13] Reject hard joint rows in fixed-penalty VBD --- .../detail/rigid_avbd/rigid_world_contact.hpp | 20 ++- dart/simulation/multibody/joint.hpp | 9 +- dart/simulation/world.cpp | 40 ++++++ dart/simulation/world.hpp | 8 +- dart/simulation/world_options.hpp | 2 + .../avbd-paper-breakable-wall-packet.json | 102 +++++++------- ...-sequential-impulse-comparison-packet.json | 126 ++++++++--------- .../avbd-paper-vbd-comparison-packet.json | 132 +++++++++--------- .../demos/scenes/avbd_paper_breakable_wall.py | 7 + .../simulation/bm_avbd_rigid_fixed_joint.cpp | 6 + .../deformable_vbd/test_avbd_rigid_block.cpp | 57 ++++++++ tests/unit/simulation/world/test_world.cpp | 44 ++++++ .../test_world_resolved_configuration.cpp | 6 +- 13 files changed, 368 insertions(+), 191 deletions(-) diff --git a/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp b/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp index d916a879860b6..a717cec55950e 100644 --- a/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp +++ b/dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp @@ -2267,7 +2267,8 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( scratch.clear(); const auto applyFixedPenaltyRows = [&](auto& rows, AvbdScalarRowInventory& inventory, - std::size_t inventoryOffset = 0u) { + std::size_t inventoryOffset = 0u, + bool requireFiniteMaterial = false) { if (!fixedPenalty) { return; } @@ -2278,6 +2279,11 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( "VBD fixed-penalty row inventory drifted from assembled rows"); const AvbdScalarRowDescriptor& descriptor = inventory[inventoryOffset + i].descriptor; + DART_SIMULATION_THROW_T_IF( + requireFiniteMaterial && !std::isfinite(descriptor.materialStiffness), + InvalidOperationException, + "VBD fixed-penalty joint rows require an explicitly configured " + "finite material stiffness; hard joint rows are not supported"); const double requestedStiffness = std::isfinite(descriptor.materialStiffness) ? descriptor.materialStiffness @@ -2362,8 +2368,16 @@ inline AvbdRigidWorldContactSolveResult solveAvbdRigidWorldContactSnapshot( scratch.jointLinearRowsScratch, scratch.jointAngularRowsScratch, options.warmStart); - applyFixedPenaltyRows(jointLinearRows, jointLinearInventory); - applyFixedPenaltyRows(jointAngularRows, jointAngularInventory); + applyFixedPenaltyRows( + jointLinearRows, + jointLinearInventory, + /*inventoryOffset=*/0u, + /*requireFiniteMaterial=*/true); + applyFixedPenaltyRows( + jointAngularRows, + jointAngularInventory, + /*inventoryOffset=*/0u, + /*requireFiniteMaterial=*/true); result.jointLinearRows = jointLinearRows.size(); result.jointAngularRows = jointAngularRows.size(); diff --git a/dart/simulation/multibody/joint.hpp b/dart/simulation/multibody/joint.hpp index 4e33b1724e5a9..a55bbb009a785 100644 --- a/dart/simulation/multibody/joint.hpp +++ b/dart/simulation/multibody/joint.hpp @@ -72,10 +72,11 @@ enum class ActuatorType /// /// The policy applies to public world-owned point-joint facades. A finite /// linear or angular stiffness makes the matching projected rows finite- -/// stiffness rows under the VBD and AVBD rigid-body families; infinity keeps -/// the default hard-constraint behavior. The default Sequential Impulse -/// family supports hard rows only and fails closed before the next step when -/// a finite stiffness is configured. +/// stiffness rows under the VBD and AVBD rigid-body families. Infinity keeps +/// the default hard-constraint behavior under Sequential Impulse and AVBD; +/// fixed-penalty VBD rejects active hard rows before the next step. The default +/// Sequential Impulse family supports hard rows only and likewise fails closed +/// when a finite stiffness is configured. struct JointConstraintProjectionPolicy { /// Starting projection stiffness. Infinity keeps the hard-start projection. diff --git a/dart/simulation/world.cpp b/dart/simulation/world.cpp index e127e9e702091..654aeb3349f2d 100644 --- a/dart/simulation/world.cpp +++ b/dart/simulation/world.cpp @@ -2181,6 +2181,38 @@ bool hasFiniteStiffnessRigidBodyJointRows(const World& world) return false; } +//============================================================================== +bool hasHardStiffnessRigidBodyJointRows(const World& world) +{ + const auto& registry = detail::registryOf(world); + const auto view = registry.view(); + for (const entt::entity entity : view) { + const auto& joint = view.get(entity); + if (!isRigidBodyJoint(registry, joint)) { + continue; + } + + const auto* config + = registry + .try_get( + entity); + if (config == nullptr || !config->enabled) { + continue; + } + + const bool hasHardLinearRows + = config->linearAxisMask != 0u + && std::isinf(config->linearMaterialStiffness); + const bool hasHardAngularRows + = config->angularAxisMask != 0u + && std::isinf(config->angularMaterialStiffness); + if (hasHardLinearRows || hasHardAngularRows) { + return true; + } + } + return false; +} + //============================================================================== void validateRigidBodyJointPipelineSupport( const World& world, RigidBodySolver solver) @@ -2204,6 +2236,14 @@ void validateRigidBodyJointPipelineSupport( "joint rows only; finite constraint projection stiffness requires the " "VBD or AVBD rigid-body solver"); + DART_SIMULATION_THROW_T_IF( + solver == RigidBodySolver::Vbd + && hasHardStiffnessRigidBodyJointRows(world), + InvalidOperationException, + "The VBD rigid-body solver supports finite-penalty rigid-body joint " + "rows only; configure finite linear and angular constraint projection " + "stiffness or select Sequential Impulse or AVBD for hard rows"); + if (solver == RigidBodySolver::Ipc) { DART_SIMULATION_THROW_T_IF( hasRigidBodyDistanceSprings(world), diff --git a/dart/simulation/world.hpp b/dart/simulation/world.hpp index 4a4f2881dcde8..72189ee4d37ec 100644 --- a/dart/simulation/world.hpp +++ b/dart/simulation/world.hpp @@ -378,10 +378,12 @@ class DART_SIMULATION_API World /// The default remains SequentialImpulse. Vbd and Avbd are explicit opt-ins /// for free rigid-body contact and public rigid-body pair constraints. Vbd /// holds conservative rows at fixed finite penalty stiffness; Avbd adds the - /// augmented-Lagrangian dual and progressive stiffness update. Ipc is + /// augmented-Lagrangian dual and progressive stiffness update. Public hard + /// pair-joint rows require SequentialImpulse or Avbd; Vbd fails closed until + /// their projection policy explicitly configures finite stiffness. Ipc is /// experimental and currently handles free mesh-like rigid bodies through - /// the internal rigid IPC stage. Unsupported family/domain combinations throw - /// rather than falling through to another solver. + /// the internal rigid IPC stage. Unsupported family/domain combinations + /// throw rather than falling through to another solver. void setRigidBodySolver(RigidBodySolver solver); /// Get the solver family used by the default rigid-body step pipeline. diff --git a/dart/simulation/world_options.hpp b/dart/simulation/world_options.hpp index 5f3edc40ec9dc..94588b6bfd517 100644 --- a/dart/simulation/world_options.hpp +++ b/dart/simulation/world_options.hpp @@ -62,6 +62,8 @@ enum class RigidBodySolver /// rigid-body pair constraints. Unlike `Avbd`, this family does not use an /// augmented-Lagrangian dual update or progressive stiffness ramp: each /// supported constraint remains at its configured finite penalty stiffness. + /// Active hard pair-joint rows are rejected until their public projection + /// policy explicitly configures finite stiffness. Vbd, }; diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json index 38ff315b226ea..819eb8fff867f 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-breakable-wall-packet.json @@ -2,8 +2,8 @@ "benchmark": { "benchmark": "BM_AvbdPaperBreakableWallStep", "context": { - "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", + "benchmark_source_sha256": "cfc992187ae0a4beeae3111bdeba6a6b76c8f08884e4416d139e161fa4944834", + "capture_source_provenance_digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0", "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", "host_name": "mark26", "json_schema_version": 1, @@ -12,18 +12,18 @@ "mhz_per_cpu": 5300, "num_cpus": 32 }, - "json_sha256": "abab909f3693333c0bb43bdd54b15df9fd6f0c3e9d4b7dd838208fb76d12565a", + "json_sha256": "1a69ca3c576e4b71f3cc3f2abe4a720ebf1dc15cb0b7fbda11f77d5e5ae1d0c0", "rows": [ { "aggregate_name": "mean", "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9523638.290000001, + "cpu_time": 8178949.550000002, "impacting_balls": 3.0, "iterations": 5, "public_avbd_family": 1.0, - "real_time": 9526517.41836841, + "real_time": 8183844.183319403, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -45,11 +45,11 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9515543.591666665, + "cpu_time": 8214461.283333337, "impacting_balls": 3.0, "iterations": 5, "public_avbd_family": 1.0, - "real_time": 9516189.9337351, + "real_time": 8218947.825662327, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -69,9 +69,9 @@ { "aggregate_name": "stddev", "aggregate_unit": "time", - "cpu_time": 46344.68280297959, + "cpu_time": 94142.05129219542, "iterations": 5, - "real_time": 49349.39234928154, + "real_time": 94021.64259774199, "repetitions": 5, "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", @@ -80,9 +80,9 @@ { "aggregate_name": "cv", "aggregate_unit": "percentage", - "cpu_time": 0.004866279187822828, + "cpu_time": 0.011510286341379301, "iterations": 5, - "real_time": 0.005180213312173162, + "real_time": 0.011488689238411964, "repetitions": 5, "run_name": "BM_AvbdPaperBreakableWallStep/iterations:120", "run_type": "aggregate", @@ -91,19 +91,19 @@ ], "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "source_provenance": { - "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d" + "benchmark_source_sha256": "cfc992187ae0a4beeae3111bdeba6a6b76c8f08884e4416d139e161fa4944834", + "capture_source_provenance_digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0" }, "stability": { - "cpu_time_cv_fraction": 0.004866279187822828, - "real_time_cv_fraction": 0.005180213312173162, + "cpu_time_cv_fraction": 0.011510286341379301, + "real_time_cv_fraction": 0.011488689238411964, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 9523638.290000001, - "mean_real_time_per_step_ns": 9526517.41836841, - "median_cpu_time_per_step_ns": 9515543.591666665, - "median_real_time_per_step_ns": 9516189.9337351 + "mean_cpu_time_per_step_ns": 8178949.550000002, + "mean_real_time_per_step_ns": 8183844.183319403, + "median_cpu_time_per_step_ns": 8214461.283333337, + "median_real_time_per_step_ns": 8218947.825662327 } }, "claim_boundary": "This packet covers the AVBD row of the publication-shaped Figure 13 breakable-wall outcome on the public CPU solver family. It does not claim exact source-scene replay, reproduce the Sequential Impulse, XPBD, and VBD comparison rows, or establish public CUDA AVBD parity.", @@ -201,7 +201,7 @@ "schema_version": 4, "source_provenance": { "algorithm": "sha256-length-prefixed-path-and-content-v1", - "digest": "f4e41fb154bc6d51eb16de658167e8307436daaf511e1c86ae4ce4004494c640", + "digest": "e8715822cf80680774dfb8b6fc8a3d78ecb0f424bfe55424fb709d8f30fddaa2", "files": [ { "path": "dart/gui/view_quality.cpp", @@ -213,15 +213,15 @@ }, { "path": "dart/simulation/world.cpp", - "sha256": "57bf31a3abcfb089b9b82d206bc5dbeb673b55ab0ca97077d2212b694ea8c6af" + "sha256": "ecde99482d1e343195e1a18b37fc46f1dd9471f308f546bf9634eff05a7164eb" }, { "path": "dart/simulation/world.hpp", - "sha256": "cbe18bc663ece8120078acc5f74dc0e627dd227cdb58ee83647386a3715f4445" + "sha256": "d3936db424028b9a6016ab8523a8ffdb749a502d1dd47dca57e26a7fc7c4363d" }, { "path": "dart/simulation/world_options.hpp", - "sha256": "bcc8e950d73534a2d5dc953431939b2fa776235d188fb685bf65589b4d556b34" + "sha256": "ed421bdb599188c64a7ef03466a92cecef218a5cee2b2f32796239070fe8c53b" }, { "path": "dart/simulation/comps/joint.hpp", @@ -245,7 +245,7 @@ }, { "path": "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp", - "sha256": "79b20d53e40bddce50ee3158f76fdd902aea5ea3d165dd75d4862b6e537b7c22" + "sha256": "a297c3db3999613364107c7e2528c917c97dbf59a3f2f435d9261cbaadd977c1" }, { "path": "dart/simulation/detail/world_step_schedule.hpp", @@ -257,7 +257,7 @@ }, { "path": "python/examples/demos/scenes/avbd_paper_breakable_wall.py", - "sha256": "40675bcd66d2ae543dc1bd60a435926dd6969893eb0a92aa451fe66f9b8bda12" + "sha256": "4794bd7e3a2b549996c8774edaa3de37b10fef9b775083f8ab474e2b1b240a09" }, { "path": "python/tests/integration/test_demos_cycle.py", @@ -265,11 +265,11 @@ }, { "path": "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", - "sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370" + "sha256": "cfc992187ae0a4beeae3111bdeba6a6b76c8f08884e4416d139e161fa4944834" }, { "path": "tests/unit/simulation/world/test_world.cpp", - "sha256": "a6bbdadee4253abbac4fde9a4b9801e7246415044deff07875063a7c6153e194" + "sha256": "db7c701ddf83cad31e50ebe62758c1da49996cdf1e4bd9fd4df13886710ae269" }, { "path": "scripts/avbd_packet_schema.py", @@ -320,11 +320,11 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 1305, - "dark_pixels": 12088, - "mid_pixels": 208731, - "p05": 53.87, - "p95": 149.723, + "bright_pixels": 1376, + "dark_pixels": 11957, + "mid_pixels": 208965, + "p05": 54.312, + "p95": 149.737, "pass": true, "reasons": [], "region": { @@ -334,7 +334,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 95.853, + "spread": 95.425, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -362,12 +362,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "088b5d36b8743dda709b997afd71aed1009adafd897e6e332ca5bd10d6956711" + "sha256": "74159d7594b8704381cee9912ed7abcda094620c9fd405726833c4e8dd303cf3" }, "label": "impact", "manifest": { "file": "manifest.json", - "sha256": "3989652b27ada91b262b2529771fd5e34f41b7c9c062e7b3abb23047056e348b" + "sha256": "9c574230629f4a521e697fd98c8bcf89639ff1b599c77233196bf2ab9acaf530" }, "scene_metrics": { "event_count": 60, @@ -4677,13 +4677,13 @@ }, "screenshot": { "file": "avbd_paper_breakable_wall_impact.png", - "sha256": "b44fc5d55df54955f53f67330689b8e9356201c417ce932793366368c15e2a3f" + "sha256": "9d1fa2bf21bc6cd0807e55ee4c9b866a0e2e44a77e02ccd18f50f28d03309724" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", + "digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0", "file_count": 1556, - "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", + "git_head": "58bf1c3e85f702ea21a5417f2f6ae352c12dcb0a", "roots": [ "CMakeLists.txt", "cmake", @@ -4720,10 +4720,10 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 1918, - "dark_pixels": 11899, - "mid_pixels": 208076, - "p05": 54.665, + "bright_pixels": 1903, + "dark_pixels": 11907, + "mid_pixels": 208063, + "p05": 54.657, "p95": 155.511, "pass": true, "reasons": [], @@ -4734,7 +4734,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 100.845, + "spread": 100.853, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -4762,12 +4762,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "8769f651d1a5c0eb64d361fd03eabf7558de3c866ac21b783101d5525a61c39a" + "sha256": "d9b2e0d9a08a58f11a7516f48cf2ce3c6782d22c35fb9607a510773e26d2b2ff" }, "label": "outcome", "manifest": { "file": "manifest.json", - "sha256": "5f314c665a413bb8cbc65efd2c543a9c0c2e78d859fbdcc76526c5c09bf5bc74" + "sha256": "42d77abb52b479cff04fb7679696eccb312a132806d8f2488eb293823d672b71" }, "scene_metrics": { "event_count": 120, @@ -9077,13 +9077,13 @@ }, "screenshot": { "file": "avbd_paper_breakable_wall_outcome.png", - "sha256": "a0777940e033e4684ec7f2f747a1b5b2a2694f849aa88d04a08e82dbb7d0c087" + "sha256": "5199cc27d8cb7176476de756234f962c32ee78205eb0df53915d875e1d272a89" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", + "digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0", "file_count": 1556, - "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", + "git_head": "58bf1c3e85f702ea21a5417f2f6ae352c12dcb0a", "roots": [ "CMakeLists.txt", "cmake", @@ -9106,12 +9106,12 @@ { "file": "avbd_paper_breakable_wall_impact.png", "role": "impact_frame_60", - "sha256": "b44fc5d55df54955f53f67330689b8e9356201c417ce932793366368c15e2a3f" + "sha256": "9d1fa2bf21bc6cd0807e55ee4c9b866a0e2e44a77e02ccd18f50f28d03309724" }, { "file": "avbd_paper_breakable_wall_outcome.png", "role": "outcome_frame_120", - "sha256": "a0777940e033e4684ec7f2f747a1b5b2a2694f849aa88d04a08e82dbb7d0c087" + "sha256": "5199cc27d8cb7176476de756234f962c32ee78205eb0df53915d875e1d272a89" }, { "file": "paper-10.png", @@ -9122,7 +9122,7 @@ "not_proven_and_limitations": "The checked identity evidence records 154 broken joints, including 79 whose initial anchors lie outside the selected 1.15 m impact regions, so this reconstruction does not claim that broken topology itself is spatially localized. The visible and numeric claim is narrower: the wall fractures, displacement damage reaches all three impact bands, and all 558 surviving joint rows remain tightly satisfied. This row does not prove exact unpublished source constants, XPBD, CUDA parity, or published/source timing parity.", "reconciliation_and_verdict": "PASS for the scoped AVBD observation: both engine ViewReports pass with all 252 bricks in focus. Frames 60 and 120 reproduce the accepted text-oracle records exactly, including the same checked 154-member broken-joint identity digest and 558 surviving rows. The maximum retained-row residuals are respectively 0.000406 m and 0.000178 rad, then 0.000772 m and 0.000317 rad. The current-source images show a broad fractured opening around the impacts while the left, right, and upper wall regions remain upright without global collapse. The broad broken-topology distribution is disclosed rather than described as localized.", "reviewer_capability": "Codex native image input with original-resolution inspection of the six current-source captures and pinned paper figure", - "sha256": "64a9f59dff9a0fe6045d33b7f0f4d11a6e058cc798eb7d66e907184f94c754cb", + "sha256": "b73b4b5db17d287aa258272ad12f2d2f4eed5ddb124736c5eadd9bb5ef0409af", "text_oracle": "Figure 13 states that AVBD forms plausible fractures and maintains the attachment constraints retained after the initial impacts; this reconstruction verifies every retained-row residual and separately records every broken identity.", "verdict": "pass", "visible_observation": "At frame 60 a broad central opening and loose bricks are visible around the impacts while substantial left, right, and upper wall regions remain upright. At frame 120 the same overall structure is still globally standing with no broad folding or collapse." diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json index 053739008f329..58b129ceacfaa 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-sequential-impulse-comparison-packet.json @@ -2,12 +2,12 @@ "benchmark": { "comparison": { "basis": "same benchmark JSON, executable, host, reconstructed scene fingerprint, trajectory length, time step, and 20-sweep budget", - "sequential_impulse_to_avbd_median_cpu_cost_ratio": 1.6447876553305811, - "sequential_impulse_to_vbd_median_cpu_cost_ratio": 1.6842558689474314 + "sequential_impulse_to_avbd_median_cpu_cost_ratio": 1.656038285910965, + "sequential_impulse_to_vbd_median_cpu_cost_ratio": 1.7178659882766967 }, "context": { - "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", + "benchmark_source_sha256": "cfc992187ae0a4beeae3111bdeba6a6b76c8f08884e4416d139e161fa4944834", + "capture_source_provenance_digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0", "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", "host_name": "mark26", "json_schema_version": 1, @@ -16,7 +16,7 @@ "mhz_per_cpu": 5300, "num_cpus": 32 }, - "json_sha256": "abab909f3693333c0bb43bdd54b15df9fd6f0c3e9d4b7dd838208fb76d12565a", + "json_sha256": "1a69ca3c576e4b71f3cc3f2abe4a720ebf1dc15cb0b7fbda11f77d5e5ae1d0c0", "method": { "benchmark": "BM_SequentialImpulsePaperBreakableWallStep", "rows": [ @@ -25,14 +25,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 15624216.650000006, + "cpu_time": 13694594.809999999, "family_index": 2, "impacting_balls": 3.0, "iterations": 5, "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_mean", "per_family_instance_index": 0, "public_sequential-impulse_family": 1.0, - "real_time": 15627557.041686183, + "real_time": 13696717.531711329, "repetitions": 5, "resolved_rigid_body_sequential-impulse": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -55,14 +55,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 15651048.633333351, + "cpu_time": 13603462.383333325, "family_index": 2, "impacting_balls": 3.0, "iterations": 5, "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_median", "per_family_instance_index": 0, "public_sequential-impulse_family": 1.0, - "real_time": 15652796.116773969, + "real_time": 13604835.59158941, "repetitions": 5, "resolved_rigid_body_sequential-impulse": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -85,14 +85,14 @@ "aggregate_unit": "time", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 52565.232515432945, + "cpu_time": 201553.6772774021, "family_index": 2, "impacting_balls": 0.0, "iterations": 5, "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_stddev", "per_family_instance_index": 0, "public_sequential-impulse_family": 0.0, - "real_time": 51872.38501883386, + "real_time": 202687.9358083481, "repetitions": 5, "resolved_rigid_body_sequential-impulse": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -115,14 +115,14 @@ "aggregate_unit": "percentage", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 0.003364343550332997, + "cpu_time": 0.014717753980586896, "family_index": 2, "impacting_balls": 0.0, "iterations": 5, "name": "BM_SequentialImpulsePaperBreakableWallStep/iterations:120_cv", "per_family_instance_index": 0, "public_sequential-impulse_family": 0.0, - "real_time": 0.0033192894372719517, + "real_time": 0.014798285453363171, "repetitions": 5, "resolved_rigid_body_sequential-impulse": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -142,21 +142,21 @@ } ], "stability": { - "cpu_time_cv_fraction": 0.003364343550332997, - "real_time_cv_fraction": 0.0033192894372719517, + "cpu_time_cv_fraction": 0.014717753980586896, + "real_time_cv_fraction": 0.014798285453363171, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 15624216.650000006, - "mean_real_time_per_step_ns": 15627557.041686183, - "median_cpu_time_per_step_ns": 15651048.633333351, - "median_real_time_per_step_ns": 15652796.116773969 + "mean_cpu_time_per_step_ns": 13694594.809999999, + "mean_real_time_per_step_ns": 13696717.531711329, + "median_cpu_time_per_step_ns": 13603462.383333325, + "median_real_time_per_step_ns": 13604835.59158941 } }, "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "source_provenance": { - "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d" + "benchmark_source_sha256": "cfc992187ae0a4beeae3111bdeba6a6b76c8f08884e4416d139e161fa4944834", + "capture_source_provenance_digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0" } }, "claim_boundary": "This packet covers the matched Sequential Impulse Figure 13 row and links the same-host AVBD/VBD comparison packet. Together they establish three of the four published comparison rows. It does not reproduce XPBD, claim exact unpublished source-scene constants, establish CUDA parity, or compare against a published/source timing.", @@ -181,10 +181,10 @@ "linked_avbd_vbd_evidence": { "benchmark_method_timings": { "avbd": { - "median_cpu_time_per_step_ns": 9515543.591666665 + "median_cpu_time_per_step_ns": 8214461.283333337 }, "vbd": { - "median_cpu_time_per_step_ns": 9292559.950000001 + "median_cpu_time_per_step_ns": 7918814.666666662 } }, "file": "avbd-paper-vbd-comparison-packet.json", @@ -195,8 +195,8 @@ "rigid_contact_solver": "avbd", "rigid_point_joint_solver": "avbd" }, - "nested_avbd_sha256": "d7fe44e4708a0b24646ac01610d166e0167642bd1ee1c58f8e05591641b58f26", - "nested_avbd_source_provenance_digest": "f4e41fb154bc6d51eb16de658167e8307436daaf511e1c86ae4ce4004494c640", + "nested_avbd_sha256": "e8fec99ab6faee09e42860d33395f640d1988f88d743db24d1a585afb395b19d", + "nested_avbd_source_provenance_digest": "e8715822cf80680774dfb8b6fc8a3d78ecb0f424bfe55424fb709d8f30fddaa2", "resolved_solver_identity": { "avbd_rigid_contact_config_emplaced": false, "recorded_from": "engine World.resolved_configuration in both VBD captures plus benchmark runtime resolved-configuration counters", @@ -205,19 +205,19 @@ "rigid_point_joint_solver": "vbd" }, "scene_spec_fingerprint": "8ca3fbfa00c3dce9", - "sha256": "0302c036e390c6db2696d8a5e5ee3074eff05aacbce6d003c468aaef2e4560a3", - "source_provenance_digest": "73c70bd23e4ad8b419c32901b02a6e5928c4b36510e682c3413baaa5367b0b88", + "sha256": "d1bcabc809f74b282d4b031a4810150f500a84205549f482594e953341604c06", + "source_provenance_digest": "56ef2fc26eaa75e9850b66d2948a31a257fc681b02bec271c7fcb2f46419e54f", "visual_evidence": { "bend_screenshot": { "file": "vbd_paper_breakable_wall_bend.png", - "sha256": "dc05890e8f09f42dfc9dd5a1c06662a15bac88bded3197eeda8c333fc40eca06" + "sha256": "6da21444b1d674db5034e439806ee0e13fd59086896159965135845e38778d99" }, "retention_screenshot": { "file": "vbd_paper_breakable_wall_retention.png", - "sha256": "69a2f2390ff4786f68f60866356291c67968aaf975e00853440c5794fdc6d590" + "sha256": "47c3cb1efecb530da0cc842797d3cbf97805a27a43a6c20157cd111d9649c83b" }, "semantic_review": { - "sha256": "e651c5c2373880977d3e35b871213f4f29ad2a4e20972cbe4121519f05fa8dbe", + "sha256": "5aab06eedbf99546ee380cdc7ca2695e26003afaeb0cbcdc702069a9fb579129", "verdict": "pass" } } @@ -275,19 +275,19 @@ "schema_version": 4, "source_provenance": { "algorithm": "sha256-length-prefixed-path-and-content-v1", - "digest": "5386c5304d3b900392596134fe421a7b33f3aca328044ea7f15b0d0491255c5e", + "digest": "c92ddd193ba0677705c5311f60be3e49c46a8a1593ef56c7861e4d801da2dce0", "files": [ { "path": "dart/simulation/world.cpp", - "sha256": "57bf31a3abcfb089b9b82d206bc5dbeb673b55ab0ca97077d2212b694ea8c6af" + "sha256": "ecde99482d1e343195e1a18b37fc46f1dd9471f308f546bf9634eff05a7164eb" }, { "path": "dart/simulation/world.hpp", - "sha256": "cbe18bc663ece8120078acc5f74dc0e627dd227cdb58ee83647386a3715f4445" + "sha256": "d3936db424028b9a6016ab8523a8ffdb749a502d1dd47dca57e26a7fc7c4363d" }, { "path": "dart/simulation/world_options.hpp", - "sha256": "bcc8e950d73534a2d5dc953431939b2fa776235d188fb685bf65589b4d556b34" + "sha256": "ed421bdb599188c64a7ef03466a92cecef218a5cee2b2f32796239070fe8c53b" }, { "path": "dart/simulation/comps/joint.hpp", @@ -311,7 +311,7 @@ }, { "path": "python/examples/demos/scenes/avbd_paper_breakable_wall.py", - "sha256": "40675bcd66d2ae543dc1bd60a435926dd6969893eb0a92aa451fe66f9b8bda12" + "sha256": "4794bd7e3a2b549996c8774edaa3de37b10fef9b775083f8ab474e2b1b240a09" }, { "path": "python/examples/demos/scenes/vbd_paper_breakable_wall.py", @@ -327,7 +327,7 @@ }, { "path": "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", - "sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370" + "sha256": "cfc992187ae0a4beeae3111bdeba6a6b76c8f08884e4416d139e161fa4944834" }, { "path": "tests/unit/simulation/contact/test_boxed_lcp_contact.cpp", @@ -335,11 +335,11 @@ }, { "path": "tests/unit/simulation/world/test_world.cpp", - "sha256": "a6bbdadee4253abbac4fde9a4b9801e7246415044deff07875063a7c6153e194" + "sha256": "db7c701ddf83cad31e50ebe62758c1da49996cdf1e4bd9fd4df13886710ae269" }, { "path": "tests/unit/simulation/world/test_world_resolved_configuration.cpp", - "sha256": "09f3935b94bb3489aa32edb5f8e9ed71b72c165a5c58e7e2a5e7a5e0d156958c" + "sha256": "2b1dc4bc4a9468ac710edf8c100036e537844ea6587c47f55ec412a8c84f393f" }, { "path": "tests/test_avbd_packet_schema.py", @@ -400,14 +400,14 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 2135, - "dark_pixels": 1826, - "mid_pixels": 222077, - "p05": 89.096, - "p95": 156.431, + "bright_pixels": 2182, + "dark_pixels": 1637, + "mid_pixels": 222292, + "p05": 93.409, + "p95": 156.511, "pass": false, "reasons": [ - "luminance spread 67.3 < 90.0" + "luminance spread 63.1 < 90.0" ], "region": { "x0": 512, @@ -416,7 +416,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 67.336, + "spread": 63.102, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -444,12 +444,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "9b56fc856e56bed7dd76394602f4835d050808aeef9c9b0c3b6e5ff66139d6e9" + "sha256": "adc3a2075d2bad84ba1055096120cdd878d4201bf8eb78c14c45e683794d4a1c" }, "label": "collapse", "manifest": { "file": "manifest.json", - "sha256": "77910887ddf3e9d5cdaffdb455a17478d8fd23429752faa2630d02b809c269c3" + "sha256": "2a91e2f39f21fc533d02214eaeb2920c1c888e727c97c42a4c1e5c889ae5671e" }, "scene_metrics": { "event_count": 120, @@ -1046,13 +1046,13 @@ }, "screenshot": { "file": "sequential_impulse_paper_breakable_wall_collapse.png", - "sha256": "83ca961c52b606a5a90c2232e3f3f4d60d970783cb22c1b0a40dcc97d22fce29" + "sha256": "dc7d20a599b8af9d856b99ade0e2926b9c73b311403adbe1e5b43222713857ba" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", + "digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0", "file_count": 1556, - "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", + "git_head": "58bf1c3e85f702ea21a5417f2f6ae352c12dcb0a", "roots": [ "CMakeLists.txt", "cmake", @@ -1089,10 +1089,10 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 1354, - "dark_pixels": 11712, - "mid_pixels": 209742, - "p05": 55.58, + "bright_pixels": 1361, + "dark_pixels": 11689, + "mid_pixels": 209739, + "p05": 55.657, "p95": 151.795, "pass": true, "reasons": [], @@ -1103,7 +1103,7 @@ "y1": 648 }, "sample_count": 236196, - "spread": 96.216, + "spread": 96.138, "thresholds": { "bright_luminance_gt": 190.0, "dark_luminance_lt": 55.0, @@ -1131,12 +1131,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "a5cf65cdf6fe31a3ab02c8bcdce69513bc1c48b9512f0d9339fb590741b4c9f5" + "sha256": "0568230c63f3c953e15edfe0dbf776b302e57bfd5205ae3e2a999cf8fd8ee27e" }, "label": "fracture", "manifest": { "file": "manifest.json", - "sha256": "43e160adb2a530eeb1e7102eefe682e1122bcdd21d1c3860b57972d2593f6605" + "sha256": "bef1b3dcfe1baf263500af52786e4e395c39e0cb9a81dd2a3fa743bfbe0a92e9" }, "scene_metrics": { "event_count": 14, @@ -1733,13 +1733,13 @@ }, "screenshot": { "file": "sequential_impulse_paper_breakable_wall_fracture.png", - "sha256": "678af175bd8cbbea575dd152884996dd36aacec63bf0b66d38a81ebd7d92fc43" + "sha256": "915c9679660a3cbe44132d2c81ae1d30708a9da476db84ab13b39707c9ec8744" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", + "digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0", "file_count": 1556, - "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", + "git_head": "58bf1c3e85f702ea21a5417f2f6ae352c12dcb0a", "roots": [ "CMakeLists.txt", "cmake", @@ -1762,12 +1762,12 @@ { "file": "sequential_impulse_paper_breakable_wall_fracture.png", "role": "fracture_frame_14", - "sha256": "678af175bd8cbbea575dd152884996dd36aacec63bf0b66d38a81ebd7d92fc43" + "sha256": "915c9679660a3cbe44132d2c81ae1d30708a9da476db84ab13b39707c9ec8744" }, { "file": "sequential_impulse_paper_breakable_wall_collapse.png", "role": "collapse_frame_120", - "sha256": "83ca961c52b606a5a90c2232e3f3f4d60d970783cb22c1b0a40dcc97d22fce29" + "sha256": "dc7d20a599b8af9d856b99ade0e2926b9c73b311403adbe1e5b43222713857ba" }, { "file": "paper-10.png", @@ -1778,7 +1778,7 @@ "not_proven_and_limitations": "The images cannot reveal joint identity or residual magnitude, so those claims rely on the checked text oracle and digest. This three-method packet does not reproduce XPBD, establish CUDA parity, prove exact unpublished source constants, or establish parity with published/source timing.", "reconciliation_and_verdict": "PASS: both engine ViewReports pass with all 252 bricks in focus, and the current-source text records exactly match the accepted oracle. At frame 14 exactly five broken identities cover the three impact regions in counts 2, 1, and 2, with none outside; all 484 outside-impact retained rows remain present. At frame 120 the same identity digest remains with no additional joint breaks, but those outside-impact rows reach 0.063460 m maximum and 0.020763 m RMS linear residual plus 0.727048 rad maximum and 0.216362 rad RMS angular residual. The images progress from a standing wall around the three impacts to a slab collapsed across the ground.", "reviewer_capability": "Codex native image input with original-resolution inspection of the six current-source captures and pinned paper figure", - "sha256": "7ecf97930c4cf6798c363a19d440cbc45717017013b1142d8c1d475f99b8f198", + "sha256": "915ebb92f6ef3c4ea702ce1971e9f34820bff5d918470ac68515b31b98e69c22", "text_oracle": "Figure 13 states that Sequential Impulse fractures first but cannot maintain retained constraints, causing slow bending and final collapse; the DART row directly measures retained rows outside the impacts.", "verdict": "pass", "visible_observation": "At frame 14 the wall is still globally upright and continuous around the three balls. At frame 120 almost the entire wall lies flat across the ground in a coherent collapsed slab while the same five joints, and no additional joints, are recorded as broken." diff --git a/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json b/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json index d3c34fb40cf5e..9f7d56509b839 100644 --- a/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json +++ b/docs/plans/104-vertex-block-descent-solver/avbd-paper-vbd-comparison-packet.json @@ -2,11 +2,11 @@ "benchmark": { "comparison": { "basis": "same executable, host, reconstructed scene fingerprint, trajectory length, time step, and 20-sweep budget", - "vbd_to_avbd_median_cpu_cost_ratio": 0.9765663790493331 + "vbd_to_avbd_median_cpu_cost_ratio": 0.9640090072289312 }, "context": { - "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", + "benchmark_source_sha256": "cfc992187ae0a4beeae3111bdeba6a6b76c8f08884e4416d139e161fa4944834", + "capture_source_provenance_digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0", "executable": "build/default/cpp/Release/bin/bm_avbd_rigid_fixed_joint", "host_name": "mark26", "json_schema_version": 1, @@ -15,7 +15,7 @@ "mhz_per_cpu": 5300, "num_cpus": 32 }, - "json_sha256": "abab909f3693333c0bb43bdd54b15df9fd6f0c3e9d4b7dd838208fb76d12565a", + "json_sha256": "1a69ca3c576e4b71f3cc3f2abe4a720ebf1dc15cb0b7fbda11f77d5e5ae1d0c0", "methods": { "avbd": { "benchmark": "BM_AvbdPaperBreakableWallStep", @@ -25,14 +25,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9523638.290000001, + "cpu_time": 8178949.550000002, "family_index": 0, "impacting_balls": 3.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_mean", "per_family_instance_index": 0, "public_avbd_family": 1.0, - "real_time": 9526517.41836841, + "real_time": 8183844.183319403, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -55,14 +55,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9515543.591666665, + "cpu_time": 8214461.283333337, "family_index": 0, "impacting_balls": 3.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_median", "per_family_instance_index": 0, "public_avbd_family": 1.0, - "real_time": 9516189.9337351, + "real_time": 8218947.825662327, "repetitions": 5, "resolved_rigid_body_avbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -85,14 +85,14 @@ "aggregate_unit": "time", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 46344.68280297959, + "cpu_time": 94142.05129219542, "family_index": 0, "impacting_balls": 0.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_stddev", "per_family_instance_index": 0, "public_avbd_family": 0.0, - "real_time": 49349.39234928154, + "real_time": 94021.64259774199, "repetitions": 5, "resolved_rigid_body_avbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -115,14 +115,14 @@ "aggregate_unit": "percentage", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 0.004866279187822828, + "cpu_time": 0.011510286341379301, "family_index": 0, "impacting_balls": 0.0, "iterations": 5, "name": "BM_AvbdPaperBreakableWallStep/iterations:120_cv", "per_family_instance_index": 0, "public_avbd_family": 0.0, - "real_time": 0.005180213312173162, + "real_time": 0.011488689238411964, "repetitions": 5, "resolved_rigid_body_avbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -142,15 +142,15 @@ } ], "stability": { - "cpu_time_cv_fraction": 0.004866279187822828, - "real_time_cv_fraction": 0.005180213312173162, + "cpu_time_cv_fraction": 0.011510286341379301, + "real_time_cv_fraction": 0.011488689238411964, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 9523638.290000001, - "mean_real_time_per_step_ns": 9526517.41836841, - "median_cpu_time_per_step_ns": 9515543.591666665, - "median_real_time_per_step_ns": 9516189.9337351 + "mean_cpu_time_per_step_ns": 8178949.550000002, + "mean_real_time_per_step_ns": 8183844.183319403, + "median_cpu_time_per_step_ns": 8214461.283333337, + "median_real_time_per_step_ns": 8218947.825662327 } }, "vbd": { @@ -161,14 +161,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9292559.540000003, + "cpu_time": 7918487.791666667, "family_index": 1, "impacting_balls": 3.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_mean", "per_family_instance_index": 0, "public_vbd_family": 1.0, - "real_time": 9293226.2699469, + "real_time": 7919422.471701789, "repetitions": 5, "resolved_rigid_body_vbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -191,14 +191,14 @@ "aggregate_unit": "time", "breakable_joints": 712.0, "collision_shapes": 256.0, - "cpu_time": 9292559.950000001, + "cpu_time": 7918814.666666662, "family_index": 1, "impacting_balls": 3.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_median", "per_family_instance_index": 0, "public_vbd_family": 1.0, - "real_time": 9293373.77504756, + "real_time": 7919288.741444083, "repetitions": 5, "resolved_rigid_body_vbd": 1.0, "resolved_rigid_constraint_iterations": 1.0, @@ -221,14 +221,14 @@ "aggregate_unit": "time", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 16911.492278430254, + "cpu_time": 23530.653186306117, "family_index": 1, "impacting_balls": 0.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_stddev", "per_family_instance_index": 0, "public_vbd_family": 0.0, - "real_time": 16977.790123070678, + "real_time": 22825.05641250831, "repetitions": 5, "resolved_rigid_body_vbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -251,14 +251,14 @@ "aggregate_unit": "percentage", "breakable_joints": 0.0, "collision_shapes": 0.0, - "cpu_time": 0.001819896036784527, + "cpu_time": 0.0029716094544048584, "family_index": 1, "impacting_balls": 0.0, "iterations": 5, "name": "BM_VbdPaperBreakableWallStep/iterations:120_cv", "per_family_instance_index": 0, "public_vbd_family": 0.0, - "real_time": 0.0018268994674082856, + "real_time": 0.002882161735160403, "repetitions": 5, "resolved_rigid_body_vbd": 0.0, "resolved_rigid_constraint_iterations": 0.0, @@ -278,22 +278,22 @@ } ], "stability": { - "cpu_time_cv_fraction": 0.001819896036784527, - "real_time_cv_fraction": 0.0018268994674082856, + "cpu_time_cv_fraction": 0.0029716094544048584, + "real_time_cv_fraction": 0.002882161735160403, "repetitions": 5 }, "timing": { - "mean_cpu_time_per_step_ns": 9292559.540000003, - "mean_real_time_per_step_ns": 9293226.2699469, - "median_cpu_time_per_step_ns": 9292559.950000001, - "median_real_time_per_step_ns": 9293373.77504756 + "mean_cpu_time_per_step_ns": 7918487.791666667, + "mean_real_time_per_step_ns": 7919422.471701789, + "median_cpu_time_per_step_ns": 7918814.666666662, + "median_real_time_per_step_ns": 7919288.741444083 } } }, "scene_spec_fingerprint": "8ca3fbfa00c3dce9", "source_provenance": { - "benchmark_source_sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370", - "capture_source_provenance_digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d" + "benchmark_source_sha256": "cfc992187ae0a4beeae3111bdeba6a6b76c8f08884e4416d139e161fa4944834", + "capture_source_provenance_digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0" } }, "claim_boundary": "This packet covers the matched fixed-penalty VBD row and its same-host CPU comparison with the linked public AVBD Figure 13 packet. It does not reproduce the Sequential Impulse or XPBD rows, claim exact unpublished source constants, establish CUDA parity, or compare against a published/source timing.", @@ -325,19 +325,19 @@ "rigid_point_joint_solver": "avbd" }, "scene_spec_fingerprint": "8ca3fbfa00c3dce9", - "sha256": "d7fe44e4708a0b24646ac01610d166e0167642bd1ee1c58f8e05591641b58f26", + "sha256": "e8fec99ab6faee09e42860d33395f640d1988f88d743db24d1a585afb395b19d", "visual_evidence": { "impact_screenshot": { "file": "avbd_paper_breakable_wall_impact.png", - "sha256": "b44fc5d55df54955f53f67330689b8e9356201c417ce932793366368c15e2a3f" + "sha256": "9d1fa2bf21bc6cd0807e55ee4c9b866a0e2e44a77e02ccd18f50f28d03309724" }, "outcome_screenshot": { "file": "avbd_paper_breakable_wall_outcome.png", - "sha256": "a0777940e033e4684ec7f2f747a1b5b2a2694f849aa88d04a08e82dbb7d0c087" + "sha256": "5199cc27d8cb7176476de756234f962c32ee78205eb0df53915d875e1d272a89" }, "semantic_review": { "file": "avbd-visual-review.json", - "sha256": "64a9f59dff9a0fe6045d33b7f0f4d11a6e058cc798eb7d66e907184f94c754cb", + "sha256": "b73b4b5db17d287aa258272ad12f2d2f4eed5ddb124736c5eadd9bb5ef0409af", "verdict": "pass" } } @@ -395,19 +395,19 @@ "schema_version": 4, "source_provenance": { "algorithm": "sha256-length-prefixed-path-and-content-v1", - "digest": "73c70bd23e4ad8b419c32901b02a6e5928c4b36510e682c3413baaa5367b0b88", + "digest": "56ef2fc26eaa75e9850b66d2948a31a257fc681b02bec271c7fcb2f46419e54f", "files": [ { "path": "dart/simulation/world.cpp", - "sha256": "57bf31a3abcfb089b9b82d206bc5dbeb673b55ab0ca97077d2212b694ea8c6af" + "sha256": "ecde99482d1e343195e1a18b37fc46f1dd9471f308f546bf9634eff05a7164eb" }, { "path": "dart/simulation/world.hpp", - "sha256": "cbe18bc663ece8120078acc5f74dc0e627dd227cdb58ee83647386a3715f4445" + "sha256": "d3936db424028b9a6016ab8523a8ffdb749a502d1dd47dca57e26a7fc7c4363d" }, { "path": "dart/simulation/world_options.hpp", - "sha256": "bcc8e950d73534a2d5dc953431939b2fa776235d188fb685bf65589b4d556b34" + "sha256": "ed421bdb599188c64a7ef03466a92cecef218a5cee2b2f32796239070fe8c53b" }, { "path": "dart/simulation/comps/joint.hpp", @@ -431,7 +431,7 @@ }, { "path": "dart/simulation/detail/rigid_avbd/rigid_world_contact.hpp", - "sha256": "79b20d53e40bddce50ee3158f76fdd902aea5ea3d165dd75d4862b6e537b7c22" + "sha256": "a297c3db3999613364107c7e2528c917c97dbf59a3f2f435d9261cbaadd977c1" }, { "path": "dart/simulation/detail/world_step_schedule.hpp", @@ -439,7 +439,7 @@ }, { "path": "python/examples/demos/scenes/avbd_paper_breakable_wall.py", - "sha256": "40675bcd66d2ae543dc1bd60a435926dd6969893eb0a92aa451fe66f9b8bda12" + "sha256": "4794bd7e3a2b549996c8774edaa3de37b10fef9b775083f8ab474e2b1b240a09" }, { "path": "python/examples/demos/scenes/vbd_paper_breakable_wall.py", @@ -451,11 +451,11 @@ }, { "path": "tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp", - "sha256": "f7a0d76a19f0966f630ccb5223a6b5421c4c4b54dd1b0591ff65510eaa10b370" + "sha256": "cfc992187ae0a4beeae3111bdeba6a6b76c8f08884e4416d139e161fa4944834" }, { "path": "tests/unit/simulation/world/test_world.cpp", - "sha256": "a6bbdadee4253abbac4fde9a4b9801e7246415044deff07875063a7c6153e194" + "sha256": "db7c701ddf83cad31e50ebe62758c1da49996cdf1e4bd9fd4df13886710ae269" }, { "path": "scripts/avbd_packet_schema.py", @@ -504,9 +504,9 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 1490, - "dark_pixels": 10920, - "mid_pixels": 210205, + "bright_pixels": 1502, + "dark_pixels": 10930, + "mid_pixels": 210192, "p05": 57.583, "p95": 151.795, "pass": true, @@ -546,12 +546,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "3163fa7058523fa20d7b75a6e5cb74eb46607794b1a58242946e98dfa32124e4" + "sha256": "48d964f36f42ff56871a9da197b3d918ee655e5d6859f4e6768f7fb4d17164fe" }, "label": "bend", "manifest": { "file": "manifest.json", - "sha256": "c95b42248e7e04c1db95dfde7d01d73a9fddac79c55f3da3c2399933bb4162ed" + "sha256": "4e2be4902f6944342f8964a122c9a74860f48d3f464ab7c9f4e3cdc5d77c8726" }, "scene_metrics": { "event_count": 18, @@ -1010,13 +1010,13 @@ }, "screenshot": { "file": "vbd_paper_breakable_wall_bend.png", - "sha256": "dc05890e8f09f42dfc9dd5a1c06662a15bac88bded3197eeda8c333fc40eca06" + "sha256": "6da21444b1d674db5034e439806ee0e13fd59086896159965135845e38778d99" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", + "digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0", "file_count": 1556, - "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", + "git_head": "58bf1c3e85f702ea21a5417f2f6ae352c12dcb0a", "roots": [ "CMakeLists.txt", "cmake", @@ -1053,14 +1053,14 @@ "image_verdict": { "checks": { "contrast": { - "bright_pixels": 840, - "dark_pixels": 12612, - "mid_pixels": 208237, + "bright_pixels": 834, + "dark_pixels": 12615, + "mid_pixels": 208277, "p05": 53.585, "p95": 151.511, "pass": false, "reasons": [ - "bright pixels 840 < 1180" + "bright pixels 834 < 1180" ], "region": { "x0": 512, @@ -1097,12 +1097,12 @@ "view": "front-oblique" }, "pass": true, - "sha256": "00491d30910abab7b4e83a7425fbb7fa0089e684e569d70cf532fdea34cf1321" + "sha256": "40a235d67c35be828dd1481ef6f8c81a200c8a116d85fda382f0944b6fc22ef1" }, "label": "retention", "manifest": { "file": "manifest.json", - "sha256": "07c8ab2f161662da9a00d550fbd86d50e3f22aa44b3519fb4fdb82ad8c9be253" + "sha256": "26a46bc09a92781a08b058c7bdeec6018bb763e3cc41ca83e452312130d056d5" }, "scene_metrics": { "event_count": 120, @@ -1559,13 +1559,13 @@ }, "screenshot": { "file": "vbd_paper_breakable_wall_retention.png", - "sha256": "69a2f2390ff4786f68f60866356291c67968aaf975e00853440c5794fdc6d590" + "sha256": "47c3cb1efecb530da0cc842797d3cbf97805a27a43a6c20157cd111d9649c83b" }, "source_provenance": { "algorithm": "sha256-length-prefixed-capture-source-tree-v1", - "digest": "10400178732ca18b4c0717c7b531766e20f31f9c5de01efc35144332f9580b8d", + "digest": "7a96ffd3b73b9649961ed858f7d9967044b6e2fe47477227e04e2840c5f0b4d0", "file_count": 1556, - "git_head": "52173fba3aa415cdf830858693195a5fbc6397b8", + "git_head": "58bf1c3e85f702ea21a5417f2f6ae352c12dcb0a", "roots": [ "CMakeLists.txt", "cmake", @@ -1588,12 +1588,12 @@ { "file": "vbd_paper_breakable_wall_bend.png", "role": "bend_frame_18", - "sha256": "dc05890e8f09f42dfc9dd5a1c06662a15bac88bded3197eeda8c333fc40eca06" + "sha256": "6da21444b1d674db5034e439806ee0e13fd59086896159965135845e38778d99" }, { "file": "vbd_paper_breakable_wall_retention.png", "role": "retention_frame_120", - "sha256": "69a2f2390ff4786f68f60866356291c67968aaf975e00853440c5794fdc6d590" + "sha256": "47c3cb1efecb530da0cc842797d3cbf97805a27a43a6c20157cd111d9649c83b" }, { "file": "paper-10.png", @@ -1604,7 +1604,7 @@ "not_proven_and_limitations": "The front-oblique image makes depth-wise bending intentionally subtle, so the measured magnitude comes from the numeric oracle rather than visual estimation. This linked VBD/AVBD packet does not prove exact unpublished constants, Sequential Impulse, XPBD, CUDA parity, or published/source timing parity.", "reconciliation_and_verdict": "PASS: both engine ViewReports pass with all 252 bricks in focus. The current-source text records exactly match the accepted oracle. At frame 18 they record zero broken joints, 120 spatially bent bricks, 0.131705 m maximum wall-normal displacement, and maximum retained-row residuals of 0.016387 m and 0.013275 rad. At frame 120 all 712 identities remain unbroken and the maximum residuals fall to 0.011095 m and 0.001310 rad. The inspected images show the corresponding continuous wall during impact and the later intact standing wall.", "reviewer_capability": "Codex native image input with original-resolution inspection of the six current-source captures and pinned paper figure", - "sha256": "e651c5c2373880977d3e35b871213f4f29ad2a4e20972cbe4121519f05fa8dbe", + "sha256": "5aab06eedbf99546ee380cdc7ca2695e26003afaeb0cbcdc702069a9fb579129", "text_oracle": "Figure 13 states that VBD bends the wall but does not break it; the DART row additionally verifies every retained joint identity and residual.", "verdict": "pass", "visible_observation": "At frame 18 the full wall remains continuous with no missing bricks or tears while the three balls load its center courses. At frame 120 the wall is again nearly planar, continuous, and fully standing." diff --git a/python/examples/demos/scenes/avbd_paper_breakable_wall.py b/python/examples/demos/scenes/avbd_paper_breakable_wall.py index b867a75e84124..8113c2cee7d26 100644 --- a/python/examples/demos/scenes/avbd_paper_breakable_wall.py +++ b/python/examples/demos/scenes/avbd_paper_breakable_wall.py @@ -1080,6 +1080,13 @@ def build_solver_variant( ) topology_records.append((3, 0, 1 + column)) + if rigid_body_solver == sx.RigidBodySolver.VBD: + for joint in joints: + policy = joint.constraint_projection_policy + policy.linear_stiffness = policy.start_stiffness + policy.angular_stiffness = policy.start_stiffness + joint.constraint_projection_policy = policy + balls: list[sx.RigidBody] = [] sphere_inertia = ( 2.0 / 5.0 * _BALL_MASS * _BALL_RADIUS * _BALL_RADIUS * np.eye(3) diff --git a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp index d4d477ecb198c..b7e3a48429e1e 100644 --- a/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp +++ b/tests/benchmark/simulation/bm_avbd_rigid_fixed_joint.cpp @@ -2236,6 +2236,12 @@ PaperBreakableWallFixture makePaperBreakableWallWorld( const std::uint64_t parentIndex, const std::uint64_t childIndex) { auto joint = addFixedJoint(*world, name, parent, child); + if (solver == sx::RigidBodySolver::Vbd) { + auto policy = joint.getConstraintProjectionPolicy(); + policy.linearStiffness = policy.startStiffness; + policy.angularStiffness = policy.startStiffness; + joint.setConstraintProjectionPolicy(policy); + } joint.setBreakForce(kBreakForce); joints.push_back(joint); topologyRecords.push_back({kind, parentIndex, childIndex}); diff --git a/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp b/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp index f58b9a79fca4d..ba4eb202d461f 100644 --- a/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp +++ b/tests/unit/simulation/deformable_vbd/test_avbd_rigid_block.cpp @@ -6988,6 +6988,63 @@ TEST(AvbdRigidBlock, RigidWorldVbdToAvbdFiniteRowsMatchFreshAvbd) } } +//============================================================================== +TEST(AvbdRigidBlock, RigidWorldFixedPenaltyRejectsHardJointRows) +{ + sx::World world; + world.setGravity(Vec3::Zero()); + + sx::RigidBodyOptions baseOptions; + baseOptions.isStatic = true; + auto base = world.addRigidBody("base", baseOptions); + sx::RigidBodyOptions linkOptions; + linkOptions.mass = 1.0; + linkOptions.position = Vec3::UnitX(); + auto link = world.addRigidBody("link", linkOptions); + + auto& registry = dart::simulation::detail::registryOf(world); + const entt::entity jointEntity = registry.create(); + auto& jointModel = registry.emplace(jointEntity); + jointModel.type = sx::comps::JointType::Fixed; + + vbd::AvbdRigidWorldPointJointInput joint; + joint.joint = jointEntity; + joint.bodyA = sx::detail::toRegistryEntity(base.getEntity()); + joint.bodyB = sx::detail::toRegistryEntity(link.getEntity()); + joint.anchorA = Vec3::Zero(); + joint.anchorB = Vec3::UnitX(); + joint.linearAxisMask = 1u; + joint.angularAxisMask = 0u; + joint.startStiffness = 4.0; + joint.linearMaterialStiffness = std::numeric_limits::infinity(); + joint.maxStiffness = 100.0; + + auto snapshot = vbd::buildAvbdRigidWorldContactSnapshot( + registry, std::span()); + ASSERT_EQ( + vbd::appendAvbdRigidWorldPointJoints( + registry, + std::span{&joint, 1u}, + snapshot), + 1u); + vbd::predictAvbdRigidWorldContactInertialTargets( + registry, snapshot, /*timeStep=*/1.0); + + vbd::AvbdRigidWorldContactSolveOptions options; + options.formulation + = vbd::AvbdRigidWorldContactSolveOptions::Formulation::FixedPenalty; + vbd::AvbdScalarRowInventory normalInventory; + vbd::AvbdScalarRowInventory frictionInventory; + EXPECT_THROW( + (void)vbd::solveAvbdRigidWorldContactSnapshot( + snapshot, + normalInventory, + frictionInventory, + /*timeStep=*/1.0, + options), + sx::InvalidOperationException); +} + //============================================================================== TEST(AvbdRigidBlock, RigidWorldContactSnapshotApplyWritesDynamicBodyState) { diff --git a/tests/unit/simulation/world/test_world.cpp b/tests/unit/simulation/world/test_world.cpp index 8ed27d56dd30d..2e37cd0e84d6a 100644 --- a/tests/unit/simulation/world/test_world.cpp +++ b/tests/unit/simulation/world/test_world.cpp @@ -3574,6 +3574,10 @@ void configurePublicRigidBlockDescentContactAndBreakableJointRowsScene( ground, brick, makeJointSpec(objectName("breakable_joint"), sx::JointType::Fixed)); + if (solver == sx::RigidBodySolver::Vbd) { + joint.setConstraintProjectionPolicy( + makeConstraintProjectionPolicy(1.0e5, 1.0e5, 1.0e5)); + } joint.setBreakForce(1.0e6); sx::RigidBodyOptions ballOptions; @@ -6142,6 +6146,46 @@ TEST(World, PublicVbdFamilyProjectsContactsWithoutPrivateBodyConfigs) EXPECT_GT(sphere.getTranslation().z(), initialPosition.z()); } +TEST(World, PublicVbdFamilyRequiresFinitePointJointProjectionStiffness) +{ + namespace sx = dart::simulation; + + const std::array jointTypes{ + sx::JointType::Fixed, + sx::JointType::Revolute, + sx::JointType::Prismatic, + sx::JointType::Spherical}; + for (const sx::JointType jointType : jointTypes) { + SCOPED_TRACE(static_cast(jointType)); + + sx::WorldOptions options; + options.gravity = Eigen::Vector3d::Zero(); + options.rigidBodySolver = sx::RigidBodySolver::Vbd; + + sx::World hardWorld(options); + sx::RigidBodyOptions parentOptions; + parentOptions.isStatic = true; + auto hardParent = hardWorld.addRigidBody("hard_parent", parentOptions); + sx::RigidBodyOptions childOptions; + childOptions.position = Eigen::Vector3d::UnitX(); + auto hardChild = hardWorld.addRigidBody("hard_child", childOptions); + (void)hardWorld.addJoint( + hardParent, hardChild, makeJointSpec("hard_joint", jointType)); + EXPECT_THROW( + hardWorld.enterSimulationMode(), sx::InvalidOperationException); + + sx::World finiteWorld(options); + auto finiteParent + = finiteWorld.addRigidBody("finite_parent", parentOptions); + auto finiteChild = finiteWorld.addRigidBody("finite_child", childOptions); + auto finiteJoint = finiteWorld.addJoint( + finiteParent, finiteChild, makeJointSpec("finite_joint", jointType)); + finiteJoint.setConstraintProjectionPolicy( + makeConstraintProjectionPolicy(1.0e5, 1.0e5, 1.0e5)); + EXPECT_NO_THROW(finiteWorld.step()); + } +} + TEST(World, PublicAvbdFamilyRejectsConflictingContactPolicy) { namespace sx = dart::simulation; diff --git a/tests/unit/simulation/world/test_world_resolved_configuration.cpp b/tests/unit/simulation/world/test_world_resolved_configuration.cpp index c6be1fba5c0db..77e292a87f55f 100644 --- a/tests/unit/simulation/world/test_world_resolved_configuration.cpp +++ b/tests/unit/simulation/world/test_world_resolved_configuration.cpp @@ -193,10 +193,14 @@ TEST(ResolvedConfiguration, RecordsPublicVbdFamilyAsRequested) sx::RigidBodyOptions childOptions; childOptions.position = Eigen::Vector3d::UnitX(); auto child = world.addRigidBody("child", childOptions); - world.addJoint( + auto joint = world.addJoint( parent, child, sx::JointSpec{.name = "fixed", .type = sx::JointType::Fixed}); + joint.setConstraintProjectionPolicy( + {.startStiffness = 1.0e5, + .linearStiffness = 1.0e5, + .angularStiffness = 1.0e5}); world.enterSimulationMode(); const auto& config = world.getResolvedConfiguration();