|
| 1 | +# Audit findings — `brainpy/dyn/{neurons,channels,ions}` (2026-06-19) |
| 2 | + |
| 3 | +Scope: `brainpy/dyn/neurons/{base,hh,lif}.py`, `brainpy/dyn/channels/*.py`, |
| 4 | +`brainpy/dyn/ions/{base,calcium,potassium,sodium}.py` (+ co-located `*_test.py`). |
| 5 | + |
| 6 | +Environment: brainpy 2.7.8, brainstate 0.5.1, brainunit 0.5.1, jax 0.10.2 (CPU). |
| 7 | + |
| 8 | +## Cross-check status (vs `dev/issues-found-20260618.md`) |
| 9 | + |
| 10 | +The neuron/channel entries from the prior audit are **already fixed in this worktree**: |
| 11 | + |
| 12 | +- **C-14** (HH/Markov channel gating NaN at voltage singularity): FIXED. A branch-safe |
| 13 | + `_exprel` helper is present and used in `sodium.py`, `sodium_compatible.py`, |
| 14 | + `potassium.py`, `potassium_compatible.py`, and `calcium.py` (`ICaHT_Re1993`). Verified |
| 15 | + finite value *and* finite gradient at the singular voltages |
| 16 | + (`IK_HH1952v2(1).f_p_alpha([-55.0]) == 0.1`, not NaN). Regression tests already exist |
| 17 | + in `channels/dyn_channels_fixes_test.py`. |
| 18 | +- **H-33** (`ions/base.py` `add_elem(k=v)` literal-keyword bug): FIXED |
| 19 | + (`ions/base.py:55` now `self.add_elem(**{k: v})`). Regression test exists. |
| 20 | +- **H-34** (`ExpIFRef*` silently dropping `noise=`): FIXED (`lif.py:1113-1116` guards on |
| 21 | + `self.noise` and uses `sdeint`). |
| 22 | +- **H-35** (`IzhikevichRef`/`GifRef` resetting state with grad-carrying `spike`): FIXED |
| 23 | + (`lif.py:3821-3831`, `4504-4522` now use `spike_no_grad` for every state reset; |
| 24 | + `AdExIFRef`/`AdQuaIFRef` likewise). |
| 25 | +- **M-17** (`PotassiumFixed` `E` default): already `-95.` and asserted by a regression test. |
| 26 | + |
| 27 | +Only one verified, still-present neuron/channel bug was found in scope (P8-H1, below), |
| 28 | +plus several Low items recorded for documentation. |
| 29 | + |
| 30 | +--- |
| 31 | + |
| 32 | +### P8-H1 — `CondNeuGroup` scales synaptic current by `1e-3/A`, double-/mis-scaling it [High] |
| 33 | +- File: `brainpy/dyn/neurons/hh.py:148-194` (`CondNeuGroupLTC.update` + `CondNeuGroup.update`/`derivative`) |
| 34 | +- Category: correctness / numerics |
| 35 | +- What: The conductance-based neuron converts the **external injected current** `x` |
| 36 | + (an absolute current, e.g. from `bp.inputs`, in nA) into a current *density* with the |
| 37 | + factor `x = x * (1e-3 / self.A)` inside `CondNeuGroupLTC.update`. Channel currents |
| 38 | + (`ch.current(V)`, already a density in µA/cm²) are correctly left unscaled in |
| 39 | + `derivative`. In the LTC class, **synaptic** currents (`sum_current_inputs`) are summed |
| 40 | + inside `derivative` and therefore also left unscaled (correct — they are densities). |
| 41 | + But `CondNeuGroup.update` (the non-LTC default class) folds `sum_current_inputs` into |
| 42 | + `x` *before* `super().update(x)` applies the `1e-3/A` factor, so the synaptic current |
| 43 | + is multiplied by `1e-3/A`. |
| 44 | +- Why it's a bug: For any `A != 1e-3` (the default is `A=1e-3`, which masks the bug), |
| 45 | + synaptic input to a `CondNeuGroup` is silently rescaled relative to channel currents and |
| 46 | + relative to the otherwise-identical `CondNeuGroupLTC`. With `A=1.0` the synaptic drive is |
| 47 | + attenuated by 1000×. |
| 48 | +- Repro (runtime, reproduced): |
| 49 | + ```python |
| 50 | + A = 1.0 |
| 51 | + for cls in [bp.dyn.CondNeuGroupLTC, bp.dyn.CondNeuGroup]: |
| 52 | + neu = cls(1, A=A, IL=bp.dyn.IL(1, g_max=0.0, E=-70.)) |
| 53 | + neu.reset_state() |
| 54 | + neu.add_inp_fun('syn', lambda V, init=0.: init + 10.0) # constant synaptic density |
| 55 | + bp.share.save(t=0., dt=0.1, i=0) |
| 56 | + V0 = float(neu.V.value[0]); neu.update(0.); V1 = float(neu.V.value[0]) |
| 57 | + print(cls.__name__, V1 - V0) # expected dt*syn/C = 1.0 |
| 58 | + # CondNeuGroupLTC -> 1.0 (correct) |
| 59 | + # CondNeuGroup -> 9.99e-4 (wrong: scaled by 1e-3/A) |
| 60 | + ``` |
| 61 | +- Fix: In `CondNeuGroup`, evaluate the synaptic current at the pre-step `self.V` (preserving |
| 62 | + the non-LTC "evaluate synapses at the fixed membrane potential" semantics) but inject it |
| 63 | + into the derivative as an unscaled **density**, exactly like channel currents — instead of |
| 64 | + folding it into the pre-scaled external `x`. Implemented by stashing the precomputed |
| 65 | + synaptic density on the instance and adding it inside `CondNeuGroup.derivative`. |
| 66 | +- Tests: `channels`/`neurons` — `hh_test.py::test_condneugroup_synaptic_current_scaling` |
| 67 | + (new) asserts `CondNeuGroup` and `CondNeuGroupLTC` give identical synaptic drive for |
| 68 | + `A != 1e-3`. |
| 69 | +- Status: fixed |
| 70 | + |
| 71 | +--- |
| 72 | + |
| 73 | +### P8-L1 — `ICaN_IS2008.derivative` variable names swapped (`phi_p` holds steady-state, `p_inf` holds tau) [Low] |
| 74 | +- File: `brainpy/dyn/channels/calcium.py:332-335` |
| 75 | +- Category: style |
| 76 | +- What: In `derivative`, the local `phi_p` actually holds the steady-state activation |
| 77 | + `p_inf(V)` and `p_inf` holds the time constant `tau_p(V)`; the returned value |
| 78 | + `self.phi * (phi_p - p) / p_inf` is numerically correct but the names are inverted and |
| 79 | + confusing. |
| 80 | +- Why it's a bug: Readability/maintenance hazard only; math is correct. |
| 81 | +- Repro: static. |
| 82 | +- Fix: recorded only. |
| 83 | +- Tests: none. |
| 84 | +- Status: recorded-only |
| 85 | + |
| 86 | +--- |
| 87 | + |
| 88 | +### P8-L2 — Pervasive non-rendering NumPy-doc section markers (`Parameters::`, `References::`, `See Also::`) [Low] |
| 89 | +- File: `brainpy/dyn/channels/*.py`, `brainpy/dyn/ions/*.py`, `brainpy/dyn/neurons/hh.py` |
| 90 | + (e.g. `sodium.py:88`, `potassium.py:95`, `calcium.py:92`, many more) |
| 91 | +- Category: style/docs |
| 92 | +- What: Docstrings use `Parameters::`/`References::`/`See Also::` (literal double-colon |
| 93 | + RST literal-block markers) instead of the NumPy-doc underline form. These will not render |
| 94 | + as proper sections in Sphinx/numpydoc and violate the project docstring style. |
| 95 | +- Why it's a bug: Documentation only; no runtime effect. |
| 96 | +- Repro: static. |
| 97 | +- Fix: recorded only (out of risk budget; mechanical but very large surface). |
| 98 | +- Tests: none. |
| 99 | +- Status: recorded-only |
| 100 | + |
| 101 | +--- |
| 102 | + |
| 103 | +### P8-L3 — `IAHP_De1994.reset_state` has a dead/duplicate assignment to `self.p` [Low] |
| 104 | +- File: `brainpy/dyn/channels/potassium_calcium_compatible.py:133` |
| 105 | +- Category: style / dead code |
| 106 | +- What: `self.p[:] = C2 / C3` is executed and then immediately overwritten by |
| 107 | + `self.p.value = bm.broadcast_to(C2 / C3, size)` at the end of the method. The first |
| 108 | + in-place assignment is redundant (and would also fail for a batched `C2/C3` whose shape |
| 109 | + differs from the unbatched `self.p`, though in practice `reset_state` is called before |
| 110 | + batching expansion). |
| 111 | +- Why it's a bug: Dead code / minor fragility; behavior currently correct. |
| 112 | +- Repro: static. |
| 113 | +- Fix: recorded only. |
| 114 | +- Tests: none. |
| 115 | +- Status: recorded-only |
| 116 | + |
| 117 | +--- |
| 118 | + |
| 119 | +### P8-L4 — `CalciumDyna.reset_state` passes `batch_size` as the `mode` positional of `variable(...)` [Low] |
| 120 | +- File: `brainpy/dyn/ions/calcium.py:133` |
| 121 | +- Category: edge/api |
| 122 | +- What: `variable(self._C_initializer, batch_size, self.varshape)` passes `batch_size` |
| 123 | + (an `int`/`None`/`Mode`) into the second positional parameter of `brainpy.initialize.variable`, |
| 124 | + whose signature is `variable(data, batch_or_mode, sizes, ...)`. This happens to work for the |
| 125 | + tested `int`/`None`/`Mode` values that `batch_or_mode` accepts, but is fragile and easy to |
| 126 | + misread. |
| 127 | +- Why it's a bug: Works for current call sites; latent fragility only. |
| 128 | +- Repro: static. |
| 129 | +- Fix: recorded only. |
| 130 | +- Tests: none. |
| 131 | +- Status: recorded-only |
0 commit comments