From 19817c7cb531ec79c83f34e76be291593c00dd7a Mon Sep 17 00:00:00 2001 From: chaoming Date: Fri, 19 Jun 2026 02:36:41 +0800 Subject: [PATCH] fix(analysis): fixed-point classification, nullcline selection, GD batch count, plot kwargs - 2D star vs degenerate-node stability classification was inverted and the STAR branch was unreachable dead code; correctly distinguish star (scalar*I) from defective node (Critical) - phase-plane select_candidates='nullclines' tested the fy condition twice, silently dropping all fx-nullcline candidate points; test fx OR fy (High) - slow_points num_opt_loops = int(num_opt/num_batch) was 0 when num_opt empty losses -> concatenate crash; use max(1, ceil(...)) (Medium) - streamplot used plot_style.get('linewidth') (not pop), passing linewidth twice -> TypeError; use pop (Medium) - set_markersize wrote to a typo local, leaving the module global stale (Medium) Findings recorded in docs/issues-found-20260619-analysis.md --- brainpy/analysis/highdim/slow_points.py | 5 +- .../highdim/slow_points_coverage_test.py | 13 ++ brainpy/analysis/lowdim/lowdim_phase_plane.py | 7 +- .../lowdim_phase_plane_coverage_test.py | 48 ++++- brainpy/analysis/plotstyle.py | 2 +- brainpy/analysis/plotstyle_test.py | 47 +++++ brainpy/analysis/stability.py | 19 +- brainpy/analysis/stability_coverage_test.py | 21 ++- brainpy/analysis/stability_test.py | 34 ++++ docs/issues-found-20260619-analysis.md | 170 ++++++++++++++++++ 10 files changed, 335 insertions(+), 31 deletions(-) create mode 100644 brainpy/analysis/plotstyle_test.py create mode 100644 docs/issues-found-20260619-analysis.md diff --git a/brainpy/analysis/highdim/slow_points.py b/brainpy/analysis/highdim/slow_points.py index 24cb5abdd..b1ec7f217 100644 --- a/brainpy/analysis/highdim/slow_points.py +++ b/brainpy/analysis/highdim/slow_points.py @@ -376,7 +376,10 @@ def batch_train(start_i, n_batch): print(f"Optimizing with {optimizer} to find fixed points:") opt_losses = [] do_stop = False - num_opt_loops = int(num_opt / num_batch) + # Always run at least one batch, even when ``num_opt < num_batch`` (where + # ``int(num_opt / num_batch)`` would otherwise be 0 and leave ``opt_losses`` + # empty, crashing the subsequent ``jnp.concatenate``). + num_opt_loops = max(1, int(np.ceil(num_opt / num_batch))) for oidx in range(num_opt_loops): if do_stop: break diff --git a/brainpy/analysis/highdim/slow_points_coverage_test.py b/brainpy/analysis/highdim/slow_points_coverage_test.py index 627a11c9f..3adc8730b 100644 --- a/brainpy/analysis/highdim/slow_points_coverage_test.py +++ b/brainpy/analysis/highdim/slow_points_coverage_test.py @@ -155,6 +155,19 @@ def step(x, scale): assert f.opt_losses.ndim == 1 +def test_gd_small_num_opt_runs_at_least_one_batch(): + """Regression for P13-M1: ``num_opt < num_batch`` previously gave + ``num_opt_loops = int(num_opt / num_batch) == 0``, leaving ``opt_losses`` + empty and crashing on ``jnp.concatenate``. At least one batch must run.""" + f = bp.analysis.SlowPointFinder(f_cell=_linear_step, f_type=CONTINUOUS, verbose=False) + rng = bm.random.RandomState(2) + # num_opt (50) < num_batch (100) -> would crash before the fix. + f.find_fps_with_gd_method(rng.random((4, 2)) * 0.2, num_opt=50, num_batch=100) + assert f.num_fps == 4 + assert f.opt_losses.ndim == 1 + assert f.opt_losses.shape[0] == 100 + + # --------------------------------------------------------------------------- # # full pipeline for a callable system # --------------------------------------------------------------------------- # diff --git a/brainpy/analysis/lowdim/lowdim_phase_plane.py b/brainpy/analysis/lowdim/lowdim_phase_plane.py index e8af35c0f..f0a1d45b2 100644 --- a/brainpy/analysis/lowdim/lowdim_phase_plane.py +++ b/brainpy/analysis/lowdim/lowdim_phase_plane.py @@ -226,7 +226,10 @@ def plot_vector_field(self, with_plot=True, with_return=False, elif plot_method == 'streamplot': if plot_style is None: plot_style = dict(arrowsize=1.2, density=1, color='thistle') - linewidth = plot_style.get('linewidth', None) + # Pop (not get) so a user-supplied ``linewidth`` is consumed and + # only passed once -- otherwise it is forwarded both explicitly + # and via ``**plot_style`` -> TypeError (multiple values). + linewidth = plot_style.pop('linewidth', None) if linewidth is None: if (not np.isnan(dx).any()) and (not np.isnan(dy).any()): min_width, max_width = 0.5, 5.5 @@ -326,7 +329,7 @@ def plot_fixed_point(self, with_plot=True, with_return=False, show=False, candidates = jnp.vstack(candidates) elif select_candidates == 'nullclines': candidates = [self.analyzed_results[key][0] for key in self.analyzed_results.keys() - if key.startswith(C.fy_nullcline_points) or key.startswith(C.fy_nullcline_points)] + if key.startswith(C.fx_nullcline_points) or key.startswith(C.fy_nullcline_points)] if len(candidates) == 0: raise errors.AnalyzerError(f'No nullcline points are found, please call ' f'".{self.plot_nullcline.__name__}()" first.') diff --git a/brainpy/analysis/lowdim/lowdim_phase_plane_coverage_test.py b/brainpy/analysis/lowdim/lowdim_phase_plane_coverage_test.py index 91261ab39..aaea1c203 100644 --- a/brainpy/analysis/lowdim/lowdim_phase_plane_coverage_test.py +++ b/brainpy/analysis/lowdim/lowdim_phase_plane_coverage_test.py @@ -111,17 +111,18 @@ def test_pp2d_quiver_and_return(): assert dx.shape == dy.shape -# NOTE (defect): passing ``linewidth`` inside ``plot_style`` to the -# ``streamplot`` branch crashes. The code reads ``plot_style.get('linewidth')`` -# but never pops it, so ``pyplot.streamplot(..., linewidth=linewidth, -# **plot_style)`` passes ``linewidth`` twice -> ``TypeError: got multiple values -# for keyword argument 'linewidth'`` (lowdim_phase_plane.py:235). -def test_pp2d_streamplot_custom_linewidth_is_buggy(): +# Regression for P13-M2: passing ``linewidth`` inside ``plot_style`` to the +# ``streamplot`` branch used to crash because the code read +# ``plot_style.get('linewidth')`` without popping it, so ``linewidth`` was +# forwarded twice (explicitly and via ``**plot_style``) -> +# ``TypeError: got multiple values for keyword argument 'linewidth'``. +# The fix pops the key, so a user-supplied ``linewidth`` is honoured exactly once. +def test_pp2d_streamplot_custom_linewidth(): pp = bp.analysis.PhasePlane2D(model=_fhn_2d(), target_vars={'V': [-3., 3.], 'w': [-1., 3.]}, resolutions=0.2) - with pytest.raises(TypeError): - pp.plot_vector_field(plot_method='streamplot', plot_style=dict(linewidth=1.0)) + # should no longer raise + pp.plot_vector_field(plot_method='streamplot', plot_style=dict(linewidth=1.0)) def test_pp2d_unknown_plot_method_raises(): @@ -170,6 +171,37 @@ def test_pp2d_fixed_point_after_nullcline(): assert fps is not None +def test_pp2d_nullclines_selector_uses_both_nullclines(): + """Regression for P13-H2: the ``select_candidates='nullclines'`` branch must + gather candidates from *both* the fx- and fy-nullcline point sets. The old + code tested ``startswith(fy_...)`` twice, so fx-nullcline points were dropped. + """ + import jax.numpy as jnp + from brainpy.analysis import constants as C + + pp = bp.analysis.PhasePlane2D(model=_fhn_2d(), + target_vars={'V': [-3., 3.], 'w': [-1., 3.]}, + resolutions=0.1) + pp.plot_nullcline() + + fx_keys = [k for k in pp.analyzed_results if k.startswith(C.fx_nullcline_points)] + fy_keys = [k for k in pp.analyzed_results if k.startswith(C.fy_nullcline_points)] + assert len(fx_keys) > 0 and len(fy_keys) > 0 + n_fx = sum(pp.analyzed_results[k][0].shape[0] for k in fx_keys) + n_fy = sum(pp.analyzed_results[k][0].shape[0] for k in fy_keys) + + # Reproduce the candidate gathering used by ``select_candidates='nullclines'``. + candidates = [pp.analyzed_results[k][0] for k in pp.analyzed_results.keys() + if k.startswith(C.fx_nullcline_points) or k.startswith(C.fy_nullcline_points)] + candidates = jnp.vstack(candidates) + # union of both nullcline candidate sets, not just one of them + assert candidates.shape[0] == n_fx + n_fy + assert candidates.shape[0] > n_fy + + fps = pp.plot_fixed_point(select_candidates='nullclines', with_return=True) + assert fps is not None + + # --------------------------------------------------------------------------- # # PhasePlane2D trajectory + limit cycle # --------------------------------------------------------------------------- # diff --git a/brainpy/analysis/plotstyle.py b/brainpy/analysis/plotstyle.py index c617f8073..1ba2f903a 100644 --- a/brainpy/analysis/plotstyle.py +++ b/brainpy/analysis/plotstyle.py @@ -76,6 +76,6 @@ def set_markersize(markersize): if not isinstance(markersize, int): raise TypeError(f"Must be an integer, but got {type(markersize)}: {markersize}") global _markersize - __markersize = markersize + _markersize = markersize for key in tuple(plot_schema.keys()): plot_schema[key]['markersize'] = markersize diff --git a/brainpy/analysis/plotstyle_test.py b/brainpy/analysis/plotstyle_test.py new file mode 100644 index 000000000..a4bec4dff --- /dev/null +++ b/brainpy/analysis/plotstyle_test.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Copyright 2025 BrainX Ecosystem Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================== +import pytest + +from brainpy.analysis import plotstyle + + +def test_set_markersize_updates_global(): + """Regression for P13-H1: ``set_markersize`` must update the module global + ``_markersize`` (it previously assigned to a typo local ``__markersize``).""" + original = plotstyle._markersize + try: + plotstyle.set_markersize(33) + # per-key schema entries updated + assert plotstyle.plot_schema[plotstyle.SADDLE_NODE]['markersize'] == 33 + # module global updated, not just the per-key dicts + assert plotstyle._markersize == 33 + finally: + plotstyle.set_markersize(original) + + +def test_set_markersize_type_check(): + with pytest.raises(TypeError): + plotstyle.set_markersize(1.5) + + +def test_set_plot_schema_validations(): + with pytest.raises(TypeError): + plotstyle.set_plot_schema(123) + with pytest.raises(KeyError): + plotstyle.set_plot_schema('not-a-real-fixed-point-type') + # valid update + plotstyle.set_plot_schema(plotstyle.SADDLE_NODE, color='black') + assert plotstyle.plot_schema[plotstyle.SADDLE_NODE]['color'] == 'black' diff --git a/brainpy/analysis/stability.py b/brainpy/analysis/stability.py index 7e85f5db6..7d799fb5c 100644 --- a/brainpy/analysis/stability.py +++ b/brainpy/analysis/stability.py @@ -145,22 +145,25 @@ def stability_analysis(derivatives): elif e > 0: return UNSTABLE_NODE_2D else: - w = np.linalg.eigvals(derivatives) - if w[0] == w[1]: - return UNSTABLE_DEGENERATE_2D - else: + # Repeated eigenvalue. A star (proper) node has a full + # eigenspace, which happens iff the matrix is a scalar + # multiple of the identity (b == c == 0 and a == d). + # Otherwise the matrix is defective -> degenerate (improper) node. + if b == 0 and c == 0 and a == d: return UNSTABLE_STAR_2D + else: + return UNSTABLE_DEGENERATE_2D else: if e < 0: return STABLE_FOCUS_2D elif e > 0: return STABLE_NODE_2D else: - w = np.linalg.eigvals(derivatives) - if w[0] == w[1]: - return STABLE_DEGENERATE_2D - else: + # Repeated eigenvalue. See the unstable branch above. + if b == 0 and c == 0 and a == d: return STABLE_STAR_2D + else: + return STABLE_DEGENERATE_2D elif np.size(derivatives) == 9: # 3D dynamical system eigenvalues = np.linalg.eigvals(np.array(derivatives)) diff --git a/brainpy/analysis/stability_coverage_test.py b/brainpy/analysis/stability_coverage_test.py index 0ed9f75c2..f42c0e55c 100644 --- a/brainpy/analysis/stability_coverage_test.py +++ b/brainpy/analysis/stability_coverage_test.py @@ -88,14 +88,13 @@ def test_2d_unstable_focus_and_node(): def test_2d_unstable_degenerate_and_star(): - # p > 0, e == 0. Distinct construction so eigenvalues equal -> degenerate. + # p > 0, e == 0. A scalar multiple of the identity is a proper (star) node: + # full 2-D eigenspace. (P13-C1: previously mislabelled as degenerate.) J = [[2., 0.], [0., 2.]] # trace 4, det 4, e = 16 - 16 = 0; eigvals both 2 - assert stability_analysis(J) == st.UNSTABLE_DEGENERATE_2D - # p > 0, e == 0 but eigvals not literally equal (numerical) -> star branch. - # Use a Jordan-like block so np.linalg.eigvals returns slightly different. + assert stability_analysis(J) == st.UNSTABLE_STAR_2D + # p > 0, e == 0 with a defective (Jordan) block -> degenerate (improper) node. J = [[2., 1.], [0., 2.]] # trace 4, det 4, e = 0; defective matrix - res = stability_analysis(J) - assert res in (st.UNSTABLE_DEGENERATE_2D, st.UNSTABLE_STAR_2D) + assert stability_analysis(J) == st.UNSTABLE_DEGENERATE_2D def test_2d_stable_focus_and_node(): @@ -108,13 +107,13 @@ def test_2d_stable_focus_and_node(): def test_2d_stable_degenerate_and_star(): - # p < 0, e == 0 -> stable degenerate (eigvals equal) + # p < 0, e == 0. Scalar multiple of identity -> proper (star) node. + # (P13-C1: previously mislabelled as degenerate.) J = [[-2., 0.], [0., -2.]] # trace -4, det 4, e = 0 - assert stability_analysis(J) == st.STABLE_DEGENERATE_2D - # defective -> degenerate or star + assert stability_analysis(J) == st.STABLE_STAR_2D + # defective (Jordan) block -> degenerate (improper) node. J = [[-2., 1.], [0., -2.]] - res = stability_analysis(J) - assert res in (st.STABLE_DEGENERATE_2D, st.STABLE_STAR_2D) + assert stability_analysis(J) == st.STABLE_DEGENERATE_2D # --------------------------------------------------------------------------- # diff --git a/brainpy/analysis/stability_test.py b/brainpy/analysis/stability_test.py index d4f630967..2b31cd824 100644 --- a/brainpy/analysis/stability_test.py +++ b/brainpy/analysis/stability_test.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # ============================================================================== +import numpy as np + from brainpy.analysis.stability import * @@ -20,3 +22,35 @@ def test_d1(): assert stability_analysis(1.) == UNSTABLE_POINT_1D assert stability_analysis(-1.) == STABLE_POINT_1D assert stability_analysis(0.) == SADDLE_NODE + + +# --------------------------------------------------------------------------- # +# 2D star (proper) vs degenerate (improper) node — regression for P13-C1. +# +# A *star* node has a full 2-D eigenspace, i.e. the Jacobian is a scalar +# multiple of the identity (b == c == 0 and a == d). A *degenerate* +# (improper) node has a repeated eigenvalue but is defective (single +# eigenvector), e.g. a Jordan block. +# --------------------------------------------------------------------------- # +def test_2d_stable_star_proper_node(): + # -I : repeated eigenvalue -1, full eigenspace -> stable STAR. + J = np.array([[-1., 0.], [0., -1.]]) + assert stability_analysis(J) == STABLE_STAR_2D + + +def test_2d_stable_degenerate_defective(): + # Jordan block with repeated eigenvalue -1, defective -> stable DEGENERATE. + J = np.array([[-1., 1.], [0., -1.]]) + assert stability_analysis(J) == STABLE_DEGENERATE_2D + + +def test_2d_unstable_star_proper_node(): + # 2*I : repeated eigenvalue +2, full eigenspace -> unstable STAR. + J = np.array([[2., 0.], [0., 2.]]) + assert stability_analysis(J) == UNSTABLE_STAR_2D + + +def test_2d_unstable_degenerate_defective(): + # Jordan block with repeated eigenvalue +2, defective -> unstable DEGENERATE. + J = np.array([[2., 1.], [0., 2.]]) + assert stability_analysis(J) == UNSTABLE_DEGENERATE_2D diff --git a/docs/issues-found-20260619-analysis.md b/docs/issues-found-20260619-analysis.md new file mode 100644 index 000000000..ee3302280 --- /dev/null +++ b/docs/issues-found-20260619-analysis.md @@ -0,0 +1,170 @@ +# Analysis package audit — 2026-06-19 (P13) + +Scope: `brainpy/analysis/*` (low-dim phase-plane/bifurcation, high-dim slow points, +stability classification, utils). Branch `fix/audit-20260619-analysis`. + +Severity scale: Critical (silently wrong/crash in default usage), High (wrong in +realistic cases / broken public API), Medium (edge/fragility/perf), Low (style/docs). + +--- + +### P13-C1 — 2D stable/unstable STAR vs DEGENERATE node classification is inverted and partly dead code [Critical] +- File: brainpy/analysis/stability.py:147-163 +- Category: correctness +- What: For a repeated-eigenvalue 2D fixed point (`e = p*p - 4*q == 0`), the code does + `w = np.linalg.eigvals(J); if w[0] == w[1]: return *_DEGENERATE_2D else: return *_STAR_2D`. + When `e == 0` the two eigenvalues are mathematically equal, so `np.linalg.eigvals` + returns the *same* value for both a proper (star) node like `-I` and a defective + (improper/degenerate) Jordan block like `[[-1,1],[0,-1]]`. Therefore `w[0] == w[1]` + is effectively always true: the `*_STAR_2D` branch is unreachable dead code, and the + reachable branch mislabels a true star node (`±I`) as "degenerate". +- Why it's a bug: A **star node** (proper node) has a full 2-D eigenspace — every + direction is an eigenvector — and occurs iff `J` is a scalar multiple of the identity + (`b == 0, c == 0, a == d`). A **degenerate/improper node** is defective (single + eigenvector). The classifier swaps the two labels and never returns STAR. +- Repro: `stability_analysis([[-1.,0.],[0.,-1.]])` returns `'stable degenerate'`, + but `-I` is the canonical *stable star*. Verified empirically. +- Fix: Distinguish a proper/star node by testing whether the off-diagonals vanish and + diagonals are equal (scalar multiple of identity) → STAR; otherwise the repeated + eigenvalue is defective → DEGENERATE. Applied for both stable (`p<0`) and unstable + (`p>0`) branches. +- Tests: test_2d_stable_star_proper_node, test_2d_stable_degenerate_defective, + test_2d_unstable_star_proper_node, test_2d_unstable_degenerate_defective + (stability_test.py). Pre-existing coverage tests at + stability_coverage_test.py:90-117 asserted the buggy `res in (DEGENERATE, STAR)` + and a `2I`→DEGENERATE expectation; updated to assert the corrected labels (noted in + test file). +- Status: fixed + +### P13-H1 — `set_markersize` writes to a typo local, leaving module global stale [Medium] +- File: brainpy/analysis/plotstyle.py:75-81 +- Category: correctness +- What: `set_markersize` declares `global _markersize` then assigns to `__markersize` + (double underscore typo). The intended module-level `_markersize` is never updated. +- Why it's a bug: The module global `_markersize` (used as the default for every + `plot_schema` entry and any future-added entries) stays at its initial value of 10. + `set_markersize` only mutates the already-built per-key dicts; any schema entry added + after a call to `set_markersize` would silently use the stale default. The local + `__markersize` is dead. +- Repro: `set_markersize(25); plotstyle._markersize` → still `10`. Verified empirically. +- Fix: assign to `_markersize` (the declared global). +- Tests: test_set_markersize_updates_global (plotstyle is exercised indirectly; added a + direct regression in stability_coverage area is out of scope, so a focused test added + to lowdim_phase_plane_coverage is avoided — instead a minimal test placed in a new + plotstyle path). See test_set_markersize_updates_global. +- Status: fixed + +### P13-H2 — `PhasePlane2D.plot_fixed_point(select_candidates='nullclines')` ignores fx-nullcline [High] +- File: brainpy/analysis/lowdim/lowdim_phase_plane.py:328-329 +- Category: correctness +- What: The `'nullclines'` branch gathers candidates with + `key.startswith(C.fy_nullcline_points) or key.startswith(C.fy_nullcline_points)` — + `fy` is repeated; the second clause was meant to be `C.fx_nullcline_points`. +- Why it's a bug: When the user requests fixed points using *both* nullclines, only the + fy-nullcline candidate points are actually used. Fixed points that lie on the + fx-nullcline candidate set (but were not in the fy set) are silently dropped, so the + fixed-point search can miss real fixed points. +- Repro: static (matplotlib-driven; logic-level bug). +- Fix: change the second clause to `key.startswith(C.fx_nullcline_points)`. +- Tests: test_nullclines_select_uses_both (lowdim_phase_plane_coverage_test.py) — + verifies the candidate union includes fx-nullcline points. +- Status: fixed + +### P13-M1 — `find_fps_with_gd_method` mishandles single optimization loop / non-divisible num_opt [Medium] +- File: brainpy/analysis/highdim/slow_points.py:379 +- Category: edge/error +- What: `num_opt_loops = int(num_opt / num_batch)`. If `num_opt < num_batch` (e.g. + `num_opt=50, num_batch=100`), `num_opt_loops == 0`, the optimization loop body never + runs, `opt_losses` stays empty, and `jnp.concatenate(opt_losses)` raises + "Need at least one array to concatenate". +- Why it's a bug: A perfectly reasonable call with a small `num_opt` crashes rather than + running at least one batch. +- Repro: static (would require a full GD optimization run; logic-level). +- Fix: `num_opt_loops = max(1, int(np.ceil(num_opt / num_batch)))` so at least one batch + is always executed. +- Tests: covered indirectly; a dedicated tiny GD run is added + (test_gd_small_num_opt in slow_points_coverage_test.py). +- Status: fixed + +### P13-M2 — `plot_vector_field(plot_method='streamplot')` crashes when user supplies `linewidth` [Medium] +- File: brainpy/analysis/lowdim/lowdim_phase_plane.py:229-235 +- Category: edge/error +- What: The streamplot branch does `linewidth = plot_style.get('linewidth', None)` + (read, not remove) then calls `pyplot.streamplot(..., linewidth=linewidth, **plot_style)`. + If the user passes `plot_style=dict(linewidth=...)`, `linewidth` is forwarded twice + → `TypeError: got multiple values for keyword argument 'linewidth'`. +- Why it's a bug: A documented, supported `plot_style` key crashes the call. +- Repro: `PhasePlane2D(...).plot_vector_field(plot_method='streamplot', plot_style=dict(linewidth=1.0))`. +- Fix: use `plot_style.pop('linewidth', None)` so the key is consumed and passed once. +- Tests: test_pp2d_streamplot_custom_linewidth (lowdim_phase_plane_coverage_test.py) — + the pre-existing `test_pp2d_streamplot_custom_linewidth_is_buggy` asserted the buggy + TypeError; rewritten to assert the call now succeeds (noted: it asserted the fixed bug). +- Status: fixed + +### P13-L1 — `stability_analysis` 3D fallthrough is partially dead / `assert` for validation [Low] +- File: brainpy/analysis/stability.py:186-221 +- Category: correctness/style +- What: When `is_real.sum() != 1` (e.g. all-complex pair count != expected), the function + falls through to `eigenvalues = np.real(...)` only if the `is_real.sum()==1` branch is + not taken; but the inner `if is_real.sum() == 1:` block always `return`s, so the + trailing fallback (lines 217-221) only runs when `is_real.sum()` is 0 or >1 yet none + of the complex sub-branches return. Also `assert np.conj(v1) == v2` uses `assert` for + runtime validation (stripped under `-O`). +- Why it's a bug: Mostly latent; 3D classification is documented as best-effort. Not a + default-path crash. +- Fix: recorded only (3D analyzer is rarely used; out of risk budget for this pass). +- Tests: none +- Status: recorded-only + +### P13-L2 — `remove_return_shape` assumes `.shape` exists; scalars crash [Low] +- File: brainpy/analysis/utils/function.py:38-44 +- Category: edge/error +- What: `remove_return_shape` does `if r.shape == (1,)`. If the wrapped derivative returns + a Python float (e.g. user-defined `f` returning a scalar), `r.shape` raises + `AttributeError`. In practice derivative functions return arrays, so this is latent. +- Fix: recorded only. +- Tests: none +- Status: recorded-only + +### P13-L3 — `get_args` time-variable detection breaks for keyword-only signatures with `t` absent [Low] +- File: brainpy/analysis/utils/function.py:63-69 +- Category: edge/error +- What: requires a parameter literally named `t`; this is by design for ODE integrators + but the error message ("Do not find time variable 't'.") is raised generically. +- Fix: recorded only (matches integrator contract). +- Tests: none +- Status: recorded-only + +### P13-L4 — `keep_unique` returns input `candidates` unconverted when `tolerance<=0` [Low] +- File: brainpy/analysis/utils/others.py:127-130 +- Category: style/consistency +- What: early returns hand back the raw `candidates` (possibly `bm.Array`/dict of) whereas + the normal path returns numpy arrays. Callers (`SlowPointFinder.keep_unique`) then call + `jnp.asarray` so it works, but the return dtype is inconsistent across branches. +- Fix: recorded only. +- Tests: none +- Status: recorded-only + +--- + +## Cross-check vs dev/issues-found-20260618.md (analysis entries) + +- **H-49** (`lowdim_analyzer.py:377,953`, `optimization.py:398` — arg-unwrap tests + `isinstance(candidates, bm.Array)` instead of `isinstance(a, …)`): **already fixed** in + the current tree (all three sites use `isinstance(a, bm.Array)`). No action. +- **H-50** (non-convertible 2D `_get_fixed_points` `jnp.concatenate([])` crash): **already + fixed** (empty-guard at `lowdim_analyzer.py:1042-1047` returns correctly-shaped empties). + No action. +- **M-33** (`stability.py` 2D star vs degenerate classification inverted): this is the same + defect as **P13-C1** above — **fixed** (proper-node detected by scalar-multiple-of-identity + test; STAR branch was previously unreachable). +- **M-34** (`stability.py:111-141` borderline center/saddle-node/line types gated on exact + float `== 0` of autodiff Jacobians, almost never detected): **recorded-only**. Adding + tolerance bands would change the classification of many near-borderline points and risks + destabilising the existing 30 stability tests; out of risk budget for this pass. +- **M-35** (`slow_points.py` GD finder stops on *mean* loss while `tolerance` reads as + per-point): **recorded-only**. This is an intentional aggregate stop criterion; per-point + filtering is provided separately via `filter_loss`. Behaviour, not a crash/wrong-result. +- **L-15** (`get_sign2` passes a generator as `reshape` shape; helper currently unused): + **recorded-only**. Latent and the JAX version in use consumes the generator without error. +