Skip to content

HyperTools 1.0.0 — release to master (review & documentation; do not merge yet)#281

Open
jeremymanning wants to merge 61 commits into
masterfrom
dev-1.0
Open

HyperTools 1.0.0 — release to master (review & documentation; do not merge yet)#281
jeremymanning wants to merge 61 commits into
masterfrom
dev-1.0

Conversation

@jeremymanning

Copy link
Copy Markdown
Member

⚠️ Do not merge yet

This PR promotes the completed HyperTools 1.0.0 line (dev-1.0) to master. It is opened for review and comprehensive change documentation; it should be merged only after maintainer sign-off, and as a squash-merge (the 1.0 development history carries large generated binaries — see Merging).

HyperTools 1.0.0 is a ground-up modernization of the toolbox. The familiar one-call API is preserved (plot, analyze, reduce, align, cluster, normalize, describe, load), but the internals, packaging, feature set, and test/security posture are new.

Diff scale (git diff master…dev-1.0): 991 files changed, +83,330 / −15,246. The hypertools/ package alone grows from a flat tools/ module set into ten focused subpackages built on one shared stack → fit-once → unstack model-application core.

This description was assembled from six parallel subsystem audits of the actual masterdev-1.0 diff, plus directly-verified demonstration figures/animations (every image below was rendered from 1.0 and visually inspected).


See it in action

Hyperaligned "story trajectories" — 36 subjects' whole-brain fMRI activity, hyperaligned in the 100-hub space then reduced to 3-D, tracing a shared path through a spoken story (examples/plot_story_trajectories.py, the new featured example):

story trajectories

Alignment — the same datasets before vs. after hyperalignment (hyp.plot(list, align='hyper')):

before after
before after

New-in-1.0 plotting — soft (mixture-model) clustering with blended memberships, lit convex-hull surfaces, KDE density shading, continuous multicolored lines + colorbars, the interactive plotly backend, and trajectory forecasting:

soft clustering hull surfaces density shading
cluster surface density
multicolored + colorbar interactive plotly backend forecasting (hyp.predict)
multicolored plotly predict

Animated plotting (hyp.plot(list, animate=True)) and hyp.describe:

animate describe
plot describe

What changed, by subsystem

1. Plotting (hypertools/plot/)

Grew from 4 files to 17: low-level drawing split into two parallel renderersmatplotlib_backend.py (~2.4k LOC) and plotly_backend.py (~2.5k LOC) — sharing backend-agnostic helpers (meshutil, surface, density, morph, trails, multiindex, colors, fonts, animate) so both stay in visual lockstep. draw.py is now a 2-line shim.

New plot() keywords: backend='matplotlib'|'plotly'|'auto' (auto = plotly on Colab/Kaggle); surface= (lit, smoothed 3-D convex-hull "blob" / filled 2-D outline, Blinn-Phong lighting); density= (2-D heatmap / 3-D volumetric KDE); colorbar= (continuous or segmented-discrete); matrix/continuous hue= (multicolored lines; mixture proportions → blended RGB via mat2colors/color_reduce); names= (per-trace legend); animation styles animate='spin'|'serial'|'window'|'morph' + per-dataset lists + 2-D animation (focused=, morph_samples=); per-dataset chemtrails/precog/bullettime lists; font=/label_alpha= (CJK-aware); xlabel/ylabel/zlabel; pipeline integration (manip=, pipeline=, impute=, resample=, predict=/t=, random_state=, return_model=True{'fig','xform_data','animation','pipeline','models','predict'}); streaming (stream_init/chunk/max/window); MultiIndex expansion; arbitrary matplotlib-prop passthrough.

Behavior: animated matplotlib plot() now returns a HyperAnimation (.to_html5_video()/.save()/notebook autoplay); defaults rotations 2→1, frame_rate 50→30; data-faithful PCHIP line interpolation; per-dataset list-kwarg length mismatches now raise rather than silently degrade. set_interactive_backend() accepts render backends.

Retired: group= (→ hue=, now raises), model=/model_params= (→ reduce={'model':…,'kwargs':…}).

2. Reduce / cluster / align / manip (hypertools/{reduce,cluster,align,manip}/)

Each rebuilt as a subpackage (main module + common.py base class); 22 new files, +6,145 LOC. Four sklearn-compatible base classes (Reducer, Clusterer, Aligner, Manipulator), each fit/transform/fit_transform/is_fitted.

  • hyp.manip is newNormalize, ZScore, Smooth (savgol|gaussian|boxcar kernels), Resample, with chaining (model=[…] builds a Pipeline).
  • Cross-module stage kwargs on all four dispatchers (manip/normalize/reduce/align/cluster= + ndims=) assemble a pipeline in canonical order manip → normalize → reduce → align → cluster.
  • return_model=True everywhere; a fitted model handed back is reused via .transform, never refit; random_state= injection.
  • New clusterers: MeanShift, DBSCAN, OPTICS, AffinityPropagation (+ existing KMeans/…); HDBSCAN now from sklearn.cluster (no external hdbscan dep). New aligners: HyperAlign, SharedResponseModel/Det/Robust, Procrustes, NullAlign. Mixture/soft-clustering reducers (GaussianMixture/BayesianGaussianMixture/LDA/NMF) return membership proportions via the same code path as hyp.cluster. Six torch autoencoder reducers (Autoencoder, Deep/Sparse/Convolutional/Sequence/Variational).

Retired: reduce model_params; align method= and align=True ('hyper' is now a deprecated alias for 'HyperAlign'); cluster ndims= no longer silently reduces (warns without reduce=).

3. IO, data loading & dataset security (hypertools/io/)

The whole io/ package is new (load.py, sources.py, streaming.py, save.py, lsl.py), superseding master's 190-line tools/load.py.

  • hyp.load resolves an 11-step chain: built-in examples → scikit-learn bundled (iris/digits/…) → seaborn → fivethirtyeight/<slug>kaggle/<owner>/<dataset> → local files (.npy/.npz/.csv/.tsv/.txt/.json/.parquet/.mat/.xlsx/.gz) → Hugging Face ids (split=, streaming=) → Google Sheets → Google Drive → Dropbox → any URL. Failed loads emit a "tried, in order" digest with a near-miss suggestion.
  • hyp.save is new — extension-aware (.csv/.npz/.parquet/.mat/.xlsx/…, else pickle), atomic (temp-file + os.replace, mode-preserving), narrowed signature (protocol= only).
Dataset security (the focus of a six-round pre-release review) — expand
  • Built-ins re-hosted as non-executable formats (.npz/.parquet/.json.gz) reconstructed with no unpickling (allow_pickle=False); master fetched Google-Drive pickles and pickle.loads'd everything.
  • Pinned SHA-256 verified before any deserialization; every cache hit re-validated; a mismatch is a hard error, never a silent redownload-reparse.
  • Atomic, locked downloads (.part temp → hash-check → os.replace, best-effort per-dataset filelock, rate-limit-aware retry).
  • Remote pickle refused unless trust=True (HypertoolsTrustError at a single chokepoint covering extension/magic-byte/protocol-0 paths); allow_pickle=False for remote npy/npz.
  • sotus migrated off the datawrangler corpus-loader bypass to a plain non-executable json.gz.
  • The fitted *_model sklearn pipelines are the one documented pickle exception — hash-pinned before unpickling.
  • Gzip-bomb cap on transparent decompression.

Also new: streaming (io.plot_stream, issue #101) and io.lsl_stream (Lab Streaming Layer input, [lsl] extra). Vendored external/ppca.py + external/brainiak.py (SRM family) replace bare deps.

4. Forecasting, imputation & the model-application core (hypertools/{predict,impute,core}/)

Entirely new (23 files, +4,774 LOC).

  • hyp.predictKalman, GaussianProcess, AutoRegressor, ARIMA (base install), Laplace ([predict]), Chronos ([predict-hf]); int/datetime t= horizon semantics; one model fit per dataset; return_model=True reuse (predict_new reuses learned params).
  • hyp.imputePPCA, SimpleImputer, KNNImputer, IterativeImputer, Kalman; splices (observed values byte-identical, only NaNs filled); the Kalman imputer fills fully-missing rows.
  • hyp.apply_model + hyp.Pipeline — the stack → fit-once → unstack core over an explicit, eval-free model whitelist (replacing a prior eval() over sklearn's namespace); picklable, reuse-capable Pipeline unifying manip/normalize/reduce/align/cluster in canonical order.
  • New HypertoolsError / HypertoolsBackendError / HypertoolsIOError; config.ini published as a queryable mirror of signature defaults.

5. Public API, packaging & CI

  • __all__ added to the top-level package (star-import no longer leaks internals). New exports: manip, predict, impute, save, apply_model, supported_models, Pipeline, HyperAnimation, the io submodule, and the three exception classes. DataGeometry removed from the public surface; load/reduce/align/describe/cluster relocated to their subpackages (same public names).
  • setup.py/requirements.txt/.travis.yml deleted → single pyproject.toml, Python 3.10–3.13, NumPy 2, PEP 639 SPDX license. pykalman/statsmodels moved into the base install (so predict/impute work out of the box). Optional extras: interactive, text, predict, predict-hf, io, density3d, torch, kaggle, lsl, gensim, dev. config.ini shipped as package data.
  • GitHub Actions (.github/workflows/test.yml), all SHA-pinned: a 12-job matrix (Linux/Windows/macOS × py3.10–3.13) + a pandas-3 acceptance gate; wheel-smoke (installs the built wheel and sdist into fresh venvs); docs-clean (pristine git archive, sphinx -W -E -a); dataset-gate (uncached fresh download of every hosted dataset). .readthedocs.yaml updated (Python 3.11, ffmpeg + pandoc, plotly_get_chrome).

6. Examples, docs & tests

  • Examples gallery: 53 scripts (+22 new) — incl. plot_datasaurus, plot_surface, plot_density, plot_colorbar, plot_multicolored_lines, plot_mixture_models, plot_predict, plot_impute, plot_autoencoders, plot_gensim_text, plot_apply_model, plot_pipelines_return_model, plot_interactive_backend, animate_plotly, animate_surface_morph, plot_story_trajectories, …
  • Tutorials: the legacy .rst tutorials became executable .ipynb notebooks, +8 new (Hugging Face / Wikipedia embeddings, conversation trajectories, streaming, LSL, stock forecasting, projectile Kalman, …). Docs theme → Furo (ContextLab branding); sphinx-gallery plotly scraper + mp4 animations; api.rst reorganized into canonical pipeline order.
  • Tests: ~161 files / ~2,050 tests (up from 17 files / ~136), ~15× — per-subsystem migration suites, systematic audit-fix regression suites, headless/backend + animation-export coverage, dataset integrity/compat/provenance, packaging-artifact + pickle-trust-boundary tests. Gated by a cross-platform matrix with a zero-warning -W bar.

Backward compatibility

The one-call API is preserved. Breaking changes are limited to long-deprecated arguments that now error instead of being silently ignored: group=hue=; model=/model_params=reduce=; align(method=…)/align=Truealign='hyper'; cluster(ndims=…) no longer reduces. plot() returns a Figure/HyperAnimation/plotly Figure and load() returns raw data — there is no central DataGeometry object anymore (it survives only as an internal unpickle-only shim, so .geo files saved by hypertools ≥ 0.8 still load). Pre-0.8 deepdish/HDF5 geo files need a one-time out-of-process conversion (documented).

Release status

  • Version is 1.0.0 (final; not .dev0). Wheel + sdist build clean, twine-valid, and both install into a fresh venv reporting hyp.__version__ == "1.0.0" (verified locally and by the CI wheel-smoke job).
  • All CI green on the tip of the 1.0 line (12-job matrix + wheel/sdist smoke + uncached dataset gate + clean-docs -W).
  • The branch went through six rounds of independent pre-release review (runtime security, dataset supply-chain, packaging, docs, and verification-tooling) — all findings resolved.

Merging

Please squash-merge this PR (per CONTRIBUTING.mdMerging pull requests): the 1.0 development history contains large generated binaries (rendered galleries, audit evidence, dataset payloads) that were later removed from the tree; a normal merge would copy that entire history — and every large blob in it — into master permanently. Squash-merging brings only the final tree.

Two small post-merge follow-ups (non-blocking): tag a signed v1.0.0 on the merge commit and publish only the final 1.0.0 artifacts to PyPI; the README already uses relative image paths, so nothing to re-point.

🤖 Generated with Claude Code

jeremymanning and others added 30 commits July 1, 2026 23:57
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…audit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erator, dev notebook

- scripts/screenshot_harness.py: headless PNG capture per function/use-case
- scripts/generate_baseline_screenshots.py: 13 baseline cases, all passing on v0.8.2
- dev/hypertools_2.0_dev.ipynb: interactive test matrix, one section per public function
- Roadmap updated with design decisions mined from fork issue tracker (incl. comments)
- tests/screenshots/ gitignored (reviewed locally / CI artifacts, not committed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mixture models to first-class 2.0 features; record approved backend='auto' policy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…models, robust coloring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lazy heavy imports

- Migrate to PEP 621 pyproject.toml (v2.0.0.dev0, py3.10+); delete setup.py,
  requirements.txt, MANIFEST.in, stale .travis.yml
- CI: py3.10-3.13 matrix, setup-python@v5, cache@v4, codecov@v4, screenshot
  artifact upload; readthedocs python 3.9->3.11
- Remove memoize entirely (user requirement): str()-keyed cache truncated
  numpy arrays -> cache collisions returned wrong results (fork issue #3)
- Lazy-import umap, seaborn, scipy.interpolate: import hypertools 5.1s -> 1.46s
- 136/136 tests pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…re-formatting, scope plot styling (fixes #259)

- Replace external hdbscan package with sklearn.cluster.HDBSCAN (always
  available); drop the SyntaxWarning filter that existed only for it
- plot(): pass format_data=False to the post-analyze reduction (data was
  already formatted; avoids a redundant format_data/PPCA pass per plot)
- plot(): apply seaborn palette/style inside plt.rc_context() so plotting no
  longer permanently mutates matplotlib rcParams (GH #259) - verified with a
  real before/after rcParams diff
- 136/136 tests pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lors)

- cluster() supports GaussianMixture, BayesianGaussianMixture, LDA, NMF and
  returns (n_samples, n_components) membership proportions (rows sum to 1);
  hard-clustering behavior unchanged
- New hypertools/tools/colors.py: mat2colors maps categorical labels,
  continuous 1D values, or 2D matrices (soft assignments / arbitrary numeric
  matrices) to RGB; colors2groups quantizes per-point colors into traces for
  the matplotlib renderer
- plot() accepts cluster='GaussianMixture' etc. (points colored by
  proportion-weighted blends) and matrix-valued hue
- 145/145 tests pass (9 new: real GaussianMixture/BGM/LDA/NMF calls,
  color-blend math, end-to-end mixture plot)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- plot([[a, b], [c]]) flattens arbitrarily nested dataset lists, coloring
  every leaf by its outermost group and rendering deeper leaves thinner and
  fainter (summary -> detail, per fork design issues #14/#16)
- Nested string lists (text corpora) are explicitly excluded and keep their
  existing text-pipeline behavior
- 156/156 tests pass (6 new, incl. rendered-line color/width assertions)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New hypertools/plot/interactive.py: plotly renderer mirroring _draw's
  contract (2D/3D traces, fmt-string mode mapping, per-trace colors/labels,
  hypertools no-ticks aesthetic, matplotlib elev/azim -> plotly camera)
- Animations: sliding-window frames (animate=True) and camera spin
  (animate='spin') with play/pause controls
- hyp.plot(..., backend='auto'|'matplotlib'|'plotly'): auto uses plotly ONLY
  on Colab/Kaggle (approved policy); matplotlib default everywhere else
- Screenshot harness exports plotly figures via kaleido
- 169/169 tests pass (13 new: policy resolution incl. Colab/Kaggle markers,
  fmt mapping, camera math, end-to-end plotly figure/animation assertions)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ok, backend fix, README 2.0 docs

- scripts/generate_verification_screenshots.py: 44/44 cases pass covering
  every public function (plot/reduce/align/normalize/cluster/analyze/
  describe/format_data/load/text) on both backends; INDEX.md manifest;
  curated copy committed to docs/images/v2.0-verification/ for PR evidence
- dev notebook executed end-to-end with 0 errors via nbclient
  (dev/hypertools_2.0_dev_executed.ipynb); notebook cells updated to
  exercise implemented 2.0 APIs
- backend.py: catch ValueError from mpl.use() -- matplotlib >=3.9 raises it
  (not ImportError) for missing ipympl; likely root cause of Colab
  animate=True failures (#235)
- README: What's new in 2.0 + modernized requirements; ipykernel in [dev]
- Full suite re-verified: 169/169 tests, 13/13 baselines, import 1.5-1.7s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… time

GitHub's windows/py3.13 runners ship a broken Tcl/Tk: TkAgg imports fine
(so backend probing selects it) but window creation raises _tkinter.TclError.
manage_backend now retries the plot once on the original backend after an
interactive-backend TclError instead of crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- plotly renderer now reproduces the matplotlib aesthetic exactly: black
  wireframe cube (3D) / square frame (2D), hidden axes, unit-cube range,
  matched camera (elev/azim), pt->px line/marker sizing, full fmt-string
  support (marker symbols + dash styles, with 3D symbol fallbacks)
- MULTICOLORED LINES: continuous or matrix hue + line fmt colors each
  trajectory continuously along its length (matplotlib Line3DCollection /
  LineCollection; plotly per-point line colors in 3D, segment traces in 2D)
- Fix long-standing is_line() bug: '' in Line2D.markers made it return
  False for every fmt string, silently disabling line interpolation on
  modern matplotlib; also parse linestyles before marker chars ('-.')
- Re-mapped per-point labels onto interpolated trajectories (fixes latent
  IndexError that interpolation re-enablement exposed)
- Parity montage generator (scripts/generate_parity_screenshots.py):
  matplotlib|plotly side-by-side for 22 identical calls
- 173/173 tests pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Removed (previously deprecated, now retired for 2.0): plot's group/model/
  model_params; reduce's model/model_params/normalize/align; align's
  method/normalize/ndims and the ambiguous align=True form (now a clear
  ValueError with migration hint); cluster's ndims
- DataGeometry.plot translates/drops retired kwargs when replaying geos
  saved by hypertools < 2.0 (group -> hue), so old files still load
- New hyp.apply_model: the stack/unstack core from the revamp design --
  one model fit across stacked datasets then unstacked to input structure
  (stack=False for per-dataset fits); model specs as registry name / dict /
  sklearn instance / pipeline list; mode auto|fit_transform|fit_predict|
  predict_proba; return_model for reuse on held-out data; explicit
  whitelist registry (no eval)
- 185/185 tests pass (12 new apply_model tests)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s for all 2.0 features

- docs/images/v2.0-parity/: 22 side-by-side (matplotlib | plotly) montages
  of identical calls -- line/marker styles, dashes, sizing, colors, hue
  variants, clustering, mixtures, nested lists, multicolored lines
- docs/images/v2.0-verification/: refreshed 75-case matrix (was 44) now
  covering every plotting feature on BOTH backends, incl. multicolored
  lines, mixture models, nested lists, marker/line styles, animations,
  and apply_model
- 5 new gallery examples (interactive backend, mixture models,
  multicolored lines, nested lists, apply_model), all executing cleanly;
  gallery rebuilt; apply_model added to the API reference
- dev notebook updated for all implemented 2.0 features and re-executed
  end-to-end with 0 errors
- README documents multicolored lines, apply_model, backend parity, and
  the retired legacy arguments
- 185/185 tests pass; import 1.41s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ments, plotly gallery

- Animation EXPORT on both backends, format by extension: .gif (Pillow),
  .png/.apng (animated PNG), .mp4/.mov/.avi (ffmpeg). plotly animations
  render each frame via kaleido then assemble; exported frames no longer
  include the play/pause controls; frame counts scale with duration.
  7 new tests save real files and verify frame counts. Sample GIFs from
  BOTH backends committed to docs/images/v2.0-animations/.
- Mixture demos now use OVERLAPPING clusters (1.5 sd apart) so multi-class
  membership is visible as blended colors (examples, screenshots, parity,
  notebook); new test asserts a substantial fraction of genuinely mixed
  assignments.
- Backend parity refinements: centered black 12pt title (matching
  matplotlib), default 640x480 canvas, 2D frame fills the canvas like
  matplotlib (no forced square), 3D box uses matplotlib's 4:4:3 aspect,
  camera distance tuned so cube sizes match (r=1.95).
- Sphinx gallery renders plotly figures (plotly_sg_scraper + kaleido);
  new animate_plotly example with an animated GIF thumbnail wired into
  post_build; interactive-backend example shows the plotly figure inline.
- Dev notebook displays animations inline (to_jshtml + plotly frames) and
  demonstrates gif export; re-executed end-to-end with 0 errors.
- Evidence regenerated: 22/22 parity montages, 75/75 verification cases.
- 192/192 tests pass (185 + 7 animation-export)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the macos/py3.11 CI failure: Google Drive answered a dataset
request with an HTML rate-limit page (200 status), which load() cached as
the dataset -- poisoning every subsequent text-data test on that runner
with UnpicklingError.

- _download_example_data: raise_for_status; detect HTML error pages before
  caching (all example datasets are pickles, which never start with '<')
- _load_example_data: on a corrupt cache, delete it and retry the download
  once before failing; never leave a poisoned cache behind
- Regression test poisons the real cache with the actual Drive error page
  and verifies recovery (or clean failure with the cache removed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… CI jobs

- _download_example_data retries up to 4 times (2s/6s/18s backoff) when the
  host rate-limits, instead of failing on the first error page
- CI caches ~/hypertools_data (immutable datasets, one cross-OS entry) so
  24 concurrent jobs stop re-downloading the same files from Google Drive
  every run -- the root cause of the intermittent text-test failures

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…multi-panel + cache verification

- SVG export on both backends: static (.svg) plus ANIMATED vector SVG via
  SMIL (frames stitched with discrete display switching; verified frame
  advance in headless Chrome by scrubbing setCurrentTime). matplotlib
  frames captured through a public AbstractMovieWriter subclass with
  frame subsampling (<=60 frames)
- plotly window animations now rotate the camera while the window
  advances, matching matplotlib's behavior
- plotly titles centered over the plot area (xref='paper') with a
  matplotlib-matched font stack
- hyperalign: n_iter argument (default 10) iteratively re-estimates the
  common template; dict form no longer returns None; removed leftover
  'method' reference that raised NameError for unknown align strings
- shapes zoo: bunny/cube/dragon/sphere/teapot/vase/biplane + datasaurus
  registered with their Dropbox sources (direct-URL download support in
  the loader; tolerant unpickler for dill/legacy-pandas formats; dill
  added as a dependency). 'egyption_mask' excluded: upstream file is an
  empty (0,3) array
- Multi-panel figures verified (hyp.plot(..., ax=...) into user subplot
  grids, 3D + 2D panels)
- Re-download hygiene verified: repeated loads leave the cache byte-stable
  (no duplication / storage leak)
- Reconstructed the classic readthedocs hyperaligned-weights animation
  (docs/images/v2.0-animations/weights_hyperaligned.gif)
- 9 new tests (tests/test_round3.py), all real calls

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… regen

- Docs: modern pydata-sphinx-theme (replacing sphinx_bootstrap_theme);
  full site + gallery build verified, screenshots committed to
  docs/images/v2.0-theme/. nbsphinx_execute='never': tutorial notebooks
  ship pre-executed, and 'auto' was re-executing every gallery notebook
  (doubling build time and hanging on plotly exports in the nbsphinx
  kernel)
- Fixed zoom: Axes3D.dist was removed in matplotlib >= 3.8, silently
  disabling animation zoom; replaced with set_box_aspect(zoom=...) using
  the exact legacy scale mapping (10 / (9 - zoom))
- Reconstructed the classic readthedocs hyperaligned-weights animation
  (docs/images/v2.0-animations/weights_hyperaligned.gif): 36 subjects,
  align='hyper', smooth interpolated trajectories, working zoom
- Modern demos: gallery examples plot_shapes_zoo + plot_datasaurus;
  executed tutorial notebooks hugging_face_embeddings (sentence-
  transformers + HF ag_news, mixture soft clustering, UMAP, animated spin
  gif) and modern_sklearn_dynamics (HDBSCAN, GaussianMixture, Lorenz
  attractor multicolored line, animated gif); registered in the tutorials
  toctree
- Animation evidence regenerated with the rotation + zoom fixes; parity
  montages regenerated (22/22) with the title-font change
- 202/202 tests pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jeremymanning and others added 30 commits July 2, 2026 13:41
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ality fixes, embedding demos

- hyperalign now REPEATEDLY applies the full procedure (n_iter passes,
  default 10): each pass's aligned output feeds the next, compounding
  convergence (mean distance to group mean 0.43 -> 0.004 over 10 passes
  on rotated copies). The classic readthedocs weights animation is
  regenerated with the corrected pipeline (normalize='across',
  align='hyper', zoom=3.5, rotations=1, frame_rate=50, linewidth=3)
- NEW animate='serial' mode (both backends): datasets appear one at a
  time in list order, each growing point-by-point while earlier ones stay
  fixed, never connected -- built for conversation-turn visualizations;
  tests assert sequential reveal on both backends
- Animation quality: full-canvas animated axes + skip tight_layout fixes
  cube/data clipping at rotation angles (border-pixel regression test);
  linewidth is now a plot() argument and animations no longer hardcode
  linewidth=1; markersize is now a plot() argument
- EXACT per-point colors for markers: matrix hue / mixture-model scatter
  renders true per-observation blends via scatter (was: quantized color
  groups); plotly path already carried per-point colors
- Shapes morph: 3510 frames @ 30fps (117s, 13 rotations), committed as
  mp4 + 20s preview gif
- Demos: wikipedia embeddings (BAAI/bge-small-en-v1.5 + UMAP + 10-way
  GaussianMixture soft clustering, markersize=2) and reddit conversation
  trajectories (convokit reddit-small, sliding-window SBERT, per-speaker
  colors, animate='serial', 30s/3 rotations) -- both executed 0 errors
  with committed gifs
- 205 tests passing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tax, 30fps standard, rebuilt demos

WEIGHTS (the classic readthedocs animation) — fully diagnosed via Jeremy's
2020 pieman_trajectory_demo notebook (hypertools 0.6.2 + timecorr):
  gaussian temporal smoothing (var=300) -> hyp.align repeated n_iter=20
  (SRM) -> smooth again -> UMAP -> animate
Two additional findings were required to reproduce it on modern deps:
- align('SRM') now supports n_iter (re-fits SRM on each pass's output;
  inter-subject correlation plateaus ~0.87 = the data's shared-signal
  ceiling, matching era behavior since the vendored SRM is unchanged
  since v0.6.2)
- modern umap-learn's default n_neighbors=15 keeps neighborhoods
  within-subject and DISPERSES the aligned bundle; n_neighbors=150 merges
  same-timepoint rows across subjects and reproduces the tight looping
  rope of the original. Recipe scripted in
  scripts/generate_weights_trajectory.py; gif regenerated (900 frames,
  30fps, tight rope verified against the reference render)

Also in this round:
- repeated-hyperalignment scale collapse fixed (procrustes' optimal
  scaling < 1 shrinks data geometrically across passes; per-pass output
  rescaling keeps norms stable through n_iter=50)
- single-call soft-cluster coloring:
  hyp.plot(x, '.', markersize=2, reduce='UMAP',
           cluster={'model': GaussianMixture, 'n_clusters': 10})
  (dict accepts top-level n_clusters and model classes, in both cluster()
  and plot(); colors flow from mixture proportions automatically)
- 30fps animation standard: plotly frame density raised to 30/s (cap
  600); all tutorial gifs re-rendered at fps=30 with no conversion
  downsampling (wikipedia 300 frames/10s/1 rotation; conversation 900
  frames/30s/1 rotation; lorenz + hf spin 900 frames/30s); shapes morph
  3510-frame mp4 + 30fps preview gif; matplotlib evidence gifs at 450
  frames (plotly evidence gifs kept from the prior render -- kaleido
  makes 450-frame exports impractically slow; noted)
- conversation demo rebuilt: 3-sentence windows WITHIN utterances (true
  disconnection), repeating per-speaker colors, animate='serial',
  rotations=1, no frame clipping
- 206 tests passing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The n_neighbors=150 recipe over-globalized the UMAP embedding and
flattened the trajectory into a near-straight line; download.png shows a
tightly-bundled bundle with a dramatic loop. Swept n_neighbors against
the reference: 15 disperses into a hairball, 150 flattens the loop, and
36 (min_dist=0.1) is the sweet spot -- one tight rope that keeps the
loop.

Verified this is a pure UMAP-neighborhood effect, not a dependency
version: the modern SRM branch is byte-identical to v0.6.2, and era
umap-learn 0.4.6 also hairballs the same aligned data at its default
n_neighbors=15.

Also switched the animation from a rolling window (animate=True +
tail_duration) to animate='spin': the window only ever showed a ~4s
fragment (a tangle), while spin draws the whole bundle and orbits it so
the loop is visible from every angle. Tightened the gif encode
(scale=340, 48-colour palette) to ~8MB, in line with the other
animation gifs, still 900 frames at 30fps with no downsampling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Switch back from animate='spin' to the classic animate=True sliding
window, per review. The critical property -- the space inside the cube
is fixed for the whole animation, independent of which window is
visible -- is guaranteed by the pipeline (helpers.scale normalizes once
from the full stacked dataset and the window updater never touches axis
limits) and verified programmatically: axis limits are identical across
frames while the visible fragment's extent slides along the loop.

(The earlier claim that window mode showed a static tangle was a frame-
extraction bug in the verification harness -- PIL's ImageSequence
yields one re-seeked image object, so materializing it with list()
produced N copies of the final frame. Measured correctly, the window
render rotates and the comet travels: mean inter-second frame motion
8.3, 0 clipped frames.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Streams are a first-class data type -- no flag. hyp.plot() detects
Python iterators/generators and Hugging Face IterableDatasets
(load_dataset(..., streaming=True)) from the structure of the input:

- the first stream_init samples (default 10,000) ESTIMATE the
  normalization/reduction parameters; those fitted models are then
  APPLIED to every subsequent sample, which is added to the plot
  dynamically (the fit-on-head/transform-forever semantics from the
  issue thread)
- stream_chunk (default 100) is the per-fetch batch size; each chunk
  renders as one live redraw / saved animation frame
- stream_max (default None) streams continually; infinite streams
  render incoming data indefinitely, and Ctrl-C cleanly finalizes any
  save_path animation and returns the geometry
- stream_window optionally shows only the trailing samples
  (comet style) while everything consumed stays on the geometry
- reduction models must support transform() (IncrementalPCA default,
  PCA, UMAP; TSNE raises); align/cluster raise for streams (cluster
  planned)
- dict rows: numeric fields concatenated in insertion order, strings
  ignored (use .select_columns() for control); datasets added to [dev]

14 real tests (tests/test_streaming.py) incl. an actual HF iris stream,
interrupt finalization, and a fitted-on-head-only assertion. New
executed tutorial docs/tutorials/streaming_data.ipynb with two
streaming animations.

Docs theme: pydata-sphinx-theme -> Furo with the ContextLab brand
ported from ContextLab/scheduler (Nunito Sans, lowercase 300-weight
headings with 0.6px letter-spacing, green #007030 / dark #4CAF50),
screenshot-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Gallery: sphinx-gallery previously executed only plot_*-named examples,
leaving chemtrails/animate*/precog/explore/save_*/analyze pages with
code but no rendered output. All examples now execute
(filename_pattern), matplotlib animations render as embedded mp4 video
(matplotlib_animations + sphinxcontrib-video; animation examples expose
`ani = ani_geo.line_ani` for the scraper), save_* examples write to
temp files, and every example page gets a branch-aware "Open in Colab"
badge + .ipynb link (post_build.py) so gallery examples open as
runnable notebooks.

Dual-backend audit (scripts/audit_gallery_backends.py): every example
runs under BOTH matplotlib and plotly in subprocesses; 78/78 pass
(save_movie under plotly needs a long timeout -- kaleido per-frame mp4
export). The audit caught a real, years-latent bug: per-dataset fmt
lists (['-','--']) routed each dataset through interp_array_list
(plural), silently replacing 2D arrays with lists of per-row
interpolations; latent because is_line() always returned False before
its round-2 fix. Fixed with interp_array + regression test.

Plotly parity for animation extras: chemtrails/precog/bullettime draw
low-opacity trail traces on window animations, tail_duration sets the
window length, and zoom moves the camera (r = 1.95*(9-zoom)/8,
mirroring the matplotlib zoom semantics); previously none of these were
forwarded to the plotly renderer. explore maps to plotly's native
hover.

Universal loader: hyp.load (and DataGeometry.plot/transform) resolve
strings by trying, in order: built-in dataset name -> local file
(npy/npz/csv/tsv/txt/json/parquet/mat/pickle) -> Hugging Face dataset
(split=/streaming=; streaming feeds straight into hyp.plot) -> Google
Drive URL or bare id -> Dropbox URL or shared-link path -> any URL with
or without https://. Lists of strings return lists of datasets. Raw
text (whitespace) still flows to the text-embedding pipeline. Also
fixes df2mat for pandas>=2 (get_dummies bool dtype made mixed
DataFrames produce object arrays that crashed np.isnan).

19 new tests (12 loader incl. real HF/Drive/Dropbox/URL fetches, 6
plotly trails, 1 interp regression); 232 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two-layer fix for example pages showing code but no output:

1. nbsphinx was claiming the gallery pages: sphinx-gallery writes a
   downloadable .ipynb next to each generated .rst, and nbsphinx
   rendered the UNEXECUTED notebook instead of the gallery page.
   auto_examples/*.ipynb is now excluded from the document build
   (downloads still work).
2. matplotlib animations render as embedded HTML5 video: the
   sphinxcontrib.video extension is registered (required by
   matplotlib_animations=(True, 'mp4')) and all animation example
   pages (chemtrails, precog, animate*, save_movie) now embed a
   playable 30s mp4, verified visually.

Includes the regenerated gallery artifacts (all 39 examples executed,
9m39s total build execution).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…report

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…round 6.5)

NEW ANIMATION STANDARD (both backends): frame_rate=30, duration=30s,
rotations=1 -- one revolution every 30 seconds. Three layers had to
change for plotly to actually match matplotlib's pacing:
- library defaults (frame_rate 50->30, rotations 2->1) and the plotly
  frame math: n_frames = frame_rate * duration exactly like matplotlib
  (600-frame cap removed); parity test asserts identical 900 frames at
  ~33ms on both backends
- DataGeometry.plot no longer replays animation-pacing kwargs baked
  into saved .geo files (old-era defaults like frame_rate=50 and
  rotations=2 silently overrode the current standard in every gallery
  example that calls geo.plot); explicit caller overrides still win
- docs builds: plotly's sphinx-gallery renderer serialized every
  animation frame through kaleido for one static png (a 900-frame
  figure took ~an hour); the show path now writes a frame-stripped png
  plus an interactive html with embedded frames capped at 150, each
  shown proportionally longer -- total duration and rotation speed
  unchanged, pages ~0.1MB

Streaming stability: the data->box transform is FROZEN from the head
(the center+scale affine is captured once); every future sample goes
through the same transform and out-of-range samples are clamped to the
closest point on the box surface. Axis limits never change once set --
no more per-chunk rescale "twitch" (verified: zero vanishing ink
across tutorial animation frames + exact-position regression tests).

Legends (both backends): rendered to the RIGHT of the plot, vertically
centered on the box (mpl bbox_to_anchor; plotly x=1.02/y=0.5 with a
reserved right margin). Screenshot-verified in 2D and 3D.

Gallery UX: thumbnail clicks were dead (sphinx-gallery >= 0.17 no
longer wraps the thumb <img> in an anchor; the old gallery-fixes.js
targeted extinct .xref markup) -- thumbnails now open the example's
notebook on Colab while title text opens the example page. Animated
thumbnails were squashed into 200x200 squares from 4:3 sources; all 7
regenerated letterboxed at the correct aspect from the new 30fps mp4s
(scripts/generate_gallery_thumbs.py). Plotly evidence gifs re-rendered
at the pacing standard. DataGeometry.plot/transform docstrings document
the universal string-loading behavior for the API pages.

237 tests passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A kaleido subprocess wedged during test_animated_svg_plotly on one
Windows runner and burned the full 6-hour Actions job timeout. No test
legitimately takes over 20 minutes; a hung native call now fails fast
with a stack dump instead of holding a runner hostage (thread method,
since the hangs are inside native calls).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every documentation notebook (39 gallery + 13 tutorials) now opens with a
branch-aware install cell so it runs standalone in Google Colab. On a
preview branch it installs that branch from GitHub
(`%pip install "hypertools[interactive] @ git+...@dev-2.0"`, verified in a
clean venv: imports as 2.0.0.dev0 with 2.0-only features working); on
master it installs the released package. scripts/add_colab_install_cell.py
injects the line idempotently into the hand-authored tutorial notebooks,
and conf.py's first_notebook_cell emits the same line for gallery
notebooks on rebuild.

Gallery examples:
- shapes zoo: plots EVERY zoo shape (bunny, cube, dragon, sphere, teapot,
  vase, biplane) as small black dots (',' pixel marker), one panel each
- datasaurus: plots ALL THIRTEEN datasets of the dozen as small black
  dots ('.' point marker), one panel each

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Squash-merge of the dev-1.0-refactor line into dev-1.0. HyperTools 1.0.0 keeps
the familiar one-call API (plot/analyze/reduce/align/cluster/normalize/
describe/load) but rebuilds the internals, packaging, and feature set.

Highlights: pyproject packaging (Python 3.10-3.13, NumPy 2); the monolithic
tools module split into focused subpackages on a shared stack->fit-once->unstack
core (hyp.apply_model, backed by pydata-wrangler); plot() returns a matplotlib
Figure / HyperAnimation / plotly Figure and load() returns raw data (DataGeometry
retired to an internal unpickle-only shim); interactive plotly backend; mixture
clustering; hull surfaces; density shading; colorbars; multicolored lines; new
animation styles + 2-D animation + morph; hyp.Pipeline; hyp.manip; hyp.predict/
impute; autoencoder + gensim + LSL + kaggle integrations; expanded hyp.load
sources; built-in datasets re-hosted as non-executable, SHA-256-pinned formats
with atomic downloads and trust-gated remote deserialization; ~2,400 tests across
Linux/Windows/macOS x py3.10-3.13 with a zero-warning bar and a clean-docs gate.

Squash-merged (not a normal merge) so the branch's large-binary development
history stays out of dev-1.0 (see CONTRIBUTING "Merging"). Full change
documentation is in the dev-1.0 -> master pull request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ve image paths

- The featured example at the top of the README is now the "story trajectories"
  animation (hyperaligned fMRI activity of 36 subjects moving together through a
  spoken story, examples/plot_story_trajectories.py) rather than the random-walk
  hypertools.gif -- a far more compelling demonstration of what HyperTools does.
  Rendered (460px) from the committed docs/images/v1.0-round17/story_trajectories.mp4.
- Switched all README example images from commit-pinned raw.githubusercontent URLs
  back to plain relative paths (images/...). Relative paths render correctly both
  on this branch (in the PR) and on master after merge, so the previous
  "flip the pinned SHA to master after release" task is no longer needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four small verified figures demonstrating 1.0 features not shown by the existing
README images, for embedding in the dev-1.0 -> master release PR: density shading
(density=True), multicolored lines + colorbar (continuous hue), the interactive
plotly backend (backend='plotly', rendered via kaleido), and trajectory
forecasting (hyp.predict, Kalman). Each was generated and visually verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… datasets

hyp.plot([A, B], hue=['A']*len(A) + ['B']*len(B)) drew a spurious straight
line from A's last point to B's first point. The per-category regroup
(reshape_data) merges rows by category and patch_lines then bridged EVERY
group to the next -- including when consecutive groups are separate input
datasets (distinct trajectories that must not be joined). GH #291.

reshape_data now optionally flags each group whose first row begins a new
input dataset; patch_lines skips the bridge INTO those groups. Bridging
WITHIN a single trajectory (one dataset colored by contiguous categories)
is unchanged, so such lines still render continuously through colour
transitions. The same fix covers the cluster line path, which shares
patch_lines.

Tests: unit tests for reshape_data(return_boundaries=) and
patch_lines(breaks=) in test_helpers.py; end-to-end regressions in
test_hue_color.py asserting no A->B bridge for two datasets and a preserved
bridge for a single trajectory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The earlier featured-example change switched the README images to relative
](images/...) paths, which 404 when the readme is rendered as the PyPI
long-description -- breaking test_wheel_metadata_has_no_relative_links.

Repin all 8 images to absolute raw.githubusercontent URLs at dev-1.0 commit
fc2429c (which carries every referenced image, including the new
story_trajectories.gif featured example). All 8 verified HTTP 200.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first GH #291 fix only handled one unique contiguous category per
dataset in dataset order. reshape_data still GLOBALLY merges every
observation of a category into one array before any line is drawn, which
destroys both dataset identity and within-trajectory run order -- so a
line could still join separate trajectories or collapse a recurring
category into a tangled polyline. A per-group boolean cannot recover
information that global merging already discarded. Reported cases:

  * two datasets sharing ONE category  -> merged into a single line
  * categories in reverse numeric order -> reverse bridge (ds2 -> ds1)
  * A A B B A A within one trajectory   -> run order lost

Fix: for LINE-bearing formats, replace the global merge with
contiguous-run segmentation (new helpers.segment_by_run), preserving
observation order and input-dataset identity. Each run is drawn as its
own trace, coloured by its category, bridged only to runs adjacent WITHIN
the same input dataset (never across a dataset boundary), and every
category gets exactly ONE legend/colorbar entry (dedup via '_nolegend_',
which _build_colorbar_info already collapses). MARKER-only plots keep the
global grouping (scatter has no edges to fuse). Applied at the shared
frontend so both backends and static+animated paths inherit it, and to
BOTH the explicit-hue and cluster paths (cluster+line was equally
affected). The superseded reshape_data(return_boundaries=) plumbing is
reverted.

Tests: segment_by_run units in test_helpers.py; a full GH #291 regression
matrix in test_hue_color.py (two datasets same/different/reverse-numeric
category, category repeated across datasets, A->B->A run order, 3+
datasets, single-point runs, marker/line/'o-' formats, matplotlib +
Plotly, spin/window/True animations, and cluster lines) asserting no
cross-trajectory bridge, shared per-category colour, and deduped legends.
Full suite 2396 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…doc fixes

Follow-up to the GH #291 categorical/cluster line-run segmentation, resolving
three reviewer findings:

* Per-INPUT-dataset style lists broke on segmentation. Splitting each dataset
  into one trace per contiguous run made the drawn-trace count exceed the
  input-dataset count, so a caller's fmt=['.','-'] / linewidth=[1,3] failed
  the one-value-per-trace length check. segment_by_run now also returns each
  run's source-dataset index, and a new _expand_styles_to_runs propagates a
  style list given at INPUT-dataset length to every run that dataset produced
  (a list already at run length is used verbatim). Wired into all three line
  regroup sites (hue + both cluster paths).

* Tuple-valued fmt (e.g. fmt=('.','-')) passed the list/tuple validation but
  then hit list-only downstream branches and surfaced an unrelated internal
  error whose occurrence depended on the hue pattern. fmt tuples are now
  normalized to lists up front.

* Documentation contract corrected: the automatic per-dataset propagation is
  narrowed to categorical/cluster LINE-run segmentation only (marker-only
  grouping merges observations across datasets into one per-category trace, so
  a per-dataset style is not well defined there, and MultiIndex expansion
  likewise needs trace-count-matched lists). Dropped the incorrect claim that
  alpha= is a broadcast named style -- it is generic **kwargs passthrough,
  applied verbatim and never broadcast. parse_kwargs' length-mismatch error
  now says 'trace(s) to draw' rather than the misleading 'dataset(s) to plot'.

Tests: seg_dataset unit assertions; per-dataset fmt/linewidth/marker
propagation (matplotlib + plotly), explicit per-run lists, cluster lines,
mismatch-still-errors; tuple fmt across plain/one-run-hue/multi-run-hue/
plotly/animation; and the marker-only trace-count contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n't hang CI

test_plotly_mp4_export repeatedly stalled Windows/py3.13 CI for the full 20-min
per-test timeout inside kaleido's sync-server call_function. kaleido 1.x's own
per-render timeout only wraps the figure CALC (asyncio.wait_for around
tab._calc_fig), NOT browser launch or tab acquisition, and its shared sync
server blocks on unbounded Queue.get -- so a wedged headless Chrome hangs a
to_image() call forever (the traceback sat 1200s at _sync_server.py join/get).

Make the animation export resilient:
* _bounded_to_image runs each frame's to_image in a daemon worker thread with a
  wall-clock watchdog (120s), raising TimeoutError on a wedge and abandoning the
  stuck (daemon) worker -- bounding the WHOLE call, not just the calc.
* _reset_kaleido_server tears the server down within a bounded time, and if a
  clean stop is itself wedged (server thread stuck in Chrome) clears the
  singleton's running flag directly so a fresh session can start.
* _retry_kaleido_export retries the whole export: the first attempt uses the
  fast shared browser session, and after a wedge it falls back to per-call
  one-shot rendering (a fresh browser per frame, watchdog-bounded) -- which an
  abandoned wedged thread (still holding the singleton's queues) cannot corrupt.
Applied to both the video/gif (PNG) and SVG frame-render loops; the shared
session's teardown now uses the bounded reset. A genuine failure now surfaces in
bounded time instead of a 20-min hang.

Tests: watchdog times out a hanging render / returns bytes when fast /
propagates real errors; retry falls back shared->one-shot after a wedge and
raises the last error when exhausted; and an end-to-end test injects a wedge
into the first frame and verifies the export still completes via the one-shot
retry (real kaleido/Chrome). Normal export path unchanged (12 export tests,
same timing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…edes thread watchdog)

Review of the previous thread-based kaleido watchdog (96ba486) found a real
cross-export hazard: after a wedge it abandoned a live worker thread and, if a
clean stop also blocked, cleared kaleido's private _initialized flag directly.
Those abandoned threads still reference the process-global GlobalKaleidoServer
singleton, so a LATER export in the same process could start a fresh server on
that singleton while the stale closer eventually wakes and joins/deletes the new
server's thread/queues -- hangs, deleted queues, leaked Chrome/threads. A
blocked native/browser call simply cannot be safely interrupted or reclaimed
from a Python thread.

Fix at a process boundary instead: frame rendering runs in a KILLABLE
subprocess (hypertools/plot/_kaleido_export_worker.py) that owns its OWN kaleido
singleton + Chrome. The parent serializes exports (_EXPORT_LOCK), serializes the
figure to JSON, and enforces a hard wall-clock deadline scaled to the frame
count; on overrun it kills the whole process tree (Chrome included, via killpg
on POSIX / taskkill /T on Windows) and retries in a fresh subprocess. The parent
process now starts NO shared kaleido server, abandons no threads, and never
touches kaleido's private state, so a wedged export cannot corrupt a later one.
The shared-session speed optimization is kept INSIDE the worker (safe -- if it
wedges the whole process is killed). Applies to both the video/gif (PNG) and SVG
export paths.

Tests replace the thread-watchdog unit tests with subprocess-lifecycle ones:
deadline scales with frame count; _kill_process_tree terminates a genuinely
blocked child in bounded time; a real render subprocess forced past a 0.5s
deadline is killed + retried + raises bounded; the worker renders every frame to
its own file; and -- the key guard -- a REAL export succeeds in the same parent
process AFTER a wedged-and-killed export (proving no cross-export corruption).
Full suite 2426 passed / 0 failed; 17 export tests pass end-to-end via the
subprocess.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…path, not in-process

test_export_worker_renders_all_frames_to_files called the worker's main() in the
TEST process, which has no timeout -- so a transient headless-Chrome wedge hung
the whole test (it did, wedged in kaleido's sync-server call_function, on the
ubuntu-3.11 CI runner and hit pytest's 1200s cap). That reintroduced exactly the
unbounded hang the subprocess boundary exists to prevent. Notably the real
export tests (test_plotly_mp4_export etc.) PASSED on the same runner because they
spawn the worker as a killable subprocess -- validating the architecture; only
the in-process test was unsafe.

Replace it with test_render_frames_via_subprocess_returns_all_frames, which
drives the production path ( -> worker subprocess
with a hard deadline + retry) and asserts one non-empty image blob per frame.
The worker's CLI contract stays covered end-to-end by every plotly export test,
all of which go through that same spawn path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The whole-export deadline was sized against the test suite's 60-frame exports,
not real usage. The DEFAULT animation is duration=30 x frame_rate=30 = ~900
frames, so max(180, n_frames*6) gave 5400s per attempt = ~3 HOURS across two
attempts -- far past pytest's 1200s cap and GitHub's job limits, so a wedged
Chrome on a default-sized export would still hang CI for hours before recovery
engaged. The old docstring's "two attempts stay well under 1200s" was false for
anything over ~100 frames.

Root problem: a frame-count-scaled whole-export deadline cannot distinguish "a
long export that is steadily rendering" from "Chrome is wedged". Any value
generous enough for a healthy 900-frame export is hours long.

Replace it with a PROGRESS watchdog (_wait_with_progress). The worker already
renames each finished frame into place atomically; the parent now counts
completed frames and resets an inactivity timer whenever a new one lands. A
wedge is caught in _KALEIDO_STALL_TIMEOUT (120s) REGARDLESS of frame count,
while a healthy export runs as long as it needs. For the default ~900-frame
animation that is ~4 minutes to detect+retry instead of ~3 hours. A generous
absolute ceiling (_export_ceiling) remains as a backstop against pathological
slow-drip progress. In-flight ".part" files are never counted as progress.

Retries now RESUME: the parent keeps ONE frames dir for the whole export and
the worker skips frames whose output already exists, so a wedge on frame 800 of
900 no longer redoes 800 successful renders.

Also (low severity, same review): bound Windows process-tree termination --
taskkill is itself a subprocess that can stall, so it now runs with timeout=15
and falls back to proc.kill() plus a bounded wait. And the export workdir is
mkdtemp + shutil.rmtree(ignore_errors=True) rather than TemporaryDirectory: on
Windows a just-killed worker/Chrome can still hold frame files open, and a
cleanup exception would otherwise REPLACE the TimeoutError we want to report.

Tests: ceiling is a backstop not the primary guard; a stalled render is killed
within the stall timeout; a slow-but-PROGRESSING render outlives the stall
timeout and is NOT killed (the property that makes a 900-frame export safe); the
absolute ceiling still trips under continuous slow progress; a forced stall in
the real render path kills+retries+raises bounded; and the worker skips
already-rendered frames (resume) without launching Chrome.

Full suite 2431 passed / 0 failed; 21 export tests pass end-to-end.
…asons

Three review follow-ups on the export watchdog.

(1) Medium -- a COMPLETE export could still be reported as a failure. The worker
entered _shared_kaleido_session() before checking whether anything was left to
render. So if a previous attempt rendered every frame and then wedged during
browser TEARDOWN (after the last frame landed), the retry would start Chrome
again just to discover there was nothing to draw -- and if that startup or
teardown also wedged, hypertools reported failure while holding every requested
frame. Fixed on both sides:
  * worker: compute the expected output paths and return IMMEDIATELY, before
    touching a browser, when they all exist;
  * parent: after every attempt, accept and load the frames whenever the set is
    complete -- however the worker ended (killed, non-zero exit, or clean).
The old test did not actually prove Chrome was avoided (the session was opened
before the skip loop). Replaced with one that puts a stub `kaleido` on
PYTHONPATH which records any start_sync_server call: with all frames present the
worker must exit 0 with NO marker written. Verified non-vacuous -- with frames
absent the same stub records a start.

(2) Low -- ceiling hits were misreported as inactivity stalls. _wait_with_progress
returned a bare bool, so the caller always said "no new frame for 120s (headless
Chrome wedged)" even when frames were arriving steadily and the absolute ceiling
was the real cause. It now returns 'exited' / 'stalled' / 'ceiling' and the
caller raises a message matching what actually happened -- these need different
diagnosis (browser fault vs. an export that is rendering far too slowly).

(3) Low -- an unsuccessful taskkill was treated as success. The Windows branch
bounded taskkill but ignored its return code, so a prompt non-zero exit (access
denied, partially-exited tree) skipped the proc.kill() fallback and could leave
the worker or its Chrome alive as a retry began. It now falls back on a non-zero
returncode as well as on TimeoutExpired.

Full suite 2432 passed / 0 failed; 22 export tests pass end-to-end.
…animation buttons

Maintainer feedback (Andy's review, relayed by Jeremy).

BUTTONS (plotly animation controls)
The Play/Pause controls sat at paper (0, 0) anchored bottom-left. In 3-D the
scene floats above that corner so it merely looked cramped, but in 2-D the axes
fill the paper area and the controls were drawn ON TOP of the chart's
bottom-left corner. They now hang below the plotting area (negative paper y,
top-anchored), laid out horizontally, with margin.b opened up so they are never
clipped, plus light theming: symmetric padding (the default padding left 'Play'
visibly off-center), subtle background/border, and the figure's own font.

FONTS (both backends)
Previously hypertools set no font at all unless non-ASCII text triggered a scan
for a single covering font, so the default look was whatever each machine had,
and mixed-script text was frequently broken: with emoji + CJK + dingbats in one
plot NO single font covers everything, so the scan gave up and the text
rendered as "tofu" boxes -- with a per-glyph UserWarning storm behind it.

- Bundle Noto Sans Regular (SIL OFL 1.1, ~570 KB) under hypertools/external
  alongside the other vendored third-party material, with its license and a
  provenance README, and ship it via package-data. Plots now look the same on
  every platform. Bundling broader scripts is infeasible (a pan-CJK face alone
  is ~16 MB), so those still come from system fonts.
- Resolve fonts through a per-glyph FALLBACK STACK instead of one face:
  matplotlib walks a font.family list, a browser walks a CSS stack. Text mixing
  scripts now renders completely from several faces (Latin from Noto Sans,
  Japanese from an installed CJK face, math symbols from DejaVu Sans). The
  stack is filtered to INSTALLED families, because naming a missing one makes
  matplotlib log 'findfont: ... not found' per text artist, and is anchored by
  DejaVu Sans (shipped with matplotlib) so it can never resolve to nothing.
- Applied inside the existing scoped rc_context, so the user's own matplotlib
  rcParams are still left untouched (covered by a new test).
- Set BOTH font.family and font.sans-serif: artists created with an explicit
  generic family='sans-serif' resolve through the latter, so setting only one
  left them on matplotlib's stock list.
- Point labels no longer force family="serif". That hardcoded generic both
  clashed with the sans used everywhere else and bypassed the stack, so a label
  character the serif faces lacked (U+2726 '✦') rendered as tofu even though an
  installed font had a glyph for it.
- The covering-font warning now fires only for characters NOTHING available can
  draw, instead of whenever no SINGLE font covered all the text -- it was
  firing on ordinary accented/Greek text that renders perfectly.

Verified by rendering and visually inspecting every text surface Jeremy listed,
on BOTH backends: titles, axis labels, colorbar tick labels, legends, point
labels, user-added text, and unicode throughout (CJK, emoji, dingbats, Greek,
math, accented Latin) -- plus 2-D and 3-D animation buttons before/after.

Full suite 2442 passed / 0 failed (the one failure in the local run is
tests/test_lsl_streaming.py picking up a FOREIGN LSL outlet broadcasting from
another app on this machine; this delta contains no LSL/IO code and that test
is green on CI).
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.

1 participant