Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
47f6477
feat(models): update exports to include dsl submodule
neich May 7, 2026
6991462
docs(dsl_models): add readme for dsl model specs and usage
neich May 7, 2026
c6deab6
feat(dsl_models): add package init with dsl model exports
neich May 7, 2026
c539bf2
feat(dsl_models): add deco2014 dsl spec with dfun and coupling
neich May 7, 2026
666e369
feat(dsl_models): add hopf dsl spec with diffusive coupling
neich May 7, 2026
e7bf02c
feat(dsl_models): add montbrio dsl spec with six state variables
neich May 7, 2026
6798263
feat(dsl_models): add naskar2021 dsl spec with inhibitory plasticity
neich May 7, 2026
2a7f3a6
docs(dsl): add readme for dsl declarative model system
neich May 7, 2026
420dfe4
feat(dsl): add public api init for declarative model dsl
neich May 7, 2026
413e917
feat(dsl): add builder module to generate model from spec
neich May 7, 2026
e2d31cc
feat(dsl): add dependent parameter dag analysis and topo sort
neich May 7, 2026
e757c63
feat(dsl): add on-disk module materialization for numba cache
neich May 7, 2026
a7bbdbc
feat(dsl): add ast rewriter to transform equations to numba form
neich May 7, 2026
e83ecd1
feat(dsl): add spec dataclasses for model declaration
neich May 7, 2026
ca7dc90
test(tests): add shared fixtures for dsl test suite
neich May 7, 2026
cda71e9
test(tests): add spec and equation validation unit tests
neich May 7, 2026
4b4ac1a
test(tests): add dependent parameter dag and evaluation tests
neich May 7, 2026
ae65e4a
test(tests): add dfun and coupling equivalence tests for dsl models
neich May 7, 2026
0bf4043
test(tests): add ux polish and cache dir configuration tests
neich May 7, 2026
986c43c
test(tests): add end-to-end simulator trajectory equivalence tests
neich May 7, 2026
e3ba89d
test(tests): add subclass recursion regression tests for dsl models
neich May 7, 2026
81eaa82
feat(dsl_models): add paired simulation comparison script
neich May 7, 2026
84b5aa6
feat(simulator): rename HistoryDense to HistoryDelays and fix buffer …
neich May 7, 2026
86c8101
docs(dsl): update readme with parameter and coupling clarifications
neich May 7, 2026
2234dc6
feat(dsl): update dfun builder to use closure-based param capture
neich May 7, 2026
a9ef80b
feat(dsl): update dependents to allow model context names in formulas
neich May 7, 2026
6e9b7c6
feat(dsl): update rewriter to support helper functions in dfun bodies
neich May 7, 2026
e6dbc71
feat(dsl): update spec to support matrix params and delayed coupling
neich May 7, 2026
c2f312f
feat(simulator): update configure to wire HistoryDelays with tract de…
neich May 7, 2026
5b286e0
test(tests): update dependents tests to verify dfun closure capture
neich May 7, 2026
a5ba031
test(tests): add delayed coupling spec and equivalence tests
neich May 7, 2026
35cc534
test(tests): add helper function callable from dfun tests
neich May 7, 2026
0c243d9
test(tests): add numerical jacobian shape and correctness tests
neich May 7, 2026
8d691a6
test(tests): add matrix-shaped dependent parameter closure tests
neich May 7, 2026
92b8cca
test(tests): add on_configure escape hatch and fic use case tests
neich May 7, 2026
a9c6a71
docs(dsl_models): update readme with loc counts and builder api docs
neich May 7, 2026
7f7a450
feat(dsl): update public api to export modelbuilder class
neich May 7, 2026
c29ab68
feat(dsl): add fluent incremental builder for modelspec assembly
neich May 7, 2026
0a8a471
test(tests): add modelbuilder chaining and imperative style tests
neich May 7, 2026
04f4c1c
docs(dsl_models): update readme with zerlaut port and feature coverage
neich May 7, 2026
4688a8c
feat(dsl_models): update exports to include zerlaut dsl models
neich May 7, 2026
fee5dfa
feat(dsl): update rewriter to support tuple-unpacking assignments
neich May 7, 2026
668667b
feat(models): update zerlaut dfun to hoist m_aux out of jit closure
neich May 7, 2026
55276b0
test(tests): update conftest to add zerlaut spec and class fixtures
neich May 7, 2026
8041113
test(tests): update equivalence tests to cover zerlaut models
neich May 7, 2026
54bab4b
test(tests): update helpers tests to cover tuple-unpacking cases
neich May 7, 2026
4fba908
feat(dsl_models): add zerlaut dsl spec with first and second order
neich May 7, 2026
9da14e3
docs(dsl_models): update readme with per-spec usage guide
neich May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions examples/dsl_models/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Reference DSL specs

This directory contains the canonical neuronumba models expressed in the DSL.
Each spec produces a `Model` subclass that is **bit-equivalent** to its
hand-written counterpart in `src/neuronumba/simulator/models/`.

| Spec | Hand-written | LOC ratio |
|---|---|---|
| [hopf_dsl.py](hopf_dsl.py) | `hopf.py` | 38 vs 152 |
| [deco2014_dsl.py](deco2014_dsl.py) | `deco2014.py` | 67 vs 416 |
| [naskar2021_dsl.py](naskar2021_dsl.py) | `naskar2021.py` | 69 vs 104 |
| [montbrio_dsl.py](montbrio_dsl.py) | `montbrio.py` | 88 vs 160 |
| [zerlaut_dsl.py](zerlaut_dsl.py) (1st + 2nd order) | `zerlaut.py` | ~320 vs 800 |

There is also [compare_simulations.py](compare_simulations.py): a runnable
script that simulates pairs of DSL and hand-written models on the same inputs
and plots their trajectories side-by-side.

## Dual purpose

These specs are both **examples** and **test references**:

- **Examples** — read them top-to-bottom to learn the DSL. Copy any of them
into your own code and modify; you'll have a working model in minutes.
- **Test references** — `tests/test_dsl_equivalence.py` imports each spec,
builds the DSL model, and compares it against the hand-written model on
random inputs. Disagreement fails the test.

## Drift policy

If you change a hand-written model (bug fix, numerical refinement), update the
DSL spec here in the same commit. The equivalence test will fail loudly if you
forget — read the failure message: it tells you both files involved.

If you change a DSL spec (e.g. for a new feature you want to add), the
equivalence test still has to pass against the hand-written reference. If they
genuinely diverge, that's a model change and belongs in a separate PR.

## What each spec illustrates

Pick the spec that's closest to what you want to build:

- **`hopf_dsl.py`** — minimal 2-variable oscillator. Shows the basic shape
of `state_vars` / `coupling_vars` / `parameters` / `equations` and the
`kind="diffusive"` coupling kernel.
- **`deco2014_dsl.py`** — single-coupling-var rate model with declared
observables (`Ie`, `re`) and an EPS-fix using `np.where` in the equations.
- **`naskar2021_dsl.py`** — extends Deco-style dynamics with a third state
variable that itself follows a slow plasticity rule.
- **`montbrio_dsl.py`** — 6-variable Montbrio model with cross-coupling and
recurrent dependent parameters (`Parameter("J_N_ee", formula="...")`).
- **`zerlaut_dsl.py`** — uses user-supplied `@nb.njit` helpers
(`get_fluct_regime_vars`, `threshold_func`, `TF`, `erfc_approx`) imported
from `neuronumba.simulator.models.zerlaut`. Demonstrates tuple-unpacking
from a multi-return helper, helper composition (`TF` calls the others
internally), and 1D-array parameters as defaults (length-10 polynomial
coefficient vectors).

For matrix-valued parameters and conduction-delay coupling, see
`tests/test_dsl_matrix_params.py` and `tests/test_dsl_delayed.py`.

## Usage

```python
from examples.dsl_models import HopfDSL # if examples/ is on sys.path
# or
from neuronumba.simulator.models.dsl import build_model
from examples.dsl_models.hopf_dsl import hopf_spec
HopfDSL = build_model(hopf_spec)

m = HopfDSL(g=0.5).configure(weights=W)
```

For incremental construction (instead of writing a full `ModelSpec` literal),
use `ModelBuilder`:

```python
from neuronumba.simulator.models.dsl import ModelBuilder

HopfDSL = (ModelBuilder("Hopf")
.add_state("x").add_state("y")
.add_coupling("x", kind="diffusive")
.add_param("a", default=-0.5)
.add_param("omega", default=0.3)
.add_equation("d_x = (a - x*x - y*y)*x - omega*y + coupling.x")
.add_equation("d_y = (a - x*x - y*y)*y + omega*x")
.build())
```
34 changes: 34 additions & 0 deletions examples/dsl_models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Reference DSL specs for neuronumba's canonical models.

These specs reproduce the existing hand-written models exactly. They serve two
purposes simultaneously:

1. **User-facing examples.** Read them to learn how to express a model in the
DSL. Each file is self-contained and small (~30-300 LOC vs 100-800 LOC for
the hand-written equivalents).

2. **Regression test references.** `tests/test_dsl_equivalence.py` imports
these specs and compares the resulting DSL-built models against the
hand-written ones bit-by-bit. If you change a hand-written model (e.g. a
numerical bug fix), the corresponding spec here must follow, or the
equivalence test will fail.
"""
from .deco2014_dsl import deco_spec, Deco2014DSL
from .hopf_dsl import hopf_spec, HopfDSL
from .montbrio_dsl import montbrio_spec, MontbrioDSL
from .naskar2021_dsl import naskar_spec, Naskar2021DSL
from .zerlaut_dsl import (
zerlaut_first_order_spec,
zerlaut_second_order_spec,
ZerlautAdaptationFirstOrderDSL,
ZerlautAdaptationSecondOrderDSL,
)

__all__ = [
"Deco2014DSL", "deco_spec",
"HopfDSL", "hopf_spec",
"MontbrioDSL", "montbrio_spec",
"Naskar2021DSL", "naskar_spec",
"ZerlautAdaptationFirstOrderDSL", "zerlaut_first_order_spec",
"ZerlautAdaptationSecondOrderDSL", "zerlaut_second_order_spec",
]
97 changes: 97 additions & 0 deletions examples/dsl_models/compare_simulations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Run paired (hand-written, DSL) simulations and show that they agree.

For each pair we:
1. Run the hand-written model and the DSL spec on the same connectivity,
params, and deterministic integrator.
2. Plot the two trajectories overlaid (visually identical).
3. Plot the difference, which should sit at machine-precision noise.

Run: PYTHONPATH=src python examples/dsl_models/compare_simulations.py
"""
from __future__ import annotations

import os
import sys

import numpy as np
import matplotlib.pyplot as plt

from neuronumba.simulator.simulator import simulate_nodelay
from neuronumba.simulator.integrators.euler import EulerDeterministic
from neuronumba.simulator.models import Hopf, Deco2014

# Make the dsl_models package importable when run as a script. The script
# lives inside the package, so we add its parent directory to sys.path.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dsl_models import HopfDSL, Deco2014DSL # noqa: E402


def make_W(n: int = 8, seed: int = 0) -> np.ndarray:
"""Symmetric, zero-diagonal connectivity normalized to max 1.0."""
rng = np.random.default_rng(seed)
W = rng.random((n, n))
W = (W + W.T) / 2
np.fill_diagonal(W, 0)
W /= W.max()
return W


def run(model_cls, weights, obs_var: str, *, t_max: float = 1000.0,
dt: float = 0.1, sampling_period: float = 1.0, **model_kwargs):
"""Run a deterministic simulation and return the observed signal."""
np.random.seed(0) # simulate_nodelay draws random tract lengths internally
model = model_cls(**model_kwargs)
integrator = EulerDeterministic(dt=dt)
return simulate_nodelay(
model, integrator, weights, obs_var,
sampling_period=sampling_period,
t_max_neuronal=t_max,
t_warmup=0.0,
)


def plot_pair(ax_overlay, ax_diff, ref, dsl, *, title: str, n_show: int = 4):
"""Overlay (left axis) + difference (right axis) for one model pair."""
t = np.arange(ref.shape[0])
for i in range(min(n_show, ref.shape[1])):
ax_overlay.plot(t, ref[:, i], color="black", lw=1.0, alpha=0.6,
label="hand-written" if i == 0 else None)
ax_overlay.plot(t, dsl[:, i], color="tab:red", lw=0.8, ls="--",
label="DSL" if i == 0 else None)
ax_overlay.set_title(f"{title} — trajectories (first {n_show} ROIs)")
ax_overlay.set_xlabel("sample index")
ax_overlay.legend(loc="upper right", fontsize=9)

diff = ref - dsl
max_abs = np.abs(diff).max()
ax_diff.plot(t, diff, lw=0.6)
ax_diff.set_title(f"{title} — difference (max |Δ| = {max_abs:.2e})")
ax_diff.set_xlabel("sample index")
ax_diff.axhline(0.0, color="black", lw=0.5, alpha=0.3)


def main():
n_rois = 8
W = make_W(n=n_rois)

# Hopf: diffusive coupling, observe state variable x.
hopf_ref = run(Hopf, W, obs_var="x", g=0.5)
hopf_dsl = run(HopfDSL, W, obs_var="x", g=0.5)

# Deco2014: linear coupling, observe state variable S_e.
deco_ref = run(Deco2014, W, obs_var="S_e", g=0.5, auto_fic=False)
deco_dsl = run(Deco2014DSL, W, obs_var="S_e", g=0.5)

fig, axes = plt.subplots(2, 2, figsize=(12, 7))
plot_pair(axes[0, 0], axes[0, 1], hopf_ref, hopf_dsl, title="Hopf")
plot_pair(axes[1, 0], axes[1, 1], deco_ref, deco_dsl, title="Deco2014")
fig.suptitle("DSL vs hand-written: deterministic Euler, n=8 ROIs", y=1.0)
fig.tight_layout()

print(f"Hopf max |Δ|: {np.abs(hopf_ref - hopf_dsl).max():.3e}")
print(f"Deco2014 max |Δ|: {np.abs(deco_ref - deco_dsl).max():.3e}")
plt.show()


if __name__ == "__main__":
main()
67 changes: 67 additions & 0 deletions examples/dsl_models/deco2014_dsl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Reduced Wong-Wang / Deco 2014 dynamic mean-field, expressed in the DSL.

DSL equivalent of `neuronumba.simulator.models.deco2014.Deco2014` (416 LOC).
Note this spec covers only the dfun + linear coupling — the hand-written class
also has `auto_fic` (FIC steady-state computation) which is not part of the
core differential equations. To use FIC with this DSL model you would override
`_init_dependant` in a subclass. See `tests/test_dsl_subclass.py` for the
subclassing pattern.

`tests/test_dsl_equivalence.py::test_equivalence_deco2014` asserts dfun
equivalence against the hand-written class to 1e-12 (with `auto_fic=False` so
both share the same `J=1.0`).
"""
from neuronumba.simulator.models.dsl import (
ModelSpec, StateVar, CouplingVar, Parameter, build_model,
)


# The hand-written Deco2014 has an EPS-fix to avoid div-by-0 in the firing-rate
# sigmoid. We faithfully reproduce that with `np.where`.
deco_spec = ModelSpec(
name="Deco2014DSL",
state_vars=[
StateVar("S_e", initial=0.001, bounds=(0.0, 1.0)),
StateVar("S_i", initial=0.001, bounds=(0.0, 1.0)),
],
coupling_vars=[CouplingVar("S_e", kind="linear")],
observables=["Ie", "re"],
parameters=[
Parameter("tau_e", default=100.0),
Parameter("tau_i", default=10.0),
Parameter("gamma_e", default=0.000641),
Parameter("gamma_i", default=0.001),
Parameter("I0", default=0.382),
Parameter("Jext_e", default=1.0),
Parameter("Jext_i", default=0.7),
Parameter("w", default=1.4),
Parameter("J_NMDA", default=0.15),
Parameter("J", default=1.0),
Parameter("ae", default=310.0),
Parameter("be", default=125.0),
Parameter("de", default=0.16),
Parameter("ai", default=615.0),
Parameter("bi", default=177.0),
Parameter("di", default=0.087),
Parameter("I_external", default=0.0),
],
equations="""
Ie = Jext_e * I0 + w * J_NMDA * S_e + J_NMDA * coupling.S_e - J * S_i + I_external
Ii = Jext_i * I0 + J_NMDA * S_e - S_i

y_e = ae * Ie - be
denom_e = 1.0 - np.exp(-de * y_e)
denom_e = np.where(np.abs(denom_e) < 1e-12, 1e-12, denom_e)
re = y_e / denom_e

y_i = ai * Ii - bi
denom_i = 1.0 - np.exp(-di * y_i)
denom_i = np.where(np.abs(denom_i) < 1e-12, 1e-12, denom_i)
ri = y_i / denom_i

d_S_e = -S_e / tau_e + gamma_e * (1.0 - S_e) * re
d_S_i = -S_i / tau_i + gamma_i * ri
""",
)

Deco2014DSL = build_model(deco_spec)
38 changes: 38 additions & 0 deletions examples/dsl_models/hopf_dsl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Hopf supercritical bifurcation model, expressed in the neuronumba DSL.

This is the DSL equivalent of `neuronumba.simulator.models.hopf.Hopf` (152 LOC
hand-written). The math is identical — `tests/test_dsl_equivalence.py::
test_equivalence_hopf` compares the dfun and coupling-kernel outputs against
the hand-written class on random inputs and asserts agreement to 1e-12.

If this file changes, that test must still pass; if `hopf.py` changes (e.g. a
numerical bug fix), this file must follow.
"""
from neuronumba.simulator.models.dsl import (
ModelSpec, StateVar, CouplingVar, Parameter, build_model,
)


hopf_spec = ModelSpec(
name="HopfDSL",
state_vars=[
StateVar("x", initial=0.1),
StateVar("y", initial=0.1),
],
coupling_vars=[
CouplingVar("x", kind="diffusive"),
CouplingVar("y", kind="diffusive"),
],
observables=[],
parameters=[
Parameter("a", default=-0.5),
Parameter("omega", default=0.3),
Parameter("I_external", default=0.0),
],
equations="""
d_x = (a - x*x - y*y) * x - omega * y + coupling.x + I_external
d_y = (a - x*x - y*y) * y + omega * x + coupling.y
""",
)

HopfDSL = build_model(hopf_spec)
Loading