HyperTools 1.0: architecture refactor + bug hunt#272
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>
…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>
…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>
Seven long-standing feature requests: implemented with evidenceThis 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 ( (raw image links below render from the #95 — MultiIndex DataFramesA DataFrame with a row MultiIndex now expands into leaf trajectories plus per-level group averages, styled exactly per the issue spec: leaves
#100 — Colorbars
#109 — Smooth convex-hull surfaces (redirected per maintainer comment)Instead of sliding-window triangle strips,
#108 (+#191) — Subtle density shading
#127 — Per-dataset animation trail styles
#142 — Legend/color kwarg bugThe 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
#177 —
|
…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>
Refined surface rendering + first-class
|
| 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) |
|---|---|
![]() |
![]() |
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) |
|---|---|
![]() |
![]() |
4. Demos migrated + docs
examples/plot_shape_morph.py: the hand-rolled normalize/sample/Hungarian/FuncAnimation machinery (≈90 lines) collapses to onehyp.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.plotdocstring documentsanimate='morph', the tagged-list form,rotations, andmorph_samples; README updated.
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
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>


















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.1. Architecture refactor (Plans 1–8)
Reorganized the working
dev-1.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): 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):
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)format_dataaligns named columns BY NAME to the first dataset's order (warns on reorder); mismatched column sets raise a clear ValueErrorwiki_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).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.analyzelike 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, realchronos-t5-tinytest).tfollows 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, andKalman, 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=Trueon both →(result, fitted)matchingapply_model's convention; the fitted model can be passed back asmodel=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.[predict]extra (pykalman, statsmodels, skaters) and[predict-hf](chronos-forecasting); GaussianProcess/AutoRegressor/sklearn imputers work on the base install; friendlyImportErrors otherwise (fresh-venv verified). yfinance is not a dependency — the tutorial self-installs it.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.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.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:
colorbar=True/dict — continuous hue (same palette as the lines, real value range) and discrete groups (segmented, labeled); coexists with legends without clipping.surface=True/dict — smooth, lit convex-hull surfaces (3D, Blinn-Phong + plotly vertex shading) and smooth filled shapes (2D), dict-controlled properties, animatable (per-frame hull recompute); the axes cube auto-expands to contain the hull. Newanimate_surface_morphgallery demo.density=True/dict — subtle per-group KDE clouds (off by default; 2D alpha-ramp, 3D iso-surfaces/go.Volumewith small-cluster auto-boost); enhancement: replace matplotlib with ipyvolume #191's ipyvolume ask is superseded by the plotly backend.chemtrails/precog/bullettimeaccept per-dataset lists (mixed trail styles in one animation); spin/serial warn instead of silently ignoring.colors=/linestyles=/markers=were silent no-ops without their singular twins — fixed.hyp.load#177hyp.loadcompleted: Drive large-file interstitial (verified against a real 498MB public file),.xlsx/.xls, Google Sheets→CSV, remote-pickle trust policy.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-segmentrotationslists ([1, 0.25, 2, ...]= per hold/transition). Both shape-morph gallery demos collapsed to singlehyp.plotcalls.Testing
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.make htmlsucceeds); gallery regenerated with the 6 new example pages (including two captured animations).🤖 Generated with Claude Code