Skip to content

HyperTools 1.0: architecture refactor + bug hunt#272

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

HyperTools 1.0: architecture refactor + bug hunt#272
jeremymanning wants to merge 243 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

The triage below has since been fully executed (2026-07-07): all 45 addressed/obsolete issues are CLOSED with per-issue run-code evidence comments; the 22 remaining feature requests carry research comments + low/medium/high effort labels; 6 issues were migrated from the jeremymanning fork (#273#278, fork tracker now empty); and 6 residual gaps found during re-verification were fixed on this branch (#94, #141, #199, #206, #209, #244). See the audit summary comment. Originally: triaged all 67 open issues against this branch with real repros (see notes/issues-to-close-on-merge.md): 31 addressed/obsolete + 16 fixed or implemented on this branch (incl. §5's #169/#132 and §6's seven features) = 47 to close on merge; 20 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 rounds (tight hulls + morph, constant rotation speed + plotly parity + lighting controls, axes-clipping + gif corruption + full-sample morphs, multibyte text support, GH #205): 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. Morph rotation speed is constant (segment duration ∝ rotation count); plotly marker sizes are empirically calibrated to matplotlib (15.1px → 5.0px for markersize=6) and volumetric shading retuned to matplotlib's subtlety; every surface color/lighting/shading knob is verified effective on both backends (incl. new lightdir; silent no-op keys removed). The "cut off bounding box" report was traced to two real rendering bugs, both fixed: 3-D scene artists were clipped at matplotlib's aspect-shrunk square viewport (now unclipped in every animation path), and animation GIFs saved at reduced dpi were corrupted by a matplotlib writer resize through the interactive-backend window (saves now dpi-safe). Morph animations use every dataset's full sample set (duplicate-to-largest, duplicates hidden at holds; hold frames are a 100% pixel match to static plots). GH #205 fixed: full multibyte (CJK) text support in both backends — automatic covering-font detection (excluding placeholder fonts like LastResort) + a font= kwarg (family/path/FontProperties) applied to labels/legends/colorbars/titles; plotly also gained real labels= annotations (was a silent no-op); CI provisions CJK fonts on all platforms with anti-tofu pixel tests.


7. Round 17 — every non-deferred open issue addressed (GH #103 #116 #123 #130 #138 #153 #154 #159 #161 #162 #174 #187 #198 #227 #273 #274 #275 #276 #277 #278)

Following the per-issue "current status (1.0 update)" triage, this round implements all 20 non-deferred open issues (the 5 marked defer/don't-implement were left untouched). Highlights: a unified cross-module API (every dispatcher takes manip=/normalize=/reduce=/align=/cluster=/return_model=, canonical order documented as a flowchart in docs/pipeline_order.rst) + public hyp.Pipeline with pipeline= reuse (#138 #153 #227 #161 #174); manip list-chaining and the story-trajectories animation — animate='window'/focused=/duration= (#274 #275, post-align inter-subject correlation −0.004→0.33, jumps 18.96→0.73); label_alpha=/axis labels/animate= dict/2-D animations (#103 #154 #123); six torch autoencoder reducers, sklearn/seaborn/538/kaggle loaders, LSL streaming, gensim text wrappers (#162 #273 #116 #130 #198, all opt-in extras); and a full docs pass — docstring coverage 131→0 with an enforcement test, complete api.rst, 7 new tutorials, regenerated README media (#276 #278 #159 #187 #277).

Executed as 19 review-gated tasks; the reviews caught and fixed five real reuse-contract bugs (silent pipeline refit, Aligner/SRM/Resample transform replaying fit-time data). Per-issue evidence comments (code + exact new API + numeric/screenshot proof) are posted on all 20 issues; see the round-17 summary comment for details.


Testing

  • Local suite: 1271 passed, 0 failed (+492 tests across all 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 x Python 3.10-3.13) on head 8c40499a -- run 28903254424
  • 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 30 commits July 9, 2026 13:25
…argin test

QC 2026-07 bug hunt (animation):
- HyperAnimation.save('x.svg')/('x.png') crashed (raw Animation.save piped h264
  into an svg/png), even though save_path='x.svg' works. .save() now routes
  through hypertools' extension-aware _save_animation dispatcher (gif/png/apng/
  svg/mp4/mov/avi) at the animation's own fps; an explicit writer= still
  delegates to matplotlib.
- animate with duration<=0 or frame_rate<=0 raised ZeroDivisionError / a cryptic
  "zero-size array" error; now a clear ValueError.
- FIXED a FALSE-POSITIVE test (test_wide_chemtrails_cube_corners_...): it failed
  on the fork point too. Root cause was in the TEST, not the render -- it measured
  margins by mixing _inked_mask's PHYSICAL buffer-pixel indices with the LOGICAL
  get_width_height() size (2x mismatch on HiDPI), yielding impossible margins like
  -512px on a "640px" canvas; and it asserted phantom projected cube corners that
  ignore set_box_aspect(zoom=1.125). The render is fully on-canvas (verified: >=118px
  ink margin across all 30 frames). The test now measures in the mask's own pixel
  space and asserts the rendered-ink extent.

New tests/test_animation_save_hardening.py. All 19 animation-margins tests green.

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

QC 2026-07 bug hunt (edge cases + polish):
- empty (0-row) input crashed cryptically inside sklearn ("The 'n_components'
  parameter ..."). format_data now raises a clear "input has no observations".
- predict(t<=0) silently returned an empty (0, n_features) forecast, and a float
  t gave a cryptic error. t is now validated (positive integer, OR a target
  datetime/Timestamp for a forecast-until-a-time horizon -- still supported).
- a streaming reduce spec read only the legacy 'params' key, so
  reduce={'model':'PCA','kwargs':{'whiten':True}} silently used defaults; it now
  accepts the canonical 'kwargs' key like every other dispatcher.
- the default text path (pretrained wiki topic model) emitted sklearn
  InconsistentVersionWarnings on every plot; the model's persisted state loads
  and transforms correctly across versions (verified numerically), so the
  known-safe load is now silenced narrowly.
- load() docstring: 'weights' is a list of 36 per-subject arrays, not 2
  (weights_avg is the 2-array averaged version).

New tests/test_edge_cases_hardening.py.

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

Red-team of ca13d05 (PPCA splice) found the splice regressed the primary
path: columns PPCA DROPS for having < min_obs observations were left NaN,
which then crashed hyp.reduce/hyp.plot with "Input X contains NaN" on
sparse-column data (the pre-splice reconstruction was dense). Now fill each
still-missing position with its column's observed mean (0.0 for an all-missing
column) so the imputed matrix is dense, while observed values stay exact and
fully-missing rows are still re-masked to NaN (documented limitation, pinned
by test_ppca_warns_and_leaves_nan_on_fully_missing_rows).

Also: single-column (< 2 valid columns) PPCA raised a cryptic LinAlgError
("0-dimensional array") from np.cov -> now a clear ValueError pointing at
Kalman/SimpleImputer/KNNImputer; and the reuse-path column-count check used
`assert cond, ValueError(...)` (raises AssertionError, stripped under -O) ->
proper raise. +2 regression tests.

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

Red-team of 3072545 found the documented analyze(cluster=...) label-recovery
path -- named_steps['cluster'].transform(returned_data) -- raised
NotImplementedError for the 3 hard clusterers with no out-of-sample predict
(DBSCAN, AgglomerativeClustering, SpectralClustering). analyze() itself was
fine (it returns the transformed data and recovers labels internally), but the
documented convenience recipe broke for those clusterers.

Now the cluster step's transform() returns the stored fit-time labels_ when it
is handed data with the same number of rows it was fit on (the recovery case) --
so recovery works for EVERY clusterer. Genuinely new data (a different row
count) still raises NotImplementedError, since a hard clusterer has no defined
labels for unseen points without refitting. Also corrected the analyze docstring
to say "pass the RETURNED transformed data" (not the raw input, whose feature
count differs after a reduce step) and to steer multi-dataset users to
cluster() directly for per-dataset labels. +6 regression cases.

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

Red-team of 7d71975 found the input-hardening fixes were incomplete:

- align({'model': 'Nope'}) (the DICT spec form) slipped past the bare-string
  "unknown align model" guard and still hit the cryptic AttributeError
  ("'str' object has no attribute 'fit_transform'"). Extracted the guard into
  _reject_unknown_aligner() and applied it on both the dict and bare-string
  paths.
- A 0-d ndarray (np.array(5)) has ndim 0, so the 1-D reshape left it untouched
  and it later raised an opaque "tuple index out of range" on i.shape[0]; a
  scalar is now one observation with one feature ((1, 1)), consistent with
  [5] -> (1, 1).
- A scalar hue (hue='red' or hue=5) became a 0-d array and was mis-measured as
  len('red') == 3 characters ("hue has 3 entries but data has 20"); it now
  broadcasts to one group per observation.
- The external PPCA EM loop was `while True` with no cap, so small/sparse or
  ill-conditioned NaN inputs (and a NaN convergence diff) could spin it forever
  (red-team saw >25s hangs). Added max_iter=500 + a non-finite-diff break with a
  clear "did not converge" warning; normal scattered-NaN data converges in far
  fewer iterations, so this only bounds genuinely degenerate fits.

+6 regression cases.

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

Red-team follow-ups (be0dcb5, edf6049):

- predict(t=...): np.True_ is np.bool_ (not Python bool) and 0-d numpy arrays
  (np.array(5)) both slipped past the horizon validation and hit a misleading
  "a datetime-like t requires a DatetimeIndex" message downstream. Now a 0-d
  array is normalized to its scalar and np.bool_ is rejected alongside bool.
- predict.common.resolve_t used `assert isinstance(index, DatetimeIndex),
  ValueError(...)` -> raised AssertionError and is stripped under `python -O`.
  Converted to a real raise.
- set_interactive_backend('Plotly') / plot(backend='Plotly') (capitalized) were
  treated as unknown mpl backends -- reproducing the exact HypertoolsBackendError
  the render routing exists to prevent. Render-backend detection and
  resolve_backend() now match case-insensitively and store the canonical
  lowercase preference.

+6 regression cases.

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

Evidence for the release-hardening pass and its independent red-team:
- evidence/hardening/{b2_1d_array,b2_flat_list,b5_align_mismatched_cols,
  k1_matplotlib}.png -- "after-fix, now works" screenshots (visually verified);
- evidence/hardening/RESULTS.txt -- numeric verification (observed-preservation
  1e-9, reduce dict+ndims matches sklearn, backend types, gif magic bytes);
- PR_EVIDENCE.md -- per-batch reproductions, red-team verdicts, fix rationale;
- pr_comment.md -- the PR #280 summary comment;
- gen_hardening_evidence.py -- reproducible evidence generator;
- BUGHUNT.md -- the confirmed-defect ledger.

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

A hue='d surface used to be painted ONE flat color -- the mean of all its
points' hue colors (e.g. gray for a rainbow hue), so the surface ignored WHERE
each color was (see before/after in notes/.../evidence/surface/). Now each mesh
vertex is colored by an inverse-distance-weighted (Shepard/IDW) blend of the
enclosed data points' colors, so the nearest coordinates dominate a vertex's
color and the surface matches the hue of the points it wraps.

- meshutil.vertex_colors_from_points(verts, points, point_colors): (V,3) IDW
  per-vertex RGB (weight 1/dist**2); face_colors_from_vertex_colors averages a
  triangle's three vertex colors for the matplotlib per-face path.
- _blinn_phong_shade already broadcasts a per-element base_rgb, so per-vertex
  (plotly) / per-face (matplotlib) colors flow through the existing lighting
  unchanged; _blend_toward_white is now array-aware.
- plot.py bundles each surfaced dataset's (points, per-point hue colors) as
  surface_point_colors and threads it to both backends' static 3D draw; None
  (no hue, or no surface) falls back to the prior flat surface color, so
  non-hue surfaces are unchanged.

Verified both backends (matplotlib screenshot; plotly Mesh3d vertexcolor now has
8740 distinct colors vs 1). +15 tests (IDW unit tests + spatial-correctness
integration for both backends). Full surface/mesh/plot suites: 163 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The story-trajectories gallery example ships an animated gif thumbnail
(docs/_static/thumbnails/sphx_glr_plot_story_trajectories_thumb.gif, 80 frames)
and sets sphinx_gallery_thumbnail_path to it, but post_build.py's
GIF_REPLACEMENTS map -- which swaps each animated example's static .png thumbnail
for its .gif in the built gallery HTML (run as a Read-the-Docs post_build job) --
never listed the story example. So on the live docs the story card showed a
frozen still frame, not the animation.

Added the story png->gif entry. Verified end-to-end against a local
`sphinx-build` + `post_build.py` run: auto_examples/index.html now references
sphx_glr_plot_story_trajectories_thumb.gif (0 remaining .png refs), the gif is
copied into _images/, and tutorials.html embeds it. The example script,
tutorial (docs/tutorials.rst, in the toctree), and source gif were already
correct; auto_examples/ build artifacts are regenerated from source by RTD, so
no committed-build change is needed.

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

Red-team of 8e6ed68 noted that when BOTH hue= and an explicit surface color=
(e.g. surface={'color': 'crimson'}) were given, the per-vertex hue coloring was
built unconditionally and silently overrode the explicit color. An explicit
color should win (principle of least surprise); hue only colors surfaces that
inherit their color (color=None). Now surface_point_colors is built only for
datasets whose surface spec has no explicit color, so an explicit color falls
back to the flat-color path. +1 regression test.

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

The story-gif red-team's completeness audit found a second frozen gallery
thumbnail: animate_surface_morph ends with a static-figure tweak (an alpha-fade
of the point layer), so sphinx-gallery thumbnailed that frozen frame as a png
instead of the morph animation -- and, unlike story, it had no shipped gif at
all. Ship a right-sized animated gif thumbnail
(docs/_static/thumbnails/sphx_glr_animate_surface_morph_thumb.gif, 90 frames,
524 KB, subsampled from the full 360-frame render), point the example's
sphinx_gallery_thumbnail_path at it, and register the png->gif swap in
post_build.py. Verified end-to-end: built index.html now references the gif
(0 png refs) and the gif is copied into _images/.

Added tests/test_docs_thumbnails.py to keep post_build.GIF_REPLACEMENTS and the
shipped _static/thumbnails/*.gif set in lockstep, so a shipped-but-unregistered
(or registered-but-missing) gif -- the exact defect behind both story and
surface_morph -- fails CI instead of silently freezing a gallery card.

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

- evidence/surface/{before,after}_mpl.png: surface goes from one flat mean color
  (gray for a rainbow hue) to per-vertex distance-weighted point colors.
- evidence/surface/story_gif_frame70.png: a frame of the animated story-
  trajectories thumbnail (confirms real animated content).
- pr_comment_2.md: the PR #280 evidence comment for both fixes + the
  surface_morph follow-up the red-team's completeness audit surfaced.

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

animate='spin' and 'serial' passed frame_rate*duration straight to
FuncAnimation as the frame count; with a fractional duration (e.g.
duration=2.5) or fractional frame_rate that product is a float, and matplotlib
does range(frames) -> "'float' object cannot be interpreted as an integer".
The parallel/window styles already used an int count (x[0].shape[0]); the
spin/serial (3-D and 2-D) paths and the morph total_frames now round to an int
too. Surfaced while regenerating the story-trajectories animation with a
non-integer duration. +regression tests (spin/serial/parallel, both backends).

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

Regenerated the story-trajectories demo per maintainer feedback. The old
version was choppy, slow, and -- most importantly -- the subjects' trajectories
did NOT move together, a real alignment failure. Root causes and fixes:

- ALIGN IN THE LOW-DIMENSIONAL SPACE. Hyperaligning a bare 3-D UMAP embedding
  barely improved inter-subject clustering. Now reduce to a ndims=10
  IncrementalPCA space and hyperalign THERE (n_iter=10), then show the first 3
  aligned dims. Measured with a scale-free within-timepoint dispersion (subjects'
  spread around their shared centroid per timepoint, / cloud scale),
  hyperalignment tightens the cloud ~18%: 0.88 (no align) -> 0.73 (aligned).
- INCREMENTALPCA, NOT UMAP. UMAP's nonlinear warping left trajectories jumpy:
  the largest per-step jump (normalized) is ~3.3 for UMAP vs ~0.37 for
  IncrementalPCA (~9x smoother) -- the difference between a choppy and a smooth
  animation (also strengthened smoothing to kernel_width=40).
- SMOOTHER + FASTER. animate='spin' over full trajectories (a spin's frame count
  is independent of the number of timepoints, so the 600-sample resample costs
  nothing), 9 s instead of 30 s.

(Plain inter-subject correlation is NOT used as the headline metric: a jumpy
UMAP embedding can score HIGH correlation while looking scattered/choppy, so
dispersion + smoothness are reported instead -- per the animation red-team.)

Updated the gallery example (docstring, exact code, three camera-angle stills),
tutorials.rst, the mp4 (6.9MB -> 2.0MB), the gallery gif thumbnail, and added
scripts/generate_story_trajectories.py so the assets are reproducible from a
committed, deterministic script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- evidence/story/story_trajectories_new.gif: the regenerated spinning animation
  (align in low-D IncrementalPCA space, n_iter=10).
- evidence/story/benchmark_reference.gif: the maintainer's quality reference.
- pr_comment_story.md: PR #280 comment with the scale-free dispersion +
  smoothness metrics (correlation retracted per the red-team) and the red-team
  verdict (ACHIEVES THE CORRECT ANIMATION).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Maintainer feedback: the trajectories were NOT well aligned -- a spaghetti
tangle instead of the reference's single coherent shared shape -- and the
reference uses a 'window' animation, not 'spin'.

ROOT CAUSE of the poor alignment: the previous version reduced each subject to
a 10-D IncrementalPCA space and hyperaligned THERE, then showed 3 dims.
Hyperalignment rotates each subject onto a shared response and needs room to do
it; 10 dims (let alone 3) starves it. The fix is to hyperalign in the full
100-hub feature space FIRST, then reduce to 3-D for display. Measured
within-timepoint dispersion (subjects' spread around a shared centroid per
timepoint, / cloud scale; lower = tighter): 0.73 (reduce-then-align, old) ->
0.51 (align-in-hub-then-reduce). The 100-hub space IS the low-dimensional space
to align in (it already summarizes ~hundreds of thousands of voxels); the
mistake was over-reducing further before aligning.

Also switched animate='spin' -> animate='window' (focused=2.5): a sliding trail
traverses each aligned trajectory so all 36 subjects are seen moving together
through the story, matching the reference style.

Updated the example (docstring + exact code + three story-moment stills),
tutorials.rst, the mp4, gallery gif thumbnail, and the deterministic
scripts/generate_story_trajectories.py. Pipeline is deterministic (verified
max|delta|=0 across runs).

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

cluster(X, cluster='KMeans', reduce='PCA', manip='ZScore', return_model=True) --
with no ndims -- returned a Pipeline whose reduce STEP was never marked fitted,
so p.transform(X) / reusing p (cluster(Y, cluster=p, ...)) crashed
NotFittedError: "reduce stage must be fit before transform". Root cause:
hyp.reduce(x, reduce='PCA', ndims=None) (or ndims >= n_features) is a legitimate
no-op and returns model=None; _DispatchStep used `_fitted is None` to mean "never
fit", conflating it with "fit to an identity". A no-op stage now records that
fit_transform ran (_is_fit) and, on transform, passes data through unchanged
instead of raising. Affects any build_pipeline stage that fits to no model
(reduce/normalize no-ops). reduce(..., return_model=True) was already fine; this
brings cluster/analyze into line. +2 regression tests.

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

Per-point labels= in an animation were created once as static annotations and
left visible on EVERY frame (a documented "known limitation" punted in prior
sessions): the label sat at its frame-0 screen position the whole time,
regardless of whether its datapoint was currently drawn or where the rotating
camera had moved it. Now each label records its within-dataset point index
(annotate_plot/add_labels), and a per-frame _sync_anim_labels():
 - shows a label ONLY while its datapoint is inside the current drawn window
   ([num - window_frames, num]) for 'window'/'parallel'/'serial';
 - keeps labels shown for 'spin' (every point is always drawn);
 - reprojects every visible label for the current (rotated) camera.
Static plots are unchanged (labels always visible). The module-global
labels_and_points is looked up safely and filtered to the current axes, so
label-free animations (and back-to-back plots) don't crash or show a prior
plot's labels. Verified numerically (label visibility scrolls with the window)
and with rendered frames. +2 regression tests.

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

Stochastic stages (UMAP/TSNE/MDS reductions, KMeans/GaussianMixture clustering)
were only reproducible through the verbose dict spec
reduce={'model':'UMAP','kwargs':{'random_state':1}}. Added a top-level
`random_state=` to reduce(), cluster(), analyze() and plot(): it is injected
into any stage model whose constructor accepts a `random_state` (checked via
inspect.signature), so `hyp.plot(x, reduce='UMAP', random_state=0)` and
`hyp.cluster(x, cluster='KMeans', random_state=0)` are repeatable. Deterministic
models (PCA/IncrementalPCA), density clusterers (DBSCAN/HDBSCAN/...) without a
random_state param, and already-constructed instances are left untouched; an
explicit random_state in a dict spec's kwargs still wins. Threaded through
build_pipeline so the cross-module (analyze/plot) pipeline reduce+cluster stages
are reproducible too. Documented on reduce() and plot(). +7 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Red-team of 52bcff8 found that fix only wired the per-frame label sync into 3-D
'parallel' and 'spin'; 3-D 'serial', ALL 2-D animations, and 'morph' still drew
per-point labels on every frame (and the docstring/commit over-claimed 'serial').
Completed it:
 - 3-D and 2-D 'serial': labels reveal CUMULATIVELY -- a label appears once its
   GLOBAL index (across concatenated datasets) has been revealed and then stays;
   labels now store _hyp_global_idx as well as the within-dataset _hyp_point_idx.
 - 2-D 'window'/'parallel': same head-window logic as 3-D.
 - 'morph' (2-D and 3-D): the single traveling cloud does not correspond to the
   original labeled points, so per-point labels are hidden for the morph.
_sync_anim_labels now takes explicit revealed=/hide_all= modes and its docstring
describes every style accurately. +4 regression tests (serial multi-dataset
global-index mapping, 2-D window, morph-hidden).

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

The animation red-team confirmed the alignment fix (subjects move together) but
noted the render was still a dense muddy knot vs the reference's legible shape.
Tuned the visuals: shorter window (focused 2.5 -> 1.5), translucent (alpha 0.55)
and thinner (lw 1.3) lines so the individual aligned ribbons show through
instead of blending into a blob. Regenerated the mp4, stills, and gif thumbnail
from the (updated) deterministic generation script.

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

Directly compared against the reference hypertools.gif: it uses OPAQUE, bold,
saturated lines, and because the subjects are tightly aligned the overlapping
ribbons read as one coherent shape. The previous translucent thin lines
(alpha 0.55) instead blurred into haze -- moving AWAY from the reference. Went
opaque + bold (alpha 0.85, lw 1.6); the short window (focused=1.5 s -> 45 of
300 frames) is what keeps the overlap legible, not transparency. Regenerated
mp4/stills/thumbnail.

Also reconciled the dispersion figures to a single canonical, deterministic
computation on the displayed 3-D coords (0.78 unaligned / 0.75 reduce-3D-then-
align / 0.64 reduce-10D-then-align / 0.47 align-in-hub-then-reduce), replacing
the earlier ~0.73->~0.51 estimates in the example and generation script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second animation red-team confirmed the window style matches but flagged the
gist_rainbow palette as gaudier/more cartoonish than the reference and its
individual strands as popping out of the bundle. Switched to seaborn's 'husl'
palette -- the classic HyperTools palette of evenly-spaced but tempered hues
(magenta/teal/gold/slate/coral), matching the reference hypertools.gif.

Verified numerically that the alignment itself is already tight and correct
(not the cause of the visual "outlier" loops): hyp.reduce on a list uses a
SHARED projection (dispersion 0.466 vs 0.464 for a hand-fit shared PCA), and
every subject's trajectory mean sits within 0.04*scale of the grand centroid.
The occasional loop outside the bundle is one subject's momentary window
segment, not a displaced/misaligned subject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR-comment draft with the story-render match (opaque bold husl vs reference),
the 3 code fixes, verified dispersion table, both red-team verdicts (story:
MATCHES REFERENCE after 3 passes; code: FIX1/3 SOLID, FIX2 completed), and the
final CI result (1485 passed / 0 failed / 4 skipped / 7 deselected). Evidence
gif regenerated from the husl mp4.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
examples/plot_hue.py (the original author's example) passes hue as one sub-list
per dataset — the classic list-of-lists form matching a list-of-datasets input.
The 1.0 hue validation flattened the DATA to n_obs but read np.asarray(hue) on
the (n_datasets, len) sublists as a 2-D matrix hue, so it raised "hue has 3
entries but the data has 900 observations". Now a nested hue whose top level
matches the number of datasets and whose sub-sequences match each dataset's
length is flattened to one value (or matrix row) per observation before
classification; genuinely flat / (n_obs, k) matrix hues are untouched, and a
nested hue with mismatched sub-lengths still raises (no silent truncation).

+4 regression tests (nested-scalar, nested-matrix, flat/matrix-unaffected,
wrong-length-still-errors) and a docstring note. Found by directly executing
all 53 gallery examples.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All 53 example scripts + 10 core tutorial notebooks executed end-to-end;
records the plot_hue nested-hue fix and the animate_plotly known-limitation
finding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drops notes/fix-qc-notes-2026-07/ (BUGHUNT/RESUME/PR_EVIDENCE, per-fix evidence
screenshots + gifs, triage repro scripts, PR-comment drafts) -- review-only
artifacts that shouldn't land in dev-1.0-refactor. All code fixes, tests,
examples, docs, and asset-generation scripts are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
QC-notes fixes for HyperTools 1.0 (do not merge — for review)
CI intermittently failed test_load_538_multi_csv_returns_dict on a transient
"502 Bad Gateway" from api.github.com (a shared CI runner-IP pool hitting the
API concurrently), on both the folder-listing call and the authenticated
per-CSV fetch. A 502/503/504 is a server-side blip, not a client error, and
clears within a second or two -- so the loader now retries transient gateway
errors (and connection-level RequestExceptions) with exponential backoff via
a new _github_get_with_retry helper, used at both call sites. Non-transient
responses (2xx/404/403-rate-limit) still return immediately, so healthy calls
and every error message are unchanged.

Tested with a REAL local loopback HTTP server (a real socket, not a mock)
that returns 502 a few times then 200: one test proves the retry recovers,
another that a persistent 502 is returned after the retries are spent so the
caller's existing HypertoolsIOError/raise_for_status still fires.

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