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