Skip to content

feat(mutual): rungs on the distributed mesh, and a block-step entry point for it - #252

Merged
TobiBu merged 7 commits into
mainfrom
feat/distributed-mutual-rungs
Aug 28, 2026
Merged

feat(mutual): rungs on the distributed mesh, and a block-step entry point for it#252
TobiBu merged 7 commits into
mainfrom
feat/distributed-mutual-rungs

Conversation

@TobiBu

@TobiBu TobiBu commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Track C of docs/plan_2026-08_C_rungs.md, from a fresh origin/main (a07528a).

Needs TobiBu/yggdrax#53 first — the exact per-particle near-field predicate needs the
remote endpoint's rung, which has to ride the demand-driven halo import.

What landed

C1 rung / level_weights through the shard_map body and the driver, k_max on the config mutual/distributed.py
C2 the weight applied correctly on the cross-domain far half same
C3 DistributedBlockStepFMM, plus make_distributed_mutual_evaluator nornax_adapter.py
C4 backend / pallas_interpret, use_pallas actually threaded mutual/distributed.py

Before this, mutual/distributed.py had zero occurrences of rung,
level_weights or k_max, so block-step individual timesteps were single-device only.

C2 is the part that was not plumbing, and it needed a fourth test

The plan says a momentum test alone will not catch a weight applied to neither side. It
is worse than that. I injected each fault and measured three criteria — 2 devices,
N = 256, leaf 4, θ = cross_θ = 0.5, 33 cross far pairs, 991 cross near pairs:

fault level partition weight linearity momentum
none 2.1e-16 2.9e-16 < 4e-17
far half left unweighted 3.9e-02 2.0e-02 < 4e-17
far weight applied twice 2.1e-16 9.4e-03 < 4e-17
far ignores the remote rung 2.1e-16 2.9e-16 < 4e-17

Momentum sees none of the three. A per-level residual at 1e-17 is not evidence the
levels are right — any single symmetric scalar per pair conserves momentum exactly,
whatever that scalar is. So:

  • the partition test (one-hot rows must sum to the total) catches the first, and
    cannot catch squaring: w² == w for 0 and 1;
  • a linearity test (a(u) + a(1−u) == a(1) at fractional rows) catches squaring —
    hence a separate test, with rows deliberately none of them 0 or 1;
  • neither catches the third, which the plan did not anticipate: a level assignment
    that is wrong yet consistent
    is still one scalar per pair, so it passes partition,
    linearity and momentum. It needed
    test_the_cross_far_level_uses_the_REMOTE_endpoints_rung: two rung-uniform clumps,
    one per device, so every cross pair provably belongs to one level and clump A's
    level-0 force is an exact direct sum. Injected as f_level = node_rung[f_local] it
    moves 1.2e-3 against a 1e-13 bound while the other three stay at round-off.

Structurally, the weight is applied once, on the evaluating device, to both directions
of the batched M2L, before either leaves
. Legal because everything downstream of an
expansion is linear in its coefficients (L2L re-centres, L2P evaluates), so weighting
the expansion is weighting the pair. The far payload's rung is read from the end of
the row, so the multipole width is stated once — indexing it at a computed
sh_size(order) offset would state it twice, and a mismatch would read a coefficient
as a rung, which survives the clamp as a perfectly valid pair level.

On the plan's second C2 concern — a cell straddling a boundary getting the same rung on
both devices — it holds by construction and is now asserted rather than assumed: a far
pair's remote endpoint is a coarse leaf holding exactly one frontier node
(accept_only_leaf_pairs + the degenerate contract check), so each endpoint's rung is
computed exactly once, by the device that owns the particles, and never recomputed
across the wire.

Which channel each rung travels on

  • Near, exact per-particle → round B of the halo import, sized by the halo.
  • Far, cell-level → the coarse frontier, one scalar per leaf appended to its
    multipole row.

Per-particle rungs could not go on the frontier: it is all_gather-ed, so
leaf_width columns there would be O(N_total) — invisible at cross_theta = 0 where
the import fetches everything anyway, and a hard cap the moment θ collapses the import
to a surface halo.

_node_rungs gives every node its cell rung as a range maximum expressed as one
prefix count per level
— this module has only the inclusive node ranges, not the level
schedule force._cell_rungs walks. k_max is small, so it is O(n·k_max) with no
parent array and no propagation scan, and
tests/unit/mutual/test_distributed_node_rungs.py checks it against both a loop oracle
and _cell_rungs on a real tree.

C3 reaches the plan's literal criterion, and needed a traceable readout

block_kdk_rollout walks its base steps with a lax.scan, so it needs a force it can
trace. The driver assembled its result with a NumPy scatter and raised on a
host-read overflow flag — neither survives a trace. Both moved:

  • the readout is a jnp scatter;
  • the "every real particle appears exactly once" check moved to the partition,
    where it belongs — it depends only on the frozen gid layout, not on any evaluation;
  • the overflow read is attempted rather than gated on isinstance(..., Tracer), the
    discipline BlockStepFMM._validate_rung documents: a concrete array closed over by a
    scan body is not a Tracer yet still cannot be read.

Result: nornax's own scanned block_kdk_rollout, 2 devices, multi-rung, 2 base steps
— momentum drift 1.8e-17
, energy bounded, and jaccpot's own advance_base_step
matching nornax's to 1e-12.

And a compile that was being paid per force

shard_map wraps a fresh closure per call, so jax.jit sees a fresh cache key and
distributed_mutual_fmm recompiles every time — fine for one force, ruinous for the
n_sub + 1 evaluations a base step asks for. make_distributed_mutual_evaluator
partitions and compiles once. Measured, 2 forced CPU devices, N = 128:

build < 0.1 s
first evaluation 20.5 s
each later one ~8 s

distributed_mutual_fmm is now that plus one call, behaviour unchanged.

What prepare() freezes is not the topology, and the docstring says so rather than
implying otherwise: the partition, padding layout, bounds, capacities and compiled
program are frozen; the per-device tree is rebuilt inside the program from the positions
handed in. That is a finer rebuild cadence than the single-device lane's, not a
coarser one, and legality is unchanged — every evaluation is internally self-consistent,
so its levels partition its own pairs and each level's momentum cancels exactly. The
consequence runs the other way: prepare need not be called once per base step, only
when the partition should change.

C4

build_mutual_state_device has accepted use_pallas all along and the driver never
passed it, so this lane ran pure JAX whatever the caller asked. Routing now mirrors the
single-device lane exactly, including its two measured decisions: the far field stays
pure JAX (both Pallas M2L shapes are slower), and every Pallas lane goes through its
custom_vjp wrapper, never the bare kernel. The cross-domain near field is pure JAX
either way — a different kernel (_tile_forces), and a Pallas lane for it is a new
kernel, not a wiring change.

Per the plan's trap list, the weighted path has its own Pallas test: level_weights[k]
is exactly the lowering that broke before, and the throughput benchmark runs unweighted.
interpret ∈ {True, False} is kept parametrized — the interpret=False case is the
only thing standing between a lowering regression and a broken backend="pallas"
, and
it skips off sm_80.

Verification

XLA_FLAGS="--xla_force_host_platform_device_count=2" JAX_PLATFORMS=cpu JAX_ENABLE_X64=1 \
  pytest -q -o addopts="" tests/integration/test_mutual_distributed.py \
                          tests/integration/test_mutual_distributed_nornax.py \
                          tests/integration/test_mutual_fmm.py \
                          tests/integration/test_mutual_fmm_nornax.py \
                          tests/unit/test_custom_vjp_parity.py \
                          tests/unit/mutual/
suite where result
test_mutual_distributed.py + tests/unit/mutual/ 2 forced CPU devices 56 passed, 1 skipped (18m36s)
test_mutual_distributed_nornax.py 2 forced CPU devices 12 passed (77s)
the interpret=False case CPU must skip, + the rung subset 2x A100 6 passed (6m52s)
test_mutual_fmm.py, test_mutual_fmm_nornax.py, test_custom_vjp_parity.py 2x A100 104 passed (1h03m)

The single-device row is the regression check, and it is insurance rather than a gate:
the diff does not touch mutual/force.py, nearfield.py, farfield.py,
device_topology.py or topology.py, and the only deleted line in
nornax_adapter.py is its __all__, so BlockStepFMM is untouched by
construction. Run anyway, because "additive by inspection" is a claim.

The one skip on CPU is test_the_pallas_backend_matches_the_jax_backend[False] --
the real Triton lowering, which needs sm_80. It is green on 2x A100, which
matters more than the rest of the C4 row: interpret mode validates the kernel's
LOGIC, not its lowerability, and per this repo's own record two mutual kernels
passed every CPU interpret test and then failed on their first GPU run.

Wall-clock is long because the box was at load ~70 on 64 cores throughout, not
because of anything here.

CI will SKIP these tests until yggdrax#53 merges

Deliberately, and verified. CI installs yggdrax from its default branch, so until #53
lands payload_sorted does not exist there -- and that is a TypeError raised deep
inside a shard_map trace on every weighted test. Both modules would go red rather
than skip, which is exactly the failure test_mutual_distributed.py's own docstring
records having been caught not preventing once before. So its guard now checks the
halo import's parameter alongside the two on the cross walk, and the nornax module
gets its own. Checked by running both against the OLD yggdrax checkout: 2 skipped,
each naming payload_sorted and #53. A guard nobody has seen fire is decoration.

pre-commit run --all-files (black, isort, pydoclint, flake8) green over the tree.

Why one PR

C1–C4 are interdependent: C1/C2's tests drive C3's evaluator (a 4× test-time win, and
also the claim under test — only shapes are static, so one compiled program must serve
every weight row and every rung assignment), and C4's threading lives inside the
function C3's refactor created. Reviewable commit by commit; the four commits are split
by concern.

Not in scope, flagged for track E

mutual/distributed.py's module docstring is stale and predates this PR: "The one
simplification, stated plainly: cross-domain interactions are EXACT. The cross walk is
driven with theta = 0"
— untrue since the cross_theta lift (#208). Left alone
because track E owns module docstrings; the parameter-level docs it contradicts are
already correct.

🤖 Generated with Claude Code

GFG-CHAOS and others added 6 commits August 28, 2026 12:44
Track C's library half. `mutual/distributed.py` had no rung vocabulary at all --
not one occurrence of `rung`, `level_weights` or `k_max` -- so block-step
individual timesteps were single-device only. It now takes the same
`rung`/`level_weights` pair `mutual/force.py::mutual_weighted_accelerations`
does, on both halves of every pair.

The near half's predicate is exact and per-particle, so the remote endpoint's
rung has to travel WITH the remote endpoint: it rides round B of the
demand-driven halo import (`import_near_halo(payload_sorted=...)`, needs
TobiBu/yggdrax#53), sized by the halo. It cannot ride the coarse frontier, which
is `all_gather`-ed -- `leaf_width` rung columns there would be `O(N_total)`
traffic and defeat the LET. The weight then multiplies `inv3`, the one tensor
both sides of the tile read, so a weighted cross block is antisymmetric for the
same reason an unweighted one is.

The far half is cell-level, so the frontier IS the right channel: one scalar per
leaf, appended to the multipole row it belongs to and read from the END of that
row, so the split cannot drift away from `_far_payload`. The local endpoint may
be an internal node -- `accept_only_leaf_pairs` constrains the remote side,
which has to be addressable -- so `_node_rungs` gives every node its rung. It
gets there as a range maximum expressed as one prefix count per level, because
this module has only the inclusive node ranges and not the level schedule
`force._cell_rungs` walks; `k_max` is small, so that is `O(n * k_max)` with no
parent array and no propagation scan.

The far weight is applied ONCE, on the evaluating device, to both directions of
the batched M2L, before either leaves. That is legal because everything
downstream of an expansion is linear in its coefficients -- L2L re-centres, L2P
evaluates -- so weighting the expansion is weighting the pair. And it matters:
applying it again on import would square it, and applying it on neither side
would leave the far half unweighted while the near half is weighted.

Also here, because they are the same file's changes:

* `backend` and `pallas_interpret` on the config, and `use_pallas` finally
  passed to `build_mutual_state_device`, which has accepted it all along while
  the driver never passed it -- so this lane ran pure JAX whatever the caller
  asked for. Routing mirrors the single-device lane exactly, including its two
  measured decisions: the far field stays pure JAX (both Pallas M2L shapes are
  slower) and every Pallas lane goes through its `custom_vjp` wrapper.
* `make_distributed_mutual_evaluator`, which partitions and compiles ONCE.
  `shard_map` wraps a fresh closure per call, so `jax.jit` sees a fresh cache
  key and `distributed_mutual_fmm` recompiles every time -- fine for one force,
  ruinous for the `n_sub + 1` evaluations a base step asks for. Measured on 2
  forced CPU devices at N = 128: build < 0.1 s, first evaluation 20.5 s, each
  later one ~8 s. `distributed_mutual_fmm` is now that plus one call, unchanged
  in behaviour.
* The readout is a `jnp` scatter and the "every real particle appears exactly
  once" check moved to the partition, where it belongs -- it depends only on the
  frozen gid layout. That is what makes a whole evaluation traceable, which is
  what lets nornax's `block_kdk_rollout` drive this lane. The overflow read is
  attempted rather than gated on `isinstance(..., Tracer)`, the same discipline
  `BlockStepFMM._validate_rung` uses and for the same reason: a concrete array
  closed over by a scan body is not a Tracer yet still cannot be read.
* `cross_far_pairs` on both result types, because a test of the cross far field
  is vacuous without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`DistributedBlockStepFMM` is track C's user-facing half: `BlockStepFMM`'s
contract, backed by the distributed mutual lane. It satisfies nornax's
`MutualForceModel` and `FusedMutualForceModel` structurally, without importing
nornax, so the dependency graph stays acyclic -- and is exported from
`jaccpot.__all__` so ODISSEO can import it directly.

`prepare()` freezes the partition, the padding layout, the tree bounds, the
capacities and the compiled program; `level_accelerations`, `total_accelerations`,
`boundary_kick`, `boundary_kick_at` and `advance_base_step` then run on live
inputs against them.

What it freezes is NOT the topology, and the docstring says so rather than
implying otherwise. On one device `prepare` builds the accepted pair lists and
every boundary reuses them; here the per-device tree is rebuilt inside the mapped
program from the positions handed in, so successive boundaries see trees built
from their own positions. That is a finer rebuild cadence, not a coarser one, and
nothing about legality changes -- every evaluation is internally self-consistent,
so its levels partition its own pairs and each level's momentum cancels exactly.
The consequence worth stating is the other way round: `prepare` does not have to
be called once per base step, only when the partition should change.

`traced_boundary_weights = True` is declared explicitly rather than left to
nornax's signature probe. The case is stronger on this lane than on one device:
each boundary kick is a whole distributed program -- tree build, cross walk, halo
exchange, reverse halo -- and under an outer `jit` or `lax.scan` over base steps
the cached executable is inlined anyway, so unrolling would put `2**k_max` copies
of it in one graph.

Two deliberate differences from the single-device class:

* It RAISES on a starved capacity instead of reporting it. An overflow drops a
  canonical pair, which drops both its halves, so the global momentum sum stays
  exact and no norm on the result reveals it. Reported is not good enough for a
  force an integrator will step with. The check is skipped under trace, where an
  exception is not available -- so a traced driver must evaluate once eagerly
  first.
* There is no `rebuild_state` and no `advance_base_step(scan_boundaries=True)`.
  Every method is eager at its own level; the boundary loop is always the
  unrolled Python one, which costs nothing here because the mapped program is
  compiled once and every boundary reuses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cannot see

The acceptance criteria for track C, and the reason there are four of them rather
than one.

Momentum is what the block-step scheme is DEFINED by, so it is the obvious
criterion -- and it is structurally blind to every way the level split can be
wrong. Any single symmetric scalar per pair conserves momentum exactly, whatever
that scalar is. Measured by injecting each fault into the cross FAR half and
reading three criteria off a 2-device run at N = 256, leaf 4,
theta = cross_theta = 0.5 (33 cross far pairs, 991 cross near pairs):

    fault                        partition   linearity   momentum
    none                          2.1e-16     2.9e-16     <4e-17
    far half left UNWEIGHTED      3.9e-02     2.0e-02     <4e-17
    far weight applied TWICE      2.1e-16     9.4e-03     <4e-17
    far ignores the REMOTE rung   2.1e-16     2.9e-16     <4e-17

So:

* `test_the_levels_partition_the_total[_with_both_far_fields_on]` -- one-hot
  weights must sum to the unweighted total. Catches a half left unweighted. It
  CANNOT catch squaring: `w**2 == w` for 0 and 1.
* `test_the_weighting_is_linear_so_it_cannot_have_been_applied_twice` and its
  far-field twin -- `a(u) + a(1 - u) == a(1)` at fractional rows, none of them 0
  or 1. This is the squaring detector, and it is a separate test for that reason.
* `test_the_cross_far_level_uses_the_REMOTE_endpoints_rung` -- the fourth fault
  gets past all three above, because a level assignment that is wrong yet
  CONSISTENT is still one scalar per pair. Two rung-uniform clumps, one per
  device, so every cross pair provably belongs to one level and clump A's level-0
  force is an exact direct sum. Injected as `f_level = node_rung[f_local]` it
  moves 1.2e-3 against a 1e-13 bound while the other three stay at round-off.
  `cross_pairs == 0` is asserted separately because it establishes the premise --
  a partition that split a clump would leave nearby particles on opposite
  devices, and they would refine to near pairs.
* Per-level momentum, on a GLOBAL sum, with `cross_far_pairs > 0` asserted so it
  is not the exact lane wearing a different configuration.

Also: one-hot against an exact direct-sum oracle at theta = cross_theta = 0,
where the whole force is a direct sum and the oracle exists; the guards on the
way in (a weight table with no rung, a rung above `k_max`, a table disagreeing
with `k_max`, an unknown backend); Pallas parity and a WEIGHTED Pallas test,
since `level_weights[k]` is exactly the lowering that broke before and the
throughput benchmark runs unweighted.

`tests/unit/mutual/test_distributed_node_rungs.py` checks `_node_rungs` against a
loop oracle and against `force._cell_rungs` on a real tree -- the two lanes must
split the far field the same way or a system's levels stop partitioning it the
moment it is spread across devices. Its vacuity guards earned their place: a free
random rung draw at leaf 8 with three levels puts a level-2 particle in every
leaf, so every cell rung is 2 and the comparison would hold for a function that
ignored its inputs.

`tests/integration/test_mutual_distributed_nornax.py` is the C3 criterion:
nornax's own scanned `block_kdk_rollout` over >= 2 devices, multi-rung, with
momentum (exact, 1.8e-17) and energy (loose, and it fails independently -- an
unstable rollout conserves momentum perfectly). Plus jaccpot's own
`advance_base_step` against nornax's, since they are separate implementations of
the same palindrome.

The rung fixtures share one evaluator rather than calling
`distributed_mutual_fmm` per weighting. That is a 4x test-time win, and it is
also the claim under test: only shapes are static, so ONE compiled program must
serve every weight row and every rung assignment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things the design document did not cover, now that the distributed lane
does them: which channel each rung travels on and why the obvious one is wrong
(the frontier is all_gather-ed, so a per-particle payload there is O(N_total));
why the far half's weight is applied once on the evaluating side, and why that is
legal (everything downstream of an expansion is linear in its coefficients); the
backend routing and the two measured decisions it mirrors; and the
partition-frozen-but-tree-rebuilt distinction, with the measured compile numbers.

The table of injected faults is the part worth keeping. Three of them leave the
per-level momentum residual below 4e-17, so a residual at 1e-17 is not evidence
the levels are right -- which is the opposite of how this lane's own tests read
before track C.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The near half's rung rides `import_near_halo(payload_sorted=...)`, which lands in
TobiBu/yggdrax#53. Against an older yggdrax that is a `TypeError` raised deep
inside a `shard_map` trace, on every weighted test -- so both modules would fail
rather than skip, which is the outcome this file's existing guard exists to
prevent and has been caught not doing once before.

`test_mutual_distributed.py`'s guard now checks the parameter on the halo import
as well as the two on the cross walk, keeping its own rule: check EVERY parameter
this module actually passes, and name the PR each one came from, because "needs a
newer yggdrax" is not actionable.

`test_mutual_distributed_nornax.py` gets its own rather than inheriting one. It
drives the weighted lane exclusively, so every test in it depends on the newest
name; only that one is checked, since the three landed in order and the older two
are a prerequisite of this lane existing at all.

Verified by running both modules against the OLD yggdrax checkout, which lacks
the parameter: 2 skipped, each naming payload_sorted and #53. A guard that has
not been seen to fire is decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…as vacuity

Two independent ways `test-distributed-mutual` went red on this branch, one of
which was hiding the other.

**Exit 5.** The module-level guard for `payload_sorted` fired -- CI installs
yggdrax from its default branch, which does not have it until TobiBu/yggdrax#53
-- so the whole file skipped, and in a job that runs ONE file that is pytest's
exit 5, "no tests were collected". Under `set -o pipefail` that fails the pytest
step, so the NEXT step, the one that exists to say "the suite was skipped, not
run" with an actionable message, never ran. The step now tolerates exit 5
specifically and still fails on everything else.

**The requirement was too wide.** `payload_sorted` carries a cross-domain near
pair's REMOTE rung, so only the rung-weighted path needs it -- but it was passed
as `payload_sorted=None` on the unweighted path too, which still requires the
parameter to exist. Passed conditionally instead, so the UNWEIGHTED lane runs
against the published yggdrax and the guard becomes per-test. Against the old
checkout the file now runs its pre-existing criteria and skips only the weighted
ones, rather than skipping wholesale and taking every one of them down with it --
which is the vacuous-green state this job was added to prevent, arrived at from
the other direction.

**And a skip is no longer read as vacuity.** Every skip in this file used to be a
whole-module one, an old yggdrax or fewer than two devices, so "any skip" and
"nothing ran" were the same thing and `^SKIPPED` was exact. They are not the same
any more: the file now carries per-test skips that are CORRECT on a CPU runner --
a kernel needing sm_80, and the level-weighted path above. Left as it was, this
job would have gone red again the moment yggdrax#53 merged, over a test that
could not have run anywhere on this box. It now matches nothing-collected or one
of the two whole-module guards firing, both of whose reasons say "the distributed
mutual force needs".

That is a deliberate loosening and worth stating: a future change that skipped
most of the suite for some other reason would not be caught, where `^SKIPPED`
would have caught it. Table-tested the five cases that matter -- old yggdrax,
single device, no tests ran, an sm_80 skip alone, and today's mixed state -- and
the first three fail while the last two pass.

Verified: 68 passed, 1 skipped (the sm_80 case, green on 2x A100) against the new
yggdrax on 2 forced CPU devices; 4 passed, 1 skipped on the fast subset against
the OLD yggdrax, where the previous guard gave 0 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TobiBu

TobiBu commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

Update: CI on this branch, and a scope fix it forced

test-distributed-mutual went red, in two independent ways — the first was hiding
the second.

Exit 5. The module-level guard for payload_sorted fired (CI installs yggdrax from
its default branch, which lacks it until #53), the whole file skipped, and in a job that
runs one file that is pytest's exit 5, "no tests were collected". Under set -o pipefail that failed the pytest step, so the next step — the one that exists to say
"the suite was skipped, not run" with an actionable message — never ran. It now tolerates
exit 5 specifically and still fails on every other code.

The requirement was too wide, and that is the substantive fix. payload_sorted
carries a cross-domain near pair's remote rung, so only the rung-weighted path needs
it — but it was passed as payload_sorted=None on the unweighted path too, which still
requires the parameter to exist. It is now passed conditionally, so the unweighted
lane runs against the published yggdrax and the guard becomes per-test. Measured against
the old checkout: the file now runs its pre-existing criteria and skips only the
weighted ones (4 passed / 1 skipped on the fast subset) instead of skipping wholesale at
0 passed — which was the vacuous-green state this job was added to prevent, reached from
the other direction.

A skip is no longer read as vacuity. Every skip in this file used to be a
whole-module one (old yggdrax, or < 2 devices), so "any skip" and "nothing ran" were
the same thing and ^SKIPPED was exact. They are not any more: the file now carries
per-test skips that are correct on a CPU runner — the sm_80-gated Pallas lowering,
and the weighted path above. Left alone, this job would have gone red again the moment
#53 merged, over a test that cannot run anywhere on a CPU box. The check now matches
nothing-collected, or one of the two whole-module guards firing.

That is a deliberate loosening, and worth flagging rather than burying: a future change
that skipped most of the suite for some other reason would not be caught, where
^SKIPPED would have caught it. Table-tested the five cases that matter:

log state verdict
old yggdrax, whole module skipped fails ✔
single device fails ✔
no tests ran fails ✔
sm_80 skip alone (post-#53) passes ✔
today's mixed state (unweighted runs, weighted skips) passes ✔

Re-verified after the change

suite where result
test_mutual_distributed.py + _nornax.py + tests/unit/mutual/ 2 forced CPU devices, new yggdrax 68 passed, 1 skipped (20m29s)
the fast subset 2 forced CPU devices, old yggdrax 4 passed, 1 skipped (was 0 passed)
pre-commit run --all-files green

Two caveats on CI coverage, neither new

  • .github/workflows/ci.yml is not owned by any track in the 2026-08 plan. This PR is
    what made those paths reachable, so I changed it — but it is the most
    reviewable-away part of this branch.
  • test_mutual_distributed_nornax.py cannot run in CI at all: nornax is not installed
    there, and the shard it lands in has one device. That matches the existing standing of
    test_mutual_fmm_nornax.py and is not a regression, but it does mean the C3 criterion
    is verified locally only (12 passed, 2 forced CPU devices) — not by CI.

…export

Two repo-wide guards this branch tripped, both correctly, and both in
`tests/unit` rather than anywhere near the distributed lane -- which stayed green
throughout.

`test_public_api_surface.py::test_all_exports_are_frozen`: adding
`DistributedBlockStepFMM` to `jaccpot.__all__` is a public-API change, and that
test's whole purpose is to make one impossible to do silently. Its docstring
prescribes the fix -- update the frozen set in the SAME change, so the contract
gets a conscious review -- so `EXPECTED_ALL` and the class-kind list gain the
name. ODISSEO imports from this surface, which is why it is frozen at all.

`test_type_annotation_guard.py`: `DistributedMutualEvaluator.layout`,
`.rung_layout` and the `build` closure were unannotated, so runtime type-checking
would have had holes exactly where a caller hands in positions, masses and rungs.
Annotated, along with `_program`, `__call__` and `__init__` on the same class
while in there.

Neither was caught locally because the verification runs were targeted subsets --
`tests/unit/mutual/`, the subdirectory the new unit test lives in -- instead of
the plan's `pytest -q`. The substitution was made because each distributed
compile costs ~25 s on a box at load ~70, and it was a bad trade: these two
guards run in 1.3 s together and exist precisely to catch a new public export and
a new unannotated callable, which is what this branch is.

Verified this time over the whole of `tests/unit` plus the golden
`tests/characterization`: exit 0, no failures, and characterization unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TobiBu
TobiBu merged commit ab5d031 into main Aug 28, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants