diff --git a/.github/workflows/ci_macos.yml b/.github/workflows/ci_macos.yml index a0dbf750ac60f..74893d376fee2 100644 --- a/.github/workflows/ci_macos.yml +++ b/.github/workflows/ci_macos.yml @@ -41,6 +41,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # check-citation-evidence (via check-lint) verifies packet + # target commits against the local object store; a shallow + # clone cannot answer whether a historical commit exists. + fetch-depth: 0 - name: Setup pixi uses: prefix-dev/setup-pixi@v0.9.6 @@ -48,6 +53,13 @@ jobs: cache: true pixi-bin-path: ${{ runner.temp }}/pixi/bin/pixi + - name: Fetch citation packet target refs + # Squash merges retire topic-branch commits from branch + # history; the surviving refs/pull/N/head namespace is not + # covered by fetch-depth: 0, so fetch the refs the packets + # actually name. + run: pixi run python scripts/check_citation_evidence.py --fetch-target-refs + - name: Check Lint run: | DART_VERBOSE=ON \ diff --git a/.github/workflows/ci_ubuntu.yml b/.github/workflows/ci_ubuntu.yml index b6bf80fb38eb8..99981466dcccc 100644 --- a/.github/workflows/ci_ubuntu.yml +++ b/.github/workflows/ci_ubuntu.yml @@ -110,6 +110,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # check-citation-evidence (via check-lint) verifies packet + # target commits against the local object store; a shallow + # clone cannot answer whether a historical commit exists. + fetch-depth: 0 - name: Setup pixi uses: prefix-dev/setup-pixi@v0.9.6 @@ -122,6 +127,13 @@ jobs: packages: libgl1-mesa-dev libglu1-mesa-dev xvfb version: "2" + - name: Fetch citation packet target refs + # Squash merges retire topic-branch commits from branch + # history; the surviving refs/pull/N/head namespace is not + # covered by fetch-depth: 0, so fetch the refs the packets + # actually name. + run: pixi run python scripts/check_citation_evidence.py --fetch-target-refs + - name: Check Lint run: | DART_VERBOSE=ON \ diff --git a/.github/workflows/ci_windows.yml b/.github/workflows/ci_windows.yml index 177d00252981e..bde02cdedc820 100644 --- a/.github/workflows/ci_windows.yml +++ b/.github/workflows/ci_windows.yml @@ -55,6 +55,11 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # check-citation-evidence (via check-lint) verifies packet + # target commits against the local object store; a shallow + # clone cannot answer whether a historical commit exists. + fetch-depth: 0 - name: Setup pixi uses: prefix-dev/setup-pixi@v0.9.6 @@ -107,6 +112,13 @@ jobs: } cmd.exe /c exit 0 + - name: Fetch citation packet target refs + # Squash merges retire topic-branch commits from branch + # history; the surviving refs/pull/N/head namespace is not + # covered by fetch-depth: 0, so fetch the refs the packets + # actually name. + run: pixi run python scripts/check_citation_evidence.py --fetch-target-refs + - name: Check Lint run: | pixi run check-lint diff --git a/CHANGELOG.md b/CHANGELOG.md index a633b3c6d25a1..859bdbc645fe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -751,6 +751,17 @@ * Tests + * Add the branch-local citation-claim evidence contract (PLAN-623): a + machine-checked `release-6.20` claims manifest under + `docs/design/dart6_citation_driven_contact_trust/`, a fail-closed + `pixi run check-citation-evidence` gate (missing commit, scene digest, + requested/resolved detector or solver identity, command, ensemble, + disposition, claim boundary, or typed-unsupported metrics fail + validation, with a permanent intentionally incomplete negative-control + packet), and the first CT-001 rolling-direction evidence packet sweeping + the available collision detectors. Additive evidence tooling only; no + library, API, ABI, or default behavior change. + * Force the ASan and required assertions-enabled builds to run without OpenSceneGraph, and require exact-candidate optional-dependency and Gazebo integration evidence before DART 6 releases: diff --git a/docs/design/README.md b/docs/design/README.md index b4e8487c3c52e..e8fe4b2ecde44 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -16,6 +16,7 @@ For cross-bucket placement decisions, use | File | Purpose | | --- | --- | | [`dart6_ai_infra.md`](dart6_ai_infra.md) | Release-branch AI documentation, model-evolution, and visual-evidence decisions within DART 6 compatibility constraints. | +| [`dart6_citation_driven_contact_trust.md`](dart6_citation_driven_contact_trust.md) | PLAN-623 citation contact trust on the LTS branch: branch-local claims manifest, fail-closed evidence packets, row set, and fix policy under the compatibility contract. | | [`dart6_collision_backends.md`](dart6_collision_backends.md) | Current DART 6.20 collision-backend architecture, compatibility boundary, downstream gz obligations, and later backend lifecycle. | | [`dart6_deformable_body.md`](dart6_deformable_body.md) | DART 6.20 deformable-body compatibility, paper scope, performance evidence, and collision-backend decisions. | diff --git a/docs/design/dart6_citation_driven_contact_trust.md b/docs/design/dart6_citation_driven_contact_trust.md new file mode 100644 index 0000000000000..c740db1a0005a --- /dev/null +++ b/docs/design/dart6_citation_driven_contact_trust.md @@ -0,0 +1,185 @@ +# DART 6.20 Citation-Driven Contact Trust + +## Status + +Durable compatibility and evidence contract for `release-6.20`. Active +implementation state lives in +`docs/dev_tasks/dart6_citation_contact_trust/`. The DART 7 long-term owner is +`docs/design/contact_trust_and_observability.md` on `main`. + +## Purpose + +DART 6.20 should answer historical and current claims about DART 6 with +source-faithful reproductions, permanent tests/benchmarks, and honest +dispositions while preserving the LTS contract. This branch may improve +existing collision, contact, solver, sleeping, parser, and diagnostic behavior +when a defect is demonstrated, but it is not a vehicle for DART 7 architecture +or new public solver families. + +## Compatibility boundary + +All work preserves unless explicitly authorized otherwise: + +- C++17 and pybind11; +- installed headers, symbols, class layouts, components, and parser surfaces; +- FCL `PRIMITIVE` as the built-in default; +- real FCL, Bullet, ODE, and DART-owned collision backends and factory keys; +- OSG and consolidated `dart-demos`; +- default simulation behavior for unaffected scenes; +- Gazebo/gz-physics/gz-sim compatibility; +- public `collision::Contact` and constraint/contact-surface semantics. + +`main` is reference evidence only. Do not import C++23, nanobind, EnTT World +storage, DART 7 solver/backend APIs, or generated DART 7 workflows. + +## Allowed work + +- Source-bound reproductions of citation, benchmark, and issue claims. +- Additive tests, benchmarks, demos, scripts, and evidence schemas. +- Internal or opt-in diagnostics with compile-time/runtime-zero common-path + overhead where the branch contract requires it. +- Implementation-local correctness, determinism, allocation, and performance + fixes with negative controls. +- Additive non-virtual helpers only after ABI/source review. +- Documentation clarifying impulse/force/wrench semantics without changing + established ABI. +- Separate `main` fixes for defects shared across branches. + +## Excluded work + +- New exact-cone/NCP, IPC, VBD/AVBD, differentiable, batch-World, rod/shell, or + biomechanics solver architecture. +- Public model/state/contact redesign. +- Default detector or solver changes. +- Removal or facade replacement of FCL/Bullet/ODE. +- New required dependencies or language/binding/rendering floor changes. +- Broad refactors justified only by DART 7 cleanliness. +- Silent contact smoothing or changed downstream force conventions. + +## Owner integration + +Before creating new fixtures or task owners, audit and reuse: + +- PLAN-621 and `docs/dev_tasks/dart6_performance_generalization/`; +- PLAN-622 and `docs/dev_tasks/dart6_deformable_body_performance/`; +- `docs/design/dart6_collision_backends.md`; +- existing DART 6 benchmark, capture, evidence, and AI verification tooling; +- current open PRs/issues and any newer completion/retirement state. + +A corpus row already owned by PLAN-621/622 stays there. The citation task points +to its evidence and does not copy the implementation checklist. + +## Claim and evidence contract + +Use the same stable claim IDs and dispositions as DART 7, but every packet is +explicitly `release-6.20` and records (enforced by +`scripts/check_citation_evidence.py` — this list matches the enforced +schema, not an aspiration beyond it): + +- exact target commit plus the runnable durable fetch command; +- scene identity: digest-bound parameters (source model, license, and + conversion provenance are recorded WHEN an external model asset is used; + procedurally built primitive scenes carry their full construction in + `scene.parameters` and have no external asset to attribute); +- detector, constraint solver, timestep, iterations, threads, and defaults + (typed unsupported where the branch exposes no accessor); +- ensemble (verified repeats, or object sweep points/seeds each bound to + their own recorded rows) and a named measurement window; +- contact/trajectory hashes, penetration, energy where available; +- exact reproducible commands (build step included) and host validity, + with measured timing forbidden when the host is not + performance-controlled; +- disposition, claim boundary, and limitations; +- Gazebo/gz relevance, recorded per packet in its limitations or notes + when a downstream-visible path is touched; +- digest-bound review evidence. + +If DART 6 lacks a comparable metric, record it as unsupported or derive it in +the harness without changing public runtime APIs. Never encode unsupported as +numeric zero. + +## Contact semantics + +DART 6 public compatibility constrains API changes, but evidence must still +distinguish: + +- collision query geometry; +- solver impulse; +- legacy per-step `Contact.force`/wrench data; +- interval-average or filtered analysis values; +- downstream Gazebo contact sensor transforms and sign/ownership. + +Tests should pin object ordering, normal convention, point/frame transforms, +force/wrench sign, timestep dependence, reset/clone behavior, and downstream +conversion where applicable. Do not silently reinterpret released fields. + +New analysis helpers should remain in tests/examples/scripts or additive +non-virtual APIs after compatibility review. + +## Initial DART 6 rows + +The durable row set for this branch (unordered — working priority lives with +the active task state, not here): + +- the completed `3k_shapes` and sleeping/collision performance campaign + (audit/guard); +- rolling/friction-direction behavior across FCL, DART, Bullet, and ODE where + supported; +- dense inelastic/elastic contact finite-state and failure grids; +- heel-strike/toe-off raw impulse and legacy force/wrench interpretation; +- contact normal/object ordering and Gazebo wrench sensor regressions; +- high mass-ratio stacks/manipulation and current solver fallback behavior; +- existing PLAN-622 soft-contact robustness rows using perturbation ensembles + (these contribute evidence without expanding the cap). + +The cap is six common fixture families with dispositions. The working order +in which rows are attacked is mutable state and is owned by the PLAN-623 +task home (`docs/dev_tasks/dart6_citation_contact_trust/README.md`) with the +branch manifest recording live lane status; this document records only which +rows exist and why. + +## Fix policy + +A DART 6 behavioral fix requires: + +- baseline reproduction on a current clean release base; +- root cause; +- smallest compatibility-safe change; +- regression that fails before and passes after; +- state/contact/rest hash or explicit re-baseline; +- no unrelated default or backend changes; +- ABI/header/component/package audit when near a public boundary; +- `pixi run -e gazebo test-gz` for collision/constraint/World/downstream work; +- a separate DART 7 issue/PR assessment. + +Performance gains cannot come from lost contacts, changed sleeping, cap hits, +skipped work that remains physically required, or a different model without +being labeled non-equivalent. + +## Diagnostics and overhead + +Diagnostics are opt-in and preferably test/harness-side. Any library +instrumentation must: + +- preserve public class layout and vtables; +- be default-off when it adds work or state; +- compile out or have proved negligible common-path cost as appropriate; +- avoid global registries, locks, or heap allocation on every solve/step; +- report actual detector/solver/fallback and unsupported metrics honestly. + +## Verification + +Every slice runs: + +- `pixi run lint`; +- focused build and C++/dartpy tests; +- relevant benchmarks with raw rows and validity checks; +- deterministic repeats/ensembles; +- visual evidence through the branch OSG/demos path when visible; +- `pixi run -e gazebo test-gz` when downstream-sensitive; +- two clean independent or role-separated reviews; +- explicit changelog decision. + +Before completion, promote durable facts to this design, existing collision, +testing, profiling, user, or release owners, then remove the dev-task folder in +the completing PR. diff --git a/docs/design/dart6_citation_driven_contact_trust/claims-manifest.json b/docs/design/dart6_citation_driven_contact_trust/claims-manifest.json new file mode 100644 index 0000000000000..f88c57ca94cc2 --- /dev/null +++ b/docs/design/dart6_citation_driven_contact_trust/claims-manifest.json @@ -0,0 +1,283 @@ +{ + "schema": "dart.citation_claim_manifest/v1", + "branch": "release-6.20", + "corpus_reference": { + "path": "docs/plans/123-citation-driven-simulation-trust/citation-claim-corpus.md", + "branch": "main", + "note": "Claim IDs, titles, and bounded claims are owned by the DART 7 corpus; this branch manifest owns only release-6.20 lane state and packets." + }, + "audited": "2026-08-14", + "claims": [ + { + "id": "CT-001", + "title": "Rolling-direction friction dependence", + "source": "SimBenchmark / historical DART contact comparison", + "lanes": { + "dart6": { + "owner": "docs/dev_tasks/dart6_citation_contact_trust", + "status": "in-progress", + "disposition": null, + "evidence": [ + "evidence/CT-001-dart6-rolling-direction.json" + ], + "notes": "First packet: rolling-direction sweep across fcl/dart/bullet/ode with the default boxed-LCP solver. Open PR #3377 owns exact-Coulomb FBF rolling/incline/turntable scenes on this branch; reuse its fixtures and evidence rather than duplicating." + } + }, + "source_url": "https://leggedrobotics.github.io/SimBenchmark/", + "source_claim": "Polyhedral friction can produce direction-dependent rolling/sliding behavior." + }, + { + "id": "CT-002", + "title": "Dense inelastic contact stability and scaling", + "source": "SimBenchmark dense 6x6x6 inelastic contact", + "lanes": { + "dart6": { + "owner": "docs/dev_tasks/dart6_citation_contact_trust", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-003", + "title": "Dense elastic contact energy behavior", + "source": "SimBenchmark dense elastic contact", + "lanes": { + "dart6": { + "owner": "docs/dev_tasks/dart6_citation_contact_trust", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-004", + "title": "Articulated momentum/energy accuracy at matched cost", + "source": "SimBenchmark articulated momentum/energy", + "lanes": { + "dart6": { + "owner": "docs/dev_tasks/dart6_citation_contact_trust", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-005", + "title": "Articulated PD-control tracking tradeoffs", + "source": "SimBenchmark articulated PD control", + "lanes": { + "dart6": { + "owner": "docs/dev_tasks/dart6_citation_contact_trust", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-006", + "title": "Heel-strike/toe-off contact-force transients", + "source": "2026 exoskeleton contact-force criticism", + "lanes": { + "dart6": { + "owner": "docs/dev_tasks/dart6_citation_contact_trust", + "status": "audit-required", + "disposition": null, + "evidence": [], + "notes": "Clarify/diagnose legacy Contact.force and constraint-impulse semantics without ABI or default change." + } + } + }, + { + "id": "CT-007", + "title": "Exact Coulomb cones vs pyramid anisotropy/conditioning", + "source": "From Compliant to Rigid Contact Simulation", + "lanes": { + "dart6": { + "owner": "evidence only; PR #3377 research lane owns the exact-Coulomb solver work", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-008", + "title": "Shared contact problem with compliance vs regularization split", + "source": "From Compliant to Rigid Contact Simulation", + "lanes": { + "dart6": { + "owner": "document existing behavior only", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-009", + "title": "Contact-aware inverse dynamics with feasibility diagnostics", + "source": "From Compliant to Rigid Contact Simulation / robotics need", + "lanes": { + "dart6": { + "owner": null, + "status": "not-applicable", + "reason": "No new inverse-dynamics architecture on the LTS branch beyond existing APIs.", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-010", + "title": "Analytic hard-contact derivatives agree with finite differences", + "source": "Nimble", + "lanes": { + "dart6": { + "owner": null, + "status": "not-applicable", + "reason": "Differentiable-simulation architecture is DART 7 only.", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-011", + "title": "Fast reset, concurrency, and deterministic synchronous stepping", + "source": "RobotDART", + "lanes": { + "dart6": { + "owner": "existing clone/reset/Recording evidence only", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-012", + "title": "High-throughput collision checking for long-horizon planning", + "source": "PEEL", + "lanes": { + "dart6": { + "owner": "optional benchmark/adapter evidence", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-013", + "title": "Thread-safe state-validity and collision queries", + "source": "Fibration Trees", + "lanes": { + "dart6": { + "owner": "optional query benchmark", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-014", + "title": "Multi-stage manipulation environment contracts", + "source": "Behavior Policy Learning", + "lanes": { + "dart6": { + "owner": "companion-only evidence", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-015", + "title": "Scalable muscle-actuated models and trustworthy gait outputs", + "source": "MASS / musculoskeletal DART use", + "lanes": { + "dart6": { + "owner": "existing SoftBody/contact evidence only; no new subsystem", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-016", + "title": "Standardized experiment model/sensor/data contracts", + "source": "FARMS", + "lanes": { + "dart6": { + "owner": "companion-only", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-017", + "title": "Codimensional rods/plants beyond rigid-body approximation", + "source": "Gazebo Plants", + "lanes": { + "dart6": { + "owner": null, + "status": "not-applicable", + "reason": "No codimensional rod/shell architecture on the LTS branch.", + "disposition": null, + "evidence": [] + } + } + }, + { + "id": "CT-018", + "title": "Large settled worlds must not stay orders of magnitude slower", + "source": "DART issue #3056 and DART 6 performance campaign", + "lanes": { + "dart6": { + "owner": "PLAN-621 (docs/dev_tasks/dart6_performance_generalization)", + "status": "audit-required", + "disposition": null, + "evidence": [], + "notes": "Issue #3056 open; PR #3428 active; PLAN-621 remains the implementation owner and this row references its evidence." + } + } + }, + { + "id": "CT-019", + "title": "Unambiguous contact normal/order/sign/frame/wrench ownership", + "source": "Historical contact-normal / wrench issues (#1425, #1073)", + "lanes": { + "dart6": { + "owner": "docs/dev_tasks/dart6_citation_contact_trust", + "status": "audit-required", + "disposition": null, + "evidence": [], + "notes": "Downstream Gazebo contact-sensor conversion is part of the oracle; test-gz applies." + } + } + }, + { + "id": "CT-020", + "title": "Single trajectories and saturated thresholds create false claims", + "source": "Current DART paper-parity work", + "lanes": { + "dart6": { + "owner": "PLAN-622 perturbation-ensemble evidence (PR #3431)", + "status": "audit-required", + "disposition": null, + "evidence": [] + } + } + } + ] +} diff --git a/docs/design/dart6_citation_driven_contact_trust/evidence/CT-001-dart6-rolling-direction.json b/docs/design/dart6_citation_driven_contact_trust/evidence/CT-001-dart6-rolling-direction.json new file mode 100644 index 0000000000000..79d879875b3fd --- /dev/null +++ b/docs/design/dart6_citation_driven_contact_trust/evidence/CT-001-dart6-rolling-direction.json @@ -0,0 +1,1232 @@ +{ + "claim_id": "CT-001", + "configuration": { + "detector": "swept: fcl, dart, bullet, ode", + "fallback_policy": "BoxedLcpConstraintSolver secondary-solver fallback (default); per-solve fallback events are not exposed on release-6.20", + "iterations": "Dantzig direct solve with PGS fallback; per-solve iteration counts are not exposed on release-6.20 and are typed unsupported in metrics.numerical.solver_iterations rather than reported as zero", + "requested": { + "backend": "cpu", + "collision_detector_sweep": [ + "fcl", + "dart", + "bullet", + "ode" + ], + "constraint_solver": "World default (boxed LCP)", + "integrator": "World::step semi-implicit default", + "precision": "float64", + "threads": "single-threaded default" + }, + "resolved": { + "by_collision_detector": { + "bullet": { + "collision_detector": "bullet", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "dart": { + "collision_detector": "dart", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "fcl": { + "collision_detector": "fcl", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "ode": { + "collision_detector": "ode", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + } + } + }, + "resolved_provenance": "ConstraintSolver.getCollisionDetector().getType() readback after setCollisionDetector plus constraint-solver type name; the writer aborts if readback differs from the request: detector type, timestep, and gravity are each asserted per run and across repeats. Boxed-LCP internal Dantzig/PGS selection is not exposed per solve on release-6.20.", + "substeps": 1, + "timestep": 0.002 + }, + "ensemble": { + "deterministic_repeats": 2, + "deterministic_repeats_identical": true, + "kind": "parameter-sweep-with-deterministic-repeats", + "measurement_window": { + "end_s": 1.0, + "start_s": 0.0, + "steps": 500 + }, + "sweep": [ + { + "angle_deg": 0.0, + "collision_detector": "fcl" + }, + { + "angle_deg": 15.0, + "collision_detector": "fcl" + }, + { + "angle_deg": 30.0, + "collision_detector": "fcl" + }, + { + "angle_deg": 45.0, + "collision_detector": "fcl" + }, + { + "angle_deg": 60.0, + "collision_detector": "fcl" + }, + { + "angle_deg": 75.0, + "collision_detector": "fcl" + }, + { + "angle_deg": 90.0, + "collision_detector": "fcl" + }, + { + "angle_deg": 0.0, + "collision_detector": "dart" + }, + { + "angle_deg": 15.0, + "collision_detector": "dart" + }, + { + "angle_deg": 30.0, + "collision_detector": "dart" + }, + { + "angle_deg": 45.0, + "collision_detector": "dart" + }, + { + "angle_deg": 60.0, + "collision_detector": "dart" + }, + { + "angle_deg": 75.0, + "collision_detector": "dart" + }, + { + "angle_deg": 90.0, + "collision_detector": "dart" + }, + { + "angle_deg": 0.0, + "collision_detector": "bullet" + }, + { + "angle_deg": 15.0, + "collision_detector": "bullet" + }, + { + "angle_deg": 30.0, + "collision_detector": "bullet" + }, + { + "angle_deg": 45.0, + "collision_detector": "bullet" + }, + { + "angle_deg": 60.0, + "collision_detector": "bullet" + }, + { + "angle_deg": 75.0, + "collision_detector": "bullet" + }, + { + "angle_deg": 90.0, + "collision_detector": "bullet" + }, + { + "angle_deg": 0.0, + "collision_detector": "ode" + }, + { + "angle_deg": 15.0, + "collision_detector": "ode" + }, + { + "angle_deg": 30.0, + "collision_detector": "ode" + }, + { + "angle_deg": 45.0, + "collision_detector": "ode" + }, + { + "angle_deg": 60.0, + "collision_detector": "ode" + }, + { + "angle_deg": 75.0, + "collision_detector": "ode" + }, + { + "angle_deg": 90.0, + "collision_detector": "ode" + } + ] + }, + "evidence": { + "commands": [ + "pixi run build", + "PYTHONPATH=build/default/cpp/Release/python/dartpy pixi run python scripts/write_citation_ct001_rolling_direction_packet.py" + ], + "raw_rows": [ + { + "along_travel_m": 0.7258893308828899, + "angle_deg": 0.0, + "collision_detector": "fcl", + "final_height_m": 0.07999996101782572, + "final_planar_speed_mps": 0.7142857301140249, + "finite": true, + "heading_error_deg": 0.0, + "lateral_drift_m": 0.0, + "max_contact_count": 1, + "max_energy_gain_j": 3.0003008910650664e-08, + "max_penetration_m": 3.89795699606843e-08, + "repeat_trajectory_sha256": [ + "4a77aca83ca57ba322909cdb84214e7f74f03d511693b7e62b18299833545aae", + "4a77aca83ca57ba322909cdb84214e7f74f03d511693b7e62b18299833545aae" + ], + "resolved": { + "collision_detector": "fcl", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.082, + "trajectory_sha256": "4a77aca83ca57ba322909cdb84214e7f74f03d511693b7e62b18299833545aae" + }, + { + "along_travel_m": 0.7249208519930566, + "angle_deg": 15.0, + "collision_detector": "fcl", + "final_height_m": 0.07999996101782567, + "final_planar_speed_mps": 0.7142857020171787, + "finite": true, + "heading_error_deg": -1.4353380530632814e-07, + "lateral_drift_m": -0.002100558299245575, + "max_contact_count": 1, + "max_energy_gain_j": 9.200552320010047e-09, + "max_penetration_m": 3.897957004395103e-08, + "repeat_trajectory_sha256": [ + "671b2885b12ada4eca7ffc5a0fbd720877fc477caf178f02be39a13d5b703d39", + "671b2885b12ada4eca7ffc5a0fbd720877fc477caf178f02be39a13d5b703d39" + ], + "resolved": { + "collision_detector": "fcl", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.078, + "trajectory_sha256": "671b2885b12ada4eca7ffc5a0fbd720877fc477caf178f02be39a13d5b703d39" + }, + { + "along_travel_m": 0.7232079452397375, + "angle_deg": 30.0, + "collision_detector": "fcl", + "final_height_m": 0.07999996101782571, + "final_planar_speed_mps": 0.7142856939774659, + "finite": true, + "heading_error_deg": 2.09890267229116e-08, + "lateral_drift_m": -0.0018832895945334882, + "max_contact_count": 1, + "max_energy_gain_j": 1.633952195767563e-09, + "max_penetration_m": 3.897956998843988e-08, + "repeat_trajectory_sha256": [ + "385847571d413022412b9d97cb762da411f74963f6bd796e01b855dd2f462d18", + "385847571d413022412b9d97cb762da411f74963f6bd796e01b855dd2f462d18" + ], + "resolved": { + "collision_detector": "fcl", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.07, + "trajectory_sha256": "385847571d413022412b9d97cb762da411f74963f6bd796e01b855dd2f462d18" + }, + { + "along_travel_m": 0.7224082462440173, + "angle_deg": 45.0, + "collision_detector": "fcl", + "final_height_m": 0.07999996101782568, + "final_planar_speed_mps": 0.714285735962786, + "finite": true, + "heading_error_deg": -3.027887964863226e-13, + "lateral_drift_m": -5.551115123125783e-17, + "max_contact_count": 1, + "max_energy_gain_j": 2.9435435422442424e-08, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "530ddcbcea801dc161696faa54cd95024f40aa0493cfdfcc2e589493e89922f3", + "530ddcbcea801dc161696faa54cd95024f40aa0493cfdfcc2e589493e89922f3" + ], + "resolved": { + "collision_detector": "fcl", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.058, + "trajectory_sha256": "530ddcbcea801dc161696faa54cd95024f40aa0493cfdfcc2e589493e89922f3" + }, + { + "along_travel_m": 0.7232079452397404, + "angle_deg": 60.0, + "collision_detector": "fcl", + "final_height_m": 0.07999996101782565, + "final_planar_speed_mps": 0.7142856939774709, + "finite": true, + "heading_error_deg": -2.0988750650757255e-08, + "lateral_drift_m": 0.0018832895945317674, + "max_contact_count": 1, + "max_energy_gain_j": 1.6339523623010166e-09, + "max_penetration_m": 3.897957004395103e-08, + "repeat_trajectory_sha256": [ + "3b3a1e0efc3893bb2839d9dfec9ee6eccd200c3539f3cc4c5951f37eb75c2d6d", + "3b3a1e0efc3893bb2839d9dfec9ee6eccd200c3539f3cc4c5951f37eb75c2d6d" + ], + "resolved": { + "collision_detector": "fcl", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.07, + "trajectory_sha256": "3b3a1e0efc3893bb2839d9dfec9ee6eccd200c3539f3cc4c5951f37eb75c2d6d" + }, + { + "along_travel_m": 0.7249208519930553, + "angle_deg": 75.0, + "collision_detector": "fcl", + "final_height_m": 0.07999996101782571, + "final_planar_speed_mps": 0.714285702017171, + "finite": true, + "heading_error_deg": 1.4353389881463893e-07, + "lateral_drift_m": 0.0021005582992501826, + "max_contact_count": 1, + "max_energy_gain_j": 9.200552375521198e-09, + "max_penetration_m": 3.897956998843988e-08, + "repeat_trajectory_sha256": [ + "d43efd64d62bd595556d9c3885f9aa4656c89d478775c314014c9103652d21c0", + "d43efd64d62bd595556d9c3885f9aa4656c89d478775c314014c9103652d21c0" + ], + "resolved": { + "collision_detector": "fcl", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.078, + "trajectory_sha256": "d43efd64d62bd595556d9c3885f9aa4656c89d478775c314014c9103652d21c0" + }, + { + "along_travel_m": 0.7258893308828891, + "angle_deg": 90.0, + "collision_detector": "fcl", + "final_height_m": 0.0799999610178257, + "final_planar_speed_mps": 0.7142857301140259, + "finite": true, + "heading_error_deg": 1.6076693426301324e-22, + "lateral_drift_m": 7.10517240765863e-19, + "max_contact_count": 1, + "max_energy_gain_j": 3.000300885513951e-08, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "586a60a444ee4bf5568ce85c45a688a2886d2b775cebfb3543bdf6171920a796", + "586a60a444ee4bf5568ce85c45a688a2886d2b775cebfb3543bdf6171920a796" + ], + "resolved": { + "collision_detector": "fcl", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.082, + "trajectory_sha256": "586a60a444ee4bf5568ce85c45a688a2886d2b775cebfb3543bdf6171920a796" + }, + { + "along_travel_m": 0.7258893071575631, + "angle_deg": 0.0, + "collision_detector": "dart", + "final_height_m": 0.07999996101782568, + "final_planar_speed_mps": 0.7142857042694309, + "finite": true, + "heading_error_deg": 0.0, + "lateral_drift_m": 0.0, + "max_contact_count": 1, + "max_energy_gain_j": 3.0239960868705396e-08, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "983271ef2f164e9c710d1f44df94d04fdfa3fa59aedf5ed5e304dfd30c422725", + "983271ef2f164e9c710d1f44df94d04fdfa3fa59aedf5ed5e304dfd30c422725" + ], + "resolved": { + "collision_detector": "dart", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.082, + "trajectory_sha256": "983271ef2f164e9c710d1f44df94d04fdfa3fa59aedf5ed5e304dfd30c422725" + }, + { + "along_travel_m": 0.7249208291109375, + "angle_deg": 15.0, + "collision_detector": "dart", + "final_height_m": 0.07999996101782568, + "final_planar_speed_mps": 0.7142856772027864, + "finite": true, + "heading_error_deg": -1.7416081274766524e-08, + "lateral_drift_m": -0.0021005570500782222, + "max_contact_count": 1, + "max_energy_gain_j": 9.439880099826325e-09, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "e9571091d7cc8a062d6838cabf811d8fdb7fbe00921fc4fe2c4b507c347996e4", + "e9571091d7cc8a062d6838cabf811d8fdb7fbe00921fc4fe2c4b507c347996e4" + ], + "resolved": { + "collision_detector": "dart", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.078, + "trajectory_sha256": "e9571091d7cc8a062d6838cabf811d8fdb7fbe00921fc4fe2c4b507c347996e4" + }, + { + "along_travel_m": 0.7232079244608078, + "angle_deg": 30.0, + "collision_detector": "dart", + "final_height_m": 0.0799999610178257, + "final_planar_speed_mps": 0.7142856717303918, + "finite": true, + "heading_error_deg": 1.3176791637633171e-07, + "lateral_drift_m": -0.0018832885444039493, + "max_contact_count": 1, + "max_energy_gain_j": 1.883046885087225e-09, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "8d84816917c8326fca76bb567f0f43c120078f0dca2eb3fe5db9828466087986", + "8d84816917c8326fca76bb567f0f43c120078f0dca2eb3fe5db9828466087986" + ], + "resolved": { + "collision_detector": "dart", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.07, + "trajectory_sha256": "8d84816917c8326fca76bb567f0f43c120078f0dca2eb3fe5db9828466087986" + }, + { + "along_travel_m": 0.7224082281964399, + "angle_deg": 45.0, + "collision_detector": "dart", + "final_height_m": 0.07999996101782567, + "final_planar_speed_mps": 0.7142857168039924, + "finite": true, + "heading_error_deg": 2.0037494422576177e-13, + "lateral_drift_m": 7.771561172376096e-16, + "max_contact_count": 1, + "max_energy_gain_j": 2.9702688530974797e-08, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "39e79e76da1b41ae6d628132c327f3f226433df095aad691c25c6d55c9fb36ae", + "39e79e76da1b41ae6d628132c327f3f226433df095aad691c25c6d55c9fb36ae" + ], + "resolved": { + "collision_detector": "dart", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.058, + "trajectory_sha256": "39e79e76da1b41ae6d628132c327f3f226433df095aad691c25c6d55c9fb36ae" + }, + { + "along_travel_m": 0.7232079244608176, + "angle_deg": 60.0, + "collision_detector": "dart", + "final_height_m": 0.07999996101782571, + "final_planar_speed_mps": 0.7142856717304028, + "finite": true, + "heading_error_deg": -1.317678273207933e-07, + "lateral_drift_m": 0.0018832885444038383, + "max_contact_count": 1, + "max_energy_gain_j": 1.8830467185537714e-09, + "max_penetration_m": 3.897956998843988e-08, + "repeat_trajectory_sha256": [ + "59ee9e67bf9a5b5b932dfb4940cf50fdc675508f6c3e23354c76454171ef22f7", + "59ee9e67bf9a5b5b932dfb4940cf50fdc675508f6c3e23354c76454171ef22f7" + ], + "resolved": { + "collision_detector": "dart", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.07, + "trajectory_sha256": "59ee9e67bf9a5b5b932dfb4940cf50fdc675508f6c3e23354c76454171ef22f7" + }, + { + "along_travel_m": 0.724920829110933, + "angle_deg": 75.0, + "collision_detector": "dart", + "final_height_m": 0.07999996101782567, + "final_planar_speed_mps": 0.714285677202779, + "finite": true, + "heading_error_deg": 1.741586531509262e-08, + "lateral_drift_m": 0.00210055705007689, + "max_contact_count": 1, + "max_energy_gain_j": 9.439880210848628e-09, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "9881d8b8402de324c7ed3942febb0d00c7c840f0eaaff507592e73b9a939a4d1", + "9881d8b8402de324c7ed3942febb0d00c7c840f0eaaff507592e73b9a939a4d1" + ], + "resolved": { + "collision_detector": "dart", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.078, + "trajectory_sha256": "9881d8b8402de324c7ed3942febb0d00c7c840f0eaaff507592e73b9a939a4d1" + }, + { + "along_travel_m": 0.7258893071575631, + "angle_deg": 90.0, + "collision_detector": "dart", + "final_height_m": 0.07999996101782568, + "final_planar_speed_mps": 0.7142857042694332, + "finite": true, + "heading_error_deg": 1.16849200480005e-22, + "lateral_drift_m": 7.105167824608745e-19, + "max_contact_count": 1, + "max_energy_gain_j": 3.0239960868705396e-08, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "4c94d702c54034f8e2186327888ed16f4570040d8b22fafd4f0f6ef784e647a6", + "4c94d702c54034f8e2186327888ed16f4570040d8b22fafd4f0f6ef784e647a6" + ], + "resolved": { + "collision_detector": "dart", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.082, + "trajectory_sha256": "4c94d702c54034f8e2186327888ed16f4570040d8b22fafd4f0f6ef784e647a6" + }, + { + "along_travel_m": 0.7238332258567366, + "angle_deg": 0.0, + "collision_detector": "bullet", + "final_height_m": 0.07999978906457897, + "final_planar_speed_mps": 0.7141295296134895, + "finite": true, + "heading_error_deg": -4.17483170223619e-05, + "lateral_drift_m": -8.028317990878797e-05, + "max_contact_count": 1, + "max_energy_gain_j": 0.00019237419087947405, + "max_penetration_m": 3.921613097190857e-05, + "repeat_trajectory_sha256": [ + "b4b6ac64c3c930ee3ef749e7adebb32fae32f174549eef1fad7fcda04280873c", + "b4b6ac64c3c930ee3ef749e7adebb32fae32f174549eef1fad7fcda04280873c" + ], + "resolved": { + "collision_detector": "bullet", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.068, + "trajectory_sha256": "b4b6ac64c3c930ee3ef749e7adebb32fae32f174549eef1fad7fcda04280873c" + }, + { + "along_travel_m": 0.7231651745675505, + "angle_deg": 15.0, + "collision_detector": "bullet", + "final_height_m": 0.07999977198926683, + "final_planar_speed_mps": 0.7141287343742414, + "finite": true, + "heading_error_deg": 0.0001606013494080212, + "lateral_drift_m": -0.00044413241977209195, + "max_contact_count": 1, + "max_energy_gain_j": 0.0001923531015808333, + "max_penetration_m": 3.9208680391311646e-05, + "repeat_trajectory_sha256": [ + "2b7f6c8158c3a8c715bd8c51590a5c198025f298e344eca137682f79c6615c98", + "2b7f6c8158c3a8c715bd8c51590a5c198025f298e344eca137682f79c6615c98" + ], + "resolved": { + "collision_detector": "bullet", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.064, + "trajectory_sha256": "2b7f6c8158c3a8c715bd8c51590a5c198025f298e344eca137682f79c6615c98" + }, + { + "along_travel_m": 0.7232384813169772, + "angle_deg": 30.0, + "collision_detector": "bullet", + "final_height_m": 0.07999971124380714, + "final_planar_speed_mps": 0.7141276433729907, + "finite": true, + "heading_error_deg": 7.250771220586366e-05, + "lateral_drift_m": -7.912470837528884e-05, + "max_contact_count": 1, + "max_energy_gain_j": 0.00019247568394265002, + "max_penetration_m": 3.9245933294296265e-05, + "repeat_trajectory_sha256": [ + "bf5df720c70b7c9544ab78089118ef70ec1c3990965ac279c967a67a54f51c61", + "bf5df720c70b7c9544ab78089118ef70ec1c3990965ac279c967a67a54f51c61" + ], + "resolved": { + "collision_detector": "bullet", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.064, + "trajectory_sha256": "bf5df720c70b7c9544ab78089118ef70ec1c3990965ac279c967a67a54f51c61" + }, + { + "along_travel_m": 0.7233313015140383, + "angle_deg": 45.0, + "collision_detector": "bullet", + "final_height_m": 0.07999963676540488, + "final_planar_speed_mps": 0.7141277863535829, + "finite": true, + "heading_error_deg": 9.090467658379377e-05, + "lateral_drift_m": 0.0006004178180518682, + "max_contact_count": 1, + "max_energy_gain_j": 0.00019247568394131775, + "max_penetration_m": 3.9245933294296265e-05, + "repeat_trajectory_sha256": [ + "c2795c5cd37e056d63300c3d7ee0e5d154def682874b90e6b2b07fb708de3b89", + "c2795c5cd37e056d63300c3d7ee0e5d154def682874b90e6b2b07fb708de3b89" + ], + "resolved": { + "collision_detector": "bullet", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.064, + "trajectory_sha256": "c2795c5cd37e056d63300c3d7ee0e5d154def682874b90e6b2b07fb708de3b89" + }, + { + "along_travel_m": 0.7233956290764734, + "angle_deg": 60.0, + "collision_detector": "bullet", + "final_height_m": 0.07999966143220519, + "final_planar_speed_mps": 0.7141291997424684, + "finite": true, + "heading_error_deg": -9.22514633336758e-05, + "lateral_drift_m": -0.000505250673743185, + "max_contact_count": 1, + "max_energy_gain_j": 0.00019247568394265002, + "max_penetration_m": 3.9245933294296265e-05, + "repeat_trajectory_sha256": [ + "cc4717f8ffa95deee723d633adbff7989ca63d3648dd5072feb29c8d5a7fa8bf", + "cc4717f8ffa95deee723d633adbff7989ca63d3648dd5072feb29c8d5a7fa8bf" + ], + "resolved": { + "collision_detector": "bullet", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.068, + "trajectory_sha256": "cc4717f8ffa95deee723d633adbff7989ca63d3648dd5072feb29c8d5a7fa8bf" + }, + { + "along_travel_m": 0.7231688271378793, + "angle_deg": 75.0, + "collision_detector": "bullet", + "final_height_m": 0.07999983687117794, + "final_planar_speed_mps": 0.7142859691160586, + "finite": true, + "heading_error_deg": 7.41328818240917e-05, + "lateral_drift_m": -0.0006696471444644081, + "max_contact_count": 1, + "max_energy_gain_j": 2.4131278808514622e-08, + "max_penetration_m": 1.825392246246338e-07, + "repeat_trajectory_sha256": [ + "29db8c982bc07ff57552ecbb13ceefdb44552bfa1275c417a98666484a9a172a", + "29db8c982bc07ff57552ecbb13ceefdb44552bfa1275c417a98666484a9a172a" + ], + "resolved": { + "collision_detector": "bullet", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.064, + "trajectory_sha256": "29db8c982bc07ff57552ecbb13ceefdb44552bfa1275c417a98666484a9a172a" + }, + { + "along_travel_m": 0.72317473617761, + "angle_deg": 90.0, + "collision_detector": "bullet", + "final_height_m": 0.07999981677841177, + "final_planar_speed_mps": 0.7142858825800176, + "finite": true, + "heading_error_deg": 2.9618440566810867e-05, + "lateral_drift_m": -0.0002570740259214119, + "max_contact_count": 1, + "max_energy_gain_j": 2.9374232712875425e-08, + "max_penetration_m": 2.123415470123291e-07, + "repeat_trajectory_sha256": [ + "6a98bff6f630816073dcd588f2f7051a6dac63ea974155957ebd2f90c5497d7a", + "6a98bff6f630816073dcd588f2f7051a6dac63ea974155957ebd2f90c5497d7a" + ], + "resolved": { + "collision_detector": "bullet", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.064, + "trajectory_sha256": "6a98bff6f630816073dcd588f2f7051a6dac63ea974155957ebd2f90c5497d7a" + }, + { + "along_travel_m": 0.7258893071575631, + "angle_deg": 0.0, + "collision_detector": "ode", + "final_height_m": 0.07999996101782568, + "final_planar_speed_mps": 0.7142857042694309, + "finite": true, + "heading_error_deg": 0.0, + "lateral_drift_m": 0.0, + "max_contact_count": 1, + "max_energy_gain_j": 3.0239960868705396e-08, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "983271ef2f164e9c710d1f44df94d04fdfa3fa59aedf5ed5e304dfd30c422725", + "983271ef2f164e9c710d1f44df94d04fdfa3fa59aedf5ed5e304dfd30c422725" + ], + "resolved": { + "collision_detector": "ode", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.082, + "trajectory_sha256": "983271ef2f164e9c710d1f44df94d04fdfa3fa59aedf5ed5e304dfd30c422725" + }, + { + "along_travel_m": 0.7249208291109375, + "angle_deg": 15.0, + "collision_detector": "ode", + "final_height_m": 0.07999996101782568, + "final_planar_speed_mps": 0.7142856772027864, + "finite": true, + "heading_error_deg": -1.7416081274766524e-08, + "lateral_drift_m": -0.0021005570500782222, + "max_contact_count": 1, + "max_energy_gain_j": 9.439880099826325e-09, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "e9571091d7cc8a062d6838cabf811d8fdb7fbe00921fc4fe2c4b507c347996e4", + "e9571091d7cc8a062d6838cabf811d8fdb7fbe00921fc4fe2c4b507c347996e4" + ], + "resolved": { + "collision_detector": "ode", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.078, + "trajectory_sha256": "e9571091d7cc8a062d6838cabf811d8fdb7fbe00921fc4fe2c4b507c347996e4" + }, + { + "along_travel_m": 0.7232079244608078, + "angle_deg": 30.0, + "collision_detector": "ode", + "final_height_m": 0.0799999610178257, + "final_planar_speed_mps": 0.7142856717303918, + "finite": true, + "heading_error_deg": 1.3176791637633171e-07, + "lateral_drift_m": -0.0018832885444039493, + "max_contact_count": 1, + "max_energy_gain_j": 1.883046885087225e-09, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "8d84816917c8326fca76bb567f0f43c120078f0dca2eb3fe5db9828466087986", + "8d84816917c8326fca76bb567f0f43c120078f0dca2eb3fe5db9828466087986" + ], + "resolved": { + "collision_detector": "ode", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.07, + "trajectory_sha256": "8d84816917c8326fca76bb567f0f43c120078f0dca2eb3fe5db9828466087986" + }, + { + "along_travel_m": 0.7224082281964399, + "angle_deg": 45.0, + "collision_detector": "ode", + "final_height_m": 0.07999996101782567, + "final_planar_speed_mps": 0.7142857168039924, + "finite": true, + "heading_error_deg": 2.0037494422576177e-13, + "lateral_drift_m": 7.771561172376096e-16, + "max_contact_count": 1, + "max_energy_gain_j": 2.9702688530974797e-08, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "39e79e76da1b41ae6d628132c327f3f226433df095aad691c25c6d55c9fb36ae", + "39e79e76da1b41ae6d628132c327f3f226433df095aad691c25c6d55c9fb36ae" + ], + "resolved": { + "collision_detector": "ode", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.058, + "trajectory_sha256": "39e79e76da1b41ae6d628132c327f3f226433df095aad691c25c6d55c9fb36ae" + }, + { + "along_travel_m": 0.7232079244608176, + "angle_deg": 60.0, + "collision_detector": "ode", + "final_height_m": 0.07999996101782571, + "final_planar_speed_mps": 0.7142856717304028, + "finite": true, + "heading_error_deg": -1.317678273207933e-07, + "lateral_drift_m": 0.0018832885444038383, + "max_contact_count": 1, + "max_energy_gain_j": 1.8830467185537714e-09, + "max_penetration_m": 3.897956998843988e-08, + "repeat_trajectory_sha256": [ + "59ee9e67bf9a5b5b932dfb4940cf50fdc675508f6c3e23354c76454171ef22f7", + "59ee9e67bf9a5b5b932dfb4940cf50fdc675508f6c3e23354c76454171ef22f7" + ], + "resolved": { + "collision_detector": "ode", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.07, + "trajectory_sha256": "59ee9e67bf9a5b5b932dfb4940cf50fdc675508f6c3e23354c76454171ef22f7" + }, + { + "along_travel_m": 0.724920829110933, + "angle_deg": 75.0, + "collision_detector": "ode", + "final_height_m": 0.07999996101782567, + "final_planar_speed_mps": 0.714285677202779, + "finite": true, + "heading_error_deg": 1.741586531509262e-08, + "lateral_drift_m": 0.00210055705007689, + "max_contact_count": 1, + "max_energy_gain_j": 9.439880210848628e-09, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "9881d8b8402de324c7ed3942febb0d00c7c840f0eaaff507592e73b9a939a4d1", + "9881d8b8402de324c7ed3942febb0d00c7c840f0eaaff507592e73b9a939a4d1" + ], + "resolved": { + "collision_detector": "ode", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.078, + "trajectory_sha256": "9881d8b8402de324c7ed3942febb0d00c7c840f0eaaff507592e73b9a939a4d1" + }, + { + "along_travel_m": 0.7258893071575631, + "angle_deg": 90.0, + "collision_detector": "ode", + "final_height_m": 0.07999996101782568, + "final_planar_speed_mps": 0.7142857042694332, + "finite": true, + "heading_error_deg": 1.16849200480005e-22, + "lateral_drift_m": 7.105167824608745e-19, + "max_contact_count": 1, + "max_energy_gain_j": 3.0239960868705396e-08, + "max_penetration_m": 3.897957001619545e-08, + "repeat_trajectory_sha256": [ + "4c94d702c54034f8e2186327888ed16f4570040d8b22fafd4f0f6ef784e647a6", + "4c94d702c54034f8e2186327888ed16f4570040d8b22fafd4f0f6ef784e647a6" + ], + "resolved": { + "collision_detector": "ode", + "constraint_solver": "BoxedLcpConstraintSolver", + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "time_step_s": 0.002 + }, + "slide_end_time_s": 0.082, + "trajectory_sha256": "4c94d702c54034f8e2186327888ed16f4570040d8b22fafd4f0f6ef784e647a6" + } + ], + "visual": { + "reason": "The oracle is numeric rotational symmetry; per-angle trajectories carry no visible-behavior claim beyond the recorded metrics.", + "status": "not-applicable" + } + }, + "host": { + "machine": "x86_64", + "note": "Host recorded for provenance only; no timing methodology was applied", + "performance_valid": false, + "platform": "Linux-7.0.0-29-generic-x86_64-with-glibc2.43", + "python": "3.14.6" + }, + "metrics": { + "allocation": { + "reason": "No allocation methodology on this row; DART 6 has no PLAN-122-style allocation gates and this packet does not measure allocations", + "status": "unsupported" + }, + "numerical": { + "max_contact_count": 1, + "max_penetration_m": 3.9245933294296265e-05, + "method": "Max over run of contact penetrationDepth and contact count from World.getLastCollisionResult; sphere final height is checked against fall-through in the disposition validity gate", + "min_final_height_m": 0.07999963676540488, + "solver_fallback_events": { + "reason": "The Dantzig-primary/PGS-secondary fallback inside BoxedLcpConstraintSolver emits no observable event on this branch, so per-solve fallback occurrences cannot be counted.", + "status": "unsupported" + }, + "solver_iterations": { + "reason": "release-6.20 exposes no per-solve LCP iteration count: BoxedLcpConstraintSolver has no getNumIterations()-style accessor, so an iteration number cannot be recorded without changing public API, which the LTS compatibility contract forbids.", + "status": "unsupported" + }, + "solver_residual": { + "reason": "release-6.20 exposes no per-solve LCP residual: the boxed-LCP solvers report no convergence measure through public API, so a residual cannot be recorded without an API change the LTS compatibility contract forbids.", + "status": "unsupported" + } + }, + "performance": { + "reason": "This packet makes no timing claim; interleaved same-host methodology is required before any performance row", + "status": "unsupported" + }, + "physical": { + "anisotropic_detectors": [ + "dart", + "fcl", + "ode" + ], + "anisotropy_findings": { + "bullet": { + "attribution": "scatter exceeding tolerance without angular structure; not attributed to polyhedral friction by this packet", + "criteria_exceeded": [ + "max_abs_lateral_drift_m" + ], + "pyramid_signature": false, + "signature_test": "lateral drift antisymmetric about 45 deg to within 5% of peak drift" + }, + "dart": { + "attribution": "orientation-dependent friction-pyramid anisotropy", + "criteria_exceeded": [ + "max_abs_lateral_drift_m" + ], + "pyramid_signature": true, + "signature_test": "lateral drift antisymmetric about 45 deg to within 5% of peak drift" + }, + "fcl": { + "attribution": "orientation-dependent friction-pyramid anisotropy", + "criteria_exceeded": [ + "max_abs_lateral_drift_m" + ], + "pyramid_signature": true, + "signature_test": "lateral drift antisymmetric about 45 deg to within 5% of peak drift" + }, + "ode": { + "attribution": "orientation-dependent friction-pyramid anisotropy", + "criteria_exceeded": [ + "max_abs_lateral_drift_m" + ], + "pyramid_signature": true, + "signature_test": "lateral drift antisymmetric about 45 deg to within 5% of peak drift" + } + }, + "identical_detector_groups": [ + [ + "dart", + "ode" + ] + ], + "isotropy_tolerance": { + "max_abs_heading_error_deg": 0.1, + "max_abs_lateral_drift_m": 0.0001, + "travel_spread_relative": 0.01 + }, + "method": "Rotational-symmetry sweep: per-angle lateral drift from the launch ray, final-velocity heading error, along-ray travel, slide-to-roll transition time, and max single-step kinetic-energy gain from Skeleton.computeKineticEnergy", + "per_detector_summary": { + "bullet": { + "antisymmetry_residual_m": 0.0012008356361037364, + "antisymmetry_residual_over_peak_drift": 1.793236402231177, + "max_abs_heading_error_deg": 0.0001606013494080212, + "max_abs_lateral_drift_m": 0.0006696471444644081, + "max_contact_count": 1, + "max_energy_gain_j": 0.00019247568394265002, + "max_penetration_m": 3.9245933294296265e-05, + "min_final_height_m": 0.07999963676540488, + "runs": 7, + "travel_mean_m": 0.7233296250924665, + "travel_spread_m": 0.0006680512891861223, + "travel_spread_relative": 0.0009235779456713422 + }, + "dart": { + "antisymmetry_residual_m": 1.5543122344752192e-15, + "antisymmetry_residual_over_peak_drift": 7.399524018723217e-13, + "max_abs_heading_error_deg": 1.3176791637633171e-07, + "max_abs_lateral_drift_m": 0.0021005570500782222, + "max_contact_count": 1, + "max_energy_gain_j": 3.0239960868705396e-08, + "max_penetration_m": 3.897957001619545e-08, + "min_final_height_m": 0.07999996101782567, + "runs": 7, + "travel_mean_m": 0.724349192807866, + "travel_spread_m": 0.003481078961123152, + "travel_spread_relative": 0.0048058022231364725 + }, + "fcl": { + "antisymmetry_residual_m": 4.6074255521944e-15, + "antisymmetry_residual_over_peak_drift": 2.1934290297198943e-12, + "max_abs_heading_error_deg": 1.4353389881463893e-07, + "max_abs_lateral_drift_m": 0.0021005582992501826, + "max_contact_count": 1, + "max_energy_gain_j": 3.0003008910650664e-08, + "max_penetration_m": 3.897957004395103e-08, + "min_final_height_m": 0.07999996101782565, + "runs": 7, + "travel_mean_m": 0.7243492146393409, + "travel_spread_m": 0.0034810846388725336, + "travel_spread_relative": 0.00480580991670681 + }, + "ode": { + "antisymmetry_residual_m": 1.5543122344752192e-15, + "antisymmetry_residual_over_peak_drift": 7.399524018723217e-13, + "max_abs_heading_error_deg": 1.3176791637633171e-07, + "max_abs_lateral_drift_m": 0.0021005570500782222, + "max_contact_count": 1, + "max_energy_gain_j": 3.0239960868705396e-08, + "max_penetration_m": 3.897957001619545e-08, + "min_final_height_m": 0.07999996101782567, + "runs": 7, + "travel_mean_m": 0.724349192807866, + "travel_spread_m": 0.003481078961123152, + "travel_spread_relative": 0.0048058022231364725 + } + }, + "validity_failures": [] + } + }, + "result": { + "claim_boundary": "release-6.20, this commit, one sphere sliding to rolling on a static ground box at v0=1 m/s, mu=0.35, dt=2 ms, 1 s horizon, default boxed-LCP constraint solver, launch angles 0-90 deg in 15 deg steps, detectors fcl, dart, bullet, ode. The claim reproduces on dart, fcl, ode, which show the antisymmetric friction-pyramid signature; excluded for exceeding the drift tolerance without that angular structure: bullet. Says nothing about other speeds, shapes, stacks, historical DART versions, or DART 7.", + "disposition": "reproduced", + "limitations": [ + "Per-solve LCP iteration counts, residuals, and Dantzig-vs-PGS fallback events are not exposed on release-6.20; they are typed unsupported in metrics.numerical rather than reported as zero, and solver identity is type-level readback.", + "bullet exceeds the drift tolerance without the antisymmetric pyramid signature (scatter exceeding tolerance without angular structure; not attributed to polyhedral friction by this packet); its scatter is NOT attributed to polyhedral friction by this packet and it is excluded from the reproducing set. Its per-angle statistics are in metrics.physical and the raw rows.", + "Detector groups dart, ode produce bit-identical trajectory hashes at every angle, so the sweep contains fewer independent measurements than detectors; see metrics.physical.identical_detector_groups.", + "The sweep covers 0-90 deg; pyramid orientation with a period other than 90 deg would need a wider sweep.", + "Detector availability depends on the build; the packet records the swept set explicitly.", + "This packet is not a cross-detector accuracy ranking; it only measures rotational-symmetry breaking per detector." + ] + }, + "review": { + "passes": [] + }, + "scene": { + "description": "One 1 kg sphere per run, radius 0.08 m, launched sliding (no spin) at 1.0 m/s along a swept in-plane angle on a static ground box, restitution 0, friction 0.35 on both bodies, default boxed-LCP constraint solver, one sweep per available collision detector.", + "digest": "sha256:94a8faf96f6afe7d4121e7007f0cb1c5ef4f9a84107f128a2f911499ac176c6b", + "id": "ct001_rolling_direction_sweep_dart6", + "parameters": { + "collision_detectors": [ + "fcl", + "dart", + "bullet", + "ode" + ], + "description": "One 1 kg sphere per run, radius 0.08 m, launched sliding (no spin) at 1.0 m/s along a swept in-plane angle on a static ground box, restitution 0, friction 0.35 on both bodies, default boxed-LCP constraint solver, one sweep per available collision detector.", + "deterministic_repeats": 2, + "friction": 0.35, + "gravity_mps2": [ + 0.0, + 0.0, + -9.81 + ], + "ground_half_extents_m": [ + 2.5, + 2.5, + 0.05 + ], + "launch_angles_deg": [ + 0.0, + 15.0, + 30.0, + 45.0, + 60.0, + 75.0, + 90.0 + ], + "launch_speed_mps": 1.0, + "restitution": 0.0, + "scene_id": "ct001_rolling_direction_sweep_dart6", + "slip_ratio_rolling_threshold": 0.02, + "sphere_mass_kg": 1.0, + "sphere_radius_m": 0.08, + "step_count": 500, + "time_step_s": 0.002 + } + }, + "schema": "dart.citation_claim_evidence/v1", + "source": { + "claim": "Polyhedral friction can produce direction-dependent rolling/sliding behavior.", + "url": "https://leggedrobotics.github.io/SimBenchmark/" + }, + "target": { + "branch": "release-6.20", + "commit": "c639a4122df57dd6cb1677d23ef27fd3c2a36819", + "fetch_hint": "git fetch origin pull/3444/head && git checkout c639a4122df57dd6cb1677d23ef27fd3c2a36819" + }, + "title": "Rolling-direction friction dependence (release-6.20 detector sweep)" +} diff --git a/docs/design/dart6_citation_driven_contact_trust/evidence/negative-controls/CT-019-dart6-intentionally-incomplete.expected-errors.json b/docs/design/dart6_citation_driven_contact_trust/evidence/negative-controls/CT-019-dart6-intentionally-incomplete.expected-errors.json new file mode 100644 index 0000000000000..ffee570db4f44 --- /dev/null +++ b/docs/design/dart6_citation_driven_contact_trust/evidence/negative-controls/CT-019-dart6-intentionally-incomplete.expected-errors.json @@ -0,0 +1,21 @@ +[ + "target.commit must be a 40-hex commit hash", + "target.fetch_hint must be the runnable durable PR-ref", + "scene.digest must match sha256:<64 hex>", + "scene.parameters must publish the non-empty parameter object", + "configuration.resolved must be a non-empty object", + "configuration.resolved_provenance must name", + "configuration.detector must be a non-empty string", + "configuration.fallback_policy must be a non-empty string", + "single runs are not evidence", + "ensemble.measurement_window is required", + "metrics.physical must record a non-empty measurement 'method'", + "reports exact zero at ['contact_force_n', 'wrench_sign_flips']", + "metrics.numerical is unsupported but has no non-empty reason", + "reports exact zero at ['wall_time_ms']", + "evidence.commands must be a non-empty list of commands", + "evidence must carry raw_rows inline or non-empty raw_paths", + "evidence.visual must list visual artifacts or be typed", + "result.claim_boundary must be a non-empty string", + "result.limitations must be a non-empty list" +] diff --git a/docs/design/dart6_citation_driven_contact_trust/evidence/negative-controls/CT-019-dart6-intentionally-incomplete.json b/docs/design/dart6_citation_driven_contact_trust/evidence/negative-controls/CT-019-dart6-intentionally-incomplete.json new file mode 100644 index 0000000000000..7451cc22d32ac --- /dev/null +++ b/docs/design/dart6_citation_driven_contact_trust/evidence/negative-controls/CT-019-dart6-intentionally-incomplete.json @@ -0,0 +1,55 @@ +{ + "schema": "dart.citation_claim_evidence/v1", + "claim_id": "CT-019", + "title": "Intentionally incomplete negative control: this packet must FAIL check_citation_evidence forever; it proves the release-6.20 validator rejects missing detector/solver identity, missing scene digest, single-run ensembles, unsupported-as-zero metrics, and prose-only closure", + "source": { + "url": "https://github.com/dartsim/dart/issues/1425", + "claim": "Contact normal, object ordering, force sign, frame, and wrench ownership must be unambiguous downstream." + }, + "target": { + "branch": "release-6.20", + "commit": "HEAD" + }, + "scene": { + "id": "ct019_pair_order_swap", + "description": "Two boxes described only in prose, with no digest and no detector identity." + }, + "configuration": { + "requested": { + "collision_detector": "fcl" + }, + "timestep": 0.001 + }, + "ensemble": { + "kind": "single-run", + "deterministic_repeats": 1 + }, + "metrics": { + "physical": { + "contact_force_n": 0.0, + "wrench_sign_flips": 0.0 + }, + "numerical": { + "status": "unsupported" + }, + "performance": { + "wall_time_ms": 0.0 + }, + "allocation": { + "status": "unsupported", + "reason": "not measured" + } + }, + "evidence": { + "commands": [], + "raw_paths": [], + "visual": [] + }, + "result": { + "disposition": "reproduced", + "limitations": [] + }, + "review": { + "passes": [] + } +} diff --git a/docs/dev_tasks/dart6_citation_contact_trust/README.md b/docs/dev_tasks/dart6_citation_contact_trust/README.md new file mode 100644 index 0000000000000..4d54e2b59029a --- /dev/null +++ b/docs/dev_tasks/dart6_citation_contact_trust/README.md @@ -0,0 +1,102 @@ +# DART 6 Citation-Driven Contact Trust — Dev Task + +## Current status + +- [x] Phase 0: Reconcile with current `release-6.20`, PLAN-621/622, open PRs, + issues, and completed/retired task state (2026-08-14: PLAN-623 free and + registered on the dashboard; PLAN-621 owns CT-018 with #3056/#3428; + PLAN-622/PR #3431 own CT-020; PR #3377 owns exact-Coulomb fixtures; + `dart.dynamics.ContactInverseDynamics` already exists on this branch). +- [x] Phase 1: Adopt the stable claim/evidence schema without DART 7 API + backports (branch-local `claims-manifest.json` + fail-closed + `pixi run check-citation-evidence` in `check-lint`, permanent negative + control, pytest coverage, first CT-001 detector-sweep packet). +- [ ] Phase 2: Audit and guard existing performance/deformable evidence. +- [ ] Phase 3: Reproduce remaining first-wave DART 6 contact claims. +- [ ] Phase 4: Land compatibility-safe fixes/diagnostics for confirmed defects. +- [ ] Phase 5: Promote durable results and delete this task folder. + +## Goal + +Produce a branch-qualified, reproducible answer for material claims about DART +6 contact, force/wrench interpretation, stability, determinism, and performance +without changing the DART 6.20 compatibility contract. + +## Required reading + +- branch `AGENTS.md` +- `docs/ai/principles.md` +- `docs/ai/verification.md` +- `docs/plans/dashboard.md` +- `docs/design/dart6_collision_backends.md` +- `docs/design/dart6_citation_driven_contact_trust.md` +- `docs/dev_tasks/dart6_performance_generalization/` if present +- `docs/dev_tasks/dart6_deformable_body_performance/` if present +- DART 7 PLAN-123/design/corpus as reference evidence only +- this folder, especially `RESUME.md` + +## Specification intake + +- **Value:** protect LTS users/downstreams, close historical claims honestly, + and feed branch-qualified evidence into DART 7 decisions. +- **Scope:** tests, benchmarks, demos, evidence packets, analysis helpers, + internal/opt-in diagnostics, and narrowly proven fixes. +- **Assumptions:** C++17/pybind11/OSG, ABI, packages, default FCL behavior, and + gz compatibility remain fixed. +- **Traceability:** PLAN-123 corpus IDs, PLAN-621/622, DART issues/PRs, primary + sources, and current branch tests/benchmarks. +- **Acceptance evidence:** baseline/current packets, negative controls, + deterministic/ensemble oracles, downstream tests, and review records. + +## Work rules + +- Work from latest `origin/release-6.20` on a non-tracking topic branch. +- Keep a separate DART 7 worktree/branch for shared bug assessment. +- Do not copy DART 7 code or APIs mechanically. +- Reuse PLAN-621/622 scenes/evidence; reference rather than duplicate. +- Preserve unaffected default outputs and explicitly re-baseline affected rows. +- Unsupported metrics are absent/typed as unsupported, never zero. +- Do not start a new solver family or public contact architecture. +- Do not push or mutate GitHub state without explicit approval. + +## Deliverables + +1. DART 6 entries for the stable claim/evidence manifest. +2. Guarded current evidence for completed performance/deformable rows. +3. First-wave contact fixtures not already owned elsewhere. +4. Contact normal/order/frame and force/wrench interpretation regressions. +5. Compatibility-safe fixes with baseline/current evidence. +6. Downstream gz verification for affected paths. +7. Durable documentation and task cleanup. + +## Non-goals + +- Exact-cone/NCP, IPC, AVBD/VBD, differentiability, batch World, sensors, + biomechanics, rods, or shells. +- Default detector/solver changes. +- Dependency or public-layout cleanup. +- Public force-semantics redesign. +- Universal cross-version claims. + +## Gates + +Use current branch task names. Typical required gates: + +- `pixi run lint` +- `pixi run check-lint` +- focused configure/build/tests +- `pixi run test-all` +- detector/solver/demos benchmark commands with raw evidence +- `pixi run -e gazebo test-gz` for collision, constraint, `World::step`, + parser, package, or downstream-sensitive changes +- ABI/header/component checks when relevant +- visual capture and semantic review when the claim is visible + +## Immediate next steps + +1. Audit current PLAN-621/622 and open PR state. +2. Map PLAN-123 corpus rows to existing DART 6 tests/evidence. +3. Select the smallest missing row that can provide a negative control. +4. Add a branch-local manifest packet and validator/adaptor without public API + change. +5. Update `RESUME.md`, `decisions.md`, and `verification.md`. diff --git a/docs/dev_tasks/dart6_citation_contact_trust/RESUME.md b/docs/dev_tasks/dart6_citation_contact_trust/RESUME.md new file mode 100644 index 0000000000000..b1fff0bdbaa9d --- /dev/null +++ b/docs/dev_tasks/dart6_citation_contact_trust/RESUME.md @@ -0,0 +1,83 @@ +# Resume: DART 6 Citation-Driven Contact Trust + +## Current reality + +Reconciled against a live checkout on 2026-08-14; bootstrap statuses verified. + +## Last session summary + +- Phase 0 audit done against `release-6.20` 39ccd52068b: PLAN-623 confirmed + free and added to `docs/plans/dashboard.md`; PLAN-621 owns CT-018 (issue + #3056 open, PR #3428 active); PLAN-622/PR #3431 own the CT-020 + perturbation-ensemble methodology; PR #3377 (research lane) owns + exact-Coulomb FBF fixtures; `dart.dynamics.ContactInverseDynamics` already + exists on this branch (existing-API note for CT-009). +- Phase 1 landed: branch-local + `docs/design/dart6_citation_driven_contact_trust/claims-manifest.json` + (20 rows, dart6 lane only, corpus_reference to the `main` corpus), + fail-closed `scripts/check_citation_evidence.py` + (`pixi run check-citation-evidence`, wired into `check-lint`), + `tests/test_check_citation_evidence.py`, and a permanent negative-control + packet. +- First complete packet: CT-001 rolling-direction detector sweep via + `scripts/write_citation_ct001_rolling_direction_packet.py` (classic + pybind11 API, default boxed-LCP solver, detectors swept per build + availability, deterministic repeats). + +## Current branch + +`feature/dart6-citation-contact-trust` in +`.claude/worktrees/citation-trust-620`, based on `origin/release-6.20` +39ccd52068b, at `978b48e6653` (7 commits: contract + CT-001 packet, three +review-fix passes, and the PR draft). `check-lint`, `test-all` (158/158), +and the citation gate are green on this head. Local only; nothing pushed. +GitHub mutations need maintainer approval. + +## Immediate next step + +PR opened with maintainer approval on 2026-08-15: #3444 (base release-6.20, +milestone DART 6.20.0), after merging the moved base and a clean-rebuild +test-all (the lockfile bump upgraded gtest and exposed stale-object link +errors; a clean rebuild resolved them). Shepherd it through CI and Codex +review (re-request '@codex review' after every push). After +the PR, Phase 2 continues with auditing/guarding PLAN-621/622 evidence rows +and the next first-wave contact rows not owned elsewhere (dense +inelastic/elastic grids, CT-002/CT-003) on a new branch. + +## Context that would be lost + +- The `main` PLAN-123 manifest keeps dart6 lanes as routing pointers; THIS + branch manifest owns `release-6.20` lane status/dispositions. Promotion + back to the `main` manifest happens explicitly at sync points, never + automatically. +- Per-solve LCP iterations/residuals and Dantzig-vs-PGS fallback events are + not exposed on this branch; packets type them unsupported rather than + fabricating zeros. Adding opt-in diagnostics is future Phase 4 work under + the compatibility contract. +- dartpy needs `pixi run build-py-dev` + (`PYTHONPATH=build/default/cpp/Release/python` for scripts); plain + `pixi run build` does not build dartpy on this branch. +- FreeJoint launch velocities: generalized velocities `[w(3), v(3)]`; the + writer asserts the `getLinearVelocity()` readback matches the requested + launch vector. + +## How to resume + +```bash +git worktree list +cd .claude/worktrees/citation-trust-620 && git status && git log -3 --oneline +pixi run check-citation-evidence +``` + +Then continue per the README status checkboxes. + +## Minimum verification for the next slice + +```bash +pixi run lint +pixi run check-lint +git diff --check +``` + +Add focused tests and `pixi run -e gazebo test-gz` as soon as behavior or a +downstream-sensitive path changes. diff --git a/docs/dev_tasks/dart6_citation_contact_trust/decisions.md b/docs/dev_tasks/dart6_citation_contact_trust/decisions.md new file mode 100644 index 0000000000000..4a9cee328d033 --- /dev/null +++ b/docs/dev_tasks/dart6_citation_contact_trust/decisions.md @@ -0,0 +1,90 @@ +# Decisions: DART 6 Citation-Driven Contact Trust + +## 2026-08-14 — Branch manifest owns release-6.20 lane state + +**Decision:** This branch's `claims-manifest.json` (design-doc sidecar) is the +single owner of `release-6.20` lane status, dispositions, and packets. The +`main` PLAN-123 manifest keeps its dart6 lanes as routing pointers and mirrors +promoted results only at explicit sync points. + +**Why:** Branches cannot share one mutable file; dual live owners would drift. +The corpus (claim IDs, titles, bounded claims) stays owned on `main` and is +referenced by `corpus_reference`, so row identity cannot fork. + +**Revisit when:** A release process needs automated cross-branch mirroring. + +## 2026-08-14 — Evidence lives in the design sidecar, not the dev task + +**Decision:** The manifest and packets live under +`docs/design/dart6_citation_driven_contact_trust/`; the dev-task folder holds +only working state and is deleted at completion. + +**Why:** Dev-task folders are removed in the completing PR; evidence packets +are durable regression guards and must survive that cleanup. + +**Revisit when:** Never for this task; new evidence types choose their durable +owner the same way. + +## 2026-08-14 — Unsupported solver internals stay typed unsupported + +**Decision:** Per-solve LCP iterations, residuals, and Dantzig-vs-PGS fallback +events are recorded as typed `unsupported` metrics on this branch instead of +zeros or library changes; opt-in diagnostics may come later under the Phase 4 +compatibility rules. + +**Why:** The LTS contract forbids speculative instrumentation, and the +north-star rule forbids encoding unsupported as numeric zero. + +**Revisit when:** A confirmed defect needs per-solve visibility; then design +the smallest opt-in diagnostic with layout/vtable preservation. + +## 2026-08-14 — Maintenance/evidence lane only + +**Decision:** DART 6 reproduces claims, guards current behavior, and fixes +confirmed defects without receiving DART 7 solver/model/contact architecture. + +**Why:** `release-6.20` is an ABI/default/downstream compatibility line. + +**Revisit when:** A maintainer authorizes a new active LTS architecture or +release boundary. + +## 2026-08-14 — Existing PLAN-621/622 owners win + +**Decision:** A corpus row already owned by performance-generalization or +deformable work remains there; this task references its evidence. + +**Why:** Duplicate task graphs create stale contradictory handoffs. + +**Revisit when:** Those tasks complete and their durable evidence owner changes. + +## 2026-08-14 — Legacy force fields are measured, not silently redefined + +**Decision:** Tests and analysis distinguish collision geometry, impulses, +legacy force/wrench outputs, and derived averages/filters. Released semantics +are not changed implicitly. + +**Why:** Downstream Gazebo and user code rely on the current data contract. + +**Revisit when:** An additive API is proven ABI-safe and needed by multiple +DART 6 consumers. + +## 2026-08-14 — No new solver family + +**Decision:** Exact-cone, IPC, AVBD/VBD, differentiable, and batch architecture +are DART 7-only. DART 6 may improve existing backends/solvers after a negative +control. + +**Why:** New solver architecture is incompatible with LTS scope and would +duplicate DART 7. + +**Revisit when:** Never for 6.20 absent explicit maintainer direction. + +## 2026-08-14 — Downstream validation is part of correctness + +**Decision:** Collision, constraint, `World::step`, parser, component, or +default-sensitive changes require gz-physics/gz-sim evidence, not only DART +tests. + +**Why:** Gazebo integration is a primary DART 6 compatibility consumer. + +**Revisit when:** The maintained downstream contract changes. diff --git a/docs/dev_tasks/dart6_citation_contact_trust/pr-draft-620.md b/docs/dev_tasks/dart6_citation_contact_trust/pr-draft-620.md new file mode 100644 index 0000000000000..ead908fa90deb --- /dev/null +++ b/docs/dev_tasks/dart6_citation_contact_trust/pr-draft-620.md @@ -0,0 +1,86 @@ +# PR draft: release-6.20 citation contact trust + +Status: OPENED as https://github.com/dartsim/dart/pull/3444 on 2026-08-15 +with maintainer approval; this file is the posted body's source. Branch `feature/dart6-citation-contact-trust`, base +`release-6.20`, milestone DART 6.20.0. Delete this file with the dev-task +folder at task completion. + +--- + +Title: `Add the PLAN-623 branch-local citation evidence contract and the +CT-001 detector-sweep packet` + +## Summary + +- Adopts the citation-trust evidence contract on the LTS branch as additive + tooling only: a branch-local claims manifest, the fail-closed + `pixi run check-citation-evidence` gate wired into `check-lint`, a + permanent negative control, 65 pytest cases, and the first `release-6.20` + evidence packet — a CT-001 rolling-direction sweep across the fcl, dart, + bullet, and ode collision detectors. +- No library, API, ABI, default, packaging, or downstream-visible change: + the diff touches only `docs/`, `scripts/`, `tests/`, `pixi.toml`, and + `CHANGELOG.md`, verified by independent review. + +## Motivation / Problem + +- Historical claims about DART 6 contact behavior had no branch-qualified, + reproducible dispositions on the maintained LTS line. PLAN-623 + (docs/design/dart6_citation_driven_contact_trust.md, dashboard entry + added here) gives them stable rows and fail-closed packets while + preserving the compatibility contract; claim identity stays owned by the + `main` corpus via `corpus_reference`, so rows cannot fork across branches. + +## Changes / Key Changes + +- Branch-local `claims-manifest.json` (single `dart6` lane per row) under + the design-doc sidecar, so evidence survives dev-task cleanup. +- `scripts/check_citation_evidence.py` — the same fail-closed packet + contract as `main`, adapted to `release-6.20` lane ownership; in + `check-lint`; permanent negative control; 65 tests in `test-ai-infra` + (the pinned task list in `scripts/check_ai_infrastructure.py` is extended + in the same change, which is how that guard is designed to grow). +- CT-001 packet: one sphere launched sliding across a swept in-plane angle, + default boxed-LCP solver, per-run detector readback asserted, two + bit-identical repeats per cell. The claim reproduces on fcl, dart, and ode + (lateral drift 2.1e-3 m with the antisymmetric friction-pyramid + signature: nulls at 0/45/90 deg, antisymmetry residual ~1e-15 of peak); + bullet exceeds the drift tolerance without that angular structure + (residual 1.79x its own peak, largest drift near 45 deg where the + mechanism predicts zero) and is excluded from the reproducing set rather + than counted as a fourth corroboration. dart and ode are bit-identical at + every angle and the packet says so. +- Per-solve LCP iterations, residuals, and Dantzig-vs-PGS fallback events do + not exist as public API on this branch; they are typed + `{status: unsupported, reason}` rather than reported as zeros. + +## Testing + +- `pixi run check-lint` (includes the new gate) and `pixi run test-all` — + 158/158 tests pass on the branch head. +- `pixi run python -I scripts/run_pytest.py + tests/test_check_citation_evidence.py -q` — 65 passed. +- `pixi run -e gazebo test-gz` not triggered: the diff has no collision, + constraint, `World::step`, parser, package, or downstream-sensitive + change (documented in the dev-task verification log with the file list). +- Three rounds of independent role-separated review; all findings fixed + in-branch and recorded, including two corrections to this branch's own + records (an imported wrong-branch verification block and a wrong + largest-drift angle). + +## Breaking Changes + +- [x] None (additive docs/scripts/tests/pixi only; LTS contract preserved). + +## Related Issues / PRs (backports) + +- Companion `main` PR: branch `feature/citation-trust-foundation` + (independent; owns the corpus, PLAN-123, and the DART 7 packets). + +#### Checklist + +- [ ] Milestone DART 6.20.0 +- [x] CHANGELOG.md updated (DART 6.20.0 "Tests") +- [x] Unit tests added (validator suite) +- [x] No new public methods/classes (docs in script docstrings) +- [x] Python bindings not applicable (no API change) diff --git a/docs/dev_tasks/dart6_citation_contact_trust/verification.md b/docs/dev_tasks/dart6_citation_contact_trust/verification.md new file mode 100644 index 0000000000000..1bfe4f103d6e7 --- /dev/null +++ b/docs/dev_tasks/dart6_citation_contact_trust/verification.md @@ -0,0 +1,471 @@ +# Verification: DART 6 Citation-Driven Contact Trust + +## Phase 0 + Phase 1 slice — 2026-08-14 + +- Branch: `feature/dart6-citation-contact-trust` from `origin/release-6.20` + 39ccd52068b in `.claude/worktrees/citation-trust-620`; worktree started + clean. +- What changed: PLAN-623 dashboard entry; branch-local + `docs/design/dart6_citation_driven_contact_trust/claims-manifest.json`; + fail-closed `scripts/check_citation_evidence.py` (task + `check-citation-evidence`, wired into `check-lint`); + `tests/test_check_citation_evidence.py` (registered in `test-ai-infra`); + permanent negative control under `evidence/negative-controls/`; + `scripts/write_citation_ct001_rolling_direction_packet.py` and the CT-001 + packet; CHANGELOG entry under DART 6.20.0 Tests. +- Commands and results: + - `pixi run build` — success; `pixi run build-py-dev` — success (dartpy + with fcl, dart, bullet, and ode detectors bound). + - `PYTHONPATH=build/default/cpp/Release/python/dartpy pixi run python + scripts/write_citation_ct001_rolling_direction_packet.py` — wrote the + packet; per-cell deterministic repeats identical (56 runs); + requested-vs-readback detector identity asserted per run. + - `pixi run lint` — clean; `pixi run check-lint` (full aggregate) — exit 0. + - `pixi run check-citation-evidence` — OK. + - `pixi run python -I scripts/run_pytest.py + tests/test_check_citation_evidence.py -q` — 48 passed. +- Measured (this commit, boxed-LCP default, sphere slide-to-roll sweep): + fcl/dart/ode max |lateral drift| 2.101e-3 m and relative travel spread + 4.806e-3; bullet 6.696e-4 m and 9.236e-4; heading errors <= 1.6e-4 deg; + min final sphere height 0.0800 m (no fall-through). Only fcl, dart, and ode + carry the antisymmetric friction-pyramid signature; bullet exceeds the + drift tolerance without angular structure (its 45 deg drift is 90% of its + largest, where + the mechanism predicts zero) and is explicitly excluded from the + reproducing set. dart and ode are bit-identical, so the sweep holds three + distinct trajectory sets, not four. (fcl still agrees with dart/ode to ~6e-7 relative, and the friction pyramid under test lives in the shared BoxedLcpConstraintSolver, so the detectors are not independent implementations of the mechanism.) Disposition `reproduced` for fcl/dart/ode + under a validity gate (rolling speed 5/7 v0, no fall-through, bounded + penetration) plus an explicit claim boundary. +- Negative control: permanent intentionally incomplete packet fails with + >= 3 errors; the gate rejects a passing negative control as vacuous. +- Performance/allocation: typed unsupported (no timing/allocation claims). +- Visual evidence: typed not-applicable (numeric rotational-symmetry + oracle). +- ABI/compatibility: additive tooling/docs/tests only; no library, header, + component, default, or downstream-visible change, so + `pixi run -e gazebo test-gz` is not triggered by this slice. +- Review passes (two independent, role-separated, on commit 1189155fe62): + a tooling/LTS-compatibility pass and a physics/evidence-honesty pass. Both + returned "not clean"; findings verified against source before acting: + 1. MAJOR — the validator's non-recursive `evidence/*.json` enumeration let + a lane close a row with a file the packet checks never reached + (reproduced: a lane pointing at `evidence/sub/p.json` containing + `{"schema": "WRONG"}` produced zero errors). Fixed by validating every + lane-referenced path wherever it sits and rejecting non-JSON, outside, + and negative-control references. + 2. MAJOR — bullet was listed as symmetry-breaking on scatter with no + angular structure. Measured antisymmetry residual relative to each + detector's own peak drift: fcl 4.607e-15 / 2.101e-3 = 0.000, dart and + ode 1.554e-15 / 2.101e-3 = 0.000, bullet 1.201e-3 / 6.697e-4 = 1.793 — + bullet's asymmetry exceeds its own signal, its largest drift sits at + 45 deg, where the mechanism predicts zero, is 90% of its largest, and + its penetration/energy + gain are ~1000x/~6000x the others. The packet now applies that + antisymmetry-signature test (threshold 5% of peak) and excludes bullet + from the reproducing set with an explicit limitation. + 3. MAJOR — the disposition had no physical-validity gate, so a degenerate + run would have strengthened "reproduced". A validity gate (rolling speed + 5/7 v0, rolling actually reached, no fall-through, bounded penetration) + now forces `unresolved` if any cell degenerates. + 4. MINOR — dart and ode are bit-identical at every angle; recorded in + `metrics.physical.identical_detector_groups` and in the limitations, so + the sweep is not read as four independent measurements. + 5. MINOR — `raw_paths` existence unchecked, string metric leaves accepted, + unsupported LCP internals carried as free prose rather than typed + markers. All fixed; per-solve iterations, residual, and fallback events + are now typed `unsupported` with branch-specific reasons. + Compatibility was reviewed clean: the diff touches only `docs/`, `scripts/`, + `tests/`, `pixi.toml`, and `CHANGELOG.md`, with nothing under `dart/`, + `python/dartpy/`, `cmake/`, or packaging. +- Round-2 verification (post-fix, independent, on this branch): confirmed the + lane-reference bypass is closed (nested non-packet, negative control, + non-JSON, outside-evidence, and shared-owner references all rejected, with + the real tree validating at zero errors as a control); confirmed every + antisymmetry number in the packet reproduces exactly from its own + `raw_rows`; confirmed the disposition validity gate is real by driving + `build_packet()` against a stubbed dartpy (each of the four conditions + independently forces `unresolved`); confirmed the typed-unsupported reasons + are factually true for `release-6.20` (no `getNumIterations`- or + `getResidual`-style accessor exists anywhere in `dart/constraint/`; + `BoxedLcpSolver::solve()` returns a bare `bool` and + `PgsBoxedLcpSolver::Option::mMaxIteration` is an input budget, not an + achieved count); and confirmed the LTS compatibility gate (16 changed + files, none under `dart/`, `python/`, `cmake/`, `CMakeLists.txt`, + packaging, `.github/`, or `pixi.lock`). +- Correction (2026-08-14): an earlier draft of this block described + `recordSolverDiagnostics`, an AVBD fall-through, and a + `solver_iterations_by_method` helper. Those belong to the DART 7 `main` + lane; none of that code exists on `release-6.20`, where + `scripts/citation_packet_utils.py` is four static markers with no + conditional logic. The text was imported in error and has been replaced by + the branch-local record above. AVBD is explicitly out of scope here (see + `decisions.md` and the branch design doc). +- Correction (2026-08-14): bullet's largest lateral drift is at 75 deg + (6.696e-4 m), not 45 deg. Its 45 deg drift is 6.004e-4 m -- 90% of its + largest, where the mechanism predicts exactly zero -- which is still a + failure of the pyramid signature, and the exclusion rests on the 1.79 + antisymmetry ratio regardless. The packet limitation and this record now + state it correctly. +- Correction (2026-08-14): the commit message of `afc6d7ac3c2` claimed two + changes it did not make on this branch -- "scope the iteration marker" and + deriving `deterministic_repeats_identical` from the comparison. Both were + DART 7 changes; on `release-6.20` the marker file is static and the field + was already derived by the preceding commit. Recorded here rather than + rewritten, since the branch is unpushed but the log should not be silently + edited to hide the overclaim. +- Note: `deterministic_repeats_identical` is structurally always `true`, + because a hash mismatch raises `SystemExit` before the packet is built. It + is a fail-closed guarantee, not a measurement; the independently checkable + evidence is `raw_rows[*].repeat_trajectory_sha256`. +- Follow-up applied: the disposition validity gate now also fails a run whose + per-step kinetic-energy gain exceeds a thousandth of the launch energy + (5.0e-4 J here). Measured: bullet 1.925e-4 J, fcl/dart/ode ~3.0e-8 J, so no + cell trips it, but a blow-up can no longer pass the gate while a symmetry + verdict is drawn. +- Final review (independent, on `d4f95f1298f`): compatibility gate passes + (16 changed files, none under `dart/`, `python/`, `cmake/`, `CMakeLists.txt`, + packaging, `.github/`, or `pixi.lock`); 37/37 bypass attempts rejected with + the unmodified tree validating at zero errors as a control; every + antisymmetry number reproduces exactly from `raw_rows`; the typed-unsupported + reasons re-verified against `dart/constraint/` on this head. Findings acted + on: + 1. MAJOR — the packet attested to assertions and a `target.commit` its + recorded run predated, because writer changes landed without regenerating. + The packet has been regenerated on the current writer. Its `raw_rows` are + byte-identical across regenerations because the fixture is deterministic, + which the packet itself evidences through + `raw_rows[*].repeat_trajectory_sha256`. + 2. MINOR — `raw_paths` accepted a directory (`.exists()`), negative controls + in a subdirectory were enumerated by neither loop, a non-string entry in a + lane's `evidence` list was silently dropped (a genuine bypass: a lane could + be closed by `[{"path": ...}]`), and `scene.digest` was format-checked but + never recomputed. All four are fixed and covered by new tests (52 cases). + 3. Residual gap recorded, not fixed: the validator performs no arithmetic + cross-check of derived metrics against `raw_rows`, so a falsified summary + would pass. That is a WS4-scale change (the gate would need to know each + packet's derivation) and is listed as follow-up rather than attempted here. +- Known gaps: Phase 2 (guard PLAN-621/622 evidence) and remaining first-wave + rows; per-solve LCP diagnostics remain typed unsupported. + +## Bootstrap record — 2026-08-14 + +### What changed + +Documentation package only: + +- DART 6 compatibility/evidence design; +- branch-local active task contract; +- dashboard snippet and cross-branch goal prompt. + +### What is not claimed + +- No live DART 6 checkout was edited. +- No build, test, benchmark, visual, ABI, package, or Gazebo gate ran. +- No branch/commit/PR/issue/GitHub state changed. +- Corpus dispositions and plan ID require current audit. + +### Required integration checks + +```bash +pixi run lint +pixi run check-lint +git diff --check +``` + +Use additional docs policy/spelling tasks present on the current branch. + +### Required behavioral record + +For every later slice record: + +- exact release base and candidate head; +- corpus row and branch-qualified claim; +- negative-control baseline; +- detector/solver/timestep/threads/seed/window; +- state/contact/rest hashes and explicit re-baselines; +- raw metric and timing packet; +- allocation and determinism result; +- ABI/header/component/package assessment; +- Gazebo/gz command and result when affected; +- visual evidence and semantic review when applicable; +- independent reviews, limitations, changelog decision, and next step. + +## Codex review round 1 (PR #3444) — 2026-08-16 + +Codex posted 4 reviews with 15 inline findings (P1/P2); all addressed in +this round, fixes silent per repo convention (no thread replies): + +- Validator fail-closed gaps (ported identically to `main`): closed-lane + disposition must match the packet's `result.disposition`; the two-review + floor requires DISTINCT reviewers; review passes must carry a + `content_digest` binding them to the packet content they reviewed + (regeneration invalidates); `raw_rows` entries must be non-empty + structured records; `raw_paths`/`visual` paths must be relative, + non-escaping, and resolve inside approved roots; sweep/seed ensembles + need valid DISTINCT entries; string metric leaves are allowed only under + semantic keys (prose can no longer masquerade as a measurement); + `configuration.requested/resolved` must carry a recognizable identity + key with no null values; NaN/Infinity are rejected at JSON load; lane + evidence paths are normalized before indexing (no dual-spelling + owner/review bypass) and may not escape the sidecar; + `corpus_reference` is pinned to the canonical DART 7 corpus path and + branch; `target.fetch_hint` is required so target commits stay + reproducible after squash-merge (PR head refs survive). +- `preserve_review` now binds carried passes to the regenerated packet's + content digest (stale reviews are dropped, failing closed); + `record_review_pass` added for digest-bound review recording. +- CT-001 writer: the energy-gain gate now starts from the launched + (pre-step) state so the first contact solve is covered, and the claim + boundary/limitations are derived from the computed detector sets + (`anisotropic_detectors`, `nonconforming_detectors`, + `identical_detector_groups`) instead of hardcoding one historical run's + bullet statistics. Packet regenerated: disposition `reproduced` + unchanged; bullet still excluded, now by computed criteria. +- Design doc: the ordered "Prioritize" list moved out of the durable + document (ordering is mutable task state; the doc now records the row + set and rationale only), and the document was added to the + `docs/design/README.md` index. +- Validator test suite: 52 -> 65 cases (every new rejection has a test; + closed-lane tests rewritten for digest-bound, distinct-reviewer, + disposition-matching closure). +- `pixi run check-citation-evidence` passes on the real tree; the + permanent negative control keeps failing (error count grew under the + stricter rules). + +## Codex review round 2 (PR #3444) — 2026-08-16 + +Round 2 re-tested the round-1 fixes adversarially; 4 new findings, all +fixed (rules mirrored on main): scene.parameters required (a digest +without published content binds nothing); exhaustive metric-leaf type +chain with booleans accepted explicitly as measured findings; reviewer +identities normalized (strip+casefold) before the distinct-reviewer +closure count; visual entries must carry a recognized media suffix. The +CT-001 packet was regenerated at the round-2 commit `1a811de5bbb` so its +recorded command runs the current writer at its recorded, fetchable +target. Disposition unchanged (reproduced). Tests: 65 -> 69; tree +validates; negative control still fails. + +## Codex review round 3 (PR #3444) — 2026-08-16 + +Three findings, all fixed on both branches: `measurement_window` is +structurally validated (non-empty string, or a non-empty object of finite +numeric values with `start_s <= end_s`; truthy placeholders rejected); +visual artifacts are verified by media SIGNATURE, not filename — a prose +file renamed to `capture.png` is rejected by magic-byte check +(PNG/JPEG/GIF/WebP/SVG/MP4/WebM); configuration identity values must be +non-empty strings. Review passes additionally require an explicit +`verdict: "pass"` (mirrored from the main round-3 finding). Tests: +69 -> 73. + +## Codex review round 4 (PR #3444) — 2026-08-16 + +Two findings, both fixed: not-applicable lanes can no longer publish a +disposition or hold evidence packets (an early `continue` had skipped all +consistency checks for them), and the Windows `check-lint` task variant +now includes `check-citation-evidence` so the gate runs on the Windows CI +lane too. Shared round-4 rules mirrored from main: metadata-suffixed keys +(method_note, backend_reason) no longer count as configuration +identities; raw rows and metric groups must carry at least one +numeric/boolean measurement or typed-unsupported marker — metadata-only +records cannot satisfy the raw-evidence or measured-group requirements. +Tests: 73 -> 77. + +## Codex review round 5 (PR #3444) — 2026-08-16 + +Four findings, all fixed (mirrored on main): asserted deterministic +repeats require the recorded `deterministic_repeats_identical: true` +verification flag; measurement windows must be structured numeric objects +(prose strings rejected); visual artifacts are validated as structurally +complete media containers (begin and end markers plus minimum size; full +decode would need an image dependency — recorded boundary); fetch hints +must match the durable PR-ref fetch form (reachability is guaranteed by +GitHub PR head refs, checked at write time via --freshness — recorded +boundary). Identity keys also match whole tokens now. Tests: 77 -> 81. + +## Codex review round 6 (PR #3444) — 2026-08-16 + +Two findings here plus four shared, all fixed (mirrored with main): +exact-form fetch hints (fullmatch of the fetch-and-checkout command); +named measurement-window bounds; placeholder identity values rejected; +header+tail media reads (no false truncation above 8 MiB); negative +controls pinned defect-by-defect via .expected-errors.json sidecars +(CT-019 sidecar added). Tests: 81 -> 84. + +## Codex review round 7 (PR #3444) — 2026-08-16 + +Three findings here plus four shared, all fixed (mirrored with main): +runnable fetch hints embedding the packet's own commit; orphan sidecars +no longer count as negative controls; step-window bounds validated as +sane integers; reproducible-form commands; hash-bound repeat claims; +bookkeeping-only rows rejected. The CT-001 hint was migrated in place +(content-equal to the updated writer's emission). Tests: 84 -> 90. + +## Codex review round 8 (PR #3444) — 2026-08-16 + +Three findings here plus three shared, all fixed (mirrored with main): +open-lane dispositions must match their packets (null allowed, +contradiction not); shell tails rejected in reproduction commands; +duplicate JSON keys rejected at load; raw-data suffix whitelist for +raw_paths; digest-shaped hash values; http(s) source URLs. Tests: +90 -> 96. + +## Codex review round 9 (PR #3444) — 2026-08-16 + +Three findings here plus the shared newline defect, all fixed (mirrored +with main): newline-proof command validation (a genuine round-8 regex +defect); per-repeat hash lists for repeat counts above 2; structural +parsing of raw-data artifacts. Tests: 96 -> 100. + +## Codex review round 10 (PR #3444) — 2026-08-16 + +Two findings here plus two shared, all fixed (mirrored with main): +declared sweep/seed points must be matched by recorded rows; the broken +DART_CITATION_PR override removed (ownership transfer updates writer +and validator constants together); artifact_digests bind path-based +evidence bytes; the CT-011 full-state instrument fix is main-only (no +6.20 restore packet). Tests: 100 -> 102. + +## Codex review round 11 (PR #3444) — 2026-08-16 + +Three findings here plus two shared, all fixed (mirrored with main): +clean-tree enforcement in writers; sweep-point/seed observation +binding; JSON artifact content requirements; and the instrument sweep +fixed this branch's CT-001 NaN-blindness (validity gates would pass a +NaN trajectory since NaN comparisons are False — rows now carry an +explicit full-state finite flag gated in validity_failures). CT-001 +regenerated at the round-11 commit. Tests: 102 -> 105. + +## Codex review round 12 (PR #3444) — 2026-08-16, loop checkpoint + +Three findings here plus two shared, all fixed (mirrored with main): +scalar sweep-point observation binding; sweep/seed ensembles require +inline rows; required host provenance with explicit performance_valid; +numeric-content CSV validation; WebP/MP4 container consistency. The +review loop pauses at this checkpoint for the maintainer's decision; +every posted finding through round 12 is addressed and pushed. Tests: +105 -> 110. + +## Pre-merge gate at the loop checkpoint — 2026-08-16 + +Full `pixi run test-all` on the checkpoint head `886558f28f1`: exit 0, +all phases pass (158/158). This is the pre-merge evidence for PR #3444; +the merge decision (or a continue-the-loop call) rests with the +maintainer. + +## Codex review round 13 (PR #3444) — 2026-08-16, loop resumed by maintainer + +Two findings here plus three shared, all fixed (mirrored with main): +structural NPY/NPZ/parquet parsing; WebM DocType verification; +NFKC-normalized reviewer identities; object-only sweep points. Tests: +110 -> 113. + +## Codex review round 14 (PR #3444) — 2026-08-16 + +One finding here plus shared fixes (mirrored with main): evidence/raw/ +is reserved for raw-data artifacts — excluded from packet discovery and +un-referenceable by lanes. This branch's CT-001 already hashed per +step, so no regeneration was needed. Tests: 113 -> 114. + +## Codex review round 15 (PR #3444) — 2026-08-16 + +Two findings here plus one shared, all fixed (mirrored with main): +distinct-row sweep matching; empty-NPY rejection; NPZ member parsing. +Tests: 114 -> 117. + +## Codex review round 16 (PR #3444) — 2026-08-16 + +Three findings here plus four shared, all fixed (mirrored with main): +distinct-row seed matching; non-empty time windows; the design doc's +packet-contract list aligned with the enforced schema; build-step +commands; performance-validity enforcement; parquet footer sanity. +CT-001's commands migrated in place. Tests: 117 -> 121. + +## Codex review round 17 (PR #3444) — 2026-08-16 + +Two findings here plus one shared, all fixed (mirrored with main): +required build step in commands; numeric/boolean NPY dtypes; +whole-group unsupported typing for unmeasured groups. Tests: +121 -> 123. + +## Codex review round 18 (PR #3444) — 2026-08-16 + +Three findings here plus two shared, all fixed (mirrored with main): +allocation under host validity; non-null sweep coordinates; +token-matched seed fields; build-before-evidence ordering. Tests: +123 -> 127. + +## Codex review round 19 (PR #3444) — 2026-08-16 + +One finding here (the env-prefixed build-command false positive) plus +three shared, all fixed (mirrored with main). Tests: 127 -> 131. + +## Codex review round 20 (PR #3444) — 2026-08-16 + +Four findings here plus the shared build-task item, all fixed +(mirrored with main): execution-command requirement; exact build-task +matching; zero-width NPY dtypes; numeric sweep-point deduplication. +Tests: 131 -> 135. + +## Codex review round 21 (PR #3444) — 2026-08-16 + +Four findings here plus two shared, all fixed (mirrored with main): +case-insensitive identity suffix exclusion; solver-category +configuration requirement; harness-executing command requirement; +finite CSV cells; Parquet dropped as an unvalidatable raw format. +Tests: 135 -> 139. + +## Codex review round 22 (PR #3444) — 2026-08-16 + +Two findings on this lane plus the main-lane pair, all mirrored both +ways. CSV/TSV finite-cell scanning now classifies columns by header: +bookkeeping/seed columns are excluded, a header of only bookkeeping +columns is rejected outright, and `seed,note` metadata can no longer +satisfy the raw-measurement gate (headerless numeric tables still +count whole). NPY/NPZ payloads are value-checked with the stdlib: +float/complex arrays must contain at least one finite element, f2/f4/f8 +and c8/c16 decode, other float widths are rejected as unverifiable. +From the main lane: a packet's 40-hex target commit must exist in the +repository object store (the three platform CI check-lint checkouts +fetch full history; shallow clones get an explicit unshallow error), +and every `scripts/...` path in an evidence command must be a real file +in the scripts/ tree. Network reachability of the PR ref remains with +`--freshness`; the default gate is offline by design. +Tests: 139 -> 149 (6.20), 159 -> 169 (main). + +## Codex review round 23 (PR #3444) — 2026-08-16 + +Three findings on this lane plus two mirrored from main. The +fetch-hint regex no longer pins this PR: any `pull/N/head` ref with a +sha equal to target.commit is a runnable durable command, so the +CT-002/CT-003 slices planned for their own PRs stay landable (the +recorded PR number is packet data; reachability stays with +--freshness). NPY dtype widths are whitelisted per kind — ` 155 (6.20), 169 -> 175 (main). + +## Codex review round 24 (PR #3444) — 2026-08-16 + +Five findings on this lane plus two mirrored from main. NPY format +versions are whitelisted to 1.0/2.0/3.0 (a version-9.0 header is not a +loadable artifact). Repeat binding now requires the supported +trajectory/state_sha256 key family — `ensemble.unrelated_hash` no +longer counts (main's CT-011 writer emits a row-level +trajectory_sha256 to conform; this lane's CT-001 rows already did). +The evidence-writer command must name THIS claim's writer +(write_citation_*.py): citing another claim's writer records a +sequence that regenerates a different experiment. Raw JSON artifacts +load through the same strict NaN/duplicate-key hooks as packets. Sweep +point deduplication canonicalizes recursively so nested int/float +coordinate spellings are one point, matching the row matcher. From +main: sweep coordinates and seed keys are excluded from the +row-measurement requirement (a coordinate restated is not an outcome), +and CT-004's articulated hash now covers every joint state per step. +Tests: 155 -> 161 (6.20), 175 -> 181 (main). diff --git a/docs/plans/dashboard.md b/docs/plans/dashboard.md index 9c1879d9b8bbf..4359c9b266edd 100644 --- a/docs/plans/dashboard.md +++ b/docs/plans/dashboard.md @@ -55,6 +55,28 @@ Priority order is document order. Active implementation handoff remains in multi-thread determinism/scaling evidence; allocation gates and Gazebo coverage before any collision, constraint, or backend-default change. +### PLAN-623: Citation-Driven Contact Trust + +- Owner doc: + [DART 6 citation-driven contact trust](../design/dart6_citation_driven_contact_trust.md) +- Status: Active +- Horizon: Now +- Dimension: Contact correctness, evidence, and LTS compatibility. +- Next step: The branch-local claim manifest, fail-closed + `pixi run check-citation-evidence` gate, permanent negative control, and the + first CT-001 rolling-direction packet (fcl/dart/bullet/ode sweep) landed + with the 2026-08-14 audit; PLAN-621 owns CT-018, PLAN-622/PR #3431 own + CT-020, PR #3377 owns exact-Coulomb fixtures. Next: the remaining + first-wave contact rows not owned elsewhere, starting with dense + inelastic/elastic grids (CT-002/CT-003). Active handoff: + `docs/dev_tasks/dart6_citation_contact_trust/`. +- Gate: Preserve C++17, pybind11, ABI, installed components, FCL default, OSG, + and unaffected behavior; every packet is `release-6.20`-qualified with + explicit detector/solver/timestep/seed/claim boundaries and deterministic or + ensemble evidence; `pixi run lint`, focused tests, + `pixi run check-citation-evidence`, and + `pixi run -e gazebo test-gz` for downstream-sensitive changes. + ### PLAN-620: Dependency Minimization And Collision Backends - Owner doc: [DART 6 collision backends](../design/dart6_collision_backends.md) diff --git a/pixi.toml b/pixi.toml index d50277a8754a0..35bb80f092fc2 100644 --- a/pixi.toml +++ b/pixi.toml @@ -181,6 +181,7 @@ check-lint = { depends-on = [ "check-lint-spell", "check-ai-commands", "check-ai-infra", + "check-citation-evidence", ] } # Fast compatibility alias retained for older local workflows. @@ -191,6 +192,7 @@ install-hooks = { cmd = "python scripts/install_git_hooks.py" } sync-ai-commands = { cmd = "python scripts/sync_ai_commands.py" } check-ai-commands = { cmd = "python scripts/sync_ai_commands.py --check" } +check-citation-evidence = { cmd = "python scripts/check_citation_evidence.py" } ai-setup = { cmd = "python scripts/setup_ai.py" } ai-doctor = { cmd = "python scripts/check_ai_infrastructure.py --doctor" } check-agent-hook = { cmd = "python scripts/check_agent_hook.py --profile staged" } @@ -205,6 +207,7 @@ test-ai-infra = { cmd = [ "tests/test_sync_ai_commands.py", "tests/test_ai_infrastructure.py", "tests/test_install_git_hooks.py", + "tests/test_check_citation_evidence.py", "-q", ] } @@ -874,6 +877,7 @@ check-lint = { depends-on = [ "check-lint-spell", "check-lint-py", "check-ai-infra", + "check-citation-evidence", ] } build = { cmd = """ diff --git a/scripts/check_ai_infrastructure.py b/scripts/check_ai_infrastructure.py index 8ab283061557a..7d6a461014aeb 100644 --- a/scripts/check_ai_infrastructure.py +++ b/scripts/check_ai_infrastructure.py @@ -1479,6 +1479,7 @@ def check_test_gate_contract(root: Path, errors: list[str]) -> None: "tests/test_sync_ai_commands.py", "tests/test_ai_infrastructure.py", "tests/test_install_git_hooks.py", + "tests/test_check_citation_evidence.py", "-q", ], "depends-on": [], diff --git a/scripts/check_citation_evidence.py b/scripts/check_citation_evidence.py new file mode 100644 index 0000000000000..c030300742729 --- /dev/null +++ b/scripts/check_citation_evidence.py @@ -0,0 +1,2365 @@ +#!/usr/bin/env python3 +"""Fail-closed validator for the DART 6.20 citation claim/evidence contract. + +Branch adaptation of the DART 7 PLAN-123 validator (same packet schema, same +dispositions, same fail-closed rules) with `release-6.20` lane ownership: + +- validates `docs/design/dart6_citation_driven_contact_trust/claims-manifest.json` + (schema `dart.citation_claim_manifest/v1`, `branch: release-6.20`, a + `corpus_reference` pointing at the DART 7 corpus that owns claim IDs, and a + single `dart6` lane per claim); +- validates every packet under `evidence/` (recursively, excluding negative + controls) and every packet a manifest lane references, wherever it sits, + against `dart.citation_claim_evidence/v1`: missing target commit, scene + digest, requested/resolved method, command, ensemble, disposition, claim + boundary, or review record fails; a lane may not point at prose, a non-JSON + file, a path outside `evidence/`, or a negative control; metric groups must + be measured-with-method or explicitly typed `unsupported` with a reason -- + never silently absent, null, NaN, a spelled placeholder such as "n/a", or an + unacknowledged exact zero (an unmeasurable quantity is a typed-unsupported + marker; a real zero is declared in `measured_zero_fields`); `raw_paths` must + resolve to files that exist; +- requires every packet in `evidence/negative-controls/` to FAIL validation + with >= 3 errors (permanent proof the validator fails closed); +- cross-checks manifest lane/evidence links, `release-6.20` branch tags, and + the two-review floor for packets that close a lane. + +`--freshness` additionally requires packets to record the current `HEAD`; it +is a packet-writing aid, not a CI gate, because squash merges legitimately +retire topic-branch commits. + +This is additive tooling only: no public API, ABI, default, or runtime change. +""" + +from __future__ import annotations + +import argparse +import functools +import hashlib +import json +import math +import posixpath +import re +import subprocess +import sys +import unicodedata +from pathlib import Path, PurePosixPath + +REPO_ROOT = Path(__file__).resolve().parents[1] +DESIGN_DIR = REPO_ROOT / "docs" / "design" / "dart6_citation_driven_contact_trust" +MANIFEST_NAME = "claims-manifest.json" + +MANIFEST_SCHEMA = "dart.citation_claim_manifest/v1" +PACKET_SCHEMA = "dart.citation_claim_evidence/v1" +BRANCH = "release-6.20" + +CLAIM_ID_RE = re.compile(r"^CT-\d{3}$") +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +SCENE_DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") + +DISPOSITIONS = ( + "missing", + "reproduced", + "fixed", + "version-specific", + "not-applicable", + "invalid-original-setup", + "unresolved", +) +LANE_STATUSES = ("audit-required", "in-progress", "closed", "not-applicable") +# String metric leaves are allowed only under keys that clearly name semantic +# metadata; everywhere else a string is prose masquerading as a measurement. +METRIC_STRING_KEY_EXACT = frozenset( + { + "method", + "note", + "regime", + "unit", + "units", + "kind", + "signature_test", + "attribution", + "contact_solver_method", + "detector", + "criteria_exceeded", + "families_with_shrinking_drift", + } +) +METRIC_STRING_KEY_SUFFIXES = ( + "_note", + "_notes", + "_semantics", + "_method", + "_methods", + "_reasons", + "_criteria", + "_test", + "_families", + "_groups", + "_detectors", + "_basis", +) + +# Visual evidence must be actual media; an ordinary text file satisfying a +# path-existence check is not a capture. +VISUAL_MEDIA_SUFFIXES = ( + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".svg", + ".apng", + ".mp4", + ".webm", +) + +# configuration.requested/resolved must name a recognizable identity, not an +# arbitrary placeholder object. Keys that merely mention an identity word in +# a metadata role (method_note, backend_reason, ...) do not count. +FETCH_HINT_RE = re.compile( + r"^git fetch origin pull/([0-9]+)/head && git checkout ([0-9a-f]{40})$" +) +IDENTITY_KEY_TOKENS = frozenset( + { + "solver", + "solvers", + "method", + "methods", + "detector", + "detectors", + "integrator", + "integration", + "backend", + "backends", + "family", + "families", + } +) +IDENTITY_METADATA_SUFFIXES = ( + "_note", + "_notes", + "_semantics", + "_reason", + "_reasons", + "_criteria", + "_test", + "_basis", + "_provenance", + "_policy", +) + + +def _is_identity_key(key: str) -> bool: + """Whole-token identity match: `contact_solver_method` counts, + `methodology` (substring only) and `method_note`/`Method_Note` + (metadata role, any case) do not.""" + lowered = key.lower() + if lowered.endswith(IDENTITY_METADATA_SUFFIXES): + return False + tokens = re.split(r"[^a-zA-Z0-9]+", lowered) + return any(token in IDENTITY_KEY_TOKENS for token in tokens) + + +# Claim identity is owned by the DART 7 corpus on `main`; the manifest may +# not silently point anywhere else. +CANONICAL_CORPUS_PATH = ( + "docs/plans/123-citation-driven-simulation-trust/citation-claim-corpus.md" +) +CANONICAL_CORPUS_BRANCH = "main" + +UNSUPPORTED_SENTINELS = frozenset( + {"", "-", "--", "n/a", "na", "none", "null", "tbd", "unknown", "unsupported"} +) + +BUILD_COMMAND_RE = re.compile( + r"^(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;|&`$]+[ \t]+)*pixi run build" + r"(?:[ \t][^;|&`$\n\r]*)?\Z" +) +COMMAND_RE = re.compile( + r"^(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;|&`$]+[ \t]+)*" r"pixi run[ \t]+[^;|&`$\n\r]+\Z" +) +HASH_VALUE_RE = re.compile(r"^(sha256:)?[0-9a-f]{32,}$") +RAW_DATA_SUFFIXES = frozenset( + {".csv", ".tsv", ".json", ".jsonl", ".ndjson", ".npz", ".npy"} +) +NUMERIC_BOOKKEEPING_KEYS = frozenset( + {"seed", "seeds", "index", "idx", "id", "ids", "repeat", "repeats", "run"} +) +NPY_DTYPE_WIDTHS = { + "b": frozenset({1}), + "i": frozenset({1, 2, 4, 8}), + "u": frozenset({1, 2, 4, 8}), + "f": frozenset({2, 4, 8, 16}), + "c": frozenset({8, 16, 32}), +} + + +def _is_seed_key(key: str) -> bool: + """Whole-token seed match: `seed`, `rng_seed`, `seed_value` count; + `unseeded_metric` does not.""" + tokens = re.split(r"[^a-zA-Z0-9]+", key.lower()) + return "seed" in tokens or "seeds" in tokens + + +def _distinct_assignment_exists(match_sets: "list[set[int]]") -> bool: + """Backtracking search for a system of distinct representatives: every + declared point must map to its OWN row.""" + ordered = sorted(match_sets, key=len) + used: set[int] = set() + + def assign(index: int) -> bool: + if index == len(ordered): + return True + for row_index in ordered[index]: + if row_index not in used: + used.add(row_index) + if assign(index + 1): + return True + used.remove(row_index) + return False + + return assign(0) + + +def _is_bookkeeping_key(terminal: str) -> bool: + """Case-insensitive whole-token bookkeeping match: `Seed`, `run_id`, + and `repeat-2` are bookkeeping; `contact_count_max` is a measurement.""" + tokens = [ + token + for token in re.split(r"[^a-zA-Z0-9]+", terminal.lower()) + if token and not token.isdigit() + ] + return bool(tokens) and all(token in NUMERIC_BOOKKEEPING_KEYS for token in tokens) + + +def _has_measurement_leaf(value: object) -> bool: + """True when the value contains a numeric/boolean leaf beyond + bookkeeping keys (the raw-rows measurement rule, applied to parsed + artifacts).""" + return any( + (_is_finite_number(leaf) or isinstance(leaf, bool)) + and not _is_bookkeeping_key(re.sub(r"(\[\d+\])+$", "", path.rsplit(".", 1)[-1])) + for path, leaf in _metric_leaves(value) + ) + + +@functools.lru_cache(maxsize=None) +def _git_object_status(commit: str) -> str: + """'exists', 'missing', 'shallow', or 'unavailable' for a commit hash in + THIS repository's object store. + + Offline by design: the default gate proves the recorded checkout points + at a commit that actually exists here; network reachability through the + PR ref stays with `--freshness`. + """ + + def _git(*argv: str) -> "subprocess.CompletedProcess[bytes]": + return subprocess.run( + ["git", "-C", str(REPO_ROOT), *argv], + capture_output=True, + check=False, + ) + + if _git("cat-file", "-e", f"{commit}^{{commit}}").returncode == 0: + return "exists" + inside = _git("rev-parse", "--is-inside-work-tree") + if inside.returncode != 0 or inside.stdout.strip() != b"true": + return "unavailable" + shallow = _git("rev-parse", "--is-shallow-repository") + if shallow.returncode == 0 and shallow.stdout.strip() == b"true": + return "shallow" + return "missing" + + +REPEAT_HASH_KEY_RE = re.compile(r"^(?:repeat_)?(?:final_)?(?:trajectory|state)_sha256$") + + +def _has_hash_list(value: object, length: int) -> bool: + """True when a supported repeat-hash key holds >= `length` digests.""" + if isinstance(value, dict): + for key, item in value.items(): + if ( + REPEAT_HASH_KEY_RE.match(str(key).lower()) + and isinstance(item, list) + and len(item) >= length + and all( + _is_nonempty_str(entry) and HASH_VALUE_RE.match(entry.strip()) + for entry in item + ) + ): + return True + if _has_hash_list(item, length): + return True + elif isinstance(value, list): + return any(_has_hash_list(item, length) for item in value) + return False + + +def _has_hash_leaf(value: object) -> bool: + """True when a SUPPORTED repeat-hash key (the trajectory/state_sha256 + family) holds a digest or a list of digests; an arbitrarily named hash + field does not bind repeats to their recorded runs.""" + if isinstance(value, dict): + for key, item in value.items(): + if REPEAT_HASH_KEY_RE.match(str(key).lower()): + if _is_nonempty_str(item) and HASH_VALUE_RE.match(item.strip()): + return True + if isinstance(item, list) and any( + _is_nonempty_str(entry) and HASH_VALUE_RE.match(entry.strip()) + for entry in item + ): + return True + if _has_hash_leaf(item): + return True + elif isinstance(value, list): + return any(_has_hash_leaf(item) for item in value) + return False + + +IDENTITY_PLACEHOLDER_VALUES = frozenset( + UNSUPPORTED_SENTINELS + | {"not measured", "pending", "todo", "missing", "unspecified"} +) + + +def _is_identity_value(value: object) -> bool: + if isinstance(value, list): + # A requested sweep of methods IS the requested identity. + return bool(value) and all( + _is_nonempty_str(entry) + and entry.strip().lower() not in IDENTITY_PLACEHOLDER_VALUES + for entry in value + ) + return ( + _is_nonempty_str(value) + and value.strip().lower() not in IDENTITY_PLACEHOLDER_VALUES + ) + + +LANE_KEYS = ("dart6",) +BRANCH_BY_LANE = {"dart6": BRANCH} + +PACKET_TOP_LEVEL_KEYS = { + "schema", + "claim_id", + "title", + "source", + "target", + "scene", + "configuration", + "ensemble", + "metrics", + "evidence", + "result", + "review", + "host", +} +REQUIRED_PACKET_KEYS = set(PACKET_TOP_LEVEL_KEYS) +METRIC_GROUPS = ("physical", "numerical", "performance", "allocation") + +GIT_QUERY_ERRORS = (OSError, subprocess.CalledProcessError) + + +def _is_nonempty_str(value: object) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _is_finite_number(value: object) -> bool: + return ( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and math.isfinite(value) + ) + + +SOLVER_CATEGORY_TOKENS = frozenset( + { + "solver", + "solvers", + "method", + "methods", + "integrator", + "integration", + "family", + "families", + } +) + + +def _has_identity_key(value: object, tokens: "frozenset | None" = None) -> bool: + """True when any key at any depth names a solver/method/... identity + with a non-empty string value; {"placeholder": null} and + {"method_note": "..."} have none. `tokens` restricts the accepted + identity categories.""" + if isinstance(value, dict): + for key, item in value.items(): + if ( + _is_identity_key(str(key)) + and _is_identity_value(item) + and ( + tokens is None + or any( + token in tokens + for token in re.split(r"[^a-zA-Z0-9]+", str(key).lower()) + ) + ) + ): + return True + if _has_identity_key(item, tokens): + return True + elif isinstance(value, list): + return any(_has_identity_key(item, tokens) for item in value) + return False + + +def _evidence_path_issue(raw_path: str, base_dir: "Path | None") -> "str | None": + """Why a packet-referenced artifact path is unacceptable, or None. + + Paths must stay relative and resolve to an existing file INSIDE an + approved root (the sidecar directory or the repository); an absolute or + escaping path can satisfy a naive existence check with host-local data + that is neither tracked nor portable. + """ + pure = PurePosixPath(raw_path) + if ( + pure.is_absolute() + or raw_path[1:2] == ":" + or "\\" in raw_path + or ".." in pure.parts + ): + return ( + f"{raw_path!r} must be a relative path inside the repository " + "evidence tree (no absolute paths, drive letters, or '..')" + ) + if base_dir is None: + return None + if _resolve_evidence_path(raw_path, base_dir) is None: + return ( + f"{raw_path!r} does not resolve to an existing file inside an " + "approved evidence root; a dangling or escaping path is prose" + ) + return None + + +def _resolve_evidence_path(raw_path: str, base_dir: "Path | None") -> "Path | None": + """The resolved in-root file a relative evidence path names, or None.""" + if base_dir is None: + return None + for root in (base_dir, REPO_ROOT): + candidate = (root / raw_path).resolve() + if candidate.is_file() and candidate.is_relative_to(root.resolve()): + return candidate + return None + + +def _raw_data_content_issue(path: "Path") -> "str | None": + """Why a raw-data artifact's bytes do not match its claimed format. + + Mirrors the visual check: a prose file renamed to `rows.csv` must not + satisfy the raw-evidence requirement. JSON variants must parse; CSV/TSV + need delimited tabular lines with a finite value in a non-bookkeeping + column; NumPy containers must parse structurally AND carry at least one + finite (or boolean/integer) element. Deep semantic validation of + tabular contents is a recorded boundary. + """ + suffix = path.suffix.lower() + try: + with path.open("rb") as stream: + head = stream.read(1 * 1024 * 1024) + except OSError: + return "could not be read for format verification" + mismatch = ( + "does not parse as its claimed raw-data format; a renamed prose " + "file is not raw evidence" + ) + no_content = ( + "parses but carries no numeric or boolean measurement content; a " + "structurally empty artifact is not raw evidence" + ) + if suffix == ".json": + try: + parsed = json.loads( + path.read_text(encoding="utf-8"), + parse_constant=_reject_nonstandard_constant, + object_pairs_hook=_reject_duplicate_keys, + ) + except (OSError, ValueError): + return mismatch + if not _has_measurement_leaf(parsed): + return no_content + return None + if suffix in (".jsonl", ".ndjson"): + try: + parsed_lines = [ + json.loads( + line, + parse_constant=_reject_nonstandard_constant, + object_pairs_hook=_reject_duplicate_keys, + ) + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + except (OSError, ValueError): + return mismatch + if not parsed_lines or not _has_measurement_leaf(parsed_lines): + return no_content + return None + if suffix in (".csv", ".tsv"): + delimiter = b"," if suffix == ".csv" else b"\t" + lines = [line for line in head.splitlines() if line.strip()] + if len(lines) < 2 or not any(delimiter in line for line in lines[:2]): + return mismatch + + def _cell_float(cell: bytes) -> "float | None": + try: + return float(cell.strip()) + except ValueError: + return None + + first_row = lines[0].split(delimiter) + if any(_cell_float(cell) is None for cell in first_row): + # Header row: the finite-cell requirement applies only to + # columns that are not run bookkeeping (seed/index/repeat/...), + # so `seed,note` metadata cannot masquerade as a measurement. + header_names = [ + cell.strip().decode("utf-8", "replace") for cell in first_row + ] + measurement_columns = { + column + for column, name in enumerate(header_names) + if not (_is_bookkeeping_key(name) or _is_seed_key(name)) + } + if not measurement_columns: + return ( + "carries only bookkeeping columns " + f"({', '.join(header_names)}); run metadata without a " + "measurement column is not raw evidence" + ) + data_lines = lines[1:50] + else: + # Headerless numeric table: every column is a candidate + # measurement (there is no name to classify). + measurement_columns = None + data_lines = lines[0:50] + has_numeric_cell = False + for line in data_lines: + for column, cell in enumerate(line.split(delimiter)): + if measurement_columns is not None: + if column not in measurement_columns: + continue + cell_value = _cell_float(cell) + if cell_value is None or not math.isfinite(cell_value): + continue + has_numeric_cell = True + break + if has_numeric_cell: + break + if not has_numeric_cell: + return no_content + return None + if suffix == ".npy": + parsed = _npy_parsed_header(head, mismatch, path.stat().st_size) + if isinstance(parsed, str): + return parsed + payload_offset, descr, payload_length = parsed + + def _payload_chunks(): + with path.open("rb") as stream: + stream.seek(payload_offset) + remaining = payload_length + while remaining > 0: + block = stream.read(min(1024 * 1024, remaining)) + if not block: + return + remaining -= len(block) + yield block + + try: + value_issue = _npy_payload_value_issue(descr, _payload_chunks()) + except OSError: + return "could not be read for format verification" + if value_issue is not None: + return value_issue[1] + return None + if suffix == ".npz": + try: + import io + import zipfile + + with path.open("rb") as stream: + archive = zipfile.ZipFile(io.BytesIO(stream.read())) + names = archive.namelist() + npy_members = [name for name in names if name.endswith(".npy")] + if not npy_members: + return mismatch + any_finite = False + first_value_issue: "str | None" = None + for name in npy_members: + member_bytes = archive.read(name) + parsed = _npy_parsed_header(member_bytes, mismatch, len(member_bytes)) + if isinstance(parsed, str): + return mismatch + payload_offset, descr, payload_length = parsed + payload = member_bytes[payload_offset : payload_offset + payload_length] + value_issue = _npy_payload_value_issue(descr, [payload]) + if value_issue is None: + any_finite = True + elif value_issue[0] == "unsupported": + return value_issue[1] + elif first_value_issue is None: + first_value_issue = value_issue[1] + if not any_finite: + return first_value_issue or mismatch + except (OSError, zipfile.BadZipFile, KeyError): + return mismatch + return None + return None + + +def _npy_parsed_header( + head: bytes, mismatch: str, total_size: "int | None" = None +) -> "str | tuple[int, str, int]": + """Parse the NPY header structurally (stdlib only): magic, version, + header length, and a literal dict with descr/shape/fortran_order whose + shape is a tuple of non-negative ints. Returns the mismatch message on + failure, else (payload offset, descr, payload byte length) for value + inspection.""" + import ast as _ast + import struct + + if not head.startswith(b"\x93NUMPY") or len(head) < 10: + return mismatch + major, minor = head[6], head[7] + if (major, minor) not in {(1, 0), (2, 0), (3, 0)}: + # numpy.load() accepts only format versions 1.0/2.0/3.0; anything + # else is not a loadable artifact. + return mismatch + if major == 1: + (header_len,) = struct.unpack("= 1 + for dim in header["shape"] + ) + and isinstance(header["descr"], str) + and re.match(r"^[<>|=]?[bifuc][1-9][0-9]*$", header["descr"]) + ): + # String/object/structured dtypes carry no numeric measurement; + # structured record dtypes are a recorded boundary. + return mismatch + kind = header["descr"].lstrip("<>|=")[0] + itemsize = int(header["descr"].lstrip("<>|=")[1:]) + if itemsize not in NPY_DTYPE_WIDTHS[kind]: + # ` "tuple[str, str] | None": + """('unsupported' | 'nonfinite', message) when an NPY payload proves no + finite measurement; None when at least one finite (or boolean/integer) + element is present. `descr` was already validated by the header parse.""" + import struct + + match = re.match(r"^([<>|=]?)([bifuc])([0-9]+)$", descr) + assert match is not None + byte_order, kind, itemsize_text = match.groups() + itemsize = int(itemsize_text) + if kind in ("b", "i", "u"): + # Booleans and integers cannot encode NaN/Inf; the non-empty shape + # already enforced by the header check makes them observations. + return None + scalar_width = itemsize if kind == "f" else itemsize // 2 + scalar_code = {2: "e", 4: "f", 8: "d"}.get(scalar_width) + if scalar_code is None or (kind == "c" and itemsize % 2 != 0): + return ( + "unsupported", + f"uses dtype {descr!r} whose element values the standard " + "library cannot decode; store measurements as f2/f4/f8 (or " + "c8/c16) so their finiteness stays checkable", + ) + prefix = byte_order if byte_order in ("<", ">") else "=" + element = struct.Struct(prefix + scalar_code * (1 if kind == "f" else 2)) + carry = b"" + for chunk in chunks: + data = carry + chunk if carry else chunk + usable = len(data) - (len(data) % element.size) + for parts in element.iter_unpack(data[:usable]): + if all(math.isfinite(part) for part in parts): + return None + carry = data[usable:] + return ( + "nonfinite", + "contains no finite element; an all-NaN/Inf array records no " + "measurement and is not raw evidence", + ) + + +def _visual_content_issue(path: "Path") -> "str | None": + """Why a resolved visual artifact's bytes do not match its claimed type. + + A prose file renamed to `capture.png` satisfies a suffix check, and a + bare eight-byte signature satisfies a header check; requiring the + container's structural begin AND end markers (plus a minimum size) means + the artifact must at least be a complete container of its claimed type. + The header and the file TAIL are read separately so large legitimate + files are not falsely reported truncated. Full decoding would need an + image dependency; that boundary is recorded in the dev-task verification + log. + """ + suffix = path.suffix.lower() + try: + size = path.stat().st_size + with path.open("rb") as stream: + header = stream.read(4096) + if size > 4096: + stream.seek(-min(size, 4096), 2) + tail = stream.read(4096) + else: + tail = header + except OSError: + return "could not be read for media-signature verification" + mismatch = ( + "is not a structurally complete media file of its claimed type; a " + "renamed or truncated artifact is not visual evidence" + ) + if size < 64: + return mismatch + stripped_tail = tail.rstrip() + if suffix in (".png", ".apng"): + ok = ( + header.startswith(b"\x89PNG\r\n\x1a\n") + and b"IHDR" in header[:64] + and stripped_tail.endswith(b"IEND\xaeB`\x82") + ) + return None if ok else mismatch + if suffix in (".jpg", ".jpeg"): + ok = header.startswith(b"\xff\xd8\xff") and stripped_tail.endswith(b"\xff\xd9") + return None if ok else mismatch + if suffix == ".gif": + ok = header.startswith((b"GIF87a", b"GIF89a")) and stripped_tail.endswith( + b"\x3b" + ) + return None if ok else mismatch + if suffix == ".webp": + if not (header[:4] == b"RIFF" and header[8:12] == b"WEBP"): + return mismatch + riff_size = int.from_bytes(header[4:8], "little") + if riff_size + 8 != size: + return mismatch + return None + if suffix == ".mp4": + if header[4:8] != b"ftyp": + return mismatch + box_size = int.from_bytes(header[0:4], "big") + if not (8 <= box_size <= size): + return mismatch + return None + if suffix == ".webm": + ok = header.startswith(b"\x1a\x45\xdf\xa3") and b"webm" in header + return None if ok else mismatch + if suffix == ".svg": + ok = b"" in stripped_tail + return None if ok else mismatch + return None + + +def _packet_content_digest(packet: dict) -> str: + """Digest of the packet minus its review block. + + A review pass binds to this digest; regenerating a packet with different + content therefore invalidates prior passes instead of silently carrying + them onto evidence they never reviewed. + """ + content = {key: value for key, value in packet.items() if key != "review"} + return ( + "sha256:" + + hashlib.sha256( + json.dumps(content, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + ) + + +def _is_unsupported_leaf(value: object) -> bool: + """True when a nested value is a typed-unsupported marker.""" + return ( + isinstance(value, dict) + and value.get("status") == "unsupported" + and set(value) <= {"status", "reason"} + ) + + +def _metric_leaves(value: object, path: str = "") -> list[tuple[str, object]]: + """Flatten a metric value into (dotted-path, leaf) pairs. + + Typed-unsupported markers are returned whole so callers can validate them + instead of descending into their `status`/`reason` strings. + """ + if _is_unsupported_leaf(value): + return [(path, value)] + if isinstance(value, dict): + leaves: list[tuple[str, object]] = [] + for key, child in value.items(): + leaves.extend(_metric_leaves(child, f"{path}.{key}" if path else str(key))) + return leaves + if isinstance(value, list): + leaves = [] + for index, child in enumerate(value): + leaves.extend(_metric_leaves(child, f"{path}[{index}]")) + return leaves + return [(path, value)] + + +def metric_group_errors(name: str, group: object) -> list[str]: + """A metric group is measured-with-method or typed unsupported. Nothing else. + + Inside a measured group, every exact-zero number must be acknowledged: an + unmeasurable quantity is a typed-unsupported marker + (`{"status": "unsupported", "reason": ...}`), and a genuinely measured zero + is listed in `measured_zero_fields` by its dotted path. That is what makes + "unsupported is never silently zero" an enforced rule instead of a promise: + a zero cannot reach a packet without its author naming which kind it is. + """ + errors: list[str] = [] + if not isinstance(group, dict) or not group: + return [f"metrics.{name} must be a non-empty object"] + if group.get("status") == "unsupported": + if not _is_nonempty_str(group.get("reason")): + errors.append(f"metrics.{name} is unsupported but has no non-empty reason") + extra = set(group) - {"status", "reason"} + if extra: + errors.append( + f"metrics.{name} mixes unsupported status with values: " + f"{sorted(extra)}" + ) + return errors + if "status" in group: + errors.append(f"metrics.{name}.status must be 'unsupported' when present") + if not _is_nonempty_str(group.get("method")): + errors.append( + f"metrics.{name} must record a non-empty measurement 'method' " + "(or be typed unsupported with a reason)" + ) + + declared_zero_fields = group.get("measured_zero_fields", []) + if not isinstance(declared_zero_fields, list) or not all( + isinstance(item, str) for item in declared_zero_fields + ): + errors.append( + f"metrics.{name}.measured_zero_fields must be a list of dotted " + "field paths" + ) + declared_zero_fields = [] + + value_keys = [key for key in group if key not in {"method", "measured_zero_fields"}] + if not value_keys: + errors.append(f"metrics.{name} has a method but no measured values") + + observed_zero_fields: list[str] = [] + leaf_count = 0 + measurement_leaves = 0 + real_measurement_leaves = 0 + for key in value_keys: + leaves = _metric_leaves(group[key], key) + leaf_count += len(leaves) + for path, leaf in leaves: + if _is_unsupported_leaf(leaf): + measurement_leaves += 1 + if not _is_nonempty_str(leaf.get("reason")): + errors.append( + f"metrics.{name}.{path} is typed unsupported but has " + "no non-empty reason" + ) + continue + if leaf is None: + errors.append( + f"metrics.{name}.{path} contains null; unsupported values " + "must be typed, not null" + ) + elif ( + isinstance(leaf, str) and leaf.strip().lower() in UNSUPPORTED_SENTINELS + ): + errors.append( + f"metrics.{name}.{path} uses the placeholder {leaf!r}; " + "unsupported values must be typed, not spelled" + ) + elif isinstance(leaf, str): + terminal = re.sub(r"(\[\d+\])+$", "", path.rsplit(".", 1)[-1]) + if not ( + terminal in METRIC_STRING_KEY_EXACT + or terminal.endswith(METRIC_STRING_KEY_SUFFIXES) + ): + errors.append( + f"metrics.{name}.{path} is prose where a measurement " + "is expected; keep strings under semantic keys " + "(method, *_note, *_semantics, ...) or type the value " + "{'status': 'unsupported', 'reason': ...}" + ) + elif not leaf.strip(): + errors.append( + f"metrics.{name}.{path} is a blank string; record the " + "annotation or drop the key" + ) + elif isinstance(leaf, (int, float)) and not isinstance(leaf, bool): + measurement_leaves += 1 + real_measurement_leaves += 1 + if not math.isfinite(leaf): + errors.append(f"metrics.{name}.{path} contains a non-finite number") + elif leaf == 0: + observed_zero_fields.append(path) + elif isinstance(leaf, bool): + # Boolean findings (stability flags, signature verdicts) are + # legitimate measured outcomes; accepting them EXPLICITLY + # here keeps the type chain exhaustive so nothing falls + # through unvalidated. + measurement_leaves += 1 + real_measurement_leaves += 1 + else: + errors.append( + f"metrics.{name}.{path} has unrecognized leaf type " + f"{type(leaf).__name__}; a measurement is a finite " + "number, a boolean finding, a whitelisted semantic " + "string, or a typed-unsupported marker" + ) + + if value_keys and leaf_count > 0 and measurement_leaves == 0: + errors.append( + f"metrics.{name} carries only semantic annotations; a measured " + "group needs at least one numeric/boolean measurement or " + "typed-unsupported marker" + ) + if ( + value_keys + and leaf_count > 0 + and real_measurement_leaves == 0 + and (measurement_leaves > 0) + ): + errors.append( + f"metrics.{name} records a method but every quantity is typed " + "unsupported; type the WHOLE group " + "{'status': 'unsupported', 'reason': ...} instead of dressing " + "an unmeasured group as measured-with-method" + ) + if value_keys and leaf_count == 0: + errors.append( + f"metrics.{name} has a method but only empty containers; that is " + "not a measurement" + ) + + unacknowledged = [ + path for path in observed_zero_fields if path not in declared_zero_fields + ] + if unacknowledged: + errors.append( + f"metrics.{name} reports exact zero at {sorted(set(unacknowledged))} " + "without acknowledgement; type each unmeasurable value as " + "{'status': 'unsupported', 'reason': ...} or list a genuinely " + "measured zero in measured_zero_fields" + ) + stale = [path for path in declared_zero_fields if path not in observed_zero_fields] + if stale: + errors.append( + f"metrics.{name}.measured_zero_fields lists {sorted(set(stale))} " + "which are not zero in this packet" + ) + return errors + + +def packet_errors( + packet: object, + *, + known_claim_ids: set[str] | None = None, + expected_branches: tuple[str, ...] = (BRANCH,), + base_dir: Path | None = None, +) -> list[str]: + """Return every fail-closed violation for one evidence packet.""" + if not isinstance(packet, dict): + return ["packet must be a JSON object"] + errors: list[str] = [] + + if packet.get("schema") != PACKET_SCHEMA: + errors.append(f"schema must be {PACKET_SCHEMA!r}") + unknown = set(packet) - PACKET_TOP_LEVEL_KEYS + if unknown: + errors.append(f"unknown top-level keys: {sorted(unknown)}") + missing = REQUIRED_PACKET_KEYS - set(packet) + if missing: + errors.append(f"missing required top-level keys: {sorted(missing)}") + + claim_id = packet.get("claim_id") + if not (isinstance(claim_id, str) and CLAIM_ID_RE.match(claim_id)): + errors.append("claim_id must match CT-NNN") + elif known_claim_ids is not None and claim_id not in known_claim_ids: + errors.append(f"claim_id {claim_id} is not in the claims manifest") + if not _is_nonempty_str(packet.get("title")): + errors.append("title must be a non-empty string") + + source = packet.get("source") + if not isinstance(source, dict): + errors.append("source must be an object") + else: + url = source.get("url") + if not (_is_nonempty_str(url) and re.match(r"^https?://\S+$", url.strip())): + errors.append( + "source.url must be a retrievable http(s) URL; a placeholder " + "does not bind the claim to its source" + ) + if not _is_nonempty_str(source.get("claim")): + errors.append("source.claim must be a non-empty string") + + target = packet.get("target") + if not isinstance(target, dict): + errors.append("target must be an object") + else: + branch = target.get("branch") + if branch not in expected_branches: + errors.append(f"target.branch must be one of {list(expected_branches)}") + commit = target.get("commit") + if not (isinstance(commit, str) and COMMIT_RE.match(commit)): + errors.append("target.commit must be a 40-hex commit hash") + fetch_hint = target.get("fetch_hint") + hint_match = ( + FETCH_HINT_RE.match(fetch_hint.strip()) + if _is_nonempty_str(fetch_hint) + else None + ) + if hint_match is None: + errors.append( + "target.fetch_hint must be the runnable durable PR-ref " + f"command (matching {FETCH_HINT_RE.pattern!r}); arbitrary " + "prose or placeholder arguments cannot be executed to reach " + "target.commit. Reachability itself is guaranteed by GitHub " + "PR head refs surviving squash-merge and is checked at " + "packet-writing time via --freshness" + ) + elif isinstance(commit, str) and hint_match.group(2) != commit: + errors.append( + "target.fetch_hint checks out " + f"{hint_match.group(2)[:12]}... but target.commit is " + f"{commit[:12]}...; the hint must reproduce THIS packet's " + "target" + ) + if isinstance(commit, str) and COMMIT_RE.match(commit): + object_status = _git_object_status(commit) + if object_status == "missing": + errors.append( + f"target.commit {commit[:12]}... does not exist in this " + "repository's object store; a well-shaped hash for a " + "nonexistent commit attributes the measurements to no " + "source revision (regenerate the packet at a real, " + "pushed commit, or restore a squash-merged topic " + "commit with --fetch-target-refs)" + ) + elif object_status == "shallow": + errors.append( + f"target.commit {commit[:12]}... cannot be verified in " + "a shallow clone; fetch full history " + "(git fetch --unshallow) so recorded provenance stays " + "checkable" + ) + elif object_status == "unavailable": + errors.append( + "target.commit cannot be verified because no git " + "object store is available; run the checker from a " + "repository checkout" + ) + + scene = packet.get("scene") + if not isinstance(scene, dict): + errors.append("scene must be an object") + else: + if not _is_nonempty_str(scene.get("id")): + errors.append("scene.id must be a non-empty string") + digest = scene.get("digest") + if not (isinstance(digest, str) and SCENE_DIGEST_RE.match(digest)): + errors.append("scene.digest must match sha256:<64 hex>") + parameters = scene.get("parameters") + if isinstance(parameters, dict) and parameters: + if not any( + _is_finite_number(leaf) or isinstance(leaf, bool) + for _, leaf in _metric_leaves(parameters) + ): + errors.append( + "scene.parameters carries no numeric or boolean values; " + "metadata-only parameters do not describe a scene the " + "digest could bind" + ) + if not (isinstance(parameters, dict) and parameters): + errors.append( + "scene.parameters must publish the non-empty parameter " + "object the digest was computed over; a well-formed digest " + "with no content binds nothing" + ) + elif isinstance(digest, str) and SCENE_DIGEST_RE.match(digest): + # When the packet publishes the parameters the digest was taken + # over, recompute it. A digest that cannot be reproduced from the + # packet's own scene description binds nothing. + expected = ( + "sha256:" + + hashlib.sha256( + json.dumps( + scene["parameters"], sort_keys=True, separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + ) + if digest != expected: + errors.append( + f"scene.digest {digest} does not match the digest of " + f"scene.parameters ({expected}); the scene description " + "and its digest disagree" + ) + + configuration = packet.get("configuration") + if not isinstance(configuration, dict): + errors.append("configuration must be an object") + else: + for side in ("requested", "resolved"): + value = configuration.get(side) + if not isinstance(value, dict) or not value: + errors.append(f"configuration.{side} must be a non-empty object") + continue + null_keys = sorted(key for key, item in value.items() if item is None) + if null_keys: + errors.append( + f"configuration.{side} carries null values at {null_keys}; " + "record the identity or omit the key" + ) + if not _has_identity_key(value): + errors.append( + f"configuration.{side} records no recognizable " + "solver/method/detector/integrator/backend identity field; " + "an arbitrary placeholder object is not a configuration" + ) + elif not _has_identity_key(value, SOLVER_CATEGORY_TOKENS): + errors.append( + f"configuration.{side} names no solver/method/integrator " + "identity; a detector alone does not record which solver " + "ran" + ) + if not _is_nonempty_str(configuration.get("resolved_provenance")): + errors.append( + "configuration.resolved_provenance must name how the resolved " + "method identity was obtained" + ) + if not _is_nonempty_str(configuration.get("detector")): + errors.append("configuration.detector must be a non-empty string") + timestep = configuration.get("timestep") + if not (_is_finite_number(timestep) and timestep > 0.0): + errors.append("configuration.timestep must be a positive number") + if not _is_nonempty_str(configuration.get("fallback_policy")): + errors.append("configuration.fallback_policy must be a non-empty string") + + declared_points = 0 + sweep = None + seeds = None + has_sweep = False + has_seeds = False + ensemble = packet.get("ensemble") + if not isinstance(ensemble, dict): + errors.append("ensemble must be an object") + else: + if not _is_nonempty_str(ensemble.get("kind")): + errors.append("ensemble.kind must be a non-empty string") + repeats = ensemble.get("deterministic_repeats") + sweep = ensemble.get("sweep") + seeds = ensemble.get("seeds") + has_repeats = ( + isinstance(repeats, int) and not isinstance(repeats, bool) and repeats >= 2 + ) + if ( + has_repeats + and isinstance(repeats, int) + and repeats > 2 + and not _has_hash_list(packet.get("evidence"), repeats) + ): + errors.append( + f"ensemble.deterministic_repeats={repeats} (> 2) requires a " + "recorded per-repeat hash list of that length somewhere in " + "evidence; two verified repeats may rely on the " + "deterministic_repeats_identical flag plus a trajectory " + "digest, larger claims must show their repeats" + ) + has_repeats = False + evidence_for_repeats = ( + { + key: value + for key, value in packet.get("evidence", {}).items() + if key != "artifact_digests" + } + if isinstance(packet.get("evidence"), dict) + else packet.get("evidence") + ) + repeat_hash_sources = [evidence_for_repeats, ensemble] + if has_repeats and not any( + _has_hash_leaf(source) for source in repeat_hash_sources + ): + errors.append( + "ensemble.deterministic_repeats is asserted but the " + "evidence carries no supported repeat-hash field (the " + "trajectory/state_sha256 family) binding the repeats to " + "recorded trajectories; an unverifiable repeat claim is " + "not an ensemble" + ) + has_repeats = False + if has_repeats and ensemble.get("deterministic_repeats_identical") is not True: + errors.append( + "ensemble.deterministic_repeats is asserted without " + "deterministic_repeats_identical: true; a repeat count the " + "writer did not verify bit-identical is a claim, not evidence" + ) + has_repeats = False + # A sweep or seed list only counts as an ensemble when its entries are + # valid and mutually distinct; [null, null] or a duplicated point is + # one run wearing an ensemble's clothes. + has_sweep = isinstance(sweep, list) and len(sweep) >= 2 + if has_sweep: + canonical_points: list[str] = [] + + def _canonical_value(value: object) -> object: + # 1 and 1.0 are the same coordinate; the row matcher's == + # treats them as equal (recursively, through nested + # containers), so the distinct-point check must too. + if isinstance(value, bool): + return value + if isinstance(value, int): + return float(value) + if isinstance(value, dict): + return {key: _canonical_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_canonical_value(item) for item in value] + return value + + for index, entry in enumerate(sweep): + if isinstance(entry, dict) and entry: + canonical_points.append( + json.dumps(_canonical_value(entry), sort_keys=True) + ) + else: + errors.append( + f"ensemble.sweep[{index}] must be a non-empty object " + "naming its coordinates (e.g. {'angle_deg': 15.0}); " + "a bare scalar cannot be matched to the row that " + "observed it" + ) + has_sweep = False + if has_sweep and len(set(canonical_points)) < 2: + errors.append( + "ensemble.sweep must contain at least two DISTINCT points" + ) + has_sweep = False + if has_sweep: + declared_points = max(declared_points, len(canonical_points)) + has_seeds = isinstance(seeds, list) and len(seeds) >= 2 + if has_seeds: + for index, entry in enumerate(seeds): + if not ( + (isinstance(entry, int) and not isinstance(entry, bool)) + or _is_nonempty_str(entry) + ): + errors.append( + f"ensemble.seeds[{index}] must be an integer or " + "non-empty string seed" + ) + has_seeds = False + if has_seeds and len({repr(entry) for entry in seeds}) < 2: + errors.append("ensemble.seeds must contain at least two DISTINCT seeds") + has_seeds = False + if has_seeds: + declared_points = max(declared_points, len(seeds)) + if not (has_repeats or has_sweep or has_seeds): + errors.append( + "ensemble must record deterministic_repeats >= 2, a sweep of " + ">= 2 points, or >= 2 seeds; single runs are not evidence" + ) + window = ensemble.get("measurement_window") + if "measurement_window" not in ensemble: + errors.append("ensemble.measurement_window is required") + elif isinstance(window, dict) and window: + bad_values = sorted( + key for key, item in window.items() if not _is_finite_number(item) + ) + if bad_values: + errors.append( + "ensemble.measurement_window values at " + f"{bad_values} must be finite numbers" + ) + start = window.get("start_s") + end = window.get("end_s") + if ( + _is_finite_number(start) + and _is_finite_number(end) + and (start < 0 or end <= start) + ): + errors.append( + "ensemble.measurement_window must be a non-empty " + "interval with start_s >= 0 and end_s > start_s; a " + "zero-duration or negative window contains no " + "measurements" + ) + has_time_bounds = {"start_s", "end_s"} <= set(window) + has_step_bounds = {"warmup_steps", "continuation_steps"} <= set(window) + if has_step_bounds: + warmup = window.get("warmup_steps") + continuation = window.get("continuation_steps") + if not ( + isinstance(warmup, int) + and not isinstance(warmup, bool) + and warmup >= 0 + and isinstance(continuation, int) + and not isinstance(continuation, bool) + and continuation >= 1 + ): + errors.append( + "ensemble.measurement_window step bounds must be " + "non-negative integers with continuation_steps >= 1" + ) + has_step_bounds = False + if not (has_time_bounds or has_step_bounds): + errors.append( + "ensemble.measurement_window must name its bounds " + "(start_s/end_s or warmup_steps/continuation_steps); " + "unnamed numbers do not record when measurements were " + "collected" + ) + else: + errors.append( + "ensemble.measurement_window must be a non-empty object of " + "finite numeric bounds; prose or truthy placeholders do not " + "record when measurements were collected" + ) + + metrics = packet.get("metrics") + if not isinstance(metrics, dict): + errors.append("metrics must be an object") + else: + for name in METRIC_GROUPS: + if name not in metrics: + errors.append( + f"metrics.{name} is required (measured or typed unsupported)" + ) + else: + errors.extend(metric_group_errors(name, metrics[name])) + + evidence = packet.get("evidence") + if not isinstance(evidence, dict): + errors.append("evidence must be an object") + else: + commands = evidence.get("commands") + if not ( + isinstance(commands, list) + and commands + and all(_is_nonempty_str(command) for command in commands) + ): + errors.append("evidence.commands must be a non-empty list of commands") + else: + for index, command in enumerate(commands): + if not COMMAND_RE.match(command.strip()): + errors.append( + f"evidence.commands[{index}] must be the " + "reproducible repository form (optional VAR=value " + "prefixes followed by 'pixi run ...'); arbitrary " + "shell strings are not the promised reproduction " + "path" + ) + continue + for script_ref in re.findall(r"scripts/[A-Za-z0-9_.\-/]+", command): + script_path = (REPO_ROOT / script_ref).resolve() + if not ( + script_path.is_file() + and script_path.is_relative_to(REPO_ROOT / "scripts") + ): + errors.append( + f"evidence.commands[{index}] references " + f"{script_ref}, which is not a file in this " + "repository's scripts/ tree; a reproduction " + "sequence must invoke a harness that exists" + ) + build_indices = [ + index + for index, command in enumerate(commands) + if BUILD_COMMAND_RE.match(command.strip()) + ] + non_build_indices = [ + index for index in range(len(commands)) if index not in build_indices + ] + if not build_indices: + errors.append( + "evidence.commands must include the build step " + "('pixi run build ...') before execution; a clean " + "checkout from target.fetch_hint has no artifacts, and " + "an existing checkout may hold stale ones" + ) + elif not non_build_indices: + errors.append( + "evidence.commands contains only build steps; a " + "reproduction sequence must also RUN the evidence " + "command" + ) + elif not any( + re.search( + r"python[ \t].*scripts/write_citation[A-Za-z0-9_]*\.py", + commands[index], + ) + for index in non_build_indices + ): + errors.append( + "no evidence.commands entry runs an evidence-writer " + "harness (python ... scripts/write_citation*.py); build " + "or maintenance tasks and unrelated scripts do not " + "regenerate this packet's evidence" + ) + elif ( + isinstance(claim_id, str) + and CLAIM_ID_RE.match(claim_id) + and not any( + re.search( + r"python[ \t].*scripts/write_citation_" + + re.escape(claim_id.lower().replace("-", "")) + + r"[a-z0-9_]*\.py", + commands[index], + ) + for index in non_build_indices + ) + ): + errors.append( + "no evidence.commands entry runs THIS claim's evidence " + f"writer (scripts/write_citation_" + f"{claim_id.lower().replace('-', '')}*.py); another " + "claim's writer regenerates a different experiment and " + "cannot reproduce these measurements" + ) + elif min(build_indices) > min(non_build_indices): + errors.append( + "evidence.commands must run the build step BEFORE the " + "evidence command; building afterwards reproduces " + "nothing" + ) + raw_paths = evidence.get("raw_paths") + raw_rows = evidence.get("raw_rows") + has_paths = isinstance(raw_paths, list) and bool(raw_paths) + has_rows = isinstance(raw_rows, list) and bool(raw_rows) + if has_rows and declared_points > len(raw_rows): + errors.append( + f"ensemble declares {declared_points} sweep/seed points but " + f"evidence.raw_rows records only {len(raw_rows)} rows; every " + "declared point needs at least one recorded sample" + ) + # Count parity is not enough: each declared object point must be + # OBSERVED by a row carrying its exact coordinates, or one point's + # measurements could be repeated to stand in for the others. + if has_rows and isinstance(sweep, list): + object_points = [ + point for point in sweep if isinstance(point, dict) and point + ] + match_sets = [] + for point in object_points: + null_coords = sorted( + key for key, value in point.items() if value is None + ) + if null_coords: + errors.append( + f"ensemble.sweep point {point} declares null " + f"coordinates at {null_coords}; absent row fields " + "would spuriously match them" + ) + continue + matches = { + index + for index, row in enumerate(raw_rows) + if isinstance(row, dict) + and all(row.get(key) == value for key, value in point.items()) + } + if not matches: + errors.append( + f"ensemble.sweep point {point} has no matching row " + "in evidence.raw_rows recording those coordinates; " + "a declared configuration without an observation is " + "not swept" + ) + match_sets.append(matches) + if all(match_sets) and not _distinct_assignment_exists(match_sets): + errors.append( + "ensemble.sweep points cannot be matched to DISTINCT " + "rows; overlapping coordinates must each have their own " + "recorded observation" + ) + if has_rows and isinstance(seeds, list): + seed_match_sets = [] + for seed in seeds: + if not ( + (isinstance(seed, int) and not isinstance(seed, bool)) + or _is_nonempty_str(seed) + ): + continue + matches = { + index + for index, row in enumerate(raw_rows) + if isinstance(row, dict) + and any(_is_seed_key(str(key)) and row[key] == seed for key in row) + } + if not matches: + errors.append( + f"ensemble seed {seed!r} has no row recording it " + "under a seed field; a declared seed without an " + "observation is not an ensemble member" + ) + seed_match_sets.append(matches) + if all(seed_match_sets) and not _distinct_assignment_exists( + seed_match_sets + ): + errors.append( + "ensemble seeds cannot be matched to DISTINCT rows; " + "every declared seed needs its own recorded observation" + ) + if not (has_paths or has_rows): + errors.append("evidence must carry raw_rows inline or non-empty raw_paths") + if (has_sweep or has_seeds) and not has_rows: + errors.append( + "a sweep/seed ensemble requires inline evidence.raw_rows so " + "each declared point's observation is checkable; path-only " + "raw evidence cannot demonstrate per-point coverage" + ) + if has_rows: + coordinate_keys = { + str(key) + for point in (sweep if isinstance(sweep, list) else []) + if isinstance(point, dict) + for key in point + } + for index, row in enumerate(raw_rows): + if not (isinstance(row, dict) and row): + errors.append( + f"evidence.raw_rows[{index}] must be a non-empty " + "structured record; null or scalar placeholders are " + "not raw evidence" + ) + continue + + def _terminal(path: str) -> str: + return re.sub(r"(\[\d+\])+$", "", path.rsplit(".", 1)[-1]) + + if not any( + (_is_finite_number(leaf) or isinstance(leaf, bool)) + and not _is_bookkeeping_key(_terminal(path)) + and not _is_seed_key(_terminal(path)) + and _terminal(path) not in coordinate_keys + for path, leaf in _metric_leaves(row) + ): + errors.append( + f"evidence.raw_rows[{index}] carries no numeric or " + "boolean measurement beyond bookkeeping (seed/index/" + "id/...) and declared sweep coordinates; a " + "coordinate restated is not an outcome, and a " + "metadata-only record is not raw evidence" + ) + if has_paths: + for index, raw_path in enumerate(raw_paths): + if not _is_nonempty_str(raw_path): + errors.append( + f"evidence.raw_paths[{index}] must be a non-empty string" + ) + continue + issue = _evidence_path_issue(raw_path, base_dir) + if issue is not None and "must be a relative path" in issue: + errors.append(f"evidence.raw_paths[{index}] {issue}") + continue + if PurePosixPath(raw_path).suffix.lower() not in RAW_DATA_SUFFIXES: + errors.append( + f"evidence.raw_paths[{index}] {raw_path!r} must name " + "a raw-data artifact " + f"({', '.join(sorted(RAW_DATA_SUFFIXES))}); prose or " + "code files are not raw evidence" + ) + continue + if issue is not None: + errors.append(f"evidence.raw_paths[{index}] {issue}") + continue + resolved = _resolve_evidence_path(raw_path, base_dir) + if resolved is not None: + content_issue = _raw_data_content_issue(resolved) + if content_issue is not None: + errors.append( + f"evidence.raw_paths[{index}] {raw_path!r} " + f"{content_issue}" + ) + referenced_paths: list[str] = [] + if has_paths: + referenced_paths.extend( + raw_path for raw_path in raw_paths if _is_nonempty_str(raw_path) + ) + visual = evidence.get("visual") + if isinstance(visual, dict): + if visual.get("status") != "not-applicable" or not _is_nonempty_str( + visual.get("reason") + ): + errors.append( + "evidence.visual object form must be " + "{'status': 'not-applicable', 'reason': ...}" + ) + elif isinstance(visual, list) and visual: + for index, item in enumerate(visual): + if _is_nonempty_str(item): + item_path = item + elif ( + isinstance(item, dict) + and _is_nonempty_str(item.get("path")) + and _is_nonempty_str(item.get("description")) + ): + item_path = item["path"] + else: + errors.append( + f"evidence.visual[{index}] must be an artifact path " + "or {'path': ..., 'description': ...}; a placeholder " + "cannot stand in for visual evidence" + ) + continue + if PurePosixPath(item_path).suffix.lower() not in VISUAL_MEDIA_SUFFIXES: + errors.append( + f"evidence.visual[{index}] {item_path!r} is not a " + "recognized visual media artifact " + f"({', '.join(VISUAL_MEDIA_SUFFIXES)}); an ordinary " + "file cannot stand in for visual evidence" + ) + continue + issue = _evidence_path_issue(item_path, base_dir) + if issue is not None: + errors.append(f"evidence.visual[{index}] {issue}") + continue + resolved = _resolve_evidence_path(item_path, base_dir) + if resolved is not None: + content_issue = _visual_content_issue(resolved) + if content_issue is not None: + errors.append( + f"evidence.visual[{index}] {item_path!r} " + f"{content_issue}" + ) + else: + errors.append( + "evidence.visual must list visual artifacts or be typed " + "not-applicable with a reason" + ) + if isinstance(visual, list): + for item in visual: + if _is_nonempty_str(item): + referenced_paths.append(item) + elif isinstance(item, dict) and _is_nonempty_str(item.get("path")): + referenced_paths.append(item["path"]) + if referenced_paths: + digests = evidence.get("artifact_digests") + if not isinstance(digests, dict): + errors.append( + "evidence.artifact_digests must map every referenced " + "artifact path to its sha256; without it, review " + "digests do not bind the referenced bytes" + ) + else: + for ref in referenced_paths: + recorded = digests.get(ref) + if not ( + isinstance(recorded, str) and SCENE_DIGEST_RE.match(recorded) + ): + errors.append( + f"evidence.artifact_digests[{ref!r}] must record " + "sha256:<64 hex> for the referenced artifact" + ) + continue + resolved = _resolve_evidence_path(ref, base_dir) + if resolved is not None: + actual = ( + "sha256:" + + hashlib.sha256(resolved.read_bytes()).hexdigest() + ) + if actual != recorded: + errors.append( + f"evidence.artifact_digests[{ref!r}] does " + "not match the referenced file's bytes; a " + "swapped artifact invalidates the packet " + "and its reviews" + ) + + result = packet.get("result") + if not isinstance(result, dict): + errors.append("result must be an object") + else: + if result.get("disposition") not in DISPOSITIONS: + errors.append(f"result.disposition must be one of {list(DISPOSITIONS)}") + if not _is_nonempty_str(result.get("claim_boundary")): + errors.append("result.claim_boundary must be a non-empty string") + limitations = result.get("limitations") + if not ( + isinstance(limitations, list) + and limitations + and all(_is_nonempty_str(item) for item in limitations) + ): + errors.append( + "result.limitations must be a non-empty list; every packet " + "has at least one honest limitation" + ) + + host = packet.get("host") + if not isinstance(host, dict): + errors.append("host must be an object recording provenance") + else: + if not _is_nonempty_str(host.get("platform")): + errors.append("host.platform must be a non-empty string") + if not isinstance(host.get("performance_valid"), bool): + errors.append( + "host.performance_valid must be an explicit boolean; timing " + "or allocation numbers from an uncontrolled host must not " + "pass silently" + ) + elif host.get("performance_valid") is False: + performance_group = ( + packet.get("metrics", {}).get("performance") + if isinstance(packet.get("metrics"), dict) + else None + ) + if ( + isinstance(performance_group, dict) + and performance_group.get("status") != "unsupported" + ): + errors.append( + "metrics.performance publishes measurements while " + "host.performance_valid is false; timing from an " + "uncontrolled host must be typed unsupported" + ) + allocation_group = ( + packet.get("metrics", {}).get("allocation") + if isinstance(packet.get("metrics"), dict) + else None + ) + if ( + isinstance(allocation_group, dict) + and allocation_group.get("status") != "unsupported" + ): + errors.append( + "metrics.allocation publishes measurements while " + "host.performance_valid is false; allocation counts " + "from an uncontrolled host must be typed unsupported" + ) + + review = packet.get("review") + if not isinstance(review, dict): + errors.append("review must be an object") + else: + passes = review.get("passes") + if not isinstance(passes, list): + errors.append("review.passes must be a list") + else: + expected_digest = _packet_content_digest(packet) + for index, entry in enumerate(passes): + if ( + not isinstance(entry, dict) + or not _is_nonempty_str(entry.get("reviewer")) + or not _is_nonempty_str(entry.get("summary")) + ): + errors.append( + f"review.passes[{index}] needs non-empty reviewer and summary" + ) + continue + if entry.get("verdict") != "pass": + errors.append( + f"review.passes[{index}] must record verdict 'pass'; " + "an entry without an explicit passing verdict (or " + "one recording a failure) cannot count toward " + "closure" + ) + if entry.get("content_digest") != expected_digest: + errors.append( + f"review.passes[{index}] is not bound to this " + "packet's content (content_digest must equal " + f"{expected_digest}); a review recorded against " + "earlier evidence does not carry over" + ) + + return errors + + +def manifest_errors(manifest: object) -> list[str]: + """Validate the branch claims manifest structure.""" + if not isinstance(manifest, dict): + return ["manifest must be a JSON object"] + errors: list[str] = [] + if manifest.get("schema") != MANIFEST_SCHEMA: + errors.append(f"manifest schema must be {MANIFEST_SCHEMA!r}") + if manifest.get("branch") != BRANCH: + errors.append(f"manifest branch must be {BRANCH!r}") + + corpus_reference = manifest.get("corpus_reference") + if ( + not isinstance(corpus_reference, dict) + or not _is_nonempty_str(corpus_reference.get("path")) + or not _is_nonempty_str(corpus_reference.get("branch")) + ): + errors.append("corpus_reference must record the owning corpus path and branch") + else: + # The contract says claim identity stays owned by the DART 7 corpus + # on `main`; accepting any two strings would let a future manifest + # silently point at another corpus and fork claim IDs. + if corpus_reference.get("path") != CANONICAL_CORPUS_PATH: + errors.append( + f"corpus_reference.path must be {CANONICAL_CORPUS_PATH!r}; " + "claim identity is owned by the DART 7 corpus" + ) + if corpus_reference.get("branch") != CANONICAL_CORPUS_BRANCH: + errors.append( + f"corpus_reference.branch must be {CANONICAL_CORPUS_BRANCH!r}" + ) + + claims = manifest.get("claims") + if not isinstance(claims, list) or not claims: + errors.append("claims must be a non-empty list") + return errors + + seen_ids: list[str] = [] + for claim in claims: + if not isinstance(claim, dict): + errors.append("every claim must be an object") + continue + claim_id = claim.get("id", "") + if not (isinstance(claim_id, str) and CLAIM_ID_RE.match(claim_id)): + errors.append(f"claim id {claim_id!r} must match CT-NNN") + continue + seen_ids.append(claim_id) + if not _is_nonempty_str(claim.get("title")): + errors.append(f"{claim_id}: title must be non-empty") + if not _is_nonempty_str(claim.get("source")): + errors.append(f"{claim_id}: source must be non-empty") + lanes_probe = claim.get("lanes") + has_lane_evidence = isinstance(lanes_probe, dict) and any( + isinstance(lane, dict) and lane.get("evidence") + for lane in lanes_probe.values() + ) + source_url = claim.get("source_url") + source_claim = claim.get("source_claim") + url_ok = _is_nonempty_str(source_url) and re.match(r"^https?://", source_url) + if source_url is not None and not url_ok: + errors.append(f"{claim_id}: source_url must be an http(s) citation URL") + if source_claim is not None and not _is_nonempty_str(source_claim): + errors.append(f"{claim_id}: source_claim must be non-empty text") + if has_lane_evidence and not (url_ok and _is_nonempty_str(source_claim)): + errors.append( + f"{claim_id}: a claim with lane evidence must pin the " + "canonical source_url and source_claim its packets bind " + "to; without them a lane can close on evidence for a " + "different cited assertion" + ) + lanes = claim.get("lanes") + if not isinstance(lanes, dict) or set(lanes) != set(LANE_KEYS): + errors.append(f"{claim_id}: lanes must define exactly {LANE_KEYS}") + continue + for lane_name, lane in lanes.items(): + prefix = f"{claim_id}.lanes.{lane_name}" + if not isinstance(lane, dict): + errors.append(f"{prefix} must be an object") + continue + status = lane.get("status") + if status not in LANE_STATUSES: + errors.append(f"{prefix}.status must be one of {list(LANE_STATUSES)}") + continue + evidence = lane.get("evidence") + if not isinstance(evidence, list): + errors.append(f"{prefix}.evidence must be a list") + evidence = [] + if status == "not-applicable": + if not _is_nonempty_str(lane.get("reason")): + errors.append( + f"{prefix} is not-applicable and must record a reason" + ) + na_disposition = lane.get("disposition") + if na_disposition not in (None, "not-applicable"): + errors.append( + f"{prefix} is not-applicable and cannot publish " + f"disposition {na_disposition!r}; a lane that does " + "not apply concludes nothing" + ) + if evidence: + errors.append( + f"{prefix} is not-applicable and must not hold " + "evidence packets" + ) + continue + if not _is_nonempty_str(lane.get("owner")): + errors.append(f"{prefix}.owner must be non-empty") + disposition = lane.get("disposition") + if status == "closed": + if disposition not in DISPOSITIONS: + errors.append(f"{prefix} is closed without a valid disposition") + if not evidence: + errors.append( + f"{prefix} is closed without evidence packets; prose " + "cannot close a row" + ) + elif disposition is not None and disposition not in DISPOSITIONS: + errors.append( + f"{prefix}.disposition must be null or one of {list(DISPOSITIONS)}" + ) + + duplicates = sorted({cid for cid in seen_ids if seen_ids.count(cid) > 1}) + if duplicates: + errors.append(f"duplicate claim ids: {duplicates}") + return errors + + +def _reject_nonstandard_constant(constant: str) -> None: + """Reject NaN/Infinity/-Infinity anywhere in a packet at load time. + + Python's default loader accepts them, but they are not JSON: a packet + carrying one is unreadable to strict consumers and therefore not the + portable machine evidence the contract promises. + """ + raise ValueError(f"non-standard JSON constant {constant!r}") + + +def _reject_duplicate_keys(pairs: list) -> dict: + """Different JSON consumers disagree on duplicate keys (first vs last + wins), so a packet carrying one is not portable machine evidence.""" + seen: dict = {} + for key, value in pairs: + if key in seen: + raise ValueError(f"duplicate JSON object key {key!r}") + seen[key] = value + return seen + + +def _load_json(path: Path, errors: list[str]) -> object | None: + try: + return json.loads( + path.read_text(encoding="utf-8"), + parse_constant=_reject_nonstandard_constant, + object_pairs_hook=_reject_duplicate_keys, + ) + except (OSError, ValueError) as error: + # json.JSONDecodeError subclasses ValueError; parse_constant raises + # a plain ValueError for NaN/Infinity. + errors.append(f"{path}: unreadable JSON ({error})") + return None + + +def _git_head(repo_root: Path) -> str | None: + try: + return ( + subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + or None + ) + except GIT_QUERY_ERRORS: + return None + + +def validate_tree(design_dir: Path, *, freshness_head: str | None = None) -> list[str]: + """Validate the branch manifest, evidence packets, and negative controls.""" + errors: list[str] = [] + manifest_path = design_dir / MANIFEST_NAME + evidence_dir = design_dir / "evidence" + negative_dir = evidence_dir / "negative-controls" + + manifest = _load_json(manifest_path, errors) + known_ids: set[str] = set() + lane_evidence: dict[str, tuple[str, str]] = {} + lane_dispositions: dict[str, tuple[str, object]] = {} + claim_sources: dict[str, tuple[object, object]] = {} + if manifest is not None: + manifest_issues = manifest_errors(manifest) + errors.extend(f"{manifest_path}: {issue}" for issue in manifest_issues) + if isinstance(manifest, dict) and isinstance(manifest.get("claims"), list): + for claim in manifest["claims"]: + if not isinstance(claim, dict): + continue + claim_id = claim.get("id") + if isinstance(claim_id, str): + known_ids.add(claim_id) + claim_sources[claim_id] = ( + claim.get("source_url"), + claim.get("source_claim"), + ) + lanes = claim.get("lanes") + if not isinstance(lanes, dict): + continue + for lane_name, lane in lanes.items(): + if not isinstance(lane, dict): + continue + lane_paths = lane.get("evidence") + if lane_paths is not None and not isinstance(lane_paths, list): + errors.append( + f"{manifest_path}: {claim_id}.lanes.{lane_name}" + ".evidence must be a list" + ) + lane_paths = [] + for rel in lane_paths or []: + if not isinstance(rel, str): + errors.append( + f"{manifest_path}: {claim_id}.lanes." + f"{lane_name}.evidence contains a non-string " + f"entry {rel!r}; every entry must be a packet " + "path, or a lane could be closed by something " + "the packet checks never reach" + ) + continue + # Canonicalize before indexing: `evidence/./p.json` + # and `evidence/p.json` are one file and must share + # one owner/one review record, and an escaping path + # must not become a distinct index key. + normalized = posixpath.normpath(rel) + if ( + posixpath.isabs(normalized) + or normalized.startswith("..") + or "\\" in rel + ): + errors.append( + f"{manifest_path}: {claim_id}.lanes." + f"{lane_name}.evidence entry {rel!r} escapes " + "the sidecar directory" + ) + continue + rel = normalized + if normalized.startswith("evidence/raw/"): + errors.append( + f"{manifest_path}: {claim_id}.lanes." + f"{lane_name}.evidence entry {rel!r} points " + "into evidence/raw/, which holds raw " + "artifacts, not packets" + ) + continue + if True: + if rel in lane_evidence: + owner = lane_evidence[rel] + errors.append( + f"{manifest_path}: packet {rel} is claimed " + f"by both {owner[0]}.{owner[1]} and " + f"{claim_id}.{lane_name}; one packet has " + "one owner" + ) + lane_evidence[rel] = (str(claim_id), str(lane_name)) + lane_dispositions[rel] = ( + str(lane.get("status")), + lane.get("disposition"), + ) + + # Every lane-referenced path is validated as a packet, wherever it sits. + # Enumerating only `evidence/*.json` would let a lane close a row with a + # file the packet checks never reach (prose, an empty object, or the + # negative control itself) simply by living one directory deeper. + for rel, (claim_id, lane_name) in sorted(lane_evidence.items()): + packet_path = design_dir / rel + if not packet_path.is_file(): + errors.append( + f"{manifest_path}: {claim_id}.lanes.{lane_name} references " + f"missing packet {rel}" + ) + continue + if packet_path.suffix != ".json": + errors.append( + f"{manifest_path}: {claim_id}.lanes.{lane_name} references " + f"{rel} which is not a .json packet" + ) + try: + relative = packet_path.resolve().relative_to(evidence_dir.resolve()) + except ValueError: + errors.append( + f"{manifest_path}: {claim_id}.lanes.{lane_name} references " + f"{rel} outside evidence/" + ) + continue + if relative.parts and relative.parts[0] == "negative-controls": + errors.append( + f"{manifest_path}: {claim_id}.lanes.{lane_name} references " + f"negative control {rel}; a control proves the validator " + "fails closed and can never be a claim's evidence" + ) + + raw_artifacts_dir = evidence_dir / "raw" + packet_paths = ( + sorted( + path + for path in evidence_dir.rglob("*.json") + if negative_dir.resolve() not in path.resolve().parents + and raw_artifacts_dir.resolve() not in path.resolve().parents + and not path.name.endswith(".expected-errors.json") + ) + if evidence_dir.is_dir() + else [] + ) + for rel in lane_evidence: + candidate = design_dir / rel + if candidate.is_file() and candidate not in packet_paths: + packet_paths.append(candidate) + packet_paths = sorted(set(packet_paths)) + for packet_path in packet_paths: + packet = _load_json(packet_path, errors) + if packet is None: + continue + issues = packet_errors( + packet, known_claim_ids=known_ids or None, base_dir=design_dir + ) + errors.extend(f"{packet_path}: {issue}" for issue in issues) + if issues or not isinstance(packet, dict): + continue + rel = packet_path.relative_to(design_dir).as_posix() + linked = lane_evidence.get(rel) + if linked is None: + errors.append( + f"{packet_path}: not referenced by any manifest lane; every " + "packet needs a claim owner" + ) + else: + claim_id, lane_name = linked + if packet.get("claim_id") != claim_id: + errors.append( + f"{packet_path}: claim_id {packet.get('claim_id')} does " + f"not match manifest lane {claim_id}.{lane_name}" + ) + canonical_url, canonical_claim = claim_sources.get(claim_id, (None, None)) + source = packet.get("source") + packet_url = source.get("url") if isinstance(source, dict) else None + packet_claim = source.get("claim") if isinstance(source, dict) else None + if canonical_url is not None and packet_url != canonical_url: + errors.append( + f"{packet_path}: source.url {packet_url!r} does not " + f"match the manifest's canonical source_url for " + f"{claim_id}; a lane must close on evidence for the " + "cited source, not a different one" + ) + if canonical_claim is not None and packet_claim != canonical_claim: + errors.append( + f"{packet_path}: source.claim does not match the " + f"manifest's canonical source_claim for {claim_id}; " + "evidence for a different assertion cannot close this " + "lane" + ) + expected_branch = BRANCH_BY_LANE.get(lane_name) + branch = ( + packet.get("target", {}).get("branch") + if isinstance(packet.get("target"), dict) + else None + ) + if expected_branch is not None and branch != expected_branch: + errors.append( + f"{packet_path}: target.branch {branch!r} does not match " + f"lane {lane_name} branch {expected_branch!r}" + ) + lane_status, lane_disposition = lane_dispositions.get(rel, (None, None)) + if lane_status == "closed": + passes = ( + packet.get("review", {}).get("passes") + if isinstance(packet.get("review"), dict) + else None + ) + if not isinstance(passes, list) or len(passes) < 2: + errors.append( + f"{packet_path}: a packet closing a lane needs at " + "least two recorded review passes" + ) + else: + reviewers = { + unicodedata.normalize( + "NFKC", entry["reviewer"].strip() + ).casefold() + for entry in passes + if isinstance(entry, dict) + and _is_nonempty_str(entry.get("reviewer")) + } + if len(reviewers) < 2: + errors.append( + f"{packet_path}: a packet closing a lane needs " + "two INDEPENDENT review passes (distinct " + "reviewers); a duplicated reviewer is one review" + ) + # A lane's published disposition -- open OR closed -- must be the + # one its evidence records; open lanes may defer (null) but may + # not contradict. + if lane_status in ("closed", "in-progress", "audit-required"): + packet_disposition = ( + packet.get("result", {}).get("disposition") + if isinstance(packet.get("result"), dict) + else None + ) + if lane_status == "closed" or lane_disposition is not None: + if lane_disposition != packet_disposition: + errors.append( + f"{packet_path}: the lane records disposition " + f"{lane_disposition!r} but the packet's result " + f"is {packet_disposition!r}; the manifest cannot " + "publish a conclusion its evidence does not " + "support" + ) + if freshness_head is not None: + commit = ( + packet.get("target", {}).get("commit") + if isinstance(packet.get("target"), dict) + else None + ) + if commit != freshness_head: + errors.append( + f"{packet_path}: target.commit {commit} is not the " + f"current HEAD {freshness_head} (--freshness)" + ) + + negative_paths = ( + sorted(negative_dir.rglob("*.json")) if negative_dir.is_dir() else [] + ) + negative_paths = [ + path + for path in negative_paths + if not path.name.endswith(".expected-errors.json") + ] + if not negative_paths: + errors.append( + f"{negative_dir}: at least one intentionally incomplete " + "negative-control packet is required to prove the validator " + "fails closed" + ) + for packet_path in negative_paths: + packet = _load_json(packet_path, errors) + if packet is None: + continue + issues = packet_errors(packet, known_claim_ids=known_ids or None) + if len(issues) < 3: + errors.append( + f"{packet_path}: negative control produced only " + f"{len(issues)} validation error(s); it must stay clearly " + "incomplete (>= 3) or the fail-closed proof is vacuous" + ) + # A sidecar pins each SEEDED defect individually: if a validator + # check regresses, its expected error disappears and this fails, + # instead of hiding behind three unrelated survivors. + sidecar = packet_path.with_name( + packet_path.name[: -len(".json")] + ".expected-errors.json" + ) + if sidecar.is_file(): + expected = _load_json(sidecar, errors) + if not ( + isinstance(expected, list) + and expected + and all(_is_nonempty_str(item) for item in expected) + ): + errors.append( + f"{sidecar}: must be a non-empty list of expected error " + "substrings" + ) + else: + for needle in expected: + if not any(needle in issue for issue in issues): + errors.append( + f"{packet_path}: seeded defect no longer " + f"detected (no error contains {needle!r}); a " + "validator check regressed" + ) + else: + errors.append( + f"{packet_path}: negative control has no " + ".expected-errors.json sidecar pinning its seeded defects" + ) + + return errors + + +def collect_target_refs(design_dir: Path) -> "list[str]": + """Unique `pull/N/head` refs named by packet fetch hints (negative + controls included; fetching an extra ref is harmless).""" + refs: set[str] = set() + for packet_path in sorted(design_dir.rglob("*.json")): + try: + packet = json.loads(packet_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if not isinstance(packet, dict): + continue + target = packet.get("target") + hint = target.get("fetch_hint") if isinstance(target, dict) else None + match = FETCH_HINT_RE.match(hint.strip()) if isinstance(hint, str) else None + if match is not None: + refs.add(f"pull/{match.group(1)}/head") + return sorted(refs) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--design-dir", + type=Path, + default=DESIGN_DIR, + help="Design sidecar directory holding the manifest and evidence", + ) + parser.add_argument( + "--freshness", + action="store_true", + help="Require every evidence packet to record the current HEAD", + ) + parser.add_argument( + "--fetch-target-refs", + action="store_true", + help=( + "Fetch every packet's recorded PR head ref into the local " + "object store and exit (CI aid: keeps squash-merged target " + "commits verifiable); validation still reports any ref that " + "stays unavailable" + ), + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.fetch_target_refs: + for ref in collect_target_refs(args.design_dir): + fetched = subprocess.run( + ["git", "-C", str(REPO_ROOT), "fetch", "origin", ref], + capture_output=True, + text=True, + check=False, + ) + state = "fetched" if fetched.returncode == 0 else "unavailable" + print(f"check_citation_evidence: {ref}: {state}") + return 0 + freshness_head: str | None = None + if args.freshness: + freshness_head = _git_head(REPO_ROOT) + if freshness_head is None: + print( + "check_citation_evidence: --freshness requires a readable git HEAD", + file=sys.stderr, + ) + return 1 + errors = validate_tree(args.design_dir, freshness_head=freshness_head) + if errors: + print(f"check_citation_evidence: {len(errors)} error(s)", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + print("check_citation_evidence: OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/citation_packet_utils.py b/scripts/citation_packet_utils.py new file mode 100644 index 0000000000000..80353598b192f --- /dev/null +++ b/scripts/citation_packet_utils.py @@ -0,0 +1,147 @@ +"""Shared helpers for release-6.20 citation evidence packets. + +Centralizes the typed-unsupported markers for quantities DART 6.20 does not +report, so no packet can quietly publish a sentinel zero as a measurement. +Each marker names why the quantity is unavailable on this branch. + +Additive tooling only: no library, API, ABI, or default change. +""" + +from __future__ import annotations + +from typing import Any + +# BoxedLcpConstraintSolver exposes neither an iteration count nor a residual +# per solve on this branch, and the Dantzig-primary/PGS-secondary fallback +# raises no observable event. +UNSUPPORTED_SOLVER_ITERATIONS: dict[str, str] = { + "status": "unsupported", + "reason": ( + "release-6.20 exposes no per-solve LCP iteration count: " + "BoxedLcpConstraintSolver has no getNumIterations()-style accessor, " + "so an iteration number cannot be recorded without changing public " + "API, which the LTS compatibility contract forbids." + ), +} + +UNSUPPORTED_SOLVER_RESIDUAL: dict[str, str] = { + "status": "unsupported", + "reason": ( + "release-6.20 exposes no per-solve LCP residual: the boxed-LCP " + "solvers report no convergence measure through public API, so a " + "residual cannot be recorded without an API change the LTS " + "compatibility contract forbids." + ), +} + +UNSUPPORTED_FALLBACK_EVENTS: dict[str, str] = { + "status": "unsupported", + "reason": ( + "The Dantzig-primary/PGS-secondary fallback inside " + "BoxedLcpConstraintSolver emits no observable event on this branch, " + "so per-solve fallback occurrences cannot be counted." + ), +} + +# A ratio against a peak of exactly zero is undefined, not zero. +UNSUPPORTED_ANTISYMMETRY_RATIO: dict[str, str] = { + "status": "unsupported", + "reason": ( + "Peak lateral drift is exactly zero for this detector, so the " + "antisymmetry-to-peak ratio is undefined rather than zero." + ), +} + + +# PLAN-623's evidence lands through this pull request; its head ref survives +# branch deletion and squash-merge, which is what makes recorded target +# commits reproducible from a clean checkout. +CITATION_PR_NUMBER = 3444 + + +def target_fetch_hint(commit: str) -> str: + """The runnable command reaching `commit` from a clean checkout forever. + + CITATION_PR_NUMBER is the PR that owns this evidence tree revision. When + a later PR takes ownership, update this constant TOGETHER WITH the + validator's FETCH_HINT_RE in the same change; there is deliberately no + env override, because a hint the branch validator rejects would only + manufacture invalid packets. + """ + return ( + f"git fetch origin pull/{CITATION_PR_NUMBER}/head && " f"git checkout {commit}" + ) + + +def packet_content_digest(packet: dict[str, Any]) -> str: + """Digest of a packet minus its review block (the validator's algorithm). + + A review pass binds to this digest, so regenerating a packet with + different content invalidates prior passes instead of carrying them onto + evidence they never reviewed. + """ + import hashlib as _hashlib + import json as _json + + content = {key: value for key, value in packet.items() if key != "review"} + return ( + "sha256:" + + _hashlib.sha256( + _json.dumps(content, sort_keys=True, separators=(",", ":")).encode("utf-8") + ).hexdigest() + ) + + +def preserve_review( + output_path: Any, new_packet: dict[str, Any] | None = None +) -> dict[str, Any]: + """Carry forward review passes that still review THIS packet. + + Packets are generated, but review passes are recorded by people and other + agents afterwards. A pass is kept only when its `content_digest` matches + the regenerated packet's content: reviews of an earlier packet (different + target commit, scene, metrics, or conclusion) are dropped so the + two-review floor cannot be satisfied by evidence nobody re-reviewed. + Passing `new_packet=None` (legacy call) keeps nothing, which fails closed. + """ + try: + import json as _json + from pathlib import Path as _Path + + existing = _json.loads(_Path(output_path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {"passes": []} + review = existing.get("review") + if not (isinstance(review, dict) and isinstance(review.get("passes"), list)): + return {"passes": []} + if new_packet is None: + return {"passes": []} + expected = packet_content_digest(new_packet) + kept = [ + entry + for entry in review["passes"] + if isinstance(entry, dict) and entry.get("content_digest") == expected + ] + return {"passes": kept} + + +def record_review_pass(packet_path: Any, reviewer: str, summary: str) -> None: + """Append a review pass bound to the packet's current content digest.""" + import json as _json + from pathlib import Path as _Path + + path = _Path(packet_path) + packet = _json.loads(path.read_text(encoding="utf-8")) + review = packet.setdefault("review", {}) + passes = review.setdefault("passes", []) + passes.append( + { + "reviewer": reviewer, + "summary": summary, + "verdict": "pass", + "content_digest": packet_content_digest(packet), + } + ) + path.write_text( + _json.dumps(packet, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) diff --git a/scripts/write_citation_ct001_rolling_direction_packet.py b/scripts/write_citation_ct001_rolling_direction_packet.py new file mode 100644 index 0000000000000..3df9f375b751f --- /dev/null +++ b/scripts/write_citation_ct001_rolling_direction_packet.py @@ -0,0 +1,793 @@ +#!/usr/bin/env python3 +"""Write the CT-001 rolling-direction evidence packet for release-6.20. + +Bounded claim (PLAN-123 corpus row CT-001, owned on `main`): polyhedral +friction can produce direction-dependent rolling/sliding behavior. Under an +isotropic Coulomb model a sphere launched sliding (no spin) on a horizontal +plane behaves identically for every in-plane launch direction; a friction +pyramid aligned to fixed tangent axes breaks that rotational symmetry. + +DART 6 lane: reproduce with existing methods/detectors only. The fixture +launches one sphere per run across a swept launch angle on a static ground +box and repeats the sweep for every collision detector available in this +build (`fcl` default, `dart`, plus `bullet`/`ode` when compiled), keeping the +default boxed-LCP constraint solver. Per run it records lateral drift from +the launch ray, final-velocity heading error, along-ray travel, +slide-to-roll transition time, kinetic-energy gain bound, max penetration, +and contact counts; each cell runs twice and must be bit-identical. + +Additive evidence tooling only: no library, API, ABI, or default change. + +Usage (after `pixi run build` with dartpy): + + PYTHONPATH=build/default/cpp/Release/python/dartpy pixi run python \ + scripts/write_citation_ct001_rolling_direction_packet.py +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import platform +import subprocess +import sys +from pathlib import Path +from typing import Any + +import dartpy as dart +import numpy as np +from citation_packet_utils import ( + UNSUPPORTED_ANTISYMMETRY_RATIO, + UNSUPPORTED_FALLBACK_EVENTS, + UNSUPPORTED_SOLVER_ITERATIONS, + UNSUPPORTED_SOLVER_RESIDUAL, + preserve_review, + target_fetch_hint, +) + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OUTPUT = ( + REPO_ROOT + / "docs" + / "design" + / "dart6_citation_driven_contact_trust" + / "evidence" + / "CT-001-dart6-rolling-direction.json" +) + +SCENE_PARAMETERS: dict[str, Any] = { + "scene_id": "ct001_rolling_direction_sweep_dart6", + "description": ( + "One 1 kg sphere per run, radius 0.08 m, launched sliding (no spin) " + "at 1.0 m/s along a swept in-plane angle on a static ground box, " + "restitution 0, friction 0.35 on both bodies, default boxed-LCP " + "constraint solver, one sweep per available collision detector." + ), + "gravity_mps2": [0.0, 0.0, -9.81], + "time_step_s": 0.002, + "step_count": 500, + "sphere_radius_m": 0.08, + "sphere_mass_kg": 1.0, + "launch_speed_mps": 1.0, + "friction": 0.35, + "restitution": 0.0, + "ground_half_extents_m": [2.5, 2.5, 0.05], + "launch_angles_deg": [0.0, 15.0, 30.0, 45.0, 60.0, 75.0, 90.0], + "deterministic_repeats": 2, + "slip_ratio_rolling_threshold": 0.02, +} + +DETECTOR_FACTORIES = ( + ("fcl", "FCLCollisionDetector"), + ("dart", "DARTCollisionDetector"), + ("bullet", "BulletCollisionDetector"), + ("ode", "OdeCollisionDetector"), +) + + +def available_detectors() -> list[str]: + return [ + key + for key, class_name in DETECTOR_FACTORIES + if hasattr(dart.collision, class_name) + ] + + +def make_detector(key: str): + for candidate, class_name in DETECTOR_FACTORIES: + if candidate == key: + return getattr(dart.collision, class_name)() + raise SystemExit(f"unknown collision detector key {key!r}") + + +def scene_digest(parameters: dict[str, Any]) -> str: + canonical = json.dumps(parameters, sort_keys=True, separators=(",", ":")) + return "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +def _make_sphere(parameters: dict[str, Any]) -> tuple[Any, Any, Any]: + radius = float(parameters["sphere_radius_m"]) + mass = float(parameters["sphere_mass_kg"]) + skel = dart.dynamics.Skeleton("ct001_sphere") + joint, body = skel.createFreeJointAndBodyNodePair(None) + body.setName("ct001_sphere_body") + shape = dart.dynamics.SphereShape(radius) + shape_node = body.createShapeNode(shape) + shape_node.createCollisionAspect() + dynamics_aspect = shape_node.createDynamicsAspect() + dynamics_aspect.setFrictionCoeff(float(parameters["friction"])) + dynamics_aspect.setRestitutionCoeff(float(parameters["restitution"])) + body.setInertia( + dart.dynamics.Inertia(mass, np.zeros(3), shape.computeInertia(mass)) + ) + start = np.array([0.0, 0.0, radius]) + joint.setTransform(dart.math.Isometry3(np.eye(3), start)) + return skel, joint, body + + +def _make_ground(parameters: dict[str, Any]) -> Any: + half = np.asarray(parameters["ground_half_extents_m"], dtype=float) + skel = dart.dynamics.Skeleton("ct001_ground") + _, body = skel.createFreeJointAndBodyNodePair(None) + body.setName("ct001_ground_body") + shape = dart.dynamics.BoxShape(2.0 * half) + shape_node = body.createShapeNode(shape) + shape_node.createCollisionAspect() + dynamics_aspect = shape_node.createDynamicsAspect() + dynamics_aspect.setFrictionCoeff(float(parameters["friction"])) + dynamics_aspect.setRestitutionCoeff(float(parameters["restitution"])) + skel.getJoint(0).setTransform( + dart.math.Isometry3(np.eye(3), np.array([0.0, 0.0, -half[2]])) + ) + skel.setMobile(False) + return skel + + +def run_single( + detector_key: str, angle_deg: float, parameters: dict[str, Any] +) -> dict[str, Any]: + """Run one launch and return raw metrics plus a trajectory hash.""" + radius = float(parameters["sphere_radius_m"]) + speed = float(parameters["launch_speed_mps"]) + dt = float(parameters["time_step_s"]) + step_count = int(parameters["step_count"]) + angle = math.radians(angle_deg) + direction = np.array([math.cos(angle), math.sin(angle), 0.0]) + lateral_axis = np.array([-direction[1], direction[0], 0.0]) + + world = dart.simulation.World("ct001") + world.setGravity(np.asarray(parameters["gravity_mps2"], dtype=float)) + world.setTimeStep(dt) + constraint_solver = world.getConstraintSolver() + constraint_solver.setCollisionDetector(make_detector(detector_key)) + + world.addSkeleton(_make_ground(parameters)) + sphere_skel, joint, body = _make_sphere(parameters) + world.addSkeleton(sphere_skel) + + joint.setVelocities(np.concatenate([np.zeros(3), speed * direction])) + launch_velocity = np.asarray(body.getLinearVelocity(), dtype=float) + if not np.allclose(launch_velocity, speed * direction, atol=1.0e-12): + raise SystemExit( + f"free-joint launch velocity readback {launch_velocity} does not " + f"match requested {speed * direction}" + ) + + resolved = { + "collision_detector": constraint_solver.getCollisionDetector().getType(), + "constraint_solver": type(constraint_solver).__name__, + "gravity_mps2": [float(v) for v in np.asarray(world.getGravity())], + "time_step_s": float(world.getTimeStep()), + } + + trajectory = hashlib.sha256() + start = np.array([0.0, 0.0, radius]) + slide_end_time = None + max_energy_gain = 0.0 + max_penetration = 0.0 + contact_count_max = 0 + # Start the energy-gain gate from the launched (pre-step) state so the + # first contact solve is covered; an initial injection that later steps + # dissipate must not escape the physical-validity gate. + previous_energy = float(sphere_skel.computeKineticEnergy()) + + finite = True + for step_index in range(step_count): + world.step() + position = np.asarray(body.getTransform().translation(), dtype=float) + velocity = np.asarray(body.getLinearVelocity(), dtype=float) + angular = np.asarray(body.getAngularVelocity(), dtype=float) + # NaN comparisons are False, so the analytic-speed validity gates + # would silently PASS a NaN trajectory; track finiteness explicitly + # over the full observed state. + if not ( + np.all(np.isfinite(position)) + and np.all(np.isfinite(velocity)) + and np.all(np.isfinite(angular)) + and np.all( + np.isfinite(np.asarray(body.getTransform().matrix(), dtype=float)) + ) + ): + finite = False + trajectory.update(position.tobytes()) + trajectory.update(velocity.tobytes()) + trajectory.update(angular.tobytes()) + + kinetic = float(sphere_skel.computeKineticEnergy()) + max_energy_gain = max(max_energy_gain, kinetic - previous_energy) + previous_energy = kinetic + + collision_result = world.getLastCollisionResult() + contacts = collision_result.getContacts() + contact_count_max = max(contact_count_max, len(contacts)) + for contact in contacts: + max_penetration = max(max_penetration, float(contact.penetrationDepth)) + + if slide_end_time is None: + planar_speed = float(np.linalg.norm(velocity[:2])) + spin_axis = np.array([-direction[1], direction[0], 0.0]) + surface_speed = radius * float(np.dot(angular, spin_axis)) + slip = abs(planar_speed - surface_speed) + denom = planar_speed + abs(surface_speed) + 1.0e-9 + if slip / denom < float(parameters["slip_ratio_rolling_threshold"]): + slide_end_time = (step_index + 1) * dt + + final_position = np.asarray(body.getTransform().translation(), dtype=float) + final_velocity = np.asarray(body.getLinearVelocity(), dtype=float) + displacement = final_position - start + along = float(np.dot(displacement, direction)) + lateral = float(np.dot(displacement, lateral_axis)) + planar_speed = float(np.linalg.norm(final_velocity[:2])) + if planar_speed > 1.0e-9: + heading_error_deg = math.degrees( + math.atan2( + float(np.dot(final_velocity, lateral_axis)), + float(np.dot(final_velocity, direction)), + ) + ) + else: + heading_error_deg = 0.0 + + return { + "angle_deg": angle_deg, + "collision_detector": detector_key, + "resolved": resolved, + "finite": finite, + "trajectory_sha256": trajectory.hexdigest(), + "along_travel_m": along, + "lateral_drift_m": lateral, + "heading_error_deg": heading_error_deg, + "final_planar_speed_mps": planar_speed, + "slide_end_time_s": slide_end_time, + "max_energy_gain_j": max_energy_gain, + "max_penetration_m": max_penetration, + "max_contact_count": contact_count_max, + "final_height_m": float(final_position[2]), + } + + +def antisymmetry_residual(rows: list[dict[str, Any]]) -> float: + """Largest |d(theta) + d(90-theta)| over the sweep. + + A friction pyramid aligned to the tangent axes makes lateral drift + antisymmetric about 45 degrees, so this residual is ~0 for a genuine + pyramid signature and comparable to the peak drift for isotropic + contact-generation scatter. Exceeding a drift tolerance is not by itself + evidence of the cited mechanism; this statistic is what separates them. + """ + by_angle = {row["angle_deg"]: row["lateral_drift_m"] for row in rows} + residual = 0.0 + for angle, drift in by_angle.items(): + mirror = by_angle.get(90.0 - angle) + if mirror is not None: + residual = max(residual, abs(drift + mirror)) + return residual + + +def summarize(rows: list[dict[str, Any]], detectors: list[str]) -> dict[str, Any]: + """Per-detector rotational-symmetry summary across the angle sweep.""" + summary: dict[str, Any] = {} + for detector in detectors: + detector_rows = [row for row in rows if row["collision_detector"] == detector] + drifts = [abs(row["lateral_drift_m"]) for row in detector_rows] + travels = [row["along_travel_m"] for row in detector_rows] + headings = [abs(row["heading_error_deg"]) for row in detector_rows] + travel_mean = sum(travels) / len(travels) + travel_spread = max(travels) - min(travels) + summary[detector] = { + "runs": len(detector_rows), + "max_abs_lateral_drift_m": max(drifts), + "max_abs_heading_error_deg": max(headings), + "travel_mean_m": travel_mean, + "travel_spread_m": travel_spread, + "travel_spread_relative": ( + travel_spread / travel_mean if travel_mean > 0.0 else 0.0 + ), + "max_energy_gain_j": max(row["max_energy_gain_j"] for row in detector_rows), + "max_penetration_m": max(row["max_penetration_m"] for row in detector_rows), + "min_final_height_m": min(row["final_height_m"] for row in detector_rows), + "max_contact_count": max(row["max_contact_count"] for row in detector_rows), + "antisymmetry_residual_m": antisymmetry_residual(detector_rows), + "antisymmetry_residual_over_peak_drift": ( + antisymmetry_residual(detector_rows) / max(drifts) + if max(drifts) > 0.0 + else dict(UNSUPPORTED_ANTISYMMETRY_RATIO) + ), + } + return summary + + +def git_head() -> str: + """HEAD commit, refusing to attribute a dirty tree's results to it. + + A packet's target.commit claims the recorded results come from that + commit's code; uncommitted modifications to tracked files would make + that attribution false, so generation aborts instead. (The packet + output file itself is checked before being overwritten, so an + unmodified existing packet does not block regeneration.) + """ + dirty = subprocess.run( + [ + "git", + "status", + "--porcelain", + "--untracked-files=no", + "--", + ".", + ":(exclude)docs/plans/123-citation-driven-simulation-trust/evidence", + ":(exclude)docs/design/dart6_citation_driven_contact_trust/evidence", + ], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + if dirty: + raise SystemExit( + "refusing to generate evidence from a dirty tree; commit or " + "stash these tracked modifications first:\n" + dirty + ) + return subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def build_packet(output_path: Path | None = None) -> dict[str, Any]: + parameters = dict(SCENE_PARAMETERS) + detectors = available_detectors() + parameters["collision_detectors"] = detectors + + rows: list[dict[str, Any]] = [] + determinism_failures: list[str] = [] + resolved_by_detector: dict[str, dict[str, Any]] = {} + + for detector in detectors: + for angle in parameters["launch_angles_deg"]: + repeats = [ + run_single(detector, angle, parameters) + for _ in range(int(parameters["deterministic_repeats"])) + ] + hashes = {run["trajectory_sha256"] for run in repeats} + if len(hashes) != 1: + determinism_failures.append( + f"{detector} angle {angle}: trajectory hashes differ " + f"{sorted(hashes)}" + ) + row = repeats[0] + for repeat in repeats: + readback = repeat["resolved"] + if readback["collision_detector"] != detector: + raise SystemExit( + f"requested detector {detector} but readback reports " + f"{readback['collision_detector']}" + ) + if readback["time_step_s"] != float(parameters["time_step_s"]): + raise SystemExit( + f"requested timestep {parameters['time_step_s']} but " + f"readback reports {readback['time_step_s']}" + ) + if readback["gravity_mps2"] != list(parameters["gravity_mps2"]): + raise SystemExit( + f"requested gravity {parameters['gravity_mps2']} but " + f"readback reports {readback['gravity_mps2']}" + ) + if readback != row["resolved"]: + raise SystemExit( + f"{detector} angle {angle}: resolved configuration " + f"differs between repeats: {readback} vs " + f"{row['resolved']}" + ) + row["repeat_trajectory_sha256"] = [ + repeat["trajectory_sha256"] for repeat in repeats + ] + previous = resolved_by_detector.setdefault(detector, row["resolved"]) + if previous != row["resolved"]: + raise SystemExit( + f"{detector}: resolved configuration drifted across the " + f"sweep: {previous} vs {row['resolved']}" + ) + rows.append(row) + + if determinism_failures: + raise SystemExit( + "deterministic repeats failed:\n " + "\n ".join(determinism_failures) + ) + + summary = summarize(rows, detectors) + isotropy_tolerance = { + "max_abs_lateral_drift_m": 1.0e-4, + "max_abs_heading_error_deg": 0.1, + "travel_spread_relative": 0.01, + } + # Record which criterion fired per detector and whether the drift carries + # the antisymmetric pyramid signature. Exceeding a drift tolerance with no + # angular structure is contact-generation scatter, not the cited + # orientation-dependent mechanism, and must not be counted as a fourth + # corroborating instance. + anisotropy_findings: dict[str, Any] = {} + for detector, stats in summary.items(): + criteria = sorted( + key + for key, tolerance in isotropy_tolerance.items() + if isinstance(stats.get(key), (int, float)) and stats[key] > tolerance + ) + ratio = stats["antisymmetry_residual_over_peak_drift"] + has_signature = isinstance(ratio, (int, float)) and ratio <= 0.05 + anisotropy_findings[detector] = { + "criteria_exceeded": criteria, + "pyramid_signature": has_signature, + "signature_test": ( + "lateral drift antisymmetric about 45 deg to within 5% of " "peak drift" + ), + "attribution": ( + "orientation-dependent friction-pyramid anisotropy" + if has_signature and criteria + else ( + "scatter exceeding tolerance without angular structure; " + "not attributed to polyhedral friction by this packet" + if criteria + else "within isotropy tolerance" + ) + ), + } + anisotropic_detectors = sorted( + detector + for detector, finding in anisotropy_findings.items() + if finding["criteria_exceeded"] and finding["pyramid_signature"] + ) + # Detectors that exceed tolerance WITHOUT the pyramid signature are + # excluded from the reproducing set. The packet's conclusions are built + # from these computed sets so a build without bullet, or a bullet that + # starts conforming, regenerates consistent text instead of asserting + # one historical run's results. + nonconforming_detectors = sorted( + detector + for detector, finding in anisotropy_findings.items() + if finding["criteria_exceeded"] and not finding["pyramid_signature"] + ) + + # Detectors whose trajectory hashes match exactly are one measurement, not + # several; recording that keeps the sweep from reading as more independent + # corroboration than it is. + hash_groups: dict[tuple[str, ...], list[str]] = {} + for detector in detectors: + key = tuple( + row["trajectory_sha256"] + for row in rows + if row["collision_detector"] == detector + ) + hash_groups.setdefault(key, []).append(detector) + identical_detector_groups = sorted( + sorted(group) for group in hash_groups.values() if len(group) > 1 + ) + + # A degenerate run (tunnelling, blow-up) would also break symmetry, so the + # disposition is gated on physical validity, not on deviation alone. + radius = float(parameters["sphere_radius_m"]) + validity_failures = [ + f"{row['collision_detector']} angle {row['angle_deg']}: {reason}" + for row in rows + for reason, bad in ( + ("non-finite state", not row["finite"]), + ( + "final speed departs from the analytic rolling speed 5/7 v0", + abs( + row["final_planar_speed_mps"] + - (5.0 / 7.0) * float(parameters["launch_speed_mps"]) + ) + > 1.0e-2, + ), + ("never reached rolling", row["slide_end_time_s"] is None), + ( + "sphere sank below its own radius (fall-through)", + row["final_height_m"] < 0.5 * radius, + ), + ( + "penetration exceeded a tenth of the radius", + row["max_penetration_m"] > 0.1 * radius, + ), + ( + # A sliding sphere must dissipate; a per-step kinetic gain + # above a thousandth of the launch energy is a blow-up + # signature, which the gate must catch before any symmetry + # verdict is trusted. + "kinetic energy injected during the run", + row["max_energy_gain_j"] + > 1.0e-3 + * 0.5 + * float(parameters["sphere_mass_kg"]) + * float(parameters["launch_speed_mps"]) ** 2, + ), + ) + if bad + ] + disposition = ( + "reproduced" + if anisotropic_detectors and not validity_failures + else "unresolved" + ) + + command = ( + "PYTHONPATH=build/default/cpp/Release/python/dartpy pixi run python " + "scripts/write_citation_ct001_rolling_direction_packet.py" + ) + packet: dict[str, Any] = { + "schema": "dart.citation_claim_evidence/v1", + "claim_id": "CT-001", + "title": ( + "Rolling-direction friction dependence " "(release-6.20 detector sweep)" + ), + "source": { + "url": "https://leggedrobotics.github.io/SimBenchmark/", + "claim": ( + "Polyhedral friction can produce direction-dependent " + "rolling/sliding behavior." + ), + }, + "target": { + "branch": "release-6.20", + "commit": (head_commit := git_head()), + "fetch_hint": target_fetch_hint(head_commit), + }, + "scene": { + "id": parameters["scene_id"], + "digest": scene_digest(parameters), + "description": parameters["description"], + "parameters": parameters, + }, + "configuration": { + "requested": { + "collision_detector_sweep": detectors, + "constraint_solver": "World default (boxed LCP)", + "integrator": "World::step semi-implicit default", + "precision": "float64", + "backend": "cpu", + "threads": "single-threaded default", + }, + "resolved": { + "by_collision_detector": resolved_by_detector, + }, + "resolved_provenance": ( + "ConstraintSolver.getCollisionDetector().getType() readback " + "after setCollisionDetector plus constraint-solver type " + "name; the writer aborts if readback differs from the " + "request: detector type, timestep, and gravity are each " + "asserted per run and across repeats. Boxed-LCP internal " + "Dantzig/PGS selection is not exposed per solve on " + "release-6.20." + ), + "detector": "swept: " + ", ".join(detectors), + "timestep": parameters["time_step_s"], + "substeps": 1, + "iterations": ( + "Dantzig direct solve with PGS fallback; per-solve iteration " + "counts are not exposed on release-6.20 and are typed " + "unsupported in metrics.numerical.solver_iterations rather " + "than reported as zero" + ), + "fallback_policy": ( + "BoxedLcpConstraintSolver secondary-solver fallback " + "(default); per-solve fallback events are not exposed on " + "release-6.20" + ), + }, + "ensemble": { + "kind": "parameter-sweep-with-deterministic-repeats", + "sweep": [ + {"collision_detector": detector, "angle_deg": angle} + for detector in detectors + for angle in parameters["launch_angles_deg"] + ], + "deterministic_repeats": int(parameters["deterministic_repeats"]), + "deterministic_repeats_identical": not determinism_failures, + "measurement_window": { + "start_s": 0.0, + "end_s": parameters["time_step_s"] * parameters["step_count"], + "steps": parameters["step_count"], + }, + }, + "metrics": { + "physical": { + "method": ( + "Rotational-symmetry sweep: per-angle lateral drift " + "from the launch ray, final-velocity heading error, " + "along-ray travel, slide-to-roll transition time, and " + "max single-step kinetic-energy gain from " + "Skeleton.computeKineticEnergy" + ), + "per_detector_summary": summary, + "isotropy_tolerance": isotropy_tolerance, + "anisotropy_findings": anisotropy_findings, + "anisotropic_detectors": anisotropic_detectors, + "identical_detector_groups": identical_detector_groups, + "validity_failures": validity_failures, + }, + "numerical": { + "method": ( + "Max over run of contact penetrationDepth and contact " + "count from World.getLastCollisionResult; sphere final " + "height is checked against fall-through in the " + "disposition validity gate" + ), + "max_penetration_m": max(row["max_penetration_m"] for row in rows), + "max_contact_count": max(row["max_contact_count"] for row in rows), + "min_final_height_m": min(row["final_height_m"] for row in rows), + "solver_iterations": dict(UNSUPPORTED_SOLVER_ITERATIONS), + "solver_residual": dict(UNSUPPORTED_SOLVER_RESIDUAL), + "solver_fallback_events": dict(UNSUPPORTED_FALLBACK_EVENTS), + }, + "performance": { + "status": "unsupported", + "reason": ( + "This packet makes no timing claim; interleaved " + "same-host methodology is required before any " + "performance row" + ), + }, + "allocation": { + "status": "unsupported", + "reason": ( + "No allocation methodology on this row; DART 6 has no " + "PLAN-122-style allocation gates and this packet does " + "not measure allocations" + ), + }, + }, + "evidence": { + "commands": ["pixi run build", command], + "raw_rows": rows, + "visual": { + "status": "not-applicable", + "reason": ( + "The oracle is numeric rotational symmetry; per-angle " + "trajectories carry no visible-behavior claim beyond " + "the recorded metrics." + ), + }, + }, + "result": { + "disposition": disposition, + "claim_boundary": ( + "release-6.20, this commit, one sphere sliding to rolling " + "on a static ground box at v0=1 m/s, mu=0.35, dt=2 ms, 1 s " + "horizon, default boxed-LCP constraint solver, launch " + "angles 0-90 deg in 15 deg steps, detectors " + + ", ".join(detectors) + + ". " + + ( + "The claim reproduces on " + + ", ".join(anisotropic_detectors) + + ", which show the antisymmetric friction-pyramid " + "signature" + if anisotropic_detectors + else ( + "No swept detector shows the antisymmetric " + "friction-pyramid signature" + ) + ) + + ( + "; excluded for exceeding the drift tolerance without " + "that angular structure: " + ", ".join(nonconforming_detectors) + if nonconforming_detectors + else "" + ) + + ". Says nothing about other " + "speeds, shapes, stacks, historical DART versions, or DART 7." + ), + "limitations": [ + "Per-solve LCP iteration counts, residuals, and " + "Dantzig-vs-PGS fallback events are not exposed on " + "release-6.20; they are typed unsupported in " + "metrics.numerical rather than reported as zero, and solver " + "identity is type-level readback.", + ] + + [ + f"{detector} exceeds the drift tolerance without the " + "antisymmetric pyramid signature " + f"({anisotropy_findings[detector]['attribution']}); its " + "scatter is NOT attributed to polyhedral friction by this " + "packet and it is excluded from the reproducing set. Its " + "per-angle statistics are in metrics.physical and the raw " + "rows." + for detector in nonconforming_detectors + ] + + ( + [ + "Detector groups " + + "; ".join(", ".join(group) for group in identical_detector_groups) + + " produce bit-identical trajectory hashes at every " + "angle, so the sweep contains fewer independent " + "measurements than detectors; see " + "metrics.physical.identical_detector_groups." + ] + if identical_detector_groups + else [] + ) + + [ + "The sweep covers 0-90 deg; pyramid orientation with a " + "period other than 90 deg would need a wider sweep.", + "Detector availability depends on the build; the packet " + "records the swept set explicitly.", + "This packet is not a cross-detector accuracy ranking; it " + "only measures rotational-symmetry breaking per detector.", + ], + }, + "review": {"passes": []}, + "host": { + "platform": platform.platform(), + "python": sys.version.split()[0], + "machine": platform.machine(), + "performance_valid": False, + "note": ( + "Host recorded for provenance only; no timing methodology " + "was applied" + ), + }, + } + if output_path is not None: + # Rebind after assembly: only passes whose content_digest matches the + # regenerated packet survive (see preserve_review). + packet["review"] = preserve_review(output_path, packet) + return packet + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", + type=Path, + default=DEFAULT_OUTPUT, + help=f"Packet output path (default: {DEFAULT_OUTPUT})", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + packet = build_packet(args.output) + 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" + ) + summary = packet["metrics"]["physical"]["per_detector_summary"] + print(f"wrote {args.output}") + for detector, stats in summary.items(): + print( + f" {detector}: max |lateral drift| " + f"{stats['max_abs_lateral_drift_m']:.3e} m, max |heading err| " + f"{stats['max_abs_heading_error_deg']:.3e} deg, travel spread " + f"{stats['travel_spread_relative']:.3e} rel, min height " + f"{stats['min_final_height_m']:.4f} m" + ) + print(f" disposition: {packet['result']['disposition']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_citation_evidence.py b/tests/test_check_citation_evidence.py new file mode 100644 index 0000000000000..e4d526b3bf377 --- /dev/null +++ b/tests/test_check_citation_evidence.py @@ -0,0 +1,1967 @@ +"""Tests for the release-6.20 citation evidence validator (fail-closed). + +Every required provenance field must fail validation when missing or +degraded; a complete packet must pass; negative-control packets must fail. +""" + +import copy +import hashlib +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "check_citation_evidence.py" +DESIGN_DIR = ROOT / "docs" / "design" / "dart6_citation_driven_contact_trust" + +# The validator verifies target commits against the real object store and +# harness paths against the real scripts/ tree, so the passing fixture must +# use a commit and harness that actually exist. +HEAD_COMMIT = subprocess.run( + ["git", "-C", str(ROOT), "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, +).stdout.strip() +HARNESS = "scripts/write_citation_ct001_rolling_direction_packet.py" +assert (ROOT / HARNESS).is_file() + + +def _load_module(): + spec = importlib.util.spec_from_file_location("check_citation_evidence", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +MODULE = _load_module() +LANE = "dart6" + + +def complete_packet() -> dict: + return { + "schema": "dart.citation_claim_evidence/v1", + "claim_id": "CT-001", + "title": "Test packet", + "source": {"url": "https://example.org/claim", "claim": "A claim."}, + "target": { + "branch": "release-6.20", + "commit": HEAD_COMMIT, + "fetch_hint": ( + "git fetch origin pull/3444/head && git checkout " + HEAD_COMMIT + ), + }, + "scene": { + "id": "test_scene", + "digest": ( + "sha256:" + + hashlib.sha256( + json.dumps( + {"gravity": -9.81}, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + ).hexdigest() + ), + "parameters": {"gravity": -9.81}, + "description": "A scene.", + }, + "configuration": { + "requested": {"collision_detector": "fcl", "solver": "boxed-lcp-dantzig"}, + "resolved": {"collision_detector": "fcl", "solver": "boxed-lcp-dantzig"}, + "resolved_provenance": "ConstraintSolver/CollisionDetector readback", + "detector": "fcl (PRIMITIVE default)", + "timestep": 0.001, + "substeps": 1, + "iterations": "defaults", + "fallback_policy": "boxed LCP secondary fallback; none observed", + }, + "ensemble": { + "kind": "deterministic-repeats", + "deterministic_repeats": 2, + "deterministic_repeats_identical": True, + "measurement_window": {"start_s": 0.0, "end_s": 1.0}, + }, + "metrics": { + "physical": { + "method": "sweep", + "lateral_drift_m": 0.0, + "measured_zero_fields": ["lateral_drift_m"], + }, + "numerical": {"method": "penetration probe", "max_penetration_m": 1e-5}, + "performance": { + "status": "unsupported", + "reason": "no timing methodology", + }, + "allocation": { + "status": "unsupported", + "reason": "no allocation methodology on this row", + }, + }, + "evidence": { + "commands": [ + "pixi run build", + "pixi run python " + HARNESS, + ], + "raw_rows": [ + { + "angle_deg": 0.0, + "lateral_drift_m": 0.0, + "trajectory_sha256": "d" * 64, + } + ], + "visual": { + "status": "not-applicable", + "reason": "numeric oracle only", + }, + }, + "result": { + "disposition": "unresolved", + "claim_boundary": "This commit, this scene only.", + "limitations": ["Single fixture."], + }, + "review": {"passes": []}, + "host": { + "platform": "test-host", + "python": "3.14", + "performance_valid": False, + }, + } + + +def test_complete_packet_passes(): + assert MODULE.packet_errors(complete_packet()) == [] + + +def test_wrong_branch_fails(): + packet = complete_packet() + packet["target"]["branch"] = "main" + errors = MODULE.packet_errors(packet) + assert any("target.branch" in error for error in errors) + + +@pytest.mark.parametrize( + "path", + [ + ("target", "commit"), + ("scene", "digest"), + ("configuration", "requested"), + ("configuration", "resolved"), + ("configuration", "resolved_provenance"), + ("configuration", "detector"), + ("configuration", "timestep"), + ("configuration", "fallback_policy"), + ("ensemble", "measurement_window"), + ("evidence", "commands"), + ("result", "disposition"), + ("result", "claim_boundary"), + ("result", "limitations"), + ], +) +def test_each_required_field_fails_closed(path): + packet = complete_packet() + section, field = path + del packet[section][field] + errors = MODULE.packet_errors(packet) + assert errors, f"deleting {section}.{field} must fail validation" + assert any(field in error for error in errors) + + +def test_single_run_ensemble_fails(): + packet = complete_packet() + packet["ensemble"]["deterministic_repeats"] = 1 + errors = MODULE.packet_errors(packet) + assert any("single runs are not evidence" in error for error in errors) + + +def test_unsupported_metric_requires_reason(): + packet = complete_packet() + packet["metrics"]["performance"] = {"status": "unsupported"} + errors = MODULE.packet_errors(packet) + assert any("no non-empty reason" in error for error in errors) + + +def test_measured_metric_requires_method(): + packet = complete_packet() + packet["metrics"]["physical"] = {"lateral_drift_m": 0.0} + errors = MODULE.packet_errors(packet) + assert any("measurement 'method'" in error for error in errors) + + +def test_unsupported_metric_cannot_carry_values(): + packet = complete_packet() + packet["metrics"]["performance"] = { + "status": "unsupported", + "reason": "no methodology", + "wall_time_ms": 0.0, + } + errors = MODULE.packet_errors(packet) + assert any("mixes unsupported status" in error for error in errors) + + +def test_null_and_nan_metric_values_fail(): + packet = complete_packet() + packet["metrics"]["numerical"]["max_penetration_m"] = None + assert any("contains null" in e for e in MODULE.packet_errors(packet)) + packet["metrics"]["numerical"]["max_penetration_m"] = float("nan") + assert any("non-finite" in e for e in MODULE.packet_errors(packet)) + + +def test_unknown_top_level_key_fails(): + packet = complete_packet() + packet["extra_notes"] = "sneaky" + errors = MODULE.packet_errors(packet) + assert any("unknown top-level keys" in error for error in errors) + + +def _minimal_manifest(ids): + return { + "schema": "dart.citation_claim_manifest/v1", + "branch": "release-6.20", + "corpus_reference": { + "path": ( + "docs/plans/123-citation-driven-simulation-trust/" + "citation-claim-corpus.md" + ), + "branch": "main", + }, + "claims": [ + { + "id": claim_id, + "title": f"Claim {claim_id}", + "source": "somewhere", + "source_url": "https://example.org/claim", + "source_claim": "A claim.", + "lanes": { + "dart6": { + "owner": "dev task", + "status": "audit-required", + "disposition": None, + "evidence": [], + }, + }, + } + for claim_id in ids + ], + } + + +def test_manifest_passes(): + assert MODULE.manifest_errors(_minimal_manifest(["CT-001"])) == [] + + +def test_manifest_requires_branch_and_corpus_reference(): + manifest = _minimal_manifest(["CT-001"]) + manifest["branch"] = "main" + errors = MODULE.manifest_errors(manifest) + assert any("release-6.20" in error for error in errors) + manifest = _minimal_manifest(["CT-001"]) + del manifest["corpus_reference"] + errors = MODULE.manifest_errors(manifest) + assert any("corpus_reference" in error for error in errors) + + +def test_manifest_rejects_extra_lane(): + manifest = _minimal_manifest(["CT-001"]) + manifest["claims"][0]["lanes"]["dart7"] = { + "owner": "x", + "status": "audit-required", + "disposition": None, + "evidence": [], + } + errors = MODULE.manifest_errors(manifest) + assert any("lanes must define exactly" in error for error in errors) + + +def test_closed_lane_requires_disposition_and_evidence(): + manifest = _minimal_manifest(["CT-001"]) + manifest["claims"][0]["lanes"]["dart6"]["status"] = "closed" + errors = MODULE.manifest_errors(manifest) + assert any("without a valid disposition" in error for error in errors) + assert any("prose cannot close a row" in error for error in errors) + + +def test_not_applicable_lane_requires_reason(): + manifest = _minimal_manifest(["CT-001"]) + manifest["claims"][0]["lanes"]["dart6"]["status"] = "not-applicable" + errors = MODULE.manifest_errors(manifest) + assert any("must record a reason" in error for error in errors) + + +def _write_tree(tmp_path, *, packet=None, negative=None, manifest=None): + design_dir = tmp_path / "design" + evidence = design_dir / "evidence" + negative_dir = evidence / "negative-controls" + negative_dir.mkdir(parents=True) + if manifest is None: + manifest = _minimal_manifest(["CT-001"]) + if packet is not None: + manifest["claims"][0]["lanes"]["dart6"]["status"] = "in-progress" + manifest["claims"][0]["lanes"]["dart6"]["evidence"] = [ + "evidence/packet.json" + ] + (design_dir / "claims-manifest.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + if packet is not None: + (evidence / "packet.json").write_text(json.dumps(packet), encoding="utf-8") + if negative is not None: + (negative_dir / "incomplete.json").write_text( + json.dumps(negative), encoding="utf-8" + ) + (negative_dir / "incomplete.expected-errors.json").write_text( + json.dumps(["missing required top-level keys"]), encoding="utf-8" + ) + return design_dir + + +def test_validate_tree_accepts_complete_state(tmp_path): + incomplete = {"schema": "dart.citation_claim_evidence/v1"} + design_dir = _write_tree(tmp_path, packet=complete_packet(), negative=incomplete) + assert MODULE.validate_tree(design_dir) == [] + + +def test_validate_tree_requires_negative_control(tmp_path): + design_dir = _write_tree(tmp_path, packet=complete_packet()) + errors = MODULE.validate_tree(design_dir) + assert any("negative-control" in error for error in errors) + + +def test_validate_tree_rejects_passing_negative_control(tmp_path): + design_dir = _write_tree( + tmp_path, packet=complete_packet(), negative=complete_packet() + ) + errors = MODULE.validate_tree(design_dir) + assert any("fail-closed proof is vacuous" in error for error in errors) + + +def test_validate_tree_rejects_unreferenced_packet(tmp_path): + manifest = _minimal_manifest(["CT-001"]) + design_dir = _write_tree( + tmp_path, + packet=complete_packet(), + negative={"schema": "x"}, + manifest=manifest, + ) + errors = MODULE.validate_tree(design_dir) + assert any("not referenced by any manifest lane" in error for error in errors) + + +def test_unacknowledged_zero_fails(): + packet = complete_packet() + packet["metrics"]["numerical"]["max_solver_residual"] = 0.0 + errors = MODULE.packet_errors(packet) + assert any("without acknowledgement" in error for error in errors) + + +def test_acknowledged_measured_zero_passes(): + packet = complete_packet() + packet["metrics"]["numerical"]["max_solver_residual"] = 0.0 + packet["metrics"]["numerical"]["measured_zero_fields"] = ["max_solver_residual"] + assert MODULE.packet_errors(packet) == [] + + +def test_typed_unsupported_leaf_passes_and_needs_reason(): + packet = complete_packet() + packet["metrics"]["numerical"]["solver_residual"] = { + "status": "unsupported", + "reason": "never computed on this path", + } + assert MODULE.packet_errors(packet) == [] + packet["metrics"]["numerical"]["solver_residual"] = {"status": "unsupported"} + errors = MODULE.packet_errors(packet) + assert any("typed unsupported" in error for error in errors) + + +def test_stale_measured_zero_declaration_fails(): + packet = complete_packet() + packet["metrics"]["numerical"]["measured_zero_fields"] = ["not_zero_here"] + errors = MODULE.packet_errors(packet) + assert any("which are not zero" in error for error in errors) + + +def test_nested_zero_is_caught_by_path(): + packet = complete_packet() + packet["metrics"]["physical"]["per_solver_summary"] = { + "BOXED_LCP": {"max_penetration_m": 0.0} + } + errors = MODULE.packet_errors(packet) + assert any( + "per_solver_summary.BOXED_LCP.max_penetration_m" in error for error in errors + ) + + +def test_spelled_placeholder_fails(): + packet = complete_packet() + packet["metrics"]["numerical"]["max_penetration_m"] = "n/a" + errors = MODULE.packet_errors(packet) + assert any("placeholder" in error for error in errors) + + +def test_only_empty_containers_fails(): + packet = complete_packet() + packet["metrics"]["numerical"] = {"method": "m", "values": {}} + errors = MODULE.packet_errors(packet) + assert any("only empty containers" in error for error in errors) + + +def test_empty_list_alongside_real_values_passes(): + packet = complete_packet() + packet["metrics"]["numerical"]["violations"] = [] + assert MODULE.packet_errors(packet) == [] + + +def test_dangling_raw_path_fails(tmp_path): + packet = complete_packet() + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = ["does/not/exist.csv"] + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any("does not resolve" in error for error in errors) + (tmp_path / "real.csv").write_text("a,b\n1,2\n", encoding="utf-8") + packet["evidence"]["raw_paths"] = ["real.csv"] + # Path-based evidence pins its artifact bytes; repeat verification is + # bound separately by an explicit trajectory digest (artifact digests + # prove file identity, not repeat determinism). + packet["evidence"]["artifact_digests"] = { + "real.csv": "sha256:" + + hashlib.sha256((tmp_path / "real.csv").read_bytes()).hexdigest() + } + packet["ensemble"]["repeat_trajectory_sha256"] = "a" * 64 + assert MODULE.packet_errors(packet, base_dir=tmp_path) == [] + + +def test_empty_measurement_window_fails(): + packet = complete_packet() + packet["ensemble"]["measurement_window"] = {} + errors = MODULE.packet_errors(packet) + assert any("measurement_window" in error for error in errors) + + +def test_validate_tree_validates_packets_in_subdirectories(tmp_path): + """A lane may not close a row with a file the packet checks never reach.""" + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"]["dart6"] + lane["status"] = "closed" + lane["disposition"] = "reproduced" + lane["evidence"] = ["evidence/sub/prose.json"] + design_dir = _write_tree(tmp_path, negative={"schema": "x"}, manifest=manifest) + sub = design_dir / "evidence" / "sub" + sub.mkdir(parents=True) + (sub / "prose.json").write_text( + json.dumps({"this is": "not a packet"}), encoding="utf-8" + ) + errors = MODULE.validate_tree(design_dir) + assert errors, "a nested non-packet must not close a lane" + assert any("missing required top-level keys" in error for error in errors) + + +def test_validate_tree_rejects_negative_control_as_lane_evidence(tmp_path): + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"]["dart6"] + lane["status"] = "closed" + lane["disposition"] = "reproduced" + lane["evidence"] = ["evidence/negative-controls/incomplete.json"] + design_dir = _write_tree(tmp_path, negative={"schema": "x"}, manifest=manifest) + errors = MODULE.validate_tree(design_dir) + assert any("negative control" in error for error in errors) + + +def test_validate_tree_rejects_non_json_lane_evidence(tmp_path): + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"]["dart6"] + lane["status"] = "closed" + lane["disposition"] = "reproduced" + lane["evidence"] = ["evidence/notes.md"] + design_dir = _write_tree(tmp_path, negative={"schema": "x"}, manifest=manifest) + (design_dir / "evidence" / "notes.md").write_text("prose", encoding="utf-8") + errors = MODULE.validate_tree(design_dir) + assert any("not a .json packet" in error for error in errors) + + +def test_validate_tree_rejects_scalar_lane_evidence(tmp_path): + manifest = _minimal_manifest(["CT-001"]) + manifest["claims"][0]["lanes"]["dart6"]["evidence"] = 7 + design_dir = _write_tree(tmp_path, negative={"schema": "x"}, manifest=manifest) + errors = MODULE.validate_tree(design_dir) + assert any("evidence must be a list" in error for error in errors) + + +def test_validate_tree_rejects_shared_packet_owner(tmp_path): + manifest = _minimal_manifest(["CT-001", "CT-002"]) + for claim in manifest["claims"]: + claim["lanes"]["dart6"]["status"] = "in-progress" + claim["lanes"]["dart6"]["evidence"] = ["evidence/packet.json"] + design_dir = _write_tree( + tmp_path, + packet=complete_packet(), + negative={"schema": "x"}, + manifest=manifest, + ) + errors = MODULE.validate_tree(design_dir) + assert any("one packet has one owner" in error for error in errors) + + +def _bound_pass(packet: dict, reviewer: str) -> dict: + """A review pass bound to the packet's current non-review content.""" + return { + "reviewer": reviewer, + "summary": "clean", + "verdict": "pass", + "content_digest": MODULE._packet_content_digest(packet), + } + + +def test_validate_tree_closed_lane_needs_two_review_passes(tmp_path): + packet = copy.deepcopy(complete_packet()) + packet["review"]["passes"] = [_bound_pass(packet, "first")] + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"]["dart6"] + lane["status"] = "closed" + lane["disposition"] = "unresolved" + lane["evidence"] = ["evidence/packet.json"] + design_dir = _write_tree( + tmp_path, packet=packet, negative={"schema": "x"}, manifest=manifest + ) + errors = MODULE.validate_tree(design_dir) + assert any("at least two recorded review passes" in error for error in errors) + packet["review"]["passes"].append(_bound_pass(packet, "second")) + (design_dir / "evidence" / "packet.json").write_text( + json.dumps(packet), encoding="utf-8" + ) + assert MODULE.validate_tree(design_dir) == [] + + +def test_validate_tree_closed_lane_needs_distinct_reviewers(tmp_path): + packet = copy.deepcopy(complete_packet()) + packet["review"]["passes"] = [ + _bound_pass(packet, "same"), + _bound_pass(packet, "same"), + ] + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"]["dart6"] + lane["status"] = "closed" + lane["disposition"] = "unresolved" + lane["evidence"] = ["evidence/packet.json"] + design_dir = _write_tree( + tmp_path, packet=packet, negative={"schema": "x"}, manifest=manifest + ) + errors = MODULE.validate_tree(design_dir) + assert any("INDEPENDENT" in error for error in errors) + + +def test_validate_tree_closed_lane_disposition_must_match_packet(tmp_path): + packet = copy.deepcopy(complete_packet()) # result.disposition: unresolved + packet["review"]["passes"] = [ + _bound_pass(packet, "first"), + _bound_pass(packet, "second"), + ] + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"]["dart6"] + lane["status"] = "closed" + lane["disposition"] = "reproduced" + lane["evidence"] = ["evidence/packet.json"] + design_dir = _write_tree( + tmp_path, packet=packet, negative={"schema": "x"}, manifest=manifest + ) + errors = MODULE.validate_tree(design_dir) + assert any( + "cannot publish a conclusion its evidence does not support" in error + for error in errors + ) + + +def test_review_pass_must_bind_to_packet_content(): + packet = complete_packet() + packet["review"]["passes"] = [ + { + "reviewer": "first", + "summary": "clean", + "content_digest": "sha256:" + "0" * 64, + } + ] + errors = MODULE.packet_errors(packet) + assert any("not bound to this packet's content" in error for error in errors) + packet["review"]["passes"] = [_bound_pass(packet, "first")] + assert MODULE.packet_errors(packet) == [] + packet["result"]["claim_boundary"] = "Changed after review." + errors = MODULE.packet_errors(packet) + assert any("not bound to this packet's content" in error for error in errors) + + +def test_fetch_hint_is_required(): + packet = complete_packet() + del packet["target"]["fetch_hint"] + errors = MODULE.packet_errors(packet) + assert any("fetch_hint" in error for error in errors) + + +def test_raw_rows_placeholders_fail(): + for bad in ([None], ["prose"], [{}], [{"k": 1}, None]): + packet = complete_packet() + packet["evidence"]["raw_rows"] = bad + errors = MODULE.packet_errors(packet) + assert any( + "raw_rows" in error and "structured record" in error for error in errors + ), bad + + +def test_raw_paths_must_stay_inside_evidence_roots(tmp_path): + for bad in ("/etc/passwd", "../escape.json", "C:\\evil.json", "a/../../b"): + packet = complete_packet() + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = [bad] + errors = MODULE.packet_errors(packet) + assert any( + "relative path inside the repository" in error for error in errors + ), bad + design = tmp_path / "design" + design.mkdir() + packet = complete_packet() + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = ["missing/nowhere.json"] + errors = MODULE.packet_errors(packet, base_dir=design) + assert any("does not resolve to an existing file" in error for error in errors) + + +def test_ensemble_sweep_and_seed_entries_must_be_valid_and_distinct(): + base = complete_packet() + del base["ensemble"]["deterministic_repeats"] + for bad, needle in ( + ([None, None], "sweep[0]"), + ([{"a": 1}, {"a": 1}], "DISTINCT points"), + ([{}, {"a": 1}], "sweep[0]"), + ): + packet = copy.deepcopy(base) + packet["ensemble"]["sweep"] = bad + errors = MODULE.packet_errors(packet) + assert any(needle in error for error in errors), (bad, errors) + for bad, needle in ( + ([None, None], "seeds[0]"), + ([7, 7], "DISTINCT seeds"), + ([True, False], "seeds[0]"), + ): + packet = copy.deepcopy(base) + packet["ensemble"]["seeds"] = bad + errors = MODULE.packet_errors(packet) + assert any(needle in error for error in errors), (bad, errors) + good = copy.deepcopy(base) + good["ensemble"]["sweep"] = [{"angle_deg": 0.0}, {"angle_deg": 15.0}] + good["evidence"]["raw_rows"] = [ + {"angle_deg": 0.0, "lateral_drift_m": 0.0, "trajectory_sha256": "d" * 64}, + {"angle_deg": 15.0, "lateral_drift_m": 0.1, "trajectory_sha256": "e" * 64}, + ] + assert MODULE.packet_errors(good) == [] + + +def test_visual_entries_are_validated(): + for bad in ([None], [{"path": "x.png"}], [123]): + packet = complete_packet() + packet["evidence"]["visual"] = bad + errors = MODULE.packet_errors(packet) + assert any("evidence.visual[0]" in error for error in errors), bad + packet = complete_packet() + packet["evidence"]["visual"] = ["/abs/frame.png"] + errors = MODULE.packet_errors(packet) + assert any("relative path inside the repository" in error for error in errors) + + +def test_prose_cannot_masquerade_as_measurement(): + packet = complete_packet() + packet["metrics"]["numerical"]["max_penetration_m"] = "not measured yet" + errors = MODULE.packet_errors(packet) + assert any("prose where a measurement is expected" in error for error in errors) + packet = complete_packet() + packet["metrics"]["numerical"]["penetration_semantics"] = "clamped at zero" + packet["metrics"]["numerical"]["clamp_note"] = "runtime clamps depth" + assert MODULE.packet_errors(packet) == [] + + +def test_configuration_placeholder_objects_fail(): + packet = complete_packet() + packet["configuration"]["resolved"] = {"placeholder": None} + errors = MODULE.packet_errors(packet) + assert any("null values" in error for error in errors) + assert any("no recognizable" in error for error in errors) + + +def test_corpus_reference_must_pin_the_canonical_owner(): + manifest = _minimal_manifest(["CT-001"]) + manifest["corpus_reference"]["path"] = "wrong/path.md" + errors = MODULE.manifest_errors(manifest) + assert any( + "claim identity is owned by the DART 7 corpus" in error for error in errors + ) + manifest = _minimal_manifest(["CT-001"]) + manifest["corpus_reference"]["branch"] = "release-6.20" + errors = MODULE.manifest_errors(manifest) + assert any("corpus_reference.branch" in error for error in errors) + + +def test_lane_evidence_paths_are_canonicalized(tmp_path): + packet = copy.deepcopy(complete_packet()) + manifest = _minimal_manifest(["CT-001", "CT-002"]) + first = manifest["claims"][0]["lanes"]["dart6"] + second = manifest["claims"][1]["lanes"]["dart6"] + first["status"] = "in-progress" + first["evidence"] = ["evidence/packet.json"] + second["status"] = "in-progress" + second["evidence"] = ["evidence/./packet.json"] + design_dir = _write_tree( + tmp_path, packet=packet, negative={"schema": "x"}, manifest=manifest + ) + errors = MODULE.validate_tree(design_dir) + assert any("one packet has one owner" in error for error in errors) + + +def test_lane_evidence_paths_cannot_escape(tmp_path): + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"]["dart6"] + lane["status"] = "in-progress" + lane["evidence"] = ["../outside.json"] + design_dir = _write_tree( + tmp_path, packet=None, negative={"schema": "x"}, manifest=manifest + ) + errors = MODULE.validate_tree(design_dir) + assert any("escapes the sidecar directory" in error for error in errors) + + +def test_validate_tree_freshness_flags_stale_commit(tmp_path): + design_dir = _write_tree( + tmp_path, packet=complete_packet(), negative={"schema": "x"} + ) + errors = MODULE.validate_tree(design_dir, freshness_head="1" * 40) + assert any("--freshness" in error for error in errors) + + +def test_repository_tree_validates(): + """The committed manifest and negative controls must pass the gate.""" + errors = MODULE.validate_tree(DESIGN_DIR) + assert errors == [] + + +def test_non_string_lane_evidence_entry_fails(tmp_path): + """A lane must not be closable by an entry the packet checks cannot read.""" + for bad in ({"path": "evidence/x.json"}, None, 42, True, ["evidence/x.json"]): + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"]["dart6"] + lane["status"] = "closed" + lane["disposition"] = "reproduced" + lane["evidence"] = [bad] + tree = _write_tree( + tmp_path / f"case{abs(hash(str(bad)))}", + negative={"schema": "x"}, + manifest=manifest, + ) + errors = MODULE.validate_tree(tree) + assert any("non-string entry" in error for error in errors), bad + + +def test_raw_path_pointing_at_a_directory_fails(tmp_path): + packet = complete_packet() + del packet["evidence"]["raw_rows"] + (tmp_path / "somedir.csv").mkdir() + packet["evidence"]["raw_paths"] = ["somedir.csv"] + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any("does not resolve" in error for error in errors) + + +def test_scene_digest_must_match_published_parameters(): + packet = complete_packet() + packet["scene"]["parameters"] = {"a": 1} + errors = MODULE.packet_errors(packet) + assert any("does not match the digest" in error for error in errors) + import hashlib as _h + import json as _j + + packet["scene"]["digest"] = ( + "sha256:" + + _h.sha256( + _j.dumps({"a": 1}, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + ) + assert MODULE.packet_errors(packet) == [] + + +def test_nested_negative_control_is_enumerated(tmp_path): + """A control in a subdirectory must still be required to fail.""" + tree = _write_tree(tmp_path, negative={"schema": "x"}) + nested = tree / "evidence" / "negative-controls" / "deep" + nested.mkdir(parents=True) + (nested / "passing.json").write_text( + json.dumps(complete_packet()), encoding="utf-8" + ) + errors = MODULE.validate_tree(tree) + assert any("fail-closed proof is vacuous" in error for error in errors) + + +def test_scene_parameters_are_required(): + packet = complete_packet() + del packet["scene"]["parameters"] + errors = MODULE.packet_errors(packet) + assert any("binds nothing" in error for error in errors) + + +def test_boolean_metric_leaves_are_valid_but_odd_types_fail(): + packet = complete_packet() + packet["metrics"]["physical"]["pyramid_signature"] = True + assert MODULE.packet_errors(packet) == [] + + +def test_reviewer_identities_are_normalized_before_counting(tmp_path): + packet = copy.deepcopy(complete_packet()) + packet["review"]["passes"] = [ + _bound_pass(packet, "reviewer-a"), + _bound_pass(packet, " Reviewer-A "), + ] + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"][LANE] + lane["status"] = "closed" + lane["disposition"] = "unresolved" + lane["evidence"] = ["evidence/packet.json"] + tree_dir = _write_tree( + tmp_path, packet=packet, negative={"schema": "x"}, manifest=manifest + ) + errors = MODULE.validate_tree(tree_dir) + assert any("INDEPENDENT" in error for error in errors) + + +def test_visual_entries_must_be_media_artifacts(): + packet = complete_packet() + packet["evidence"]["visual"] = ["CHANGELOG.md"] + errors = MODULE.packet_errors(packet) + assert any("not a recognized visual media artifact" in error for error in errors) + + +def test_review_entries_require_a_passing_verdict(): + packet = complete_packet() + entry = _bound_pass(packet, "first") + del entry["verdict"] + packet["review"]["passes"] = [entry] + errors = MODULE.packet_errors(packet) + assert any("verdict 'pass'" in error for error in errors) + entry["verdict"] = "fail" + errors = MODULE.packet_errors(packet) + assert any("verdict 'pass'" in error for error in errors) + + +def test_identity_values_must_be_non_empty(): + for bad in ("", [], False, {}): + packet = complete_packet() + packet["configuration"]["resolved"] = {"solver": bad} + errors = MODULE.packet_errors(packet) + assert any("no recognizable" in error for error in errors), bad + + +def test_measurement_window_placeholders_fail(): + for bad in (True, 1, [None], {"a": None}, {"start_s": 2.0, "end_s": 1.0}): + packet = complete_packet() + packet["ensemble"]["measurement_window"] = bad + errors = MODULE.packet_errors(packet) + assert any("measurement_window" in error for error in errors), bad + packet = complete_packet() + packet["ensemble"]["measurement_window"] = { + "warmup_steps": 250, + "continuation_steps": 100, + } + assert MODULE.packet_errors(packet) == [] + packet["ensemble"]["measurement_window"] = {"foo": 1.0} + errors = MODULE.packet_errors(packet) + assert any("must name its bounds" in error for error in errors) + packet["ensemble"]["measurement_window"] = "full 1 s horizon" + errors = MODULE.packet_errors(packet) + assert any("measurement_window" in error for error in errors) + + +def test_visual_artifacts_are_verified_by_content(tmp_path): + fake = tmp_path / "design" + fake.mkdir() + (fake / "capture.png").write_text("not an image", encoding="utf-8") + packet = complete_packet() + packet["evidence"]["visual"] = ["capture.png"] + errors = MODULE.packet_errors(packet, base_dir=fake) + assert any("structurally complete" in error for error in errors) + png = b"\x89PNG\r\n\x1a\n" + bytes.fromhex( + "0000000d49484452000000010000000108060000001f15c489" + "0000000a49444154789c63000100000500010d0a2db4" + "0000000049454e44ae426082" + ) + (fake / "real.png").write_bytes(png) + packet["evidence"]["visual"] = ["real.png"] + packet["evidence"]["artifact_digests"] = { + "real.png": "sha256:" + hashlib.sha256(png).hexdigest() + } + assert MODULE.packet_errors(packet, base_dir=fake) == [] + + +def test_not_applicable_lane_cannot_conclude(tmp_path): + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"][LANE] + lane["status"] = "not-applicable" + lane["reason"] = "does not apply here" + lane["disposition"] = "fixed" + lane["evidence"] = ["evidence/packet.json"] + errors = MODULE.manifest_errors(manifest) + assert any("concludes nothing" in error for error in errors) + assert any("must not hold" in error for error in errors) + + +def test_metadata_keys_are_not_identities(): + packet = complete_packet() + packet["configuration"]["resolved"] = {"method_note": "not measured"} + errors = MODULE.packet_errors(packet) + assert any("no recognizable" in error for error in errors) + + +def test_raw_rows_need_measurement_content(): + packet = complete_packet() + packet["evidence"]["raw_rows"] = [{"note": "pending"}] + errors = MODULE.packet_errors(packet) + assert any("metadata-only record" in error for error in errors) + packet["evidence"]["raw_rows"] = [ + {"lateral_drift_m": 1.5e-3, "trajectory_sha256": "f" * 64} + ] + assert MODULE.packet_errors(packet) == [] + + +def test_metric_group_needs_a_real_measurement(): + packet = complete_packet() + packet["metrics"]["numerical"] = {"method": "manual", "note": "not measured"} + errors = MODULE.packet_errors(packet) + assert any("only semantic annotations" in error for error in errors) + + +def test_identity_tokens_are_whole_words(): + packet = complete_packet() + packet["configuration"]["resolved"] = {"methodology": "pending"} + errors = MODULE.packet_errors(packet) + assert any("no recognizable" in error for error in errors) + + +def test_asserted_repeats_need_recorded_verification(): + packet = complete_packet() + del packet["ensemble"]["deterministic_repeats_identical"] + errors = MODULE.packet_errors(packet) + assert any("did not verify bit-identical" in error for error in errors) + + +def test_fetch_hint_must_be_the_durable_pr_ref_command(): + packet = complete_packet() + packet["target"]["fetch_hint"] = "not a command" + errors = MODULE.packet_errors(packet) + assert any("runnable durable PR-ref" in error for error in errors) + packet["target"]["fetch_hint"] = "git fetch origin pull/999/head" + errors = MODULE.packet_errors(packet) + assert any("runnable durable PR-ref" in error for error in errors) + + +def test_signature_only_media_is_rejected(tmp_path): + root = tmp_path / "design" + root.mkdir() + (root / "stub.png").write_bytes(b"\x89PNG\r\n\x1a\n") + packet = complete_packet() + packet["evidence"]["visual"] = ["stub.png"] + errors = MODULE.packet_errors(packet, base_dir=root) + assert any("structurally complete" in error for error in errors) + + +def test_identity_placeholder_values_are_rejected(): + for bad in ("unknown", "n/a", "not measured", "pending"): + packet = complete_packet() + packet["configuration"]["resolved"] = {"solver": bad} + errors = MODULE.packet_errors(packet) + assert any("no recognizable" in error for error in errors), bad + + +def test_fetch_hint_suffix_or_prefix_variants_fail(): + for bad in ( + "git fetch origin pull/3444/head", + "git fetch origin pull/3444/head; echo no-checkout", + "git fetch origin pull/3444/head-wrong && git checkout ", + ): + packet = complete_packet() + packet["target"]["fetch_hint"] = bad + errors = MODULE.packet_errors(packet) + assert any("runnable durable PR-ref" in error for error in errors), bad + + +def test_negative_control_sidecar_pins_each_seeded_defect(tmp_path): + tree_dir = _write_tree(tmp_path, packet=None, negative={"schema": "x"}) + sidecar = ( + tree_dir / "evidence" / "negative-controls" / "incomplete.expected-errors.json" + ) + sidecar.write_text( + json.dumps(["an error text that no check produces"]), encoding="utf-8" + ) + errors = MODULE.validate_tree(tree_dir) + assert any("seeded defect no longer detected" in error for error in errors) + sidecar.unlink() + errors = MODULE.validate_tree(tree_dir) + assert any("no .expected-errors.json sidecar" in error for error in errors) + + +def test_fetch_hint_must_check_out_the_target_commit(): + packet = complete_packet() + packet["target"]["fetch_hint"] = ( + "git fetch origin pull/3444/head && git checkout " + "1" * 40 + ) + errors = MODULE.packet_errors(packet) + assert any("must reproduce THIS packet's target" in error for error in errors) + + +def test_orphan_sidecar_does_not_count_as_a_control(tmp_path): + tree_dir = _write_tree(tmp_path, packet=None, negative={"schema": "x"}) + (tree_dir / "evidence" / "negative-controls" / "incomplete.json").unlink() + errors = MODULE.validate_tree(tree_dir) + assert any("at least one intentionally incomplete" in error for error in errors) + + +def test_step_window_bounds_must_be_sane_integers(): + packet = complete_packet() + packet["ensemble"]["measurement_window"] = { + "warmup_steps": -1.5, + "continuation_steps": -2, + } + errors = MODULE.packet_errors(packet) + assert any("non-negative integers" in error for error in errors) + + +def test_commands_must_be_the_reproducible_pixi_form(): + for bad in ("pending", "echo success", "bash -c 'anything'"): + packet = complete_packet() + packet["evidence"]["commands"] = [bad] + errors = MODULE.packet_errors(packet) + assert any("reproducible repository form" in error for error in errors), bad + packet = complete_packet() + packet["evidence"]["commands"] = [ + "pixi run build", + "PYTHONPATH=build/x pixi run python " + HARNESS, + ] + assert MODULE.packet_errors(packet) == [] + packet["evidence"]["commands"] = ["PYTHONPATH=build/x pixi run python " + HARNESS] + errors = MODULE.packet_errors(packet) + assert any("must include the build step" in error for error in errors) + + +def test_bookkeeping_only_rows_fail(): + packet = complete_packet() + packet["evidence"]["raw_rows"] = [{"seed": 1, "trajectory_sha256": "e" * 64}] + errors = MODULE.packet_errors(packet) + assert any("beyond bookkeeping" in error for error in errors) + + +def test_repeats_need_hash_bearing_evidence(): + packet = complete_packet() + packet["evidence"]["raw_rows"] = [{"angle_deg": 0.0, "lateral_drift_m": 0.5}] + errors = MODULE.packet_errors(packet) + assert any( + "binding the repeats to recorded trajectories" in error for error in errors + ) + + +def test_open_lane_disposition_must_match_packet(tmp_path): + packet = copy.deepcopy(complete_packet()) # result.disposition: unresolved + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"][LANE] + lane["status"] = "in-progress" + lane["disposition"] = "fixed" + lane["evidence"] = ["evidence/packet.json"] + tree_dir = _write_tree( + tmp_path, packet=packet, negative={"schema": "x"}, manifest=manifest + ) + errors = MODULE.validate_tree(tree_dir) + assert any( + "cannot publish a conclusion its evidence does not support" in error + for error in errors + ) + + +def test_commands_with_shell_tails_fail(): + for bad in ( + "pixi run test; false", + "pixi run test && rm -rf /", + "pixi run test | tee log", + "pixi run test `id`", + ): + packet = complete_packet() + packet["evidence"]["commands"] = [bad] + errors = MODULE.packet_errors(packet) + assert any("reproducible repository form" in error for error in errors), bad + + +def test_duplicate_json_keys_fail_at_load(tmp_path): + tree_dir = _write_tree(tmp_path, packet=None, negative={"schema": "x"}) + (tree_dir / "evidence" / "packet.json").write_text( + '{"schema": "a", "schema": "b"}', encoding="utf-8" + ) + manifest = _minimal_manifest(["CT-001"]) + manifest["claims"][0]["lanes"][LANE]["status"] = "in-progress" + manifest["claims"][0]["lanes"][LANE]["evidence"] = ["evidence/packet.json"] + (tree_dir / "claims-manifest.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + errors = MODULE.validate_tree(tree_dir) + assert any("duplicate JSON object key" in error for error in errors) + + +def test_prose_raw_paths_fail(tmp_path): + (tmp_path / "AGENTS.md").write_text("prose", encoding="utf-8") + packet = complete_packet() + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = ["AGENTS.md"] + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any("must name a raw-data artifact" in error for error in errors) + + +def test_placeholder_hash_values_do_not_bind_repeats(): + packet = complete_packet() + packet["evidence"]["raw_rows"] = [{"lateral_drift_m": 0.5, "hash": "pending"}] + errors = MODULE.packet_errors(packet) + assert any( + "binding the repeats to recorded trajectories" in error for error in errors + ) + + +def test_placeholder_source_urls_fail(): + packet = complete_packet() + packet["source"]["url"] = "pending" + errors = MODULE.packet_errors(packet) + assert any("retrievable http(s) URL" in error for error in errors) + + +def test_commands_with_embedded_newlines_fail(): + for bad in ("pixi run check\nfalse", "pixi run check\necho hacked\n"): + packet = complete_packet() + packet["evidence"]["commands"] = [bad] + errors = MODULE.packet_errors(packet) + assert any("reproducible repository form" in error for error in errors), bad + + +def test_large_repeat_claims_need_per_repeat_hash_lists(): + packet = complete_packet() + packet["ensemble"]["deterministic_repeats"] = 1000 + errors = MODULE.packet_errors(packet) + assert any("must show their repeats" in error for error in errors) + packet["evidence"]["repeat_trajectory_sha256"] = ["a" * 64] * 1000 + assert MODULE.packet_errors(packet) == [] + + +def test_metadata_only_scene_parameters_fail(): + packet = complete_packet() + params = {"note": "pending"} + packet["scene"]["parameters"] = params + packet["scene"]["digest"] = ( + "sha256:" + + hashlib.sha256( + json.dumps(params, sort_keys=True, separators=(",", ":")).encode() + ).hexdigest() + ) + errors = MODULE.packet_errors(packet) + assert any("metadata-only parameters" in error for error in errors) + + +def test_prose_inside_raw_data_files_fails(tmp_path): + (tmp_path / "rows.csv").write_text("totally unstructured prose", encoding="utf-8") + packet = complete_packet() + del packet["ensemble"]["deterministic_repeats"] + del packet["ensemble"]["deterministic_repeats_identical"] + packet["ensemble"]["sweep"] = [{"angle_deg": 0.0}, {"angle_deg": 15.0}] + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = ["rows.csv"] + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any( + "does not parse as its claimed raw-data format" in error for error in errors + ) + + +def test_declared_sweep_points_need_matching_rows(): + packet = complete_packet() + del packet["ensemble"]["deterministic_repeats"] + del packet["ensemble"]["deterministic_repeats_identical"] + packet["ensemble"]["sweep"] = [{"angle_deg": 0.0}, {"angle_deg": 15.0}] + errors = MODULE.packet_errors(packet) + assert any( + "every declared point needs at least one recorded sample" in e for e in errors + ) + + +def test_swapped_artifacts_invalidate_the_packet(tmp_path): + (tmp_path / "real.csv").write_text("a,b\n1,2\n", encoding="utf-8") + packet = complete_packet() + del packet["ensemble"]["deterministic_repeats"] + del packet["ensemble"]["deterministic_repeats_identical"] + packet["ensemble"]["sweep"] = [{"angle_deg": 0.0}, {"angle_deg": 15.0}] + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = ["real.csv"] + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any("artifact_digests must map" in e for e in errors) + packet["evidence"]["artifact_digests"] = {"real.csv": "sha256:" + "0" * 64} + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any("does not match the referenced file's bytes" in e for e in errors) + + +def test_sweep_points_must_be_observed_by_rows(): + packet = complete_packet() + del packet["ensemble"]["deterministic_repeats"] + del packet["ensemble"]["deterministic_repeats_identical"] + packet["ensemble"]["sweep"] = [{"angle_deg": 0.0}, {"angle_deg": 15.0}] + packet["evidence"]["raw_rows"] = [ + {"angle_deg": 0.0, "lateral_drift_m": 0.0, "trajectory_sha256": "d" * 64}, + {"angle_deg": 0.0, "lateral_drift_m": 0.0, "trajectory_sha256": "d" * 64}, + ] + errors = MODULE.packet_errors(packet) + assert any("has no matching row" in error for error in errors) + + +def test_declared_seeds_must_be_observed_by_rows(): + packet = complete_packet() + del packet["ensemble"]["deterministic_repeats"] + del packet["ensemble"]["deterministic_repeats_identical"] + packet["ensemble"]["seeds"] = [7, 11] + packet["evidence"]["raw_rows"] = [ + {"seed": 7, "lateral_drift_m": 0.1, "trajectory_sha256": "d" * 64}, + {"seed": 7, "lateral_drift_m": 0.1, "trajectory_sha256": "d" * 64}, + ] + errors = MODULE.packet_errors(packet) + assert any("has no row recording it" in error for error in errors) + + +def test_structurally_empty_json_artifacts_fail(tmp_path): + (tmp_path / "rows.json").write_text('"just prose"', encoding="utf-8") + packet = complete_packet() + del packet["ensemble"]["deterministic_repeats"] + del packet["ensemble"]["deterministic_repeats_identical"] + packet["ensemble"]["sweep"] = [{"angle_deg": 0.0}, {"angle_deg": 15.0}] + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = ["rows.json"] + packet["evidence"]["artifact_digests"] = { + "rows.json": "sha256:" + + hashlib.sha256((tmp_path / "rows.json").read_bytes()).hexdigest() + } + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any( + "carries no numeric or boolean measurement content" in error for error in errors + ) + + +def test_scalar_sweep_points_are_rejected(): + packet = complete_packet() + del packet["ensemble"]["deterministic_repeats"] + del packet["ensemble"]["deterministic_repeats_identical"] + packet["ensemble"]["sweep"] = [0.0, 15.0] + packet["evidence"]["raw_rows"] = [ + {"angle_deg": 0.0, "lateral_drift_m": 0.0, "trajectory_sha256": "d" * 64}, + {"angle_deg": 15.0, "lateral_drift_m": 0.0, "trajectory_sha256": "d" * 64}, + ] + errors = MODULE.packet_errors(packet) + assert any("naming its coordinates" in error for error in errors) + + +def test_sweep_ensembles_require_rows(tmp_path): + (tmp_path / "rows.csv").write_text("a,b\n1,2\n", encoding="utf-8") + packet = complete_packet() + del packet["ensemble"]["deterministic_repeats"] + del packet["ensemble"]["deterministic_repeats_identical"] + packet["ensemble"]["sweep"] = [{"angle_deg": 0.0}, {"angle_deg": 15.0}] + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = ["rows.csv"] + packet["evidence"]["artifact_digests"] = { + "rows.csv": "sha256:" + + hashlib.sha256((tmp_path / "rows.csv").read_bytes()).hexdigest() + } + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any("requires inline evidence.raw_rows" in error for error in errors) + + +def test_host_provenance_is_required(): + packet = complete_packet() + del packet["host"] + errors = MODULE.packet_errors(packet) + assert any("missing required top-level keys" in error for error in errors) + packet = complete_packet() + del packet["host"]["performance_valid"] + errors = MODULE.packet_errors(packet) + assert any( + "performance_valid must be an explicit boolean" in error for error in errors + ) + + +def test_metadata_only_csv_fails(tmp_path): + (tmp_path / "rows.csv").write_text("note,status\nfoo,pending\n", encoding="utf-8") + packet = complete_packet() + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = ["rows.csv"] + packet["evidence"]["artifact_digests"] = { + "rows.csv": "sha256:" + + hashlib.sha256((tmp_path / "rows.csv").read_bytes()).hexdigest() + } + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any( + "carries no numeric or boolean measurement content" in error for error in errors + ) + + +def test_signature_only_webp_and_mp4_fail(tmp_path): + root = tmp_path / "d" + root.mkdir() + (root / "clip.webp").write_bytes( + b"RIFF" + b"\x00\x01\x02\x03" + b"WEBP" + b"p" * 60 + ) + packet = complete_packet() + packet["evidence"]["visual"] = ["clip.webp"] + packet["evidence"]["artifact_digests"] = { + "clip.webp": "sha256:" + + hashlib.sha256((root / "clip.webp").read_bytes()).hexdigest() + } + errors = MODULE.packet_errors(packet, base_dir=root) + assert any("structurally complete" in error for error in errors) + + +def test_unicode_variant_reviewers_count_once(tmp_path): + packet = copy.deepcopy(complete_packet()) + packet["review"]["passes"] = [ + _bound_pass(packet, "Jos\u00e9"), + _bound_pass(packet, "Jose\u0301"), + ] + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"][LANE] + lane["status"] = "closed" + lane["disposition"] = "unresolved" + lane["evidence"] = ["evidence/packet.json"] + tree_dir = _write_tree( + tmp_path, packet=packet, negative={"schema": "x"}, manifest=manifest + ) + errors = MODULE.validate_tree(tree_dir) + assert any("INDEPENDENT" in error for error in errors) + + +def test_stub_binary_artifacts_fail(tmp_path): + import zipfile as _zip + + empty_zip = tmp_path / "empty.npz" + with _zip.ZipFile(empty_zip, "w"): + pass + stub_npy = tmp_path / "stub.npy" + stub_npy.write_bytes(b"\x93NUMPY" + b"x" * 100) + for name in ("empty.npz", "stub.npy"): + packet = complete_packet() + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = [name] + packet["evidence"]["artifact_digests"] = { + name: "sha256:" + hashlib.sha256((tmp_path / name).read_bytes()).hexdigest() + } + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any( + "does not parse as its claimed raw-data format" in error for error in errors + ), name + + +def test_stub_webm_fails(tmp_path): + root = tmp_path / "d" + root.mkdir() + (root / "clip.webm").write_bytes(b"\x1a\x45\xdf\xa3" + b"p" * 96) + packet = complete_packet() + packet["evidence"]["visual"] = ["clip.webm"] + packet["evidence"]["artifact_digests"] = { + "clip.webm": "sha256:" + + hashlib.sha256((root / "clip.webm").read_bytes()).hexdigest() + } + errors = MODULE.packet_errors(packet, base_dir=root) + assert any("structurally complete" in error for error in errors) + + +def test_raw_artifacts_under_evidence_raw_are_not_packets(tmp_path): + tree_dir = _write_tree(tmp_path, packet=None, negative={"schema": "x"}) + raw_dir = tree_dir / "evidence" / "raw" + raw_dir.mkdir() + (raw_dir / "rows.json").write_text( + json.dumps([{"lateral_drift_m": 0.5}]), encoding="utf-8" + ) + errors = MODULE.validate_tree(tree_dir) + assert not any("rows.json" in error for error in errors) + manifest = _minimal_manifest(["CT-001"]) + lane = manifest["claims"][0]["lanes"][LANE] + lane["status"] = "in-progress" + lane["evidence"] = ["evidence/raw/rows.json"] + (tree_dir / "claims-manifest.json").write_text( + json.dumps(manifest), encoding="utf-8" + ) + errors = MODULE.validate_tree(tree_dir) + assert any("holds raw artifacts, not packets" in error for error in errors) + + +def test_overlapping_sweep_points_need_distinct_rows(): + packet = complete_packet() + del packet["ensemble"]["deterministic_repeats"] + del packet["ensemble"]["deterministic_repeats_identical"] + packet["ensemble"]["sweep"] = [ + {"angle_deg": 0.0}, + {"angle_deg": 0.0, "detector": "fcl"}, + ] + packet["evidence"]["raw_rows"] = [ + { + "angle_deg": 0.0, + "detector": "fcl", + "lateral_drift_m": 0.1, + "trajectory_sha256": "d" * 64, + }, + {"unrelated_metric": 1.0, "trajectory_sha256": "e" * 64}, + ] + errors = MODULE.packet_errors(packet) + assert any("DISTINCT" in error and "own" in error for error in errors) + + +def test_empty_npy_arrays_fail(tmp_path): + header = b"{'descr': ' bytes: + header = ( + b"{'descr': '" + descr.encode() + b"', 'fortran_order': False, " + b"'shape': (" + shape.encode() + b",), }" + ) + header += b" " * (63 - len(header) % 64) + b"\n" + return b"\x93NUMPY\x01\x00" + len(header).to_bytes(2, "little") + header + payload + + +def _path_packet(tmp_path, name: str, data: bytes) -> dict: + (tmp_path / name).write_bytes(data) + packet = complete_packet() + del packet["evidence"]["raw_rows"] + packet["evidence"]["raw_paths"] = [name] + packet["evidence"]["artifact_digests"] = { + name: "sha256:" + hashlib.sha256(data).hexdigest() + } + packet["ensemble"]["repeat_trajectory_sha256"] = "a" * 64 + return packet + + +def test_nonexistent_target_commits_fail(): + packet = complete_packet() + packet["target"]["commit"] = "f" * 40 + packet["target"]["fetch_hint"] = ( + "git fetch origin pull/3444/head && git checkout " + "f" * 40 + ) + errors = MODULE.packet_errors(packet) + assert any( + "does not exist in this repository's object store" in error for error in errors + ) + + +def test_missing_harness_scripts_fail(): + packet = complete_packet() + packet["evidence"]["commands"] = [ + "pixi run build", + "pixi run python scripts/does_not_exist.py", + ] + errors = MODULE.packet_errors(packet) + assert any("must invoke a harness that exists" in error for error in errors) + + +def test_all_nan_npy_payloads_fail(tmp_path): + import struct + + payload = struct.pack(" bytes: + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + for name, data in members.items(): + archive.writestr(name, data) + return buffer.getvalue() + + packet = _path_packet(tmp_path, "nan.npz", _zip({"a.npy": nan_member})) + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any("contains no finite element" in error for error in errors) + + packet = _path_packet( + tmp_path, "mixed.npz", _zip({"a.npy": nan_member, "b.npy": fin_member}) + ) + assert MODULE.packet_errors(packet, base_dir=tmp_path) == [] + + +def test_bookkeeping_only_csv_columns_fail(tmp_path): + packet = _path_packet(tmp_path, "meta.csv", b"seed,run\n7,1\n8,2\n") + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any("run metadata without a measurement column" in error for error in errors) + + packet = _path_packet(tmp_path, "notes.csv", b"seed,note\n7,control\n8,x\n") + errors = MODULE.packet_errors(packet, base_dir=tmp_path) + assert any( + "carries no numeric or boolean measurement content" in error for error in errors + ) + + +def test_csv_measurement_columns_still_pass(tmp_path): + packet = _path_packet(tmp_path, "rows.csv", b"seed,drift_m\n7,0.25\n8,0.50\n") + assert MODULE.packet_errors(packet, base_dir=tmp_path) == [] + + +def test_headerless_numeric_csv_still_passes(tmp_path): + packet = _path_packet(tmp_path, "grid.csv", b"1.0,2.0\n3.0,4.0\n") + assert MODULE.packet_errors(packet, base_dir=tmp_path) == [] + + +def test_fetch_hints_may_name_other_evidence_prs(): + packet = complete_packet() + packet["target"]["fetch_hint"] = ( + "git fetch origin pull/9999/head && git checkout " + HEAD_COMMIT + ) + assert MODULE.packet_errors(packet) == [] + + +def test_invalid_npy_dtype_widths_fail(tmp_path): + packet = _path_packet(tmp_path, "odd.npy", _npy_bytes("