diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef4ddd2..983f66f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: - name: Check Barnes-Hut self-gravity reference run: | node tests/barnes-hut.mjs + node tests/nbody-view.mjs cargo test --manifest-path nbody/Cargo.toml --locked --offline cargo run --manifest-path nbody/Cargo.toml --locked --offline -- verify cargo run --manifest-path nbody/Cargo.toml --locked --offline -- verify-flat diff --git a/.github/workflows/native-gpu.yml b/.github/workflows/native-gpu.yml index c35a9f4..2180f43 100644 --- a/.github/workflows/native-gpu.yml +++ b/.github/workflows/native-gpu.yml @@ -3,9 +3,9 @@ name: Native GPU runtime on: push: branches: [main] - paths: ['runtime/**', 'rust/**', 'nbody/**', 'data/uff/**', 'tests/uff-reference.json', 'scripts/*gpu*', 'scripts/vast-runner.py', 'rust-toolchain.toml', '.github/workflows/native-gpu.yml', '.dockerignore', 'LICENSE', 'NOTICE.md'] + paths: ['runtime/**', 'rust/**', 'nbody/**', 'data/uff/**', 'tests/uff-reference.json', 'tests/test_bh2d_hardware_sweep.py', 'scripts/*gpu*', 'scripts/bench-bh2d-hardware.py', 'scripts/bench-bh2d-hardware-launch.sh', 'scripts/bench-bh2d-hardware-launch.cmd', 'scripts/vast-runner.py', 'rust-toolchain.toml', '.github/workflows/native-gpu.yml', '.dockerignore', 'LICENSE', 'NOTICE.md'] pull_request: - paths: ['runtime/**', 'rust/**', 'nbody/**', 'data/uff/**', 'tests/uff-reference.json', 'scripts/*gpu*', 'scripts/vast-runner.py', 'rust-toolchain.toml', '.github/workflows/native-gpu.yml', '.dockerignore', 'LICENSE', 'NOTICE.md'] + paths: ['runtime/**', 'rust/**', 'nbody/**', 'data/uff/**', 'tests/uff-reference.json', 'tests/test_bh2d_hardware_sweep.py', 'scripts/*gpu*', 'scripts/bench-bh2d-hardware.py', 'scripts/bench-bh2d-hardware-launch.sh', 'scripts/bench-bh2d-hardware-launch.cmd', 'scripts/vast-runner.py', 'rust-toolchain.toml', '.github/workflows/native-gpu.yml', '.dockerignore', 'LICENSE', 'NOTICE.md'] workflow_dispatch: permissions: @@ -25,6 +25,12 @@ jobs: python-version: '3.12' - name: Check SSH runner and result extraction on Windows run: python runtime/tests/test_runner.py + - name: Check BH2D Windows launcher propagates Python failure + shell: cmd + run: | + call scripts\bench-bh2d-hardware-launch.cmd --particles 512 --dry-run + if %ERRORLEVEL% EQU 0 exit /b 1 + exit /b 0 runner-python: runs-on: ubuntu-24.04 @@ -60,6 +66,8 @@ jobs: bash -n scripts/run-gpu.sh scripts/bootstrap-gpu.sh python runtime/tests/test_runner.py python runtime/cuda/check_barnes_hut_layout.py + python tests/test_bh2d_hardware_sweep.py + sh scripts/bench-bh2d-hardware-launch.sh --dry-run --output "$RUNNER_TEMP/bh2d-plan" --particles 512,4096,8192 - name: Install software Vulkan for compute validation run: | sudo apt-get update -qq diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 41032de..fcaf318 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -29,6 +29,7 @@ jobs: node tests/physics.mjs node tests/app.mjs node tests/barnes-hut.mjs + node tests/nbody-view.mjs cargo test --manifest-path nbody/Cargo.toml --locked --offline cargo run --manifest-path nbody/Cargo.toml --locked --offline -- verify node scripts/build-site.mjs diff --git a/NOTICE.md b/NOTICE.md index 86f8464..025398b 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -9,14 +9,15 @@ and local PNG/WebM/JSON capture. Galaxy orbital equations and the accelerated renderer replace the original gate–centre–mouth transfer geometry. The adapted browser sources (`galaxy-core.js`, `app.js`, `renderer.js`, -`index.html`, `style.css`, and the JavaScript smoke/application tests) retain +`rotation-lab.html`, `style.css`, and the JavaScript smoke/application tests) retain **Mozilla Public License 2.0** file notices. The full licence is in [`LICENSES/MPL-2.0.txt`](LICENSES/MPL-2.0.txt). Their corresponding source is provided directly in this repository and static distribution. -The new Rust sampler and native example, build scripts, generated Wasm module -and browser payload, and benchmark are -**Apache-2.0**, under the repository's existing [`LICENSE`](LICENSE). +The new N-body browser entrypoint (`index.html`, `nbody-clock.js`, and +`nbody-viz.js`), Rust sampler and native example, build scripts, generated Wasm +module and browser payload, and benchmark are **Apache-2.0**, under the +repository's existing [`LICENSE`](LICENSE). The existing repository licence has not been used to relicense the MPL files. VORTEX's reference photographs, artwork and historical stress-test reports are diff --git a/README.md b/README.md index bb3cc67..1fd4eb8 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,15 @@ It began as an adaptation of the VORTEX 2.1.0 particle lab and now combines: -- interactive rotation-curve visualization; +- a lightweight default N-body simulator: 768 real interacting bodies, luminous sprites and short orbital trails; +- interactive rotation-curve visualization in the preserved rotation-law instrument; - UFF, Newtonian, NFW, Burkert, MOND/RAR, and authored visual rotation laws; - deterministic Rust/WebAssembly sampling in the browser; - native Rust CPU execution with Float/libm and BAM32/Q2.30 LUT backends; - Vulkan/`wgpu` and NVIDIA CUDA compute paths; - memory-bounded exact-u64 logical addressing; - reproducible benchmark receipts, topology evidence, and archived scaling studies; -- an opt-in Barnes–Hut resident self-gravity laboratory with a direct-force oracle and live quadtree visualization; +- a Barnes–Hut resident self-gravity laboratory with a direct-force oracle and live quadtree visualization; - a GPU-oriented BH #2A Morton/Z-order + flat-cell CPU substrate with repeatable topology receipts; - a BH #2B1 explicit f32/u32 transfer ABI with executable Vulkan/WGSL flat-tree traversal and matched CUDA traversal source; - BH #2B2 multi-step resident self-gravity with persistent GPU state, GPU kick/drift/final-kick kernels, and an explicit CPU tree-rebuild boundary; @@ -37,9 +38,10 @@ The v0.4.0 native-CPU evidence baseline remains archived at Zenodo as: | Layer | Current state | | --- | --- | -| Browser instrument | Offline HTML/CSS/JS + bundled Rust/Wasm; no server or CDN required | -| Browser logical population | Up to `2^32 = 4,294,967,296` logical stars on the Rust/Wasm path | -| Browser rendered sample | Up to 65,536 stars per frame on Rust/Wasm + WebGL | +| Browser N-body home page | 128–2,048 interacting bodies (768 default); planar Barnes–Hut gravity, leapfrog integration, glow/trails and orbit/zoom controls | +| Rotation-law instrument | Preserved at `rotation-lab.html`; offline HTML/CSS/JS + bundled Rust/Wasm | +| Rotation-law logical population | Up to `2^32 = 4,294,967,296` logical stars on the Rust/Wasm path | +| Rotation-law rendered sample | Up to 65,536 stars per frame on Rust/Wasm + WebGL | | Native CPU runtime | Exact positive-u64 logical population, bounded resident sample up to 16,777,216 particles | | CPU projection backends | `float-libm` and `bam-lut-q30` | | Native GPU runtime | Rust/`wgpu`/Vulkan plus NVIDIA CUDA/CuPy RawKernel | @@ -61,9 +63,13 @@ The earlier v0.4.0 archive remains the **before-state** for the CPU architecture ## 1. Browser instrument -Open **`index.html`** directly in a modern browser. No install, local server, CDN, or network connection is required, including for the bundled Rust/WebAssembly engine. +Open **`index.html`** directly in a modern browser for the N-body simulator. No install, local server, CDN, or network connection is required. The default 768 bodies all contribute mass; reducing the resident count reduces computation. Glow sprites and ten-step trails add visual density without adding simulated particles. The camera tilts a planar physical system; it is not a 3D force solver. -The separate **`barnes-hut.html`** entrypoint is an opt-in resident self-gravity lab with a live quadtree overlay and direct-force error probes. It does not change the default rotation-law instrument. +Choose a binary encounter, rotating disc or cold collapse. Pause, single-step, change the seed, inspect the tree, or pause for a direct-force audit. Physics uses a fixed time step on a 60 Hz schedule, independent of monitor refresh rate. Overload drops catch-up work rather than enlarging the time step or changing the body count. Reduced-motion preference starts paused and disables trails. + +The original prescribed-field instrument, including its Rust/WebAssembly sampling, UFF controls and exports, is preserved at **`rotation-lab.html`**. The detailed tree laboratory remains at **`barnes-hut.html`**. The following logical-population and sampling controls describe the rotation-law instrument. + +The separate **`barnes-hut.html`** entrypoint is an opt-in resident self-gravity lab with a live quadtree overlay and direct-force error probes. The default browser entrypoint is the N-body simulator at **`index.html`**, while the preserved rotation-law instrument remains at **`rotation-lab.html`**. The browser instrument lets you change morphology, mass model, viewing geometry, and time while watching the galaxy and its rotation curve respond together. @@ -272,6 +278,16 @@ BH #2D is the active optimization path. It retains BH #2C and BH #2B2 as executa BH #2D correctness is exercised through software Vulkan in CI, but **software-Vulkan timings are not hardware performance evidence**. A hardware speed claim requires a non-software adapter receipt. +The remaining BH #2D evidence item has a fail-closed real-GPU sweep runner: + +```bash +sh scripts/bench-bh2d-hardware-launch.sh \ + --output runs/bh2d-hardware-sweep \ + --adapter 0 +``` + +It runs a source-pinned 512→65,536 resident-body sweep by default, requires hardware receipts, validates the frozen correctness/oracle gates, and hashes each receipt/log into a scaling manifest. The manifest is evidence for that exact source/workload/adapter, not an automatic production-promotion claim. + `--allow-software` enables verification through software Vulkan and must not be interpreted as hardware GPU performance evidence. The CUDA checker freezes ABI/source parity without claiming CUDA execution where no NVIDIA CUDA run occurred. @@ -467,6 +483,9 @@ sh scripts/test-cpu-runtime.sh # Retro CPU portability sh scripts/test-retro-cpu.sh +# GPU evidence-runner host-side tests +python3 tests/test_bh2d_hardware_sweep.py + # CUDA host-side tests python3 tests/test_cuda_u64.py ``` diff --git a/ROADMAP.md b/ROADMAP.md index 28ba21a..39d4764 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -199,6 +199,8 @@ Stable radix ordering starts from resident-body order and preserves that order f The initial CI fixture proves correctness through Mesa Vulkan but does not establish a hardware speedup. Real GPU performance evidence remains an explicit BH #2D completion item. +The repository now includes `scripts/bench-bh2d-hardware.py` to capture that evidence without weakening the boundary. The runner requires a clean tracked source tree and `--require-hardware`, validates the BH #2D correctness/determinism gates at every point, preserves repeat-matched BH #2C comparison through 4,096 bodies, and emits a source-pinned scaling manifest with receipt/log hashes. The harness is implementation support; BH #2D remains evidence-pending until a real-GPU manifest exists. + ## BH #2E — Hardware Promotion and Scaling After BH #2D obtains real-hardware receipts, the next promotion rung is: @@ -397,3 +399,7 @@ All of the following remain useful research directions, but they are explicitly These items were deferred while PE #15 was active. v0.6.0 closed that phase; any future work now requires an explicit phase with its own evidence boundary. The previous detailed designs are preserved by the immutable `v0.5.0` source archive and tag; they are not discarded, only postponed. + +## Browser N-body observatory + +The home page now runs the existing planar Barnes–Hut solver with a modest 768-body default, selectable 128–2,048 bodies, fixed-rate integration, glow/trails and camera controls. The original prescribed-field visual is preserved at `rotation-lab.html`; `barnes-hut.html` retains the detailed tree lab. This UI upgrade supplies no native GPU timing evidence and does not change BH #2D evidence-pending or BH #2E promotion gates. diff --git a/barnes-hut.css b/barnes-hut.css index 339a46a..c608237 100644 --- a/barnes-hut.css +++ b/barnes-hut.css @@ -69,3 +69,13 @@ footer { min-height: 40px; border-top: 1px solid var(--line); color: var(--muted .readouts div { border-right: 0; border-bottom: 1px solid var(--line); } .readouts div:last-child { border-bottom: 0; } } + +/* N-body observatory controls. */ +.gesture-note { color: var(--muted); font-size: 0.7rem; align-self: center; margin-left: auto; } +#nbodyCanvas { touch-action: none; cursor: grab; } +#nbodyCanvas:active { cursor: grabbing; } +:focus-visible { outline: 2px solid var(--hot); outline-offset: 3px; } +input[type="range"], input[type="checkbox"] { accent-color: var(--hot); } +@media (max-width: 560px) { + .gesture-note { margin-left: 0; width: 100%; line-height: 1.5; } +} diff --git a/barnes-hut.html b/barnes-hut.html index 1abf041..d6b9223 100644 --- a/barnes-hut.html +++ b/barnes-hut.html @@ -18,7 +18,7 @@

QSOL-IMC / GALAXY / SELF-GRAVITY LAB

BARNES–HUT

- +
@@ -48,7 +48,7 @@

BARNES–HUT

What changed?

This lab is a separate self-gravitating execution path. Every resident body contributes mass to a deterministic quadtree. Distant cells may be replaced by their centre of mass when s / d < θ; nearby cells are opened. A kick–drift–kick leapfrog step rebuilds the tree after the drift.

-

The main GALAXY rotation-law instrument remains unchanged. Its huge logical populations are sampled independent test particles; they are not silently reinterpreted as mutually interacting bodies. This lab keeps the resident population explicit because self-gravity creates cross-particle coupling.

+

The GALAXY rotation-law instrument remains available separately. Its huge logical populations are sampled independent test particles; they are not silently reinterpreted as mutually interacting bodies. This lab keeps the resident population explicit because self-gravity creates cross-particle coupling.

diff --git a/docs/BARNES-HUT.md b/docs/BARNES-HUT.md index 8491904..05494a1 100644 --- a/docs/BARNES-HUT.md +++ b/docs/BARNES-HUT.md @@ -2,7 +2,7 @@ GALAXY now has two deliberately distinct dynamics families: -1. **Prescribed-field / test-particle modes** — the existing browser, CPU and GPU paths. These retain huge logical address spaces because individual resident particles do not affect one another. +1. **Prescribed-field / test-particle modes** — the rotation-law browser instrument (`rotation-lab.html`), CPU and GPU paths. These retain huge logical address spaces because individual resident particles do not affect one another. 2. **Resident self-gravity** — the Barnes–Hut laboratory. Every resident body's mass contributes to the force field, so the complete interacting resident set is explicit and cannot be substituted by independent logical-u64 tiles. ## Why Barnes–Hut @@ -55,6 +55,9 @@ A future GPU implementation should preserve this CPU/direct reference as its cor ## Visualization +`index.html` is the default lightweight N-body observatory. It reuses the verified browser force solver with 768 resident bodies by default (128–2,048 selectable), fixed-rate leapfrog scheduling, seeded presets, optional trails and luminous sprites. Rendering contributes no additional gravitational mass. Orbit/zoom controls project the planar dynamics without modifying them. At 4× speed on an ordinary 60 Hz display, each rendered frame advances four physics steps. After a render gap the scheduler may batch up to 24 steps, derived from the 100 ms elapsed-time cap, then discards any remaining overload debt; hidden tabs suspend physics and under sustained load simulated time advances more slowly. Force probes pause the simulation so their result remains attached to the displayed state. + + `barnes-hut.html` is an offline browser laboratory. It renders the resident bodies and can overlay quadtree cells while the system evolves. Controls expose: - resident body count; @@ -289,6 +292,34 @@ Kernel families are batched into command buffers to avoid measuring one CPU/GPU Receipts classify the selected adapter as either `software-validation` or `hardware`. `--require-hardware` rejects software adapters. Mesa/llvmpipe timing is therefore correctness and diagnostic evidence only; a hardware performance claim requires a receipt from a real GPU. +### Real-hardware sweep + +`scripts/bench-bh2d-hardware.py` is the evidence runner for the remaining BH #2D hardware item. It executes a strictly increasing resident-body sweep through `galaxy-bh-gpu-tree-parallel --require-hardware`, refuses a dirty tracked source tree or an existing evidence directory, validates every receipt, requires one adapter identity for the complete sweep, and hashes each receipt/log into `manifest.json`. + +The default sweep is: + +```text +512 -> 1024 -> 2048 -> 4096 -> 8192 -> 16384 -> 32768 -> 65536 +``` + +Counts through 4,096 retain repeat-matched BH #2C timing. Larger points explicitly require the BH #2C and bounded full-oracle skips already encoded by the verifier; they do not silently drop those gates. + +Example on a real GPU: + +```bash +sh scripts/bench-bh2d-hardware-launch.sh \ + --output runs/bh2d-hardware-sweep \ + --adapter 0 +``` + +A completed scaling manifest is hardware evidence for the exact recorded source, workload and adapter. It does **not** by itself promote BH #2D to production or establish a host-independent speed claim. + ## Next rung After real-hardware BH #2D receipts exist, BH #2E can evaluate scaling, memory/capacity limits and production promotion while retaining BH #2A/B2B2/B2C as frozen oracles. + +### Hardware harness launch and toolchain boundary + +Launch real evidence capture through the clean launcher: `sh scripts/bench-bh2d-hardware-launch.sh ...` on POSIX or `scripts\\bench-bh2d-hardware-launch.cmd ...` on native Windows. The launcher starts isolated Python under a newly constructed environment so inherited loader-injection variables are not resident in the evidence process. Direct Python execution is rejected. Imported use by the unit-test runner is for host validation only. + +The harness now selects and records a platform-specific tool boundary. Linux uses the system compiler/linker/Git path, macOS uses the native system paths plus standard Homebrew locations, and Windows records Git plus any active MSVC/LLVM linker tools before narrowing the build PATH to those resolved tool directories. Cargo and rustc are recorded separately from rustup proxies and invoked through their resolved binaries. The OS, installed system libraries and toolchain are trusted host prerequisites; this is provenance checking, not a sandbox against a hostile host. Git checks clear ambient `GIT_*` selectors and bind the checkout explicitly, including linked worktrees. Relative `CARGO_HOME` is interpreted from Cargo's repository working directory. Logs preserve raw bytes, receipt parser failures enter the failed-manifest path, and workload values are validated before any output is created. Manifests serialize strict JSON. diff --git a/docs/ENGINE.md b/docs/ENGINE.md index 544b3ed..65e7f16 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -95,7 +95,7 @@ represent a 2³² logical population. WebGL context loss stops the current recor and switches to a fresh Canvas element with the smaller sample. The payload is base64 in an external classic script, avoiding fetch and file-URL -CORS restrictions when opening `index.html` directly. It needs no `eval`, no +CORS restrictions when opening `rotation-lab.html` directly. It needs no `eval`, no worker, no remote package, and no cross-origin-isolation headers. CSP explicitly permits Wasm compilation using `wasm-unsafe-eval`. Compilation failure uses the bounded JavaScript fallback and is reported in the interface. diff --git a/docs/GPU-RUNTIME.md b/docs/GPU-RUNTIME.md index 362025c..e54f244 100644 --- a/docs/GPU-RUNTIME.md +++ b/docs/GPU-RUNTIME.md @@ -29,7 +29,9 @@ Install Rust through [rustup](https://rustup.rs/) and a working native GPU drive The repository pins Rust 1.85.1. Linux uses Vulkan; Windows can use Vulkan or D3D12, and macOS uses Metal through [wgpu](https://github.com/gfx-rs/wgpu/tree/v24.0.5). The Linux path is the cloud target. Windows/Metal hardware execution has not been -validated by the Linux CI gate. +validated by the Linux CI gate. The BH #2D evidence harness itself is platform-aware: +use `scripts\\bench-bh2d-hardware-launch.cmd` on native Windows so Cargo can retain +its recorded MSVC/LLVM linker environment without requiring a POSIX `cc`. On Ubuntu, with the GPU vendor driver already installed: @@ -275,6 +277,16 @@ It also records `hardware_performance_claim_allowed`. `--require-hardware` fails Tree-build stage families are batched into command buffers before synchronization. Reported stage times cover bounds, Morton generation, radix ordering, target-position assignment, topology and aggregates rather than one host synchronization per small kernel. +For repeatable real-hardware scaling evidence, use the fail-closed sweep runner: + +```bash +sh scripts/bench-bh2d-hardware-launch.sh \ + --output runs/bh2d-hardware-sweep \ + --adapter 0 +``` + +The runner defaults to 512 through 65,536 resident bodies, refuses software receipts and dirty tracked source, keeps the selected adapter fixed across the sweep, validates the BH #2D correctness/oracle status encoded in every receipt, and records SHA-256 hashes for the receipts and logs in a source-revision-pinned `manifest.json`. Use `--dry-run` to inspect the exact commands without executing GPU work. + ## Physics and numerical behavior The source remains UFF commit diff --git a/index.html b/index.html index 6aad4eb..73ef9ae 100644 --- a/index.html +++ b/index.html @@ -1,121 +1,131 @@ - + - - - GALAXY · Stellar motion lab - - - - - - - - + + + GALAXY · N-body simulator + + + +
- -

QSOL-IMC / STELLAR MOTION LAB

GALAXY

- +
+

QSOL-IMC / LIVE N-BODY SIMULATOR

+

GALAXY

+
+
-
-
-

LIVE OBSERVATION

Grand design spiral

Differential rotation
-
- Your browser needs Canvas support to display the galaxy. -
INCLINATION 38°
-
DISC / BULGE / HALO— FPS
+ +
+
+
+

FEWER BODIES. REAL GRAVITY.

Binary disc encounter

+ RUNNING
-
Drag to orbit · scroll to zoom · space to pause
-
-
LOGICAL STARS16,777,2162²⁴ indexed field
-
RENDERED STARS—representative sample / frame
-
PARTICLE BUFFER—sample + orbital-rate buffers
+
+ +
θ 0.50— FPS
+
SELF-GRAVITY / LEAPFROGSTEP 0
-
-

Rotation curve

UFF empirical v4
- -
━ Selected model┄ Baryons○ UFF demo input ± error
-
At 8 kpc —Orbit period —Model time 0 Myr
-

UFF demonstration data · fixed parameters, no fit. Shaded ends extend the supplied component speeds beyond 0.5–12 kpc.

-
Compare the six demo rows - - - - - - -
Radius (kpc)Demo ± error (km/s)Model (km/s)
0.540 ± 5—
1.065 ± 5—
2.090 ± 4—
5.0115 ± 4—
8.0130 ± 6—
12.0135 ± 7—
-
-

Preparing the deterministic star field.

-
About this galaxy

The UFF modes use gas, disc and bulge component curves to calculate circular speeds, with a selectable halo or acceleration law and an optional central mass. Those speeds drive the stars’ orbits. The original visual mode retains its adjustable shear.

The star distribution remains an authored disc, bulge and halo. Morphology controls change that display geometry; mass-to-light controls change the dynamics. Logical stars describe a sampled population. Only the rendered count is drawn.

UFF’s demo table is an included example, not an identified observational catalogue. Source equations and model definitions ↗

+
+ + + + + Drag to orbit · scroll to zoom · space to pause +
+ +
+
BODIES—resident, mutually coupled
+
TREE NODES—— depth
+
FORCE TERMS—— vs direct
+
PROBE RMS ERROR—12 direct-force probes
+
+ +

768 bodies, each contributing mass. Glow and trails add depth without adding gravitational work.

+
+ Real gravity, a little optical theatre +

Every displayed body participates in this planar N-body simulation. A deterministic Barnes–Hut quadtree approximates distant mass; a kick–drift–kick leapfrog integrator evolves positions and velocities. Bodies can pull, scatter and form tidal structures instead of following prescribed orbits.

+

Soft luminous sprites and short position-history trails make a modest population look rich. They add no mass or hidden simulated stars. The 3D-looking inclination is a projection of a 2D physical system, in normalized units with G = 1.

+

Physics targets 60 fixed steps per second at 1× speed. Under load it slows down rather than increasing the integration step or changing the population. This browser view is separate from the native GPU evidence programme.

+
-
- +
GALAXY N-body simulator · normalized planar coordinates · Newtonian point-mass force with Plummer-style softening
diff --git a/nbody-clock.js b/nbody-clock.js new file mode 100644 index 0000000..e2b52b8 --- /dev/null +++ b/nbody-clock.js @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// Bounded fixed-rate scheduling; never enlarge dt to catch up with rendering. +(function (global) { + "use strict"; + const MAX_SPEED = 4; + const MAX_ELAPSED_SECONDS = 0.1; + const MAX_STEPS_PER_FRAME = Math.ceil(60 * MAX_SPEED * MAX_ELAPSED_SECONDS); + class FixedClock { + constructor() { this.reset(); } + reset() { this.last = null; this.remainder = 0; } + advance(now, active, speed = 1) { + if (!active) { this.reset(); return 0; } + if (this.last === null) { this.last = now; return 0; } + const elapsed = Math.max(0, Math.min(MAX_ELAPSED_SECONDS, (now - this.last) / 1000)); + this.last = now; + this.remainder += elapsed * 60 * speed; + const due = Math.floor(this.remainder + 1e-9); + const steps = Math.min(MAX_STEPS_PER_FRAME, due); + this.remainder -= due; // Drop overload debt; no unbounded catch-up burst. + return steps; + } + } + global.GalaxyNBodyClock = FixedClock; +})(globalThis); diff --git a/nbody-viz.js b/nbody-viz.js new file mode 100644 index 0000000..b1fe677 --- /dev/null +++ b/nbody-viz.js @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: Apache-2.0 +(function () { + "use strict"; + const BH = globalThis.GalaxyBarnesHut; + const byId = id => document.getElementById(id); + const canvas = byId("nbodyCanvas"); + const ctx = canvas.getContext("2d", { alpha: false }); + if (!BH || !ctx) throw new Error("N-body simulator could not start"); + + const motion = matchMedia("(prefers-reduced-motion: reduce)"); + const clock = new globalThis.GalaxyNBodyClock(); + const history = []; + const sprites = [makeSprite("224,187,126"), makeSprite("206,211,220")]; + let lastReadout = 0, previousDrawStep = -1; + const state = { + bodies: [], latest: null, running: !motion.matches, step: 0, lastFrame: 0, fpsStart: performance.now(), frames: 0, + preset: "collision", count: 768, seed: 303, theta: 0.5, softening: 0.03, bucket: 4, + dt: 0.004, stepsPerFrame: 1, showTree: false, treeDepth: 5, probeRms: null, simulatedTime: 0, tilt: 38, rotation: -18, zoom: 1, light: 1, trails: !motion.matches, view: { cx: 0, cy: 0, span: 3 } + }; + + function makeSprite(rgb) { + const sprite = document.createElement("canvas"); + sprite.width = sprite.height = 64; + const paint = sprite.getContext("2d"); + const glow = paint.createRadialGradient(32, 32, 0, 32, 32, 32); + glow.addColorStop(0, "rgba(255,249,228,1)"); + glow.addColorStop(0.07, `rgba(${rgb},0.85)`); + glow.addColorStop(0.22, `rgba(${rgb},0.25)`); + glow.addColorStop(0.6, `rgba(${rgb},0.045)`); + glow.addColorStop(1, `rgba(${rgb},0)`); + paint.fillStyle = glow; paint.fillRect(0, 0, 64, 64); + return sprite; + } + + function remember() { + const points = new Float64Array(state.bodies.length * 2); + state.bodies.forEach((b, i) => { points[i * 2] = b.x; points[i * 2 + 1] = b.y; }); + history.push(points); + if (history.length > 10) history.shift(); + } + + function options() { return { theta: state.theta, softening: state.softening, bucket: state.bucket, maxDepth: 40, G: 1 }; } + function formatInt(n) { return Math.round(n).toLocaleString("en-US"); } + function presetName() { return state.preset === "collision" ? "Binary disc encounter" : state.preset === "cold" ? "Cold collapse" : "Single rotating disc"; } + + function reset() { + if (state.preset === "collision") state.bodies = BH.makeCollision(state.count, state.seed); + else { + state.bodies = BH.makeDisc(state.count, state.seed, { radius: state.preset === "cold" ? 1.05 : 0.95, spin: state.preset === "cold" ? 0.05 : 0.72 }); + } + state.latest = BH.accelerations(state.bodies, options()); + state.step = 0; + state.simulatedTime = 0; + state.view = extent(); state.zoom = 1; + byId("zoom").value = 1; + history.length = 0; remember(); clock.reset(); + syncControlLabels(); + invalidateAudit(); + byId("presetTitle").textContent = presetName(); + byId("viewStatus").textContent = `${state.bodies.length.toLocaleString()} bodies, each contributing mass. Glow and trails add depth without adding gravitational work.`; + updateReadouts(); draw(); + } + + function resize() { + const rect = canvas.getBoundingClientRect(); + const dpr = Math.min(globalThis.devicePixelRatio || 1, 2); + const width = Math.max(1, Math.round(rect.width * dpr)); + const height = Math.max(1, Math.round(rect.height * dpr)); + if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } + } + + function extent() { + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const b of state.bodies) { + minX = Math.min(minX, b.x); minY = Math.min(minY, b.y); maxX = Math.max(maxX, b.x); maxY = Math.max(maxY, b.y); + } + const cx = (minX + maxX) * 0.5, cy = (minY + maxY) * 0.5; + const span = Math.max(maxX - minX, maxY - minY, 2.4) * 1.15; + return { cx, cy, span }; + } + + function draw() { + resize(); + const w = canvas.width, h = canvas.height, view = state.view; + const dpr = Math.min(globalThis.devicePixelRatio || 1, 2); + const scale = Math.min(w, h) / view.span * state.zoom; + const angle = state.rotation * Math.PI / 180; + const c = Math.cos(angle), sn = Math.sin(angle), tilt = Math.cos(state.tilt * Math.PI / 180); + const project = (x, y) => { + x -= view.cx; y -= view.cy; + return [w * 0.5 + (x * c - y * sn) * scale, h * 0.5 - (x * sn + y * c) * tilt * scale]; + }; + ctx.fillStyle = "#020304"; ctx.fillRect(0, 0, w, h); + // Faint reference rings supply depth without extra simulated particles. + ctx.strokeStyle = "rgba(151,143,126,0.08)"; ctx.lineWidth = dpr * 0.65; + for (const radius of [0.5, 1, 1.5]) { + ctx.beginPath(); + for (let k = 0; k <= 96; k++) { + const a = k / 96 * Math.PI * 2; + const [x, y] = project(view.cx + radius * Math.cos(a), view.cy + radius * Math.sin(a)); + if (k === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + } + if (state.showTree && state.latest) { + ctx.lineWidth = dpr * 0.6; + for (const node of BH.flattenTree(state.latest.tree, state.treeDepth)) { + if (node.depth === 0) continue; + ctx.strokeStyle = `rgba(214,173,103,${Math.max(0.05, 0.3 - node.depth * 0.035)})`; + ctx.beginPath(); + for (const [i, [dx, dy]] of [[-1,-1], [1,-1], [1,1], [-1,1]].entries()) { + const [x, y] = project(node.cx + dx * node.half, node.cy + dy * node.half); + if (!i) ctx.moveTo(x,y); else ctx.lineTo(x,y); + } + ctx.closePath(); ctx.stroke(); + } + } + ctx.globalCompositeOperation = "lighter"; + if (state.trails && history.length > 1) { + ctx.lineWidth = 0.7 * dpr; + for (let k = 1; k < history.length; k++) { + ctx.strokeStyle = `rgba(198,180,150,${0.18 * k / history.length * state.light})`; + ctx.beginPath(); + for (let i = 0; i < state.bodies.length; i++) { + const [x0,y0] = project(history[k-1][i*2], history[k-1][i*2+1]); + const [x1,y1] = project(history[k][i*2], history[k][i*2+1]); + ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); + } + ctx.stroke(); + } + } + for (let i = 0; i < state.bodies.length; i++) { + const b = state.bodies[i]; + const [x, y] = project(b.x, b.y); + const size = (10 + (i % 7) * 1.5) * dpr * Math.sqrt(state.light); + ctx.globalAlpha = 0.65 + (i % 5) * 0.07; + const group = state.preset === "collision" ? (i < state.bodies.length / 2 ? 0 : 1) : i % 2; + ctx.drawImage(sprites[group], x - size/2, y - size/2, size, size); + } + ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; + previousDrawStep = state.step; + } + + function updateReadouts() { + const n = state.bodies.length; + byId("bodyReadout").textContent = formatInt(n); + byId("thetaBadge").textContent = "θ " + state.theta.toFixed(2); + byId("timeReadout").textContent = "t " + state.simulatedTime.toFixed(3) + " · STEP " + formatInt(state.step); + if (state.latest) { + const stats = state.latest.stats; + const terms = stats.direct + stats.approximated; + const exactTerms = n * (n - 1); + const reduction = exactTerms ? Math.max(0, 1 - terms / exactTerms) : 0; + byId("nodeReadout").textContent = formatInt(state.latest.tree.nodeCount); + byId("depthReadout").textContent = (state.latest.tree.maxDepth + 1) + " levels · " + formatInt(state.latest.tree.leafCount) + " leaves"; + byId("termReadout").textContent = formatInt(terms); + byId("reductionReadout").textContent = (reduction * 100).toFixed(1) + "% fewer force terms than direct"; + } + byId("errorReadout").textContent = state.probeRms === null ? "—" : (state.probeRms * 100).toFixed(2) + "%"; + byId("runBadge").textContent = state.running ? "RUNNING" : "PAUSED"; + byId("playToggle").textContent = state.running ? "Pause" : "Resume"; + byId("playToggle").setAttribute("aria-pressed", String(state.running)); + } + + function invalidateAudit(message = "Run the direct-force probe for the current state.") { + state.probeRms = null; + byId("errorReadout").textContent = "—"; + byId("auditStatus").textContent = message; + } + + function integrateOnce() { + state.latest = BH.stepLeapfrog(state.bodies, state.dt, options()); + state.step++; + state.simulatedTime += state.dt; + if (state.trails) remember(); + invalidateAudit("State advanced; run the direct-force probe again for this step."); + } + + function audit() { + if (!state.latest) return; + state.running = false; clock.reset(); + const n = state.bodies.length; + const probes = Math.min(12, n); + let sumSq = 0, max = 0; + for (let k = 0; k < probes; k++) { + const index = Math.floor(k * (n - 1) / Math.max(1, probes - 1)); + const exact = BH.directAccelerationFor(index, state.bodies, options()); + const approx = state.latest.values[index]; + const denom = Math.max(Math.hypot(exact.ax, exact.ay), 1e-30); + const rel = Math.hypot(approx.ax - exact.ax, approx.ay - exact.ay) / denom; + sumSq += rel * rel; max = Math.max(max, rel); + } + state.probeRms = Math.sqrt(sumSq / probes); + byId("auditStatus").textContent = `Direct audit: ${probes} probes · RMS ${(state.probeRms * 100).toFixed(3)}% · max ${(max * 100).toFixed(3)}%.`; + updateReadouts(); + } + + function syncControlLabels() { + byId("countValue").textContent = state.count; + byId("thetaValue").textContent = state.theta.toFixed(2); + byId("softeningValue").textContent = state.softening.toFixed(3); + byId("bucketValue").textContent = state.bucket; + byId("treeDepthValue").textContent = state.treeDepth; + byId("dtValue").textContent = state.dt.toFixed(4); + byId("stepsValue").textContent = state.stepsPerFrame + "×"; + byId("tiltValue").textContent = state.tilt + "°"; + byId("rotationValue").textContent = state.rotation + "°"; + byId("zoomValue").textContent = state.zoom.toFixed(1) + "×"; + byId("lightValue").textContent = state.light.toFixed(1) + "×"; + byId("trails").checked = state.trails; + } + + function bind() { + byId("playToggle").addEventListener("click", () => { state.running = !state.running; clock.reset(); updateReadouts(); }); + byId("singleStep").addEventListener("click", () => { state.running = false; clock.reset(); integrateOnce(); draw(); updateReadouts(); }); + byId("reset").addEventListener("click", reset); + byId("audit").addEventListener("click", audit); + byId("preset").addEventListener("change", e => { state.preset = e.target.value; reset(); }); + byId("count").addEventListener("input", e => { state.count = Number(e.target.value); syncControlLabels(); }); + byId("count").addEventListener("change", reset); + byId("seed").addEventListener("change", e => { state.seed = Math.min(4294967295, Math.max(1, Math.trunc(Number(e.target.value) || 303))); e.target.value = state.seed; reset(); }); + byId("theta").addEventListener("input", e => { state.theta = Number(e.target.value); syncControlLabels(); state.latest = BH.accelerations(state.bodies, options()); invalidateAudit("Opening angle changed; run the direct-force probe again."); updateReadouts(); draw(); }); + byId("softening").addEventListener("input", e => { state.softening = Number(e.target.value); syncControlLabels(); state.latest = BH.accelerations(state.bodies, options()); invalidateAudit("Softening changed; run the direct-force probe again."); updateReadouts(); draw(); }); + byId("bucket").addEventListener("input", e => { state.bucket = Number(e.target.value); syncControlLabels(); state.latest = BH.accelerations(state.bodies, options()); invalidateAudit("Leaf bucket changed; run the direct-force probe again."); updateReadouts(); draw(); }); + byId("showTree").addEventListener("change", e => { state.showTree = e.target.checked; draw(); }); + byId("treeDepth").addEventListener("input", e => { state.treeDepth = Number(e.target.value); syncControlLabels(); draw(); }); + byId("dt").addEventListener("input", e => { state.dt = Number(e.target.value); syncControlLabels(); }); + byId("steps").addEventListener("input", e => { state.stepsPerFrame = Number(e.target.value); syncControlLabels(); }); + byId("fitView").addEventListener("click", () => { state.view = extent(); state.zoom = 1; byId("zoom").value = 1; syncControlLabels(); draw(); }); + for (const name of ["tilt", "rotation", "zoom", "light"]) { + byId(name).addEventListener("input", e => { state[name] = Number(e.target.value); syncControlLabels(); draw(); }); + } + byId("trails").addEventListener("change", e => { state.trails = e.target.checked; history.length = 0; remember(); draw(); }); + let pointer = null; + canvas.addEventListener("pointerdown", e => { pointer = { id: e.pointerId, x: e.clientX, y: e.clientY }; canvas.setPointerCapture(e.pointerId); }); + canvas.addEventListener("pointermove", e => { + if (!pointer || pointer.id !== e.pointerId) return; + state.rotation = Math.round(((state.rotation + (e.clientX - pointer.x) * 0.4 + 540) % 360) - 180); + state.tilt = Math.round(Math.max(0, Math.min(80, state.tilt + (e.clientY - pointer.y) * 0.3))); + pointer.x = e.clientX; pointer.y = e.clientY; + byId("rotation").value = state.rotation; byId("tilt").value = state.tilt; + syncControlLabels(); draw(); + }); + for (const event of ["pointerup", "pointercancel", "lostpointercapture"]) canvas.addEventListener(event, () => { pointer = null; }); + canvas.addEventListener("wheel", e => { + e.preventDefault(); state.zoom = Math.max(0.3, Math.min(3, state.zoom * Math.exp(-e.deltaY * 0.001))); + byId("zoom").value = state.zoom; syncControlLabels(); draw(); + }, { passive: false }); + document.addEventListener("visibilitychange", () => { + clock.reset(); + state.frames = 0; + state.fpsStart = performance.now(); + }); + motion.addEventListener("change", e => { + if (e.matches) { state.running = false; state.trails = false; clock.reset(); history.length = 0; syncControlLabels(); updateReadouts(); draw(); } + }); + globalThis.addEventListener("resize", draw); + document.addEventListener("keydown", e => { + if (e.code === "Space" && !e.repeat && !e.target.closest("input, select, button, a, summary")) { + e.preventDefault(); state.running = !state.running; clock.reset(); updateReadouts(); + } + }); + } + + function frame(now) { + const steps = clock.advance(now, state.running && !document.hidden, state.stepsPerFrame); + try { + for (let i = 0; i < steps; i++) integrateOnce(); + } catch (error) { + state.running = false; clock.reset(); + byId("viewStatus").textContent = "Simulation stopped: " + error.message + ". Reduce the time step, then reset."; + } + if (!document.hidden) { + if (previousDrawStep !== state.step) draw(); + if (now - lastReadout > 200 || !state.running) { updateReadouts(); lastReadout = now; } + state.frames++; + if (now - state.fpsStart >= 750) { + byId("fpsReadout").textContent = Math.round(state.frames * 1000 / (now - state.fpsStart)) + " FPS"; + state.frames = 0; state.fpsStart = now; + } + } + requestAnimationFrame(frame); + } + + bind(); syncControlLabels(); reset(); requestAnimationFrame(frame); +})(); diff --git a/rotation-lab.html b/rotation-lab.html new file mode 100644 index 0000000..e05f8f6 --- /dev/null +++ b/rotation-lab.html @@ -0,0 +1,121 @@ + + + + + + + + + + GALAXY · Stellar motion lab + + + + + + + + + + +
+ +

QSOL-IMC / STELLAR MOTION LAB

GALAXY

+ +
+
+
+

LIVE OBSERVATION

Grand design spiral

Differential rotation
+
+ Your browser needs Canvas support to display the galaxy. +
INCLINATION 38°
+
DISC / BULGE / HALO— FPS
+
+
Drag to orbit · scroll to zoom · space to pause
+
+
LOGICAL STARS16,777,2162²⁴ indexed field
+
RENDERED STARS—representative sample / frame
+
PARTICLE BUFFER—sample + orbital-rate buffers
+
+
+

Rotation curve

UFF empirical v4
+ +
━ Selected model┄ Baryons○ UFF demo input ± error
+
At 8 kpc —Orbit period —Model time 0 Myr
+

UFF demonstration data · fixed parameters, no fit. Shaded ends extend the supplied component speeds beyond 0.5–12 kpc.

+
Compare the six demo rows + + + + + + +
Radius (kpc)Demo ± error (km/s)Model (km/s)
0.540 ± 5—
1.065 ± 5—
2.090 ± 4—
5.0115 ± 4—
8.0130 ± 6—
12.0135 ± 7—
+
+

Preparing the deterministic star field.

+
About this galaxy

The UFF modes use gas, disc and bulge component curves to calculate circular speeds, with a selectable halo or acceleration law and an optional central mass. Those speeds drive the stars’ orbits. The original visual mode retains its adjustable shear.

The star distribution remains an authored disc, bulge and halo. Morphology controls change that display geometry; mass-to-light controls change the dynamics. Logical stars describe a sampled population. Only the rendered count is drawn.

UFF’s demo table is an included example, not an identified observational catalogue. Source equations and model definitions ↗

+
+ +
+ + + diff --git a/runtime/src/nbody_gpu.rs b/runtime/src/nbody_gpu.rs index fc2ea11..c1860ab 100644 --- a/runtime/src/nbody_gpu.rs +++ b/runtime/src/nbody_gpu.rs @@ -295,10 +295,11 @@ fn select_adapter( }) } -fn describe(adapter: &wgpu::Adapter, index: usize) -> Value { +fn describe(adapter: &wgpu::Adapter, index: usize, adapter_count: usize) -> Value { let info = adapter.get_info(); json!({ "index": index, + "adapter_count": adapter_count, "name": info.name, "backend": format!("{:?}", info.backend), "device_type": format!("{:?}", info.device_type), @@ -333,8 +334,9 @@ impl NbodyGpu { let index = select_adapter(&infos, name, allow_software).ok_or( "No matching hardware compute adapter. --allow-software permits a software adapter for validation.", )?; + let adapter_count = infos.len(); let adapter = available_adapters.swap_remove(index); - let info = describe(&adapter, index); + let info = describe(&adapter, index, adapter_count); let available = adapter.limits(); let limits = wgpu::Limits { max_storage_buffer_binding_size: available.max_storage_buffer_binding_size, diff --git a/scripts/bench-bh2d-hardware-launch.cmd b/scripts/bench-bh2d-hardware-launch.cmd new file mode 100644 index 0000000..dd9b7ef --- /dev/null +++ b/scripts/bench-bh2d-hardware-launch.cmd @@ -0,0 +1,35 @@ +@echo off +rem SPDX-License-Identifier: Apache-2.0 +setlocal + +set "LD_PRELOAD=" +set "LD_AUDIT=" +set "LD_LIBRARY_PATH=" +set "DYLD_INSERT_LIBRARIES=" +set "DYLD_LIBRARY_PATH=" +set "DYLD_FRAMEWORK_PATH=" +set "VK_DRIVER_FILES=" +set "VK_ADD_DRIVER_FILES=" +set "VK_ICD_FILENAMES=" +set "VK_LAYER_PATH=" +set "VK_ADD_LAYER_PATH=" +set "VK_INSTANCE_LAYERS=" +set "VK_LOADER_LAYERS_ENABLE=" +set "VK_LOADER_LAYERS_DISABLE=" +set "VK_LOADER_DRIVERS_SELECT=" +set "VK_LOADER_DRIVERS_DISABLE=" +set "GALAXY_BH2D_CLEAN_LAUNCH=1" + +where py >nul 2>nul +if errorlevel 1 goto :python_fallback +py -3 -I "%~dp0bench-bh2d-hardware.py" %* +exit /b %ERRORLEVEL% + +:python_fallback +where python >nul 2>nul +if %ERRORLEVEL% NEQ 0 ( + echo BH #2D hardware sweep: Python 3 is required 1>&2 + exit /b 127 +) +python -I "%~dp0bench-bh2d-hardware.py" %* +exit /b %ERRORLEVEL% diff --git a/scripts/bench-bh2d-hardware-launch.sh b/scripts/bench-bh2d-hardware-launch.sh new file mode 100644 index 0000000..7f2d428 --- /dev/null +++ b/scripts/bench-bh2d-hardware-launch.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +PYTHON_BIN=$(command -v python3 || true) +if [ -z "$PYTHON_BIN" ]; then + echo "BH #2D hardware sweep: python3 is required" >&2 + exit 127 +fi + +if [ -x /usr/bin/env ]; then + ENV_BIN=/usr/bin/env +elif [ -x /bin/env ]; then + ENV_BIN=/bin/env +else + echo "BH #2D hardware sweep: system env executable is required" >&2 + exit 127 +fi + +PATH_VALUE=${PATH-} +HOME_VALUE=${HOME-} +USER_VALUE=${USER-} +LOGNAME_VALUE=${LOGNAME-} +TMPDIR_VALUE=${TMPDIR-} +CARGO_HOME_VALUE=${CARGO_HOME:-$HOME_VALUE/.cargo} +RUSTUP_HOME_VALUE=${RUSTUP_HOME:-$HOME_VALUE/.rustup} + +exec "$ENV_BIN" -i \ + GALAXY_BH2D_CLEAN_LAUNCH=1 \ + PATH="$PATH_VALUE" \ + HOME="$HOME_VALUE" \ + USER="$USER_VALUE" \ + LOGNAME="$LOGNAME_VALUE" \ + TMPDIR="$TMPDIR_VALUE" \ + CARGO_HOME="$CARGO_HOME_VALUE" \ + RUSTUP_HOME="$RUSTUP_HOME_VALUE" \ + "$PYTHON_BIN" -I "$SCRIPT_DIR/bench-bh2d-hardware.py" "$@" diff --git a/scripts/bench-bh2d-hardware.py b/scripts/bench-bh2d-hardware.py new file mode 100644 index 0000000..f0b0253 --- /dev/null +++ b/scripts/bench-bh2d-hardware.py @@ -0,0 +1,1697 @@ +#!/usr/bin/env -S python3 -I +# SPDX-License-Identifier: Apache-2.0 +"""Fail-closed BH #2D real-hardware scaling sweep. + +This runner does not create a performance claim by itself. It executes the +existing galaxy-bh-gpu-tree-parallel verifier with --require-hardware at each +requested resident-body count, validates the emitted receipts, and writes a +manifest tying the sweep to one clean Git revision and one adapter identity. +""" + +from __future__ import annotations + +# sys is built in: fail before importing anything from a caller-controlled path. +import sys +if __name__ == "__main__" and not sys.flags.isolated: + raise SystemExit( + "Hardware capture requires the clean launcher: scripts/bench-bh2d-hardware ..." + ) + +import argparse +import hashlib +import json +import math +import os +import platform +import re +import shlex +import shutil +import stat +import subprocess +import tomllib +from pathlib import Path, PureWindowsPath +from typing import Any + +RECEIPT_SCHEMA = "galaxy.barnes-hut-parallel-gpu-tree-receipt.v1" +MANIFEST_SCHEMA = "galaxy.bh2d-hardware-scaling-manifest.v1" +MAX_PARTICLES = 65_536 +BH2C_CAP = 4_096 +DEFAULT_PARTICLES = "512,1024,2048,4096,8192,16384,32768,65536" +BENCHMARK_SAMPLE_SCOPE = ( + "complete rebuild call including host-side uniform/bind-group setup, " + "command encoding, submit and synchronization" +) +WORKGROUP_SIZE = 128 +TREE_LEVELS = 17 +TREE_BUCKET_SIZE = 4 +GPU_BODY_BYTES = 32 +GPU_ENTRY_BYTES = 16 +GPU_CELL_BYTES = 64 +TREE_META_BYTES = 32 +BOUNDS_RECORD_BYTES = 16 +RADIX_DIGITS = 16 +TREE_ORDERING = ( + "stable-lsd-radix-4bit-morton-code; resident body index retained for equal Morton keys" +) +TREE_LAYOUT = "sparse-level-order-slot=depth*N+group_start" +CLEAN_LAUNCH_ENV = "GALAXY_BH2D_CLEAN_LAUNCH" +BUILD_ENV_EXACT = { + "LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH", "LIBRARY_PATH", "COMPILER_PATH", + "VK_DRIVER_FILES", "VK_ADD_DRIVER_FILES", "VK_ICD_FILENAMES", + "VK_LAYER_PATH", "VK_ADD_LAYER_PATH", "VK_INSTANCE_LAYERS", + "VK_LOADER_LAYERS_ENABLE", "VK_LOADER_LAYERS_DISABLE", + "VK_LOADER_DRIVERS_SELECT", "VK_LOADER_DRIVERS_DISABLE", + "GCC_EXEC_PREFIX", "CPATH", "C_INCLUDE_PATH", "CPLUS_INCLUDE_PATH", + "RUSTFLAGS", + "CARGO_ENCODED_RUSTFLAGS", + "CARGO_BUILD_RUSTFLAGS", + "RUSTC", + "RUSTC_WRAPPER", + "RUSTC_WORKSPACE_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "RUSTC_BOOTSTRAP", + "RUSTUP_TOOLCHAIN", + "CARGO_BUILD_TARGET", + "CARGO_INCREMENTAL", + "CC", + "CXX", + "AR", + "RANLIB", + "CFLAGS", + "CXXFLAGS", + "CPPFLAGS", + "LDFLAGS", +} +BUILD_ENV_PATTERNS = ( + # Darwin dyld variables can inject or redirect libraries into evidence + # subprocesses. Fail closed for the namespace rather than chasing keys. + re.compile(r"^DYLD_.+$"), + re.compile(r"^CARGO_TARGET_.+_(?:RUSTFLAGS|LINKER|RUNNER)$"), + # Cargo exposes profile configuration through CARGO_PROFILE__*. + # Fail closed for the whole namespace so newly added profile keys cannot + # silently change evidence-build semantics. + re.compile(r"^CARGO_PROFILE_.+$"), +) + + +class SweepError(RuntimeError): + pass + + +def trusted_system_path(system_name: str | None = None) -> str: + """Return the minimal host-tool search path used after tool selection.""" + system_name = system_name or platform.system() + if system_name == "Windows": + candidates: list[str] = [] + system_root = os.environ.get("SystemRoot", r"C:\Windows") + candidates.append(str(PureWindowsPath(system_root) / "System32")) + for variable in ("ProgramFiles", "ProgramFiles(x86)"): + root = os.environ.get(variable) + if root: + base = PureWindowsPath(root) / "Git" + candidates.extend((str(base / "cmd"), str(base / "bin"))) + local = os.environ.get("LOCALAPPDATA") + if local: + base = PureWindowsPath(local) / "Programs" / "Git" + candidates.extend((str(base / "cmd"), str(base / "bin"))) + separator = ";" + elif system_name == "Darwin": + candidates = [ + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin", + "/usr/local/bin", + "/opt/homebrew/bin", + ] + separator = ":" + else: + candidates = ["/usr/bin", "/bin", "/usr/local/bin"] + separator = ":" + + unique: list[str] = [] + seen: set[str] = set() + for candidate in candidates: + key = candidate.casefold() if system_name == "Windows" else candidate + if key not in seen: + seen.add(key) + unique.append(candidate) + return separator.join(unique) + + +def extend_tool_path(base: str, directories: list[str], system_name: str) -> str: + separator = ";" if system_name == "Windows" else ":" + parts = [item for item in base.split(separator) if item] + seen = {item.casefold() if system_name == "Windows" else item for item in parts} + for directory in directories: + key = directory.casefold() if system_name == "Windows" else directory + if key not in seen: + seen.add(key) + parts.append(directory) + return separator.join(parts) + + +def require_clean_launcher() -> None: + if os.environ.get(CLEAN_LAUNCH_ENV) != "1": + raise SweepError( + "hardware evidence capture must start through the clean launcher " + "(scripts/bench-bh2d-hardware on POSIX or " + "scripts\\bench-bh2d-hardware.cmd on Windows)" + ) + + +def parse_particles(raw: str) -> list[int]: + try: + values = [int(item.strip()) for item in raw.split(",") if item.strip()] + except ValueError as exc: + raise SweepError("--particles must be a comma-separated list of integers") from exc + if not values: + raise SweepError("--particles must contain at least one value") + if any(value < 2 or value > MAX_PARTICLES for value in values): + raise SweepError(f"every particle count must be in 2..={MAX_PARTICLES}") + if values != sorted(values) or len(set(values)) != len(values): + raise SweepError("--particles must be strictly increasing with no duplicates") + return values + + +def git_invocation(command: list[str], cwd: Path) -> tuple[list[str], dict[str, str]]: + system_path = trusted_system_path() + env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")} + env.update(PATH=system_path, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) + git = shutil.which("git", path=system_path) + if git is None: + raise SweepError("system Git is required") + # rev-parse also handles a linked worktree's .git file. No ambient selectors. + result = subprocess.run( + [git, "--no-replace-objects", "-C", str(cwd), "rev-parse", "--absolute-git-dir"], + env=env, + capture_output=True, + check=False, + ) + if result.returncode: + raise SweepError("could not locate the checkout Git directory") + git_dir = result.stdout.decode("utf-8").strip() + return [ + git, + "--no-replace-objects", + "--git-dir", + git_dir, + "--work-tree", + str(cwd.resolve()), + "-c", + "core.fsmonitor=false", + *command[1:], + ], env + + +def run_checked_bytes(command: list[str], cwd: Path) -> bytes: + env = None + if command[0] == "git": + command, env = git_invocation(command, cwd) + completed = subprocess.run( + command, + cwd=cwd, + env=env, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).decode( + "utf-8", errors="replace" + ).strip() + raise SweepError( + f"command failed ({completed.returncode}): {' '.join(command)}\n{detail}" + ) + return completed.stdout + + +def run_checked(command: list[str], cwd: Path) -> str: + payload = run_checked_bytes(command, cwd) + try: + return payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise SweepError("command output is not valid UTF-8") from exc + + +def git_revision(repo_root: Path) -> str: + return run_checked(["git", "rev-parse", "HEAD"], repo_root).strip() + + +def is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def path_label(path: Path, repo_root: Path) -> str: + resolved = path.resolve() + try: + return str(resolved.relative_to(repo_root.resolve())) + except ValueError: + home = Path.home().resolve() + try: + return str(Path("$HOME") / resolved.relative_to(home)) + except ValueError: + return str(resolved) + + +def effective_cargo_config_paths(repo_root: Path) -> list[Path]: + candidates: list[Path] = [] + current = repo_root.resolve() + while True: + cargo_dir = current / ".cargo" + candidates.extend((cargo_dir / "config.toml", cargo_dir / "config")) + if current.parent == current: + break + current = current.parent + + cargo_home = Path(os.environ.get("CARGO_HOME", Path.home() / ".cargo")).expanduser() + if not cargo_home.is_absolute(): + cargo_home = repo_root / cargo_home + cargo_home = cargo_home.resolve() + candidates.extend((cargo_home / "config.toml", cargo_home / "config")) + + unique: list[Path] = [] + seen: set[Path] = set() + for candidate in candidates: + resolved = candidate.resolve() + if resolved not in seen and resolved.is_file(): + seen.add(resolved) + unique.append(resolved) + return unique + + +def build_environment_overrides() -> list[str]: + names: list[str] = [] + for name, value in os.environ.items(): + if not value: + continue + if name in BUILD_ENV_EXACT or any(pattern.fullmatch(name) for pattern in BUILD_ENV_PATTERNS): + names.append(name) + return sorted(names) + + +def require_no_build_environment_overrides() -> None: + overrides = build_environment_overrides() + if overrides: + raise SweepError( + "build-affecting environment overrides are not allowed for hardware evidence capture: " + + ", ".join(overrides) + ) + + +def resolve_executable(command: str, name: str) -> Path: + resolved = shutil.which(command) + if resolved is None: + raise SweepError(f"could not resolve {name} executable: {command}") + path = Path(os.path.abspath(resolved)) + if not path.is_file(): + raise SweepError(f"resolved {name} executable is not a file: {path}") + return path + + +def tool_record(command: str, name: str, repo_root: Path) -> dict[str, Any]: + invocation = resolve_executable(command, name) + selected = invocation + # Keep argv[0] semantics of proxies, but identify the actual selected tool. + rustup = shutil.which("rustup") + is_proxy = invocation.resolve().stem == "rustup" or ( + rustup is not None and os.path.samefile(invocation, rustup) + ) + if is_proxy: + rustup_command = rustup if rustup and os.path.samefile(invocation, rustup) else str(invocation.resolve()) + selected = Path(run_checked([rustup_command, "which", name], repo_root).strip()) + require(selected.is_absolute() and selected.is_file(), f"rustup did not resolve {name}") + require(not os.path.samefile(selected, invocation), f"rustup resolved {name} to its proxy") + flags = ["--version", "--verbose"] if name == "cargo" else ["-vV"] + return { + "path": path_label(selected, repo_root), + "executable": str(selected.absolute()), + "sha256": sha256_file(selected), + "invocation": str(invocation), + "proxy_sha256": sha256_file(invocation) if is_proxy else None, + "version_verbose": run_checked([str(selected), *flags], repo_root).strip(), + } + + +def toolchain_context(cargo_command: str, repo_root: Path) -> dict[str, Any]: + require_no_build_environment_overrides() + system_name = platform.system() + base_path = trusted_system_path(system_name) + cargo = tool_record(cargo_command, "cargo", repo_root) + cargo["requested"] = cargo_command + rustc = tool_record("rustc", "rustc", repo_root) + + if system_name == "Windows": + candidates = ("git", "cl", "link", "lib", "rc", "lld-link") + discovery_path = os.environ.get("PATH", "") + else: + candidates = ("cc", "c++", "clang", "clang++", "gcc", "g++", "ld", "as", "ar", "ranlib", "git") + discovery_path = base_path + + system_tools: dict[str, dict[str, str]] = {} + tool_directories: list[str] = [] + for name in candidates: + search_path = base_path if name == "git" else discovery_path + path = shutil.which(name, path=search_path) + if path: + resolved = Path(path).resolve() + system_tools[name] = { + "path": str(resolved), + "sha256": sha256_file(resolved), + } + tool_directories.append(str(resolved.parent)) + + require("git" in system_tools, "trusted Git executable is required") + if system_name != "Windows": + require( + "cc" in system_tools or "clang" in system_tools or "gcc" in system_tools, + "system C compiler/linker driver is required", + ) + + build_path = extend_tool_path(base_path, tool_directories, system_name) + return { + "platform": system_name, + "cargo": cargo, + "rustc": rustc, + "build_path": build_path, + "system_tools": system_tools, + } + + +def build_environment(context: dict[str, Any]) -> dict[str, str]: + env = { + key: value + for key, value in os.environ.items() + if not key.startswith("GIT_") + and key not in BUILD_ENV_EXACT + and not any(pattern.fullmatch(key) for pattern in BUILD_ENV_PATTERNS) + } + env["PATH"] = context["build_path"] + env["RUSTC"] = context["rustc"]["executable"] + return env + + +def dependency_source_context( + repo_root: Path, + toolchain: dict[str, Any], +) -> list[dict[str, Any]]: + """Bind the exact non-repository dependency trees Cargo will compile.""" + command = [ + toolchain["cargo"]["executable"], + "metadata", + "--manifest-path", + "runtime/Cargo.toml", + "--locked", + "--offline", + "--format-version", + "1", + ] + completed = subprocess.run( + command, + cwd=repo_root, + env=build_environment(toolchain), + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if completed.returncode != 0: + detail = (completed.stderr or completed.stdout).decode( + "utf-8", errors="replace" + ).strip() + raise SweepError(f"could not resolve locked offline Cargo dependency sources: {detail}") + try: + metadata = json.loads(completed.stdout.decode("utf-8")) + except (UnicodeDecodeError, ValueError) as exc: + raise SweepError("Cargo metadata is not valid UTF-8 JSON") from exc + + root = repo_root.resolve() + packages = require_list( + require_object(metadata, "Cargo metadata").get("packages"), + "Cargo metadata packages", + ) + result: list[dict[str, Any]] = [] + for index, value in enumerate(packages): + package = require_object(value, f"Cargo metadata packages[{index}]") + manifest = Path( + require_string( + package.get("manifest_path"), + f"Cargo metadata packages[{index}].manifest_path", + nonempty=True, + ) + ).resolve() + package_root = manifest.parent + source = package.get("source") + require( + source is None or isinstance(source, str), + f"Cargo metadata packages[{index}].source must be a string or null", + ) + # Repository path/workspace packages are already bound by Git + # provenance. Registry/git packages remain external compiler inputs + # even when CARGO_HOME happens to live under the checkout. + if source is None and is_within(package_root, root): + continue + require( + package_root.is_dir(), + f"Cargo dependency source directory is missing: {package_root}", + ) + result.append( + { + "name": require_string( + package.get("name"), + f"Cargo metadata packages[{index}].name", + nonempty=True, + ), + "version": require_string( + package.get("version"), + f"Cargo metadata packages[{index}].version", + nonempty=True, + ), + "source": source, + "path": path_label(package_root, repo_root), + "tree_sha256": sha256_directory(package_root), + } + ) + result.sort( + key=lambda item: ( + item["name"], + item["version"], + item["source"] or "", + item["path"], + ) + ) + return result + + +def reject_cargo_config_redirects(document: Any, path: Path) -> None: + root = require_object(document, f"Cargo config {path}") + + forbidden_top_level = { + "paths": "dependency path overrides", + "source": "source replacement", + "registries": "registry replacement", + "patch": "dependency patching", + } + for key, description in forbidden_top_level.items(): + if key in root: + raise SweepError( + f"Cargo config {path} contains unsupported {description} via [{key}]" + ) + + env = root.get("env") + if env is not None: + require_object(env, f"Cargo config {path}.env") + raise SweepError( + f"Cargo config {path} contains [env] overrides; build environment injection is not allowed" + ) + + build = root.get("build") + if build is not None: + build = require_object(build, f"Cargo config {path}.build") + forbidden_build = ( + "rustc", + "rustc-wrapper", + "rustc-workspace-wrapper", + "rustflags", + "rustdocflags", + ) + for key in forbidden_build: + if key in build: + raise SweepError( + f"Cargo config {path} contains unsupported build.{key} redirect/override" + ) + + target = root.get("target") + if target is not None: + target = require_object(target, f"Cargo config {path}.target") + for target_name, target_config in target.items(): + target_config = require_object( + target_config, + f"Cargo config {path}.target.{target_name}", + ) + for key in ("runner", "linker", "rustflags", "rustdocflags"): + if key in target_config: + raise SweepError( + f"Cargo config {path} contains unsupported target.{target_name}.{key} " + "redirect/override" + ) + + +def cargo_config_context(repo_root: Path) -> list[dict[str, str]]: + context: list[dict[str, str]] = [] + root = repo_root.resolve() + for path in effective_cargo_config_paths(repo_root): + if is_within(path, root): + relative = path.relative_to(root) + git_command, git_env = git_invocation( + ["git", "ls-files", "--error-unmatch", "--", str(relative)], repo_root) + tracked = subprocess.run( + git_command, env=git_env, + cwd=repo_root, + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if tracked.returncode != 0: + raise SweepError( + "untracked or ignored Cargo configuration affects the evidence build: " + f"{relative}" + ) + payload = path.read_bytes() + try: + document = tomllib.loads(payload.decode("utf-8")) + except UnicodeDecodeError as exc: + raise SweepError(f"Cargo config is not valid UTF-8: {path}") from exc + except tomllib.TOMLDecodeError as exc: + raise SweepError(f"Cargo config is not valid TOML: {path}: {exc}") from exc + reject_cargo_config_redirects(document, path) + context.append( + { + "path": path_label(path, repo_root), + "sha256": hashlib.sha256(payload).hexdigest(), + } + ) + return context + + +def require_raw_tracked_worktree_matches_index(repo_root: Path) -> None: + records = run_checked(["git", "ls-files", "--stage", "-z"], repo_root) + for record in (item for item in records.split("\0") if item): + try: + metadata, relative = record.split("\t", 1) + mode, object_id, stage = metadata.split(" ") + except ValueError as exc: + raise SweepError("could not parse tracked index evidence") from exc + if stage != "0": + raise SweepError( + f"tracked path has an unresolved index stage and cannot be evidence: {relative}" + ) + + candidate = repo_root / relative + expected = run_checked_bytes(["git", "cat-file", "blob", object_id], repo_root) + if mode == "120000": + if not candidate.is_symlink(): + raise SweepError( + f"tracked source tree is dirty; symlink state changed: {relative}" + ) + actual = os.fsencode(os.readlink(candidate)) + elif mode in ("100644", "100755"): + if candidate.is_symlink() or not candidate.is_file(): + raise SweepError( + f"tracked source tree is dirty; file state changed: {relative}" + ) + actual = candidate.read_bytes() + executable = bool(candidate.stat().st_mode & stat.S_IXUSR) + if executable != (mode == "100755"): + raise SweepError( + f"tracked source tree is dirty; executable mode changed: {relative}" + ) + else: + raise SweepError( + f"unsupported tracked Git mode {mode} during evidence capture: {relative}" + ) + + if actual != expected: + raise SweepError( + "tracked source tree is dirty in raw bytes; commit or revert changes " + f"before hardware evidence capture: {relative}" + ) + + +def require_clean_source_tree( + repo_root: Path, + allowed_untracked_root: Path | None = None, +) -> None: + flagged_records = run_checked(["git", "ls-files", "-v", "-z"], repo_root) + flagged: list[str] = [] + for record in (item for item in flagged_records.split("\0") if item): + if len(record) < 3 or record[1] != " ": + raise SweepError("could not parse git index flag evidence") + tag = record[0] + path = record[2:] + if tag != "H": + flagged.append(f"{tag} {path}") + if flagged: + preview = ", ".join(repr(item) for item in flagged[:8]) + suffix = "" if len(flagged) <= 8 else f", ... (+{len(flagged) - 8} more)" + raise SweepError( + "tracked files use index flags or states that can hide worktree changes; " + f"clear assume-unchanged/skip-worktree and restore a normal index first: {preview}{suffix}" + ) + + git_command, git_env = git_invocation( + ["git", "diff", "--no-ext-diff", "--cached", "--quiet", "--"], + repo_root, + ) + completed = subprocess.run(git_command, env=git_env, cwd=repo_root, check=False) + if completed.returncode != 0: + raise SweepError( + "tracked index differs from HEAD; commit or revert changes before hardware evidence capture" + ) + + # Git worktree diffs can apply repository-local clean filters. Compare the + # raw filesystem bytes against the index blobs instead, so attributes and + # filter commands cannot make altered compiler inputs appear clean. + require_raw_tracked_worktree_matches_index(repo_root) + + raw_untracked = run_checked( + ["git", "ls-files", "--others", "--exclude-standard", "-z"], + repo_root, + ) + allowed = allowed_untracked_root.resolve() if allowed_untracked_root is not None else None + unexpected: list[str] = [] + for relative in (item for item in raw_untracked.split("\0") if item): + candidate = (repo_root / relative).resolve() + if allowed is not None and is_within(candidate, allowed): + continue + unexpected.append(relative) + if unexpected: + preview = ", ".join(repr(path) for path in unexpected[:8]) + suffix = "" if len(unexpected) <= 8 else f", ... (+{len(unexpected) - 8} more)" + raise SweepError( + "untracked files are present outside the evidence output; " + f"commit, remove, or ignore them only after proving they cannot affect the build: {preview}{suffix}" + ) + + +def require_source_provenance( + repo_root: Path, + revision: str, + allowed_untracked_root: Path | None = None, + expected_cargo_context: list[dict[str, str]] | None = None, + expected_toolchain_context: dict[str, Any] | None = None, + expected_dependency_source_context: list[dict[str, Any]] | None = None, + cargo_command: str = "cargo", +) -> None: + current = git_revision(repo_root) + if current != revision: + raise SweepError( + f"source revision changed during hardware evidence capture: expected {revision}, got {current}" + ) + require_clean_source_tree(repo_root, allowed_untracked_root) + current_cargo_context = cargo_config_context(repo_root) + if expected_cargo_context is not None and current_cargo_context != expected_cargo_context: + raise SweepError("effective Cargo configuration changed during hardware evidence capture") + current_toolchain_context = toolchain_context(cargo_command, repo_root) + if ( + expected_toolchain_context is not None + and current_toolchain_context != expected_toolchain_context + ): + raise SweepError("Cargo/Rust toolchain changed during hardware evidence capture") + current_dependency_source_context = dependency_source_context( + repo_root, + current_toolchain_context, + ) + if ( + expected_dependency_source_context is not None + and current_dependency_source_context != expected_dependency_source_context + ): + raise SweepError( + "Cargo dependency source cache changed during hardware evidence capture" + ) + + +def load_receipt(path: Path) -> Any: + payload = path.read_bytes() + try: + text = payload.decode("utf-8") + except UnicodeDecodeError as exc: + raise SweepError(f"receipt is not valid UTF-8: {path}") from exc + def reject_nonstandard_constant(value: str) -> None: + raise ValueError(f"non-standard JSON constant: {value}") + + def reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON object key: {key}") + result[key] = value + return result + + try: + return json.loads( + text, + parse_constant=reject_nonstandard_constant, + object_pairs_hook=reject_duplicate_keys, + ) + except ValueError as exc: + raise SweepError(f"receipt is not valid JSON: {path}") from exc + + +def sha256_directory(path: Path) -> str: + digest = hashlib.sha256() + root = path.resolve() + entries = sorted(root.rglob("*"), key=lambda entry: entry.relative_to(root).as_posix()) + for entry in entries: + relative = entry.relative_to(root) + if entry.is_symlink(): + raise SweepError(f"Cargo dependency source contains unsupported symlink: {entry}") + mode = entry.lstat().st_mode + require( + entry.is_dir() or entry.is_file(), + f"Cargo dependency source contains unsupported entry: {entry}", + ) + relative_bytes = os.fsencode(str(relative)) + mode_bits = stat.S_IFMT(mode) | stat.S_IMODE(mode) + digest.update(len(relative_bytes).to_bytes(8, "big")) + digest.update(relative_bytes) + digest.update(mode_bits.to_bytes(4, "big")) + if entry.is_file(): + digest.update(bytes.fromhex(sha256_file(entry))) + return digest.hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def require(condition: bool, message: str) -> None: + if not condition: + raise SweepError(message) + + +def require_object(value: Any, name: str) -> dict[str, Any]: + require(isinstance(value, dict), f"{name} must be an object") + return value + + +def require_list(value: Any, name: str) -> list[Any]: + require(isinstance(value, list), f"{name} must be an array") + return value + + +def require_int(value: Any, name: str, *, minimum: int | None = None) -> int: + require(isinstance(value, int) and not isinstance(value, bool), f"{name} must be an integer") + if minimum is not None: + require(value >= minimum, f"{name} must be >= {minimum}") + return value + + +def require_number( + value: Any, + name: str, + *, + positive: bool = False, + nonnegative: bool = False, +) -> float: + require( + isinstance(value, (int, float)) and not isinstance(value, bool), + f"{name} must be numeric", + ) + try: + numeric = float(value) + except (OverflowError, TypeError, ValueError) as exc: + raise SweepError(f"{name} cannot be represented as a finite number") from exc + require(math.isfinite(numeric), f"{name} must be finite") + if positive: + require(numeric > 0.0, f"{name} must be positive") + if nonnegative: + require(numeric >= 0.0, f"{name} must be nonnegative") + return numeric + + +def require_string(value: Any, name: str, *, nonempty: bool = False) -> str: + require(isinstance(value, str), f"{name} must be a string") + if nonempty: + require(bool(value.strip()), f"{name} must not be empty") + return value + + +def require_checksum(value: Any, name: str) -> str: + checksum = require_string(value, name, nonempty=True) + require( + re.fullmatch(r"[0-9a-f]{16}", checksum) is not None, + f"{name} must be a 16-digit lowercase hexadecimal checksum", + ) + return checksum + + +def expected_parallel_tree_buffer_bytes(particles: int) -> int: + blocks = (particles + WORKGROUP_SIZE - 1) // WORKGROUP_SIZE + body_bytes = particles * GPU_BODY_BYTES + entry_bytes = particles * GPU_ENTRY_BYTES + cell_bytes = particles * TREE_LEVELS * GPU_CELL_BYTES + bounds_bytes = blocks * BOUNDS_RECORD_BYTES + histogram_bytes = blocks * RADIX_DIGITS * 4 + offsets_bytes = histogram_bytes + return ( + body_bytes + + entry_bytes * 2 + + cell_bytes + + TREE_META_BYTES + + bounds_bytes * 2 + + histogram_bytes + + offsets_bytes + ) + + +def require_same_number(actual: Any, expected: float, name: str) -> float: + numeric = require_number(actual, name) + require(numeric == float(expected), f"{name} does not match requested workload") + return numeric + + +def upper_median(values: list[float]) -> float: + ordered = sorted(values) + return ordered[len(ordered) // 2] + + +def validated_samples(value: Any, name: str, expected_count: int) -> list[float]: + raw = require_list(value, name) + require(len(raw) == expected_count, f"{name} sample count mismatch") + return [ + require_number(sample, f"{name}[{index}]", positive=True) + for index, sample in enumerate(raw) + ] + + +def validate_state_error(value: Any, name: str) -> dict[str, float]: + state = require_object(value, name) + result = { + "position_rms_relative_l2": require_number( + state.get("position_rms_relative_l2"), + f"{name}.position_rms_relative_l2", + nonnegative=True, + ), + "position_max_relative": require_number( + state.get("position_max_relative"), + f"{name}.position_max_relative", + nonnegative=True, + ), + "velocity_rms_relative_l2": require_number( + state.get("velocity_rms_relative_l2"), + f"{name}.velocity_rms_relative_l2", + nonnegative=True, + ), + "velocity_max_relative": require_number( + state.get("velocity_max_relative"), + f"{name}.velocity_max_relative", + nonnegative=True, + ), + } + require( + (result["position_rms_relative_l2"] == 0.0) + == (result["position_max_relative"] == 0.0), + f"{name} position RMS/max zero-state mismatch", + ) + require( + (result["velocity_rms_relative_l2"] == 0.0) + == (result["velocity_max_relative"] == 0.0), + f"{name} velocity RMS/max zero-state mismatch", + ) + require(result["position_rms_relative_l2"] < 0.03, f"{name} position RMS gate failed") + require(result["position_max_relative"] < 0.30, f"{name} position max gate failed") + require(result["velocity_rms_relative_l2"] < 0.03, f"{name} velocity RMS gate failed") + require(result["velocity_max_relative"] < 0.30, f"{name} velocity max gate failed") + return result + + +def validate_force_errors( + value: Any, + name: str, + sample_count: int, +) -> tuple[float, float]: + evidence = require_object(value, name) + rms = require_number( + evidence.get("force_rms_relative"), + f"{name}.force_rms_relative", + nonnegative=True, + ) + maximum = require_number( + evidence.get("force_max_relative"), + f"{name}.force_max_relative", + nonnegative=True, + ) + require(rms <= maximum, f"{name} force RMS cannot exceed force maximum") + require( + maximum <= rms * math.sqrt(sample_count), + f"{name} force maximum is too large for RMS and sample count", + ) + require(rms < 0.04, f"{name} force RMS gate failed") + require(maximum < 0.30, f"{name} force max gate failed") + return rms, maximum + + +def parse_adapter_index_selector(value: str) -> int | None: + # Mirror Rust usize::from_str closely enough for the producer contract: + # optional leading '+', ASCII decimal digits, no whitespace/sign-minus, + # and overflow falls back to textual name selection. + if re.fullmatch(r"\+?[0-9]+", value) is None: + return None + parsed = int(value, 10) + usize_max = (sys.maxsize << 1) | 1 + return parsed if parsed <= usize_max else None + + +def adapter_identity(gpu: Any, adapter_selector: str | None = None) -> str: + info = require_object(gpu, "receipt.gpu") + index = require_int(info.get("index"), "receipt.gpu.index", minimum=0) + adapter_count = require_int( + info.get("adapter_count"), + "receipt.gpu.adapter_count", + minimum=1, + ) + require(index < adapter_count, "receipt.gpu.index must be below adapter_count") + name = require_string(info.get("name"), "receipt.gpu.name", nonempty=True) + if adapter_selector is not None: + selected_index = parse_adapter_index_selector(adapter_selector) + if selected_index is not None and selected_index < adapter_count: + require( + index == selected_index, + "receipt.gpu.index does not match the numeric adapter selector", + ) + else: + require( + adapter_selector.lower() in name.lower(), + "receipt.gpu.name does not match the textual adapter selector", + ) + device_type = require_string( + info.get("device_type"), "receipt.gpu.device_type", nonempty=True + ) + software_name = name.lower() + producer_classifies_software = ( + device_type.lower() == "cpu" + or any( + marker in software_name + for marker in ("llvmpipe", "lavapipe", "swiftshader", "software") + ) + ) + require( + not producer_classifies_software, + "receipt.gpu contradicts the producer software-adapter classification", + ) + backend = require_string(info.get("backend"), "receipt.gpu.backend", nonempty=True) + require( + backend in {"Vulkan", "Metal", "Dx12"}, + "receipt.gpu.backend is not enabled by the producer", + ) + required = { + "index": index, + "adapter_count": adapter_count, + "name": name, + "backend": backend, + "device_type": device_type, + "driver": require_string(info.get("driver"), "receipt.gpu.driver"), + "driver_info": require_string(info.get("driver_info"), "receipt.gpu.driver_info"), + } + require( + bool(required["driver"].strip() or required["driver_info"].strip()), + "receipt.gpu must include nonempty driver or driver_info provenance", + ) + require(info.get("software") is False, "receipt.gpu.software must be false for hardware evidence") + return json.dumps(required, sort_keys=True, separators=(",", ":")) + + +def validate_receipt( + receipt: Any, + *, + particles: int, + preset: str, + steps: int, + dt_myr: float, + seed: int, + theta: float, + softening_kpc: float, + direct_probes: int, + oracle_limit: int, + benchmark_warmup: int, + benchmark_repeats: int, + adapter_selector: str | None = None, +) -> dict[str, Any]: + root = require_object(receipt, "receipt") + require(root.get("schema") == RECEIPT_SCHEMA, "unexpected BH #2D receipt schema") + require(root.get("status") == "complete", "BH #2D receipt is not complete") + require(root.get("phase") == "BH-2D", "receipt phase is not BH-2D") + require(root.get("builder") == "gpu-parallel-sparse-radix-v1", "unexpected BH #2D builder") + + require(root.get("preset") == preset, "receipt preset does not match requested workload") + require_int(root.get("particles"), "receipt.particles") + require(root.get("particles") == particles, "receipt particle count does not match sweep point") + require_int(root.get("steps"), "receipt.steps") + require(root.get("steps") == steps, "receipt step count does not match sweep configuration") + require_same_number(root.get("dt_myr"), dt_myr, "receipt.dt_myr") + require_int(root.get("seed"), "receipt.seed") + require(root.get("seed") == seed, "receipt seed does not match requested workload") + require_same_number(root.get("theta"), theta, "receipt.theta") + require_same_number( + root.get("softening_kpc"), + softening_kpc, + "receipt.softening_kpc", + ) + require_same_number( + root.get("simulated_time_myr"), + dt_myr * steps, + "receipt.simulated_time_myr", + ) + + require(root.get("measurement_class") == "hardware", "software-validation receipt rejected") + require( + root.get("hardware_performance_claim_allowed") is True, + "receipt does not authorize hardware performance evidence", + ) + host_tree_rebuilds = require_int( + root.get("host_tree_rebuilds"), + "receipt.host_tree_rebuilds", + minimum=0, + ) + require(host_tree_rebuilds == 0, "host tree rebuild occurred") + host_particle_readbacks = require_int( + root.get("host_particle_readbacks_during_steps"), + "receipt.host_particle_readbacks_during_steps", + minimum=0, + ) + require( + host_particle_readbacks == 0, + "host particle readback occurred inside the evolution loop", + ) + force_solves = require_int( + root.get("force_solves"), + "receipt.force_solves", + minimum=0, + ) + require(force_solves == steps + 1, "unexpected force-solve count") + evolution_tree_builds = require_int( + root.get("evolution_tree_builds"), + "receipt.evolution_tree_builds", + minimum=0, + ) + require(evolution_tree_builds == steps + 1, "unexpected tree-build count") + + identity = adapter_identity(root.get("gpu"), adapter_selector) + + tree = require_object(root.get("tree"), "receipt.tree") + require( + tree.get("ordering") == TREE_ORDERING, + "receipt.tree.ordering does not match the frozen stable Morton ordering", + ) + require( + tree.get("layout") == TREE_LAYOUT, + "receipt.tree.layout does not match the frozen sparse level-order layout", + ) + require(tree.get("repeat_rebuild_matches") is True, "same-state repeat tree checksum changed") + require_checksum( + tree.get("initial_checksum_fnv_mix64"), + "receipt.tree.initial_checksum_fnv_mix64", + ) + final_checksum = require_checksum( + tree.get("final_checksum_fnv_mix64"), + "receipt.tree.final_checksum_fnv_mix64", + ) + repeat_checksum = require_checksum( + tree.get("repeat_checksum_fnv_mix64"), + "receipt.tree.repeat_checksum_fnv_mix64", + ) + require( + final_checksum == repeat_checksum, + "receipt repeat tree checksum does not match the final tree checksum", + ) + active_cell_count = require_int( + tree.get("active_cell_count"), + "receipt.tree.active_cell_count", + minimum=1, + ) + leaf_count = require_int(tree.get("leaf_count"), "receipt.tree.leaf_count", minimum=1) + max_depth = require_int(tree.get("max_depth"), "receipt.tree.max_depth", minimum=0) + cell_capacity = TREE_LEVELS * particles + require( + active_cell_count <= cell_capacity, + "receipt.tree.active_cell_count exceeds the frozen sparse-cell capacity", + ) + require( + leaf_count <= active_cell_count, + "receipt.tree.leaf_count cannot exceed active_cell_count", + ) + internal_cell_count = active_cell_count - leaf_count + require( + leaf_count <= 3 * internal_cell_count + 1, + "receipt.tree.leaf_count exceeds quadtree fan-out capacity", + ) + require( + leaf_count <= particles, + "receipt.tree.leaf_count cannot exceed the resident particle count", + ) + require( + max_depth <= TREE_LEVELS - 1, + "receipt.tree.max_depth exceeds the frozen Morton depth", + ) + internal_cells_per_depth = particles // (TREE_BUCKET_SIZE + 1) + topology_capacity = leaf_count + sum( + min(4 ** depth, internal_cells_per_depth) + for depth in range(max_depth) + ) + require( + active_cell_count <= topology_capacity, + "receipt.tree.active_cell_count exceeds per-depth quadtree occupancy", + ) + require( + active_cell_count >= max_depth + 1, + "receipt.tree.active_cell_count is too small for the reported maximum depth", + ) + require( + internal_cell_count >= max_depth, + "receipt.tree has too few internal cells for the reported maximum depth", + ) + leaf_path_capacity = sum( + min(4 ** depth, leaf_count) + for depth in range(max_depth) + ) + require( + internal_cell_count <= leaf_path_capacity, + "receipt.tree has too many internal cells for its leaf-path capacity", + ) + if max_depth < TREE_LEVELS - 1: + minimum_leaf_count = (particles + TREE_BUCKET_SIZE - 1) // TREE_BUCKET_SIZE + require( + leaf_count >= minimum_leaf_count, + "receipt.tree.leaf_count is too small for the frozen bucket size below maximum depth", + ) + if particles > TREE_BUCKET_SIZE: + require( + active_cell_count > leaf_count, + "receipt.tree must contain an internal cell when particles exceed the frozen bucket size", + ) + require( + max_depth > 0, + "receipt.tree.max_depth must be positive when particles exceed the frozen bucket size", + ) + + force = require_object(root.get("final_force"), "receipt.final_force") + expected_probe_count = min(direct_probes, particles) + require_int(force.get("direct_probe_count"), "receipt.final_force.direct_probe_count") + require( + force.get("direct_probe_count") == expected_probe_count, + "receipt direct-probe count does not match requested workload", + ) + direct_rms = require_number( + force.get("direct_probe_rms_relative"), + "receipt.final_force.direct_probe_rms_relative", + nonnegative=True, + ) + direct_max = require_number( + force.get("direct_probe_max_relative"), + "receipt.final_force.direct_probe_max_relative", + nonnegative=True, + ) + require(direct_rms <= direct_max, "direct-force RMS cannot exceed maximum") + require( + direct_max <= direct_rms * math.sqrt(expected_probe_count), + "direct-force maximum is too large for RMS and probe count", + ) + require(direct_rms < 0.04, "direct-force RMS gate failed") + require(direct_max < 0.30, "direct-force max gate failed") + + expected_oracle_status = "executed" if particles <= oracle_limit else "skipped-particle-limit" + require( + force.get("bh2a_flat_status") == expected_oracle_status, + f"BH #2A final-force status mismatch: expected {expected_oracle_status}", + ) + if expected_oracle_status == "executed": + flat_rms = require_number( + force.get("gpu_vs_bh2a_flat_rms_relative"), + "receipt.final_force.gpu_vs_bh2a_flat_rms_relative", + nonnegative=True, + ) + flat_max = require_number( + force.get("gpu_vs_bh2a_flat_max_relative"), + "receipt.final_force.gpu_vs_bh2a_flat_max_relative", + nonnegative=True, + ) + require( + flat_rms <= flat_max, + "BH #2A final-force RMS cannot exceed maximum", + ) + require( + flat_max <= flat_rms * math.sqrt(particles), + "BH #2A final-force maximum is too large for RMS and particle count", + ) + require(flat_rms < 0.04, "BH #2A final-force RMS gate failed") + require(flat_max < 0.30, "BH #2A final-force max gate failed") + + oracles = require_object(root.get("gpu_oracles"), "receipt.gpu_oracles") + require( + oracles.get("status") == expected_oracle_status, + f"GPU oracle status mismatch: expected {expected_oracle_status}", + ) + if expected_oracle_status == "executed": + for key, label in ( + ("bh2c_serial_gpu", "receipt.gpu_oracles.bh2c_serial_gpu"), + ("bh2b2_host_tree_gpu", "receipt.gpu_oracles.bh2b2_host_tree_gpu"), + ): + oracle = require_object(oracles.get(key), label) + validate_state_error(oracle.get("state_error"), f"{label}.state_error") + validate_force_errors(oracle, label, particles) + else: + require( + require_int(oracles.get("limit"), "receipt.gpu_oracles.limit") == oracle_limit, + "GPU oracle skip limit does not match requested oracle limit", + ) + + trajectory = require_object( + root.get("trajectory_vs_bh2a_flat_f64"), + "receipt.trajectory_vs_bh2a_flat_f64", + ) + require( + trajectory.get("status") == expected_oracle_status, + f"CPU trajectory status mismatch: expected {expected_oracle_status}", + ) + if expected_oracle_status == "executed": + validate_state_error( + trajectory.get("state_error"), + "receipt.trajectory_vs_bh2a_flat_f64.state_error", + ) + else: + require( + require_int( + trajectory.get("limit"), + "receipt.trajectory_vs_bh2a_flat_f64.limit", + ) + == oracle_limit, + "CPU trajectory skip limit does not match requested oracle limit", + ) + + benchmark = require_object(root.get("tree_build_benchmark"), "receipt.tree_build_benchmark") + require( + require_string( + benchmark.get("sample_scope"), + "receipt.tree_build_benchmark.sample_scope", + nonempty=True, + ) + == BENCHMARK_SAMPLE_SCOPE, + "benchmark sample scope does not match the frozen complete-rebuild declaration", + ) + require( + benchmark.get("stage_timings_are_diagnostics") is True, + "benchmark must mark stage timings as diagnostics", + ) + require( + require_int(benchmark.get("warmup"), "receipt.tree_build_benchmark.warmup") + == benchmark_warmup, + "benchmark warmup count does not match requested workload", + ) + require( + require_int(benchmark.get("repeats"), "receipt.tree_build_benchmark.repeats") + == benchmark_repeats, + "benchmark repeat count does not match requested workload", + ) + + parallel_samples = validated_samples( + benchmark.get("parallel_samples_seconds"), + "receipt.tree_build_benchmark.parallel_samples_seconds", + benchmark_repeats, + ) + parallel_median = upper_median(parallel_samples) + producer_parallel_median = require_number( + benchmark.get("parallel_median_seconds"), + "receipt.tree_build_benchmark.parallel_median_seconds", + positive=True, + ) + require( + producer_parallel_median == parallel_median, + "parallel benchmark median does not match its samples", + ) + + serial = require_object( + benchmark.get("bh2c_serial"), + "receipt.tree_build_benchmark.bh2c_serial", + ) + if particles <= BH2C_CAP: + require(serial.get("status") == "executed", "BH #2C comparison should execute at this size") + serial_samples = validated_samples( + serial.get("samples_seconds"), + "receipt.tree_build_benchmark.bh2c_serial.samples_seconds", + benchmark_repeats, + ) + serial_median = upper_median(serial_samples) + producer_serial_median = require_number( + serial.get("median_seconds"), + "receipt.tree_build_benchmark.bh2c_serial.median_seconds", + positive=True, + ) + require( + producer_serial_median == serial_median, + "BH #2C benchmark median does not match its samples", + ) + speedup = serial_median / parallel_median + producer_speedup = require_number( + serial.get("parallel_vs_serial_speedup"), + "receipt.tree_build_benchmark.bh2c_serial.parallel_vs_serial_speedup", + positive=True, + ) + require( + producer_speedup == speedup, + "BH #2C speedup does not match the validated benchmark medians", + ) + else: + require( + serial.get("status") == "skipped-bh2c-cap", + "BH #2C comparison must skip above 4096", + ) + require( + require_int( + serial.get("limit"), + "receipt.tree_build_benchmark.bh2c_serial.limit", + ) + == BH2C_CAP, + "BH #2C benchmark skip limit is invalid", + ) + speedup = None + + parallel_tree_buffer_bytes = require_int( + root.get("parallel_tree_buffer_bytes"), + "receipt.parallel_tree_buffer_bytes", + minimum=1, + ) + expected_buffer_bytes = expected_parallel_tree_buffer_bytes(particles) + require( + parallel_tree_buffer_bytes == expected_buffer_bytes, + "receipt.parallel_tree_buffer_bytes does not match the frozen BH #2D allocation formula", + ) + + return { + "particles": particles, + "parallel_tree_buffer_bytes": parallel_tree_buffer_bytes, + "active_cell_count": active_cell_count, + "leaf_count": leaf_count, + "max_depth": max_depth, + "parallel_median_seconds": parallel_median, + "bh2c_parallel_vs_serial_speedup": speedup, + "direct_probe_rms_relative": direct_rms, + "direct_probe_max_relative": direct_max, + "adapter_identity": identity, + } + + +def command_for( + args: argparse.Namespace, + particles: int, + receipt: Path, + target_dir: Path, +) -> list[str]: + command = [ + args.cargo, + "run", + "--release", + "--manifest-path", + "runtime/Cargo.toml", + "--target-dir", + str(target_dir), + "--locked", + "--bin", + "galaxy-bh-gpu-tree-parallel", + "--", + "--preset", + args.preset, + "--particles", + str(particles), + "--steps", + str(args.steps), + "--dt-myr", + str(args.dt_myr), + "--seed", + str(args.seed), + "--theta", + str(args.theta), + "--softening-kpc", + str(args.softening_kpc), + "--direct-probes", + str(args.direct_probes), + "--oracle-limit", + str(args.oracle_limit), + "--benchmark-warmup", + str(args.benchmark_warmup), + "--benchmark-repeats", + str(args.benchmark_repeats), + "--require-hardware", + "--receipt", + str(receipt), + ] + if args.adapter: + command.extend(["--adapter", args.adapter]) + return command + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run a fail-closed real-hardware BH #2D scaling sweep" + ) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--particles", default=DEFAULT_PARTICLES) + parser.add_argument("--preset", choices=("disc", "collision"), default="disc") + parser.add_argument("--steps", type=int, default=3) + parser.add_argument("--dt-myr", type=float, default=0.01) + parser.add_argument("--seed", type=int, default=303) + parser.add_argument("--theta", type=float, default=0.5) + parser.add_argument("--softening-kpc", type=float, default=0.05) + parser.add_argument("--direct-probes", type=int, default=12) + parser.add_argument("--oracle-limit", type=int, default=4096) + parser.add_argument("--benchmark-warmup", type=int, default=2) + parser.add_argument("--benchmark-repeats", type=int, default=7) + parser.add_argument("--adapter") + parser.add_argument("--cargo", default="cargo") + parser.add_argument( + "--dry-run", + action="store_true", + help="print planned hardware commands without creating files or running Cargo", + ) + return parser.parse_args() + + +def main() -> int: + require_clean_launcher() + args = parse_args() + repo_root = Path(__file__).resolve().parents[1] + manifest_path: Path | None = None + manifest: dict[str, Any] | None = None + try: + particles_list = parse_particles(args.particles) + if args.preset == "collision": + require( + particles_list[0] >= 4, + "collision preset requires every particle count to be at least 4", + ) + require(1 <= args.steps <= 256, "--steps must be in 1..=256") + require(2 <= args.oracle_limit <= BH2C_CAP, "--oracle-limit must be in 2..=4096") + require(1 <= args.benchmark_repeats <= 31, "--benchmark-repeats must be in 1..=31") + require(0 <= args.benchmark_warmup <= 10, "--benchmark-warmup must be in 0..=10") + + require_number(args.dt_myr, "--dt-myr") + require_number(args.theta, "--theta") + require_number(args.softening_kpc, "--softening-kpc") + require(1e-6 <= args.dt_myr <= 1, "--dt-myr must be in [1e-6, 1]") + require(0 <= args.theta <= 2, "--theta must be in [0, 2]") + require(0 <= args.softening_kpc <= 100, "--softening-kpc must be in [0, 100]") + require(1 <= args.direct_probes <= 64, "--direct-probes must be in 1..=64") + require(0 <= args.seed <= 2**64 - 1, "--seed must fit u64") + + output = args.output.expanduser().resolve() + if args.dry_run: + plan_args = argparse.Namespace(**vars(args)) + plan_args.cargo = str(resolve_executable(args.cargo, "cargo").absolute()) + for particles in particles_list: + run_dir = output / f"n{particles:06d}" + receipt = run_dir / "receipt.json" + target_dir = run_dir / "cargo-target" + print(shlex.join(command_for(plan_args, particles, receipt, target_dir))) + return 0 + + if output.exists(): + raise SweepError(f"output directory already exists: {output}") + + revision = git_revision(repo_root) + require_clean_source_tree(repo_root) + build_cargo_context = cargo_config_context(repo_root) + build_toolchain_context = toolchain_context(args.cargo, repo_root) + build_dependency_source_context = dependency_source_context( + repo_root, + build_toolchain_context, + ) + require_source_provenance( + repo_root, + revision, + expected_cargo_context=build_cargo_context, + expected_toolchain_context=build_toolchain_context, + expected_dependency_source_context=build_dependency_source_context, + cargo_command=args.cargo, + ) + + output.mkdir(parents=True) + manifest_path = output / "manifest.json" + manifest = { + "schema": MANIFEST_SCHEMA, + "status": "running", + "claim_boundary": ( + "hardware receipts and scaling observations for the recorded source/workload/adapter only; " + "this manifest does not by itself promote BH #2D to production" + ), + "source_revision": revision, + "build_context": { + "cargo_configuration": build_cargo_context, + "toolchain": build_toolchain_context, + "dependency_sources": build_dependency_source_context, + "environment_overrides": [], + }, + "host": { + "platform": platform.platform(), + "python": sys.version.split()[0], + "python_executable": str(Path(sys.executable).resolve()), + "python_sha256": sha256_file(Path(sys.executable).resolve()), + "machine": platform.machine(), + "launch_boundary": "clean-environment-launcher-v1", + }, + "configuration": { + "particles": particles_list, + "preset": args.preset, + "steps": args.steps, + "dt_myr": args.dt_myr, + "seed": args.seed, + "theta": args.theta, + "softening_kpc": args.softening_kpc, + "direct_probes": args.direct_probes, + "oracle_limit": args.oracle_limit, + "benchmark_warmup": args.benchmark_warmup, + "benchmark_repeats": args.benchmark_repeats, + "adapter_selector": args.adapter, + }, + "runs": [], + } + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n") + + adapter: str | None = None + for particles in particles_list: + require_source_provenance( + repo_root, + revision, + output, + expected_cargo_context=build_cargo_context, + expected_toolchain_context=build_toolchain_context, + expected_dependency_source_context=build_dependency_source_context, + cargo_command=args.cargo, + ) + + run_dir = output / f"n{particles:06d}" + run_dir.mkdir() + receipt_path = run_dir / "receipt.json" + log_path = run_dir / "run.log" + target_dir = run_dir / "cargo-target" + if target_dir.exists(): + raise SweepError( + f"fresh Cargo target directory unexpectedly exists before build: {target_dir}" + ) + command = command_for(args, particles, receipt_path, target_dir) + + command[0] = build_toolchain_context["cargo"]["executable"] + completed = subprocess.run( + command, + env=build_environment(build_toolchain_context), + cwd=repo_root, + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + # Preserve every output byte, including non-UTF-8 driver diagnostics. + log_path.write_bytes(completed.stdout) + try: + require_source_provenance( + repo_root, + revision, + output, + expected_cargo_context=build_cargo_context, + expected_toolchain_context=build_toolchain_context, + expected_dependency_source_context=build_dependency_source_context, + cargo_command=args.cargo, + ) + if completed.returncode != 0: + raise SweepError( + f"BH #2D hardware run failed at {particles} particles; see {log_path}" + ) + if not receipt_path.is_file(): + raise SweepError(f"missing receipt after {particles}-particle run") + + receipt = load_receipt(receipt_path) + summary = validate_receipt( + receipt, + particles=particles, + preset=args.preset, + steps=args.steps, + dt_myr=args.dt_myr, + seed=args.seed, + theta=args.theta, + softening_kpc=args.softening_kpc, + direct_probes=args.direct_probes, + oracle_limit=args.oracle_limit, + benchmark_warmup=args.benchmark_warmup, + benchmark_repeats=args.benchmark_repeats, + adapter_selector=args.adapter, + ) + if adapter is None: + adapter = summary["adapter_identity"] + elif summary["adapter_identity"] != adapter: + raise SweepError("adapter identity changed during the scaling sweep") + except (OSError, ValueError, SweepError): + failed_run = { + "particles": particles, + "status": "failed", + "exit_code": completed.returncode, + "log": str(log_path.relative_to(output)), + "log_sha256": sha256_file(log_path), + "cargo_target_dir": str(target_dir.relative_to(output)), + } + if receipt_path.is_file(): + failed_run["receipt"] = str(receipt_path.relative_to(output)) + failed_run["receipt_sha256"] = sha256_file(receipt_path) + manifest["runs"].append(failed_run) + manifest_path.write_text( + json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n" + ) + raise + + summary.update( + { + "receipt": str(receipt_path.relative_to(output)), + "receipt_sha256": sha256_file(receipt_path), + "log": str(log_path.relative_to(output)), + "log_sha256": sha256_file(log_path), + "cargo_target_dir": str(target_dir.relative_to(output)), + } + ) + summary.pop("adapter_identity") + manifest["runs"].append(summary) + manifest["adapter_identity"] = adapter + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n") + + require_source_provenance( + repo_root, + revision, + output, + expected_cargo_context=build_cargo_context, + expected_toolchain_context=build_toolchain_context, + expected_dependency_source_context=build_dependency_source_context, + cargo_command=args.cargo, + ) + manifest["status"] = "complete" + manifest["completed_run_count"] = len(manifest["runs"]) + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n") + print(manifest_path) + return 0 + except (OSError, ValueError, SweepError) as exc: + if manifest_path is not None and manifest is not None and manifest_path.parent.exists(): + manifest["status"] = "failed" + manifest["error"] = str(exc) + try: + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True, allow_nan=False) + "\n") + except OSError: + pass + print(f"BH #2D hardware sweep: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/build-site.mjs b/scripts/build-site.mjs index c2ca2ce..e0e48bc 100644 --- a/scripts/build-site.mjs +++ b/scripts/build-site.mjs @@ -6,11 +6,11 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const output = path.join(root, "_site"); fs.rmSync(output, { recursive: true, force: true }); fs.mkdirSync(output, { recursive: true }); -for (const file of ["index.html", "style.css", "barnes-hut.html", "barnes-hut.css", "barnes-hut.js", "barnes-hut-viz.js", "galaxy-core.js", "uff-physics.js", "rotation-curve.js", "renderer.js", "app.js", "data/uff-demo.js", "data/uff/DEMO_GALAXY.csv", "data/uff/provenance.json", "data/uff/NOTICE", "wasm/galaxy-wasm.js", "wasm/galaxy_sampler.wasm", "LICENSE", "LICENSES/MPL-2.0.txt", "NOTICE.md"]) { +for (const file of ["index.html", "rotation-lab.html", "nbody-clock.js", "nbody-viz.js", "style.css", "barnes-hut.html", "barnes-hut.css", "barnes-hut.js", "barnes-hut-viz.js", "galaxy-core.js", "uff-physics.js", "rotation-curve.js", "renderer.js", "app.js", "data/uff-demo.js", "data/uff/DEMO_GALAXY.csv", "data/uff/provenance.json", "data/uff/NOTICE", "wasm/galaxy-wasm.js", "wasm/galaxy_sampler.wasm", "LICENSE", "LICENSES/MPL-2.0.txt", "NOTICE.md"]) { fs.mkdirSync(path.dirname(path.join(output, file)), { recursive: true }); fs.copyFileSync(path.join(root, file), path.join(output, file)); } -for (const page of ["index.html", "barnes-hut.html"]) { +for (const page of ["index.html", "barnes-hut.html", "rotation-lab.html"]) { const html = fs.readFileSync(path.join(output, page), "utf8"); for (const [, asset] of html.matchAll(/(?:src|href)="([^"#]+)"/g)) { if (!asset.startsWith("http") && !fs.existsSync(path.join(output, asset))) { diff --git a/tests/app.mjs b/tests/app.mjs index d3626b0..11f8b0c 100644 --- a/tests/app.mjs +++ b/tests/app.mjs @@ -31,7 +31,7 @@ class Element { } async function boot({ rust = true, gpu = true, brokenWasm = false, reducedMotion = false } = {}) { - const html = read("index.html"), elements = new Map(); + const html = read("rotation-lab.html"), elements = new Map(); for (const match of html.matchAll(/<(\w+)\b[^>]*\bid="([^"]+)"[^>]*>/g)) { const el = new Element(match[1], match[2]); el.value = match[0].match(/\bvalue="([^"]*)"/)?.[1] || ""; diff --git a/tests/nbody-view.mjs b/tests/nbody-view.mjs new file mode 100644 index 0000000..8c06bb3 --- /dev/null +++ b/tests/nbody-view.mjs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +// Execute the real solver and view with a small DOM/canvas adapter. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +const read = name => fs.readFileSync(new URL('../' + name, import.meta.url), 'utf8'); +class Element { + constructor(tag = 'div') { this.tagName = tag; this.listeners = {}; this.value = ''; this.checked = false; this.textContent = ''; } + addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn); } + emit(name, extra = {}) { for (const fn of this.listeners[name] || []) fn({target:this, preventDefault(){}, ...extra}); } + setAttribute() {} + getBoundingClientRect() { return {width:900, height:600}; } + closest() { return null; } + setPointerCapture() {} +} +function boot(reduced = false) { + const elements = new Map(); let scheduled, bodies, latest, steps = 0, draws = 0, currentNow = 0; + for (const m of read('index.html').matchAll(/<(\w+)\b[^>]*\bid="([^"]+)"[^>]*>/g)) { + const el = new Element(m[1]); el.value = m[0].match(/value="([^"]*)"/)?.[1] || ''; + el.checked = m[0].includes(' checked'); elements.set(m[2], el); + } + const paint = new Proxy({}, { get: (o,k) => k === 'createRadialGradient' ? () => ({addColorStop(){}}) : k === 'drawImage' ? () => {draws++;} : () => {}, set:(o,k,v)=>{o[k]=v;return true;} }); + elements.get('nbodyCanvas').getContext = () => paint; + const doc = new Element(); doc.hidden = false; doc.getElementById = id => elements.get(id); + doc.createElement = () => ({getContext:()=>paint}); + const motion = new Element(); motion.matches = reduced; + const ctx = vm.createContext({document:doc, performance:{now:()=>currentNow}, matchMedia:()=>motion, + requestAnimationFrame(fn){scheduled=fn;}, devicePixelRatio:1, addEventListener(){}, console}); + vm.runInContext(read('barnes-hut.js'),ctx); + const solver = ctx.GalaxyBarnesHut; + ctx.GalaxyBarnesHut = {...solver, accelerations(b,o){bodies=b;latest=solver.accelerations(b,o);return latest;}, + stepLeapfrog(b,dt,o){steps++; bodies=b;latest=solver.stepLeapfrog(b,dt,o);return latest;} }; + vm.runInContext(read('nbody-clock.js'),ctx); + vm.runInContext(read('nbody-viz.js'),ctx); + return {elements, doc, motion, frame(now){currentNow=now;scheduled(now);}, setNow(now){currentNow=now;}, steps:()=>steps, draws:()=>draws, + bodies:()=>JSON.parse(JSON.stringify(bodies)), latest:()=>latest, click:id=>elements.get(id).emit('click'), + set(id,value,event='input'){elements.get(id).value=String(value);elements.get(id).emit(event);} }; +} +function clockSteps(hz, speed) { + const ctx = vm.createContext({}); + vm.runInContext(read('nbody-clock.js'), ctx); + const clock = new ctx.GalaxyNBodyClock(); + let total = 0; + for (let i = 0; i <= hz; i++) total += clock.advance(i * 1000 / hz, true, speed); + return total; +} +assert.equal(clockSteps(50, 4), 240); +assert.equal(clockSteps(100, 4), 240); + +// Same simulated wall time on 60 and 144 Hz displays gives the same trajectory. +const a=boot(), b=boot(); +assert.equal( + a.elements.get('depthReadout').textContent, + (a.latest().tree.maxDepth + 1) + " levels · " + a.latest().tree.leafCount.toLocaleString("en-US") + " leaves" +); +a.set('count',128,'change'); b.set('count',128,'change'); +for(let i=0;i<=60;i++)a.frame(i*1000/60); +for(let i=0;i<=144;i++)b.frame(i*1000/144); +assert.equal(a.steps(),60); assert.equal(b.steps(),60); assert.deepEqual(a.bodies(),b.bodies()); +a.click('playToggle'); const paused=a.bodies(); a.frame(1200);a.frame(2000); +assert.deepEqual(a.bodies(),paused); +a.set('tilt',70);a.set('rotation',90);a.set('zoom',2); +assert.deepEqual(a.bodies(),paused,'camera must not alter dynamics'); +a.click('singleStep');assert.equal(a.steps(),61);assert.equal(a.elements.get('runBadge').textContent,'PAUSED'); +a.click('audit');assert.match(a.elements.get('auditStatus').textContent,/Direct audit: 12 probes/); +a.frame(3000); assert.notEqual(a.elements.get('errorReadout').textContent,'—'); +a.click('playToggle');a.frame(4000);a.frame(4020); +assert.equal(a.elements.get('errorReadout').textContent,'—','advancing invalidates audit'); +a.doc.hidden=true; a.doc.emit('visibilitychange'); const hidden=a.steps();a.frame(100000); assert.equal(a.steps(),hidden); +a.doc.hidden=false; a.setNow(200000); a.doc.emit('visibilitychange');a.frame(200000); +assert.equal(a.steps(),hidden,'hidden tab does not catch up'); +assert.notEqual(a.elements.get('fpsReadout').textContent,'0 FPS','visibility resume must not include hidden time in FPS'); +a.frame(200017);assert.equal(a.steps(),hidden+1); +a.motion.emit('change',{matches:true});assert.equal(a.elements.get('runBadge').textContent,'PAUSED'); +assert.equal(a.elements.get('trails').checked,false); +const reduced=boot(true);reduced.frame(0);reduced.frame(1000);assert.equal(reduced.steps(),0); +for(const preset of ['disc','collision','cold']){ + reduced.set('preset',preset,'change');const initial=reduced.bodies(); + reduced.click('singleStep');assert.ok(reduced.bodies().every(b=>[b.x,b.y,b.vx,b.vy].every(Number.isFinite))); + reduced.click('reset');assert.deepEqual(reduced.bodies(),initial); +} +assert.equal(reduced.bodies().length,768); +// Assets must remain available for the default and both preserved instruments. +for(const page of ['index.html','rotation-lab.html','barnes-hut.html']) { + for(const [,asset] of read(page).matchAll(/(?:src|href)="([^"#]+)"/g)) { + if(!asset.startsWith('http'))assert.ok(fs.existsSync(new URL('../'+asset,import.meta.url)),asset); + } +} +console.log('PASS: N-body refresh-rate invariance through 4x speed, pause/step, view isolation, audits, reduced motion, hidden tabs, presets and offline links.'); diff --git a/tests/smoke.mjs b/tests/smoke.mjs index a8a2d1c..3032400 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -108,7 +108,7 @@ for (const value of [NaN, Infinity, -1, 0.5, 2 ** 32 + 1]) { assert.ok(wasm.memory.buffer.byteLength < 16 * 1024 * 1024, "Wasm memory must stay bounded across rebuilds"); // Static/offline entrypoints, unique controls, attribution and no missing assets. -const html = read("index.html"); +const html = read("rotation-lab.html"); const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); assert.equal(ids.length, new Set(ids).size); for (const [, id] of read("app.js").matchAll(/byId\("([^"]+)"\)/g)) assert.ok(ids.includes(id), `Missing control ${id}`); diff --git a/tests/test_bh2d_hardware_sweep.py b/tests/test_bh2d_hardware_sweep.py new file mode 100644 index 0000000..67f2bf3 --- /dev/null +++ b/tests/test_bh2d_hardware_sweep.py @@ -0,0 +1,1526 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +import importlib.util +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "bench-bh2d-hardware.py" +LAUNCHER_PATH = ROOT / "scripts" / "bench-bh2d-hardware-launch.sh" + + +def launched_env(): + env = os.environ.copy() + env["GALAXY_BH2D_CLEAN_LAUNCH"] = "1" + return env + + +SPEC = importlib.util.spec_from_file_location("bench_bh2d_hardware", MODULE_PATH) +sweep = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(sweep) + + +STATE_ERROR = { + "position_rms_relative_l2": 0.001, + "position_max_relative": 0.01, + "velocity_rms_relative_l2": 0.001, + "velocity_max_relative": 0.01, +} + + +def receipt_for( + particles: int, + *, + preset: str = "disc", + steps: int = 3, + dt_myr: float = 0.01, + seed: int = 303, + theta: float = 0.5, + softening_kpc: float = 0.05, + direct_probes: int = 12, + oracle_limit: int = 4096, + warmup: int = 2, + repeats: int = 7, +): + # Synthetic tree statistics scale with the workload so the shared fixture + # remains structurally valid as validator invariants tighten. These are + # test-only values, not measured GPU topology evidence. + leaf_count = particles + active_cell_count = 2 * leaf_count - 1 + max_depth = (particles - 1).bit_length() + + force_rms = 0.001 + force_max = force_rms * min(particles ** 0.5, 10.0) + probe_count = min(direct_probes, particles) + direct_rms = 0.001 + direct_max = direct_rms * (probe_count ** 0.5) + + oracle_status = "executed" if particles <= oracle_limit else "skipped-particle-limit" + if oracle_status == "executed": + oracles = { + "status": "executed", + "bh2c_serial_gpu": { + "state_error": dict(STATE_ERROR), + "force_rms_relative": force_rms, + "force_max_relative": force_max, + }, + "bh2b2_host_tree_gpu": { + "state_error": dict(STATE_ERROR), + "force_rms_relative": force_rms, + "force_max_relative": force_max, + }, + } + trajectory = {"status": "executed", "state_error": dict(STATE_ERROR)} + flat_rms = force_rms + flat_max = force_max + else: + oracles = { + "status": "skipped-particle-limit", + "limit": oracle_limit, + "reason": "bounded oracle", + } + trajectory = { + "status": "skipped-particle-limit", + "limit": oracle_limit, + "reason": "bounded trajectory", + } + flat_rms = None + flat_max = None + + parallel_samples = [0.005] * repeats + serial = ( + { + "status": "executed", + "samples_seconds": [0.01] * repeats, + "median_seconds": 0.01, + "parallel_vs_serial_speedup": 2.0, + } + if particles <= 4096 + else {"status": "skipped-bh2c-cap", "limit": 4096} + ) + return { + "schema": sweep.RECEIPT_SCHEMA, + "status": "complete", + "phase": "BH-2D", + "builder": "gpu-parallel-sparse-radix-v1", + "measurement_class": "hardware", + "hardware_performance_claim_allowed": True, + "preset": preset, + "particles": particles, + "steps": steps, + "dt_myr": dt_myr, + "simulated_time_myr": dt_myr * steps, + "seed": seed, + "theta": theta, + "softening_kpc": softening_kpc, + "gpu": { + "index": 0, + "adapter_count": 1, + "name": "Synthetic GPU", + "backend": "Vulkan", + "device_type": "DiscreteGpu", + "driver": "synthetic-driver", + "driver_info": "1.0", + "software": False, + }, + "host_tree_rebuilds": 0, + "host_particle_readbacks_during_steps": 0, + "force_solves": steps + 1, + "evolution_tree_builds": steps + 1, + "parallel_tree_buffer_bytes": sweep.expected_parallel_tree_buffer_bytes(particles), + "tree": { + "ordering": sweep.TREE_ORDERING, + "layout": sweep.TREE_LAYOUT, + "initial_checksum_fnv_mix64": "1111111111111111", + "final_checksum_fnv_mix64": "2222222222222222", + "repeat_checksum_fnv_mix64": "2222222222222222", + "repeat_rebuild_matches": True, + "active_cell_count": active_cell_count, + "leaf_count": leaf_count, + "max_depth": max_depth, + }, + "final_force": { + "bh2a_flat_status": oracle_status, + "gpu_vs_bh2a_flat_rms_relative": flat_rms, + "gpu_vs_bh2a_flat_max_relative": flat_max, + "direct_probe_count": probe_count, + "direct_probe_rms_relative": direct_rms, + "direct_probe_max_relative": direct_max, + }, + "trajectory_vs_bh2a_flat_f64": trajectory, + "gpu_oracles": oracles, + "tree_build_benchmark": { + "sample_scope": sweep.BENCHMARK_SAMPLE_SCOPE, + "stage_timings_are_diagnostics": True, + "warmup": warmup, + "repeats": repeats, + "parallel_samples_seconds": parallel_samples, + "parallel_median_seconds": 0.005, + "bh2c_serial": serial, + }, + } + + +def validation_kwargs(particles: int, **overrides): + values = { + "particles": particles, + "preset": "disc", + "steps": 3, + "dt_myr": 0.01, + "seed": 303, + "theta": 0.5, + "softening_kpc": 0.05, + "direct_probes": 12, + "oracle_limit": 4096, + "benchmark_warmup": 2, + "benchmark_repeats": 7, + "adapter_selector": None, + } + values.update(overrides) + return values + + +class Bh2dHardwareSweepTests(unittest.TestCase): + def test_particle_list_must_be_strictly_increasing(self): + self.assertEqual(sweep.parse_particles("512,4096,8192"), [512, 4096, 8192]) + for invalid in ("", "4096,512", "512,512", "1", "65537", "512,nope"): + with self.subTest(invalid=invalid): + with self.assertRaises(sweep.SweepError): + sweep.parse_particles(invalid) + + def test_cleanliness_rejects_untracked_cargo_config(self): + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "GALAXY Test"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "galaxy-test@example.invalid"], + cwd=repo, + check=True, + ) + (repo / "tracked.txt").write_text("tracked\n") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "fixture"], cwd=repo, check=True) + + cargo = repo / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text('[build]\nrustflags = ["-C", "target-cpu=native"]\n') + + with self.assertRaisesRegex(sweep.SweepError, "untracked files"): + sweep.require_clean_source_tree(repo) + + def test_ignored_cargo_config_is_rejected_even_when_git_hides_it(self): + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "GALAXY Test"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "galaxy-test@example.invalid"], + cwd=repo, + check=True, + ) + (repo / "tracked.txt").write_text("tracked\n") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "fixture"], cwd=repo, check=True) + + info_exclude = repo / ".git" / "info" / "exclude" + info_exclude.write_text(".cargo/\n") + cargo = repo / ".cargo" + cargo.mkdir() + (cargo / "config.toml").write_text('[build]\nrustflags = ["-C", "target-cpu=native"]\n') + + sweep.require_clean_source_tree(repo) + with self.assertRaisesRegex(sweep.SweepError, "Cargo configuration"): + sweep.cargo_config_context(repo) + + def test_cleanliness_rejects_assume_unchanged_and_skip_worktree(self): + for flag in ("--assume-unchanged", "--skip-worktree"): + with self.subTest(flag=flag), tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "GALAXY Test"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "galaxy-test@example.invalid"], + cwd=repo, + check=True, + ) + path = repo / "tracked.txt" + path.write_text("one\n") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "fixture"], cwd=repo, check=True) + subprocess.run(["git", "update-index", flag, "tracked.txt"], cwd=repo, check=True) + path.write_text("two\n") + + self.assertEqual( + subprocess.run( + ["git", "diff", "--quiet", "--"], + cwd=repo, + check=False, + ).returncode, + 0, + ) + with self.assertRaisesRegex(sweep.SweepError, "index flags"): + sweep.require_clean_source_tree(repo) + + def test_cleanliness_uses_raw_bytes_instead_of_clean_filters(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + repo = root / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "GALAXY Test"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "galaxy-test@example.invalid"], + cwd=repo, + check=True, + ) + source = repo / "source.rs" + source.write_text("original\n") + subprocess.run(["git", "add", "source.rs"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "fixture"], cwd=repo, check=True) + + clean = root / "clean-filter.sh" + clean.write_text("#!/bin/sh\nsed 's/replacement/original/g'\n") + clean.chmod(0o755) + subprocess.run( + ["git", "config", "filter.hide.clean", str(clean)], + cwd=repo, + check=True, + ) + info_attributes = repo / ".git" / "info" / "attributes" + info_attributes.write_text("source.rs filter=hide\n") + source.write_text("replacement\n") + + self.assertEqual( + subprocess.run( + ["git", "diff", "--quiet", "--", "source.rs"], + cwd=repo, + check=False, + ).returncode, + 0, + ) + with self.assertRaisesRegex(sweep.SweepError, "raw bytes"): + sweep.require_clean_source_tree(repo) + + def test_cleanliness_allows_only_the_evidence_output(self): + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "GALAXY Test"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "galaxy-test@example.invalid"], + cwd=repo, + check=True, + ) + (repo / "tracked.txt").write_text("tracked\n") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "fixture"], cwd=repo, check=True) + + output = repo / "runs" / "evidence" + output.mkdir(parents=True) + (output / "manifest.json").write_text("{}\n") + sweep.require_clean_source_tree(repo, output) + + (repo / "unexpected.txt").write_text("changes build provenance\n") + with self.assertRaisesRegex(sweep.SweepError, "unexpected.txt"): + sweep.require_clean_source_tree(repo, output) + + def test_source_provenance_rejects_head_change(self): + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "GALAXY Test"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "galaxy-test@example.invalid"], + cwd=repo, + check=True, + ) + path = repo / "tracked.txt" + path.write_text("one\n") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "one"], cwd=repo, check=True) + revision = sweep.git_revision(repo) + + path.write_text("two\n") + subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "two"], cwd=repo, check=True) + + with self.assertRaisesRegex(sweep.SweepError, "source revision changed"): + sweep.require_source_provenance(repo, revision) + + def test_git_replacement_objects_cannot_rewrite_provenance(self): + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "GALAXY Test"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "galaxy-test@example.invalid"], + cwd=repo, + check=True, + ) + source = repo / "source.rs" + source.write_text("original\n") + subprocess.run(["git", "add", "source.rs"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "A"], cwd=repo, check=True) + revision_a = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=repo, text=True + ).strip() + + source.write_text("replacement\n") + subprocess.run(["git", "add", "source.rs"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "B"], cwd=repo, check=True) + revision_b = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=repo, text=True + ).strip() + + subprocess.run(["git", "checkout", "--detach", "-q", revision_a], cwd=repo, check=True) + subprocess.run(["git", "replace", revision_a, revision_b], cwd=repo, check=True) + subprocess.run(["git", "reset", "--hard", "-q", "HEAD"], cwd=repo, check=True) + self.assertEqual(source.read_text(), "replacement\n") + self.assertEqual(sweep.git_revision(repo), revision_a) + with self.assertRaisesRegex(sweep.SweepError, "differs from HEAD|raw bytes"): + sweep.require_clean_source_tree(repo) + + def test_cargo_config_context_detects_mid_sweep_change(self): + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "GALAXY Test"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "galaxy-test@example.invalid"], + cwd=repo, + check=True, + ) + cargo = repo / ".cargo" + cargo.mkdir() + config = cargo / "config.toml" + config.write_text("[build]\nincremental = false\n") + subprocess.run(["git", "add", ".cargo/config.toml"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "fixture"], cwd=repo, check=True) + revision = sweep.git_revision(repo) + context = sweep.cargo_config_context(repo) + + config.write_text("[build]\nincremental = true\n") + with self.assertRaises(sweep.SweepError): + sweep.require_source_provenance( + repo, + revision, + expected_cargo_context=context, + ) + + def test_build_environment_overrides_are_rejected(self): + for name in ( + "LD_AUDIT", + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + "VK_DRIVER_FILES", + "VK_ADD_DRIVER_FILES", + "VK_ICD_FILENAMES", + "VK_LAYER_PATH", + "VK_ADD_LAYER_PATH", + "VK_INSTANCE_LAYERS", + "VK_LOADER_LAYERS_ENABLE", + "VK_LOADER_LAYERS_DISABLE", + "VK_LOADER_DRIVERS_SELECT", + "VK_LOADER_DRIVERS_DISABLE", + "RUSTFLAGS", + "CARGO_BUILD_RUSTFLAGS", + "RUSTC", + "RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WRAPPER", + "CARGO_BUILD_RUSTC_WORKSPACE_WRAPPER", + "RUSTUP_TOOLCHAIN", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS", + "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER", + "CARGO_PROFILE_RELEASE_LTO", + "CARGO_PROFILE_RELEASE_DEBUG_ASSERTIONS", + "CARGO_PROFILE_RELEASE_OVERFLOW_CHECKS", + "CARGO_PROFILE_RELEASE_BUILD_OVERRIDE_OPT_LEVEL", + ): + with self.subTest(name=name), mock.patch.dict( + os.environ, + {name: "evidence-changing-value"}, + clear=False, + ): + with self.assertRaisesRegex(sweep.SweepError, name): + sweep.require_no_build_environment_overrides() + + def test_dependency_source_context_binds_cached_source_bytes(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + repo = root / "repo" + repo.mkdir() + runtime = repo / "runtime" + runtime.mkdir() + (runtime / "Cargo.toml").write_text("[package]\nname='fixture'\nversion='0.1.0'\n") + dependency = root / "cargo-home" / "registry" / "src" / "index" / "wgpu-24.0.5" + dependency.mkdir(parents=True) + manifest = dependency / "Cargo.toml" + source = dependency / "src" / "lib.rs" + source.parent.mkdir() + manifest.write_text("[package]\nname='wgpu'\nversion='24.0.5'\n") + source.write_text("pub const VALUE: u32 = 1;\n") + metadata = { + "packages": [ + { + "name": "fixture", + "version": "0.1.0", + "source": None, + "manifest_path": str(runtime / "Cargo.toml"), + }, + { + "name": "wgpu", + "version": "24.0.5", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "manifest_path": str(manifest), + }, + ] + } + completed = subprocess.CompletedProcess( + [], + 0, + stdout=__import__("json").dumps(metadata).encode(), + stderr=b"", + ) + toolchain = { + "cargo": {"executable": "/selected/cargo"}, + "rustc": {"executable": "/selected/rustc"}, + "build_path": "/usr/bin:/bin", + } + with mock.patch.object(sweep.subprocess, "run", return_value=completed): + first = sweep.dependency_source_context(repo, toolchain) + source.write_text("pub const VALUE: u32 = 2;\n") + second = sweep.dependency_source_context(repo, toolchain) + self.assertEqual(len(first), 1) + self.assertEqual(first[0]["name"], "wgpu") + self.assertNotEqual(first[0]["tree_sha256"], second[0]["tree_sha256"]) + + def test_in_repo_registry_dependency_is_still_hashed(self): + with tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + runtime = repo / "runtime" + runtime.mkdir() + (runtime / "Cargo.toml").write_text( + "[package]\nname='fixture'\nversion='0.1.0'\n" + ) + dependency = ( + repo + / "runtime" + / "target" + / "cargo-home" + / "registry" + / "src" + / "index" + / "wgpu-24.0.5" + ) + dependency.mkdir(parents=True) + manifest = dependency / "Cargo.toml" + source = dependency / "src" / "lib.rs" + source.parent.mkdir() + manifest.write_text("[package]\nname='wgpu'\nversion='24.0.5'\n") + source.write_text("pub const VALUE: u32 = 1;\n") + metadata = { + "packages": [ + { + "name": "fixture", + "version": "0.1.0", + "source": None, + "manifest_path": str(runtime / "Cargo.toml"), + }, + { + "name": "wgpu", + "version": "24.0.5", + "source": "registry+https://github.com/rust-lang/crates.io-index", + "manifest_path": str(manifest), + }, + ] + } + completed = subprocess.CompletedProcess( + [], + 0, + stdout=__import__("json").dumps(metadata).encode(), + stderr=b"", + ) + toolchain = { + "cargo": {"executable": "/selected/cargo"}, + "rustc": {"executable": "/selected/rustc"}, + "build_path": "/usr/bin:/bin", + } + with mock.patch.object(sweep.subprocess, "run", return_value=completed): + context = sweep.dependency_source_context(repo, toolchain) + self.assertEqual(len(context), 1) + self.assertEqual(context[0]["name"], "wgpu") + self.assertTrue(context[0]["path"].startswith("runtime/target/cargo-home/")) + + def test_dependency_tree_hash_binds_file_modes(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + helper = root / "helper.sh" + helper.write_text("#!/bin/sh\nexit 0\n") + helper.chmod(0o644) + first = sweep.sha256_directory(root) + helper.chmod(0o755) + second = sweep.sha256_directory(root) + self.assertNotEqual(first, second) + + def test_cargo_config_execution_and_source_redirects_are_rejected(self): + cases = { + "rustc-wrapper": '[build]\nrustc-wrapper = "/tmp/wrapper"\n', + "target-linker": ( + '[target.x86_64-unknown-linux-gnu]\n' + 'linker = "/tmp/linker"\n' + ), + "target-runner": ( + '[target.x86_64-unknown-linux-gnu]\n' + 'runner = "/tmp/runner"\n' + ), + "target-rustflags": ( + '[target.x86_64-unknown-linux-gnu]\n' + 'rustflags = ["-C", "linker=/tmp/linker"]\n' + ), + "source": ( + '[source.crates-io]\n' + 'replace-with = "vendored"\n' + '[source.vendored]\n' + 'directory = "/tmp/vendor"\n' + ), + "paths": 'paths = ["/tmp/override"]\n', + "env": '[env]\nRUSTFLAGS = "-C target-cpu=native"\n', + } + for name, payload in cases.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: + repo = Path(tmp) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "GALAXY Test"], cwd=repo, check=True) + subprocess.run( + ["git", "config", "user.email", "galaxy-test@example.invalid"], + cwd=repo, + check=True, + ) + cargo = repo / ".cargo" + cargo.mkdir() + config = cargo / "config.toml" + config.write_text(payload) + subprocess.run(["git", "add", ".cargo/config.toml"], cwd=repo, check=True) + subprocess.run(["git", "commit", "-qm", "fixture"], cwd=repo, check=True) + + with self.assertRaisesRegex(sweep.SweepError, "unsupported|not allowed"): + sweep.cargo_config_context(repo) + + def test_toolchain_context_records_resolved_binaries_and_versions(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + bin_dir = root / "bin" + bin_dir.mkdir() + cargo = bin_dir / "cargo-custom" + rustc = bin_dir / "rustc" + cargo.write_text("#!/bin/sh\necho 'cargo 9.9.9 (fixture)'\n") + rustc.write_text("#!/bin/sh\necho 'rustc 9.9.9 (fixture)'\n") + cargo.chmod(0o755) + rustc.chmod(0o755) + + with mock.patch.dict( + os.environ, + {"PATH": str(bin_dir)}, + clear=True, + ): + context = sweep.toolchain_context("cargo-custom", root) + + self.assertEqual(context["cargo"]["requested"], "cargo-custom") + self.assertEqual(context["cargo"]["version_verbose"], "cargo 9.9.9 (fixture)") + self.assertEqual(context["rustc"]["version_verbose"], "rustc 9.9.9 (fixture)") + self.assertEqual(len(context["cargo"]["sha256"]), 64) + self.assertEqual(len(context["rustc"]["sha256"]), 64) + + def test_cli_requires_clean_launcher_and_isolated_python(self): + import sys + for isolated in (False, True): + result = subprocess.run( + [sys.executable, *(["-I"] if isolated else []), str(MODULE_PATH), + "--output", "/unused", "--particles", "512", "--dry-run"], + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("clean launcher", result.stderr) + + with tempfile.TemporaryDirectory() as tmp: + result = subprocess.run( + [ + "sh", + str(LAUNCHER_PATH), + "--output", + str(Path(tmp) / "plan"), + "--particles", + "512", + "--dry-run", + ], + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_platform_tool_paths_cover_windows_and_darwin(self): + with mock.patch.dict( + os.environ, + { + "SystemRoot": r"C:\\Windows", + "ProgramFiles": r"C:\\Program Files", + "USERPROFILE": r"C:\\Users\\tester", + }, + clear=True, + ): + windows = sweep.trusted_system_path("Windows") + self.assertIn(";", windows) + self.assertIn(r"C:\Windows\System32", windows) + self.assertIn(r"C:\Program Files\Git\cmd", windows) + + darwin = sweep.trusted_system_path("Darwin") + self.assertIn("/usr/bin", darwin) + self.assertIn("/opt/homebrew/bin", darwin) + + def test_windows_toolchain_does_not_require_posix_cc(self): + records = { + "cargo": { + "path": "cargo.exe", + "executable": r"C:\\Rust\\cargo.exe", + "sha256": "a" * 64, + "invocation": r"C:\\Rust\\cargo.exe", + "proxy_sha256": None, + "version_verbose": "cargo fixture", + }, + "rustc": { + "path": "rustc.exe", + "executable": r"C:\\Rust\\rustc.exe", + "sha256": "b" * 64, + "invocation": r"C:\\Rust\\rustc.exe", + "proxy_sha256": None, + "version_verbose": "rustc fixture", + }, + } + + def which(name, path=None): + if name == "git": + return r"C:\\Program Files\\Git\\cmd\\git.exe" + if name == "link": + return r"C:\\VS\\bin\\link.exe" + return None + + with mock.patch.object(sweep, "require_no_build_environment_overrides"), \ + mock.patch.object(sweep.platform, "system", return_value="Windows"), \ + mock.patch.object(sweep, "tool_record", side_effect=lambda command, name, root: dict(records[name])), \ + mock.patch.object(sweep.shutil, "which", side_effect=which), \ + mock.patch.object(sweep, "sha256_file", return_value="c" * 64): + context = sweep.toolchain_context("cargo", ROOT) + + self.assertEqual(context["platform"], "Windows") + self.assertIn("git", context["system_tools"]) + self.assertIn("link", context["system_tools"]) + self.assertNotIn("cc", context["system_tools"]) + self.assertIn(";", context["build_path"]) + + def test_system_build_path_and_explicit_rustc(self): + with mock.patch.dict( + os.environ, + { + "PATH": "/unrecorded/bin", + "GIT_WORK_TREE": "/other", + "LD_AUDIT": "/tmp/audit.so", + "LD_PRELOAD": "/tmp/preload.so", + "DYLD_INSERT_LIBRARIES": "/tmp/inject.dylib", + "DYLD_LIBRARY_PATH": "/tmp/dylibs", + "DYLD_FRAMEWORK_PATH": "/tmp/frameworks", + "VK_DRIVER_FILES": "/tmp/icd.json", + "VK_ADD_DRIVER_FILES": "/tmp/additional-icd.json", + "VK_ICD_FILENAMES": "/tmp/legacy-icd.json", + "VK_LAYER_PATH": "/tmp/layers", + "VK_ADD_LAYER_PATH": "/tmp/additional-layers", + "VK_INSTANCE_LAYERS": "VK_LAYER_SYNTHETIC", + "VK_LOADER_LAYERS_ENABLE": "VK_LAYER_SYNTHETIC", + "VK_LOADER_LAYERS_DISABLE": "~implicit~", + "VK_LOADER_DRIVERS_SELECT": "*synthetic*", + "VK_LOADER_DRIVERS_DISABLE": "*other*", + }, + ): + env = sweep.build_environment({ + "build_path": "/trusted/bin:/trusted/tools", + "rustc": {"executable": "/selected/bin/rustc"}, + }) + self.assertEqual(env["PATH"], "/trusted/bin:/trusted/tools") + self.assertEqual(env["RUSTC"], "/selected/bin/rustc") + self.assertNotIn("GIT_WORK_TREE", env) + self.assertNotIn("LD_AUDIT", env) + self.assertNotIn("LD_PRELOAD", env) + for name in ( + "DYLD_INSERT_LIBRARIES", + "DYLD_LIBRARY_PATH", + "DYLD_FRAMEWORK_PATH", + "VK_DRIVER_FILES", + "VK_ADD_DRIVER_FILES", + "VK_ICD_FILENAMES", + "VK_LAYER_PATH", + "VK_ADD_LAYER_PATH", + "VK_INSTANCE_LAYERS", + "VK_LOADER_LAYERS_ENABLE", + "VK_LOADER_LAYERS_DISABLE", + "VK_LOADER_DRIVERS_SELECT", + "VK_LOADER_DRIVERS_DISABLE", + ): + self.assertNotIn(name, env) + + def test_git_invocation_binds_checkout_and_clears_selectors(self): + with mock.patch.dict(os.environ, {"GIT_WORK_TREE": "/other", "GIT_DIR": "/other/.git", + "GIT_INDEX_FILE": "/other/index"}): + command, env = sweep.git_invocation(["git", "status"], ROOT) + self.assertIn(str(ROOT), command) + self.assertIn(str(ROOT / ".git"), command) + for name in ("GIT_WORK_TREE", "GIT_DIR", "GIT_INDEX_FILE"): + self.assertNotIn(name, env) + self.assertEqual(sweep.git_revision(ROOT), + subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip()) + + def test_relative_cargo_home_is_relative_to_repo(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + home = root / "cargo-home" + home.mkdir() + config = home / "config.toml" + config.write_text("[build]\nincremental = false\n") + with mock.patch.dict(os.environ, {"CARGO_HOME": "cargo-home"}): + self.assertIn(config, sweep.effective_cargo_config_paths(root)) + + def test_rustup_proxy_records_selected_binary(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + rustup = root / "rustup" + rustup.write_bytes(b"proxy identity") + proxy = root / "cargo" + proxy.symlink_to(rustup) + selected = root / "selected-cargo" + selected.write_bytes(b"selected compiler tool") + with mock.patch.object(sweep, "resolve_executable", return_value=proxy), \ + mock.patch.object(sweep, "run_checked", side_effect=[str(selected), "cargo fixture"]): + record = sweep.tool_record("cargo", "cargo", root) + self.assertEqual(record["executable"], str(selected)) + self.assertEqual(record["sha256"], sweep.sha256_file(selected)) + self.assertEqual(record["proxy_sha256"], sweep.sha256_file(rustup)) + self.assertNotEqual(record["sha256"], record["proxy_sha256"]) + + def test_hardlinked_rustup_proxy_uses_rustup_command_name(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + rustup = root / "rustup" + rustup.write_bytes(b"proxy") + proxy = root / "cargo" + os.link(rustup, proxy) + selected = root / "toolchain-cargo" + selected.write_bytes(b"cargo") + with mock.patch.object(sweep, "resolve_executable", return_value=proxy), \ + mock.patch.object(sweep.shutil, "which", return_value=str(rustup)), \ + mock.patch.object(sweep, "run_checked", side_effect=[str(selected), "cargo fixture"]) as run: + record = sweep.tool_record("cargo", "cargo", root) + self.assertEqual(run.call_args_list[0].args[0], [str(rustup), "which", "cargo"]) + self.assertEqual(record["sha256"], sweep.sha256_file(selected)) + + def test_invalid_workloads_do_not_create_output(self): + import sys + for flag, value in (("--dt-myr", "nan"), ("--theta", "inf"), + ("--softening-kpc", "-1"), ("--direct-probes", "65"), + ("--seed", str(2**64))): + with self.subTest(flag=flag), tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "evidence" + result = subprocess.run( + [sys.executable, "-I", str(MODULE_PATH), + "--output", str(output), flag, value], + capture_output=True, + env=launched_env(), + ) + self.assertEqual(result.returncode, 1) + self.assertFalse(output.exists()) + + def test_integer_parse_limit_is_normalized(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "receipt.json" + path.write_text('{"value":' + '1' * 5000 + '}') + with self.assertRaisesRegex(sweep.SweepError, "not valid JSON"): + sweep.load_receipt(path) + + def test_non_utf8_run_log_preserved_and_manifest_failed(self): + import argparse + import json + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "evidence" + args = argparse.Namespace(output=output, particles="512", preset="disc", steps=3, + dt_myr=0.01, seed=303, theta=0.5, softening_kpc=0.05, + direct_probes=12, oracle_limit=4096, benchmark_warmup=2, + benchmark_repeats=7, adapter=None, cargo="cargo", dry_run=False) + context = {"cargo": {"executable": "/selected/cargo"}, + "rustc": {"executable": "/selected/rustc"}, + "build_path": "/usr/bin:/bin"} + with mock.patch.dict(os.environ, {sweep.CLEAN_LAUNCH_ENV: "1"}, clear=False), \ + mock.patch.object(sweep, "parse_args", return_value=args), \ + mock.patch.object(sweep, "git_revision", return_value="fixture"), \ + mock.patch.object(sweep, "require_clean_source_tree"), \ + mock.patch.object(sweep, "cargo_config_context", return_value=[]), \ + mock.patch.object(sweep, "toolchain_context", return_value=context), \ + mock.patch.object(sweep, "dependency_source_context", return_value=[]), \ + mock.patch.object(sweep, "require_source_provenance"), \ + mock.patch.object(sweep.platform, "platform", return_value="fixture"), \ + mock.patch.object(sweep.subprocess, "run", return_value= subprocess.CompletedProcess([], 1, b"driver: \xff\n")): + self.assertEqual(sweep.main(), 1) + log_path = output / "n000512/run.log" + self.assertEqual(log_path.read_bytes(), b"driver: \xff\n") + manifest = json.loads((output / "manifest.json").read_text()) + self.assertEqual(manifest["status"], "failed") + self.assertEqual(len(manifest["runs"]), 1) + failed = manifest["runs"][0] + self.assertEqual(failed["particles"], 512) + self.assertEqual(failed["status"], "failed") + self.assertEqual(failed["exit_code"], 1) + self.assertEqual(failed["log"], "n000512/run.log") + self.assertEqual(failed["log_sha256"], sweep.sha256_file(log_path)) + + def test_successful_unusable_receipts_bind_run_log(self): + import argparse + import json + + cases = { + "missing": None, + "malformed": b"{not-json", + "rejected": (lambda: ( + lambda receipt: json.dumps(receipt).encode() + )({**receipt_for(512), "status": "incomplete"}))(), + } + for name, receipt_payload in cases.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "evidence" + args = argparse.Namespace( + output=output, + particles="512", + preset="disc", + steps=3, + dt_myr=0.01, + seed=303, + theta=0.5, + softening_kpc=0.05, + direct_probes=12, + oracle_limit=4096, + benchmark_warmup=2, + benchmark_repeats=7, + adapter=None, + cargo="cargo", + dry_run=False, + ) + context = { + "cargo": {"executable": "/selected/cargo"}, + "rustc": {"executable": "/selected/rustc"}, + "build_path": "/usr/bin:/bin", + } + + def verifier_run(command, **kwargs): + receipt_arg = Path(command[command.index("--receipt") + 1]) + if receipt_payload is not None: + receipt_arg.write_bytes(receipt_payload) + return subprocess.CompletedProcess( + command, + 0, + b"exited cleanly but no usable receipt\n", + ) + + with mock.patch.dict(os.environ, {sweep.CLEAN_LAUNCH_ENV: "1"}, clear=False), \ + mock.patch.object(sweep, "parse_args", return_value=args), \ + mock.patch.object(sweep, "git_revision", return_value="fixture"), \ + mock.patch.object(sweep, "require_clean_source_tree"), \ + mock.patch.object(sweep, "cargo_config_context", return_value=[]), \ + mock.patch.object(sweep, "toolchain_context", return_value=context), \ + mock.patch.object(sweep, "dependency_source_context", return_value=[]), \ + mock.patch.object(sweep, "require_source_provenance"), \ + mock.patch.object(sweep.platform, "platform", return_value="fixture"), \ + mock.patch.object(sweep.subprocess, "run", side_effect=verifier_run): + self.assertEqual(sweep.main(), 1) + + log_path = output / "n000512/run.log" + manifest = json.loads((output / "manifest.json").read_text()) + self.assertEqual(manifest["status"], "failed") + self.assertEqual(len(manifest["runs"]), 1) + failed = manifest["runs"][0] + self.assertEqual(failed["status"], "failed") + self.assertEqual(failed["exit_code"], 0) + self.assertEqual(failed["log"], "n000512/run.log") + self.assertEqual(failed["log_sha256"], sweep.sha256_file(log_path)) + receipt_path = output / "n000512/receipt.json" + if receipt_payload is None: + self.assertNotIn("receipt", failed) + else: + self.assertEqual(failed["receipt"], "n000512/receipt.json") + self.assertEqual( + failed["receipt_sha256"], + sweep.sha256_file(receipt_path), + ) + + def test_receipt_rejects_nonstandard_json_constants(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "receipt.json" + for constant in ("NaN", "Infinity", "-Infinity"): + with self.subTest(constant=constant): + path.write_text( + '{"timings_seconds":{"parallel_tree_build_total":' + + constant + + "}}" + ) + with self.assertRaisesRegex(sweep.SweepError, "not valid JSON"): + sweep.load_receipt(path) + + def test_receipt_rejects_duplicate_object_keys(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "receipt.json" + path.write_text( + '{"measurement_class":"software-validation",' + '"measurement_class":"hardware"}' + ) + with self.assertRaisesRegex(sweep.SweepError, "not valid JSON"): + sweep.load_receipt(path) + + def test_invalid_utf8_receipt_is_normalized_to_sweep_error(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "receipt.json" + path.write_bytes(b"{\xff}") + with self.assertRaisesRegex(sweep.SweepError, "not valid UTF-8"): + sweep.load_receipt(path) + + def test_verifier_command_uses_fresh_explicit_target_directory(self): + args = __import__("argparse").Namespace( + cargo="cargo", + preset="disc", + steps=3, + dt_myr=0.01, + seed=303, + theta=0.5, + softening_kpc=0.05, + direct_probes=12, + oracle_limit=4096, + benchmark_warmup=2, + benchmark_repeats=7, + adapter=None, + ) + receipt = Path("/evidence/n000512/receipt.json") + target_dir = Path("/evidence/n000512/cargo-target") + command = sweep.command_for(args, 512, receipt, target_dir) + self.assertIn("--target-dir", command) + index = command.index("--target-dir") + self.assertEqual(command[index + 1], str(target_dir)) + self.assertNotIn("runtime/target", " ".join(command)) + + def test_collision_dry_run_rejects_fewer_than_four_particles(self): + import sys + with tempfile.TemporaryDirectory() as tmp: + result = subprocess.run( + [ + sys.executable, + "-I", + str(MODULE_PATH), + "--output", + str(Path(tmp) / "plan"), + "--preset", + "collision", + "--particles", + "2", + "--dry-run", + ], + capture_output=True, + text=True, + env=launched_env(), + ) + self.assertEqual(result.returncode, 1) + self.assertIn("at least 4", result.stderr) + + def test_dry_run_does_not_execute_cargo(self): + import sys + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + marker = root / "executed" + fake_cargo = root / "cargo" + fake_cargo.write_text( + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> " + repr(str(marker)) + "\n" + ) + fake_cargo.chmod(0o755) + result = subprocess.run( + [ + sys.executable, + "-I", + str(MODULE_PATH), + "--output", + str(root / "plan"), + "--particles", + "512", + "--cargo", + str(fake_cargo), + "--dry-run", + ], + capture_output=True, + text=True, + env=launched_env(), + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(marker.exists()) + self.assertTrue(result.stdout.startswith(str(fake_cargo))) + + def test_dry_run_normalizes_output_and_cargo_paths(self): + import sys + result = subprocess.run( + [ + sys.executable, + "-I", + str(MODULE_PATH), + "--output", + "~/evidence", + "--particles", + "512", + "--dry-run", + ], + capture_output=True, + text=True, + env=launched_env(), + ) + self.assertEqual(result.returncode, 0, result.stderr) + command = __import__("shlex").split(result.stdout.strip()) + self.assertTrue(Path(command[0]).is_absolute()) + expected = str((Path.home() / "evidence" / "n000512" / "receipt.json").resolve()) + self.assertIn(expected, command) + self.assertNotIn("~/evidence", result.stdout) + + def test_shared_receipt_fixture_is_valid_across_sweep_sizes(self): + for particles in (512, 4096, 8192, 65536): + with self.subTest(particles=particles): + summary = sweep.validate_receipt( + receipt_for(particles), + **validation_kwargs(particles), + ) + self.assertEqual(summary["particles"], particles) + + def test_hardware_receipt_at_oracle_size_is_accepted(self): + summary = sweep.validate_receipt(receipt_for(4096), **validation_kwargs(4096)) + self.assertEqual(summary["particles"], 4096) + self.assertEqual(summary["bh2c_parallel_vs_serial_speedup"], 2.0) + self.assertEqual(summary["parallel_median_seconds"], 0.005) + + def test_large_hardware_receipt_requires_explicit_oracle_skips(self): + summary = sweep.validate_receipt(receipt_for(8192), **validation_kwargs(8192)) + self.assertEqual(summary["particles"], 8192) + self.assertIsNone(summary["bh2c_parallel_vs_serial_speedup"]) + + def test_receipt_must_bind_the_full_requested_workload(self): + mutations = { + "preset": "collision", + "dt_myr": 1.0, + "seed": 999, + "theta": 2.0, + "softening_kpc": 100.0, + } + for field, value in mutations.items(): + with self.subTest(field=field): + receipt = receipt_for(512) + receipt[field] = value + with self.assertRaisesRegex(sweep.SweepError, "requested workload"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["final_force"]["direct_probe_count"] = 1 + with self.assertRaisesRegex(sweep.SweepError, "direct-probe count"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree_build_benchmark"]["warmup"] = 9 + with self.assertRaisesRegex(sweep.SweepError, "warmup count"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree_build_benchmark"]["repeats"] = 6 + with self.assertRaisesRegex(sweep.SweepError, "repeat count"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_adapter_index_is_bound_into_identity_and_numeric_selector(self): + receipt0 = receipt_for(512) + receipt1 = receipt_for(512) + receipt1["gpu"]["index"] = 1 + receipt1["gpu"]["adapter_count"] = 2 + summary0 = sweep.validate_receipt(receipt0, **validation_kwargs(512)) + summary1 = sweep.validate_receipt(receipt1, **validation_kwargs(512)) + self.assertNotEqual(summary0["adapter_identity"], summary1["adapter_identity"]) + + with self.assertRaisesRegex(sweep.SweepError, "numeric adapter selector"): + sweep.validate_receipt( + receipt1, + **validation_kwargs(512, adapter_selector="0"), + ) + + summary = sweep.validate_receipt( + receipt0, + **validation_kwargs(512, adapter_selector="+0"), + ) + self.assertEqual(summary["particles"], 512) + self.assertEqual(sweep.parse_adapter_index_selector("+0"), 0) + self.assertIsNone(sweep.parse_adapter_index_selector("-0")) + self.assertIsNone(sweep.parse_adapter_index_selector(" 0")) + + receipt = receipt_for(512) + receipt["gpu"]["name"] = "AMD Radeon RX 7900 XTX" + with self.assertRaisesRegex(sweep.SweepError, "textual adapter selector"): + sweep.validate_receipt( + receipt, + **validation_kwargs(512, adapter_selector="NVIDIA"), + ) + + summary = sweep.validate_receipt( + receipt, + **validation_kwargs(512, adapter_selector="radeon rx"), + ) + self.assertEqual(summary["particles"], 512) + + receipt = receipt_for(512) + receipt["gpu"]["name"] = "NVIDIA GeForce RTX 4090" + summary = sweep.validate_receipt( + receipt, + **validation_kwargs(512, adapter_selector="4090"), + ) + self.assertEqual(summary["particles"], 512) + + receipt = receipt_for(512) + receipt["gpu"]["backend"] = "Gl" + with self.assertRaisesRegex(sweep.SweepError, "not enabled by the producer"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_adapter_identity_is_mandatory_and_hardware_bound(self): + for gpu in ( + {}, + { + "name": "Synthetic GPU", + "backend": "Vulkan", + "device_type": "DiscreteGpu", + "driver": "", + "driver_info": "", + "software": False, + }, + ): + with self.subTest(gpu=gpu): + receipt = receipt_for(512) + receipt["gpu"] = gpu + with self.assertRaises(sweep.SweepError): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_producer_software_adapter_classification_is_enforced(self): + receipt = receipt_for(512) + receipt["gpu"]["device_type"] = "Cpu" + receipt["gpu"]["software"] = False + with self.assertRaisesRegex(sweep.SweepError, "software-adapter classification"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + for name in ("llvmpipe", "lavapipe GPU", "SwiftShader Device", "Software Rasterizer"): + with self.subTest(name=name): + receipt = receipt_for(512) + receipt["gpu"]["name"] = name + receipt["gpu"]["software"] = False + with self.assertRaisesRegex(sweep.SweepError, "software-adapter classification"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_software_receipt_is_rejected(self): + receipt = receipt_for(512) + receipt["measurement_class"] = "software-validation" + receipt["hardware_performance_claim_allowed"] = False + receipt["gpu"]["software"] = True + with self.assertRaisesRegex(sweep.SweepError, "software-validation"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_malformed_typed_receipt_is_rejected_as_sweep_error(self): + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = "corrupt" + with self.assertRaisesRegex(sweep.SweepError, "active_cell_count must be an integer"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_zero_work_counters_require_integer_types(self): + for field in ( + "host_tree_rebuilds", + "host_particle_readbacks_during_steps", + ): + with self.subTest(field=field): + receipt = receipt_for(512) + receipt[field] = False + with self.assertRaisesRegex(sweep.SweepError, field): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_execution_counters_require_integer_types(self): + for field in ("force_solves", "evolution_tree_builds"): + with self.subTest(field=field): + receipt = receipt_for(512) + receipt[field] = 4.0 + with self.assertRaisesRegex(sweep.SweepError, field): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_tree_representation_declarations_are_frozen(self): + for field, value, message in ( + ("ordering", "not-stable-or-morton", "stable Morton ordering"), + ("layout", "arbitrary-layout", "sparse level-order layout"), + ): + with self.subTest(field=field): + receipt = receipt_for(512) + receipt["tree"][field] = value + with self.assertRaisesRegex(sweep.SweepError, message): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_repeat_tree_mismatch_is_rejected(self): + receipt = receipt_for(512) + receipt["tree"]["repeat_rebuild_matches"] = False + with self.assertRaisesRegex(sweep.SweepError, "repeat tree checksum"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_repeat_tree_checksum_fields_are_required_and_compared(self): + receipt = receipt_for(512) + del receipt["tree"]["final_checksum_fnv_mix64"] + with self.assertRaisesRegex(sweep.SweepError, "final_checksum_fnv_mix64"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["repeat_checksum_fnv_mix64"] = "3333333333333333" + with self.assertRaisesRegex(sweep.SweepError, "does not match"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["repeat_checksum_fnv_mix64"] = "NOT-A-CHECKSUM" + with self.assertRaisesRegex(sweep.SweepError, "16-digit lowercase hexadecimal"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_tree_structure_is_bounded_by_frozen_sparse_representation(self): + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = sweep.TREE_LEVELS * 512 + 1 + with self.assertRaisesRegex(sweep.SweepError, "sparse-cell capacity"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 17 + receipt["tree"]["leaf_count"] = 18 + with self.assertRaisesRegex(sweep.SweepError, "cannot exceed active_cell_count"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 1201 + receipt["tree"]["leaf_count"] = 600 + receipt["tree"]["max_depth"] = 16 + with self.assertRaisesRegex(sweep.SweepError, "resident particle count"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["max_depth"] = 17 + with self.assertRaisesRegex(sweep.SweepError, "frozen Morton depth"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 6742 + receipt["tree"]["leaf_count"] = 512 + receipt["tree"]["max_depth"] = 16 + with self.assertRaisesRegex(sweep.SweepError, "per-depth quadtree occupancy"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 6485 + receipt["tree"]["leaf_count"] = 512 + receipt["tree"]["max_depth"] = 16 + with self.assertRaisesRegex(sweep.SweepError, "per-depth quadtree occupancy"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 1 + receipt["tree"]["leaf_count"] = 1 + receipt["tree"]["max_depth"] = 0 + with self.assertRaisesRegex(sweep.SweepError, "bucket size"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 2 + receipt["tree"]["leaf_count"] = 1 + receipt["tree"]["max_depth"] = 16 + with self.assertRaisesRegex(sweep.SweepError, "too small"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 2 + receipt["tree"]["leaf_count"] = 1 + receipt["tree"]["max_depth"] = 1 + with self.assertRaisesRegex(sweep.SweepError, "bucket size"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 145 + receipt["tree"]["leaf_count"] = 129 + receipt["tree"]["max_depth"] = 16 + with self.assertRaisesRegex(sweep.SweepError, "fan-out"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 17 + receipt["tree"]["leaf_count"] = 12 + receipt["tree"]["max_depth"] = 16 + with self.assertRaisesRegex(sweep.SweepError, "internal cells"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 100 + receipt["tree"]["leaf_count"] = 1 + receipt["tree"]["max_depth"] = 16 + with self.assertRaisesRegex(sweep.SweepError, "leaf-path capacity"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["tree"]["active_cell_count"] = 34 + receipt["tree"]["leaf_count"] = 2 + receipt["tree"]["max_depth"] = 16 + with self.assertRaisesRegex(sweep.SweepError, "leaf-path capacity"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_state_error_zero_maximum_matches_rms(self): + for key in ("bh2c_serial_gpu", "bh2b2_host_tree_gpu"): + with self.subTest(key=key, component="position"): + receipt = receipt_for(512) + state = receipt["gpu_oracles"][key]["state_error"] + state["position_rms_relative_l2"] = 0.02 + state["position_max_relative"] = 0.0 + with self.assertRaisesRegex(sweep.SweepError, "position RMS/max zero-state mismatch"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + with self.subTest(key=key, component="velocity"): + receipt = receipt_for(512) + state = receipt["gpu_oracles"][key]["state_error"] + state["velocity_rms_relative_l2"] = 0.02 + state["velocity_max_relative"] = 0.0 + with self.assertRaisesRegex(sweep.SweepError, "velocity RMS/max zero-state mismatch"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + state = receipt["trajectory_vs_bh2a_flat_f64"]["state_error"] + state["position_rms_relative_l2"] = 0.0 + state["position_max_relative"] = 0.01 + with self.assertRaisesRegex(sweep.SweepError, "position RMS/max zero-state mismatch"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_force_rms_cannot_exceed_reported_maximum(self): + receipt = receipt_for(512) + receipt["final_force"]["direct_probe_rms_relative"] = 0.03 + receipt["final_force"]["direct_probe_max_relative"] = 0.001 + with self.assertRaisesRegex(sweep.SweepError, "direct-force RMS cannot exceed maximum"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["final_force"]["gpu_vs_bh2a_flat_rms_relative"] = 0.03 + receipt["final_force"]["gpu_vs_bh2a_flat_max_relative"] = 0.001 + with self.assertRaisesRegex(sweep.SweepError, "BH #2A final-force RMS cannot exceed maximum"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + for key in ("bh2c_serial_gpu", "bh2b2_host_tree_gpu"): + with self.subTest(key=key): + receipt = receipt_for(512) + receipt["gpu_oracles"][key]["force_rms_relative"] = 0.03 + receipt["gpu_oracles"][key]["force_max_relative"] = 0.001 + with self.assertRaisesRegex(sweep.SweepError, "force RMS cannot exceed force maximum"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_force_maximum_has_lower_rms_bound(self): + receipt = receipt_for(512) + receipt["final_force"]["direct_probe_rms_relative"] = 0.0 + receipt["final_force"]["direct_probe_max_relative"] = 0.29 + with self.assertRaisesRegex(sweep.SweepError, "probe count"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["final_force"]["gpu_vs_bh2a_flat_rms_relative"] = 0.0 + receipt["final_force"]["gpu_vs_bh2a_flat_max_relative"] = 0.29 + with self.assertRaisesRegex(sweep.SweepError, "particle count"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + for key in ("bh2c_serial_gpu", "bh2b2_host_tree_gpu"): + with self.subTest(key=key): + receipt = receipt_for(512) + receipt["gpu_oracles"][key]["force_rms_relative"] = 0.0 + receipt["gpu_oracles"][key]["force_max_relative"] = 0.29 + with self.assertRaisesRegex(sweep.SweepError, "sample count"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_executed_oracle_payloads_are_required(self): + receipt = receipt_for(512) + receipt["gpu_oracles"] = {"status": "executed"} + with self.assertRaisesRegex(sweep.SweepError, "bh2c_serial_gpu"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + receipt = receipt_for(512) + receipt["trajectory_vs_bh2a_flat_f64"] = {"status": "executed"} + with self.assertRaisesRegex(sweep.SweepError, "state_error"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_numeric_overflow_is_normalized_to_sweep_error(self): + receipt = receipt_for(512) + receipt["dt_myr"] = 10 ** 400 + with self.assertRaisesRegex(sweep.SweepError, "finite number"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_tree_buffer_size_is_bound_to_particle_count(self): + for particles in (2, 127, 128, 129, 4096, 65536): + with self.subTest(particles=particles): + expected = 1152 * particles + 160 * ((particles + 127) // 128) + 32 + self.assertEqual( + sweep.expected_parallel_tree_buffer_bytes(particles), + expected, + ) + + receipt = receipt_for(512) + receipt["parallel_tree_buffer_bytes"] = 1 + with self.assertRaisesRegex(sweep.SweepError, "allocation formula"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_benchmark_scope_must_match_frozen_declaration(self): + receipt = receipt_for(512) + receipt["tree_build_benchmark"]["sample_scope"] = ( + "complete rebuild call excluding queue submit and GPU synchronization" + ) + with self.assertRaisesRegex(sweep.SweepError, "frozen complete-rebuild declaration"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_parallel_median_is_recomputed_from_samples(self): + receipt = receipt_for(512) + receipt["tree_build_benchmark"]["parallel_samples_seconds"] = [100.0] * 7 + receipt["tree_build_benchmark"]["parallel_median_seconds"] = 0.000001 + with self.assertRaisesRegex(sweep.SweepError, "median does not match"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_serial_benchmark_summary_is_recomputed_from_samples(self): + receipt = receipt_for(512) + serial = receipt["tree_build_benchmark"]["bh2c_serial"] + serial["samples_seconds"] = [50.0] * 7 + serial["median_seconds"] = 0.01 + with self.assertRaisesRegex(sweep.SweepError, "BH #2C benchmark median"): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_nonfinite_or_nonpositive_benchmark_samples_are_rejected(self): + for bad in (0.0, -1.0, float("inf"), float("nan")): + with self.subTest(bad=bad): + receipt = receipt_for(512) + receipt["tree_build_benchmark"]["parallel_samples_seconds"][0] = bad + with self.assertRaises(sweep.SweepError): + sweep.validate_receipt(receipt, **validation_kwargs(512)) + + def test_bh2c_benchmark_must_skip_above_its_cap(self): + receipt = receipt_for(8192) + receipt["tree_build_benchmark"]["bh2c_serial"] = { + "status": "executed", + "samples_seconds": [0.01] * 7, + "median_seconds": 0.01, + "parallel_vs_serial_speedup": 2.0, + } + with self.assertRaisesRegex(sweep.SweepError, "must skip above 4096"): + sweep.validate_receipt(receipt, **validation_kwargs(8192)) + + +if __name__ == "__main__": + unittest.main()