Skip to content

HyperTools 1.0: architecture refactor + bug hunt#272

Open
jeremymanning wants to merge 124 commits into
dev-1.0from
dev-1.0-refactor
Open

HyperTools 1.0: architecture refactor + bug hunt#272
jeremymanning wants to merge 124 commits into
dev-1.0from
dev-1.0-refactor

Conversation

@jeremymanning

@jeremymanning jeremymanning commented Jul 5, 2026

Copy link
Copy Markdown
Member

HyperTools 1.0 — architecture refactor + bug hunt

This PR merges the completed HyperTools 1.0 class-based refactor into dev-1.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.

Note: originally developed under the working name "HyperTools 2.0"; renumbered to 1.0 (package version 1.0.0.dev0, branches dev-1.0/dev-1.0-refactor) per project decision. Older commit messages/notes may still say 2.0.

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

Supersedes #271, which GitHub auto-closed when its head branch was renamed dev-2.0-refactordev-1.0-refactor; all commits, evidence, and CI history carry over unchanged.


1. Architecture refactor (Plans 1–8)

Reorganized the working dev-1.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 1.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): 31 addressed/obsolete + 15 fixed or implemented on this branch (incl. §5's #169/#132 and §6's seven features) = 46 to close on merge; 21 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)
#132 DataFrame columns consumed positionally across datasets format_data aligns named columns BY NAME to the first dataset's order (warns on reorder); mismatched column sets raise a clear ValueError
#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).

5. New: hyp.predict + hyp.impute (resolves GH #169)

Two new modules in the established class-based style (base class + one file per model + funnel dispatcher), integrated into hyp.plot/hyp.analyze like cluster/align:

  • hyp.predict(data, model=..., t=...) — timeseries forecasting: Kalman, GaussianProcess, AutoRegressor (any sklearn regressor, recursive multi-step), ARIMA, Laplace (skaters ensemble), Chronos (HuggingFace foundation model, real chronos-t5-tiny test). t follows Use Kalman filter to fill in missing data #169's spec (int steps, or a datetime on time-indexed data — including past-date truncation). One forecast per input dataset, same dimensions, continued index.
  • hyp.impute(data, model=...) — missing data: PPCA (default; clean interface over the vendored implementation — format_data's fill now routes through it, behavior-preserving), SimpleImputer/KNNImputer/IterativeImputer, and Kalman, which fills rows where every feature is NaN — the exact gap Use Kalman filter to fill in missing data #169 describes (PPCA cannot).
  • return_model=True on both → (result, fitted) matching apply_model's convention; the fitted model can be passed back as model= on new data and is applied without re-estimation (verified: fit on A, forecast/impute B).
  • hyp.plot(data, predict='Kalman', t=30) overlays one dashed, low-opacity, same-color forecast tail per dataset (2D + 3D, both backends, no legend duplication, frame always contains the forecasts). impute= selects the missing-data model in the plot/analyze pipeline.
  • Dependencies: new [predict] extra (pykalman, statsmodels, skaters) and [predict-hf] (chronos-forecasting); GaussianProcess/AutoRegressor/sklearn imputers work on the base install; friendly ImportErrors otherwise (fresh-venv verified). yfinance is not a dependency — the tutorial self-installs it.
  • Tutorials (executed, real data):
    • stock_forecasting.ipynb — scrapes 2y of real Yahoo Finance prices for 4 tickers, backtests all models against a 30-day holdout with an honest MAE/MAPE table (spoiler: ARIMA/Kalman ≈ the naive baseline, as efficient-market theory predicts — the tutorial says so).
    • projectile_kalman.ipynb — a real NBA SportVU jump-shot arc (25 Hz optical tracking): Kalman imputation of 5 fully-occluded frames recovers them to RMSE 0.20 ft vs the recorded truth; forecasting the arc's final 20 frames from the first 30 lands within MAE 4.2 ft.
  • Forecast direction fix (review follow-up): the GaussianProcess default kernel is now DotProduct + RBF + WhiteKernel — the old stationary RBF reverted forecasts to the training mean beyond the data (drift −0.026/pt vs observed +0.0019/pt: reversed); the linear term extrapolates trends (+0.002..+0.010/pt: continues). Before/after renders + measurements in this comment.
  • Gallery: plot_predict (helical forecasts) + plot_impute (PPCA-vs-Kalman panels on the Use Kalman filter to fill in missing data #169 case); API reference sections added.

6. Seven long-standing feature requests (GH #95, #100, #108, #109, #127, #142, #177, #191)

All seven implemented in the 1.0 design language, in both rendering backends, each with numeric + screenshot evidence in this comment:

Every graphical feature was adversarially screenshot-reviewed by fresh agents across a {2D,3D}×{mpl,plotly}×{static,animated} grid; their findings drove 6 further fix commits (plotly WebGL surface artifacts, bounding-box containment, MultiIndex colorbars, legend fitting, 3D density visibility, trail-mode warnings). New gallery examples: plot_surface, plot_density, plot_colorbar, plot_multiindex, animate_trails_mix, animate_surface_morph.

Maintainer-feedback round (evidence): hulls now hug the observations (hull-hugging smoothing: post-Taubin pull-back to the hull; cube-cloud oversize 1.63×→1.13×, ≥99% containment; axes box sized from actual meshes incl. mid-morph union bound, both backends) and morphing is a first-class animation style — animate='morph' (Hungarian point-cloud morphs between datasets, tagged-list form for static backdrops) with per-segment rotations lists ([1, 0.25, 2, ...] = per hold/transition). Both shape-morph gallery demos collapsed to single hyp.plot calls.


Testing

  • Local suite: 765 passed, 0 failed (+310 tests across both rounds: surface/density/colorbar/multiindex/trails/meshutil/load/color-alias/morph-animation/hull-tightness suites); 6 plotly→kaleido image-export tests deselected locally only — they deadlock Chromium in this sandbox but run fine in CI.
  • CI: all 12 jobs green (ubuntu/macos/windows × Python 3.10–3.13) — see this PR's checks for the head commit.
  • Docs rebuilt (make html succeeds); gallery regenerated with the 6 new example pages (including two captured animations).

🤖 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 16 commits July 5, 2026 17:05
…ot assumed contiguity

_add_animation assumed trail traces (chemtrails/precog/bullettime) sit
immediately after the data traces (range(n_data_traces + n_trail_traces)).
That breaks when predict= forecast traces are also present, since they are
appended between the data and trail traces, shifting the real trail
indices right by len(data). Frame updates would then overwrite the static
forecast traces with trail geometry while the real trail traces stayed
empty forever. plotly_draw now records the trail traces' actual start
index and _add_animation addresses them by that recorded index instead of
assuming contiguity.

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

Adds plot(..., density=True/dict): a subtle KDE "glow" behind each
dataset, off by default. 2D uses an alpha-ramped imshow (mpl) / heatmap
Contour (plotly); 3D uses marching-cubes iso-surfaces (mpl, optional
scikit-image via the new [density3d] extra, else a scatter-fog fallback)
/ a go.Volume (plotly, tuned for visibility at hypertools' shared
[-1,1] scene scale -- the naive alpha-scaled opacity formula rendered
essentially invisible in real multi-dataset scenes). Animated plots
compute density once from the full data as a static background, never
per-frame (a KDE eval is far too slow for a frame budget).

New hypertools/plot/density.py mirrors surface.py's validate/broadcast
pattern. tests/test_density.py (30 cases, no mocks, including a real
import-system meta-path-blocker subprocess test for the scikit-image-
absent fallback). Evidence PNGs in docs/images/v1.0-seven-features/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chemtrails/precog/bullettime now accept a bool (broadcast, unchanged
default behavior) or a list/tuple of bool -- one entry per FINAL (post
cluster/hue-reshape) drawn dataset -- so mixed per-dataset trail styles
are honored on both backends (e.g. one dataset with a past trail,
another with a future trail, another with a full trail). Previously a
list value was silently truthy-broadcast to every dataset regardless of
its content.

- New hypertools/plot/trails.py::broadcast_trail_flag (mirrors
  surface.py's broadcast_surface), validated in plot.py after cluster/
  hue reshaping and defensively in both backends.
- mpl: update_lines_parallel indexes the three per-dataset lists per
  frame (same semantics, now per dataset); trail artists are created
  only for datasets with at least one flag set.
- plotly: trail traces are built only for flagged datasets
  (trail_dataset_indices); the explicit trail-trace-index frame-update
  fix (commit 30dac24) continues to hold.
- animate= itself (True/'parallel'/'spin'/'serial') stays a single
  global mode -- one camera, one frame loop -- documented in the
  docstring; only the trail flags became per-dataset.
- tests/test_animation_styles.py: mixed per-dataset styles on both
  backends (exact artist/trace assertions at a fixed frame), no-flag
  datasets get no trail artist/trace, scalar/list broadcast
  equivalence, bad-length ValueError, spin/serial unaffected, plotly
  layout-lock regression.
- Evidence PNGs: docs/images/v1.0-seven-features/trails_perdataset_{mpl,plotly}.png

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… w/ level styling (GH #95)

A DataFrame with a row MultiIndex (nlevels >= 2) is expanded, before
format_data/analyze/reduce runs, into one leaf dataset per unique full
index combination; after the pipeline transforms the leaves, one MEAN
trajectory per non-leaf level grouping (deepest up to top) is computed in
the transformed space and appended, with per-dataset color/linewidth/
alpha/linestyle/label overrides. linewidth = 1 + (levels aggregated
over); alpha = min(1.0, 1/(level_idx+1)+0.2), leaves most transparent,
top-level mean fully opaque; color assigned by top-level index value
(shared across a group's leaves+means); only the top-level mean carries
a legend label. hue= is superseded with a warning; cluster=/n_clusters=
raise (both would fight the MultiIndex color assignment); an explicit
linestyle(s) list must have one entry per unique top-level value.

New hypertools/plot/multiindex.py (expand_multiindex/build_multiindex_
styles). Also fixes a plotly gap: showlegend now honors label='_nolegend_'
(matplotlib's own underscore convention), needed so non-top-level traces
don't leak a literal "_nolegend_" legend entry.

tests/test_multiindex.py (24 cases, no mocks): exact mean-array equality,
2-/3-level formula checks, plotly parity, unequal-length-group warning,
hue/cluster interaction, animated smoke, single-level regression. Evidence
PNGs docs/images/v1.0-seven-features/multiindex_{mpl,plotly}.png. Full
suite: 626 passed, 6 deselected (standard kaleido/svg local skips).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dedupe warnings (GH #95)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…; rescale tiny-cloud shrinkage (GH #109)

Three separate, verified upstream plotly.js WebGL rendering defects were
triggered by surface=, not a bug in our mesh geometry (confirmed clean:
full un-culled face set, no duplicate/degenerate faces, closed 2-manifold,
consistent normals):

- Speckle at near-opaque alpha: plotly's Mesh3d has a documented,
  unfixed depth-sort bug for translucent meshes (plotly/plotly.py#3554).
  Fixed by baking the requested alpha into the mesh color (blended over
  the white background) and always rendering opacity=1.0.
- Hole at alpha=1.0: Scatter3d marker points enclosed by an opaque Mesh3d
  are not reliably depth-composited by plotly and punch through the
  surface (verified in a real Chromium tab, not just kaleido). Fixed by
  hiding (NaN-ing) points a dataset's own surface encloses.
- Hole between two overlapping datasets' surfaces: whichever mesh trace
  is added first gets punched by the other's volume (verified by swapping
  dataset order, which relocates the hole). Fixed by trimming each mesh's
  faces that fall inside another dataset's mesh before building the trace.

Also: smooth_hull_3d's pre_inflate under-compensates for small/sparse
point clouds (a 5-point cloud's mesh contained as few as ~1/5 of its own
points, silently). Added a post-hoc, grow-only rescale so the mesh
recovers >=96% containment regardless of hull size, warning only if that
target still isn't met.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion seams (GH #109)

Round 2 fixes for the surface= feature. Root causes: (1) surface meshes are
built AFTER data is scaled into the [-1,1] cube, so pre_inflate + smoothing
overshoot + round-1's grow-only containment rescale routinely bulge past
the hard-coded cube_scale=1 in both backends; (2) plotly's Mesh3d back-face
culls individual triangles per camera angle, punching holes through a
mesh's own genuine (Taubin-smoothing) concave dimples even with no second
dataset in the scene -- separate from, and previously conflated with, a
real WebGL depth-fight between two overlapping fully-opaque Mesh3d volumes
(re-verified live in Chromium: round 1's trim must stay for that case).

- hypertools/plot/surface.py: new surface_cube_scale() sizes the axes
  cube/scene range to the built mesh's actual extent instead of assuming 1.
- hypertools/plot/matplotlib_backend.py: static and animated 3-D paths
  size cube_scale from the real mesh (built once, reused for shading);
  animations use a fixed bound computed from the full-data mesh.
- hypertools/plot/plotly_backend.py: cube trace + scene axis ranges use
  the same computed bound; Mesh3d traces (initial and per-frame updates)
  now emit both winding orders per face with flatshading=True, so no
  triangle can ever be culled from any angle; the mesh-mesh trim is now
  priority-based (only cut against lower-indexed datasets) instead of
  mutual, so the first dataset in an overlapping cluster stays a complete
  surface for later ones' cut edges to sit against -- fixes the round-1
  "oval hole exposing interior" and reduces (but does not eliminate) the
  jagged seam at deep overlaps.
- examples/animate_surface_morph.py: replaced its hard-coded cube_scale
  fudge factor with the new surface_cube_scale() helper (this example
  hand-rolls its own animation and bypasses plot()'s surface machinery).

Tests: added axes-cube containment tests (static + 3-frame animated mpl;
static plotly) and double-sided/priority-trim regression tests to
tests/test_surface.py; updated face-count assertions for the doubled-face
Mesh3d geometry. 69 passed (test_surface.py + test_meshutil.py +
test_plotly_trace_indices.py); 653 passed, 1 deselected (full suite).

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

Round 2's flatshading=True double-winding fix (for plotly's per-triangle
self-culling) shaded each doubled face from its own possibly-reversed
normal, rendering the reversed copy dark wherever the original faced the
light -- large jagged dark patches on every surface. Replaced with
per-vertex Blinn-Phong shading (new blinn_phong_vertex_colors/
vertex_normals in meshutil.py), precomputed and passed to Mesh3d as
vertexcolor with plotly's own lighting forced to the identity. Both
windings of a doubled face share vertex indices, so they're always
colored identically, making the defect structurally impossible. Colors
are recomputed every animation frame since the camera view changes in
every animate mode (matching mpl's own per-frame recompute).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… in all layouts (GH #100, GH #95)

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

hypertools jointly scales all datasets into one shared cube, so widely
separated 3-D clusters occupy only a small fraction of it; the density=
layer's base alpha/opacity (tuned for a scene-filling dataset) then
covered too little screen area to be visible, at ALL levels values and
in both backends.

Adds density_alpha_boost()/bbox_extent() (hypertools/plot/density.py): a
multiplier, ~1 (no-op) for a scene-filling dataset, ramping up (gamma=2,
capped at 6x) for a dataset small relative to the whole scene. Wired into
matplotlib's iso-surface/fog alphas and plotly's go.Volume opacity.
Plotly also needed resolve_plotly_volume_params() to widen the KDE grid
padding and shift the opacity ramp toward lower density values -- boosting
opacity/surface_count alone stays invisible behind plotly's opaque,
same-colored markers, which fully cover a small cluster's footprint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chemtrails/precog/bullettime silently no-op under animate='spin'/'serial':
update_lines_spin/serial never touch trail artists, leaving them frozen
and invisible for the whole animation. Since 'spin' has no "current
position" for a trail to lead/follow and 'serial' already communicates
elapsed time via its point-by-point reveal, skip creating trail artists/
traces entirely in these two modes and warn once, naming the mode, the
ignored flag(s), and which dataset indices had them set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ltiIndex, per-dataset trails

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ure registration for sphinx-gallery capture

hyp.plot(show=False)'s GH #148 close deregisters the figure from pyplot,
which silently prevented sphinx-gallery from capturing manually-built
FuncAnimations (the new surface-morph demo rendered as a static page, and
plot_shape_morph only appeared animated because of a stale md5 cache).
Both examples now pass their own ax= (the documented your-figure-to-manage
path), keeping the figure registered so the videos render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…add seven-features plan + session notes

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

Copy link
Copy Markdown
Member Author

Seven long-standing feature requests: implemented with evidence

This round addresses GH #95, #100, #108, #109, #127, #142, #177 (and #191 via the plotly backend), all in the 1.0 API design language, working in both rendering backends (backend='matplotlib' / backend='plotly'). Commit range 8d424e08..3861ca6e. Every graphical feature was screenshot-reviewed by fresh reviewer agents across a {2D,3D} × {mpl,plotly} × {static,animated} grid, and every bug they found was fixed and re-verified (details per section). Full suite: 681 tests passing (was 455 at the start of this round).

(raw image links below render from the dev-1.0-refactor branch)

#95 — MultiIndex DataFrames

A DataFrame with a row MultiIndex now expands into leaf trajectories plus per-level group averages, styled exactly per the issue spec: leaves lw=1, level-k means lw=k+1; alpha = min(1, 1/(depth_below_top+1) + 0.2) (top-level means opaque, leaves faint); colors shared by top-level index value; linestyles list length = number of top-level values; legend shows one entry per top-level value. Means are computed on transformed trajectories (post normalize/reduce/align) and equal np.mean of member leaves exactly (asserted in tests). 25 tests.

matplotlib plotly
mi-mpl mi-plotly

#100 — Colorbars

hyp.plot(..., colorbar=True) (or a dict: label, ticks, location). Continuous hue → colorbar over the actual hue value range using the same palette as the lines (endpoint RGB equality asserted numerically); discrete groups (cluster / lists / categorical hue) → segmented colorbar with legend/group labels (the groupvals question from the issue is answered by the hue/group values themselves). Legend + colorbar coexist without overlap (rasterized-pixel fitting, verified for location='left'/'top', long labels, and animated plots). Found + fixed a pre-existing bug along the way: legend=True with a string categorical hue showed integer ids instead of category names. 22 tests.

continuous (mpl) discrete + legend (mpl) continuous (plotly)
cb1 cb2 cb3

#109 — Smooth convex-hull surfaces (redirected per maintainer comment)

Instead of sliding-window triangle strips, surface=True (or a dict API: alpha, color, lighting={...}, smoothing, pre_inflate, keep_points) computes each dataset's convex hull and renders it as a smooth, lit surface (3D) or smooth filled shape (2D):

  • Geometry: scipy.spatial.ConvexHull → outward orientation → interleaved rounds of midpoint subdivision + Taubin smoothing (pure numpy/scipy, no new dependency; 13.4 ms/mesh for 200 points, animation-friendly). 2D outlines use a centripetal Catmull-Rom closed spline (100% hull-vertex containment). Tiny clouds get a grow-only rescale so containment stays ≥96% (measured 100% on test blobs).
  • matplotlib: per-face Blinn-Phong shading + backface culling (eliminates z-sorting cracks); plotly: Mesh3d with precomputed vertex shading (same Blinn-Phong model), double-winding + priority face-trimming to work around three verified upstream plotly.js WebGL defects (self-culling holes, translucency speckle, intersecting-mesh punch-through — each reproduced in a live Chromium tab before being worked around).
  • The axes cube auto-expands to contain the full hull (both backends, static + all animation modes) — surfaces are never cut off by the bounding box.
  • Animations recompute the hull per frame from the visible window. New gallery demo: animate_surface_morph.py — the shapes-zoo morph rendered as a morphing lit surface.
    62 surface+mesh tests.
mpl (translucent, lit) plotly (vertex-shaded) morph demo frames
s1 s2 s3

#108 (+#191) — Subtle density shading

density=True (off by default; dict: alpha [default 0.2], levels, grid, per_group) renders each group's KDE as a soft cloud: 2D mpl imshow with an alpha-ramped colormap (contourf rejected — hard edges); 3D mpl nested marching-cubes iso-surfaces (scikit-image as optional density3d extra, with a real scatter-fog fallback + install hint); plotly go.Volume/go.Contour. Shell/volume opacity auto-boosts for small-in-scene clusters (separated datasets stayed invisible at fixed alpha — found by the review wave, fixed, re-verified at separations 0/5/10). Density is computed once from the full data for animations (measured KDE eval 536 ms @ 50³ ≫ 33 ms frame budget) and drawn as a static background. #191's ipyvolume proposal is superseded by the plotly backend, which provides the fast interactive 3D rendering it asked for. 57 tests.

2D (mpl) 3D iso-surfaces (mpl) 3D volume (plotly)
d1 d2 d3

#127 — Per-dataset animation trail styles

chemtrails, precog, and bullettime each accept a bool or a list of bools (one per dataset), so different datasets can use different trail styles in one animation (validated per-frame in tests: at frame k the chemtrails dataset's trail ends at k, the precog dataset's covers the future, bullettime stays full — both backends). The animation mode (parallel/spin/serial) stays global (one camera, one frame loop — documented); setting trail flags under spin/serial now warns naming the affected datasets instead of silently no-opping. 19 tests.

mpl (chemtrails / precog / bullettime per dataset)
t1

#142 — Legend/color kwarg bug

The 2017 symptom (legend swatches not tracking explicit colors) is already fixed by the 1.0 refactor — verified with renders in both backends. But re-verification exposed that colors=, linestyles=, and markers= (the plural aliases) were silent no-ops unless the singular kwarg was also passed — all three hoisted and fixed, with conflict warnings only when both forms are supplied. 10 regression tests.

before (colors=['red','green','blue'] ignored) after
142b 142a

#177hyp.load completion

Most of the issue was already implemented on this branch (Drive share-links/ids, Dropbox, HF datasets, npy/npz/csv/tsv/txt/json/parquet/mat/pickle + content sniffing). This round closed the gaps, each verified against live services:

  • Large Drive files: the virus-scan interstitial is now parsed and followed — verified end-to-end against a public 498 MB file (Content-Length 498,881,336 bytes, byte count matched exactly; full download in ~25–31 s). The interstitial parser is tested against the real captured HTML (committed fixture); the live test is @pytest.mark.bigdata (excluded from default/CI runs).
  • Excel: .xlsx via openpyxl (new io extra), exact round-trip test; .xls raises a friendly install hint for xlrd.
  • Google Sheets: spreadsheet URLs rewrite to CSV export — live-verified against Google's public sample sheet (loads as a (30, 6) DataFrame).
  • Remote pickle policy: unpickling from remote sources warns unless trust=True; remote .npy/.npz load with allow_pickle=False by default (a dedicated HypertoolsTrustError keeps policy errors distinct from parse errors); builtin example datasets exempt. Tested over a real local HTTP server.
    15 new tests.

Process notes

🤖 Generated with Claude Code

jeremymanning and others added 10 commits July 5, 2026 23:32
…ow (46 to close on merge)

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

Root cause of the surface-morph "explosion": _rescale_for_containment matched
each hull vertex to its nearest-BY-ANGLE mesh vertex and used that single
vertex's radial distance as a stand-in for the mesh's true reach along the
ray — cosine similarity ~0.999 (nearly perfectly aligned) still produced a
1.27x ratio on the demo's cube shape, because the true farthest vertex along
that exact ray was never considered. Replaced with the exact ray-vs-convex-hull
exit distance (halfspace representation via ConvexHull(verts).equations),
matching precisely what points_enclosed's Delaunay containment test checks.

- pre_inflate default 1.15 -> 1.0 (no blanket padding); shrinkage Taubin
  smoothing introduces is recovered by a minimal, grow-only, mathematically
  bounded post-hoc rescale targeting the actual input points (>=99%
  containment, not just hull vertices).
- Rescale cap set to 3.0 (not the initially-considered ~1.1): well-sampled
  data needs at most ~1.15-1.25x and is fixed by the corrected computation
  itself; genuinely sparse hulls (n=4-20) need up to ~2.9x to hit 99%
  containment under the same fixed Taubin smoothing — an unavoidable
  consequence of smoothing a coarse hull, and the reason pre_inflate/rescale
  existed in the first place (GH #109). A hard 1.1 cap would silently regress
  the long-standing tiny-cloud containment guarantee.
- New tests: tightness (hull_slack helper), containment >=99% across
  n=4..2000, explosion regression against an adversarial synthetic cube
  fixture. Updated tiny-cloud containment target 96% -> 99% (n=4 added) and
  4 pre_inflate=1.15 references pinned to the old default.
- Regenerated docs/images/v1.0-seven-features/surface_3d_{mpl,plotly}.png
  (library defaults, tighter fit).

705 passed, 6 deselected (standard kaleido deselects) full suite; 94 passed
tests/test_meshutil.py + tests/test_surface.py.

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

CI runs matplotlib 3.11+, where Poly3DCollection stores verts in _faces
(with an _invalid_vertices mask) instead of the _vec homogeneous cache
the local 3.10 venv has -- 9 CI jobs failed on AttributeError. The test
helper now tries each known private location.

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

Task M1b (maintainer follow-up, same day as M1: "the convex hulls need
to be tighter; they don't hug the observations as closely as they
should"). M1's uniform grow-to-contain still left ~1.16x mesh/cloud
ratios and 5-6.5% slack on cube/teapot, because a single isotropic
post-hoc rescale compensates Taubin's corner-shrink by ballooning the
already-tight flat faces outward along with the corners that actually
needed it.

- New `_ray_exit_distance` helper factors out the exact halfspace
  ray-vs-convex-polytope exit-distance math M1 introduced, now shared
  by both `_rescale_for_containment` and the new `_pull_back_to_hull`.
- New `_pull_back_to_hull`: after each Taubin pass, mesh vertices that
  fell inside the ORIGINAL (unsmoothed) hull are pulled back towards
  its surface along their own centroid ray; vertices already outside
  are left alone. Applied progressively across rounds plus a light
  Taubin touch-up + second pull-back, so the mesh hugs the data by
  construction instead of shrink-then-uniformly-regrow.
- `smooth_hull_3d` gains a `hull_blend` parameter (default 0.85, chosen
  after a numeric + visual {0.5, 0.7, 0.85, 1.0} sweep across
  cube/bunny/blob/teapot). M1's bounded grow-only containment rescale
  remains as the final safety net, unchanged.
- Retightened test bounds throughout (12% slack bound -> 6%; 1.5x cube
  explosion bound -> 1.35x) and added `TestSmoothHull3DTightnessAtScale`
  checking the maintainer's stated n>=100 targets (ratio<=1.05,
  slack<=2%) against cube/blob/teapot fixtures, with each honestly
  documenting which target is/isn't met and why (teapot meets both;
  the deliberately adversarial sharp cube meets neither as tightly,
  since pull-back only ever pulls vertices in, never trims Taubin's
  own outward bulge on already-outside vertices).

tests/test_meshutil.py + tests/test_surface.py: 101 passed. Full suite
(standard 6 kaleido deselects): 712 passed, 6 deselected.

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

Adds animate='morph' (both backends): datasets are treated as point
clouds and morphed ds_1 -> ds_2 -> ... -> ds_N through a hold/morph/
hold/... schedule, using the same sample + chain-match (Hungarian
algorithm via scipy.optimize.linear_sum_assignment) + smoothstep-eased
interpolation as examples/plot_shape_morph.py, now lifted into a
reusable hypertools/plot/morph.py. animate= also accepts a per-dataset
list ('morph'/None/False) to morph a subset of datasets while others
render as static backdrops. rotations= accepts a per-segment list
(length 2N-1) with continuous (no-jump) camera azimuth across segment
boundaries. surface=True recomputes the traveling cloud's hull every
frame; trail styles (chemtrails/precog/bullettime) warn + are ignored
for morph, matching spin/serial.

Task M2 of docs/superpowers/plans/2026-07-06-morph-animation.md.

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

Rewrite examples/plot_shape_morph.py and examples/animate_surface_morph.py to
delegate to the new library-native animate='morph' style (with a per-segment
rotations list) instead of hand-rolled Hungarian-matching + FuncAnimation +
manual mesh/shading/cube-scale code. Regenerate evidence frame grids and add
a README feature bullet.

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

animate='morph' + surface= sized the axes cube/limits from each morphing
dataset's FULL, original-order cloud, but every per-frame mesh is actually
rebuilt from the Hungarian-matched `sampled` clouds. smooth_hull_3d's
ConvexHull/Taubin-smoothing pipeline is not invariant to input row order
for hulls with many coplanar faces (e.g. a cube), so the reordered
per-frame mesh could exceed the sizing bound computed from the original
order; mid-morph interpolated points can also exceed either endpoint's own
hull while always staying inside their union's. Fix: size the cube once,
up front, from meshes built with the exact sampled arrays that will be
drawn plus one mesh over their union, in both matplotlib_backend.py and
plotly_backend.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…overshoot docs; earlier validation

Code review of the morph/tight-hull series (c0eff29..b6074f7) found:
(1) surface=True + animate='morph' built a redundant full-cloud
ConvexHull mesh per morph-tagged dataset solely for axes-box sizing, a
documented OOM/hang risk on large clouds -- now sizes those datasets
only from the M3b sampled+union meshes (mpl); plotly's equivalent
already did this, its comment just claimed otherwise, now corrected.
(2) examples/animate_surface_morph.py's docstring claimed a false "10%
overshoot" cap vs. meshutil's actual _RESCALE_CAP=3.0 -- rewritten to
describe the real hull-hugging-by-construction behavior. (3) rotations-
list-mode and <2-morph-dataset validation moved as early as possible;
the genuinely pipeline-dependent (cluster/hue-reshape) length checks
stay late, now documented as such.

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

With animate=['morph', None, 'morph'] (mpl backend), the untagged static
backdrop dataset's Line3D was initialized with only its first point
(dat[0:1, ...]) and update_morph never touches non-morph-tagged lines, so
it silently stayed a 1-point "cloud" for the whole animation instead of
being fully drawn as the docstring claimed. Fix: draw untagged datasets'
lines with their full data once, before the animation starts, mirroring
the plotly backend (where an untagged trace is simply never referenced by
any frame and so keeps its initial full-data draw).

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

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

Copy link
Copy Markdown
Member Author

Refined surface rendering + first-class animate='morph' (maintainer feedback round)

Addresses the three follow-up requests: tighter hulls, morphing as a real animation style, and per-segment rotation control — with the shape demos migrated and evidence for both backends below. Commit range c0eff29a..HEAD.

1. Hulls now hug the observations

Root causes found and fixed:

  • The containment rescale over-grew meshes (its nearest-by-angle ray proxy overstated the needed growth — measured up to 1.63× cloud extent on the demo's cube hold), compounding with a blanket pre_inflate=1.15.
  • Fix: pre_inflate → 1.0 (no blanket padding); the rescale now uses the exact ray-vs-hull exit distance and is hard-capped; and — the key change — hull-hugging smoothing: after each Taubin pass, vertices that fell inside the original hull are pulled back toward the hull surface, so the mesh hugs the data by construction instead of shrinking-then-ballooning.

Measured on the demo's five shapes (mesh/cloud extent ratio, ≥99% containment everywhere):

shape before after
bunny 1.40 1.09
cube 1.63 1.13
sphere ~1.15 1.02
teapot ~1.36 1.01
vase ~1.36 1.09

The axes box is also now sized from the actual built meshes including mid-morph geometry (union-hull bound), on both backends, so surfaces never cross the wireframe:

before (loose, box escapes) after (snug, contained — one panel per morph segment)
before after

2. animate='morph' — Hungarian point-cloud morphs as a library animation style

hyp.plot([ds1, ds2, ...], animate='morph') treats the datasets as point clouds and morphs ds1→ds2→…→dsN (hold/transition segments, 2N−1 total): equal-size samples, consecutive clouds matched point-for-point with scipy.optimize.linear_sum_assignment, smoothstep easing — exactly the shapes-demo recipe, now built in (hypertools/plot/morph.py), for both backends. Colors interpolate between the datasets' palette colors during transitions. animate also accepts a per-dataset list (['morph', None, 'morph']): tagged datasets morph, untagged ones stay as static backdrops. Trail flags warn+skip (as with spin/serial); predict= is guarded.

3. Per-segment rotations list

rotations=[1, 0.25, 2, 0.25, 1] gives dataset 1 a full camera rotation, the first transition a quarter turn, dataset 2 two rotations, etc. (list length = 2N−1; only valid with the morph style — scalar rotations works everywhere and keeps the old default). Azimuth accumulates continuously across segments (verified numerically: hold start/end azimuth identical when its rotation count is 0; no jumps at boundaries — adjacent-frame camera deltas smooth in both backends).

matplotlib (6 checkpoints, rotations=[1,0.25,2,0.25,1]) plotly (same)
mpl plotly

4. Demos migrated + docs

  • examples/plot_shape_morph.py: the hand-rolled normalize/sample/Hungarian/FuncAnimation machinery (≈90 lines) collapses to one hyp.plot(clouds, animate='morph', rotations=[...]) call; holds spin a slow full rotation, transitions a brisk quarter-turn.
  • examples/animate_surface_morph.py: same + surface=True — all manual mesh/shading/axes-sizing code deleted.
  • hyp.plot docstring documents animate='morph', the tagged-list form, rotations, and morph_samples; README updated.

shape morph frames

Verification

  • Suite: 765 passed (+84 this round: morph schedule/matching/rotations math with exact-value asserts, hull tightness/containment metrics, box-containment regressions incl. mid-morph, mixed-tagging, both backends).
  • Independent code review (math verification of the ray-exit/pull-back formulas, Hungarian chaining, azimuth continuity) and a fresh visual review across both backends drove three additional fixes (sampled-only box sizing for large clouds, early validation, untagged-dataset rendering in mpl mixed tagging) — all re-verified.

🤖 Generated with Claude Code

jeremymanning and others added 2 commits July 6, 2026 02:58
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… 3s; measured 0.5006s on windows-3.11)

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