Skip to content

fix!: consolidated 0.6.0 fixes (supersedes scattered PRs) - #483

Open
ejolly wants to merge 38 commits into
masterfrom
0.6.0-fixes
Open

fix!: consolidated 0.6.0 fixes (supersedes scattered PRs)#483
ejolly wants to merge 38 commits into
masterfrom
0.6.0-fixes

Conversation

@ejolly

@ejolly ejolly commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Rolling branch for the remaining 0.6.0 fixes. Rather than juggling the scattered open PRs, work lands here incrementally and this PR supersedes them one at a time as their content is absorbed and finalized.

Landed so far

Nilearn skill drift (supersedes #477)

find_spikes / DesignMatrix regressor hygiene (supersedes #472)

Carries both original fixes from #472 (duplicate spike regressors from the two detectors; the column-less design matrix losing its row count), plus the review-driven rework:

  • clean= is gone — dedup is unconditional. The colliding detections are bitwise-identical one-hot columns, so dedup only decides which name survives (global_spike, deterministic tie-break). No information is at stake, so there is no opt-out: an escape hatch would only manufacture straight duplicate columns, which the design layer now refuses outright.
  • append(axis=1) raises on bitwise-duplicate columns (identical values under any names, compared on Float64 bytes so an int one-hot and its float twin match), just as duplicate names already did. A design with straight duplicate columns is rank deficient by construction — the model over it is not computable — and silently keeping one copy would be a modeling decision made on the user's behalf. Only duplication introduced by the append is checked; a base matrix that already contains duplicates is left alone. BREAKING relative to master.
  • n_rows is now a real contract: it survives copy() / copy_with() / the copy-constructor (with a fallback to the source's height when a transform empties the frame), a conflicting or negative value raises instead of being silently ignored, and to_numpy() / np.asarray() return (n, 0) instead of (0, 0) for a column-less matrix.

Decisions resolved

  • Silent dedup vs. warn (open question on fix(outliers,designmatrix): find_spikes emits duplicate and length-less regressors #472): resolved as neither — dedup stays silent because it is name-retention only; anything that would actually change the design (straight duplicates) errors instead. Guiding principle: never make a modeling decision the user doesn't know about — error when the model is not computable.
  • Tie-break: global_spike wins by default; the column values are identical either way.

Docs: migration guide is updated; API reference regeneration (poe docs-generate) is deferred to a batched pass before merge.

Also landed (detail in the comments below)

Remaining before merge

Batched docs regeneration (poe docs-generate + poe changelog). Everything else — #479 (iplot autoscaling), #478 (BrainCollection.predict map-reduce), #484 (GPU survey) — is scoped in the current status comment; #478 phase 2 and #484 are expected to be fast-follow PRs rather than blockers.

ljchang and others added 12 commits July 27, 2026 22:36
…iency

BREAKING: `BrainData.fit()` no longer accepts `design_clean`,
`design_clean_thresh`, `design_clean_exclude_confounds`, or
`design_clean_fill_na`, and no longer runs `DesignMatrix.clean()` implicitly.
It estimates exactly the design it is given. Callers who want columns dropped
call `DesignMatrix.clean()` explicitly. `fit()` now emits a UserWarning when
the design matrix is rank deficient.

fit(model='glm') defaulted to running DesignMatrix.clean() on X, dropping any
column correlating >= 0.95 with an earlier one. Three problems:

1. The criterion is a correlation heuristic, not a rank test, so it dropped
   columns from designs that were perfectly estimable. A real dartbrains
   first-level design lost poly_1 and poly_2 at rank 48 of 48 — full rank.

2. It is order-dependent. clean() keeps the first of a correlated pair, so
   which regressor survives depends on the order the design was built in:
     add_dct_basis().add_poly()  -> drops poly_1, poly_2
     add_poly().add_dct_basis()  -> drops cosine_1, cosine_2
   Same regressors, same data, two different fitted models.

3. It was silent. The only reporting was behind progress_bar, which fit()
   leaves as None, so the estimated model differed from the specified one with
   no indication. Downstream this surfaces as a confusing
   "Contrast vector length (48) must match number of regressors (46)".

Dropping regressors changes the model, so it is the caller's decision.
DesignMatrix.clean() already exists as the explicit, discoverable way to do it.

Meanwhile the genuinely dangerous case passed silently in both modes: with
cleaning disabled, a singular design fits via pseudo-inverse and splits the
effect evenly across dependent columns, returning finite betas that are not
uniquely determined. fit() now checks the rank and warns with the rank, the
column count, the column names, and the suggested next step (.vif() / .clean()).
It warns rather than raises because over-parameterized designs can still have
estimable contrasts.

Tests written red-first: fit keeps every column of a full-rank design even at
r=0.99; the removed kwargs raise TypeError; a singular design warns with an
actionable message; a well-formed design does not warn.

API docs not regenerated here — docs/api is already drifted from master and
`poe docs-generate` is a separate step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVDnsKutg6Yqv99dYmUeuP
…letion

The warning previously recommended `DesignMatrix.clean()` as the fix, which
recommends the weaker tool. Dropping a column does not make its variance
disappear — it reassigns it to whichever correlated column survived, and which
one survives depends on the order the design was built in. Ridge has a unique
solution even when X'X is singular, since (X'X + alpha*I) is always invertible,
and that solution is invariant to column order.

Measured on a design with two regressors at r=0.99:

  ridge   swap the collinear columns -> weights swap exactly
          (equal to float32 solver precision, ~1e-6)
  clean() order [a, b, c] keeps ['a', 'c']
          order [b, a, c] keeps ['b', 'c']   <- different model

The warning now leads with `fit(model='ridge')`, suggests inspecting with
`.vif()` first, and still mentions `.clean()` as an option while naming its
order-dependence.

Adds test_ridge_is_order_invariant_where_clean_is_not, which pins the property
the recommendation rests on, and extends the warning-content test to assert the
guidance names ridge and vif so it can't silently regress.

The migration guide gains a "Prefer regularization to dropping columns" section
with the worked contrast, plus the caveat that regularization fixes the
estimation problem and not the identifiability one: for exactly collinear
regressors no method separates their individual contributions, so ridge buys a
stable answer rather than a recovered one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVDnsKutg6Yqv99dYmUeuP
`find_spikes()` runs two independent detectors — one on the per-TR global
signal, one on the mean absolute frame-to-frame difference — and turned every
detection into its own one-hot indicator column. A single bad volume is
routinely caught by both, so the same TR could be flagged twice, producing
exactly identical regressors:

    find_spikes(img, global_spike_cutoff=0.8, diff_spike_cutoff=0.8)
    # 23 columns, rank 16   <- rank deficient

That is nltools manufacturing the very degeneracy `BrainData.fit()` now warns
about, from a function whose whole job is to hand you nuisance regressors.
Reproduced on real localizer data: at looser cutoffs, 22 of 23 spike columns
collided with another.

Adds `clean: bool = True` to `nltools.stats.find_spikes`, plumbed through
`BrainData.find_spikes` and `find_spikes_data`. When a TR is flagged by more
than one detector, the duplicates are dropped and the `global_spike` column is
kept, so the tie-break is deterministic rather than insertion-ordered.
`clean=False` restores one column per detection.

This is deduplication of the function's own output, not a modeling decision:
the dropped columns are bitwise identical to ones that remain, so there is no
information loss and no arbitrary choice. That is why it defaults on, unlike
the implicit design cleaning removed from fit() earlier in this PR.

Tests cover: no duplicates by default, full rank by default, clean=False
preserving every detection, global_spike winning the tie-break, distinct TRs
never merged, and confound marking surviving the dedup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVDnsKutg6Yqv99dYmUeuP
…ount

Finding no spikes is a normal outcome, but it broke the first-level build for
any clean subject:

    spikes = bold.find_spikes(...)     # no spikes detected
    task.append(spikes, axis=1)
    ValueError: All Design Matrices must have the same number of rows!

Polars derives height from columns, so a frame with no columns always reports
0 rows. find_spikes built its empty result as
`pl.DataFrame({"_no_spikes": [0]*n}).drop("_no_spikes")` — dropping the only
column discards the height — and the resulting (0, 0) matrix then failed
append()'s row check against the rest of the design.

A design matrix with no regressors still describes a specific number of
timepoints, so DesignMatrix now takes an optional `n_rows` used when the frame
has no columns; `shape` and `__len__` consult it. find_spikes passes the TR
count through.

`append()` additionally drops regressor-less matrices before the row check, so
this composes even for a column-less matrix built without an explicit height,
and appending only empty matrices returns a copy rather than raising.

This also fixes TestFindSpikes::test_find_spikes_brain_data, which fails on
origin/master for exactly this reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YVDnsKutg6Yqv99dYmUeuP
…mily

The inference functions printed a tqdm progress bar unconditionally, with no
way to turn it off. Calling them in a loop -- a calibration study running 100
permutation tests, a per-parcel sweep, anything scripted -- emitted one
progress bar per call, and the only workaround was wrapping every call in
contextlib.redirect_stderr.

Adds `progress_bar: bool = False` across the family, matching the canonical
kwarg table in CLAUDE.md and the existing convention in BrainCollection,
LocalAlignment, and the ridge solvers.

Two shared helpers in algorithms/inference/utils.py replace fourteen
hand-rolled tqdm sites:

  maybe_tqdm(iterable, *, progress_bar, **kw)   # iteration-driven bars
  make_progress_bar(*, progress_bar, **kw)      # manually-updated bars

`make_progress_bar` returns a `_NullProgressBar` when disabled, so call sites
that drive a bar via .update() need no branching. Both import tqdm lazily, so
it stays off the import path when unused.

Threaded through both layers, since nltools.stats re-exports thin wrappers that
are distinct function objects from the algorithm-layer functions:

  algorithms/inference: one_sample, two_sample, correlation, matrix,
                        timeseries, bootstrap (5 private helpers), isc
  nltools.stats:        the five permutation wrappers
  facades:              Adjacency.similarity / .ttest, stats_label_distance

BREAKING: progress bars are now off by default. isc_permutation_test and
isc_group_permutation_test previously defaulted to progress_bar=True and now
default to False; every other function in the family previously had no way to
disable its bar. Pass progress_bar=True to restore the old output.

Tests cover both layers: that the kwarg exists, that it defaults to False, that
nothing reaches stderr by default, and that progress_bar=True still produces a
bar (so the knob is a real toggle, not a silent no-op).
The public inference functions accepted every option positionally --
isc_permutation_test had 15 positional-or-keyword params and 0 keyword-only --
and dispatched into private helpers with 8-11 positional arguments. Inserting a
parameter anywhere in one of those signatures silently shifts every argument
after it.

That is not hypothetical. While adding progress_bar in the previous commit, a
parameter inserted ahead of `single_feature` shifted its value into
`progress_bar` at the dispatch site. No error, no type failure -- the bar simply
came back on, and only an assertion that stderr stays empty caught it.

Changes:

- `*` marker after the leading data arguments on the seven public entry points
  (one_sample, two_sample, correlation, matrix, timeseries, isc, isc_group),
  as CLAUDE.md requires for any public function with 3+ kwargs.
- private CPU/GPU helpers made keyword-only too, so the dispatch sites cannot be
  positional even internally.
- all seven dispatch call sites converted to keyword arguments.
- scripts/check_kwonly.py extended to cover nltools/algorithms/inference. It
  excluded the whole algorithms package as "internals", but this module is
  documented in docs/api/algorithms/inference.md and imported directly
  downstream, so the facade-translation rationale does not apply. Verified the
  expanded scope actually fires by removing a `*` and confirming it fails.

Fixes a latent bug this exposed: the two_sample GPU dispatch never forwarded
progress_bar at all, so progress_bar=True was silently ignored on that path.

Also adds an interim signature-parity test asserting the nltools.stats wrappers
and the engines agree on every parameter and default except the documented
parallel/backend -> device rename. That test would have caught the drift that
motivated all of this. The underlying question -- whether the two layers should
export 13 same-named distinct objects at all -- is design discussion #474.

BREAKING: options must now be passed by keyword. `one_sample_permutation_test(data,
5000)` becomes `one_sample_permutation_test(data, n_permute=5000)`. The leading
data arguments remain positional.
…p clean=

The colliding detections from the two detectors are bitwise-identical
one-hot columns, so deduplication only decides which NAME survives
(global_spike, deterministically) — there is no information to preserve
and therefore no reason for an opt-out. An escape hatch would only
manufacture straight duplicate columns, which append(axis=1) now refuses.

Tests discover detector collisions by running each detector solo instead
of relying on the removed clean=False path.
Appending a column whose values are identical to an existing column
(under any name) now raises ValueError, just as duplicate names already
did. A design with straight duplicate columns is rank deficient by
construction — the model over it is not computable — and silently keeping
one copy would be a modeling decision made on the user's behalf.

Columns are compared on Float64 byte representation, so an int one-hot
and its float twin count as the same regressor. Only duplication
introduced by the append is checked; a base matrix that already contains
duplicates is left to its owner.

BREAKING: DesignMatrix.append(axis=1) raises ValueError when an appended
column's values are bitwise identical to another column's, where it
previously appended silently. Drop or modify one of the columns first.
…values

n_rows (the recorded length of a column-less DesignMatrix, new in this
branch) was dropped by every copy path — copy()/copy_with()/the
copy-constructor silently reset a (60, 0) matrix to (0, 0) — and a value
that contradicted a non-empty frame was silently ignored.

Now: get_metadata/copy_with carry n_rows (falling back to the source's
height when a transform empties the frame), the copy-constructor inherits
it, a conflicting or negative n_rows raises ValueError, and
to_numpy()/np.asarray() honor the recorded length by returning an
(n, 0) array instead of (0, 0).
…clean' into 0.6.0-fixes

# Conflicts:
#	docs/migration-guide.md
…onable

Reworks the warning that replaced fit()'s implicit design cleaning (merged
from refactor/remove-implicit-design-clean), per maintainer feedback on the
RFC: a loud warning with helpful tips rather than a policy lecture.

- Names the likely-involved columns via pivoted QR (truncated at 5) instead
  of dumping every column name into the message.
- Catches p > n designs — rank deficient by construction — which the old
  guard silently skipped (the one case guaranteed to be degenerate got no
  warning at all).
- Offers all three next steps: inspect with .vif(), drop redundant columns
  with .clean() (with the order-dependence caveat), or fit(model='ridge')
  for a unique, order-invariant solution.
- Dedicated RankDeficientDesignWarning category (UserWarning subclass) so it
  stands out in output and can be silenced surgically.

Unit tests cover the pure helper directly (fast); the slow fit() integration
tests continue to pass unchanged. Migration guide quote synced.
@ejolly

ejolly commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Landed: the #470 rework (no implicit design cleaning + a diagnostic rank-deficiency warning). Supersedes #470.

The RFC branch is merged in as-is (all four design_clean* kwargs removed; fit() estimates exactly the design it's given), and the warning is reworked per the review discussion:

  • Helpful tips, both fixes: the warning now diagnoses the problem and offers the next steps — inspect with DesignMatrix.vif(), try DesignMatrix.clean() to drop redundant columns (with the order-dependence caveat stated inline), or try fit(model='ridge') for a unique, order-invariant solution.
  • Names the culprits: likely-involved columns are identified via pivoted QR and truncated at 5, instead of dumping the full column roster into the message.
  • p > n gap fixed: a design with more columns than timepoints — rank deficient by construction — previously hit an early return and got no warning; it now warns with that exact diagnosis.
  • Loud and filterable: emitted as a dedicated RankDeficientDesignWarning (a UserWarning subclass), so it stands out and can be silenced surgically.

Sample output:

RankDeficientDesignWarning: Design matrix is rank deficient: rank 2 of 3 columns — 1 column(s)
are linear combinations of the others (likely involved: condA_dup). ... Possible fixes:
(1) inspect the collinearity with `DesignMatrix.vif()`; (2) try `DesignMatrix.clean()` ...;
(3) try regularization — `fit(model='ridge')` ...

Decision resolved: warn (never raise, never auto-clean) — dropping regressors is a modeling decision that belongs to the caller; the warning's job is to make the degeneracy impossible to miss and the options obvious.

Known follow-up (out of scope here): warnings raised inside BrainCollection.fit() joblib workers may not reach the user's session reliably — worth its own pass on warning propagation.

ejolly added 3 commits August 20, 2026 16:19
…-wide progress mechanism

Promotes the two helpers added by the inference progress_bar work from
nltools/algorithms/inference/utils.py to nltools/utils.py and converts every
hand-rolled tqdm site to use them: alignment/local.py (x2), ridge/solvers.py,
braindata/neighborhoods.py, braindata/prediction.py (x2),
collection/execution.py (tqdm_joblib), and the five isc.py loops. The helpers
now use tqdm.auto, so notebooks get widget bars everywhere (previously only
collection ops did). A source-scan test pins the invariant: no module outside
nltools/utils.py may import tqdm.

Also answers the review ask to measure bar overhead before standardizing:
at bench_inference sizes (30x5000, n_permute=1000, CPU) bars cost +1.6%
(one_sample), +4.1% (two_sample), +12.7% (correlation — 7ms on a 60ms
workload), confirming off-by-default. With bars off by default the
benchmark's _quiet() stderr redirect is dead code and is removed.

Dropped the try/except ImportError fallbacks around tqdm — it is a hard core
dependency. The searchlight decode path previously materialized
list(tqdm(...)) up front, driving the bar to 100% before any decoding ran;
bars now advance during the actual work.
…at facades

The inference engines' progress_bar kwarg was unreachable from four public
facades that previously showed bars unconditionally — Adjacency.similarity,
Adjacency.ttest, Adjacency.bootstrap, and BrainData.bootstrap silently went
from always-on to always-off with no knob. Each now exposes
progress_bar: bool = False (keyword-only, canonical trailing position) and
forwards it down the delegation chain (adjacency/modeling.py bootstrap,
braindata/bootstrap.py and its five engine call sites).

Also removes a phantom progress_bar entry from phase_randomize's docstring
(the function has no such kwarg — it has no permutation loop), documents the
kwarg in the module-level similarity() docstring, and adds the missing
migration-guide entry for the one silent behavior change in the family:
isc_permutation_test / isc_group_permutation_test flipped from
progress_bar=True to False.
@ejolly

ejolly commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Landed: the #473 rework (inference progress_bar, standardized library-wide). Supersedes #473.

The fix/inference-progress-bar branch is merged in as-is, then reworked per the review asks:

  • Measured the bar overhead before standardizing (review ask Removed unused variable #1): at bench_inference sizes (30×5000, n_permute=1000, CPU, min of 5), an enabled bar costs +1.6% (one_sample), +4.1% (two_sample), +12.7% (correlation — that's ~7ms of tqdm update cost on a 60ms workload, only visible because the workload is tiny). Real per-update cost, which is exactly why off-by-default is right; opt-in cost is negligible in absolute terms. With bars off by default, the benchmark's _quiet() stderr redirect became dead code and is removed.
  • Standardized across the library (review ask Error with predict constructor using mask #2): maybe_tqdm / make_progress_bar are promoted from algorithms/inference/utils.py to nltools/utils.py and every hand-rolled tqdm site now goes through them — alignment/local.py (×2), ridge/solvers.py, braindata/neighborhoods.py, braindata/prediction.py (×2), collection/execution.py, and the five isc.py loops. The helpers use tqdm.auto, so notebooks get widget bars everywhere. A source-scan test pins the invariant: no module outside nltools/utils.py may import tqdm.
  • Restored the facades that silently lost their bars: Adjacency.similarity / .ttest / .bootstrap and BrainData.bootstrap previously showed bars unconditionally; after the engine change they could never show one (kwarg not threaded). Each now exposes keyword-only progress_bar: bool = False and forwards it, with facade-level tests.
  • Small defects fixed: phantom progress_bar line in phase_randomize's docstring (it has no such kwarg); missing kwarg doc on similarity(); and a migration-guide entry for the one silent behavior change — isc_permutation_test / isc_group_permutation_test flipped from progress_bar=True to False.

Coming next on this branch: the #475 rework (keyword-only enforced everywhere, with a documented exemption for the numpy-mirroring backends.py shims).

ejolly added 2 commits August 20, 2026 16:32
…s the package

Extends the #475 work per review: check_kwonly.py now scans all of nltools/
(tests excluded) instead of a curated root list with an algorithms/ carve-out,
and the 12 remaining violations are fixed — SRM/DetSRM.__init__,
ridge_svd/ridge_cv, KFoldStratified.__init__ (matching sklearn's own
KFold(n_splits, *, ...) shape), plot_mean_label_distance,
plot_between_label_distance, and plot_interactive_brain all gain the `*`
marker. Every internal call site already passed these options as keywords, so
no in-repo behavior changes; external positional callers now get a TypeError.

The four numpy-mirroring shims in algorithms/backends.py (zeros_like,
ones_like, full_like, assert_array_almost_equal) are exempted via an explicit,
documented EXEMPT list in the checker — their signatures deliberately match
numpy for drop-in substitutability, and a `*` would break that. The list is
for externally-dictated signatures only.

Also finishes the private-dispatch hardening the original PR described but
missed: `*` added to _matrix_permutation_cpu_parallel and the five private
isc.py helpers, so inference dispatch cannot go positional even internally.

BREAKING: options on the newly-marked public functions must now be passed by
keyword (e.g. KFoldStratified(5, True) -> KFoldStratified(5, shuffle=True)).

Gates: poe lint, poe lint-api (semgrep + check_kwonly + vocabulary), and the
full default suite (1755 passed).
@ejolly

ejolly commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Landed: the #475 rework (keyword-only enforced everywhere). Supersedes #475.

The fix/inference-keyword-only branch is merged in as-is (the seven inference entry points get their * markers, plus the check_kwonly.py extension and the facade↔engine parity tests), then reworked per the review ask — "enforce keyword only arguments everywhere for consistency":

  • The checker is now uniform: check_kwonly.py scans all of nltools/ (tests excluded) with no per-layer carve-outs — the inference-only exception block and root list are gone.
  • All 12 remaining violations fixed: SRM/DetSRM.__init__, ridge_svd/ridge_cv, KFoldStratified.__init__ (matching sklearn's own KFold(n_splits, *, ...) shape), and the three plotting functions. Every internal call site already passed keywords, so nothing in-repo changes behavior; external positional callers get a loud TypeError (the convention's point).
  • One documented exemption: the four numpy-mirroring shims in algorithms/backends.py (zeros_like, ones_like, full_like, assert_array_almost_equal) keep their numpy-shaped signatures via an explicit EXEMPT list in the checker, each with its rationale inline — a * there would break drop-in substitutability with numpy for no safety gain. The list is restricted to externally-dictated signatures.
  • Private dispatch finished: the original PR's body said the CPU/GPU helpers were keyword-only, but matrix.py and the five isc.py private helpers were missed — they now have * markers too, so inference dispatch cannot go positional even internally.

Gates: poe lint, full poe lint-api (semgrep + check_kwonly + vocabulary check — the semgrep kwargs-internal-forwarding rule now also sees the promoted progress helpers; the tqdm-mirroring no-op methods carry the standard nosemgrep suppression), and the full default suite: 1755 passed.

CI runs poe lint-api in the lint job, so the uniform rule is now gated on every push.

Both inference PRs (#473, #475) are now fully absorbed. Coming next: fixes for #478 / #474 / #479 (per the sequencing note on #474, the parallel=device= rename can now land safely on top of the * markers).

ejolly added 2 commits August 20, 2026 17:07
…al core into nltools.algorithms

Closes the full-consolidation scope of #474. nltools.stats had become a
compatibility layer over the functional core; v0.6.0 makes nltools.algorithms
the single entry point — every user-facing statistical function is importable
flat from it (51 exports), organized into focused submodules underneath.

BREAKING: nltools.stats is removed. Implementations moved: corrections.py,
outliers.py, regression.py as-is; timeseries.py -> algorithms/signal.py and
correlation.py -> algorithms/similarity.py (avoids name echoes with the
inference engine's modules); intersubject.py -> algorithms/inference/;
alignment.py -> algorithms/alignment/procrustes.py. stats/permutation.py is
deleted outright — the nltools.algorithms permutation exports ARE the
algorithms.inference engine functions (identity, no wrapper layer), so
facade/engine drift is structurally impossible.

BREAKING: the inference engine speaks the canonical device= vocabulary
directly. parallel= -> device= on every algorithms.inference entry point
(values unchanged: 'cpu' | 'gpu' | None), validate_parallel_parameter ->
validate_device_parameter, and result dicts report a 'device' key instead of
'parallel'. phase_randomize(backend='numpy'|'torch') is now keyword-only
device='cpu'|'gpu'|'auto' with warn-and-fallback on unknown values. The ridge
and alignment layers keep parallel= internally (documented Backend
abstraction); facades still translate at those boundaries.

Details:
- The intersubject functions (isc/isc_group/isfc/isps) export flat from
  nltools.algorithms only, imported straight from inference/intersubject.py —
  re-exporting the isc *function* from the inference package would shadow the
  inference.isc engine *module*.
- Tests: red-first test_algorithms_api.py (flat surface, stats gone,
  wrapper-free identity) and test_inference/test_device_kwarg.py; tests/stats/
  -> tests/core/test_algorithms/; the facade-parity suites are superseded by
  the identity tests and removed.
- Semgrep vocabulary rules now enforce algorithms/inference (exclusions
  narrowed to ridge/ + alignment/ + backends.py); poe test-stats ->
  test-algorithms.
- Docs kept in sync: migration guide gains the (stats-module-removed) section
  with the full old->new mapping and all stale nltools.stats/parallel=
  guidance rewritten; build_api_docs.py + myst.yml map the new modules;
  CLAUDE.md, development/index.md, inference-internals.md, api-vocabulary.yml
  (+ regenerated AUTOGEN blocks), and the GLM tutorial updated. Site
  regeneration stays batched for the deferred docs-generate pass.

Gates: 1769 passed (default suite), poe lint, poe lint-api.
…c=/null_dist)

Follow-up to the #474 consolidation: the ISC family now speaks the canonical
kwarg table end to end, removing the last boundary translation the old
nltools.stats wrapper performed.

BREAKING: isc_permutation_test / isc_group_permutation_test rename metric=
(the 'median'|'mean' central-tendency choice) to summary=, and sim_metric=
(the similarity metric) to metric= — `metric` now means the same thing here
as everywhere else in the API. isc_group() and BrainCollection.isc/.isc_test
likewise take summary= instead of metric= (isc() already used summary=).

BREAKING: every ISC result — engines, the isc/isc_group wrappers, and
BrainCollection — exposes the null under the engine-standard 'null_dist'
key; the legacy 'null_distribution' key is removed.

Also: the isc/isc_group wrappers expose progress_bar: bool = False and
forward it (previously hard-coded off), completing the progress-bar
threading; wrapper docstrings and the collection helpers
(_aggregate_corrs and the streaming ISC paths) renamed to match.

Tests: red-first test_inference/test_isc_vocabulary.py (14 tests: canonical
signatures on all six entry points incl. BrainCollection, null_dist keys,
summary validation, wrapper progress-bar threading); existing isc suites
updated to the new vocabulary. Migration guide and execution-model.md
updated in place.

Gates: 1783 passed (default suite), poe lint, poe lint-api.
@ejolly

ejolly commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Landed: #474 full consolidation — nltools.stats removed + ISC vocabulary canonicalized

Two commits on 0.6.0-fixes:

1bb91524 — remove nltools.stats; consolidate into nltools.algorithms

  • Every user-facing statistical function now imports flat from nltools.algorithms (51 exports). Moves (history-preserving renames): corrections/outliers/regression as-is, timeseriessignal, correlationsimilarity, intersubjectinference/, alignment.pyalignment/procrustes.py. stats/permutation.py deleted outright — the permutation exports are the engine functions (identity, pinned by test_algorithms_api.py), so wrapper/engine drift is structurally impossible.
  • The inference engine speaks device= directly (parallel=device= everywhere incl. validation.py; result key 'parallel''device'; phase_randomize(backend=)device='cpu'|'gpu'|'auto'). Ridge/alignment keep parallel= internally (documented Backend abstraction).
  • Semgrep vocabulary rules now enforce algorithms/inference (exclusions narrowed to ridge/+alignment/); poe test-statstest-algorithms; migration guide gained the (stats-module-removed) section with the full old→new mapping.

bd46f57a — ISC vocabulary canonicalized (the legacy fast-follow)

  • isc_permutation_test/isc_group_permutation_test: metric= (median/mean) → summary=, sim_metric=metric=metric now means the similarity metric here like everywhere else. isc_group() and BrainCollection.isc/.isc_test take summary= too.
  • All ISC results (wrappers + BrainCollection included) expose null_dist; the legacy null_distribution key is gone. isc/isc_group expose and forward progress_bar (previously hard-coded off).

Gates: 1783 passed (default suite), poe lint, poe lint-api. Site regen still batched for the deferred docs-generate pass before merge.

Spotted but deliberately left out of scope: Adjacency.cluster_summary(metric='median') and extract_roi(metric='mean') use metric= for a mean/median aggregation choice — same violation family the ISC pass just fixed. Worth a call on whether those should become summary= before 0.6.0 ships.

ejolly added 2 commits August 20, 2026 17:28
BREAKING: Adjacency.cluster_summary renames method= -> summary=
('mean'|'median'|None central tendency) and its old summary= (within/between
scope) -> scope=. BrainData.extract_roi renames metric= -> method=
('mean'|'median'|'pca' selects an extraction variant; metric stays reserved
for similarity metrics). Closes the last two mean/median-vocabulary
violations flagged in the #474 consolidation follow-up.
…vocabulary.yml

docs/_data/api-vocabulary.yml is now the single source of truth for the API
vocabulary: it gained an `enforcement:` section (banned alias kwargs, per-kwarg
semantic contracts, structured exceptions/exemptions) and a new AST checker
(scripts/check_api_vocabulary.py, first step of `poe lint-api`) validates every
public signature in the package against it. The six banned-kwarg semgrep rules
migrated into the manifest so banned lists live in exactly one place; semgrep
keeps the result-key and **kwargs rules. CLAUDE.md and the docs now point at
the manifest instead of duplicating the table (which moved its three
CLAUDE.md-only rows — threshold pair, include_diag, radius_mm — into the YAML).

The checker surfaced and this commit fixes three drift sites:

BREAKING: BrainData.fit defaults progress_bar=False and no longer inherits
bd.verbose when unset (verbose is reserved for log-level only).
SphereNeighborhoods.iter_neighborhoods takes progress_bar keyword-only.
Deliberate deviations are now recorded as manifest exemptions with reasons
(BrainData.predict n_jobs=1 memory guard; align n_iter solver iterations).
@ejolly

ejolly commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Landed: cluster_summary/extract_roi canonicalization + programmatic vocabulary enforcement (scope expansion)

Two more chunks on 0.6.0-fixes, closing out the open calls from the ISC follow-up comment above and expanding scope with machine enforcement of the kwarg vocabulary to prevent future drift and support future development.

76c663be — the two remaining mean/median violations (breaking)

  • Adjacency.cluster_summary(method=, summary=)cluster_summary(summary=, scope=)summary='mean'|'median'|None is now the central tendency (joining the ISC canon); the within/between-cluster choice moved to scope= (it previously squatted on the summary= name).
  • BrainData.extract_roi(metric=)extract_roi(method=)'mean'|'median'|'pca' selects an extraction variant (PCA isn't a central tendency), i.e. exactly the concept the banned extract_type alias maps to; metric= stays reserved for similarity metrics.

027a7229 — manifest-driven vocabulary enforcement (breaking)

docs/_data/api-vocabulary.yml — which already rendered the docs vocabulary tables — is now the machine-checked single source of truth:

  • New enforcement: section: banned alias kwargs, per-kwarg semantic contracts (e.g. metric= may never default to 'mean'/'median'; progress_bar must be keyword-only = False; n_jobs = -1), and structured exceptions/exemptions each carrying a reason.
  • New AST checker scripts/check_api_vocabulary.py (first step of poe lint-api) validates every public signature in the package against it, with 16 unit tests plus a real-tree test in the pytest gate. It catches the semantic drift semgrep patterns can't express — a correctly-named kwarg holding the wrong concept is precisely what cluster_summary/extract_roi were.
  • The six banned-kwarg semgrep rules migrated into the manifest so banned lists live in exactly one place; semgrep keeps the result-key and **kwargs rules it's better at.
  • CLAUDE.md and docs/development/index.md now point at the manifest instead of duplicating the table (the three rows that existed only in CLAUDE.md — threshold pair, include_diag, radius_mm — moved into the YAML first, so it's complete).

The checker immediately surfaced 16 findings; triage: 3 real drift fixes (BrainData.fit no longer couples progress_bar to bd.verbose — BREAKING; iter_neighborhoods and the DesignMatrix append helpers take progress_bar keyword-only) and the rest were deliberate designs now recorded as visible manifest exemptions with rationales (BrainData.predict(n_jobs=1) searchlight-memory/nesting guard; align(n_iter=) = SRM solver iterations à la F105; Backend's device=None; SpatialScale metadata carriers).

Verification: 1800 passed, poe lint and full poe lint-api (vocabulary checker → semgrep → kw-only → docs-drift) green. Migration guide updated for all renames and the fit behavior change.

…#474)

Every sign-ambiguous p-value now defaults two-tailed and speaks one
vocabulary: tail: int | str = 2, accepting 2|'two' (default) and 1|'one'
(one-tailed in the test's canonical positive direction — never chosen
from the data). Default (tail=2) output is numerically unchanged
everywhere. Enforced via a tail contract in api-vocabulary.yml.

BREAKING: the v0.5 -1/'upper'/'lower' public forms now raise ValueError
(negate the data / swap groups / flip the contrast for the negative
direction; _compute_pvalue keeps them internally for forced-tail sites).
BrainData.ttest and Adjacency.ttest previously ignored tail= on the
parametric path (always two-sided); tail now maps onto scipy's
alternative=, and the z map matches the requested tail.

New tail= options (default 2 ≡ old behavior): BrainData.ttest2,
BrainData/Adjacency.bootstrap, multivariate_similarity, regress,
Adjacency.regress, BrainCollection.ttest/.ttest2/.isc_test, Roc.calculate.
Collection permutation tests accept the string forms and route through
the shared engine p-value. Sites where only one tail is statistically
valid (dcorr, ANOVA F, isps Rayleigh, SRM variance) are untouched, and
GLM contrast p-maps stay nilearn one-sided as a documented exception.
@ejolly

ejolly commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Landed: canonical tail= vocabulary — the last #474 checklist item (007e6f9f, breaking)

The "p-values consistently one- or two-tailed with an option to switch" sweep from #474 is done. A full audit of every p-value-producing entry point found the permutation engine already standardized (two-tailed default) but four drift patterns around it; all are now resolved under one rule.

The vocabulary

tail: int | str = 2 everywhere — 2 | 'two' (two-tailed, the library default) or 1 | 'one' (one-tailed in the test's canonical positive direction: correlation/ISC/similarity > 0, mean > popmean, group1 > group2). The direction is fixed by the test, never chosen from the data (a data-driven direction would silently halve every p). Wanting the negative direction = negate the data, swap the groups, or flip the contrast. The v0.5 -1/'upper'/'lower' public forms now raise with exactly that guidance. Default (tail=2) output is numerically unchanged at every site.

What the audit found and fixed

  • Silently-ignored tail (bug): BrainData.ttest(tail=1) / Adjacency.ttest(tail=1) only forwarded tail to the permutation branch — the default parametric path always returned two-sided p. Now mapped onto scipy's alternative=; the "z" map is derived from the reported p so it matches the requested tail.
  • Restricted vocab: isc engine + wrappers, procrustes_distance, and the collection permutation tests accepted only int 1|2; all now speak the full vocabulary, and the collection pair routes through the shared engine _compute_pvalue (identical Phipson-Smyth form) instead of hand-rolled branches.
  • Hard-coded two-tailed, no switch → new tail= options (default ≡ old output): BrainData.ttest2, BrainData/Adjacency.bootstrap, multivariate_similarity, regress/Adjacency.regress, BrainCollection.ttest/.ttest2/.isc_test, Roc.calculate.
  • Statistically forced one-tailed sites untouched, no knob: distance_correlation (dcorr ≥ 0), ANOVA's F, isps' Rayleigh, SRM variance components — only one tail is valid there.
  • The GLM exception (deliberate): compute_contrasts(statistic='p') stays nilearn/SPM one-sided (directional contrasts are the neuroimaging convention) — recorded as a documented exception in api-vocabulary.yml, called out in the docstrings and migration guide.

Enforcement

api-vocabulary.yml gained a tail vocabulary row + contract (allowed_defaults: [2]), so the manifest checker from the previous chunk now locks the default in for every current and future public signature. New test_tail_vocabulary.py (44 tests: 28-entry-point signature sweep + vocabulary + one-vs-two semantics) plus facade behavioral tests in the braindata/adjacency/collection suites.

Verification: 1851 passed; poe lint + full poe lint-api green. Migration guide section: (tail-vocabulary).

@ejolly

ejolly commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Remaining work before/after this PR merges

Superseded by the current status comment — kept for history.

Everything this PR set out to consolidate has landed (supersedes #470, #472, #473, #475, #477 — all closed/merged — and completes #474). What's left, in rough priority order:

On this branch, before merge

  • Batched docs regeneration: poe docs-generate (the docs/myst.yml TOC references the new per-module algorithms API pages that griffe2md hasn't generated yet, and several committed docs/api/*.md files carry pre-consolidation prose) + poe changelog (git-cliff regen to pick up the !: commits above).

Scoped and ready to implement (this PR or fast-follows)

Newly filed

  • Survey remaining gpu support #484 — survey remaining GPU support: several algorithms advertise device='gpu' but defer the implementation. Known deferred sites from this consolidation's audit, as a starting inventory: matrix_permutation_test (validation hard-raises on GPU), Spearman/Kendall rank correlation in correlation_permutation_test (CPU-only, NotImplementedError on GPU), SRM/DetSRM (device='gpu' silently falls back to CPU), and BrainCollection.permutation_test/permutation_test2 (device= is currently informational only). Needs scoping: which of these get real GPU paths for 0.6.0 vs. get their docs/validation tightened to fail loudly.

The vocabulary/enforcement work from this PR (api-vocabulary.yml manifest + check_api_vocabulary.py in lint-api) is designed to hold all of the above to the same conventions as they land.

ejolly added 2 commits August 20, 2026 18:28
Every column nltools invents rather than the user now carries the reserved
prefix `.nl_`: `.nl_poly_0` / `.nl_cosine_1` (add_poly / add_dct_basis),
`.nl_global_spike1` / `.nl_diff_spike1` (find_spikes), and the run-separated
variants a multi-run append produces (`0_poly_0` -> `.nl_r0_poly_0`; the run
index moved inside the prefix and prefixes never stack).

The point is detection. Machinery that has to recognize nltools' own columns
was pattern-matching user-controlled names and was wrong in both directions:

- add_poly() counted underscores, so a single-run design carrying the standard
  24-parameter motion expansion (trans_x_sq, rot_x_diff_sq, ...) could not have
  drift terms added at all -- it raised about run-separated polynomials that
  were never there (#471). add_dct_basis() carried the same flawed guard. Both
  now share one predicate keyed on the reserved namespace, and both refuse
  per-run polynomial *and* cosine drift (previously each saw only its own kind).
- vif(exclude_confounds=False) dropped any column whose name merely contained
  "poly_0" while missing the all-ones `cosine_0` it actually needed to drop.
  Now keyed on a generated-intercept predicate, so the DCT constant is excluded
  and user columns never are.

nltools/utils.py holds the single source of truth: RESERVED_PREFIX plus
reserved_name / run_separated_name to build names and is_reserved_name /
parse_run_separated to recognize them. append(axis=1) refuses a raw
pandas/polars frame whose columns use the prefix -- those columns are the
user's by definition, and admitting them would defeat the namespace.

Users may now name their own regressors anything, including `poly_0`, without
colliding with the machinery.

BREAKING: all generated DesignMatrix / find_spikes column names gained the
`.nl_` prefix, and run-separated columns changed shape from `{run}_{col}` to
`.nl_r{run}_{col}`. Code selecting generated columns by name must be updated;
see the (reserved-column-prefix) section of the migration guide.

Gates: 1873 passed, `poe lint` and full `poe lint-api` green.
Writing a DesignMatrix and reading it back did not work, for two independent
reasons.

**A `.csv` was written tab-separated.** `write()` defaulted to a tab delimiter
whatever the extension, while the file constructor chose the delimiter from the
extension, so `dm.write("design.csv")` read back as a single column named
`'cond_a\tcond_b'`. The delimiter now comes from one helper
(`separator_for_path`) used by both sides: `.csv` means comma, everything else
means tab. An explicit `sep=` still overrides. Files already on disk with a
mismatched delimiter are detected on read -- a single parsed column whose name
contains the other delimiter is an unambiguous tell -- and re-parsed, so they
load without intervention.

**There was no `.h5` reader.** `write_h5` produced a valid file that nothing
could open: the constructor routed every path to the CSV reader, which died
with `ComputeError: invalid utf-8 sequence`. Adds `read_h5` and dispatches on
`is_h5_path`. Because such a file is a serialized DesignMatrix rather than a
table awaiting interpretation, it requires neither `run_length` nor
`sampling_freq`, and it restores `sampling_freq`, `.convolved`, `.confounds`,
`.multi`, and the recorded row count of a column-less matrix (so `find_spikes`
output for a clean subject round-trips as `(n_tr, 0)`). Explicit kwargs still
override what the file recorded.

`write_h5` now stores the frame as Arrow IPC bytes through the existing
`nltools.io.h5` helpers rather than a homogeneous numpy array, so column dtypes
survive exactly -- an integer spike indicator comes back an integer. The
previous layout (float matrix + `S`-typed `columns` dataset) is still read.

Neither path had any test coverage; adds `test_designmatrix_io.py` (16 tests)
covering text symmetry per extension, the mismatched-separator fallback, h5
metadata/dtype/empty-matrix round trips, and the legacy h5 layout.

BREAKING: `write()` to a `.csv` now emits comma-separated data (it emitted tabs
before, which its own reader could not parse). Code parsing nltools-written
`.csv` files with an explicit tab delimiter must switch to comma or pass
`sep="\t"` to `write()`.

Gates: 1889 passed, `poe lint` and full `poe lint-api` green.
@ejolly

ejolly commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Landed: #471 as a reserved column namespace, plus the DesignMatrix file round-trip

Two commits on 0.6.0-fixes. Supersedes #471.

604073fb — reserve the .nl_ namespace for generated columns (breaking)

#471 fixed the add_poly() false positive by matching a tighter regex. This goes after the underlying problem instead: nltools was recognizing its own columns by pattern-matching user-controlled names, which is wrong in both directions no matter how good the pattern is. Every column nltools generates now carries the reserved prefix .nl_, and detection keys on the namespace we control.

v0.5.1 v0.6.0 Produced by
poly_0, poly_1, … .nl_poly_0, .nl_poly_1, … add_poly()
cosine_0, cosine_1, … .nl_cosine_0, .nl_cosine_1, … add_dct_basis()
global_spike1, diff_spike1, … .nl_global_spike1, .nl_diff_spike1, … find_spikes()
0_poly_0, 1_motion_x, … .nl_r0_poly_0, .nl_r1_motion_x, … append(axis=0, keep_separate=True)

Run separation moved the index inside the prefix and gained an r so it's parseable, and prefixes never stack (.nl_poly_0 in run 1 → .nl_r1_poly_0). It applies to your own confounds too (motion_x.nl_r0_motion_x): the run-prefixed variant is a name nltools invented, so it belongs to the namespace.

Two detection bugs this kills:

  • add_poly() / add_dct_basis() (the reported bug): a single-run design carrying the standard 24-parameter motion expansion couldn't add drift terms at all. Both adders now share one predicate keyed on the prefix. Per fix(designmatrix): add_poly() rejects designs with ordinary 2-underscore confounds #471's rework, both also refuse per-run polynomial and cosine drift — that shared-guard shape is kept, just keyed on the namespace rather than a regex over arbitrary names.
  • vif(exclude_confounds=False) (found while auditing for the same pattern, not in fix(designmatrix): add_poly() rejects designs with ordinary 2-underscore confounds #471): it excluded columns via "poly_0" not in c, which dropped a user column named my_poly_0_estimate while missing the all-ones cosine_0 it actually needed to drop — leaving a singular correlation matrix. Now keyed on a generated-intercept predicate that covers both .nl_poly_0 and .nl_cosine_0 plus their run-separated variants.

nltools/utils.py holds the single source of truth: RESERVED_PREFIX plus reserved_name() / run_separated_name() to build names and is_reserved_name() / parse_run_separated() to recognize them. append(axis=1) refuses a raw pandas/polars frame whose columns use the prefix — those columns are the user's by definition, and admitting them would defeat the namespace. DesignMatrix inputs are unaffected.

Net effect for users: you can now name your own regressors anything, including poly_0, without colliding with the machinery.

3aea7d63write() and the file constructor now round-trip (breaking)

Found while checking that dotted column names survive a save/load. They didn't — but neither did anything else, for two reasons unrelated to the rename. Neither path had any test coverage, which is why both survived.

  • A .csv was written tab-separated. write() defaulted to a tab delimiter whatever the extension while the constructor chose the delimiter from the extension, so dm.write("design.csv") read back as one column named 'cond_a\tcond_b'. Both sides now derive it from one helper (.csv → comma, else tab); explicit sep= still overrides. Files already on disk with the mismatched delimiter are detected and re-parsed, so they load without intervention.
  • There was no .h5 reader. write_h5 produced a valid file nothing could open — every path went to the CSV reader, which died with ComputeError: invalid utf-8 sequence. DesignMatrix("design.h5") now works and, since an .h5 is a serialized object rather than a table awaiting interpretation, needs neither run_length nor sampling_freq; it restores sampling_freq, .convolved, .confounds, .multi, and the recorded height of a column-less matrix (so find_spikes output for a clean subject round-trips as (n_tr, 0)).

The h5 writer now stores the frame as Arrow IPC bytes through the existing nltools/io/h5.py helpers instead of to_numpy(), so dtypes survive exactly — an integer spike indicator comes back an integer, and a DM with any non-numeric column no longer produces an object array h5py rejects. The previous layout is still read.

BREAKING beyond the renames: write() to a .csv now emits comma-separated data. Anything parsing an nltools-written .csv with an explicit tab delimiter needs to switch.

Verification

1889 passed (default suite), poe lint and full poe lint-api green. Migration guide gained (reserved-column-prefix) and (designmatrix-file-round-trip); the stale "there is no DM HDF5 reader yet" line is now accurate. The naming convention is recorded as a design rule in docs/development/index.md and CLAUDE.md.


Remaining work

On this branch, before merge

  • Batched docs regeneration: poe docs-generate (the myst.yml TOC references per-module algorithms API pages griffe2md hasn't generated, and the committed docs/api/*.md still show pre-rename column names) + poe changelog (git-cliff regen for the !: commits). Smoke-build docs-site after, since the TOC is currently ahead of the generated sources.

Scoped and ready to implement

  • iplot(): window opens at raw min/max — add robust autoscaling and percentile thresholds #479iplot() robust autoscaling: compute the window in Python (98th percentile of |nonzero| as ceiling, epsilon floor), percentile threshold strings via a shared resolve_threshold() in nltools/utils.py, autoscale: bool | tuple = True. Fold in the cal_min/slider inconsistency from the issue — once Python owns the window, always pass explicit cal_min/cal_max so the handles can't show one window while niivue renders another. One decision embedded: threshold() computes its percentile over b.data including zeros, which skews it for a masked map; the viewer wants nonzero. Sharing the helper means deciding whether to fix that in both (a silent change to thresholding results) or keep them deliberately different.
  • Design: should BrainCollection.predict() aggregate subjects, or map over them? #478BrainCollection.predict() map-reduce: direction is settled (map, per the issue discussion). Phase 1 is the predict_group() carve-out plus the int-cv groups= discard fix — small, and could ride this branch if we want the breaking rename inside 0.6.0 rather than a 0.6.1 deprecation. Phase 2 is the real work: per-subject Predict results need a PredictCollection container, which raises whether per-subject prediction routes through the _ItemTask/path-backed execution machinery and what the on-disk form is (the HDF5 fit bundles are the precedent) — that touches the execution model, so it wants its own PR. Phase 3: decide the legacy cv() path's fate and wire or remove the inert CVScheme.split_by (0.6.0 is the window).

Needs a scoping call

  • Survey remaining gpu support #484 — remaining GPU support: recommendation for 0.6.0 is no new GPU implementations — instead make every advertised-but-deferred site fail loudly and consistently (matching what matrix_permutation_test and Spearman/Kendall already do), fix the two silent offenders (SRM/DetSRM silently falls back to CPU; BrainCollection.permutation_test/permutation_test2 treat device= as informational), and record the rest as the 0.6.x roadmap. Real GPU paths are each their own project and shouldn't gate the release.

@ejolly

ejolly commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Scoping #484 is a requirement for 0.6.0 - need to revisit with proper scoping plan

@ejolly

ejolly commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Current status: remaining-work decisions resolved — final scope for this PR

All open decisions from the previous status comment are now resolved (that comment is superseded by this one). Basis for #484: the site-by-site GPU survey.

Decisions

  1. Survey remaining gpu support #484 lands on this branch, in full. Not the minimal fail-loudly pass — the survey exposed five divergent memory-budget/batch-size implementations, hard-coded budgets (4 GB defaults, an 8 GB constant inside SRM/hyperalignment that ignores the user's kwarg), and zero reactive OOM handling anywhere. Resolution: build one core GPU execution layer in backends.py and refactor everything onto it:
    • device_memory_budget() — measured budgets (torch.cuda.mem_get_info on CUDA, psutil-with-headroom elsewhere); an explicit max_gpu_memory_gb still wins.
    • auto_batch_size() — the single batch calculator; each algorithm supplies only its per-item working-set estimate.
    • An OOM-retry wrapper — catch device OOM → empty_cache() → halve batch → retry (floor 1, then a clear error). Predictive sizing plus this reactive net is the "never OOMs" guarantee.
    • All five consumers refactored: the inference engines, ridge, LocalAlignment, braindata/bootstrap, and the SRM/hyperalignment n_jobs sizing. Default CPU outputs pinned unchanged by tests.
  2. Run-or-raise policy, stated and enforced: explicit device='gpu' runs on the GPU or raises; 'auto' is the only graceful-fallback path (recorded in api-vocabulary.yml + docs/development/). Accordingly: SRM/DetSRM raise NotImplementedError on parallel='gpu' and drop the dead max_gpu_memory_gb kwarg; LocalAlignment validates parallel= (currently any typo silently runs numpy) and raises instead of logger-level fallback when torch is missing.
  3. Kendall GPU: implemented now, not roadmapped. The warn-and-fallback at correlation_permutation_test(metric='kendall', device='gpu') is replaced by a real tie-corrected tau-b kernel (batched O(n²) pairwise — cheap at permutation-test sample sizes — riding the new batching layer, parity-tested against scipy.stats.kendalltau).
  4. BrainCollection.permutation_test/permutation_test2 delegate to the engine (as isc_test already does): device= and n_jobs= stop being informational, ~60 lines of duplicated sign-flip/label-shuffle code go away. Seeded null draws change (engine RNG ≠ the hand-rolled default_rng) — migration-guide entry; same test, same distribution family.
  5. Design: should BrainCollection.predict() aggregate subjects, or map over them? #478 phase 1 rides this branch: predict_group() carve-out + the int-cv groups= discard fix, and the legacy cv() pipeline plus the inert CVScheme.split_by are removed (0.6.0 is the window; predict_group covers cross-subject CV). Phases 2–3 (PredictCollection, per-subject predict(y=) through the execution machinery) remain a follow-up PR.
  6. iplot(): window opens at raw min/max — add robust autoscaling and percentile thresholds #479 rides this branch: shared resolve_threshold() computing percentiles over finite nonzero voxels in both threshold() and iplot() (breaking-but-correct for masked maps — migration-guide entry), robust autoscale (autoscale: bool | tuple = True; 98th-percentile-of-|nonzero| ceiling, epsilon floor), and always-explicit cal_min/cal_max so the slider handles show the window actually rendered.
  7. Remaining GPU roadmap → their own issues, after this lands: the SRM torch port and the matrix-permutation GPU path (each a real project; neither gates 0.6.0).

Implementation order

  1. Core GPU layer (helpers → consumer refactor → SRM/LocalAlignment raise → Kendall kernel → collection delegation → policy docs; sync ridge-internals.md/inference-internals.md)
  2. Design: should BrainCollection.predict() aggregate subjects, or map over them? #478 phase 1
  3. iplot(): window opens at raw min/max — add robust autoscaling and percentile thresholds #479
  4. Batched docs pass (docs-generate + changelog + docs-site smoke build) — including the stale v0.5 tail docstring in one_sample.py the audit turned up

ejolly added 3 commits August 26, 2026 17:36
…OM recovery, run-or-raise

Consolidates every GPU/batched code path onto a single core layer in
algorithms/backends.py, closing the #484 requirement for 0.6.0
(scoping survey: #484, decisions: PR #483 thread):

- device_memory_budget(): max_gpu_memory_gb=None (the new default
  everywhere) measures the device — free CUDA memory with headroom,
  available system RAM for MPS/CPU — instead of assuming 4 GB (or the
  hard-coded 8 GB inside SRM/hyperalignment worker sizing).
- auto_batch_size(): the one batch calculator, replacing five divergent
  implementations (inference _auto_batch_size, bootstrap
  _auto_batch_size_ridge, ridge _auto_n_targets_batch,
  LocalAlignment._auto_batch_size, isc's byte math). Algorithms now
  supply only their per-item working-set estimate; a source-scan test
  pins budget math out of every other module.
- compute_oom_safe(): reactive OOM recovery in every GPU batched loop —
  empty cache, split the already-drawn batch inputs, retry. RNG draws
  stay outside the retried compute, so a seeded result is bit-identical
  with or without OOM (pinned by test_oom_recovery.py).
- auto_n_jobs_for_arrays() + the n_jobs helpers moved from
  inference/utils (re-exported), deduplicating six copy-pasted
  worker-sizing blocks in srm/hyperalignment.

Run-or-raise policy (api-vocabulary.yml + docs/development/index.md):
explicit device='gpu'/parallel='gpu' runs on the GPU or raises; 'auto'
is the only graceful fallback.

- SRM/DetSRM raise NotImplementedError on parallel='gpu' (was silent
  CPU fallback); the dead max_gpu_memory_gb kwarg is removed.
- LocalAlignment validates parallel= (typos silently ran numpy), raises
  for gpu+srm/hyperalignment and for gpu without torch.
- Kendall GPU is real: tie-corrected tau-b kernel via pre-computed
  pairwise sign tensors (permutation-invariant denominator), parity-
  tested against scipy.kendalltau — the warn-and-fallback is gone.
- BrainCollection.permutation_test/permutation_test2 delegate to the
  engine (like isc_test): device=/n_jobs= were informational, now real;
  both gain progress_bar=; ~60 duplicated lines deleted.

Also deletes the stale algorithms/inference/DESIGN.md (superseded by
docs/development/inference-internals.md).

BREAKING: SRM/DetSRM.fit() no longer accepts max_gpu_memory_gb and
parallel='gpu' raises; LocalAlignment rejects unknown parallel= values
and gpu-without-torch; max_gpu_memory_gb defaults changed from 4.0 to
None (measured) across inference/ridge/braindata entry points; seeded
null distributions from BrainCollection.permutation_test/
permutation_test2 change (engine RNG replaces the hand-rolled loop).
Migration guide: (gpu-execution-layer).
…pipeline

#478 phase 1. BrainCollection.predict(y=...) aggregated — one model with
subjects as samples — while every other per-subject method maps. The
aggregate operation now lives under an explicit name:

- predict_group(y, *, ...) is the old predict(y=) group-MVPA path,
  returning the same Predict dataclass. predict(y=...) raises with
  guidance; predict(X_new=) (per-subject predict-after-fit) unchanged.
  The per-subject decoding form of predict(y=) is #478 phases 2-3.
- Bug fix: an int cv= no longer discards groups=. predict(cv=5,
  groups=subject_ids) resolved to plain KFold, whose groups argument is
  documented "always ignored" — folds were byte-identical to passing no
  groups, same subject in train and test. The new
  nltools.cross_validation.resolve_group_cv resolves int specs to
  StratifiedGroupKFold (classifiers) / GroupKFold (regressors) when
  groups are supplied; a behavioral test pins that a group never
  straddles a fold boundary.
- The legacy cv() pipeline is deleted: BrainCollectionPipeline,
  pipeline.py, the pipesteps/ machinery, and the never-read
  CVScheme.split_by knob. Its one capability predict_group lacked — the
  label-permutation null — is ported: predict_group(n_permute=,
  random_state=) attaches permutation_scores / permutation_pvalue
  (new fields on Predict), same (1 + #(null >= obs)) / (n + 1) form.

BREAKING: BrainCollection.predict(y=...) raises (use predict_group);
BrainCollection.cv() and BrainCollectionPipeline are removed.
Migration guide: (predict-group).
…tile thresholds

Closes #479. iplot() opened with its window at the raw data min/max, so
a couple of outlier voxels set the whole color scale — a single-subject
beta map rendered as washed-out noise and the 3D view as a solid box
(the same bug: cal_min at the data minimum leaves no voxel transparent).

- autoscale: bool | tuple = True on iplot(): the default window ceiling
  is the 98th percentile of the finite nonzero magnitudes (outliers no
  longer set the scale — nilearn/FSLeyes practice) with an epsilon floor
  (zeros transparent, everything else visible; threshold up from there).
  (lo_pct, hi_pct) picks both edges; False is the old extremes window,
  made explicit. Explicit threshold/lower/upper override per edge.
  Window logic lives in viewer.compute_display_window (pure function).
- nltools.utils.resolve_threshold() is the single source of truth for
  percentile strings ("98%"), now accepted by iplot's threshold/lower/
  upper like threshold() always did. Both resolve over finite NONZERO
  values — on a masked map most voxels are exactly zero (absence of
  data) and previously dragged every percentile toward zero. iplot
  resolves magnitude percentiles (divergent magnitude window);
  threshold() signed ones.
- The slider always shows the rendered window: cal_min/cal_max are
  always computed in Python and passed explicitly, ending the state
  where the handles showed the extremes while niivue rendered its own
  auto window (and the first slider touch destroyed it).

BREAKING: threshold(upper="98%") results change on any map containing
zeros (percentile now over nonzero voxels); iplot's default window is
autoscaled rather than min/max. Migration guide: (iplot-autoscale).
…leanup

The deferred docs pass for the 0.6.0 consolidation (PR #483):

- poe docs-generate: 65 API pages regenerated, catching the committed
  docs/api/*.md up with the myst.yml TOC (per-module algorithms pages)
  and every rename this branch landed (predict_group, the core GPU
  layer in backends, measured max_gpu_memory_gb defaults, Kendall GPU,
  .nl_ columns); tutorial .md regenerated from the marimo sources.
- poe changelog: git-cliff regen picks up the branch's breaking commits.
- Fixed four inference docstrings (one_sample, two_sample, correlation,
  matrix) still documenting the removed v0.5 tail forms
  ('upper'/'lower'/-1) that now raise — found during the #484 audit.
- 04_brain_collection tutorial prose: bc.predict(y=)/bc.cv() pointer
  updated to predict_group().

Verified with a full `poe docs-site --execute` smoke build: exit 0, no
execution errors.
@ejolly

ejolly commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Landed: the full resolved scope — #484 (GPU layer), #478 phase 1, #479, batched docs pass

All four phases from the decision record are on the branch. Supersedes the record's "remaining work" list; nothing is left before merge.

d5853840 — one core GPU execution layer (breaking; closes the #484 0.6.0 requirement)

Everything scoped in the #484 survey plus the consolidation the survey exposed:

  • Measured budgets: max_gpu_memory_gb=None (the new default everywhere) measures the device — free CUDA memory with headroom, available system RAM for MPS/CPU — replacing the assumed 4 GB (and SRM/hyperalignment's internal hard-coded 8 GB). device_memory_budget() is the one budget source.
  • One batch calculator: auto_batch_size() replaces five divergent implementations (inference, ridge bootstrap, ridge targets, LocalAlignment, isc's byte math); algorithms supply only their per-item working-set estimate. A source-scan test pins GB→bytes math out of every other module.
  • Reactive OOM recovery: every GPU batched loop routes device compute through compute_oom_safe() — on OOM: empty cache, split the already-drawn batch inputs, retry. RNG draws stay outside the retried compute, so a seeded result is bit-identical with or without OOM (test_oom_recovery.py pins this by simulating OOM and asserting array equality). A machine can no longer OOM except when a single item exceeds device memory, which raises a clear MemoryError.
  • Run-or-raise policy (now stated in api-vocabulary.yml + docs/development/index.md): explicit device='gpu'/parallel='gpu' runs on the GPU or raises; 'auto' is the only graceful fallback. SRM/DetSRM raise NotImplementedError (were silent CPU fallbacks; dead max_gpu_memory_gb kwarg removed), LocalAlignment validates parallel= (typos silently ran numpy) and raises for gpu+srm/hyperalignment and gpu-without-torch.
  • Kendall GPU implemented (per the decision to build it now, not roadmap it): tie-corrected tau-b via pre-computed pairwise sign tensors — permutations only re-index them and the tie denominator is permutation-invariant — parity-tested against scipy.stats.kendalltau with ties, null distribution matching the CPU path seed-for-seed. The warn-and-fallback is gone.
  • Collection permutation tests delegate to the engine (like isc_test): device=/n_jobs= were informational, now real; both gained progress_bar=; ~60 duplicated lines deleted. Seeded null draws change (engine RNG) — migration-guide entry.
  • Six copy-pasted worker-sizing blocks in srm/hyperalignment collapsed into auto_n_jobs_for_arrays(); the n_jobs helpers moved to backends.py (re-exported). Stale algorithms/inference/DESIGN.md deleted (superseded by docs/development/inference-internals.md, which is updated, as is ridge-internals.md).

Migration guide: (gpu-execution-layer).

8988bf1a#478 phase 1: predict_group() carve-out; legacy cv() removed (breaking)

  • predict_group(y, ...) is the old predict(y=...) group-MVPA aggregate under its true name (same Predict result). predict(y=...) raises with guidance; predict(X_new=) unchanged. Per-subject decoding (predict(y=) as a map) remains Design: should BrainCollection.predict() aggregate subjects, or map over them? #478 phases 2–3.
  • int-cv groups= discard fixed: nltools.cross_validation.resolve_group_cv resolves int specs to StratifiedGroupKFold/GroupKFold when groups are supplied; a behavioral test pins that a group never straddles a fold boundary.
  • The cv() pipeline, BrainCollectionPipeline, pipesteps/, and the inert CVScheme.split_by are deleted (net −1,613 lines). Its label-permutation null is ported to predict_group(n_permute=, random_state=)permutation_scores/permutation_pvalue on Predict.

Migration guide: (predict-group).

4810da99#479: iplot() robust autoscaling + shared zero-aware percentile thresholds (breaking)

  • autoscale: bool | tuple = True: default window ceiling at the 98th percentile of finite nonzero magnitudes (outliers no longer wash out the map; same principle as nilearn/FSLeyes), epsilon floor (zeros transparent, everything else visible — threshold up). Tuple picks both edges; False = the old raw extremes, made explicit.
  • resolve_threshold() in nltools.utils is the single source of truth for "98%", used by both threshold() and iplot(), resolving over finite nonzero values (per the decision: fixed in both — a masked map's zeros no longer drag percentiles toward 0). iplot percentiles are magnitude percentiles (its window is a divergent magnitude window); threshold()'s are signed.
  • Slider ≡ render: cal_min/cal_max are always computed in Python and passed explicitly, killing the handles-show-one-window-niivue-renders-another inconsistency (and the washed-out 3D "blue box", which was the same bug).

Migration guide: (iplot-autoscale).

1089bdb0 — docs pass

poe docs-generate (API sources caught up with the myst.yml TOC), poe changelog, and a docs-site smoke build. Also fixed during the audit: four inference docstrings still documenting the removed v0.5 tail forms ('upper'/'lower'/-1).

Verification

Every phase gated on poe lint + full poe lint-api + the full default suite; final state: all green (counts in the per-phase commit messages). New coverage: core-layer unit tests, OOM-recovery determinism, GPU-policy tests, Kendall-GPU parity, engine-delegation parity, group-cv resolution, autoscale windows, zero-aware percentiles.

Follow-ups filed

@ejolly

ejolly commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Remaining work — open items and pending calls

Correction to the previous status comment: its "nothing is left before merge" line and the "fast-follow" labels were premature — whether each item below lands in this PR or after it is an open call, not a settled one. This is the complete inventory of what remains; none of it is scheduled.

Implementation items still open

Verification not yet done on this branch

  • poe test-all (slow + integration): not run. The default suite (1921 passed) plus the slow SRM/hyperalignment/LocalAlignment subset ran, but not the full slow/integration set.
  • gpu-marked tests on CUDA hardware: nothing on this branch has run against a CUDA device (local runs exercised torch-cpu/MPS). The measured-budget CUDA path (torch.cuda.mem_get_info) is unit-tested but not hardware-verified.
  • poe docs-wasm: the WASM/Pyodide tutorial export was not rebuilt or browser-verified — only docs-generate + docs-site --execute ran. Given the known WASM fragility, a real-browser check may be warranted before release.
  • Benchmarks: bench_inference (and the ridge benches) were not re-run after the GPU-layer refactor. The consumer refactor is parity-pinned for correctness but its performance profile (measured budgets → larger batches; removed per-batch empty_cache) is unmeasured.

Calls that are yours to make

  • Which of the above must land in this PR vs. after it.
  • Merge timing for the PR itself (CI on the pushed branch: lint and Pyodide smoke green so far; the 3.11/3.12/3.13 test matrix was still running at posting time).
  • Whether 0.6.0 release steps (version bump, poe release) wait on any of the items above.

ejolly added 10 commits August 26, 2026 18:31
…ot bitwise

CI (torch-cpu, linux) failed test_oom_recovery test_correlation[pearson]:
37/1500 null values differed by <= 6e-08 (one float32 ulp), mismatches
starting exactly at the recovery sub-batch boundaries (rows 36, 73).
The recovery design holds — the pre-drawn permutations are reused
exactly — but torch blocks einsum/std reductions differently per batch
shape, so bitwise equality across splits was an overclaim (it happened
to hold on MPS locally).

- test_oom_recovery: one shared comparison at rtol=1e-4/atol=1e-6 for
  null distributions and a single-boundary-count-flip tolerance
  (1.01/(n+1)) for p-values, applied to all three determinism tests so
  the one_sample/two_sample variants can't flake the same way later.
- compute_oom_safe docstring, inference-internals.md, and the migration
  guide now state the guarantee precisely: identical draws, outputs
  equal to within float32 reduction order.
…e 'loso'/'loro'

resolve_group_cv generalizes to resolve_cv, the single cv-spec resolution
rule for every prediction level: 'loo' -> LeaveOneOut, 'logo' ->
LeaveOneGroupOut, int -> (Stratified)KFold honoring groups= via the Group
variants, splitter passthrough. The removed domain names raise with
migration guidance — both were LeaveOneGroupOut differing only in the
implied grouping, which groups= expresses directly.

predict_group's default becomes cv='logo' (identical splits: groups still
defaults to one per subject = leave-one-subject-out); leave-one-run-out is
now cv='logo', groups='run'. Int specs for classifiers now resolve to
StratifiedKFold (previously plain KFold at the group level).

BREAKING: cv='loso'/'loro' removed; use cv='logo' with groups=.
predict_group(cv=<int>) with a classifier now stratifies folds.

Part of #478 (phases 2-3 groundwork).
…ls travel with the data

BrainData.predict(y=None) now falls back to a single-column .Y frame;
y='name' and groups='name' select columns of a multi-column .Y (the
row-aligned metadata carrier, so within-subject grouping variables like
run live beside the labels). An object with both a fitted encoding model
and a stored .Y refuses the no-argument call as ambiguous. validate_frame
accepts dicts so `bd.Y = {"label": arr, "run": runs}` just works.

predict_mvpa's cv resolution now routes through resolve_cv: int specs
accept and honor groups= (previously silently discarded — the same bug
predict_group had), and cv='loo'/'logo' work at the single-subject level.

BREAKING: predict(cv=<int>, groups=...) now group-aware; previously
groups was ignored for int cv specs.

The per-subject half of #478: BrainCollection.predict(y=...) maps this.
…ntainer

Frozen, sequence-like container: one Predict per subject plus the source
collection's per-subject metadata. mean_scores/std_scores stack score
summaries, scores renders the whole-brain case as a polars table, and
weight_maps/accuracy_maps stack the per-subject maps into one
BrainData (n_subjects, n_voxels) — the bridge to second-level inference.

Part of #478 phases 2-3.
…478 map-reduce gap

BrainCollection.predict(y=...) now does what every other method on the
class does: one operation per subject. It maps BrainData.predict over the
items — one model per subject, CV within that subject's own rows — and
returns a PredictCollection carrying the collection's metadata, with
weight_maps/accuracy_maps stacking for second-level inference.

Label resolution mirrors the class's data model: y=None decodes each
subject's single-column .Y, y='name' picks a .Y column, one shared array
applies everywhere, and a list of arrays is per-subject. groups= resolves
the same way for within-subject schemes (cv='logo', groups='run' =
leave-one-run-out inside each subject).

Runs through the _ItemTask/_apply machinery: subject-level parallelism,
progress, and path-backed caching. cache=True writes one predict bundle
(.h5) per subject — the result's ingredients (arrays, brain maps, model
spec for refit), never a pickled estimator; the in-memory results mirror
the bundle (estimator=None) so resumed sessions see identical fields.
write_predict_bundle/read_predict_bundle join the fit-bundle IO in
execution.py under the shared schema version.

BREAKING: predict(y=...) previously raised (and before that, aggregated
across subjects — that operation is predict_group since the phase-1
carve-out). predict() with no arguments now decodes stored .Y instead of
raising for a missing X_new.

Closes #478 phases 2-3.
…tion model, vocabulary

Migration guide's #478 section now documents the finished state: predict(y=)
per-subject decoding -> PredictCollection, predict_group as the aggregate,
the 'loso'/'loro' -> 'logo'/'loo' rename, and the shared resolve_cv groups
fix at both levels. execution-model.md gains the predict-bundle section
(ingredients-only, no pickled estimators) and folds predict(y=) into the
path-backed/cache story. api-vocabulary.yml gains the `cv` row; tables and
API sources regenerated.

Also removes the stale collection pipeline/pipesteps API pages and their
build_api_docs.py + myst.yml entries — those modules died with the legacy
cv() pipeline but the generator config kept resurrecting the pages.

Part of #478 phases 2-3.
…iring fixed in batched phase randomization

poe test-all (not run since the GPU-layer refactor) surfaced two real bugs
in the timeseries GPU path plus stale slow tests:

- circle_shift GPU derived shift amounts via RandomState(seed).choice(
  arange(n)) — a different draw than circle_shift()'s randint(1, n), and
  one that could produce an identity shift. Same seed gave different
  permutations on GPU vs CPU, breaking the deterministic cross-backend
  RNG contract. New _circle_shift_amounts() makes exactly the CPU draw.
- _phase_randomize_gpu_batched applied an extra .flip() to the negative-
  frequency phases. pos_freq/neg_freq are built already in conjugate
  order, so flipping mispairs conjugates: the spectrum goes non-Hermitian
  and .real of the ifft silently distorts the surrogate — statistically
  wrong, not just nondeterministic. (The non-batched GPU version was
  correct; only the batched path the permutation test uses had the flip.)

Both pinned by new TestGpuDrawIdentity unit tests (per-seed CPU/GPU draw
identity); the two slow GPU-vs-CPU tests now pass at their original tight
tolerance. inference-internals.md documents the pairing invariant.

BREAKING: GPU null distributions for timeseries_correlation_permutation_
test change (they now equal the CPU nulls for a given seed).

Stale slow tests corrected alongside: test_threshold now asserts the
documented v0.6.0 zero-aware "98%" percentile semantics, and the two
test_matches_stats_py_* cross-checks are deleted — their comparison
target (correlation_permutation_test(method=...)) was deliberately split
into the timeseries function and no longer exists.

Full suite (poe test-all): 2395 passed, 0 failed, 5 skipped (CUDA-gated).
…categories preserved

Warnings raised inside _apply workers (RankDeficientDesignWarning above
all) died on the loky worker's stderr: invisible to catch_warnings,
pytest.warns, and filterwarnings("error") in the parent, usually invisible
entirely in notebooks, and suppressed on repeat runs by the reused
workers' once-per-location filter. A rank-deficient design could fit
silently at n_jobs>1.

_wrap_worker now captures warnings as pickle-safe _WorkerWarning records
(category as import-path strings, never a class object) and _apply relays
them via warnings.warn_explicit: deduplicated by (category, message)
across subjects with a who-raised-it annotation, real categories preserved
so parent-side filters and pytest.warns work, UserWarning fallback (class
name kept in text) for unimportable categories. The serial n_jobs=1 path
uses the same capture/relay, so behavior is identical at any n_jobs.

Documented caveats (execution-model.md): filterwarnings("error") promotes
at relay time after the parallel step, and warnings preceding a worker
exception are dropped in favor of the error.
…owser support to 0.6.1

Tutorials are now plain marimo notebooks (PEP 723 header = marimo + nltools) for
local editing and molab; every IN_WASM / micropip / seed_resources / browser_*
cell is gone. `docs-preview` runs `myst start --execute` so pages show baked
outputs from the execute cache instead of silently re-rendering without them.
Fixed two tutorials that raised mid-run (halting every later cell): 04_isc
passed metric="median" (now summary="median"); 02_design_matrix appended exact
duplicate columns, which append() now refuses — it appends jittered copies.

BREAKING: removed nltools.templates.seed_resources and the Pyodide/IDBFS fetch
path, nltools.datasets.PAIN_RESOURCES / EMOTION_METADATA / emotion_resources,
scripts/build_marimo_wasm.py, the docs-wasm and test-pyodide poe tasks, the
pyodide CI job, and nltools/tests/pyodide. All of it is preserved on the
0.6.1-browser branch.
Plot cells ended with plt.gcf() / _fig as their last expression, so the figure
was emitted by the rich display and again by the pyplot end-of-cell flush (in
both marimo and the docs' ipykernel). End the cells on a statement instead.
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