Skip to content

Add KineticIon support and family-phased cell updates#89

Open
TLDSZ wants to merge 7 commits into
mainfrom
ion_dyn_v2
Open

Add KineticIon support and family-phased cell updates#89
TLDSZ wants to merge 7 commits into
mainfrom
ion_dyn_v2

Conversation

@TLDSZ
Copy link
Copy Markdown
Collaborator

@TLDSZ TLDSZ commented May 13, 2026

Description

  • Add a declarative KineticIon template for NMODL-style ion species, including Factor, Species, Reaction, Source, and
    Conserve building blocks.
  • Add Cell(update_policy=...) with a new family_phased mode that advances voltage first, then channels, then ions, while keeping
    legacy as the default behavior.
  • Fix the mixed update-order case where an independently integrated ion coexists with non-independent channels by caching the step-
    start total current for current-driven ion dynamics.
  • Extend runtime ion-family detection and lowering so kinetic ions are treated as a supported ion family.
  • Update probe sampling so mechanism/ion probes can read plain attributes in addition to runtime states.
  • Refresh braincell/ion/Cerebellum_ion_import_plan.md to match the new kinetic-ion direction and the mixed ion/channel update
    behavior.

How Has This Been Tested

  • Ran:
    python3 -m pytest braincell/_base_ion_test.py braincell/_compute/runtime_test.py braincell/_multi_compartment/cell_test.py braincell/_multi_compartment/probes_test.py braincell/ion/_base_test.py
  • Result:
    104 passed in 9.08s
  • Verified KineticIon init/reset behavior, conservation resolution, derivative computation, post-integral projection, and
    pack_info() behavior.
  • Verified Cell.update_policy default/validation behavior plus family-phased channel-before-ion execution and cached-current usage
    for current-driven ions.
  • Verified probe sampling for runtime states, plain fields, mechanism current, and total ion current.

Types of changes

  • Changes follow the CONTRIBUTING guidelines.
  • Update necessary documentation accordingly.

Summary by Sourcery

Introduce declarative kinetic ion support and an explicit cell update policy for family-phased ion/channel scheduling.

New Features:

  • Add declarative kinetic-ion templates (Factor, Species, Reaction, Source, Conserve) and a KineticIon base class for NMODL-style reaction-network ions.
  • Allow Cell to select an update_policy, including a new family_phased mode that orders voltage, channels, and ions explicitly while preserving the existing legacy behavior.
  • Enable MechanismProbe and ion/mechanism probes to read plain attributes in addition to runtime state fields.

Bug Fixes:

  • Ensure current-driven ion dynamics use a cached step-start total current to avoid mixed update-order inconsistencies when ions and channels are integrated differently.

Enhancements:

  • Extend runtime ion-family detection so kinetic ions are treated as a supported ion family and participate in multi-compartment execution.
  • Refine ion and cell lifecycle hooks to support kinetic-ion integration, per-phase state integration, and independent ion/channel solvers.
  • Clarify and compress the cerebellar ion import plan document to reflect the new kinetic-ion direction and scheduling semantics.

Tests:

  • Add targeted tests for kinetic-ion initialization, conservation resolution, derivative computation, post-integral projection, and pack_info() behavior.
  • Add tests covering Cell.update_policy, family-phased execution ordering, and cached-current usage for current-driven ions.
  • Extend probe tests to validate sampling of both runtime states and plain fields on mechanisms and ions.
  • Add coverage for independent-integration dispatch in ion updates and for runtime ion-family classification including kinetic ions.

Tunenip added 2 commits May 12, 2026 22:28
2.add update_policy
2.solve the independent ion with non-indepent
channel situation
@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented May 13, 2026

Reviewer's Guide

Introduces a declarative KineticIon template and associated runtime plumbing, adds an explicit Cell.update_policy with a new family_phased scheduling mode that orders voltage, channels, and ions, fixes mixed update-order for current-driven ions via cached step-start current, extends runtime ion-family detection and mechanism probing, and updates tests and documentation accordingly.

Sequence diagram for Cell.update family_phased scheduling

sequenceDiagram
    participant Cell
    participant DHS as dhs_voltage_step
    participant Chan as RuntimeChannel
    participant Ion as RuntimeIon

    rect rgb(230,230,250)
        Cell->>Cell: _cache_family_phased_currents(point_V_before)
        Cell->>DHS: dhs_voltage_step(self,t,dt,I_ext)
        DHS-->>Cell: V updated
    end

    rect rgb(220,255,220)
        Cell->>Chan: _channel_phase_call(pre_integral,point_V_after,ion)
        Cell->>Chan: _channel_phase_call(compute_derivative,point_V_after,ion)
        Cell->>Chan: _channel_phase_call(post_integral,point_V_after,ion)
    end

    rect rgb(220,240,255)
        Cell->>Ion: _run_ion_hook(_ion_compute_derivative_hook,point_V_after)
        Ion-->>Ion: _ion_compute_derivative_hook(V)
        Ion-->>Ion: derivative(Ci,V,total_current=_cached_total_current)
        Cell->>Ion: _run_ion_hook(_ion_post_integral_hook,point_V_after)
    end
Loading

File-Level Changes

Change Details Files
Introduce declarative kinetic-ion template primitives and a KineticIon base with conservation handling and independent integration support.
  • Add Factor, Species, Reaction, Source, and Conserve dataclasses to describe NMODL-style reaction networks.
  • Implement KineticIon mixin that manages species initialization/reset, algebraic conservation resolution, derivative computation via reactions/sources, Nernst E from Ci, and optional independent integration with substeps.
  • Add internal helpers _Specs, _Species, _Conserve, and _Flux to validate templates, manage visible/amount conversion, resolve conserved pools, and accumulate fluxes into DiffEqState derivatives.
braincell/ion/_base.py
Integrate kinetic ions and independent-ion behavior into the existing ion lifecycle and tests.
  • Ensure ion pre/post hooks are called from BaseIon pre_integral/post_integral and that IndependentIntegration ions dispatch to make_integration and propagate updates to independent child channels.
  • Add _SimpleKineticIon test ion and tests for init/reset, species_value resolution, conservation, derivative computation, post-integral projection, and pack_info behavior.
  • Add tests verifying IndependentIntegration dispatch from Ion.update.
braincell/_base_ion.py
braincell/ion/_base_test.py
braincell/_base_ion_test.py
Add explicit Cell.update_policy with a new family_phased path that orders voltage, channel, and ion updates and uses cached ion currents.
  • Introduce update_policy property with validation helper _resolve_update_policy; default remains 'legacy'.
  • Implement _update_family_phased which performs: step-start ion-current caching, DHS voltage step, a global channel phase (including ion-bound channels) using a DiffEqModule proxy and ind_exp_euler_step, followed by an ion phase that advances ion states (including independent ions via their solver/substeps).
  • Add helpers for collecting DiffEqStates across channel/ion nodes, lifting ion-bound channels into the channel phase, and running independent phase solvers; add _cache_family_phased_currents storing _cached_total_current on ions when uses_total_current is true.
  • Extend tests to build a minimal Ca+channel setup, check default policy, error on bad policy, verify channel-before-ion execution order, and confirm current-driven ions see the cached step-start current.
braincell/_multi_compartment/cell.py
braincell/_multi_compartment/cell_test.py
Adjust DynamicNernstIon and runtime ion-family detection to cooperate with cached currents and kinetic ions.
  • Change DynamicNernstIon._ion_compute_derivative_hook to prefer a cached _cached_total_current when present, falling back to computing current(V) otherwise.
  • Extend runtime ion-family detection so KineticIon subclasses map to a 'kinetic' family alongside existing dynamic/init/fixed families.
braincell/ion/_base.py
braincell/_compute/runtime.py
Relax mechanism/ion probe sampling to read both brainstate.State and plain attributes.
  • Replace _probe_state_attr with _probe_attr_value that returns State.value for brainstate.State attributes and the raw attribute otherwise.
  • Update mechanism/ion sampling to use _probe_attr_value and adjust runtime tests so probes successfully read plain fields such as g_max while still erroring on unknown mechanisms.
  • Add focused unit tests for _probe_attr_value behavior on state vs plain attributes.
braincell/_multi_compartment/probes.py
braincell/_multi_compartment/probes_test.py
braincell/_compute/runtime_test.py
Update documentation around Cerebellum ion import to reflect new kinetic-ion direction and scheduling behavior.
  • Rewrite Cerebellum_ion_import_plan.md into a concise status/progress document referencing KineticIon, kinetic pieces, Cell.update_policy, and current-caching behavior, and outlining next validation steps and limitations.
braincell/ion/Cerebellum_ion_import_plan.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

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

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

  • The KineticIon.uses_total_current path in _ion_compute_derivative_hook hard-requires _cached_total_current and raises at runtime if it is missing; consider either guarding this path behind an explicit update_policy/owner check or providing a clearer public contract for how kinetic ions are expected to be driven outside Cell(update_policy='family_phased').
  • The Cell._update_family_phased implementation introduces a fairly involved parallel scheduling stack (_PhaseTarget, _collect_*, _run_independent_phase_solver); it may be worth isolating this into a dedicated module or reusing more of the existing solver abstraction to avoid duplication and make it easier to keep phase ordering consistent across future solvers.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `KineticIon.uses_total_current` path in `_ion_compute_derivative_hook` hard-requires `_cached_total_current` and raises at runtime if it is missing; consider either guarding this path behind an explicit `update_policy`/owner check or providing a clearer public contract for how kinetic ions are expected to be driven outside `Cell(update_policy='family_phased')`.
- The `Cell._update_family_phased` implementation introduces a fairly involved parallel scheduling stack (`_PhaseTarget`, `_collect_*`, `_run_independent_phase_solver`); it may be worth isolating this into a dedicated module or reusing more of the existing solver abstraction to avoid duplication and make it easier to keep phase ordering consistent across future solvers.

## Individual Comments

### Comment 1
<location path="braincell/_base_ion.py" line_range="222-228" />
<code_context>
             node.reset_state(V, ion_info, batch_size)

     def update(self, V, *args, **kwargs):
+        if isinstance(self, IndependentIntegration):
+            self.make_integration(V)
+            ion_info = self.pack_info()
+            for key, node in brainstate.graph.nodes(self, Channel, allowed_hierarchy=(1, 1)).items():
+                if isinstance(node, IndependentIntegration):
+                    node.update(V, ion_info)
+            return
         ion_info = self.pack_info()
         for key, node in brainstate.graph.nodes(self, Channel, allowed_hierarchy=(1, 1)).items():
</code_context>
<issue_to_address>
**issue (bug_risk):** IndependentIntegration ions skip non-independent child channels in update().

With this override, when `self` is an `IndependentIntegration`, only child channels that are also `IndependentIntegration` get their `update` called; any other `Channel` children are skipped entirely. If such mixed configurations are valid, those non-independent channels will stop updating once the parent becomes independent. Consider either calling `update` on all child channels:

```python
if isinstance(self, IndependentIntegration):
    self.make_integration(V)
    ion_info = self.pack_info()
    for _, node in brainstate.graph.nodes(self, Channel, allowed_hierarchy=(1, 1)).items():
        node.update(V, ion_info)
    return
```

or clearly documenting/enforcing that independent ions cannot own non-independent channels.
</issue_to_address>

### Comment 2
<location path="braincell/_multi_compartment/cell.py" line_range="1814-1825" />
<code_context>
+            else:
+                ion._run_ion_hook("_ion_post_integral_hook", point_V_after)
+
+    def _cache_family_phased_currents(self, point_V):
+        runtime = self.runtime
+        for ion_name, ion in runtime.ions.items():
+            if getattr(type(ion), "uses_total_current", False):
+                # Cache a step-start current snapshot for ion dynamics only.
+                # The voltage phase still evaluates membrane current through the
+                # existing path; this cache merely prevents ion dynamics from
+                # re-reading a newer current after channel / voltage states have
+                # already advanced.
+                ion._cached_total_current = ion.current(point_V, include_external=True)
+                ion._cached_total_current_policy = self._update_policy
+                ion._cached_total_current_time = self._resolve_t()
+
     def reset_state(self, batch_size=None) -> None:
</code_context>
<issue_to_address>
**suggestion:** Cached current metadata fields are written but never read, suggesting either dead state or a missing consumer.

The `_cache_family_phased_currents` helper writes three attributes on each ion:

```python
aio n._cached_total_current = ...
ion._cached_total_current_policy = self._update_policy
ion._cached_total_current_time = self._resolve_t()
```

Only `_cached_total_current` is read later (in `DynamicNernstIon` / `KineticIon` hooks); the policy/time fields are unused in the new code. If they’re meant for validation (e.g., checking the cache’s update policy or timestamp), consider adding those checks now. Otherwise, drop the unused attributes to avoid dead state and implied but unenforced invariants.

```suggestion
    def _cache_family_phased_currents(self, point_V):
        runtime = self.runtime
        for ion_name, ion in runtime.ions.items():
            if getattr(type(ion), "uses_total_current", False):
                # Cache a step-start current snapshot for ion dynamics only.
                # The voltage phase still evaluates membrane current through the
                # existing path; this cache merely prevents ion dynamics from
                # re-reading a newer current after channel / voltage states have
                # already advanced.
                ion._cached_total_current = ion.current(point_V, include_external=True)
```
</issue_to_address>

### Comment 3
<location path="braincell/ion/_base_test.py" line_range="113-122" />
<code_context>
+class _SimpleKineticIon(Calcium, KineticIon):
</code_context>
<issue_to_address>
**suggestion (testing):** Add tests for KineticIon/_Specs validation error paths (missing Ci, duplicate species/factors, invalid conserves/reactions).

The new KineticIon/_Specs logic has substantial validation (unique factor/species names, required 'Ci', valid Conserve membership and size, no algebraic 'Ci', reaction stoichiometry rules, and source/target constraints), but current tests only hit the happy path via _SimpleKineticIon. Please add table-driven tests with minimal dummy KineticIon subclasses that trigger these failures (e.g., missing 'Ci', duplicate species, Conserve with non-species/algebraic 'Ci', invalid stoichiometry, Source targeting algebraic species) and assert the expected ValueError/TypeError from init_state or species_values.

Suggested implementation:

```python
import pytest

from braincell.ion._base import Reaction

```

```python
from braincell.ion._base import Source
from braincell.ion._base import Species
from braincell.quad.protocol import DiffEqState


class _MissingCiIon(KineticIon):
    """KineticIon without a 'Ci' species – should fail Specs validation."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    # Intentionally omit 'Ci'
    species = (
        Species("B", init=1.0 * u.mM, factor="cyto"),
    )


class _DuplicateSpeciesIon(KineticIon):
    """KineticIon with duplicate species names – should fail Specs validation."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    species = (
        Species("Ci", init=0.1 * u.mM, factor="cyto"),
        # Duplicate name 'Ci'
        Species("Ci", init=0.2 * u.mM, factor="cyto"),
    )


class _AlgebraicCiIon(KineticIon):
    """KineticIon with algebraic 'Ci' – Specs should forbid algebraic Ci."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    # Mark Ci as algebraic / non-differential according to Species API
    species = (
        Species("Ci", init=0.1 * u.mM, factor="cyto", algebraic=True),
    )


class _InvalidConserveIon(KineticIon):
    """KineticIon with invalid Conserve definition (non-species/algebraic Ci)."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    species = (
        Species("Ci", init=0.1 * u.mM, factor="cyto"),
        Species("B", init=1.0 * u.mM, factor="cyto"),
    )

    # Conserve definition is intentionally invalid; exact API may differ.
    conserves = (
        Conserve("total_B", members=("Ci", "nonexistent_species")),
    )


class _InvalidReactionStoichIon(KineticIon):
    """KineticIon with invalid reaction stoichiometry rules."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    species = (
        Species("Ci", init=0.1 * u.mM, factor="cyto"),
        Species("B", init=1.0 * u.mM, factor="cyto"),
    )

    # Reaction with illegal stoichiometry (e.g., Ci only on one side, or
    # violating charge/particle conservation) – adjust to match actual rules.
    reactions = (
        Reaction("bad_reaction", reactants={"Ci": 1}, products={"B": 2}),
    )


class _SourceToAlgebraicSpeciesIon(KineticIon):
    """KineticIon with a Source targeting an algebraic species."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    species = (
        Species("Ci", init=0.1 * u.mM, factor="cyto"),
        Species("A", init=0.0 * u.mM, factor="cyto", algebraic=True),
    )

    # Source is intentionally targeting algebraic species 'A'
    sources = (
        Source("bad_source", target="A", current=lambda self, t: 0 * u.mM / u.ms),
    )


@pytest.mark.parametrize(
    "ion_cls, error_substring",
    (
        (_MissingCiIon, "Ci"),
        (_DuplicateSpeciesIon, "duplicate"),
        (_AlgebraicCiIon, "algebraic"),
        (_InvalidConserveIon, "Conserve"),
        (_InvalidReactionStoichIon, "reaction"),
        (_SourceToAlgebraicSpeciesIon, "Source"),
    ),
)
def test_kinetic_ion_specs_validation_errors(ion_cls, error_substring):
    """
    Table-driven tests that exercise KineticIon/_Specs validation error paths.

    Each dummy KineticIon subclass has a deliberately invalid Specs definition.
    We assert that initializing state (or computing species values) raises the
    appropriate validation error.
    """
    with pytest.raises((ValueError, TypeError)) as excinfo:
        ion = ion_cls()  # __init__ signature may require args; see additional_changes.
        # Trigger Specs validation. Depending on implementation, this may
        # happen on __init__, init_state, or species_values.
        #
        # The following line assumes init_state exists and takes a DiffEqState
        # (or similar) – adjust as needed.
        ion.init_state(DiffEqState())

    assert error_substring in str(excinfo.value)


class _RecorderChannel(Channel):

```

1. Ensure the following imports exist (either already present or added near the top of the file): `from braincell.ion._base import KineticIon, Factor, Conserve, Calcium` and the units alias `from braincell import u` (or whatever the project uses for units). Adjust the `Source`, `Reaction`, `Species`, `Factor`, `Conserve`, and `DiffEqState` imports to match the actual module layout.
2. The `Species` constructor in your codebase may not use an `algebraic` keyword; replace `algebraic=True` with the correct way to define algebraic/non-differential species (e.g., `ode=None`, `expr=...`, or a specific flag) and adjust the tests accordingly.
3. The `Conserve`, `Reaction`, and `Source` constructors in this patch are based on typical signatures. Update the arguments (`members=`, `reactants=`, `products=`, `target=`, etc.) to match your actual API and to create configurations that violate your specific validation rules (e.g., invalid membership, illegal stoichiometry, or Sources targeting algebraic species).
4. The `ion_cls()` call in `test_kinetic_ion_specs_validation_errors` assumes a no-arg constructor; if your `KineticIon` subclasses require parameters (e.g., morphology, channel density, or diffusion coefficients), update the test to construct them with minimal valid arguments.
5. The `ion.init_state(DiffEqState())` call is a placeholder for whatever method actually triggers `_Specs` validation in your implementation (`init_state`, `species_values`, or another initializer). Replace this with the correct method and a minimal `DiffEqState` instance (or other required arguments) to ensure each invalid Specs configuration raises the expected `ValueError`/`TypeError`.
6. If your validation errors use specific custom exception types or distinct messages, narrow the `pytest.raises` type and replace the generic `error_substring` checks with exact message matches or more precise assertions.
</issue_to_address>

### Comment 4
<location path="braincell/_multi_compartment/cell_test.py" line_range="296-305" />
<code_context>
+        with self.assertRaises(ValueError):
+            Cell(_simple_cell().morpho, cv_policy=CVPerBranch(), update_policy="bad")
+
+    def test_family_phased_channel_runs_before_ion(self):
+        cell = self._build_family_phased_cell()
+        cell.update_policy = "family_phased"
+        cell.init_state()
+        ion = cell.get_ion("ca_dyn")
+        channel = ion.channels["ca_dep"]
+        ion.Ci.value = jnp.asarray([1.0]) * u.mM
+        with brainstate.environ.context(dt=0.1 * u.ms):
+            cell.update()
+
+        self.assertEqual(_PHASE_LOG, ["channel_compute", "ion_compute"])
+
+    def test_family_phased_ion_uses_cached_current(self):
</code_context>
<issue_to_address>
**suggestion (testing):** Exercise family_phased behaviour with independently integrated channels/ions to cover the independent-path helpers.

The new family-phased code adds helpers and special handling for IndependentIntegration channels/ions, but the tests only cover the regular (non-independent) path. Please add or extend a test so that a channel, an ion, or both use IndependentIntegration with non-trivial substeps, and assert that (1) family order is preserved and (2) their make_integration/solver path is exercised (e.g., via call logging). This will ensure the independent path remains covered and robust to future refactors.

Suggested implementation:

```python
    def test_family_phased_ion_uses_cached_current(self):
        cell = self._build_family_phased_cell()
        cell.update_policy = "family_phased"
        cell.init_state()
        ion = cell.get_ion("ca_dyn")
        channel = ion.channels["ca_dep"]
        ion.Ci.value = jnp.asarray([1.0]) * u.mM
        channel.p.value = jnp.asarray([999.0])
        with brainstate.environ.context(dt=0.1 * u.ms):
            cell.update()

    def test_family_phased_independent_integration_channel_and_ion(self):
        """Exercise IndependentIntegration helpers under the family_phased policy.

        This ensures that:
        1. The independent integration path (make_integration / solver) is exercised.
        2. Family order is preserved (channel runs before ion) even for independent paths.
        """
        # Build a cell where both the channel and ion are configured for independent integration
        cell = self._build_family_phased_cell(
            independent_channel=True,
            independent_ion=True,
        )
        cell.update_policy = "family_phased"
        cell.init_state()

        ion = cell.get_ion("ca_dyn")
        channel = ion.channels["ca_dep"]

        # Initialise some non-trivial state so that the integrators actually run
        ion.Ci.value = jnp.asarray([1.0]) * u.mM
        channel.p.value = jnp.asarray([0.5])

        # Clear phase log so we only see events from this test
        _PHASE_LOG.clear()

        # Use a non-trivial number of substeps to exercise the independent solver helpers
        with brainstate.environ.context(dt=0.1 * u.ms, substeps=5):
            cell.update()

        # Assert that the independent integration helpers were invoked.
        # These entries should be appended by the IndependentIntegration-aware
        # make_integration / solver wrappers for the channel and ion.
        self.assertIn("independent_channel_make_integration", _PHASE_LOG)
        self.assertIn("independent_ion_make_integration", _PHASE_LOG)

        # Also ensure that the compute phase order is preserved for the family:
        # the channel's compute should precede the ion's compute in the phase log.
        self.assertLess(
            _PHASE_LOG.index("independent_channel_compute"),
            _PHASE_LOG.index("independent_ion_compute"),
        )

```

To fully implement this test and satisfy the comment:

1. **Extend `_build_family_phased_cell`** (in this file or the corresponding helper module) to accept `independent_channel` and `independent_ion` keyword arguments. When these are `True`, construct the `"ca_dep"` channel and `"ca_dyn"` ion using the IndependentIntegration path (e.g. by wrapping their integration in the IndependentIntegration helper or by selecting the IndependentIntegration-based implementation).

2. **Hook into the IndependentIntegration helpers** used by channels/ions so they append the following markers to `_PHASE_LOG`:
   - `"independent_channel_make_integration"` when the independent channel's `make_integration`/solver wrapper is invoked.
   - `"independent_ion_make_integration"` when the independent ion's `make_integration`/solver wrapper is invoked.
   - `"independent_channel_compute"` and `"independent_ion_compute"` when their respective compute phases run under the independent path.

   The exact place to log these should be inside the IndependentIntegration-specific `make_integration`/solver implementations for the channel and ion, consistent with how the existing `"channel_compute"` / `"ion_compute"` markers are emitted for the non-independent path.

3. Ensure that the IndependentIntegration helpers respect the `substeps` argument from `brainstate.environ.context`, so the `substeps=5` in the new test actually drives the independent solver path (rather than being ignored).
</issue_to_address>

### Comment 5
<location path="braincell/ion/_base.py" line_range="332" />
<code_context>
         raise NotImplementedError
+
+
+class KineticIon(IndependentIntegration):
+    """Template for NMODL-style kinetic ion species.
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider caching _Specs, _Species, _Conserve, and _Flux as per-instance adapters in _init_kinetic_ion and reusing them in hooks to simplify the runtime pipeline and avoid redundant constructions.

You can reduce both structural and cognitive complexity without changing behavior by instantiating the runtime helpers once per ion instance instead of rebuilding `_Specs`, `_Species`, `_Conserve`, and `_Flux` in every hook.

That flattens the helper stack and makes the data flow clearer (“this instance has its runtime adapter”) while also avoiding repeated allocations.

### 1. Cache runtime adapters on the instance

In `_init_kinetic_ion`, build and cache the spec/runtime helpers:

```python
class KineticIon(IndependentIntegration):
    ...

    def _init_kinetic_ion(
        self,
        *,
        Co=None,
        temp=None,
        valence=None,
        solver: str = "rk4",
        substeps: int = 1,
    ):
        ...
        self.temp = braintools.init.param(temp, self.varshape, allow_none=False)

        # NEW: cache specs + runtime helpers
        self._specs = _Specs.for_type(type(self))
        self._species = _Species(self, self._specs)
        self._conserve = _Conserve(self, self._specs, self._species)
        self._flux = _Flux(self, self._specs, self._species)
```

This keeps all existing validation/caching behavior but turns `_Specs` into a per-type “compile-time” artifact and `_Species`/`_Conserve`/`_Flux` into per-instance “runtime adapters” with explicit ownership.

### 2. Reuse cached adapters in hooks

Then simplify the hooks to use the cached adapters instead of re-creating them:

```python
class KineticIon(IndependentIntegration):
    ...

    def species_values(self):
        """Return the current full visible species view."""
        return self._conserve.resolve()

    def _ion_init_state_hook(self, V, batch_size: int = None):
        """Initialize runtime species and project algebraic species."""
        self._species.init(batch_size=batch_size)
        self._conserve.writeback(V)

    def _ion_reset_state_hook(self, V, batch_size: int = None):
        """Reset runtime species and project algebraic species."""
        self._species.reset(batch_size=batch_size)
        self._conserve.writeback(V)

    def _ion_compute_derivative_hook(self, V):
        """Resolve algebraic species and write diffeq derivatives."""
        total_current = None
        if type(self).uses_total_current:
            if not hasattr(self, "_cached_total_current"):
                raise RuntimeError(
                    f"{type(self).__name__} requires a cached total current before compute_derivative()."
                )
            total_current = self._cached_total_current

        species_values = self._conserve.resolve(V)
        self._flux.compute(V, species_values, total_current=total_current)

    def _ion_post_integral_hook(self, V):
        """Refresh cached algebraic species after one integration step."""
        self._conserve.writeback(V)
```

Key benefits:

- Eliminates repeated `_Specs.for_type(type(self))` and `_Species/_Conserve/_Flux` constructions.
- Makes the runtime pipeline more discoverable (`self._species`, `self._conserve`, `self._flux` are clearly the adapters).
- Preserves all existing semantics and public APIs.

This is a small, targeted refactor that directly addresses point (1) from the review (“deep helper stack and indirection”) without removing features or changing the declarative DSL.
</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 thread braincell/_base_ion.py
Comment on lines +222 to +228
if isinstance(self, IndependentIntegration):
self.make_integration(V)
ion_info = self.pack_info()
for key, node in brainstate.graph.nodes(self, Channel, allowed_hierarchy=(1, 1)).items():
if isinstance(node, IndependentIntegration):
node.update(V, ion_info)
return
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): IndependentIntegration ions skip non-independent child channels in update().

With this override, when self is an IndependentIntegration, only child channels that are also IndependentIntegration get their update called; any other Channel children are skipped entirely. If such mixed configurations are valid, those non-independent channels will stop updating once the parent becomes independent. Consider either calling update on all child channels:

if isinstance(self, IndependentIntegration):
    self.make_integration(V)
    ion_info = self.pack_info()
    for _, node in brainstate.graph.nodes(self, Channel, allowed_hierarchy=(1, 1)).items():
        node.update(V, ion_info)
    return

or clearly documenting/enforcing that independent ions cannot own non-independent channels.

Comment thread braincell/_multi_compartment/cell.py Outdated
Comment on lines +1814 to +1825
def _cache_family_phased_currents(self, point_V):
runtime = self.runtime
for ion_name, ion in runtime.ions.items():
if getattr(type(ion), "uses_total_current", False):
# Cache a step-start current snapshot for ion dynamics only.
# The voltage phase still evaluates membrane current through the
# existing path; this cache merely prevents ion dynamics from
# re-reading a newer current after channel / voltage states have
# already advanced.
ion._cached_total_current = ion.current(point_V, include_external=True)
ion._cached_total_current_policy = self._update_policy
ion._cached_total_current_time = self._resolve_t()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion: Cached current metadata fields are written but never read, suggesting either dead state or a missing consumer.

The _cache_family_phased_currents helper writes three attributes on each ion:

aio n._cached_total_current = ...
ion._cached_total_current_policy = self._update_policy
ion._cached_total_current_time = self._resolve_t()

Only _cached_total_current is read later (in DynamicNernstIon / KineticIon hooks); the policy/time fields are unused in the new code. If they’re meant for validation (e.g., checking the cache’s update policy or timestamp), consider adding those checks now. Otherwise, drop the unused attributes to avoid dead state and implied but unenforced invariants.

Suggested change
def _cache_family_phased_currents(self, point_V):
runtime = self.runtime
for ion_name, ion in runtime.ions.items():
if getattr(type(ion), "uses_total_current", False):
# Cache a step-start current snapshot for ion dynamics only.
# The voltage phase still evaluates membrane current through the
# existing path; this cache merely prevents ion dynamics from
# re-reading a newer current after channel / voltage states have
# already advanced.
ion._cached_total_current = ion.current(point_V, include_external=True)
ion._cached_total_current_policy = self._update_policy
ion._cached_total_current_time = self._resolve_t()
def _cache_family_phased_currents(self, point_V):
runtime = self.runtime
for ion_name, ion in runtime.ions.items():
if getattr(type(ion), "uses_total_current", False):
# Cache a step-start current snapshot for ion dynamics only.
# The voltage phase still evaluates membrane current through the
# existing path; this cache merely prevents ion dynamics from
# re-reading a newer current after channel / voltage states have
# already advanced.
ion._cached_total_current = ion.current(point_V, include_external=True)

Comment on lines +113 to +122
class _SimpleKineticIon(Calcium, KineticIon):
default_Co = 2.0 * u.mM
default_valence = 2

factors = (
Factor("cyto", lambda self: self.cyt_volume),
)
species = (
Species("Ci", init=0.1 * u.mM, factor="cyto"),
Species("B", init=1.0 * u.mM, factor="cyto"),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (testing): Add tests for KineticIon/_Specs validation error paths (missing Ci, duplicate species/factors, invalid conserves/reactions).

The new KineticIon/_Specs logic has substantial validation (unique factor/species names, required 'Ci', valid Conserve membership and size, no algebraic 'Ci', reaction stoichiometry rules, and source/target constraints), but current tests only hit the happy path via _SimpleKineticIon. Please add table-driven tests with minimal dummy KineticIon subclasses that trigger these failures (e.g., missing 'Ci', duplicate species, Conserve with non-species/algebraic 'Ci', invalid stoichiometry, Source targeting algebraic species) and assert the expected ValueError/TypeError from init_state or species_values.

Suggested implementation:

import pytest

from braincell.ion._base import Reaction
from braincell.ion._base import Source
from braincell.ion._base import Species
from braincell.quad.protocol import DiffEqState


class _MissingCiIon(KineticIon):
    """KineticIon without a 'Ci' species – should fail Specs validation."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    # Intentionally omit 'Ci'
    species = (
        Species("B", init=1.0 * u.mM, factor="cyto"),
    )


class _DuplicateSpeciesIon(KineticIon):
    """KineticIon with duplicate species names – should fail Specs validation."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    species = (
        Species("Ci", init=0.1 * u.mM, factor="cyto"),
        # Duplicate name 'Ci'
        Species("Ci", init=0.2 * u.mM, factor="cyto"),
    )


class _AlgebraicCiIon(KineticIon):
    """KineticIon with algebraic 'Ci' – Specs should forbid algebraic Ci."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    # Mark Ci as algebraic / non-differential according to Species API
    species = (
        Species("Ci", init=0.1 * u.mM, factor="cyto", algebraic=True),
    )


class _InvalidConserveIon(KineticIon):
    """KineticIon with invalid Conserve definition (non-species/algebraic Ci)."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    species = (
        Species("Ci", init=0.1 * u.mM, factor="cyto"),
        Species("B", init=1.0 * u.mM, factor="cyto"),
    )

    # Conserve definition is intentionally invalid; exact API may differ.
    conserves = (
        Conserve("total_B", members=("Ci", "nonexistent_species")),
    )


class _InvalidReactionStoichIon(KineticIon):
    """KineticIon with invalid reaction stoichiometry rules."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    species = (
        Species("Ci", init=0.1 * u.mM, factor="cyto"),
        Species("B", init=1.0 * u.mM, factor="cyto"),
    )

    # Reaction with illegal stoichiometry (e.g., Ci only on one side, or
    # violating charge/particle conservation) – adjust to match actual rules.
    reactions = (
        Reaction("bad_reaction", reactants={"Ci": 1}, products={"B": 2}),
    )


class _SourceToAlgebraicSpeciesIon(KineticIon):
    """KineticIon with a Source targeting an algebraic species."""
    default_Co = 2.0 * u.mM
    default_valence = 2

    factors = (
        Factor("cyto", lambda self: self.cyt_volume),
    )
    species = (
        Species("Ci", init=0.1 * u.mM, factor="cyto"),
        Species("A", init=0.0 * u.mM, factor="cyto", algebraic=True),
    )

    # Source is intentionally targeting algebraic species 'A'
    sources = (
        Source("bad_source", target="A", current=lambda self, t: 0 * u.mM / u.ms),
    )


@pytest.mark.parametrize(
    "ion_cls, error_substring",
    (
        (_MissingCiIon, "Ci"),
        (_DuplicateSpeciesIon, "duplicate"),
        (_AlgebraicCiIon, "algebraic"),
        (_InvalidConserveIon, "Conserve"),
        (_InvalidReactionStoichIon, "reaction"),
        (_SourceToAlgebraicSpeciesIon, "Source"),
    ),
)
def test_kinetic_ion_specs_validation_errors(ion_cls, error_substring):
    """
    Table-driven tests that exercise KineticIon/_Specs validation error paths.

    Each dummy KineticIon subclass has a deliberately invalid Specs definition.
    We assert that initializing state (or computing species values) raises the
    appropriate validation error.
    """
    with pytest.raises((ValueError, TypeError)) as excinfo:
        ion = ion_cls()  # __init__ signature may require args; see additional_changes.
        # Trigger Specs validation. Depending on implementation, this may
        # happen on __init__, init_state, or species_values.
        #
        # The following line assumes init_state exists and takes a DiffEqState
        # (or similar) – adjust as needed.
        ion.init_state(DiffEqState())

    assert error_substring in str(excinfo.value)


class _RecorderChannel(Channel):
  1. Ensure the following imports exist (either already present or added near the top of the file): from braincell.ion._base import KineticIon, Factor, Conserve, Calcium and the units alias from braincell import u (or whatever the project uses for units). Adjust the Source, Reaction, Species, Factor, Conserve, and DiffEqState imports to match the actual module layout.
  2. The Species constructor in your codebase may not use an algebraic keyword; replace algebraic=True with the correct way to define algebraic/non-differential species (e.g., ode=None, expr=..., or a specific flag) and adjust the tests accordingly.
  3. The Conserve, Reaction, and Source constructors in this patch are based on typical signatures. Update the arguments (members=, reactants=, products=, target=, etc.) to match your actual API and to create configurations that violate your specific validation rules (e.g., invalid membership, illegal stoichiometry, or Sources targeting algebraic species).
  4. The ion_cls() call in test_kinetic_ion_specs_validation_errors assumes a no-arg constructor; if your KineticIon subclasses require parameters (e.g., morphology, channel density, or diffusion coefficients), update the test to construct them with minimal valid arguments.
  5. The ion.init_state(DiffEqState()) call is a placeholder for whatever method actually triggers _Specs validation in your implementation (init_state, species_values, or another initializer). Replace this with the correct method and a minimal DiffEqState instance (or other required arguments) to ensure each invalid Specs configuration raises the expected ValueError/TypeError.
  6. If your validation errors use specific custom exception types or distinct messages, narrow the pytest.raises type and replace the generic error_substring checks with exact message matches or more precise assertions.

Comment on lines +296 to +305
def test_family_phased_channel_runs_before_ion(self):
cell = self._build_family_phased_cell()
cell.update_policy = "family_phased"
cell.init_state()
ion = cell.get_ion("ca_dyn")
channel = ion.channels["ca_dep"]
ion.Ci.value = jnp.asarray([1.0]) * u.mM
with brainstate.environ.context(dt=0.1 * u.ms):
cell.update()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

suggestion (testing): Exercise family_phased behaviour with independently integrated channels/ions to cover the independent-path helpers.

The new family-phased code adds helpers and special handling for IndependentIntegration channels/ions, but the tests only cover the regular (non-independent) path. Please add or extend a test so that a channel, an ion, or both use IndependentIntegration with non-trivial substeps, and assert that (1) family order is preserved and (2) their make_integration/solver path is exercised (e.g., via call logging). This will ensure the independent path remains covered and robust to future refactors.

Suggested implementation:

    def test_family_phased_ion_uses_cached_current(self):
        cell = self._build_family_phased_cell()
        cell.update_policy = "family_phased"
        cell.init_state()
        ion = cell.get_ion("ca_dyn")
        channel = ion.channels["ca_dep"]
        ion.Ci.value = jnp.asarray([1.0]) * u.mM
        channel.p.value = jnp.asarray([999.0])
        with brainstate.environ.context(dt=0.1 * u.ms):
            cell.update()

    def test_family_phased_independent_integration_channel_and_ion(self):
        """Exercise IndependentIntegration helpers under the family_phased policy.

        This ensures that:
        1. The independent integration path (make_integration / solver) is exercised.
        2. Family order is preserved (channel runs before ion) even for independent paths.
        """
        # Build a cell where both the channel and ion are configured for independent integration
        cell = self._build_family_phased_cell(
            independent_channel=True,
            independent_ion=True,
        )
        cell.update_policy = "family_phased"
        cell.init_state()

        ion = cell.get_ion("ca_dyn")
        channel = ion.channels["ca_dep"]

        # Initialise some non-trivial state so that the integrators actually run
        ion.Ci.value = jnp.asarray([1.0]) * u.mM
        channel.p.value = jnp.asarray([0.5])

        # Clear phase log so we only see events from this test
        _PHASE_LOG.clear()

        # Use a non-trivial number of substeps to exercise the independent solver helpers
        with brainstate.environ.context(dt=0.1 * u.ms, substeps=5):
            cell.update()

        # Assert that the independent integration helpers were invoked.
        # These entries should be appended by the IndependentIntegration-aware
        # make_integration / solver wrappers for the channel and ion.
        self.assertIn("independent_channel_make_integration", _PHASE_LOG)
        self.assertIn("independent_ion_make_integration", _PHASE_LOG)

        # Also ensure that the compute phase order is preserved for the family:
        # the channel's compute should precede the ion's compute in the phase log.
        self.assertLess(
            _PHASE_LOG.index("independent_channel_compute"),
            _PHASE_LOG.index("independent_ion_compute"),
        )

To fully implement this test and satisfy the comment:

  1. Extend _build_family_phased_cell (in this file or the corresponding helper module) to accept independent_channel and independent_ion keyword arguments. When these are True, construct the "ca_dep" channel and "ca_dyn" ion using the IndependentIntegration path (e.g. by wrapping their integration in the IndependentIntegration helper or by selecting the IndependentIntegration-based implementation).

  2. Hook into the IndependentIntegration helpers used by channels/ions so they append the following markers to _PHASE_LOG:

    • "independent_channel_make_integration" when the independent channel's make_integration/solver wrapper is invoked.
    • "independent_ion_make_integration" when the independent ion's make_integration/solver wrapper is invoked.
    • "independent_channel_compute" and "independent_ion_compute" when their respective compute phases run under the independent path.

    The exact place to log these should be inside the IndependentIntegration-specific make_integration/solver implementations for the channel and ion, consistent with how the existing "channel_compute" / "ion_compute" markers are emitted for the non-independent path.

  3. Ensure that the IndependentIntegration helpers respect the substeps argument from brainstate.environ.context, so the substeps=5 in the new test actually drives the independent solver path (rather than being ignored).

Comment thread braincell/ion/_base.py
raise NotImplementedError


class KineticIon(IndependentIntegration):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider caching _Specs, _Species, _Conserve, and _Flux as per-instance adapters in _init_kinetic_ion and reusing them in hooks to simplify the runtime pipeline and avoid redundant constructions.

You can reduce both structural and cognitive complexity without changing behavior by instantiating the runtime helpers once per ion instance instead of rebuilding _Specs, _Species, _Conserve, and _Flux in every hook.

That flattens the helper stack and makes the data flow clearer (“this instance has its runtime adapter”) while also avoiding repeated allocations.

1. Cache runtime adapters on the instance

In _init_kinetic_ion, build and cache the spec/runtime helpers:

class KineticIon(IndependentIntegration):
    ...

    def _init_kinetic_ion(
        self,
        *,
        Co=None,
        temp=None,
        valence=None,
        solver: str = "rk4",
        substeps: int = 1,
    ):
        ...
        self.temp = braintools.init.param(temp, self.varshape, allow_none=False)

        # NEW: cache specs + runtime helpers
        self._specs = _Specs.for_type(type(self))
        self._species = _Species(self, self._specs)
        self._conserve = _Conserve(self, self._specs, self._species)
        self._flux = _Flux(self, self._specs, self._species)

This keeps all existing validation/caching behavior but turns _Specs into a per-type “compile-time” artifact and _Species/_Conserve/_Flux into per-instance “runtime adapters” with explicit ownership.

2. Reuse cached adapters in hooks

Then simplify the hooks to use the cached adapters instead of re-creating them:

class KineticIon(IndependentIntegration):
    ...

    def species_values(self):
        """Return the current full visible species view."""
        return self._conserve.resolve()

    def _ion_init_state_hook(self, V, batch_size: int = None):
        """Initialize runtime species and project algebraic species."""
        self._species.init(batch_size=batch_size)
        self._conserve.writeback(V)

    def _ion_reset_state_hook(self, V, batch_size: int = None):
        """Reset runtime species and project algebraic species."""
        self._species.reset(batch_size=batch_size)
        self._conserve.writeback(V)

    def _ion_compute_derivative_hook(self, V):
        """Resolve algebraic species and write diffeq derivatives."""
        total_current = None
        if type(self).uses_total_current:
            if not hasattr(self, "_cached_total_current"):
                raise RuntimeError(
                    f"{type(self).__name__} requires a cached total current before compute_derivative()."
                )
            total_current = self._cached_total_current

        species_values = self._conserve.resolve(V)
        self._flux.compute(V, species_values, total_current=total_current)

    def _ion_post_integral_hook(self, V):
        """Refresh cached algebraic species after one integration step."""
        self._conserve.writeback(V)

Key benefits:

  • Eliminates repeated _Specs.for_type(type(self)) and _Species/_Conserve/_Flux constructions.
  • Makes the runtime pipeline more discoverable (self._species, self._conserve, self._flux are clearly the adapters).
  • Preserves all existing semantics and public APIs.

This is a small, targeted refactor that directly addresses point (1) from the review (“deep helper stack and indirection”) without removing features or changing the declarative DSL.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants