Skip to content

fix(analysis): fixed-point classification, nullcline selection, GD batches, plot kwargs#849

Merged
chaoming0625 merged 1 commit into
masterfrom
fix/audit-20260619-analysis
Jun 18, 2026
Merged

fix(analysis): fixed-point classification, nullcline selection, GD batches, plot kwargs#849
chaoming0625 merged 1 commit into
masterfrom
fix/audit-20260619-analysis

Conversation

@chaoming0625

@chaoming0625 chaoming0625 commented Jun 18, 2026

Copy link
Copy Markdown
Member

Fresh review of brainpy/analysis.

  • Critical — 2D star vs degenerate-node stability classification was inverted (STAR branch was dead code); ±λI matrices now correctly classified as STAR.
  • High — phase-plane select_candidates='nullclines' tested the fy condition twice, silently dropping all fx-nullcline candidates; now fx OR fy.
  • Mediumslow_points num_opt_loops was 0 when num_opt<num_batch -> empty losses -> concatenate crash; now max(1, ceil(...)).
  • Medium — phase-plane streamplot passed user linewidth twice (.get not .pop) -> TypeError.
  • Mediumplotstyle.set_markersize wrote a typo local, leaving the module global stale.

In-scope: 192 passed. Findings: docs/issues-found-20260619-analysis.md.

Summary by Sourcery

Fix multiple correctness and robustness issues in the analysis package, primarily around 2D stability classification, phase-plane fixed point selection, and high-dimensional slow point optimization, and add targeted regression tests and audit documentation.

Bug Fixes:

  • Correct 2D stability classification to distinguish proper (star) nodes from degenerate (defective) nodes based on the Jacobian being a scalar multiple of the identity.
  • Ensure PhasePlane2D fixed point selection with select_candidates='nullclines' uses candidates from both fx and fy nullclines instead of dropping fx-nullcline points.
  • Prevent plot_vector_field(streamplot) from raising a TypeError when a user supplies linewidth via plot_style by consuming the key before forwarding kwargs.
  • Guarantee the gradient-descent slow point finder runs at least one optimization batch even when num_opt < num_batch, avoiding empty-loss concatenation crashes.
  • Fix plotstyle.set_markersize to update the module-level _markersize global instead of a typo local variable, keeping schema defaults in sync.

Enhancements:

  • Clarify and strengthen stability and phase-plane tests to assert the corrected star vs degenerate classifications and nullcline-based candidate selection semantics.

Documentation:

  • Add an audit report documenting identified issues, severities, decisions, and fixed vs recorded-only items for the analysis package.

Tests:

  • Add regression tests covering star vs degenerate 2D node classification for stable and unstable cases.
  • Add regression tests to verify PhasePlane2D nullcline candidate selection uses both fx and fy nullclines when requested.
  • Add regression tests ensuring streamplot linewidth customization no longer crashes and that slow point GD runs handle small num_opt values correctly.
  • Add plotstyle tests for markersize updates, type checking, and plot schema validation.

…tch 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<num_batch
  -> 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
@chaoming0625 chaoming0625 merged commit 5d06443 into master Jun 18, 2026
0 of 3 checks passed
@sourcery-ai

sourcery-ai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

Fixes several correctness and robustness issues in the analysis package: corrects 2D star vs degenerate node classification, ensures nullcline-based fixed-point selection uses both nullclines, makes GD-based slow point search always run at least one batch, fixes a streamplot linewidth kwarg collision, and corrects plot marker size global state handling while adding regression tests and documentation of the audit findings.

Sequence diagram for streamplot linewidth handling in plot_vector_field

sequenceDiagram
  actor User
  participant PhasePlane2D
  participant pyplot_streamplot

  User->>PhasePlane2D: plot_vector_field(plot_method='streamplot', plot_style)
  PhasePlane2D->>PhasePlane2D: plot_style.pop(linewidth, None)
  PhasePlane2D->>PhasePlane2D: [compute linewidth if None]
  PhasePlane2D->>pyplot_streamplot: streamplot(..., linewidth=linewidth, **plot_style)
Loading

Flow diagram for 2D star vs degenerate node stability classification

flowchart TD
  A[Start stability_analysis 2D] --> B[Compute discriminant e]
  B --> C{e == 0?}
  C -- No --> Z[Use existing non-repeated classification]
  C -- Yes --> D[Extract a,b,c,d from Jacobian]
  D --> E{p < 0?}
  E -- Yes --> F[Stable branch]
  E -- No --> G[Unstable branch]
  F --> H{b == 0 and c == 0 and a == d?}
  G --> H
  H -- Yes --> I[Return STABLE_STAR_2D or UNSTABLE_STAR_2D]
  H -- No --> J[Return STABLE_DEGENERATE_2D or UNSTABLE_DEGENERATE_2D]
Loading

File-Level Changes

Change Details Files
Correct 2D stability classification between STAR and DEGENERATE nodes for repeated eigenvalues and update coverage tests.
  • Replace eigenvalue-equality heuristic with structural check for scalar multiple of identity (b==c==0 and a==d) to classify proper STAR vs defective DEGENERATE nodes in the 2D case.
  • Add explicit regression tests for stable/unstable, star/degenerate 2D nodes using identity and Jordan-block matrices.
  • Tighten existing stability coverage tests to assert the now-correct labels for ±2I and corresponding defective matrices instead of allowing either STAR or DEGENERATE.
brainpy/analysis/stability.py
brainpy/analysis/stability_test.py
brainpy/analysis/stability_coverage_test.py
Fix PhasePlane2D nullcline-based fixed point candidate selection and streamplot linewidth handling, with regression tests.
  • In the 'nullclines' selector, include both fx- and fy-nullcline point sets instead of checking fy twice and silently dropping fx-nullcline candidates.
  • In the streamplot branch, pop 'linewidth' from plot_style before passing kwargs so user-supplied linewidth is forwarded exactly once and does not raise a TypeError.
  • Add regression tests to ensure streamplot accepts a custom linewidth without error and that the nullcline selector aggregates candidates from both nullclines and finds fixed points.
brainpy/analysis/lowdim/lowdim_phase_plane.py
brainpy/analysis/lowdim/lowdim_phase_plane_coverage_test.py
Harden gradient-descent-based SlowPointFinder to avoid empty loss concatenation when num_opt < num_batch, and add coverage.
  • Change num_opt_loops computation to max(1, ceil(num_opt / num_batch)) so at least one optimization batch runs even when num_opt < num_batch.
  • Add a regression test that runs GD with num_opt < num_batch, asserting it completes, finds the expected number of fixed points, and produces a 1D opt_losses of the expected length.
brainpy/analysis/highdim/slow_points.py
brainpy/analysis/highdim/slow_points_coverage_test.py
Fix plotstyle global marker size mutation and improve its test coverage and validations.
  • Correct set_markersize to assign to the module-global _markersize (not a local typo variable) and keep plot_schema entries in sync.
  • Add tests to verify that set_markersize updates both the global and schema entries, enforces integer types, and that set_plot_schema validates key types and existence while applying updates.
brainpy/analysis/plotstyle.py
brainpy/analysis/plotstyle_test.py
Document the analysis package audit findings for this pass.
  • Add a markdown report summarizing all issues found in the 2026-06-19 analysis package audit, including fixed and recorded-only items and cross-checks against previous issue logs.
docs/issues-found-20260619-analysis.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In stability_analysis, the scalar-multiple-of-identity check for distinguishing STAR vs DEGENERATE nodes is duplicated between the stable and unstable branches; consider extracting this into a small helper to reduce repetition and keep the classification logic in one place.
  • The new num_opt_loops = max(1, int(np.ceil(num_opt / num_batch))) in SlowPointFinder will run at least one batch even when num_opt <= 0 and can overshoot the requested num_opt; it may be clearer to explicitly handle num_opt <= 0 (e.g., early-return) and to bound the total number of optimization steps to num_opt inside the loop.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `stability_analysis`, the scalar-multiple-of-identity check for distinguishing STAR vs DEGENERATE nodes is duplicated between the stable and unstable branches; consider extracting this into a small helper to reduce repetition and keep the classification logic in one place.
- The new `num_opt_loops = max(1, int(np.ceil(num_opt / num_batch)))` in `SlowPointFinder` will run at least one batch even when `num_opt <= 0` and can overshoot the requested `num_opt`; it may be clearer to explicitly handle `num_opt <= 0` (e.g., early-return) and to bound the total number of optimization steps to `num_opt` inside the loop.

## Individual Comments

### Comment 1
<location path="brainpy/analysis/lowdim/lowdim_phase_plane.py" line_range="226-235" />
<code_context>
+                # 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()):
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Using `pop` here mutates `plot_style` and assumes it is a mutable mapping, which may surprise callers or break with non-dict mappings.

This changes behavior vs the previous `get`: it now mutates `plot_style` and requires it to implement `pop`. That can silently alter a dict that callers reuse after `plot_vector_field`, and can fail for generic `Mapping` instances. You can avoid both issues by copying before mutation (e.g. `plot_style = dict(plot_style)`) and popping from the copy used for `streamplot`, preserving the multiple-values fix without changing the caller’s object or type contract.

```suggestion
            elif plot_method == 'streamplot':
                if plot_style is None:
                    # Start from default style when no user style is provided.
                    plot_style = dict(arrowsize=1.2, density=1, color='thistle')
                else:
                    # Work on a local copy to avoid mutating the caller's mapping
                    # (which may be a non-dict Mapping or reused elsewhere).
                    plot_style = dict(plot_style)
                # 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
                candidates = jnp.vstack(candidates)
```
</issue_to_address>

### Comment 2
<location path="brainpy/analysis/plotstyle_test.py" line_range="40-47" />
<code_context>
+        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'
</code_context>
<issue_to_address>
**suggestion (testing):** Avoid leaking global `plot_schema` modifications across tests by restoring the original entry.

This test leaves `plotstyle.plot_schema[plotstyle.SADDLE_NODE]['color']` set to `'black'`, which can affect later tests that expect the default. Consider capturing the original entry at the start of the test and restoring it in a `try/finally` block (as done in `test_set_markersize_updates_global`) to keep tests isolated and order-independent.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines 226 to 235
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Using pop here mutates plot_style and assumes it is a mutable mapping, which may surprise callers or break with non-dict mappings.

This changes behavior vs the previous get: it now mutates plot_style and requires it to implement pop. That can silently alter a dict that callers reuse after plot_vector_field, and can fail for generic Mapping instances. You can avoid both issues by copying before mutation (e.g. plot_style = dict(plot_style)) and popping from the copy used for streamplot, preserving the multiple-values fix without changing the caller’s object or type contract.

Suggested change
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
elif plot_method == 'streamplot':
if plot_style is None:
# Start from default style when no user style is provided.
plot_style = dict(arrowsize=1.2, density=1, color='thistle')
else:
# Work on a local copy to avoid mutating the caller's mapping
# (which may be a non-dict Mapping or reused elsewhere).
plot_style = dict(plot_style)
# 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
candidates = jnp.vstack(candidates)

Comment on lines +40 to +47
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'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Avoid leaking global plot_schema modifications across tests by restoring the original entry.

This test leaves plotstyle.plot_schema[plotstyle.SADDLE_NODE]['color'] set to 'black', which can affect later tests that expect the default. Consider capturing the original entry at the start of the test and restoring it in a try/finally block (as done in test_set_markersize_updates_global) to keep tests isolated and order-independent.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests labels Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant