Skip to content

Docs: Sphinx → properdocs migration + descending-drive Poisson fix (+ Optuna parallelization)#19

Merged
RaulSimpetru merged 61 commits into
mainfrom
feat/puffer-api
Jul 16, 2026
Merged

Docs: Sphinx → properdocs migration + descending-drive Poisson fix (+ Optuna parallelization)#19
RaulSimpetru merged 61 commits into
mainfrom
feat/puffer-api

Conversation

@RaulSimpetru

@RaulSimpetru RaulSimpetru commented Jun 23, 2026

Copy link
Copy Markdown
Member

Summary

The pufferlib-inspired API-redesign proposal and its kernel core have been moved to a separate PR — they are superseded by a new functional/differentiable API design, and keeping them here mixed unfinished design work with shippable fixes. This PR is now scoped to three verified, independently-reviewable workstreams.

1. Docs: Sphinx → properdocs (MkDocs)

Migrates the documentation to properdocs (mkdocs-material + mkdocstrings), matching MyoGestic / Virtual-Hand-Interface: properdocs.yml (material theme, mkdocstrings API, section-index), flat markdown under docs/ (home, getting-started, neo-blocks), the mkdocstrings API pages, and a mkdocs-gallery example gallery. The Sphinx config (conf.py, rst pages, make/Makefile) is removed; .github/workflows/docs.yml builds + deploys to GitHub Pages.

Example gallery with inline plots. Executing the NEURON gallery in mkdocs-gallery's single, long-lived process segfaults as NEURON's native runtime state accumulates (it has no per-example process isolation), so:

  • docs/gallery_conf.py forces joblib.Paralleln_jobs=1 (spawning loky worker processes after NEURON init is one crash source), no-ops plt.show (removes the "FigureCanvasAgg non-interactive" warning that leaked into every Out block; figures are still captured via plt.get_fignums()), and disables tqdm progress bars (they were being scraped into the rendered output).
  • scripts/build_docs.py wraps the build with bounded retries — mkdocs-gallery md5-caches each executed example, so a retry resumes past the crash in a fresh process — plus a no-progress guard (no infinite loop) and completeness validation (a failed example re-runs; a deterministically-failing one aborts loudly instead of deploying a broken page).
  • Scoped execution: the self-contained 01_basic + 02_finetune galleries execute (inline plots); the paper (03_papers/watanabe) and clinical (04_clinical) galleries render source-only because they rely on __file__ (undefined under exec()) and load each other's .pkl outputs. Execution runs only on the main-branch deploy (+ manual workflow_dispatch); pull requests build source-only so review stays fast (~10s).

Docstring/gallery hygiene: leftover Sphinx rST (:func:/:class:/.. note::) swept from public docstrings; docstring↔signature mismatches fixed (AffIb__Pool, DescendingDrive__Pool.timestep__ms, a stale Muscle cross-ref, and muscle.py's conductivity + phantom Results params → zero griffe warnings); deprecated RANDOM_GENERATOR/SEED labelled; the noise/binning utilities + DescendingDrive_Gamma__Pool documented; deprecated plt.cm.get_cmapplt.get_cmap and neuron.run()→an explicit fadvance loop; and a single-spike divide-by-zero guard in the firing-rate examples.

2. Fix: the descending-drive "Poisson" process was actually a Gamma process

DescendingDrive__Pool(process_type="poisson") with poisson_batch_size/N > 1 did not emit a Poisson process. The inter-spike threshold was drawn as the average of N Exp(1) variates (-(1/N)·log(∏U)), i.e. a Gamma(N, N) renewal process (ISI CV = 1/√N), progressively more regular as N grows. Only N = 1 was ever correct, and the knob silently duplicated the existing DD_Gamma path.

  • The generator now draws a single Exp(1) threshold (-log1p(-U), which also removes a latent log(0) that could permanently silence a unit) — a discrete-time Poisson process (CV = 1, exact in the dt → 0 limit). poisson_batch_size is removed; regular/Gamma firing goes through process_type="gamma", shape=N.
  • The afferents' misnamed poisson_batch_size — really the Gamma shape of the AffIa/AffII/AffIb generators, not a Poisson batch size — is renamed to shape, and the docstrings that called it a "maximum firing rate" are corrected.
  • Call sites migrated: true Poisson where Poisson was intended; explicit process_type="gamma", shape=N where regular firing was deliberately used (behaviour-preserving).
  • New tests/test_poisson_process.py proves — through both the public DescendingDrive__Pool API and the raw generator — that the Poisson path fits a Poisson process (KS vs Exponential, χ² goodness-of-fit on spike counts with the full upper tail, Fano factor, serial independence) and that the Gamma path is a genuine Gamma of the requested shape (KS fit, shape recovery, wrong-shape rejection). The same battery, run on the Gamma process, is the discriminating control.
  • Credit: @joaohbbittar first identified and empirically documented this bug in Misspecified N parameter in Poisson process generator #22 — showing the descending-drive "Poisson" generator was actually a Gamma process. This PR supersedes Misspecified N parameter in Poisson process generator #22 with the root-cause fix and the statistical proof above. Thanks, João!

3. Optuna example parallelization (~5×)

examples/03_papers/watanabe/02 now fans its 25 independent Optuna trials across worker processes sharing one SQLite study: ~2000 s → 139 s, equivalent optimum. Verified faithful (byte-identical sim/objective, deterministic per seed, independently audited). h5py added to the nwb extra so the NWB/HDF5 examples are fully declarable.

Notes for reviewers

  • Pages source: docs.yml deploys via GitHub Actions, so repo Settings → Pages → Source must be set to "GitHub Actions" (the old workflow pushed to a gh-pages branch).

Design proposal for a backend-swappable, vectorized MyoGen API: a shared
flat SimState buffer stepped in lockstep by a backend-agnostic driver, with
two backend seams (neural dynamics NEURON->jaxley; muscle/force analytic->FEM)
and a closed-loop stage graph for afferent feedback. Buffer-first data model
with neo/NWB as opt-in adapters. Maps the full current public surface onto the
new layers and lists net-new work and open questions for team review.
Bite-sized TDD plan for the backend-agnostic simulation kernel (myogen/kernel):
SimState shared buffer, Stage/Backend protocols, the Simulation lockstep driver
with one-tick closed-loop feedback, and the buffer-first SimResult with lazy
neo/NWB adapters. Seven tasks, fully CI-testable without NEURON/jaxley/FEM.
NEURONBackend, real stages, jaxley and FEM are scoped as follow-on plans.
SimResult.from_state() extracts spike times in seconds, copies force and
surface EMG arrays, infers grid_shape from EMG buffer dimensions, and
handles missing optional buffers gracefully. Simulation.run() now returns
SimResult instead of bare SimState; all kernel tests updated to read
buffers through sim.state.
…rocesses

Extract simulation logic and constants into _oscillating_dc_helpers.py,
add _optimize_dc_worker.py subprocess worker, and rewrite the launch in
02_optimize_oscillating_dc.py to fan out N workers that all write to one
shared SQLite Optuna study. Avoids NEURON h singleton / macOS spawn
constraints; gallery-safe via conf.py ignore_pattern for the _ files.
Example 14 (calibrate_noise_from_real) imports h5py directly to load real
recordings, but it was only available transitively. Declaring it in the nwb
extra makes 'uv sync --extra nwb' sufficient to run the NWB/HDF5 examples
(11, 13, 14).
Many MyoGen examples use multiprocessing/joblib (which breaks under the
gallery's exec context) or form slow inter-dependent chains, so executing
the gallery in CI is unreliable. Render examples as source + prose with
full-code download links instead; opt into execution with MKDOCS_GALLERY_PLOT=true.
Add MuscleGeometry/JointGeometry/JointBiomechanics to the simulator API,
the four missing type aliases, and a RANDOM_GENERATOR/SEED note; restore the
How-to-cite, Contributing, and License sections on the home page and the
MUAP simulate/access step in the quick-start (per migration-coverage audit).
Capture one representative figure per example (run standalone, where they work)
and wire them as mkdocs-gallery thumbnails via per-example
mkdocs_gallery_thumbnail_path directives + a logo default_thumb_file fallback.
Gives the source-only gallery real pictures on the overview grid without
executing examples in the docs build (which spawn-bombs on the multiprocessing
examples).
- CI: execute the mkdocs-gallery examples (MKDOCS_GALLERY_PLOT=true) in
  the docs build so figures render inline in the deployed site, matching
  the previous Sphinx docs. Runs on ubuntu-latest, where NEURON + joblib
  forking is stable (local macOS builds stay source-only to avoid a
  fork-related segfault in the joblib examples).
- Home: constrain the logo with an inline style so Material's
  `.md-typeset img { height:auto }` can no longer override the height
  attribute and stretch it to full column width.
- Correct the conductivity parameter names in the Muscle docstring to
  match the constructor: muscle_conductivity_{radial,longitudinal}__S_per_m
  (were __S_m, so griffe reported them as absent from the signature).
- Move the "Results are stored in ..." prose in generate_muscle_fiber_centers
  and assign_mfs2mns out of the Parameters section into Notes, so griffe no
  longer misreads it as a phantom "Results" parameter.

The docs now build with zero griffe warnings.
Every gallery example that calls plt.show() (24 of them) emitted a
"FigureCanvasAgg is non-interactive, and thus cannot be shown" UserWarning
into its rendered Out block, dragging the absolute source path along with
it. mkdocs-gallery captures figures via plt.get_fignums() independently of
show(), so show() serves no purpose during a headless build. No-op it (as
sphinx-gallery does) and pin the Agg backend.
plt.cm.get_cmap is deprecated in recent matplotlib; the warning leaked into
the rendered Out block of examples 03 and 04. plt.get_cmap returns an
identical colormap, so the figures are unchanged.
mkdocs-gallery scrapes stderr, so the libraries' and examples' live tqdm
progress bars were captured verbatim into every example's rendered Out
block (e.g. "Running simulation: 99%|...| 14881/15000 ms"), along with a
"TqdmWarning: clamping frac to range [0,1]". tqdm reads TQDM_DISABLE only at
import, too early to set from here, so force-disable every tqdm instance
created during the build.
neuron.run(tstop) is deprecated (emits a DeprecationWarning that leaked into
the docs). Its body is simply `tstop = X; while (t < tstop) { fadvance() }`
— it does NOT re-initialise. The call sites already run h.finitialize()
with a hand-set initial voltage, so the documented replacement
`h.stdinit()` would wrongly re-initialise and change results; we replicate
neuron.run()'s exact behaviour instead. Also drops the now-unused
`import neuron` at both sites.
mean_firing_rate(st.time_slice(st.min(), st.max())) divides by the spike
span (last - first spike), which is zero for a train with a single spike:
divide-by-zero -> inf/nan that then poisoned the downstream np.mean/np.std
("invalid value in subtract"), all leaking into the rendered docs (and making
the printed motor-neuron rate inf/nan). Require >=2 spikes, since a rate over
the spike span is only defined then. Affects examples 03 and finetune/04;
example 02 was already guarded this way.
@RaulSimpetru
RaulSimpetru deleted the feat/puffer-api branch July 12, 2026 16:05
@RaulSimpetru
RaulSimpetru restored the feat/puffer-api branch July 12, 2026 16:07
@RaulSimpetru RaulSimpetru reopened this Jul 12, 2026
# Conflicts:
#	docs/source/conf.py
#	docs/source/examples.rst
…ples

The MKDOCS_GALLERY_PLOT=true CI docs build segfaulted (exit 139) while
executing the gallery. mkdocs-gallery runs every example sequentially in one
long-lived process; once NEURON has initialised native runtime state that the
reset_neuron() hook cannot unwind, MyoGen spawning joblib `loky` worker
processes (parallel muscle-fibre / MUAP generation in SurfaceEMG, muscle
assignment, intramuscular EMG, and example 09's explicit n_jobs=2) crashes the
process. Reproduced on Linux CI and macOS. Force every joblib.Parallel to
n_jobs=1 for the build: in-process, no worker boundary (and, as a bonus, the
tqdm-disable patch stays effective since no fresh worker re-imports it).

Also wires the examples/04_clinical category (SCI iEMG + PIC spasticity,
merged from main) into the properdocs gallery: add it to gallery_conf.py
examples/gallery dirs, ignore the _pic_protocols helper, add a Clinical nav
entry, and convert its README.rst to README.md.
…teness

Executing the whole example gallery in one process segfaults from NEURON
native-state accumulation (mkdocs-gallery has no per-example process
isolation). scripts/build_docs.py wraps `properdocs build`: because
mkdocs-gallery md5-caches each executed example, retrying resumes past the
crash in a fresh process with less accumulated state — the same mechanism the
old Sphinx CI leaned on with `make html || make html`. The wrapper aborts if a
retry makes no progress (no infinite loop) and validates that every executable
example has its rendered page and figures before the site can deploy (a
NEURON SIGSEGV lands before the md5 stamp is written, so crashing examples
re-run rather than caching incomplete). docs.yml now runs the wrapper.
Executing the NEURON example gallery is slow and needs retries to survive
state-accumulation segfaults (the heavy watanabe/clinical tail clears only a
couple of examples per fresh process). It only affects the *deployed* site, so
build source-only on pull requests (fast review builds) and execute the gallery
only on push-to-main and manual workflow_dispatch. Raise the retry cap to 12
for the executed deploy build.
_pic_protocols.py called DescendingDrive__Pool(poisson_batch_size=5) — the
pre-Poisson-fix API this branch removed — which broke the clinical example and
test_pic_spasticity (merged from main) with "unexpected keyword argument
'poisson_batch_size'". poisson_batch_size=5 was Gamma(5,5) regular firing
(CV=1/sqrt(5)); the equivalent new API is process_type="gamma", shape=5.
…only

The retry wrapper does get the full gallery to execute on Linux, but the paper
(03_papers/watanabe) and clinical (04_clinical) examples render broken: they use
__file__ (undefined under mkdocs-gallery's exec()) to locate a results dir and
load each other's .pkl outputs. Restrict gallery execution to the self-contained
01_basic + 02_finetune (filename_pattern) so those get inline plots reliably, and
leave watanabe/clinical source-only. Also harden scripts/build_docs.py: parse
failed example names and drop their stamps so a broken example re-runs (a
deterministic failure then surfaces as "no progress" instead of a silently cached
broken page), and scope the completeness target to the executed subdirs.
@RaulSimpetru
RaulSimpetru requested a review from pyro098 July 12, 2026 20:10
@RaulSimpetru RaulSimpetru assigned RaulSimpetru and unassigned pyro098 Jul 12, 2026
@RaulSimpetru RaulSimpetru linked an issue Jul 12, 2026 that may be closed by this pull request
6 tasks
The migration removed docs/source/ and switched to MkDocs directory URLs, but
README.md still pointed at the old Sphinx paths (review findings 4, 5):
- logo src docs/source/_static/ -> docs/images/ (was a 404 on GitHub/PyPI)
- Installation #installation -> getting-started/#install (install moved there)
- Examples examples.html -> auto_examples/01_basic/
- User Guide neo_blocks_guide.html -> neo-blocks/
(#how-to-cite is left as-is: index.md still has a "How to cite" section.)
The workflow-level `concurrency: group: pages` serialized every Docs run, so
PR "Docs" checks queued behind a long main-branch executed build (up to
MAX_ATTEMPTS retries). Move the concurrency block to the deploy job, where the
actual Pages-deploy race is.
Address Optuna-example review findings (1, 3, 7):
- Workers now pass sampler=TPESampler(seed=...) to load_study(). Optuna doesn't
  persist samplers to storage, so each worker was silently building a fresh
  UNSEEDED sampler -> non-reproducible dc_offset suggestions despite the study's
  seed=42. (Full bit-reproducibility across parallel workers is still not
  possible; this removes the unseeded-sampler drift.)
- Drive "noise" was np.clip(N(0,1), 0, inf) -> mean ~0.4 Hz, a systematic DC
  bias on every trial's drive. Make it zero-mean and clip the total instead.
- Lower the default worker count 8 -> min(4, N_TRIALS) (each worker builds a
  memory-heavy 800-MN+400-DD network; 8 could OOM a laptop/CI; env override
  raises it), and delete any stale study at start so reruns don't accumulate
  trials into best_trial.
…x review)

Two issues the reviewer and I missed, caught by Codex adversarial verification:
- The subprocess parallelization dropped the wall-clock guard: TIMEOUT_SECONDS
  was defined/exported but unused, so a stalled worker would run/wait forever.
  Restore it by passing timeout=TIMEOUT_SECONDS to each worker's study.optimize
  (workers run concurrently, bounding the whole optimization to ~TIMEOUT_SECONDS).
- The drive-noise unbias fix was half-done: 01_compute_baseline_force.py still
  used one-sided clip(N(0,1),0,inf) (~+0.4 Hz), so the reference force was
  computed with biased noise while trials used the corrected zero-mean noise.
  Make the baseline match (zero-mean, clip the total).
- Minor: validate_cache() now checks the rendered .md is non-empty (matching its
  own docstring), not merely present.
…w finding 2)

The objective normalizes each simulation to its own peak (%MVC methodology, per
Watanabe) and matches the normalized steady-state force *profile* against the
identically-normalized baseline — not absolute force in Newtons. The old
docstring/comments ("match reference force", "scale to Newtons") read as
absolute-force matching, which the reviewer and Codex both flagged as
misleading. Clarify the intent in scripts 01 and 02; no logic change.
@RaulSimpetru
RaulSimpetru merged commit bc0392b into main Jul 16, 2026
15 checks passed
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.

Migrate documentation to MkDocs Material (like MyoGestic)

2 participants