|
| 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