Skip to content

Commit d3f7e76

Browse files
committed
fix(dyn/synapses): DualExp equal-tau handling and STP per-neuron reset
- DualExpon raised ZeroDivisionError / produced NaN when tau_rise == tau_decay; compute the normalization with the e/tau limit element-wise (High) - DualExponV2 silently output zeros/NaN for equal taus; raise a clear ValueError in the auto-normalizer pointing to bp.dyn.Alpha (High) - STP.reset_state crashed for a per-neuron array U (Variable.fill_ needs a scalar); broadcast U instead (High) Findings recorded in docs/issues-found-20260619-dyn-synapses.md
1 parent 7a629ee commit d3f7e76

3 files changed

Lines changed: 280 additions & 3 deletions

File tree

brainpy/dyn/synapses/abstract_models.py

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,57 @@ def return_info(self):
159159
def _format_dual_exp_A(self, A):
160160
A = parameter(A, sizes=self.varshape, allow_none=True, sharding=self.sharding)
161161
if A is None:
162+
# The peak normalizer ``A = tau_decay / (tau_decay - tau_rise) * ...`` divides
163+
# by zero when ``tau_rise == tau_decay``. For ``DualExponV2`` (the only consumer
164+
# of this auto-normalizer that stores ``A`` directly and returns
165+
# ``A * (g_decay - g_rise)``) the two gates collapse to identical trajectories,
166+
# so ``g_decay - g_rise`` is identically zero and no finite ``A`` can recover a
167+
# non-zero waveform. Fail loudly with an actionable message rather than emitting
168+
# a ZeroDivisionError / silent NaN. ``DualExpon`` does not reach this branch for
169+
# the equal-tau case: it computes its ``a`` coefficient directly (see below).
170+
if bm.any(self.tau_rise == self.tau_decay):
171+
raise ValueError(
172+
'The dual-exponential peak normalizer "A" is undefined when '
173+
'"tau_rise == tau_decay" (the rise and decay gates collapse to the '
174+
'same trajectory). Use brainpy.dyn.Alpha for a single-time-constant '
175+
'alpha synapse, or pass an explicit "A".'
176+
)
162177
A = (self.tau_decay / (self.tau_decay - self.tau_rise) *
163178
bm.float_power(self.tau_rise / self.tau_decay, self.tau_rise / (self.tau_rise - self.tau_decay)))
164179
return A
165180

166181

182+
def _dual_exp_a(self, A):
183+
r"""Compute the input-scaling coefficient ``a`` for :class:`DualExpon`.
184+
185+
The conductance jump per spike is ``a``. With the auto peak normalizer
186+
(``A is None``) the closed form simplifies to
187+
188+
.. math::
189+
190+
a = \frac{1}{\tau_{rise}}
191+
\left(\frac{\tau_{rise}}{\tau_{decay}}\right)^{\tau_{rise}/(\tau_{rise}-\tau_{decay})}
192+
193+
which is finite even when :math:`\tau_{rise} = \tau_{decay}` (the
194+
dual-exponential degenerates to the normalized alpha function). In that limit
195+
L'Hôpital gives :math:`a = e/\tau`, evaluated element-wise so heterogeneous
196+
time constants are supported. An explicitly supplied ``A`` is honoured as
197+
``a = (tau_decay - tau_rise) / (tau_rise * tau_decay) * A``.
198+
"""
199+
A = parameter(A, sizes=self.varshape, allow_none=True, sharding=self.sharding)
200+
if A is not None:
201+
return (self.tau_decay - self.tau_rise) / self.tau_rise / self.tau_decay * A
202+
equal = self.tau_rise == self.tau_decay
203+
ratio = self.tau_rise / self.tau_decay
204+
# ``where(equal, ...)`` on the exponent avoids a 0/0 in the unused branch so the
205+
# gradient/value stays finite; the equal-tau entries take the L'Hôpital value e/tau.
206+
exponent = self.tau_rise / bm.where(equal, bm.ones_like(self.tau_decay),
207+
self.tau_rise - self.tau_decay)
208+
a_general = bm.float_power(ratio, exponent) / self.tau_rise
209+
a_limit = bm.exp(bm.ones_like(self.tau_rise)) / self.tau_rise
210+
return bm.where(equal, a_limit, a_general)
211+
212+
167213
class DualExpon(SynDyn):
168214
r"""Dual exponential synapse model.
169215
@@ -262,8 +308,11 @@ def __init__(
262308
# parameters
263309
self.tau_rise = self.init_param(tau_rise)
264310
self.tau_decay = self.init_param(tau_decay)
265-
A = _format_dual_exp_A(self, A)
266-
self.a = (self.tau_decay - self.tau_rise) / self.tau_rise / self.tau_decay * A
311+
# Compute the conductance-jump coefficient ``a`` directly. This avoids the
312+
# ``(tau_decay - tau_rise)`` cancellation that produced a ZeroDivisionError /
313+
# NaN for equal time constants, and supports the alpha-function limit
314+
# ``tau_rise == tau_decay`` (a = e/tau) element-wise.
315+
self.a = _dual_exp_a(self, A)
267316

268317
# integrator
269318
self.integral = odeint(JointEq(self.dg, self.dh), method=method)
@@ -854,8 +903,11 @@ def __init__(
854903

855904
def reset_state(self, batch_or_mode=None, **kwargs):
856905
self.x = self.init_variable(bm.ones, batch_or_mode)
906+
# Initialise ``u`` to the release probability ``U`` by broadcasting rather than
907+
# ``Variable.fill_`` (which only accepts a scalar). This supports a per-neuron
908+
# array ``U`` and batched modes alike.
857909
self.u = self.init_variable(bm.ones, batch_or_mode)
858-
self.u.fill_(self.U)
910+
self.u.value = self.u.value * self.U
859911

860912
@property
861913
def derivative(self):

brainpy/dyn/synapses/abstract_models_test.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import unittest
1616

1717
import matplotlib.pyplot as plt
18+
import numpy as np
1819

1920
import brainpy as bp
2021
import brainpy.math as bm
@@ -99,3 +100,75 @@ def update(self):
99100
bp.visualize.line_plot(indices * bm.get_dt(), gs, legend='g', show=show)
100101

101102
plt.close('all')
103+
104+
105+
class TestDualExpEqualTau(unittest.TestCase):
106+
"""Regression tests for P9-H1 / P9-H2: ``tau_rise == tau_decay``."""
107+
108+
def test_equal_tau_no_crash(self):
109+
# P9-H1: scalar equal taus previously raised ZeroDivisionError at construction.
110+
syn = bp.dyn.DualExpon(2, tau_rise=10., tau_decay=10.)
111+
self.assertTrue(np.all(np.isfinite(np.asarray(syn.a))))
112+
# array taus with one equal pair previously yielded a NaN coefficient.
113+
syn2 = bp.dyn.DualExpon(2,
114+
tau_rise=bm.asarray([10., 5.]),
115+
tau_decay=bm.asarray([10., 50.]))
116+
self.assertTrue(np.all(np.isfinite(np.asarray(syn2.a))))
117+
118+
def test_equal_tau_matches_alpha(self):
119+
# P9-H1: the equal-tau dual-exponential is the normalized alpha function;
120+
# a single unit spike drives a response that peaks at ~1.0.
121+
bm.set(dt=0.05)
122+
123+
class Net(bp.DynSysGroup):
124+
def __init__(self, tau):
125+
super().__init__()
126+
self.inp = bp.dyn.SpikeTimeGroup(1, bm.zeros(1, dtype=int), bm.asarray([1.]))
127+
self.syn = bp.dyn.DualExpon(1, tau_rise=tau, tau_decay=tau)
128+
129+
def update(self):
130+
return self.syn(self.inp())
131+
132+
net = Net(10.)
133+
indices = bm.as_numpy(bm.arange(4000))
134+
gs = bm.for_loop(net.step_run, indices)
135+
peak = float(np.max(np.asarray(gs)))
136+
self.assertTrue(np.isfinite(peak))
137+
self.assertAlmostEqual(peak, 1.0, places=2)
138+
bm.set(dt=0.1)
139+
140+
def test_v2_equal_tau_raises(self):
141+
# P9-H2: DualExponV2 is structurally singular for equal taus; the auto
142+
# normalizer must raise a clear error rather than crash / output zeros.
143+
with self.assertRaises(ValueError):
144+
bp.dyn.DualExponV2(2, tau_rise=10., tau_decay=10.)
145+
146+
147+
class TestSTPReset(unittest.TestCase):
148+
"""Regression tests for P9-H3: per-neuron array ``U``."""
149+
150+
def test_array_U_reset(self):
151+
# P9-H3: array U previously crashed in reset_state via Variable.fill_.
152+
U = bm.asarray([0.1, 0.2, 0.3])
153+
syn = bp.dyn.STP(3, U=U)
154+
np.testing.assert_allclose(np.asarray(syn.u.value), np.asarray(U))
155+
np.testing.assert_allclose(np.asarray(syn.x.value), np.ones(3))
156+
157+
def test_array_U_run(self):
158+
# The synapse must also integrate without error for heterogeneous U.
159+
from brainpy.context import share
160+
bm.set(dt=0.1)
161+
syn = bp.dyn.STP(3, U=bm.asarray([0.1, 0.2, 0.3]))
162+
out = []
163+
for i in range(20):
164+
share.save(t=i * 0.1, dt=0.1, i=i)
165+
spk = bm.asarray([1., 0., 1.]) if i == 5 else bm.asarray([0., 0., 0.])
166+
out.append(np.asarray(syn.update(spk)))
167+
out = np.asarray(out)
168+
self.assertTrue(np.all(np.isfinite(out)))
169+
170+
def test_scalar_U_batched(self):
171+
# Scalar U with batching must keep working (no regression).
172+
syn = bp.dyn.STP(3, U=0.15, mode=bm.BatchingMode(4))
173+
self.assertEqual(syn.u.shape, (4, 3))
174+
np.testing.assert_allclose(np.asarray(syn.u.value), np.full((4, 3), 0.15))
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# P9 — dyn/synapses + dyn/projections audit (2026-06-19)
2+
3+
Branch: `fix/audit-20260619-dyn-synapses`
4+
Scope: `brainpy/dyn/synapses/{abstract_models,bio_models,delay_couplings}.py`,
5+
`brainpy/dyn/projections/{align_post,align_pre,base,conn,delta,inputs,plasticity,utils,vanilla}.py`
6+
(+ co-located `*_test.py`).
7+
8+
Note on prior audit (`dev/issues-found-20260618.md`): several previously-reported
9+
synapse/projection bugs are **already fixed in this tree** and were re-verified as
10+
not-present: C-06/H-39 (STP facilitation `u` uses decayed locals — see the
11+
documented comment + `u = u + pre_spike*U*(1-u); x = x - pre_spike*u*x`), C-17
12+
(PoissonInput uses `scale=sqrt(b*p)` std, not variance), C-18
13+
(`HalfProjAlignPost.update` reuses `current` instead of calling `comm` twice),
14+
C-19/H-41 (`STDP_Song2000` passes `W_min/W_max=None` through unchanged and uses
15+
`bm.as_jax` on traces), H-40 (`projections/base.py` now re-exports the real
16+
`Projection`/`SynConn`). These are noted for the record only.
17+
18+
---
19+
20+
### P9-H1 — DualExpon raises ZeroDivisionError / yields NaN when `tau_rise == tau_decay` [High]
21+
- File: brainpy/dyn/synapses/abstract_models.py:159-164,265-266
22+
- Category: numerics / edge / api-drift
23+
- What: `_format_dual_exp_A` computes the peak normalizer
24+
`A = tau_decay/(tau_decay - tau_rise) * (tau_rise/tau_decay)**(tau_rise/(tau_rise-tau_decay))`.
25+
When `tau_rise == tau_decay` the leading factor divides by zero. For Python-float
26+
taus this raises `ZeroDivisionError` at construction; for array taus it silently
27+
produces `inf`, and `DualExpon`'s `a = (tau_decay-tau_rise)/(tau_rise*tau_decay)*A`
28+
then becomes `0*inf = nan`.
29+
- Why it's a bug: equal rise/decay time constants is a legitimate, common request
30+
(the dual-exponential degenerates to the normalized alpha function). It is also the
31+
parameterization used by the `dynold` `AlphaCUBA/AlphaCOBA` compat classes (C-20),
32+
which route through `DualExpon`. A crash / silent NaN on a realistic default is wrong.
33+
- Repro: `bp.dyn.DualExpon(2, tau_rise=10., tau_decay=10.)` -> `ZeroDivisionError`;
34+
`bp.dyn.DualExpon(2, tau_rise=bm.asarray([10.,5.]), tau_decay=bm.asarray([10.,50.]))`
35+
-> `a = [nan, 0.258...]`.
36+
- Fix: compute `DualExpon.a` via the closed form `a = (1/tau_rise) *
37+
(tau_rise/tau_decay)**(tau_rise/(tau_rise-tau_decay))` (algebraically equal to the
38+
old expression but free of the `(tau_decay-tau_rise)` cancellation), and special-case
39+
the equal-tau limit element-wise to its L'Hôpital value `a = e/tau` (verified: a
40+
single-spike response then peaks at exactly 1.0). The `A is None` auto-normalizer path
41+
only; an explicitly supplied `A` is honoured unchanged.
42+
- Tests: `abstract_models_test.py::TestDualExpon::test_equal_tau_no_crash`,
43+
`::test_equal_tau_matches_alpha`
44+
- Status: fixed
45+
46+
### P9-H2 — DualExponV2 silently outputs all-zeros (or NaN) when `tau_rise == tau_decay` [High]
47+
- File: brainpy/dyn/synapses/abstract_models.py:159-164,413
48+
- Category: numerics / edge
49+
- What: `DualExponV2` stores `self.a = A` and returns `a * (g_decay - g_rise)`. With
50+
equal taus the two gates obey identical ODEs with identical inputs, so
51+
`g_decay - g_rise ≡ 0` for all time; the synapse is structurally singular. With the
52+
default `A=None` normalizer this additionally hits the same div-by-zero/`inf` as P9-H1,
53+
giving `inf*0 = nan`.
54+
- Why it's a bug: the model produces a silently-dead (identically zero) or NaN synapse
55+
for an innocuous parameter choice, with no diagnostic. Unlike `DualExpon`, no finite
56+
`A` can recover a non-zero waveform, so the only correct behaviour is a clear error.
57+
- Repro: `bp.dyn.DualExponV2(2, tau_rise=10., tau_decay=10.)` -> `ZeroDivisionError`;
58+
with an explicit `A=1.` the output is identically 0 over a full simulation.
59+
- Fix: in `_format_dual_exp_A`, when `A is None` and `tau_rise == tau_decay`, raise a
60+
clear `ValueError` telling the user the dual-exponential normalizer is undefined for
61+
equal time constants and to use `brainpy.dyn.Alpha` (single-tau alpha synapse) instead.
62+
(Only the V2/auto-normalizer path reaches this branch; `DualExpon` computes `a`
63+
directly per P9-H1 and never calls the helper for the equal-tau case.)
64+
- Tests: `abstract_models_test.py::TestDualExpon::test_v2_equal_tau_raises`
65+
- Status: fixed
66+
67+
### P9-H3 — STP.reset_state crashes for per-neuron (array) `U` [High]
68+
- File: brainpy/dyn/synapses/abstract_models.py:855-858
69+
- Category: correctness / edge
70+
- What: `reset_state` does `self.u = self.init_variable(bm.ones, ...)` then
71+
`self.u.fill_(self.U)`. `fill_` requires a scalar fill value (`shape == ()`), so when
72+
`U` is a per-neuron array the call raises
73+
`MathError: The shape of the fill value must be ()`.
74+
- Why it's a bug: heterogeneous release probability `U` across synapses is a standard,
75+
realistic short-term-plasticity configuration; the model crashes on construction.
76+
- Repro: `bp.dyn.STP(3, U=bm.asarray([0.1, 0.2, 0.3]))` -> `MathError`.
77+
- Fix: initialise `u` by broadcasting `U` into the (possibly batched) state:
78+
`self.u = self.init_variable(bm.ones, batch_or_mode); self.u.value = self.u.value * self.U`.
79+
Works for scalar `U`, array `U`, and batched modes.
80+
- Tests: `abstract_models_test.py::TestSTP::test_array_U_reset`,
81+
`::test_array_U_run`
82+
- Status: fixed
83+
84+
### P9-M1 — STP/STD discrete jumps assume binary `pre_spike`; graded inputs scaled wrongly [Medium]
85+
- File: brainpy/dyn/synapses/abstract_models.py:800-801,883-884
86+
- Category: numerics
87+
- What: the "simplified" updates `x = x - pre_spike*U*self.x` (STD) and
88+
`u = u + pre_spike*U*(1-u); x = x - pre_spike*u*x` (STP) reproduce the original
89+
`bm.where(pre_spike, ...)` exactly only for `pre_spike in {0,1}`. For graded
90+
(non-binary) presynaptic signals the depression/facilitation magnitude is scaled by
91+
the graded value, which is not the documented Tsodyks–Markram jump.
92+
- Why it's a bug: callers feeding graded "spikes" get a different (linearly scaled)
93+
release, with no warning. (Matches prior-audit M-22.)
94+
- Repro: static — compare `bm.where(pre>0, x-U*x, x)` vs `x-pre*U*x` for `pre=0.5`.
95+
- Fix: recorded only. The simplified form is a defensible generalization, is faithful to
96+
the historical `dynold` STD/STP convention for the binary case, and changing it risks
97+
altering long-standing numeric behaviour. Documenting/asserting binary input is a
98+
cross-cutting API decision left to maintainers.
99+
- Tests: none
100+
- Status: recorded-only
101+
102+
### P9-M2 — STD applies depression jump to the pre-decay state, inconsistent with the STP fix [Medium]
103+
- File: brainpy/dyn/synapses/abstract_models.py:795-801
104+
- Category: numerics
105+
- What: `STD.update` integrates `x -> x_decayed` but then applies the release jump using
106+
the **pre-decay** Variable: `self.x.value = x_decayed - pre_spike*U*self.x`. The
107+
companion `STP` model was deliberately changed (prior-audit H-39, documented comment)
108+
to apply jumps to the **decayed** local `x`. STD was left on the pre-decay form, so the
109+
two short-term-plasticity models now discretize the spike jump inconsistently (off by
110+
one decay step in the jump term).
111+
- Why it's a bug: an asymmetry/correctness smell; the decayed-local form is the cleaner
112+
discretization.
113+
- Repro: static.
114+
- Fix: recorded only. The pre-decay form matches the original commented code AND the
115+
`dynold` reference (`short_term_plasticity.py` STD), and the per-step difference is
116+
`O(dt/tau)` confined to the jump term. Changing it alters historical STD numerics for
117+
every user; flagged for a maintainer decision rather than fixed unilaterally.
118+
- Tests: none
119+
- Status: recorded-only
120+
121+
### P9-L1 — DelayCoupling docstrings reference a non-existent gain `g`; malformed `Parameters::` [Low]
122+
- File: brainpy/dyn/synapses/delay_couplings.py:131-163,234-256
123+
- Category: style
124+
- What: `DiffusiveCoupling`/`AdditiveCoupling` docstrings describe
125+
`coupling = g * (...)` but no `g` gain parameter exists (the gain is folded into
126+
`conn_mat`). They also use the literal-block `Parameters::` marker instead of a
127+
NumPy-doc underlined `Parameters` section (CLAUDE.md mandates NumPy-doc). Matches
128+
prior-audit L-09/L-14.
129+
- Why it's a bug: misleading docs / Sphinx rendering.
130+
- Fix: recorded only (Low).
131+
- Tests: none
132+
- Status: recorded-only
133+
134+
### P9-L2 — GABAa docstring lists wrong default alpha/beta [Low]
135+
- File: brainpy/dyn/synapses/bio_models.py:286-287
136+
- Category: style
137+
- What: the `Args` block says `alpha: Default 0.062` and `beta: Default 3.57`, but the
138+
constructor defaults are `alpha=0.53`, `beta=0.18`.
139+
- Why it's a bug: documentation does not match code.
140+
- Fix: recorded only (Low).
141+
- Tests: none
142+
- Status: recorded-only
143+
144+
### P9-L3 — SynConn.update raises with non-f-string message; conn_mat shape check on raw input [Low]
145+
- File: brainpy/dyn/projections/conn.py:119; delay_couplings.py:81
146+
- Category: style / edge
147+
- What: minor robustness/style items (e.g. `AdditiveCoupling.update`'s final
148+
`else: raise ValueError` has no message; `conn_mat.shape` is read before confirming
149+
the object exposes `.shape`).
150+
- Fix: recorded only (Low).
151+
- Tests: none
152+
- Status: recorded-only

0 commit comments

Comments
 (0)