Skip to content

Commit 564aed2

Browse files
authored
fix(math/object_transform): cond/ifelse, collectors, VariableView edge cases (#840)
fix(math/object_transform): honor documented inputs in cond/ifelse, collectors, VariableView - bm.cond crashed on constant (non-callable) branches despite docstring allowing ArrayType/float/int/bool; wrap constants into callables (Medium) - bm.ifelse crashed (len() on bool) when `conditions` was a scalar bool/array; normalize scalar to a 1-element list (Medium) - Collector.__sub__ raised a bare KeyError instead of the descriptive ValueError used by every other not-found path (Medium) - VariableView.value setter crashed on plain inputs and never unwrapped State / canonicalized numpy dtype; mirror the hardened Variable setter (Medium) Findings recorded in docs/issues-found-20260619-math-object-transform.md
1 parent 4f1fbb0 commit 564aed2

7 files changed

Lines changed: 308 additions & 9 deletions

File tree

brainpy/math/object_transform/collectors.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,15 @@ def __sub__(self, other: Union[Dict, Sequence]):
112112
if isinstance(key, str):
113113
keys_to_remove.append(key)
114114
else:
115-
keys_to_remove.extend(id_to_keys[id(key)])
115+
# Look the value up by identity. A value object that is not
116+
# present must raise the same descriptive ``ValueError`` as
117+
# the other "not found" paths below, rather than a bare
118+
# ``KeyError(id)`` from an unchecked dict access.
119+
matched = id_to_keys.get(id(key))
120+
if matched is None:
121+
raise ValueError(f'Cannot remove {key}, because we do not find it '
122+
f'in {self.keys()}.')
123+
keys_to_remove.extend(matched)
116124

117125
for key in set(keys_to_remove):
118126
if key in gather:

brainpy/math/object_transform/collectors_test.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,16 @@ def test_sub_with_list_missing_key_raises():
151151
c - ['nope']
152152

153153

154+
def test_sub_with_list_missing_value_raises():
155+
# P4-M3: removing a *value* object that is not present must raise the same
156+
# descriptive ValueError as the string-key path, not a bare KeyError(id).
157+
present = object()
158+
absent = object()
159+
c = Collector({'a': present})
160+
with pytest.raises(ValueError):
161+
c - [absent]
162+
163+
154164
def test_sub_rejects_bad_type():
155165
c = Collector({'a': 1})
156166
with pytest.raises(ValueError):

brainpy/math/object_transform/controls.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,22 @@ def cond(
150150
if not isinstance(operands, (tuple, list)):
151151
operands = (operands,)
152152
operands = _unwrap_state_operands(operands)
153+
154+
# ``true_fun``/``false_fun`` may be constants (array/number), per the
155+
# documented contract. Wrap any non-callable branch into a callable that
156+
# ignores ``*operands`` and returns the (unwrapped) constant, mirroring the
157+
# handling in ``ifelse``. Otherwise brainstate would try to *call* the
158+
# constant and raise ``TypeError: '<type>' object is not callable``.
159+
def _make_branch(branch):
160+
if callable(branch):
161+
return warp_to_no_state_input_output(branch)
162+
const = _unwrap_operand_leaf(branch)
163+
return warp_to_no_state_input_output(lambda *args: const)
164+
153165
return brainstate.transform.cond(
154166
pred,
155-
warp_to_no_state_input_output(true_fun),
156-
warp_to_no_state_input_output(false_fun),
167+
_make_branch(true_fun),
168+
_make_branch(false_fun),
157169
*operands
158170
)
159171

@@ -230,6 +242,14 @@ def make_callable(branch):
230242

231243
branches = [make_callable(branch) for branch in branches]
232244

245+
# A single condition may be passed as a bare scalar bool/array (the
246+
# docstring types ``conditions`` as ``bool, sequence of bool``). Normalise
247+
# it into a one-element list so it flows through the conversion below;
248+
# otherwise ``brainstate.transform.ifelse`` would call ``len()`` on the
249+
# scalar and raise ``TypeError: object ... has no len()``.
250+
if not isinstance(conditions, (list, tuple)):
251+
conditions = [conditions]
252+
233253
# Convert if-elif-else chain to mutually exclusive conditions
234254
if isinstance(conditions, (list, tuple)) and len(conditions) > 0:
235255
conditions = list(conditions)

brainpy/math/object_transform/controls_test.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from functools import partial
1818

1919
import jax
20+
import jax.numpy as jnp
2021
from absl.testing import parameterized
2122
from jax import vmap
2223

@@ -528,3 +529,49 @@ def run(a, b):
528529
run(0., 1.)
529530

530531
self.assertIn("cond_fun should not have any write states", str(cm.exception))
532+
533+
534+
class TestCondBranchTypes(parameterized.TestCase):
535+
"""Regression for P4-M1: ``bm.cond`` must accept non-callable (constant)
536+
branches, as advertised by its docstring (``callable, ArrayType, float,
537+
int, bool``)."""
538+
539+
def test_cond_with_constant_branches(self):
540+
# Scalar Python constants as branches.
541+
self.assertEqual(float(bm.cond(True, 1.0, 2.0)), 1.0)
542+
self.assertEqual(float(bm.cond(False, 1.0, 2.0)), 2.0)
543+
544+
def test_cond_with_array_branches(self):
545+
# Array constants as branches (unwrapped before forwarding).
546+
r_true = bm.cond(True, bm.asarray([1., 2.]), bm.asarray([3., 4.]))
547+
r_false = bm.cond(False, bm.asarray([1., 2.]), bm.asarray([3., 4.]))
548+
self.assertTrue(bm.allclose(r_true, bm.asarray([1., 2.])))
549+
self.assertTrue(bm.allclose(r_false, bm.asarray([3., 4.])))
550+
551+
def test_cond_callable_still_works(self):
552+
# Callable branches keep working (and may mutate Variable state).
553+
a = bm.Variable(bm.zeros(2))
554+
555+
def tf(op):
556+
a.value += op
557+
558+
def ff(op):
559+
a.value -= op
560+
561+
bm.cond(True, tf, ff, 5.0)
562+
self.assertTrue(bm.allclose(a.value, bm.asarray([5., 5.])))
563+
564+
565+
class TestIfElseScalarCondition(parameterized.TestCase):
566+
"""Regression for P4-M2: ``bm.ifelse`` must accept a scalar-bool
567+
``conditions`` argument, as advertised by its docstring."""
568+
569+
def test_ifelse_scalar_python_bool(self):
570+
self.assertEqual(int(bm.ifelse(conditions=True, branches=[lambda: 1, lambda: 2])), 1)
571+
self.assertEqual(int(bm.ifelse(conditions=False, branches=[lambda: 1, lambda: 2])), 2)
572+
573+
def test_ifelse_scalar_array_bool(self):
574+
r = bm.ifelse(conditions=jnp.asarray(True), branches=[lambda: 10, lambda: 20])
575+
self.assertEqual(int(r), 10)
576+
r = bm.ifelse(conditions=jnp.asarray(False), branches=[lambda: 10, lambda: 20])
577+
self.assertEqual(int(r), 20)

brainpy/math/object_transform/object_transform_fixes_test.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,41 @@ def test_variable_view_setter_shape_and_dtype_checks():
928928
view.value = jnp.zeros(3) # wrong shape
929929

930930

931+
# P4-M4: ``VariableView.value`` setter must accept the same inputs as
932+
# ``Variable.value`` (plain list / numpy / State), instead of crashing or
933+
# silently mismatching dtype.
934+
935+
def test_variable_view_setter_python_list_matches_variable():
936+
# A plain Python list is handled identically to ``Variable.value`` (both
937+
# raise a descriptive MathError rather than the previous opaque
938+
# ``AttributeError: 'list' object has no attribute 'shape'``).
939+
origin = bm.Variable(jnp.arange(5.))
940+
view = bm.VariableView(origin, slice(None, 2, None))
941+
parent_var = bm.Variable(jnp.arange(2.))
942+
with pytest.raises(MathError):
943+
parent_var.value = [10., 11.]
944+
with pytest.raises(MathError):
945+
view.value = [10., 11.]
946+
947+
948+
def test_variable_view_setter_canonicalizes_numpy_dtype():
949+
# A float64 numpy array assigned into a float32 view must be canonicalized,
950+
# not rejected with a dtype MathError.
951+
origin = bm.Variable(jnp.arange(5., dtype=jnp.float32))
952+
view = bm.VariableView(origin, slice(None, 2, None))
953+
view.value = np.array([7., 8.], dtype=np.float64)
954+
assert origin.value.dtype == jnp.float32
955+
assert bm.allclose(origin.value[:2], jnp.asarray([7., 8.]))
956+
957+
958+
def test_variable_view_setter_unwraps_state():
959+
origin = bm.Variable(jnp.arange(5.))
960+
view = bm.VariableView(origin, slice(None, 2, None))
961+
src = bm.Variable(jnp.asarray([20., 21.]))
962+
view.value = src
963+
assert bm.allclose(origin.value[:2], jnp.asarray([20., 21.]))
964+
965+
931966
# ===========================================================================
932967
# Additional coverage for base.py
933968
# ===========================================================================

brainpy/math/object_transform/variables.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -329,23 +329,37 @@ def value(self):
329329

330330
@value.setter
331331
def value(self, v):
332+
# Normalize/unwrap the incoming value *before* validating its
333+
# shape/dtype, mirroring the hardened ``Variable.value`` setter. Without
334+
# this a plain Python ``list``/scalar (no ``.shape``) raises an
335+
# ``AttributeError``, a ``brainstate.State`` is not unwrapped, and a
336+
# ``numpy`` array is never canonicalized to the view's dtype.
337+
if isinstance(v, brainstate.State):
338+
v = v.value
339+
if isinstance(v, Array):
340+
v = v.value
341+
elif isinstance(v, np.ndarray):
342+
v = jnp.asarray(v)
343+
332344
int_shape = self.shape
345+
ext_shape = jnp.shape(v)
333346
if self.batch_axis is None:
334-
ext_shape = v.shape
347+
pass
335348
else:
336-
ext_shape = v.shape[:self.batch_axis] + v.shape[self.batch_axis + 1:]
349+
ext_shape = ext_shape[:self.batch_axis] + ext_shape[self.batch_axis + 1:]
337350
int_shape = int_shape[:self.batch_axis] + int_shape[self.batch_axis + 1:]
338351
if ext_shape != int_shape:
339-
error = f"The shape of the original data is {self.shape}, while we got {v.shape}"
352+
error = f"The shape of the original data is {self.shape}, while we got {jnp.shape(v)}"
340353
if self.batch_axis is None:
341354
error += '. Do you forget to set "batch_axis" when initialize this variable?'
342355
else:
343356
error += f' with batch_axis={self.batch_axis}.'
344357
raise MathError(error)
345-
if v.dtype != self._value.dtype:
358+
ext_dtype = _get_dtype(v)
359+
if ext_dtype != self._value.dtype:
346360
raise MathError(f"The dtype of the original data is {self._value.dtype}, "
347-
f"while we got {v.dtype}.")
348-
self._value[self.index] = v.value if isinstance(v, Array) else v
361+
f"while we got {ext_dtype}.")
362+
self._value[self.index] = v
349363

350364

351365
@register_pytree_node_class
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# Audit 2026-06-19 — `brainpy/math/object_transform`
2+
3+
Reviewer: senior Python + JAX expert (P4 slice). Branch
4+
`fix/audit-20260619-math-object-transform`. JAX 0.10.2, brainstate 0.5.1.
5+
6+
## Context
7+
8+
A prior audit (`dev/issues-found-20260618.md`) already fixed the major
9+
Critical/High issues in this package (C-25 `VarDict.tree_unflatten`/`jax.util`,
10+
C-26 `Variable` pytree metadata loss, H-01 `cls_jit` negative argnums, H-02
11+
state-in-operands, H-03 zero-length pytree guard, H-04 `jit` `dyn_vars` kwargs,
12+
H-05 `to()`/`cpu()`, H-06 `Variable.value` setter ordering, H-08
13+
`register_implicit_vars` container flatten, H-09 `Variable.__hash__`, M-02
14+
`cls_jit` `donate_argnums`, M-05 `ifelse` `check_cond=False`, L-04/L-05/L-06).
15+
These were verified present and working in this worktree (all 222 in-scope tests
16+
green at baseline). This document records a *fresh* review; remaining findings
17+
are predominantly documented-contract / edge-case error-handling gaps.
18+
19+
---
20+
21+
### P4-M1 — `bm.cond` crashes on non-callable branches [Medium]
22+
- File: brainpy/math/object_transform/controls.py:96-158
23+
- Category: edge/error
24+
- What: The docstring types both `true_fun` and `false_fun` as
25+
``callable, ArrayType, float, int, bool``, i.e. a constant branch is a
26+
supported input. But `cond` forwards the branches straight to
27+
`warp_to_no_state_input_output(true_fun)` (which just `@wraps` them) and then
28+
to `brainstate.transform.cond`, which calls them. A constant branch is never
29+
wrapped into a callable, so `bm.cond(True, 1.0, 2.0)` raises
30+
``TypeError: 'float' object is not callable``. The sibling `ifelse` handles
31+
this correctly via its `make_callable` helper.
32+
- Why it's a bug: A documented call form crashes. Historical BrainPy `cond`
33+
accepted constant branches.
34+
- Repro: ``bm.cond(True, 1.0, 2.0)````TypeError: 'float' object is not callable``
35+
- Fix: wrap non-callable `true_fun`/`false_fun` into zero-arg callables before
36+
forwarding (mirroring `ifelse.make_callable`), unwrapping any `Array`/`State`
37+
constant to its raw value so brainstate accepts it as an operand-free branch.
38+
- Tests: `controls_test.py::TestCondBranchTypes` (3 cases)
39+
- Status: fixed
40+
41+
### P4-M2 — `bm.ifelse` crashes on a scalar-bool `conditions` [Medium]
42+
- File: brainpy/math/object_transform/controls.py:161-267
43+
- Category: edge/error
44+
- What: The docstring types `conditions` as ``bool, sequence of bool``. The
45+
mutually-exclusive-condition conversion is guarded by
46+
``isinstance(conditions, (list, tuple))``; a bare scalar bool falls straight
47+
through to `brainstate.transform.ifelse`, which immediately does
48+
``len(conditions)`` and raises ``TypeError: object of type 'bool' has no
49+
len()``.
50+
- Why it's a bug: A documented single-condition call form crashes.
51+
- Repro: ``bm.ifelse(conditions=True, branches=[lambda: 1, lambda: 2])``
52+
``TypeError: object of type 'bool' has no len()``
53+
- Fix: normalize a scalar (non-list/tuple) `conditions` into a one-element list
54+
before the conversion block. The existing ``len(branches) > len(conditions)``
55+
branch then appends the implicit ``else`` condition, giving the correct
56+
two-way dispatch.
57+
- Tests: `controls_test.py::TestIfElseScalarCondition` (2 cases)
58+
- Status: fixed
59+
60+
### P4-M3 — `Collector.__sub__` raises raw `KeyError` on a missing value operand [Medium]
61+
- File: brainpy/math/object_transform/collectors.py:102-122
62+
- Category: edge/error
63+
- What: When subtracting a list/tuple that contains a *value* object (not a
64+
string key) which is not present in the collector, the code does
65+
``id_to_keys[id(key)]`` without a membership check, raising a bare
66+
``KeyError(<int id>)``. Every other "not found" path in `__sub__` raises a
67+
descriptive ``ValueError`` (and the co-located test
68+
`test_sub_with_list_missing_key_raises` asserts ``ValueError`` for the string
69+
case), so this is an inconsistent / unhelpful failure mode.
70+
- Why it's a bug: Contract violation — the documented/observed behaviour for a
71+
missing removal target is `ValueError`, not a cryptic id-keyed `KeyError`.
72+
- Repro:
73+
```python
74+
c = Collector(); c['a'] = some_var
75+
c - [other_var_not_in_c] # -> KeyError(140...id)
76+
```
77+
- Fix: use ``id_to_keys.get(id(key))`` and raise the same descriptive
78+
``ValueError`` used elsewhere when the object is absent.
79+
- Tests: `collectors_test.py::test_sub_with_list_missing_value_raises`
80+
- Status: fixed
81+
82+
### P4-M4 — `VariableView.value` setter is non-robust and asymmetric with `Variable` [Medium]
83+
- File: brainpy/math/object_transform/variables.py:330-348
84+
- Category: edge/error
85+
- What: The setter accesses ``v.shape`` / ``v.dtype`` on the raw input *before*
86+
unwrapping, and only unwraps `Array` (not `brainstate.State`/`np.ndarray`).
87+
Consequences: ``view.value = [1., 2.]`` raises
88+
``AttributeError: 'list' object has no attribute 'shape'`` and a numpy array
89+
is never canonicalized to the view's dtype. The parent `Variable.value`
90+
setter was already hardened (H-06) to unwrap `State`/`Array`/`np.ndarray`
91+
first; the view setter was left behind, so the two diverge.
92+
- Why it's a bug: Assigning a plain list/number/State to a `VariableView`
93+
(a documented, public update path) crashes or silently mismatches dtype,
94+
unlike the equivalent assignment to a `Variable`.
95+
- Repro: ``bm.VariableView(bm.Variable(bm.arange(5.)), slice(0, 2)).value = [1., 2.]``
96+
``AttributeError``
97+
- Fix: unwrap `State`/`Array`/`np.ndarray` first (as the parent does), then use
98+
``jnp.shape``/`_get_dtype` for validation. This makes `VariableView` accept
99+
the same inputs as `Variable` (numpy canonicalization, `State` unwrap) and,
100+
for a plain Python list, fail with the *same* descriptive ``MathError`` as
101+
the parent rather than an opaque ``AttributeError`` (a bare list remains
102+
rejected for both, consistent with the parent — see P4-L1).
103+
- Tests: `object_transform_fixes_test.py::test_variable_view_setter_python_list_matches_variable`,
104+
`...::test_variable_view_setter_canonicalizes_numpy_dtype`,
105+
`...::test_variable_view_setter_unwraps_state`
106+
- Status: fixed
107+
108+
### P4-L1 — `Variable.value = <python list>` yields a confusing "object" dtype error [Low]
109+
- File: brainpy/math/object_transform/variables.py:142-170
110+
- Category: edge/error
111+
- What: A plain Python list is not unwrapped/`jnp.asarray`-ed, so the dtype
112+
check computes ``canonicalize_dtype(list)`` → object dtype and raises
113+
``MathError: ... while we got object`` instead of either accepting the list
114+
or giving a clear message. (Lists are not a documented input, hence Low.)
115+
- Why it's a bug: Misleading diagnostic for a near-miss usage.
116+
- Repro: ``bm.Variable(bm.arange(2.)).value = [1., 2.]``
117+
- Fix: recorded only.
118+
- Tests: none
119+
- Status: recorded-only
120+
121+
### P4-L2 — `Variable.tree_unflatten` invokes `record_state_init` on every unflatten [Low]
122+
- File: brainpy/math/object_transform/variables.py:199-214
123+
- Category: perf/correctness (latent)
124+
- What: `tree_unflatten` calls ``brainstate.State.__init__`` to rebuild
125+
bookkeeping. That runs ``source_info_util.current()`` (non-trivial) and
126+
``record_state_init(self)``, which appends the reconstructed state to every
127+
active ``TRACE_CONTEXT.new_state_catcher``. A `Variable` is reconstructed on
128+
*every* pytree round-trip (each jit/vmap/scan boundary, every `tree_map`).
129+
If such a round-trip happens inside a brainstate "new-state catcher" context
130+
(model-construction time), the rebuilt-but-not-actually-new state could be
131+
spuriously caught. Not reproducible through the normal brainstate transform
132+
paths (they close over states rather than passing Variables as pytree args),
133+
so left as Low.
134+
- Why it's a bug: Theoretical state-leak / minor per-unflatten cost.
135+
- Repro: static (no observable failure in normal usage; verified jit/tree_map
136+
round-trips do not leak).
137+
- Fix: recorded only. (Reverting to full ``Variable.__init__`` would be worse —
138+
it re-runs batch-axis validation + naming. A clean fix needs a brainstate
139+
"rehydrate without recording" entry point, which is out of scope.)
140+
- Tests: none
141+
- Status: recorded-only
142+
143+
### P4-L3 — auto name counter can collide with a manually supplied name [Low]
144+
- File: brainpy/math/object_transform/naming.py:68-74
145+
- Category: edge/error
146+
- What: ``get_unique_name`` hands out ``f'{type}{counter}'`` and bumps the
147+
counter, ignoring names already taken manually. Creating ``Foo(name='Foo1')``
148+
before the auto counter reaches 1 makes the next auto-named ``Foo()`` raise
149+
``UniqueNameError``. Long-standing historical BrainPy behaviour.
150+
- Why it's a bug: Surprising collision; mitigated by `clear_name_cache()`.
151+
- Repro: ``Foo(); Foo(name='Foo1'); Foo()````UniqueNameError``
152+
- Fix: recorded only (historical contract; would change naming semantics).
153+
- Tests: none
154+
- Status: recorded-only
155+
156+
---
157+
158+
## Cross-check vs `dev/issues-found-20260618.md`
159+
160+
All object_transform / variables / transforms entries from the prior audit were
161+
verified **already fixed** in this worktree and confirmed working:
162+
C-25, C-26, H-01, H-02, H-03, H-04, H-05, H-06, H-08, H-09, M-02, M-03 (docstring
163+
now says ``(final_carry, stacked_ys)``), M-04 (now documented), M-05, M-06 (now
164+
documented intentional carry-passthrough), L-04, L-05, L-06. No still-present
165+
verified bug from that list remained in scope.

0 commit comments

Comments
 (0)