HyperTools 2.0: architecture refactor + bug hunt#271
Closed
jeremymanning wants to merge 69 commits into
Closed
Conversation
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>
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>
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.1. Architecture refactor (Plans 1–8)
Reorganized the working
dev-2.0code into the class-based structure, on modern deps, withDataGeometryremoved from the public API:core(eval-freeapply_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). Oldtools/names remain as shims.DataGeometryremoved from the public API (kept only as a hidden unpickle shim so legacy hosted.geodatasets still load).plot()returns a Figure /(fig, ani);plot(..., return_model=True)returns{'fig','xform_data','models','animation'};load()returns raw data.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_aspectzoom 1.25→1.125 + full-canvas axes. Min cube-to-edge margin over the fullsave_movierotation: right 80→96px, bottom 51→72px.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'].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_legendnow measures the rasterized pixels under default rcParams and widens the figure (keeping the plot's size) until the legend has a margin. Regeneratedplot_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):
import hypertoolsmutated globalrcParams['pdf.fonttype']manage_backend's snapshot/restoreget_projcrash on 2D labeled plotsget_proj; match annotate/update tuple shapes for 2D vs 3Dn_clustersn_clustersonly when the model's signature accepts it; register those clusterersshow=Falseleaked the figure into pyplotplt.close(fig)for static figures (skipped when the user passedax, and for animated figures, whose timer must stay alive)wiki_modelkeyreduce=<class/instance>→UnboundLocalErrormodel_paramsin the custom-estimator branch4. CI fixes (get all platforms green)
The first CI run surfaced two platform issues (unrelated to the features above); both fixed:
os.getenv('HOME')at import time to build its data dir;HOMEis unset on Windows, soos.path.join(None, …)crashed dw's (and hypertools') import. Fixed hypertools-side by settingHOME=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; verifiedimport datawranglerworks withHOMEunset). Thepydata-wranglerpin is bumped to>=0.5.1; the one-lineHOMEguard stays as belt-and-suspenders for environments still on 0.5.0.plt.close()(the disabling the figure doesn't work as intended #148 fix), so tests/callbacks that readfig.canvas.renderer/buffer_rgba()failed. Fixed by guarding the renderer inupdate_positionand rendering the affected tests through an explicit Agg canvas. (savefigafter close still works, so users are unaffected.)plt.close()destroyed theFuncAnimation'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 theshow=Falseclose — the disabling the figure doesn't work as intended #148 complaint was about static figures, and animations need their timer alive for playback.plt.get_fignums(), which the disabling the figure doesn't work as intended #148 close empties; it now uses the figure(s) returned byplot()(13/13 cases pass).Testing
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).make htmlsucceeds); animated + legend gallery figures regenerated.🤖 Generated with Claude Code