Skip to content

Commit 4f1fbb0

Browse files
authored
fix(math): ShardedArray pytree flatten + remove_diag guard (#839)
fix(math): correct ShardedArray pytree flatten and remove_diag guard - ShardedArray pytree round-trip dropped _keep_sharding, raising AttributeError under every JAX transform (jit/vmap/scan/grad/tree_map); add tree_flatten/tree_unflatten carrying _keep_sharding in aux_data (High) - remove_diag raised an opaque broadcasting error on tall (m>n) matrices; add a clear ValueError guard (Medium) Findings recorded in docs/issues-found-20260619-math-core.md
1 parent 368e28e commit 4f1fbb0

4 files changed

Lines changed: 283 additions & 0 deletions

File tree

brainpy/math/math_core_fixes_test.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,3 +895,83 @@ def test_partition_with_axis_name_sequence():
895895
def test_keep_constraint_on_bp_array():
896896
out = sharding.keep_constraint(Array([1., 2., 3.]))
897897
np.testing.assert_allclose(np.asarray(out), [1., 2., 3.])
898+
899+
900+
# ===========================================================================
901+
# P2 audit (2026-06-19) regression tests
902+
# ===========================================================================
903+
904+
# --- ndarray.py : P2-H1 (ShardedArray pytree round-trip) -------------------
905+
906+
def test_shardedarray_pytree_round_trip_preserves_value_and_keep_sharding():
907+
"""P2-H1: ``ShardedArray`` reused the base ``Array.tree_unflatten`` which
908+
only set ``_value`` and never ``_keep_sharding``. Any pytree round-trip
909+
(``jit``/``vmap``/``scan``/``grad``/``tree_map``) then made the ``value``
910+
getter raise ``AttributeError: ... has no attribute '_keep_sharding'``.
911+
The flatten/unflatten pair must round-trip both attributes."""
912+
from jax.tree_util import tree_flatten, tree_unflatten
913+
914+
for keep in (True, False):
915+
sa = ShardedArray(jnp.arange(6.), keep_sharding=keep)
916+
flat, treedef = tree_flatten(sa)
917+
back = tree_unflatten(treedef, flat)
918+
assert isinstance(back, ShardedArray)
919+
# The getter must not raise (regression for the missing attribute).
920+
np.testing.assert_allclose(np.asarray(back.value), np.arange(6.))
921+
# ``keep_sharding`` must survive the round-trip.
922+
assert back._keep_sharding is keep
923+
924+
925+
def test_shardedarray_works_under_jit():
926+
"""P2-H1: a ``ShardedArray`` passed through ``jit`` (which pytree-flattens
927+
and unflattens its arguments) must not crash when its value is read."""
928+
929+
@jax.jit
930+
def f(x):
931+
return x.value + 1.
932+
933+
out = f(ShardedArray(jnp.arange(3.)))
934+
np.testing.assert_allclose(np.asarray(out), [1., 2., 3.])
935+
936+
937+
def test_shardedarray_works_under_vmap():
938+
"""P2-H1: the same fix is exercised by ``vmap``."""
939+
940+
@jax.vmap
941+
def g(x):
942+
return x.value * 2.
943+
944+
out = g(ShardedArray(jnp.arange(4.)))
945+
np.testing.assert_allclose(np.asarray(out), [0., 2., 4., 6.])
946+
947+
948+
# --- others.py : P2-M1 (remove_diag m > n) ---------------------------------
949+
950+
def test_remove_diag_square_and_wide():
951+
"""P2-M1: the working ``m <= n`` path is unchanged."""
952+
from brainpy.math.others import remove_diag
953+
954+
square = remove_diag(jnp.arange(9).reshape(3, 3))
955+
np.testing.assert_array_equal(np.asarray(square), [[1, 2], [3, 5], [6, 7]])
956+
957+
wide = remove_diag(jnp.arange(12).reshape(3, 4))
958+
np.testing.assert_array_equal(np.asarray(wide),
959+
[[1, 2, 3], [4, 6, 7], [8, 9, 11]])
960+
961+
962+
def test_remove_diag_tall_raises_clear_error():
963+
"""P2-M1: a tall matrix (m > n) has no well-defined ``(m, n-1)`` result; the
964+
old code crashed with an opaque broadcasting error. It must now raise a
965+
clear ``ValueError`` mentioning the shape constraint."""
966+
from brainpy.math.others import remove_diag
967+
968+
with pytest.raises(ValueError, match=r'm <= n'):
969+
remove_diag(jnp.arange(12).reshape(4, 3))
970+
971+
972+
def test_remove_diag_still_rejects_non_2d():
973+
"""P2-M1: the pre-existing ndim guard is preserved."""
974+
from brainpy.math.others import remove_diag
975+
976+
with pytest.raises(ValueError, match=r'2D matrix'):
977+
remove_diag(jnp.arange(8).reshape(2, 2, 2))

brainpy/math/ndarray.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,24 @@ def __init__(self, value, dtype: Any = None, *, keep_sharding: bool = True):
243243
super().__init__(value, dtype)
244244
self._keep_sharding = keep_sharding
245245

246+
def tree_flatten(self):
247+
# Carry ``_keep_sharding`` in ``aux_data`` so it survives a pytree
248+
# round-trip (``jit``/``vmap``/``scan``/``grad``). Flatten the *raw*
249+
# ``_value`` rather than the ``value`` property: the property inserts a
250+
# ``with_sharding_constraint``, which must not run during the abstract
251+
# flatten step (the leaf may be a tracer/``ShapeDtypeStruct``).
252+
return (self._value,), self._keep_sharding
253+
254+
@classmethod
255+
def tree_unflatten(cls, aux_data, flat_contents):
256+
# Reconstruct without ``__init__`` (the leaf may be abstract during
257+
# tracing) and restore ``_keep_sharding`` from ``aux_data``; otherwise
258+
# the ``value`` getter raises ``AttributeError`` after any transform.
259+
ins = object.__new__(cls)
260+
ins._value = flat_contents[0]
261+
ins._keep_sharding = True if aux_data is None else aux_data
262+
return ins
263+
246264
@property
247265
def value(self):
248266
"""The value stored in this array.

brainpy/math/others.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,18 @@ def remove_diag(arr):
9494
raise ValueError(f'Only support 2D matrix, while we got a {arr.ndim}D array.')
9595
arr = as_jax(arr)
9696
m, n = arr.shape
97+
# ``remove_diag`` drops the diagonal element ``[i, i]`` from every row, so it
98+
# only has a well-defined ``(m, n - 1)`` result when every row owns a
99+
# diagonal element, i.e. ``m <= n``. With ``m > n`` the rows ``i >= n`` have
100+
# no diagonal to remove and the off-diagonal element count
101+
# (``m * n - n``) no longer matches ``m * (n - 1)``; the old code crashed
102+
# with an opaque broadcasting/reshape error. Fail fast with a clear message.
103+
if m > n:
104+
raise ValueError(
105+
f'remove_diag requires the number of rows to not exceed the number '
106+
f'of columns (m <= n), so that every row has a diagonal element to '
107+
f'remove. But we got a matrix with shape {arr.shape}.'
108+
)
97109
# Static off-diagonal indices (computed with numpy so they are concrete
98110
# constants and the gather traces cleanly under jit/vmap).
99111
rows = np.repeat(np.arange(m), n - 1)
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
# BrainPy math-core fresh audit — 2026-06-19 (P2)
2+
3+
Scope: `brainpy/math/_utils.py`, `datatypes.py`, `defaults.py`, `environment.py`,
4+
`modes.py`, `ndarray.py`, `scales.py`, `sharding.py`, `others.py`, `remove_vmap.py`
5+
(+ co-located `*_test.py`).
6+
7+
Environment: jax 0.10.2, brainstate 0.5.1, brainunit 0.5.1, brainevent 0.1.0,
8+
braintools 0.1.10 (CPU-only). `import brainpy` works, so findings tagged
9+
`[verified]` were reproduced at runtime.
10+
11+
This is a fresh pass. The fixes recorded in `dev/issues-found-20260618.md`
12+
(C-10, M-07, M-08, M-09, M-10, H-10, H-11, H-12, H-14, H-15, L-02, L-03) are all
13+
present in the current tree and were re-verified as correct; they are **not**
14+
re-reported here. The findings below are new.
15+
16+
## Summary counts
17+
- Critical: 0
18+
- High: 1
19+
- Medium: 1
20+
- Low: 4
21+
- Fixed: 2 (the High + the Medium)
22+
- Recorded-only: 4 (all Low)
23+
24+
---
25+
26+
### P2-H1 — `ShardedArray` pytree round-trip drops `_keep_sharding``AttributeError` under every JAX transform [High]
27+
- File: brainpy/math/ndarray.py:228-288 (root cause: inherited `Array.tree_unflatten` at :110-119; `_keep_sharding` introduced at :242)
28+
- Category: correctness / api-drift
29+
- What: `ShardedArray` adds the slot `_keep_sharding` (set only in `__init__`) but
30+
reuses the base `Array.tree_flatten`/`tree_unflatten`. `tree_flatten` returns
31+
`aux_data=None` and `tree_unflatten` reconstructs via `object.__new__(cls)`
32+
setting only `_value`. So after any pytree round-trip the reconstructed
33+
`ShardedArray` has no `_keep_sharding` attribute, and its `value` getter
34+
(which reads `self._keep_sharding`) raises
35+
`AttributeError: 'ShardedArray' object has no attribute '_keep_sharding'`.
36+
- Why it's a bug: JAX flattens/unflattens pytree leaves on essentially every
37+
transform boundary (`jit`, `vmap`, `scan`/`for_loop`, `grad`, `tree_map`,
38+
`eval_shape`). `ShardedArray` is a registered pytree node and is the wrapper
39+
`brainpy.math.sharding._device_put` returns (so `partition`/`partition_by_*`/
40+
`device_mesh` all hand back `ShardedArray`s). Passing such an array into any
41+
jitted/vmapped function — the entire point of sharding — crashes. The
42+
`keep_sharding=False` option was also silently lost (reset to the default).
43+
- Repro (verified):
44+
```python
45+
import jax, jax.numpy as jnp
46+
from brainpy.math.ndarray import ShardedArray
47+
jax.jit(lambda x: x.value + 1.)(ShardedArray(jnp.arange(3.)))
48+
# AttributeError: 'ShardedArray' object has no attribute '_keep_sharding'
49+
```
50+
- Fix: Added `ShardedArray.tree_flatten` (returns `(self._value,), self._keep_sharding`
51+
— flattens the raw `_value` to avoid running `with_sharding_constraint` during
52+
the abstract flatten step) and `ShardedArray.tree_unflatten` (reconstructs
53+
`_value` and restores `_keep_sharding` from `aux_data`, defaulting to `True`).
54+
- Tests: `test_shardedarray_pytree_round_trip_preserves_value_and_keep_sharding`,
55+
`test_shardedarray_works_under_jit`, `test_shardedarray_works_under_vmap`
56+
(in `math_core_fixes_test.py`).
57+
- Status: fixed
58+
59+
---
60+
61+
### P2-M1 — `remove_diag` crashes with an opaque error on tall (m > n) matrices [Medium]
62+
- File: brainpy/math/others.py:80-102
63+
- Category: edge/error
64+
- What: The docstring claims support for any `(M, N)` matrix returning
65+
`(M, N-1)`, but the off-diagonal index construction is inconsistent for
66+
`m > n`: `rows = np.repeat(np.arange(m), n - 1)` yields `m*(n-1)` indices
67+
while `cols` is taken from `~np.eye(m, n)` which has `m*n - n` `True` entries.
68+
When `m > n` these counts differ and the advanced-index gather raises an
69+
opaque `ValueError: Incompatible shapes for broadcasting`.
70+
- Why it's a bug: `remove_diag` removes element `[i, i]` from each row, which is
71+
only well-defined when every row owns a diagonal element, i.e. `m <= n`. The
72+
historical implementation (boolean-mask + reshape) also failed for `m > n`,
73+
just at the reshape step — so this was never supported, but the new error
74+
message is misleading and hard to diagnose.
75+
- Repro (verified): `remove_diag(jnp.arange(12).reshape(4, 3))` → broadcasting
76+
`ValueError` referencing internal gather shapes.
77+
- Fix: Added an explicit guard that raises a clear `ValueError` (matching the
78+
existing `ndim` guard style) explaining the `m <= n` requirement, before the
79+
gather. The `m <= n` path is unchanged.
80+
- Tests: `test_remove_diag_square_and_wide`,
81+
`test_remove_diag_tall_raises_clear_error`, `test_remove_diag_still_rejects_non_2d`.
82+
- Status: fixed
83+
84+
---
85+
86+
### P2-L1 — `IdScaling._reject_overrides` raises a confusing truth-value error for array `bias`/`scale` [Low]
87+
- File: brainpy/math/scales.py:87-98
88+
- Category: edge/error
89+
- What: `_reject_overrides` does `if bias is not None and bias != 0.` / `scale != 1.`.
90+
When called with a non-scalar array `bias`/`scale`, `bias != 0.` is an array
91+
and the `and`/`if` coerces it to bool, raising
92+
`ValueError: The truth value of an array with more than one element is ambiguous`.
93+
- Why it's a bug: misleading error for an unusual-but-legal input. The intent is
94+
to reject non-default overrides; an array override should be rejected with the
95+
intended "IdScaling ignores bias/scale" message, not a numpy truthiness error.
96+
- Repro (verified): `IdScaling().offset_scaling(jnp.zeros(3), bias=jnp.zeros(3))`.
97+
- Fix: recorded only (Low; out of fix scope). Suggested: compare with
98+
`np.ndim(bias) == 0 and bias != 0.` or `np.any(np.asarray(bias) != 0.)`.
99+
- Tests: none
100+
- Status: recorded-only
101+
102+
---
103+
104+
### P2-L2 — `set()` does not validate `bp_object_as_pytree`, unlike `environment()` [Low]
105+
- File: brainpy/math/environment.py:354-442 (vs `environment.__init__` :217-219)
106+
- Category: edge/error / api-drift
107+
- What: `environment.set()` validates `dt`, `mode`, `x64`, `float_`, `int_`,
108+
`bool_`, `complex_`, `numpy_func_return` up front (M-07 fix) but never checks
109+
that `bp_object_as_pytree` is a `bool`. `environment.__init__` does assert it.
110+
So `bm.set(bp_object_as_pytree='nope')` silently stores a string.
111+
- Why it's a bug: minor API inconsistency; a bad value is stored and only
112+
surfaces later where the flag is consumed. Not silently-wrong numerics.
113+
- Repro (verified): `bm.set(bp_object_as_pytree='not a bool')` stores the string.
114+
- Fix: recorded only (Low). Suggested: add
115+
`if bp_object_as_pytree is not None: assert isinstance(bp_object_as_pytree, bool)`
116+
to the validation block.
117+
- Tests: none
118+
- Status: recorded-only
119+
120+
---
121+
122+
### P2-L3 — `keep_constraint` / `_keep_constraint` do not skip `SingleDeviceSharding` (inconsistent with M-09 fix) [Low]
123+
- File: brainpy/math/sharding.py:227-248
124+
- Category: perf / style
125+
- What: The M-09 fix made `ShardedArray.value` skip inserting
126+
`with_sharding_constraint` for `SingleDeviceSharding` (pure overhead on a
127+
single device). The standalone `keep_constraint`/`_keep_constraint` helpers
128+
still insert the constraint unconditionally. For symmetry they should apply
129+
the same guard.
130+
- Why it's a bug: only a consistency/perf nit — verified that on a single CPU
131+
device XLA elides the constraint to an empty jaxpr (`jax.make_jaxpr` shows no
132+
equations), so there is no real runtime cost in jax 0.10.2. Recorded for
133+
consistency, not correctness.
134+
- Repro: static / `jax.make_jaxpr(keep_constraint)(jnp.arange(3.))` → no eqns.
135+
- Fix: recorded only (Low). Suggested: mirror the `SingleDeviceSharding` check.
136+
- Tests: none
137+
- Status: recorded-only
138+
139+
---
140+
141+
### P2-L4 — `Scaling.transform` raises bare `ZeroDivisionError` on a degenerate `scaled_V_range` [Low]
142+
- File: brainpy/math/scales.py:29-48
143+
- Category: edge/error
144+
- What: `scale = (V_max - V_min) / (scaled_V_max - scaled_V_min)` divides by zero
145+
when `scaled_V_min == scaled_V_max`, surfacing as a bare `ZeroDivisionError`
146+
with no context.
147+
- Why it's a bug: invalid user input produces an unhelpful error. Low impact —
148+
the exception is already raised, just not descriptive.
149+
- Repro (verified): `Scaling.transform([0., 10.], scaled_V_range=(1., 1.))`.
150+
- Fix: recorded only (Low). Suggested: validate
151+
`scaled_V_max != scaled_V_min` with a clear message.
152+
- Tests: none
153+
- Status: recorded-only
154+
155+
---
156+
157+
## Re-verified as already-correct (prior 2026-06-18 fixes, no action)
158+
- `enable_x64()` / `disable_x64()` keep brainstate `precision` and JAX
159+
`jax_enable_x64` in sync (C-10) — verified: enable→`(64, True)`, disable→`(32, False)`.
160+
- `set()` validates before mutating (M-07).
161+
- `Mode` is hashable and usable in sets / dict keys (H-10).
162+
- `Array.device` is a property returning a `jax.Device`; `device_buffer`,
163+
`block_host_until_ready`, `block_until_ready`, `at` all work (H-11).
164+
- `Array(scalar)` stores an array, `.shape` works (H-12).
165+
- `_compatible_with_brainpy_array` returns `out` when `out=` is given (H-14).
166+
- `remove_diag` traces cleanly under `jit`/`vmap` for `m <= n` (H-15).
167+
- `ShardedArray.value` skips `with_sharding_constraint` on `SingleDeviceSharding` (M-09).
168+
- `get_sharding` warns on a full axis-name mismatch (M-10).
169+
- `remove_vmap` delegates to `brainstate.transform.unvmap`; global-reduction
170+
semantics documented and verified under `vmap`/`jit` (M-08).
171+
- `IdScaling` rejects non-default scalar `bias`/`scale` (L-02).
172+
- base `Array` vs `ShardedArray` value-setter policy documented (L-03).
173+
</content>

0 commit comments

Comments
 (0)