Skip to content

HyperTools 2.0: architecture refactor + bug hunt#271

Closed
jeremymanning wants to merge 69 commits into
dev-1.0from
dev-2.0-refactor
Closed

HyperTools 2.0: architecture refactor + bug hunt#271
jeremymanning wants to merge 69 commits into
dev-1.0from
dev-2.0-refactor

Conversation

@jeremymanning

@jeremymanning jeremymanning commented Jul 4, 2026

Copy link
Copy Markdown
Member

HyperTools 2.0 — architecture refactor + bug hunt

This PR merges the completed HyperTools 2.0 class-based refactor into dev-2.0, together with a broad bug hunt (open-issue triage + two animation-rendering fixes you reported + a legend-clipping fix) and the CI fixes needed to get all platforms green.

⚠️ Please do not merge. Opened for your review; I'll proceed however you decide. Base: dev-2.0 ← Head: dev-2.0-refactor.


1. Architecture refactor (Plans 1–8)

Reorganized the working dev-2.0 code into the class-based structure, on modern deps, with DataGeometry removed from the public API:

  • Deps modernized: numpy ≥2, pandas ≥3, scikit-learn ≥1.4, matplotlib ≥3.8, plotly 6.x, via datawrangler 0.5 (funnel/stack/unstack/format-detection/model-dispatch). Supports pandas 3.0+.
  • Class-based modules: core (eval-free apply_model), external (vendored PPCA + brainiak SRM/DetSRM/RSRM), manip (Normalize/ZScore/Smooth/Resample), reduce/align/cluster (generic sklearn dispatch by name), io, plot (matplotlib + plotly backends). Old tools/ names remain as shims.
  • DataGeometry removed from the public API (kept only as a hidden unpickle shim so legacy hosted .geo datasets still load). plot() returns a Figure / (fig, ani); plot(..., return_model=True) returns {'fig','xform_data','models','animation'}; load() returns raw data.
  • Docs/gallery/tutorials migrated to the 2.0 API and rebuilt.

2. Reported animation fixes

Animated bounding box "crowded / cut off on the right." Animated 3D plots zoomed the cube in too far. Fix: set_box_aspect zoom 1.25→1.125 + full-canvas axes. Min cube-to-edge margin over the full save_movie rotation: right 80→96px, bottom 51→72px.

before (crowded) after (margin)
before after

Duplicate animation-legend entries (GH #207). Faint "trail" artists carried the dataset label, so each label appeared twice. Fix: trails tagged _nolegend_; legend is now the static union of in-focus datasets. Verified ['first','second'].

legend

Clipped static gallery legends. Wide legends (long labels / many entries) clipped off the right edge. Root cause: the fit routine measured the legend under seaborn's (narrower) font, but the figure is saved downstream under the default (wider) font. Fix: _fit_right_legend now measures the rasterized pixels under default rcParams and widens the figure (keeping the plot's size) until the legend has a margin. Regenerated plot_legend/plot_PPCA/plot_missing_data; pixel-based regression test added.


3. Open-issue triage → close-on-merge

Triaged all 67 open issues against this branch with real repros (see notes/issues-to-close-on-merge.md): 28 ADDRESSED + 6 OBSOLETE + 6 bugs fixed here = 40 to close on merge; 27 stay open (feature requests / design decisions), each documented.

Bugs fixed from triage (all regression-tested):

# bug fix
#259 import hypertools mutated global rcParams['pdf.fonttype'] removed import-time mutation; editable-PDF default set inside manage_backend's snapshot/restore
#223 get_proj crash on 2D labeled plots guard get_proj; match annotate/update tuple shapes for 2D vs 3D
#146/#190 DBSCAN/MeanShift/OPTICS crash on n_clusters inject n_clusters only when the model's signature accepts it; register those clusterers
#148 show=False leaked the figure into pyplot plt.close(fig) for static figures (skipped when the user passed ax, and for animated figures, whose timer must stay alive)
#214 wiki-model docstring vs wiki_model key docstring corrected
reduce=<class/instance>UnboundLocalError initialize model_params in the custom-estimator branch

4. CI fixes (get all platforms green)

The first CI run surfaced two platform issues (unrelated to the features above); both fixed:

  • Windows (all Pythons) — collection error. datawrangler 0.5.0 evaluates os.getenv('HOME') at import time to build its data dir; HOME is unset on Windows, so os.path.join(None, …) crashed dw's (and hypertools') import. Fixed hypertools-side by setting HOME=expanduser('~') before importing dw; filed upstream as Import crashes on Windows: config datadir evals os.getenv('HOME') which is None data-wrangler#32, fixed in dw 0.5.1 (released; verified import datawrangler works with HOME unset). The pydata-wrangler pin is bumped to >=0.5.1; the one-line HOME guard stays as belt-and-suspenders for environments still on 0.5.0.
  • macOS/Ubuntu Python 3.11+ — 3 tests. matplotlib 3.11 (Python 3.11+ only) resets a figure's canvas after plt.close() (the disabling the figure doesn't work as intended #148 fix), so tests/callbacks that read fig.canvas.renderer/buffer_rgba() failed. Fixed by guarding the renderer in update_position and rendering the affected tests through an explicit Agg canvas. (savefig after close still works, so users are unaffected.)
  • Windows — animated-figure draw crash. On Windows the animate backend switch to TkAgg actually succeeds (headless Linux/mac fall back to Agg), so the disabling the figure doesn't work as intended #148 plt.close() destroyed the FuncAnimation's real Tk timer and any later draw of the returned figure crashed ('NoneType' object has no attribute 'start'). Animated figures are now exempt from the show=False close — the disabling the figure doesn't work as intended #148 complaint was about static figures, and animations need their timer alive for playback.
  • Ubuntu 3.12 — screenshot-verification step. The screenshot harness discovered figures via plt.get_fignums(), which the disabling the figure doesn't work as intended #148 close empties; it now uses the figure(s) returned by plot() (13/13 cases pass).

Testing

  • Local suite: 343 passed, 0 failed (pytest -q); 6 plotly→kaleido image-export tests deselected locally only — they deadlock Chromium in this sandbox but run fine in CI (CI runs all 349).
  • CI: all 12 jobs green (ubuntu/macos/windows × Python 3.10–3.13) after the §4 fixes — run 28721072551; see this PR's checks for the head commit.
  • Docs rebuilt (make html succeeds); animated + legend gallery figures regenerated.

🤖 Generated with Claude Code

jeremymanning and others added 30 commits July 3, 2026 13:17
Reorganize dev-2.0 into the jeremymanning fork's structure (base class per
area, folder per module, one file per child class); remove DataGeometry;
adopt datawrangler for the wrangling core (hybrid); keep dev-2.0's
plotting/animation/streaming/coloring. Classic API names + module aliases;
polars support via dw; strangler migration keeping tests green per commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bite-sized TDD plan: declare pydata-wrangler dep + text extra, probe dw
0.4.0 API surface and behavior (stack/unstack, funnel over numpy/pandas/
polars/list, sklearn text embed), reconcile py3.13/CI, establish the
data-wrangler issue-coordination workflow.

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

Controller recon resolved the environment blocker and confirmed dw 0.4.0's
surface: standardize on .venv (py3.12), pin pandas<3 (dw#30), use verified
dw.wrangle text call, and smoke-check against the 242-pass baseline.

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

HyperTools 2.0 step 0: adopt datawrangler for the wrangling core. Adds
pydata-wrangler>=0.4.0, a hypertools[text] extra -> pydata-wrangler[hf]
(opt-in transformer embeddings), and a temporary pandas<3 ceiling because
dw 0.4.0 type detection breaks on pandas 3.0 (data-wrangler#30).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Canonical list of dw symbols the 2.0 refactor depends on. Missing symbols
become filed data-wrangler issues + xfail-with-link, not silent green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Proves the real round-trips Plans 2-6 depend on: MultiIndex stack/unstack,
funnel generalization over numpy/pandas/list/polars, and sklearn text
embedding via dw.wrangle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the refactor branch to CI triggers, documents the py3.13/dw status, and
starts the data-wrangler issue-coordination log.

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

Move exceptions to core (shim _shared); add eval-free unpack_model + RobustDict;
central config.ini via dw configurator; relocate apply_model to core.model as
source of truth (tools shim) + accept fork {model,args,kwargs} dict form.
Behavior-preserving; existing suite stays green. Deep arrays->DataFrames dw
conversion deferred to per-module plans.

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>
…form

tools/apply_model.py becomes a shim; core.model is now the source of truth.
Adds {model,args,kwargs} spec support alongside {model,params}. Behavior
otherwise identical; existing apply_model tests unchanged.

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

276 tests green. Captures the manip design questions surfaced from reading the
fork sources: arrays vs DataFrames, Smooth=savgol-not-gaussian, Resample needs
core.get, Normalize semantics reconciliation, fork bugs to validate/fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Move vendored ppca/srm to external/ (shim _externals); build Manipulator base +
Normalize/ZScore/Smooth/Resample (DataFrame/dw-based, fork ports validated+fixed)
+ hyp.manip dispatcher (funnel-wrapped, applies Manipulator directly, not via
array-based core.apply_model). hyp.normalize compat left untouched. Adds core.get.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…al; shim _externals

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>
Fork ports validated/fixed: np.clip bounds, core.shared.get import, Series dtypes.
Gaussian-smooth mode still owed (Plan 6, weights pipeline).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Normalize/ZScore/Smooth)

The axis==1 branch of each transformer self-called the module-level
transformer name, which is the @dw.decorate.apply_stacked-decorated
function. Because apply_stacked unconditionally re-stacks its input
(adding a synthetic 'ID' row-index level even for a single DataFrame),
transposing that already-stacked frame leaked the ID level into the
columns, and the fitted params (keyed by the original, pre-stacking
row labels) could no longer be looked up -- raising "key of type
tuple not found and not a MultiIndex" for ZScore(axis=1),
Normalize(axis=1), and Smooth(axis=1).

Fix: keep @dw.decorate.apply_stacked only on the inner, always-axis==0
per-column core (renamed _transform_stacked); make the public
transformer an undecorated dispatcher that transposes the raw,
not-yet-stacked data and recurses into itself for the axis==1 case,
so the stacking machinery never sees a transposed frame.
resample.py's transformer/fitter carry no such decorator and were
unaffected.

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

dw 0.5.0 fixes data-wrangler#30 (pandas-3 type detection). Ceiling lifted:
pandas>=2.2.0 (no upper), pydata-wrangler>=0.5.0, text extra [hf]>=0.5.0. CI
gains a pinned-pandas-3 acceptance gate (ubuntu/py3.12). Validated: full suite
293 passed on dw0.5.0/pandas3.0.3/numpy2.3.5 (== 2.3.3 baseline, no regressions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…m tools

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tools/procrustes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ot carried)

RobustSharedResponseModel omitted (external.brainiak vendors SRM+DetSRM only).
test_rsrm_not_exported uses `from hypertools.align import srm` because the classic
hyp.align callable shadows the hypertools.align attribute (chained-attribute
import form unsupported by design; see Plan 7 top-level API item).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move hypertools/tools/cluster.py to hypertools/cluster/cluster.py, fix the
format_data import to go through tools.format_data, and add
hypertools/cluster/__init__.py exposing cluster/models/mixture_models.
Recreate hypertools/tools/cluster.py as a re-export shim so
core.model._build_registry (which imports models/mixture_models from
hypertools.tools.cluster) keeps resolving. Classic hyp.cluster is
unchanged (still resolves via the tools/ shim per __init__.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jeremymanning and others added 22 commits July 4, 2026 01:10
plot_stream now captures fig from hyp_plot() (a matplotlib Figure per
Task 4) instead of a geo, reaches artists via fig.axes[0].lines instead
of geo.ax.lines, and bundles all streaming outputs (raw data, projected
xform_data, n_samples, reduce_model, truncated) into a single
fig.stream_info dict rather than mutating geo attributes.

Rewrites test_streaming.py (16 tests) and the streaming assertion in
test_load_sources.py::test_load_huggingface_streaming_flows_to_plot to
match: geo -> fig, geo.ax -> fig.axes[0], geo.data/xform_data ->
fig.stream_info['data'/'xform_data'].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extracts .data from the hosted DataGeometry pickles into plain pickles
(gitignored rehost/) for re-upload before the DataGeometry deletion (Plan 7 T7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d() returns raw data

Plan 7 Task 7 (final geo-removal). Per Jeremy's 2026-07-04 decision, keep a
HIDDEN/internal DataGeometry used ONLY to unpickle the hosted example-dataset
geo pickles; users never receive a geo (plot() returns a Figure, load() returns
raw data). No dataset re-hosting.

- datageometry.py: trim to minimal internal unpickle-only class at its current
  import path (get_data + minimal __init__ for _load_legacy); "INTERNAL, not
  public API" docstring; drop plot()/save()/transform()/get_formatted_data() and
  _maybe_load_strings.
- __init__.py: remove the public DataGeometry export (no more hyp.DataGeometry).
- io/load.py: base path returns geo.get_data() (raw data); reduce/align/
  normalize path returns analyze() output directly (drop the plot() detour).
- _shared/helpers.py: remove dead check_geo (+ now-unused import copy).
- tools/format_data.py: remove dead DataGeometry import + 'geo' dispatch branch.
- tools/text2mat.py: load(corpus) now returns raw data (drop .get_data()).
- plot/plot.py: add explicit `import copy` (was leaking via helpers `import *`).
- Delete abandoned re-host artifacts (scripts/rehost_example_datasets.py,
  rehost/ gitignore line).
- Rewrite the 26 geo tests: delete test_geo.py; test_load returns raw/analyzed
  data directly; test_reduce/normalize/describe/format_data geo tests call the
  function on raw data; retire test_datageometry_plot; fix .get_data() ripples
  in test_plot/test_align/test_procrustes.

Full suite: 318 passed, 0 failed (MPLBACKEND=Agg, py3.12, dw0.5/pandas3.0.3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… verify)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ad API

hyp.load() now returns raw data instead of a DataGeometry, and hyp.plot()
is a top-level function returning a Figure (or (Figure, Animation) for
matplotlib animations) instead of a geo.plot() method. Updates all 20
non-plot_geo gallery examples accordingly:

- geo = hyp.load(...) -> data = hyp.load(...)
- geo.get_data() / hyp.load(...).get_data() -> data (raw already)
- geo.plot(**kw) -> hyp.plot(data, **kw)
- the 6 animation examples: ani = geo.plot(animate=...).line_ani
  -> fig, ani = hyp.plot(data, animate=...)

plot_procrustes.py also repoints its procrustes import from the retired
hypertools.tools.procrustes shim to hypertools.align.procrustes (matching
tests/test_procrustes.py, already updated in Plan 7 Task 2).

All 20 examples verified to run standalone under MPLBACKEND=Agg with
exit code 0, including save_movie.py's real ffmpeg mp4 write.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oved)

plot_geo.py was written around the removed DataGeometry/`geo` object
(geo.plot(), geo.save(), geo.transform(), geo.get_data()). Rewrite it to
demonstrate the actual 2.0 hyp.plot() return shapes: a plain Figure by
default, and {'fig','xform_data','models'} when return_model=True.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xecute

Migrate all 13 hand-authored docs/tutorials/*.ipynb notebooks off the
retired DataGeometry/geo API onto hyp.plot's figure-return API (fig =
hyp.plot(...), fig, ani = hyp.plot(..., animate=True), raw hyp.load(...),
return_model=True bundles), then re-execute every notebook end-to-end via
nbconvert so committed outputs reflect real runs against this branch.

- align/analyze/cluster/normalize/reduce/plot: drop geo/.get_data(), rename
  geo-> fig; plot.ipynb also repoints hyp.tools.procrustes to the 2.0
  location (hypertools.align.procrustes); reduce.ipynb updated for the
  ndims=None (no forced reduction) default and a single-array-unwrap quirk
  in hyp.reduce.
- conversation_trajectories/modern_sklearn_dynamics/streaming_data: adopt
  the (fig, ani) animate return tuple and fig.stream_info dict.
- hugging_face_embeddings/wikipedia_embeddings/text: geo->fig renames,
  wiki.get_data() -> raw list indexing; enlarged text.ipynb's SOTU sample
  so UMAP has enough points for a stable neighbor graph.
- geo.ipynb repurposed (matching Task 2's plot_geo.py) into "Working with
  plot outputs (figures & return_model)", replacing the DataGeometry-object
  walkthrough with figure/return_model/animate-tuple/save patterns.

Re-ran scripts/add_colab_install_cell.py to refresh the branch-aware Colab
install cells (now correctly dev-2.0-refactor instead of stale dev-2.0).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… placed right on both backends

BUG 3 (plotly markers): _MARKER_SYMBOLS in plotly_backend.py was a
hand-maintained subset of matplotlib's marker set, omitting , 1 2 3 4 P X | _.
_parse_fmt therefore failed to recognize those as markers and fell through to
mode='lines', so e.g. hyp.plot(d, ',', backend='plotly') drew solid lines
instead of pixel markers. Completed the table so every printable matplotlib
marker char maps to a valid plotly symbol, and added 3D fallbacks for the new
non-3D-legal symbols. matplotlib already handled all markers; both backends
now agree.

BUG 1 (legend clipping): the right-side legend (ax.legend(loc='center left',
bbox_to_anchor=(1.02, 0.5))) was clipped off the figure's right edge on 3D
plots because plt.tight_layout() reserves room for an outside legend on 2D
axes but not on Axes3D. Added _fit_right_legend(), called after tight_layout
for static matplotlib plots with a legend: it measures the rendered legend and
pulls the subplot's right edge leftward via subplots_adjust until the legend
fits fully within the canvas (no-op when it already fits).

Tests: added plotly marker regressions + a matplotlib/plotly parity test
(tests/test_interactive.py) and legend-placement tests for 2D/3D incl. long
labels (tests/test_plot.py). Full suite 325 passed (was 318).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds examples/plot_shape_morph.py, a self-contained sphinx-gallery
example that morphs between all seven shapes-zoo point clouds using
Hungarian-matched point correspondences and a FuncAnimation with a
rotating camera. Uses the current 2.0 API only (hyp.load returns raw
arrays; hyp.plot(..., show=False) returns a bare Figure) -- no
DataGeometry/get_data/line_ani.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…~6x too fast)

Export writers derived per-frame delay from the (possibly subsampled) frame
count (1000*duration/n_frames), so any frame subsampling in the export path
collapsed total playback -- exported gifs played too fast. The committed
dev/plotly_spin_demo.gif was written subsampled (45 frames, not 90).

Decouple per-frame delay from frame count: _export_animation_file now sets
frame_ms = round(1000/frame_rate) (the true inter-frame interval) over the
FULL fig.frames, and the video branch uses fps=frame_rate directly. Frame
subsampling stays only on the interactive-HTML embedding (_show_sphinx_gallery)
and vector-SVG paths -- never the exported gif/png/mp4. Matplotlib path already
saved every frame at PillowWriter(fps=frame_rate); documented the invariant.

Regenerated dev/*.gif with the fixed code. Added regression tests asserting
both the full frame count (frame_rate*duration) and total playback ~= duration
on each backend. Interactive 900-frame pacing parity unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…api.rst + stale stubs

- api.rst: drop DataGeometry section, tools.procrustes->align.procrustes
- delete orphaned autosummary stubs for removed symbols (DataGeometry, tools.{reduce,load,cluster,procrustes})
- regenerate all gallery examples + tutorials under the 2.0 API (fixed plotly markers, unclipped 3D legends, full-length animation gifs, + shapes-zoo morph example)
- refresh docs/auto_examples/spin.gif to full-length (was stale 75-frame)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Installs Playwright (chromium) into .venv and adds
scripts/verify_docs_playwright.py, which serves the already-built
docs/_build/html/ over a real local HTTP server and drives it with a
real headless Chromium browser -- no mocks. It asserts, on the gallery
index, 5 example pages (2 static, 2 mp4-animated, 1 plotly-animated),
and 2 tutorial pages: example images/thumbnails and animation frames
decode to non-zero dimensions and are pixel-content non-blank (real
stddev check via PIL/numpy, not existence-only); animated pages embed
a real <video> or a rendered Plotly animation (Plotly.animate/addFrames
calls); and the "Open in Colab" affordance is branch-aware (contains
dev-2.0-refactor) on both the example-page badge and the tutorial-page
install cell. All 8 pages pass. Screenshots + PR_EVIDENCE.md land in
docs/images/v2.0-docs/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nor)

- io/load.py: any({...}) built a set of arg values; reduce/align dicts are
  unhashable, crashing hyp.load with reduce={dict}/align={dict}. Switched
  both guards to any(v is not None and v is not False for v in (...)).
- plot/plot.py: HDBSCAN n_clusters guard checked the raw `cluster` arg
  (a dict in the dict-form branch) instead of the resolved model name,
  letting n_clusters leak into HDBSCAN params and crash.
- plot/plot.py: return_model=True + animate=True dropped the only reference
  to the FuncAnimation; the return_model bundle now carries 'animation'.
- plot/matplotlib_backend.py: update_lines_parallel hardcoded elev=10
  instead of using the elev fargs parameter.
- core/model.py: fixed the apply_model(mode='auto') docstring to match the
  actual (intended) predict_proba-first ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ver clipped

Jeremy reported the default zoom for ANIMATED plots could clip the wireframe
bounding box on the right during rotation. Measuring a full 360 deg spin on
both backends (inked-bbox margin per frame, 90 azimuths, 640x480) showed
NEITHER backend actually clips -- matplotlib kept an 80px min right margin,
plotly 95px -- but Jeremy asked for a slight, symmetric zoom-out for comfort,
so apply one conservatively (static plots unchanged).

matplotlib: new _anim_box_zoom(zoom) = 9/(9-zoom) -> 1.125 at the default
zoom=1 (was 10/(9-zoom)=1.25), used by update_lines_parallel/spin/serial.
Static plots never call set_box_aspect(zoom=...), so they are unaffected.

plotly: new _anim_zoom_r(zoom) = _zoom_r(zoom) * 1.1 pulls the animation
camera ~10% farther back; used by the initial camera AND every frame when
animating. Static plots keep _zoom_r unchanged (byte-identical).

Result (full-rotation min right margin, before -> after):
  matplotlib 80 -> 95 px; plotly 95 -> 119 px. All edges gain margin,
  symmetrically, without leaving excessive whitespace.

Regenerated dev/animation_demo.gif and dev/plotly_spin_demo.gif with the
fixed code. Added regression tests: test_anim_box_zoom_is_zoomed_out,
test_spin_box_never_clipped (real per-frame pixel bbox check across a full
rotation), and test_plotly_animation_zooms_out_vs_static. Pacing/parity and
animation-export suites stay green (73 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ries)

Animated line plots draw a faint alpha=0.3 trail artist per dataset in
addition to the in-focus moving window. Both carried the dataset's label,
so ax.legend() collected each label twice. Set trail labels to
'_nolegend_' so only the in-focus lines appear. The legend is built once
from the upfront line artists, so it shows the static union of in-focus
datasets and never changes across frames (covers the serial-animation
case). plotly already tagged trails showlegend=False.

Adds regression tests: default + chemtrails animations show exactly one
legend entry per dataset; serial animation legend is the static union.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- #259: importing hypertools no longer mutates global matplotlib rcParams.
  Removed the module-level pdf.fonttype=42 in matplotlib_backend.py; the
  editable-PDF default is now set inside backend.py's manage_backend scope
  (after its rcParams snapshot) so it applies to hypertools' own saves but
  is restored afterward, never leaking.
- #223: update_position() no longer calls Axes3D-only get_proj() on 2D
  labeled plots, and the annotate_plot/update_position tuple shapes now
  match for both 2D (3-tuple) and 3D (4-tuple) -- fixes AttributeError/
  ValueError on button-release over a 2D labeled plot.
- #146 & #190: cluster() injects n_clusters only when the resolved model's
  signature accepts it (was hardcoded != 'HDBSCAN'), and MeanShift/DBSCAN/
  OPTICS/AffinityPropagation are registered -- density/bandwidth clusterers
  now work by name and by class.
- #148: show=False now closes the figure (when the user didn't supply an
  ax), removing it from pyplot's manager so it doesn't reappear via
  flush_figures/plt.show(); returned Figure/animation stay valid.
- #214: load() docstring uses wiki_model (matches the dict key).
- reduce.py: passing a custom sklearn class/instance to reduce= no longer
  raises UnboundLocalError (model_params was undefined in the else branch);
  class vs. instance handled correctly. Unblocks the custom-model path (#162).

Adds regression tests: tests/plot/test_matplotlib_backend_bugs.py (#259/#223/
#148), plus cluster (MeanShift/DBSCAN) and reduce (custom class/instance) cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-runs the 7 matplotlib animated examples (animate, animate_MDS,
animate_spin, chemtrails, precog, save_movie, plot_shape_morph) plus the
hand-maintained spin.gif so the committed mp4s/gifs reflect the current
backend: the animated 3D bounding box is zoomed out slightly (set_box_aspect
1.25 -> 1.125 + full-canvas axes) for a comfortable margin at every rotation
angle, and animation legends no longer duplicate the in-focus line with its
faint trail. plot_geo re-rendered (return_model docstring now lists the
'animation' key). animate_plotly retains its cached artifact (plotly 3D
auto-fits; its 900-frame kaleido gif export is prohibitively slow here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
notes/issues-to-close-on-merge.md catalogs all 67 open GitHub issues triaged
against dev-2.0-refactor (close-on-merge list, bugs fixed this branch, and
what to leave open). docs/images/v2.0-anim-fix/ holds before/after frames for
the animated bounding-box zoom-out and the de-duplicated animation legend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… fit

- Windows CI: datawrangler 0.5 evaluates os.getenv('HOME') at import time to
  build its data dir; HOME is unset on Windows so dw (and hypertools) crashed
  on import. Set HOME=expanduser('~') before importing dw in core/configurator.
- mpl 3.11 (Python 3.11+): plt.close(fig) (the show=False fix) resets the
  figure canvas, so update_position() crashed on fig.canvas.renderer. Guard the
  renderer (skip the reposition if absent) and render test_spin_box_never_clipped
  through an explicit Agg canvas so buffer_rgba is always available.
- Legend clipping (gallery/example figs): _fit_right_legend now WIDENS the
  figure (keeping the plot's size/position) until the legend has a right-edge
  margin, instead of shrinking the axes and giving up at a floor. Crucially it
  measures the rasterized pixels under DEFAULT rcParams -- hypertools draws
  inside a seaborn rc_context whose font is narrower than the font the figure
  is actually saved with downstream, so measuring under seaborn made wide
  legends look like they fit when they clipped. Long labels / many entries now
  stay fully visible (verified R>=12px margin on 3D+2D, short legends unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Asserts the SAVED figure keeps a right-edge margin for long labels / many
entries -- fails on the pre-fix _fit_right_legend, passes with the widen-under-
default-rcParams fix. The existing get_window_extent tests measured under the
seaborn font and missed the clip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… fix

plot_legend, plot_PPCA, plot_missing_data re-rendered: legends now keep a
consistent right-edge margin (figure widened as needed) instead of relying on
axis-shrink. Regression-tested in tests/test_plot.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeremymanning jeremymanning changed the title HyperTools 2.0: animation rendering fixes + open-issue triage HyperTools 2.0: architecture refactor + bug hunt Jul 4, 2026
jeremymanning and others added 3 commits July 4, 2026 18:03
…ned fig

Two remaining CI failures were fallout from the GH #148 close:

- Windows test_spin_box_never_clipped: on Windows the animate backend switch
  to TkAgg actually succeeds, so plt.close() destroys the FuncAnimation's real
  Tk timer; the animation's pending first-draw hook then crashes any later
  draw of the returned figure ('NoneType' object has no attribute 'start' /
  'add_callback'). Animated figures are now exempt from the show=False close
  (the #148 complaint was about static figures; animations need their timer
  alive for playback). Headless Linux/mac fall back to Agg, which is why only
  Windows hit this.
- Ubuntu 3.12 screenshot step (12/13 failed 'produced no matplotlib figures'):
  the harness discovered figures via plt.get_fignums(), which the #148 close
  empties. capture() now prefers the RETURNED figure(s) (Figure, (fig, ani),
  or return_model dict), falling back to the pyplot registry.

Verified: spin + backend-bugs tests pass; generate_baseline_screenshots.py
13/13 succeeded. Also folds in gallery zips regenerated by the legend rebuild.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tream)

dw 0.5.1 resolves ContextLab/data-wrangler#32 (config datadir built from
os.getenv('HOME'), None on Windows) via os.path.expanduser. Bump the base and
[text] pins; keep the zero-risk HOME setdefault guard for environments still
on 0.5.0, with the comment updated to reflect the upstream fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…0.5.1)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeremymanning jeremymanning deleted the dev-2.0-refactor branch July 5, 2026 02:53
jeremymanning added a commit that referenced this pull request Jul 5, 2026
… supersedes #271

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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