diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 15e7d963..e69de29b 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -1,16 +0,0 @@
-
-## Notes (skill-compounder)
-
-- **2026-09-03** Executing a docs/tutorials launch notebook locally runs its Colab '%pip install ... git+...@dev-1.0' cell, which overwrites the venv's editable hypertools with the stale REMOTE branch mid-run (seen 2026-09-03 as '48 dimensions ... static plots support at most 2'). scripts/execute_tutorial.py now tags 'pip install' cells skip-execution in memory; after any other notebook run, check 'pip show hypertools' says Editable, and 'pip install -e .[dev]' if not.
-- **2026-09-03** Regenerating a docs/tutorials notebook from its example script (Plan 4 pattern, used 5x on 2026-09-03): generate cells from the script's section markers with a scratchpad script (install cell carried over byte-identical), never leave a HyperAnimation as a cell's last expression (its repr embeds an 89 KB video), execute with scripts/execute_tutorial.py (skips pip-install cells, disables HF progress bars), save the GIF at <=15 fps / ~300 frames (a 900-frame GIF is 13 MB), record the measured visible-output set in tests/test_examples_are_native.py, re-measure the budget once, commit script+notebook+GIF+gate together. Gallery pages for show=False examples need the HyperAnimation scraper in docs/conf.py.
-- **2026-09-03** When a push will trigger hosted CI, commit the session-note/memory update BEFORE the push (seen twice on 2026-09-03: note committed right after each push, so every CI cycle had to be re-run or cancelled on the note-only head; PR #283).
-- **2026-09-03** Bluesky launch thread (notes/bluesky-launch, gitignored): re-verify atproto lexicon limits with curl before posting (video cap moved 100->300 MB between July and Sept 2026), count post graphemes with the regex module's \X (not len), render clips from the examples' construct_artifact() via the scratchpad render script, and expect tutorial 'Full code' links to 404 until RTD builds master.
-- **2026-09-03** Yahoo v8 chart 'range=max&interval=1d' silently returns 3-MONTH bars (AAPL: 169 rows since 1984); pass explicit period1/period2 epoch bounds to get daily. SEC XBRL needs a User-Agent with a contact (the project's pyproject email); 'companyconcept' can be EMPTY for a filer (ABT, KO) whose 'companyfacts' has the concept (measured 2026-09-03).
-- **2026-09-03** Tutorial notebooks are GENERATED (scripts/generate_tutorial_notebook.py) then executed (scripts/execute_tutorial.py); after changing SPECS (dpi, prose) ALWAYS regenerate before re-executing -- on 2026-09-04 a dpi revert was made in the generator only, and two 10-minute re-executions wrote the stale dpi. Video size is CRF-bound now (was a fixed 1800 kbit/s), so dpi does not trade size.
-- **2026-09-04** After a library behaviour change (new error text, a warning removed, a new return type), grep docs/tutorials/*.ipynb for prose or stored outputs demonstrating the OLD behaviour and re-execute those notebooks (2026-09-04: io.ipynb taught hyp.load(df) as a TypeError after load gained passthrough; align/reduce stored the glyph warning; lsl_streaming stored the liblsl ERR line).
-- **2026-09-05** When the finding IS that a file/match is absent, chain ls/grep with '|| true' (or test -e) so the expected exit 1 is not logged as a tool failure
-- **2026-09-05** Guard sed -n "$((n-12)),..." on a possibly-empty $n: an empty grep result makes sed see '-12' as an option; use grep -B/-A context instead
-- **2026-09-05** A 'clean' docs build (sphinx -W -E -a) does NOT remove sphinx-gallery's generated docs/auto_examples/ (gitignored): after deleting or renaming an example, rm -rf docs/auto_examples first or -W fails on 'document isn't included in any toctree' for the stale pages (2026-09-05, after the gallery consolidation).
-- **2026-09-05** Subagent dispatch prompts must state the no-mocks rule verbatim ('no mock objects, no monkeypatching library functions as spies; prove behaviour with real observables'): on 2026-09-05 an agent verified 'never touches the network' by monkeypatching requests.get and seaborn_dataset; replaced with mtime/.part/uncached-URL-raises observables.
-- **2026-09-05** Never assert absolute font-metric numbers (probe heights, figure inches, pixel rows) in tests: CI's matplotlib (3.11.1) hints text differently from the local 3.10.8 -- 2026-09-05 two new tests failed on every CI job while green locally; assert against the library's own probe on the same axes, or relative tolerances.
-
diff --git a/.claude/compound/lessons/bluesky-launch-thread/SKILL.md b/.claude/compound/lessons/bluesky-launch-thread/SKILL.md
new file mode 100644
index 00000000..36f009fd
--- /dev/null
+++ b/.claude/compound/lessons/bluesky-launch-thread/SKILL.md
@@ -0,0 +1,10 @@
+---
+name: bluesky-launch-thread
+description: Use when preparing or posting the Bluesky launch thread (notes/bluesky-launch, gitignored).
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Re-verify atproto lexicon limits with curl before posting (the video cap has moved, 100 to
+300 MB). Count post graphemes with the regex module's \X, not len. Render clips from the
+examples' construct_artifact() via the scratchpad render script. Expect tutorial "Full
+code" links to 404 until Read the Docs builds master.
diff --git a/.claude/compound/lessons/commit-notes-before-push/SKILL.md b/.claude/compound/lessons/commit-notes-before-push/SKILL.md
new file mode 100644
index 00000000..3d0b27fb
--- /dev/null
+++ b/.claude/compound/lessons/commit-notes-before-push/SKILL.md
@@ -0,0 +1,8 @@
+---
+name: commit-notes-before-push
+description: Use when about to push a branch that triggers hosted CI and a session-note or memory update is still uncommitted.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Commit the session-note or memory update BEFORE the push. A note committed right after the
+push moves the head to a note-only commit, and every CI cycle has to be re-run or cancelled.
diff --git a/.claude/compound/lessons/datatype-gate-no-raw-isinstance/SKILL.md b/.claude/compound/lessons/datatype-gate-no-raw-isinstance/SKILL.md
new file mode 100644
index 00000000..545e1076
--- /dev/null
+++ b/.claude/compound/lessons/datatype-gate-no-raw-isinstance/SKILL.md
@@ -0,0 +1,15 @@
+---
+name: datatype-gate-no-raw-isinstance
+description: Use when writing or delegating hypertools library code that checks whether an input is a DataFrame, Series or array.
+created: 2026-10-09
+origin: project hypertools, session f69d921d
+---
+Classify inputs with the shared helpers in hypertools/_shared/helpers.py
+(is_frame_dataset, is_series_like, is_array_dataset, as_pandas_dataframe), never with
+`isinstance(x, pd.DataFrame)` or `isinstance(x, pd.Series)`. tests/test_datatype_gate.py
+scans the package and fails with "datatype check(s) outside the shared coercion layer"
+for each raw check, so polars and other frames are treated like pandas ones.
+An agent's targeted test run usually does not include that file, and the failure then
+shows up only in the full suite: put `tests/test_datatype_gate.py` in the test list of
+any prompt that adds input handling. To tell a row-labelled pandas Series from a polars
+one, use `is_series_like(x)` plus a non-callable `x.index`.
diff --git a/.claude/compound/lessons/docs-site-check-every-page/SKILL.md b/.claude/compound/lessons/docs-site-check-every-page/SKILL.md
new file mode 100644
index 00000000..85e4f6c8
--- /dev/null
+++ b/.claude/compound/lessons/docs-site-check-every-page/SKILL.md
@@ -0,0 +1,18 @@
+---
+name: docs-site-check-every-page
+description: Use when asked to build the hypertools Sphinx / Read the Docs site and confirm it looks correct, or when reviewing built docs pages in a browser.
+created: 2026-10-09
+origin: project hypertools, session f69d921d
+---
+Check EVERY built page with a scripted browser, never a hand-picked list. Build the RTD
+way (from docs/: READTHEDOCS=True READTHEDOCS_GIT_IDENTIFIER=master READTHEDOCS_OUTPUT=$OUT
+sphinx -T -b html -d _build/doctrees . $OUT/html, then python docs/post_build.py), serve
+$OUT/html with `python -m http.server`, and run check_built_site.py (beside this lesson;
+playwright is in .venv; see also scripts/verify_docs_playwright.py). It reads each page's
+::before/::after content for ERROR/WARNING, broken images, console errors, leaked RST and
+overflow at 1400px and 400px, and saves screenshots to look at.
+Why: a sampled review passed the site while docs/hierarchy.html showed furo's red
+"ERROR: Adding a table of contents" box from a `.. contents::` directive. That box is CSS
+::before text, so sphinx -W (0 warnings), HTML text scans and link checkers cannot see it.
+Before trusting 0 findings, plant a defect on a temporary copy of a page and confirm the
+checker reports it.
diff --git a/.claude/compound/lessons/docs-site-check-every-page/check_built_site.py b/.claude/compound/lessons/docs-site-check-every-page/check_built_site.py
new file mode 100644
index 00000000..305d34c5
--- /dev/null
+++ b/.claude/compound/lessons/docs-site-check-every-page/check_built_site.py
@@ -0,0 +1,97 @@
+import json
+import os
+import sys
+from playwright.sync_api import sync_playwright
+
+# usage: python check_built_site.py HTML_DIR BASE_URL OUT_DIR (serve HTML_DIR first: python -m http.server)
+ROOT = sys.argv[1]
+BASE = sys.argv[2].rstrip("/") + "/"
+OUT = sys.argv[3]
+os.makedirs(OUT, exist_ok=True)
+pages = sorted(
+ os.path.relpath(os.path.join(d, f), ROOT)
+ for d, _, fs in os.walk(ROOT)
+ for f in fs
+ if f.endswith(".html") and not d.startswith(os.path.join(ROOT, "_"))
+)
+shots = [
+ p
+ for p in pages
+ if (
+ ("/" not in p and not p.startswith("hypertools."))
+ or p.startswith("tutorials/")
+ or p == "auto_examples/index.html"
+ )
+]
+shots += [
+ "hypertools.plot.html",
+ "hypertools.predict.html",
+ "hypertools.describe.html",
+ "hypertools.io.LSLStream.html",
+ "hypertools.HyperAnimation.html",
+]
+JS = """() => {
+ const out={};
+ out.broken=[...document.images].filter(i=>i.complete&&i.naturalWidth===0).map(i=>i.getAttribute('src')).slice(0,5);
+ out.overflow=document.documentElement.scrollWidth-document.documentElement.clientWidth;
+ const pseudo=[]; for(const el of document.querySelectorAll('article *')){ for(const ps of ['::before','::after']){ const c=getComputedStyle(el,ps).content; if(c&&/ERROR|WARNING|unnecessary/i.test(c)) pseudo.push(el.tagName+'.'+el.className+': '+c.slice(0,80)); } }
+ out.pseudo=pseudo.slice(0,3);
+ out.sysmsg=document.querySelectorAll('.system-message, .problematic').length;
+ out.videos=[...document.querySelectorAll('video')].length;
+ const txt=[...document.querySelectorAll('article p, article li, article dd, article dt, article h1, article h2, article h3')].map(e=>{const c=e.cloneNode(true); c.querySelectorAll('code,pre,.math,script,style').forEach(x=>x.remove()); return c.textContent;}).join('\\n');
+ out.leaks=(txt.match(/[^\\n]{0,30}(``|:[a-z]+:`|\\.\\. [a-z-]+::)[^\\n]{0,30}/g)||[]).slice(0,3);
+ return out; }"""
+res = {}
+with sync_playwright() as pw:
+ b = pw.chromium.launch()
+ for theme in ["light"]:
+ ctx = b.new_context(viewport={"width": 1400, "height": 1800})
+ pg = ctx.new_page()
+ errs = []
+ pg.on(
+ "console",
+ lambda m: errs.append(m.text[:160]) if m.type == "error" else None,
+ )
+ pg.on("pageerror", lambda e: errs.append("PAGEERROR " + str(e)[:160]))
+ for p in pages:
+ errs.clear()
+ try:
+ pg.goto(BASE + p, wait_until="load", timeout=60000)
+ pg.wait_for_timeout(400)
+ except Exception as e:
+ res[p] = {"goto": str(e)[:100]}
+ continue
+ r = pg.evaluate(JS)
+ r["console"] = sorted(set(errs))[:4]
+ res[p] = r
+ if p in shots:
+ pg.screenshot(path=os.path.join(OUT, p.replace("/", "__") + ".png"))
+ # phone width overflow on every page
+ ctx = b.new_context(viewport={"width": 400, "height": 800})
+ pg = ctx.new_page()
+ for p in pages:
+ try:
+ pg.goto(BASE + p, wait_until="load", timeout=60000)
+ pg.wait_for_timeout(250)
+ res[p]["phone_overflow"] = pg.evaluate(
+ "()=>document.documentElement.scrollWidth-document.documentElement.clientWidth"
+ )
+ except Exception as e:
+ res[p]["phone"] = str(e)[:80]
+ b.close()
+json.dump(res, open(os.path.join(OUT, "results.json"), "w"), indent=1)
+bad = {
+ p: r
+ for p, r in res.items()
+ if r.get("broken")
+ or r.get("overflow", 0) > 2
+ or r.get("pseudo")
+ or r.get("sysmsg")
+ or r.get("leaks")
+ or r.get("console")
+ or r.get("phone_overflow", 0) > 2
+ or "goto" in r
+}
+print(len(res), "pages checked;", len(bad), "with findings")
+for p, r in bad.items():
+ print(p, {k: v for k, v in r.items() if v and k not in ("videos",)})
diff --git a/.claude/compound/lessons/figure-qa-three-separated-agents/SKILL.md b/.claude/compound/lessons/figure-qa-three-separated-agents/SKILL.md
new file mode 100644
index 00000000..6d14db34
--- /dev/null
+++ b/.claude/compound/lessons/figure-qa-three-separated-agents/SKILL.md
@@ -0,0 +1,13 @@
+---
+name: figure-qa-three-separated-agents
+description: Use when reviewing the figures of a hypertools notebook or the feature tour for correctness.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Use three SEPARATED agents: one writes each figure's EXPECTED appearance from the notebook
+code, prose and docstrings without seeing images; one describes the OBSERVED renders
+without seeing code; one adjudicates with probes. This finds gaps solo eyeballing misses
+(ax= palette ignored, z-label outside the tight bbox, a 2-D frame on the data, faded
+recoloured forecasts). Render plotly outputs from their notebook JSON with kaleido (the
+scratchpad review/manifest and tour_out/extract.py pattern), and re-run only the changed
+figures each round.
diff --git a/.claude/compound/lessons/forecast-index-is-not-dataset-index/SKILL.md b/.claude/compound/lessons/forecast-index-is-not-dataset-index/SKILL.md
new file mode 100644
index 00000000..46fa7968
--- /dev/null
+++ b/.claude/compound/lessons/forecast-index-is-not-dataset-index/SKILL.md
@@ -0,0 +1,12 @@
+---
+name: forecast-index-is-not-dataset-index
+description: Use when writing hypertools code that indexes schedules, lines or runs by a forecast.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Forecast index != dataset index. A predict=[...] collection's forecasts are MODEL-MAJOR
+(forecast i = model i//n_datasets, dataset i%n_datasets), and hue=/cluster= regrouping
+makes RUN index != dataset index. Any code that touches a forecast must translate through
+_model_forecast_owner / forecast_datasets (forecast -> source dataset) and _forecast_owner
+/ _seg_ds (dataset -> final run) before indexing. The bug hides when the counts coincide
+and shows as an IndexError in animated reveal lookups or wrong plotly hyp_dataset tags.
diff --git a/.claude/compound/lessons/global-setting-as-context-manager/SKILL.md b/.claude/compound/lessons/global-setting-as-context-manager/SKILL.md
new file mode 100644
index 00000000..c0631de8
--- /dev/null
+++ b/.claude/compound/lessons/global-setting-as-context-manager/SKILL.md
@@ -0,0 +1,12 @@
+---
+name: global-setting-as-context-manager
+description: Use when changing hyp.set_autoinstall or adding another process-global setting that is both a direct call and a context manager.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Model it as LIVE handle records (weakrefs, in construction order) plus a BASELINE folded
+in by the weakref callback of a handle that dies unentered; take the newer of the top
+record and the baseline by call order; mark exited records finished. The failure modes are
+a restore-order race, retained handles, and a construct-then-enter race across threads.
+Test overlapping blocks across threads, construct-then-enter interleaving, 100k discarded
+direct calls (tracemalloc), and a block handle dying after exit.
diff --git a/.claude/compound/lessons/legend-marker-on-line-artist/SKILL.md b/.claude/compound/lessons/legend-marker-on-line-artist/SKILL.md
new file mode 100644
index 00000000..202ad87b
--- /dev/null
+++ b/.claude/compound/lessons/legend-marker-on-line-artist/SKILL.md
@@ -0,0 +1,9 @@
+---
+name: legend-marker-on-line-artist
+description: Use when a hypertools draw path puts a line's markers on a separate artist (fmt split, truth= overlay).
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+A line whose markers are drawn on a SEPARATE artist shows a marker-less legend glyph. Keep
+marker= on the line artist with markevery=[] so the legend handle carries it, and check
+every marker-splitting draw path for this.
diff --git a/.claude/compound/lessons/lsl-test-unique-stream-type/SKILL.md b/.claude/compound/lessons/lsl-test-unique-stream-type/SKILL.md
new file mode 100644
index 00000000..e2b6ac3a
--- /dev/null
+++ b/.claude/compound/lessons/lsl-test-unique-stream-type/SKILL.md
@@ -0,0 +1,10 @@
+---
+name: lsl-test-unique-stream-type
+description: Use when writing or debugging a hypertools LSL test that resolves a stream by type.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+A test that resolves by type='EEG' fails whenever ANOTHER process advertises an idle EEG
+outlet (a notebook kernel running the tutorial's synthetic outlet), because lsl_stream()
+takes the first match. Give a test outlet a unique stream TYPE, as with the unique names,
+and check pylsl.resolve_streams() for foreign outlets before blaming the library.
diff --git a/.claude/compound/lessons/no-absolute-font-metrics-in-tests/SKILL.md b/.claude/compound/lessons/no-absolute-font-metrics-in-tests/SKILL.md
new file mode 100644
index 00000000..b1210276
--- /dev/null
+++ b/.claude/compound/lessons/no-absolute-font-metrics-in-tests/SKILL.md
@@ -0,0 +1,10 @@
+---
+name: no-absolute-font-metrics-in-tests
+description: Use when writing a hypertools test that asserts text or figure measurements.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Never assert absolute font-metric numbers (probe heights, figure inches, pixel rows). CI's
+matplotlib (3.11.1) hints text differently from the local 3.10.8, so such tests pass
+locally and fail on every CI job. Assert against the library's own probe on the same axes,
+or use relative tolerances.
diff --git a/.claude/compound/lessons/notebook-pip-cell-overwrites-editable/SKILL.md b/.claude/compound/lessons/notebook-pip-cell-overwrites-editable/SKILL.md
new file mode 100644
index 00000000..93ac8bd4
--- /dev/null
+++ b/.claude/compound/lessons/notebook-pip-cell-overwrites-editable/SKILL.md
@@ -0,0 +1,11 @@
+---
+name: notebook-pip-cell-overwrites-editable
+description: Use when executing a docs/tutorials notebook locally in hypertools.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+A launch notebook's Colab cell `%pip install ... git+...@dev-1.0` overwrites the venv's
+editable hypertools with the stale REMOTE branch mid-run (symptom: "48 dimensions ...
+static plots support at most 2"). Execute with scripts/execute_tutorial.py, which tags
+'pip install' cells skip-execution in memory. After any other notebook run, check that
+`pip show hypertools` says Editable, and run `pip install -e .[dev]` if not.
diff --git a/.claude/compound/lessons/pin-matplotlib-backend-for-colab/SKILL.md b/.claude/compound/lessons/pin-matplotlib-backend-for-colab/SKILL.md
new file mode 100644
index 00000000..62547d8c
--- /dev/null
+++ b/.claude/compound/lessons/pin-matplotlib-backend-for-colab/SKILL.md
@@ -0,0 +1,10 @@
+---
+name: pin-matplotlib-backend-for-colab
+description: Use when hypertools code or a notebook uses a matplotlib-only return API (fig.axes, .canvas, HyperAnimation .draw_frame/.on_frame/.n_frames, an internal hyp.plot call).
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Colab and Kaggle auto-select the PLOTLY render backend, so matplotlib-only return APIs
+break there while passing locally (plot_stream's head plot, the tour's animation clock,
+launch notebooks). Pin backend='matplotlib' at such call sites and test them under
+hyp.set_interactive_backend('plotly'), the same preference Colab sets.
diff --git a/.claude/compound/lessons/plotly-scene-sized-by-height/SKILL.md b/.claude/compound/lessons/plotly-scene-sized-by-height/SKILL.md
new file mode 100644
index 00000000..67c8c618
--- /dev/null
+++ b/.claude/compound/lessons/plotly-scene-sized-by-height/SKILL.md
@@ -0,0 +1,13 @@
+---
+name: plotly-scene-sized-by-height
+description: Use when sizing or calibrating a Plotly 3-D scene or its camera distance in hypertools.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Plotly sizes a 3-D scene by its domain HEIGHT only and clips at the sides: the same
+267x209 px cube appears in 600x300 and 1200x300 scenes, and a 300x600 scene's cube is 415
+tall, cut at 300 wide. At hypertools' default eye the cube is 0.89 x height wide and 0.70
+x height tall, and apparent size goes as 1/eye-distance only when backing OFF (an eye
+nearer than about 0.8x clips the front). Name a calibration constant by its reference:
+SCENE_CUBE_WIDTH_PER_HEIGHT = 1.4 means width per CUBE height, and reading it as width per
+SCENE height backs the camera off 1.4x in every square panel cell.
diff --git a/.claude/compound/lessons/readme-is-lowercase/SKILL.md b/.claude/compound/lessons/readme-is-lowercase/SKILL.md
new file mode 100644
index 00000000..673fe98b
--- /dev/null
+++ b/.claude/compound/lessons/readme-is-lowercase/SKILL.md
@@ -0,0 +1,9 @@
+---
+name: readme-is-lowercase
+description: Use when a hypertools test or script opens the repository readme by path.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+The repository's readme is lowercase `readme.md`. A test that opens 'README.md' passes on
+macOS (case-insensitive) and fails on every Linux CI job. Check the tracked file name with
+`git ls-files` before hard-coding a path in a test.
diff --git a/.claude/compound/lessons/regenerate-before-reexecute/SKILL.md b/.claude/compound/lessons/regenerate-before-reexecute/SKILL.md
new file mode 100644
index 00000000..82023d98
--- /dev/null
+++ b/.claude/compound/lessons/regenerate-before-reexecute/SKILL.md
@@ -0,0 +1,10 @@
+---
+name: regenerate-before-reexecute
+description: Use when changing SPECS (dpi, prose) for the generated tutorial notebooks in hypertools.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Tutorial notebooks are GENERATED (scripts/generate_tutorial_notebook.py) and then executed
+(scripts/execute_tutorial.py). After changing SPECS, ALWAYS regenerate before
+re-executing; otherwise a 10-minute re-execution writes the stale value. Video size is
+CRF-bound (not a fixed bitrate), so dpi does not trade against size.
diff --git a/.claude/compound/lessons/regenerate-tutorial-notebook/SKILL.md b/.claude/compound/lessons/regenerate-tutorial-notebook/SKILL.md
new file mode 100644
index 00000000..b54f92b5
--- /dev/null
+++ b/.claude/compound/lessons/regenerate-tutorial-notebook/SKILL.md
@@ -0,0 +1,15 @@
+---
+name: regenerate-tutorial-notebook
+description: Use when regenerating a docs/tutorials notebook from its example script in hypertools.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+1. Generate cells from the script's section markers with a scratchpad script; carry the
+ install cell over byte-identical.
+2. Never leave a HyperAnimation as a cell's last expression (its repr embeds an 89 KB video).
+3. Execute with scripts/execute_tutorial.py (skips pip-install cells, disables HF progress bars).
+4. Save the GIF at <=15 fps and about 300 frames (a 900-frame GIF is 13 MB).
+5. Record the measured visible-output set in tests/test_examples_are_native.py and
+ re-measure the budget once.
+6. Commit script, notebook and GIF and the gate together.
+Gallery pages for show=False examples need the HyperAnimation scraper in docs/conf.py.
diff --git a/.claude/compound/lessons/release-verification-pipeline/SKILL.md b/.claude/compound/lessons/release-verification-pipeline/SKILL.md
new file mode 100644
index 00000000..2703e439
--- /dev/null
+++ b/.claude/compound/lessons/release-verification-pipeline/SKILL.md
@@ -0,0 +1,11 @@
+---
+name: release-verification-pipeline
+description: Use when running the full hypertools release verification (notebook re-execution, full pytest, sphinx -W gallery, example smoke gate).
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+The pipeline runs about 50 minutes, past the Bash tool's 10-minute cap. Write it as a
+scratchpad zsh script with step markers, launch it with nohup, and poll the log for the
+DONE marker from run_in_background watchers (each at most 9.5 minutes). After any sphinx
+build, `git checkout` the two autosummary stubs FrameContext and LSLStream, which are
+regenerated with whitespace-only changes.
diff --git a/.claude/compound/lessons/relpath-strings-use-forward-slash/SKILL.md b/.claude/compound/lessons/relpath-strings-use-forward-slash/SKILL.md
new file mode 100644
index 00000000..58463d70
--- /dev/null
+++ b/.claude/compound/lessons/relpath-strings-use-forward-slash/SKILL.md
@@ -0,0 +1,9 @@
+---
+name: relpath-strings-use-forward-slash
+description: Use when a hypertools test compares repo-relative paths as strings (allowlists, rosters, scanner findings).
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Build such paths with `os.path.relpath(...).replace(os.sep, '/')`. Otherwise the test
+passes on macOS and Linux and fails every Windows job on 'docs\tutorials\align.ipynb'
+against 'docs/tutorials/align'.
diff --git a/.claude/compound/lessons/remove-auto-examples-before-docs-build/SKILL.md b/.claude/compound/lessons/remove-auto-examples-before-docs-build/SKILL.md
new file mode 100644
index 00000000..9e4c8620
--- /dev/null
+++ b/.claude/compound/lessons/remove-auto-examples-before-docs-build/SKILL.md
@@ -0,0 +1,10 @@
+---
+name: remove-auto-examples-before-docs-build
+description: Use when building hypertools docs with sphinx -W after deleting or renaming a gallery example.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+A "clean" docs build (sphinx -W -E -a) does NOT remove sphinx-gallery's generated
+docs/auto_examples/ (gitignored). After deleting or renaming an example, `rm -rf
+docs/auto_examples` first, or -W fails on "document isn't included in any toctree" for the
+stale pages.
diff --git a/.claude/compound/lessons/stale-notebook-prose-after-behaviour-change/SKILL.md b/.claude/compound/lessons/stale-notebook-prose-after-behaviour-change/SKILL.md
new file mode 100644
index 00000000..2ba8a484
--- /dev/null
+++ b/.claude/compound/lessons/stale-notebook-prose-after-behaviour-change/SKILL.md
@@ -0,0 +1,12 @@
+---
+name: stale-notebook-prose-after-behaviour-change
+description: Use when a hypertools library behaviour changes (new error text, a warning removed, a new return type).
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Grep docs/tutorials/*.ipynb AND the gitignored notes/colab/*.ipynb (the Colab feature
+tour) for prose or stored outputs that demonstrate the OLD behaviour, and re-execute those
+notebooks. Examples of what goes stale: a tutorial teaching hyp.load(df) as a TypeError
+after load gained passthrough, a stored glyph warning, a stored liblsl ERR line, a tour
+note saying "pip install predict-hf first". Before editing an install claim, verify it in
+a FRESH venv (python -m venv, pip install ., then the call).
diff --git a/.claude/compound/lessons/windows-os-replace-retry/SKILL.md b/.claude/compound/lessons/windows-os-replace-retry/SKILL.md
new file mode 100644
index 00000000..2e097404
--- /dev/null
+++ b/.claude/compound/lessons/windows-os-replace-retry/SKILL.md
@@ -0,0 +1,10 @@
+---
+name: windows-os-replace-retry
+description: Use when writing a cache or atomic-write rename with os.replace in hypertools, or reading a PermissionError from Windows CI.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+os.replace onto a file other threads are reading or renaming raises PermissionError
+(WinError 5) on Windows only. Wrap cache and atomic-write renames in a short retry that
+accepts an existing identical destination, as _replace_retrying in
+hypertools/io/sources.py does. Expect Windows CI to be the only place such a test fails.
diff --git a/.claude/compound/lessons/yahoo-sec-api-traps/SKILL.md b/.claude/compound/lessons/yahoo-sec-api-traps/SKILL.md
new file mode 100644
index 00000000..9426ab60
--- /dev/null
+++ b/.claude/compound/lessons/yahoo-sec-api-traps/SKILL.md
@@ -0,0 +1,10 @@
+---
+name: yahoo-sec-api-traps
+description: Use when fetching price history from the Yahoo v8 chart API or filings data from SEC XBRL.
+created: 2026-10-03
+origin: notes kept in CLAUDE.md
+---
+Yahoo v8 chart with `range=max&interval=1d` silently returns 3-MONTH bars (AAPL: 169 rows
+since 1984); pass explicit period1/period2 epoch bounds to get daily data. SEC XBRL needs
+a User-Agent with a contact (the project's pyproject email). `companyconcept` can be EMPTY
+for a filer (ABT, KO) whose `companyfacts` has the concept, so fall back to companyfacts.
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index e62101b0..40dc171a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -64,11 +64,14 @@ jobs:
- name: Install system dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
- sudo apt-get update
+ sudo apt-get -o Acquire::Retries=3 update
# fonts-noto-cjk (GH #205): ubuntu-latest ships no CJK-covering font
# at all, so tests/test_multibyte.py's `requires_covering_font`
# skipif would silently skip every CJK-dependent test on this OS.
- sudo apt-get install -y ffmpeg fonts-noto-cjk
+ # --no-install-recommends: ffmpeg's recommends (VA/VDPAU drivers, speech
+ # models) are never used here and tripled the download; a slow mirror
+ # (182 MB at 111 kB/s, 2026-10-08) pushed docs-clean past its timeout.
+ sudo apt-get -o Acquire::Retries=3 install -y --no-install-recommends ffmpeg fonts-noto-cjk
fc-cache -f
- name: Install system dependencies (macOS)
@@ -108,6 +111,12 @@ jobs:
# wheels for all three CI platforms/Python versions here.
pip install -e ".[dev,torch]"
+ - name: Check repository lint (Ubuntu Python 3.12)
+ if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
+ run: |
+ python -m pip install "ruff==0.15.20"
+ python -m ruff check .
+
- name: Pre-fetch headless Chrome for kaleido (Plotly image/GIF export)
# tests/test_animation_export.py exports Plotly figures to GIF/MP4 via
# kaleido 1.x, which drives a headless Chrome. Pre-fetching it here (into
@@ -253,13 +262,16 @@ jobs:
python-version: '3.11'
- name: Install system dependencies
run: |
- sudo apt-get update
+ sudo apt-get -o Acquire::Retries=3 update
# ffmpeg: mp4 rendering of animated gallery examples (matches RTD).
# fonts-noto-cjk: CJK-covering font for the multibyte gallery text.
# pandoc: nbsphinx renders the hand-authored tutorials/*.ipynb via
# pandoc (Read the Docs' build image bundles pandoc; a bare GH runner
# does not, so declare it here or the notebook stage fails).
- sudo apt-get install -y ffmpeg fonts-noto-cjk pandoc
+ # --no-install-recommends: ffmpeg's recommends (VA/VDPAU drivers, speech
+ # models) are never used here and tripled the download; a slow mirror
+ # (182 MB at 111 kB/s, 2026-10-08) pushed docs-clean past its timeout.
+ sudo apt-get -o Acquire::Retries=3 install -y --no-install-recommends ffmpeg fonts-noto-cjk pandoc
fc-cache -f
- name: Export a pristine source tree (tracked files only)
run: |
@@ -289,6 +301,18 @@ jobs:
run: |
cd /tmp/docs-clean/docs
python -m sphinx -b html -W -E -a . _build/html
+ - name: Run the docstring and guide examples (sphinx doctest builder)
+ env:
+ MPLBACKEND: Agg
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # the gallery was just built; the doctest builder only needs the
+ # `>>>` examples in docstrings and the guides (release audit
+ # 2026-09-07: two `hypertools.load` examples failed under this
+ # builder while the HTML build was green)
+ HYPERTOOLS_DOCS_PLOT_GALLERY: '0'
+ run: |
+ cd /tmp/docs-clean/docs
+ python -m sphinx -b doctest -W . _build/doctest
- name: Release gate -- generated gallery notebooks install the PyPI package
# docs/auto_examples/*.ipynb are gitignored and GENERATED by the build
# above from docs/conf.py's branch-aware install-cell (so they don't exist
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f60f160c..2ee243bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-## 1.1.0 (2026-09-04)
+## 1.1.0 (2026-10-09)
Hierarchical (`MultiIndex`) DataFrames become a first-class input. A frame
whose **columns** carry a hierarchy now expands into one trace per group
@@ -8,12 +8,41 @@ plus per-level means (the row axis has done this since 1.0); `hyp.predict`
forecasts a hierarchy one group at a time with explicit model ownership; and
`predict=` forecasts every plotted trajectory, derived means included.
-Five previously-accepted inputs are now **rejected** -- see *Changed /
-validation*. One of them (duplicate timestamps) is not hierarchy-specific
-and reaches flat `hyp.predict` callers.
+Seven previously-accepted inputs are now **rejected** -- see *Changed /
+validation*. Three of them are not hierarchy-specific: duplicate timestamps
+(which reaches flat `hyp.predict` callers), `ax=` combined with `animate=`,
+and a matplotlib Axes passed as `ax=` under the plotly backend.
### Added
+- **Forecasts use observation times.** Timed rows are sorted before fitting.
+ An index with a regular calendar (a stored or inferable frequency, a
+ `PeriodIndex`, month starts/ends, business-day sessions) is stepped on that
+ calendar; otherwise one future step is the median positive timestamp gap
+ (formerly the minimum gap), independently per dataset. Every forecaster
+ accepts a `step=` override, including calendar aliases such as `'B'` and
+ `'MS'`. GaussianProcess fits actual times; discrete-time models linearly
+ interpolate irregular observations onto a regular grid, with a warning.
+ Fitted reuse preserves the learned time scale. Series plots fit their signal
+ columns together using the time index, including animated forecasts, instead
+ of forecasting each `[index, value]` display pair. See the API guide for the
+ interpolation policy and its limitations.
+- **Integer timestamp arithmetic preserves elapsed times.** Signed and
+ unsigned integer indexes use overflow-safe subtraction and addition,
+ including large epoch offsets. Discrete-time forecasting no longer rejects
+ valid unsigned timestamps because their past offsets wrap into the future.
+- **Backtests score matching observation times.** GaussianProcess evaluates
+ held-out timestamps directly; discrete-time forecasts are linearly
+ interpolated to them, with a warning when needed. Training rows alone
+ determine the model and its step. Returned forecasts carry the same index
+ as the truth, and `horizon` reports held-out observations rather than the
+ number of generated grid steps.
+- **Manipulator input and row-wise fixes.** A 1-D array or flat numeric
+ list/tuple consistently means one column in `manip`, direct classes,
+ `Pipeline`, and fitted reuse. Row-wise ZScore/Normalize work on multiple
+ datasets while preserving each dataset's statistics, index and column names.
+ MatrixColormap's exact interpolation now honors `set_gamma()`.
+
- **A column MultiIndex frame expands into one trace per group.** The
innermost column level is the feature axis; every level above it groups,
so a `(Market, Sector, Ticker)` frame draws one trajectory per sector plus
@@ -135,8 +164,12 @@ and reaches flat `hyp.predict` callers.
row versus column semantics, the plot/predict divergence, hue forms, mean
construction, limitations, dual-axis and list inputs, return shapes, the
unfitted/fitted ownership table, backend parity and feature
- correspondence. All 138 of its examples are executed by the test suite
- rather than merely read. `docs/pipeline_order.rst` gains hierarchy
+ correspondence. Its worked examples are `.. doctest::` blocks, run by
+ Sphinx's doctest builder (`make doctest` in `docs/`, and the docs CI
+ job), which is what checks the printed outputs and the error messages
+ the guide quotes; the test suite pins the guide's section list, its
+ links from the API reference and the tutorials page, and its comparison
+ table (`tests/test_docs_hierarchy_guide.py`). `docs/pipeline_order.rst` gains hierarchy
expansion and mean construction as a side branch, in the prose and in the
regenerated diagram: expansion runs before `format_data`/`analyze`, so
every leaf gets the identical canonical pipeline, while mean construction
@@ -204,12 +237,15 @@ and reaches flat `hyp.predict` callers.
pipeline output's own coordinates (for `reduce=None` the raw columns) with
fixed joint limits for animations, on both backends; `xlim=`/`ylim=` set
them explicitly. `axis_scale='unit'` (the default) is unchanged and now
- documented: 2-D plots are mean-centred, rescaled into the unit box and
- pinned to (-1.1, 1.1) (GH #285).
-- **`ndims=1` is a time-series mode.** Each column becomes one line against
- the row index (a DatetimeIndex gives real dates; arrays use 0..n-1), 2+
- columns are allowed and legend-named by column, `axis_scale` defaults to
- `'data'`, and animations reveal along x. Previously `ndims=1` drew one
+ documented: 2-D plots are mean-centred and rescaled into [-1, 1], inside
+ a frame square of half-width 1.125, with both axes pinned to +/-1.2375
+ (10 % beyond the square) (GH #285).
+- **`ndims=1` is a time-series mode.** With `reduce=None`, each column
+ becomes one line against the row index (a DatetimeIndex gives real
+ dates; arrays use 0..n-1); 2+ columns are allowed, and `legend=True`
+ names each line by its column. With the default `reduce=`, the data are
+ first reduced to one component, drawn as one line. `axis_scale` defaults
+ to `'data'`, and animations reveal along x. Previously `ndims=1` drew one
column rescaled into [-1, 1] at 0..n-1 and refused 2+ columns (GH #285).
- **`truth=` beside a forecast.** `hyp.plot(train, predict='Chronos', t=30,
truth=held_out)` draws the actual continuation in the same space, styled
@@ -249,7 +285,8 @@ and reaches flat `hyp.predict` callers.
Per-panel axis labels come from DataFrame columns as in the single-axes
call. On plotly the panels are `make_subplots` scenes and
`return_model=True` returns the same bundle as matplotlib, which adds
- `axes`, `panels` and `panel_models` (GH #285).
+ `axes`, `panels`, `panel_models` and, for a shared fit, the one fitted
+ `pipeline` (GH #285).
- **Title styling on every frame.** `title_kwargs=dict(size=, weight=,
family=, color=, y=)` is applied by hypertools' own title updater,
including per-segment `title=` lists; `title_color=` takes one colour per
@@ -271,7 +308,11 @@ and reaches flat `hyp.predict` callers.
'lorenz' | 'blobs' | 'moons' | 'swiss_roll' | 's_curve', random_state=...,
n_datasets=...)` generates seeded example data (scikit-learn kwargs pass
through; `n_datasets > 1` returns a list), replacing the random-walk,
- helix and blob generators every tutorial wrote by hand (GH #285).
+ helix and blob generators every tutorial wrote by hand. Any keyword
+ `hyp.load` does not use itself is collected into `**source_kwargs` and
+ handed to the synthetic or web resolver that matches the name; with any
+ other kind of source, already-loaded data included, it raises `TypeError`
+ that quotes the keyword instead of dropping it (GH #285).
- **Web sources.** `hyp.load('wikipedia:
')` (plain-text extract;
`'A|B'` returns a list), `hyp.load('yahoo:', start=, end=,
interval=)` (daily OHLCV through explicit epoch bounds) and
@@ -290,13 +331,20 @@ and reaches flat `hyp.predict` callers.
- **Several forecasters in one call, backtests, and imputer scoring.**
`hyp.predict(x, model=[...])` returns `{name: forecast}`;
`hyp.predict(x, model=[...], holdout=k)` fits on the head and scores the
- held-out tail (MAE / RMSE / MAPE, `per_column=`, `return_forecasts=`) against
- every model plus an always-present naive last-value baseline, with
- `scores.attrs['best']` and `['beats_baseline']`; `hyp.impute(x, model=[...],
- truth=full)` scores imputers on the damaged cells only, with a column-mean
- baseline and an `unscored` column for rows a model left NaN (GH #285).
-- **`Smooth(center=False, min_periods=)` and a `Delay` manipulator.** A
- trailing (causal) boxcar identical to `pandas.rolling(...).mean()`, and a
+ held-out tail against every model plus an always-present naive last-value
+ baseline, with `scores.attrs['best']` and `['beats_baseline']`;
+ `metrics=` picks which of MAE / RMSE / MAPE to report (all three by
+ default; the first one ranks `best`), `per_column=True` gives one row per
+ model and column, and `return_forecasts=True` also returns the forecasts
+ that were scored. `hyp.impute(x, model=[...], truth=full)` scores imputers
+ on the damaged cells only, with the same `metrics=` and `per_column=`, a
+ column-mean baseline and an `unscored` column for rows a model left NaN;
+ `return_imputed=True` also returns the scored imputations, the baseline and
+ the truth (GH #285).
+- **`Smooth(kernel='boxcar', center=False, min_periods=)` and a `Delay`
+ manipulator.** A trailing (causal) boxcar identical to
+ `pandas.rolling(...).mean()` (`center=False` needs `kernel='boxcar'`; the
+ default savgol kernel has no trailing form and raises `ValueError`), and a
Takens time-delay embedding (`hyp.manip(x, model='Delay', tau=, dims=)`)
(GH #285).
- **Alignment quality score.** `hyp.align(..., return_score=True)` returns the
@@ -328,6 +376,42 @@ and reaches flat `hyp.predict` callers.
so one `hyp.load` call can be the entry point over mixed names and
in-memory data; other types still raise `TypeError`. Previously any
non-string raised.
+- **Input datatype handling defers to datawrangler.** The shared coercion
+ layer (`format_data`, `get_type`/`get_dtype`, `as_dataframe`, the
+ `predict`/`impute` normalisation, `io.streaming.is_stream`) classifies
+ inputs with `dw.zoo` predicates and converts with `dw.wrangle` instead of
+ its own `isinstance` ladders, so polars DataFrames, LazyFrames and Series
+ are accepted wherever pandas is -- `plot`, `reduce`, `align`, `cluster`,
+ `normalize`, `manip`, `predict`, `impute`, `analyze`, `describe` -- with
+ identical results (polars nulls become missing data), and whatever
+ datawrangler adds later comes for free. The same holds throughout:
+ `plot`'s `hue=`, `labels=`, `truth=`, matrix `palette=`, `panels=` and
+ its axis/legend labels; `manip` (and the Manipulator classes and
+ `Pipeline` steps directly), `align`, `stack`, `damage`, `apply_model`,
+ the fitted `Normalizer`, `save`/`load`, `text2mat` and the impute
+ backtest's `truth=`/`mask=` read any frame backend datawrangler
+ recognises through the shared predicates; a one-column DataFrame of
+ labels as `hue=` no longer raises `IndexError`, and `text2mat` accepts a
+ Series of documents. A static test (`tests/test_datatype_gate.py`) keeps
+ hand-rolled pandas/numpy type checks out of the library, and
+ `tests/test_polars_inputs.py` and `tests/test_polars_inputs_wave1.py`
+ compare the polars results against pandas.
+- **Palettes from images read as gradients, and a data matrix is a
+ palette.** Colors extracted from an image are put in a deterministic
+ order -- by value, dark to bright -- when the image is used as a plot
+ palette (`palette_sort=` or a spec's `?sort=` picks `'value'`, `'hue'`,
+ `'lightness'`, `'columns'` or `'original'`; `image_palette()` itself and
+ a per-dataset image's lead color keep the salience order). A t x k data
+ matrix (array, nested list or DataFrame) passed as `palette=`,
+ `forecast_palette=` or a per-dataset entry is reduced to three dimensions
+ with `hyp.reduce` (`palette_reduce=`, default 'PCA', with
+ `palette_manip=`/`palette_normalize=`/`palette_align=` passed through),
+ each reduced column is scaled to [0, 1] as an RGB channel, the rows are
+ sorted (default along the first component) and the result is a colormap
+ resampled by interpolation to however many colors the plot needs
+ (`hypertools.plot.colors.matrix_palette`, `sort_colors`,
+ `MatrixColormap`). A 2-D array with 3 or 4 columns and every value in
+ [0, 1] stays a list of colors.
- **Optional extras install themselves on demand.** The first call that
needs plotly, kaleido, HF text embeddings, skaters (`Laplace`),
chronos-forecasting (`Chronos`), torch (autoencoder reducers), gensim,
@@ -338,9 +422,13 @@ and reaches flat `hyp.predict` callers.
`pyproject.toml` stays the single declaration of every extra
(`hypertools._shared.lazy_import`). Static image export with the plotly
backend provisions kaleido's Chrome the same way, plus the four system
- libraries a fresh Colab/Kaggle image lacks. `HYPERTOOLS_AUTO_INSTALL=0`
- disables installation: a missing extra then raises `ImportError` naming
- the manual `pip install "hypertools[]"` command, as before.
+ libraries a fresh Colab/Kaggle image lacks. `hyp.set_autoinstall(False)`
+ turns installation off, for the session or for one block as a context
+ manager (the same two forms as `set_interactive_backend`): a missing
+ extra then raises `ImportError` naming the manual `pip install
+ "hypertools[]"` command, as before. The environment variable
+ `HYPERTOOLS_AUTO_INSTALL=0` sets the starting value for images built
+ ahead of time.
### Changed / validation
@@ -470,6 +558,32 @@ previously ambiguous or silently lossy.
### Bug fixes
+- **NumPy 2-compatible optional dependency floors.** The `gensim` and
+ `density3d` extras require gensim>=4.4.0 and scikit-image>=0.25.0,
+ respectively; dev/docs requirements match. Earlier advertised minimums
+ predate upstream NumPy 2 support.
+- **Backtest model ownership.** Forecast backtests fit an independent copy
+ of an unfitted model instance for each dataset, instead of reusing the
+ first dataset's learned parameters on subsequent datasets. Forecast and
+ imputation scoring leave caller-owned instances unchanged and reject
+ already fitted instances, which may have seen the held-out truth (GH #285).
+- **Concurrent URL caching.** Threads downloading the same URL use distinct
+ temporary files, preventing `FileNotFoundError` during atomic replacement
+ (GH #285).
+- **Delay column collisions.** `Delay` rejects duplicate column labels and
+ distinct labels with identical string representations, instead of silently
+ overwriting embedded features (GH #285).
+- **Bundled font precedence.** The bundled Noto Sans faces take precedence
+ over same-family system fonts, keeping rendering consistent across machines
+ with an additional Noto Sans installation (GH #285).
+- **Release documentation.** Plotting and scoring features shipped in 1.1
+ are identified as 1.1 in their API documentation, correcting leftover 1.2
+ labels. Installation guidance distinguishes Kalman imputation from
+ Kalman/ARIMA forecasting. The forecast example and its executed tutorial
+ use native URL caching; the browser verifier checks versioned notebook
+ links, rendered install cells, and real Plotly frame transitions without
+ requiring autoplay (GH #284, GH #285).
+
Each of these was found while building the above, and each affects FLAT
input too.
@@ -511,12 +625,17 @@ input too.
- **A plotly `save_path=` to a raster/PDF format on a machine without
Chrome failed with kaleido's bare `RuntimeError`.** kaleido 1.x renders
through a headless Chrome, which a fresh Colab or Kaggle kernel does not
- have. The failure is now a `HypertoolsIOError` naming the file, the
- cause, and the ways out: `import plotly.io as pio; pio.get_chrome()`
- (about 150 MB) plus, on Colab and Kaggle, the four system libraries the
- downloaded Chrome needs (`apt-get install -y libatk1.0-0
- libatk-bridge2.0-0 libatspi2.0-0 libxcomposite1`; measured 2026-09-04),
- installing Chrome, or saving with `backend='matplotlib'`.
+ have. With installation on (the default), hypertools now fetches a
+ Chrome for kaleido on first use, plus (on Debian/Ubuntu images where it
+ can run `apt-get`) the system libraries that Chrome needs; see *Optional
+ extras install themselves on demand* above. When that fails,
+ or `hyp.set_autoinstall(False)` is in force, the failure is a
+ `HypertoolsIOError` stating the cause and the ways out:
+ `import plotly.io as pio; pio.get_chrome()` (about 150 MB) plus, on
+ Colab and Kaggle, the four system libraries the downloaded Chrome needs
+ (`apt-get install -y libatk1.0-0 libatk-bridge2.0-0 libatspi2.0-0
+ libxcomposite1`; measured 2026-09-04), installing Chrome, or saving with
+ `backend='matplotlib'`.
- **Under the plotly backend, a figure kept in a variable was not displayed
in a notebook.** `fig = hyp.plot(x)` drew nothing (on Colab, where
`backend='auto'` resolves to plotly, 29 of the feature tour's plot cells
@@ -605,10 +724,9 @@ input too.
- **plotly discarded the per-trace alpha under a continuous `hue=`** for the
same figures, from the other direction: the colour serializer drops the
4th channel and nothing set the trace `opacity`, so a hue plot that
- matplotlib drew at `alpha=0.7` rendered fully opaque on plotly. Line
- colours now carry the alpha; **marker** colours deliberately do not,
- because matplotlib's per-point marker colours carry none either, and
- parity is stated against matplotlib.
+ matplotlib drew at `alpha=0.7` rendered fully opaque on plotly. Line and
+ marker colours now carry the alpha on both backends (hue-coloured markers
+ ignored `alpha=` on matplotlib too until the release review).
- **With `ndims=1`, matplotlib drew the `predict=` overlay at x = 0..t**
instead of continuing the observed series: the overlay was plotted with no
@@ -689,13 +807,901 @@ input too.
the passed-in pipeline too (in place, on the same object the bundle
returns), unless it already carries one of its own.
+### Fixed during the release review
+
+Found by the pre-publication review of the 1.1.0 draft against 1.0.0.
+Because 1.1.0 had not been published, they ship in it.
+
+- **A model that left values missing can no longer be named the best.**
+ `hyp.impute(x, model=[...], truth=)` and
+ `hyp.predict(x, model=[...], holdout=)` picked `scores.attrs['best']` as
+ the lowest score regardless of coverage, so a model scored on fewer,
+ easier values could win: `PPCA`, which cannot fill a fully-missing row,
+ was "best" with an MAE of 0.94 over the 10 cells it filled while
+ `SimpleImputer` scored 63.7 over all 25. Only complete models (`unscored`
+ of 0 over every dataset and column) are ranked now. Incomplete models keep
+ their rows, are listed in the new `scores.attrs['incomplete']`, and the
+ warning says they are excluded from the ranking. When no model is
+ complete, `attrs['best']` is `None`, `attrs['best_score']` is NaN and
+ `attrs['beats_baseline']` is `None`; `attrs['beats_baseline']` is also
+ `None` when the baseline row is itself incomplete. Each case warns.
+- **`hyp.impute(truth=, mask=)` matches labelled data by label.** `truth`
+ and `mask` were checked for shape and then compared cell by position, so
+ a truth DataFrame with its columns or rows in another order was scored
+ against the wrong cells without a word (an MAE of 18.83 or 1.83 where the
+ answer is 3.67). When the data and `truth`/`mask` are both labelled, the
+ same labels in a different order are now reordered to the data's, and
+ labels that differ (or are repeated and not in identical order) raise a
+ `ValueError` naming the axis and the labels. A default `0..n-1` index
+ counts as labels; a polars frame is matched by column name and row
+ position. Bare arrays are compared by position as before, so
+ `truth.to_numpy()` is the way to compare two differently-labelled frames
+ cell for cell. A wrong-shaped array `truth` now gets hypertools' shape
+ message instead of a pandas internals error.
+- **A backtest pairs forecast and held-out columns by label.** A custom
+ `Forecaster` that returned the training columns in another order was
+ scored against the wrong columns; it is reordered now, and one that
+ returns different column labels raises a `ValueError`. The built-in
+ forecasters were not affected.
+- **An installed extra that is too old is upgraded, or reported as what it
+ is.** On-demand installation only asked whether an optional package
+ imported, not whether it met the requirement hypertools declares. In an
+ environment that already held plotly 5.24.1 next to kaleido 1.3
+ (`plotly>=6.1.1` is required), saving a plotly figure as an image failed
+ with plotly's "Image export using the "kaleido" engine requires the
+ kaleido package", although kaleido was installed. Every package of an
+ extra is now checked against its declared requirement, once per process.
+ One that is too old is upgraded with a notice (`hypertools: upgrading
+ plotly 5.24.1 to plotly>=6.1.1 ...`) as long as it has not been imported
+ yet; if it has, or after `set_autoinstall(False)`, the call raises
+ `ImportError` naming the installed version, the requirement and the
+ command (`pip install "hypertools[interactive]"`, plus a restart when the
+ old version is already imported). With `backend='auto'` such a plotly
+ gives a warning and a matplotlib figure. `backend='plotly'` no longer
+ imports plotly before that check. A correctly resolved
+ `pip install "hypertools[interactive]"` was never affected.
+- **`explore=True` hover labels stay inside the window.** The label was
+ always drawn up and to the left of the hovered point, so a point near the
+ left edge of a native window had its label cut off. It now opens toward
+ the centre of the axes.
+- **plotly names and groups every leaf of a nested dataset list by its
+ group.** For `hyp.plot([[a, b], [c, d]], backend='plotly')` only each
+ group's first leaf carried the group's label; the others fell back to
+ their flat leaf number, so the leaves were named `'1'`, `'2'`, `'2'`,
+ `'4'`. Hover named the wrong group, and the legendgroups paired a leaf of
+ group 1 with a leaf of group 2, so a legend click toggled the wrong
+ lines. Every leaf is now named by its outer group (or by its entry in a
+ per-group `legend=` list), as a hierarchy's leaves already were, in static
+ and animated figures. Matplotlib was not affected.
+- **3-D plotly markers on a line (`fmt='-o'`) draw at matplotlib's size.**
+ These markers are given per-point sizes, and `go.Scatter3d` draws a
+ per-point size at half the diameter of the same single size. The
+ conversion did not allow for that, so the markers drew at half
+ matplotlib's diameter. With `markersize=3` they were about 2 px dots under
+ a 1.4 px line, and most were hidden. They now match matplotlib within the
+ existing marker-parity tolerance (for example 4 px, previously 2 px, against matplotlib's
+ 5 px at `markersize=3`). Marker-only 3-D traces and all 2-D traces were
+ already correct.
+
+- **An animated forecast now starts where that frame's line ends.**
+ `predict=` with `animate=` drew each frame's forecast from the last raw
+ observation at or before the drawn head. An animation is paced on a
+ refined frame grid, so the head usually sits *between* two observations:
+ the forecast hung back from the line's tip and stood still until the head
+ reached the next observation, then jumped. On an 8-row trajectory over 160
+ frames it lagged by up to 45% of the plot box and stalled for 23
+ consecutive frames. It now starts at the vertex the frame actually draws
+ last, on both backends and for 1-D, 2-D and 3-D plots. The predicted
+ points themselves are unchanged, so `t=` still counts raw steps on from
+ the last observation.
+
+- **An animated `truth=` no longer shows the answer before the forecast
+ gets there.** A time-progressing animation (`True`/`'parallel'`,
+ `'serial'`, `'window'`, and `hue=`/`cluster=` regrouped reveals) drew
+ the whole held-out continuation from frame 0. Each truth row now appears
+ on the first frame whose drawn forecast of that dataset (any model's,
+ for `predict=[...]`) reaches or passes its position, and stays on every
+ later frame; with no forecast drawn yet it is hidden. On the feature-tour
+ series (24 hourly rows, `t=3`, 9 frames) the truth was on all 9 frames
+ and is now on the last; on a 20-row walk forecast 12 steps out it now
+ grows 0, 0, 0, 0, 1, 3, 5, 7, 9, 12 rows over 10 frames. Both backends
+ agree frame for frame (plotly's base trace holds frame 0's state), the
+ `'truth'` legend entry is on every frame, and static plots and
+ `animate='spin'` still draw it in full.
+
+- **Default forecast group colours no longer repeat an observed line's
+ colour.** `forecast_hue=`/`forecast_cluster=` without
+ `forecast_palette=` started the palette over, so forecast group 0 was
+ drawn in exactly dataset 0's colour: `hyp.plot([a, b, a + 2, b + 2],
+ predict='Kalman', t=6, forecast_cluster='KMeans', forecast_n_clusters=2)`
+ drew its two groups in `#db5f57` and `#57d3db`, the colours of datasets
+ 0 and 2. The groups now continue the figure's `palette=` past the colour
+ slots the observed data takes (its datasets or `hue=`/`cluster=` groups,
+ plus any an earlier call took on a reused `ax=`): the same call draws
+ `#dbc257` and `#57db80`, the free slots of 'hls' at 8, and `palette='Set2'`
+ gives Set2's fifth and sixth colours. Same on both backends, static and
+ animated; `panels=` resolves the default against the whole grid, so each
+ label keeps the single-axes figure's colour. An explicit
+ `forecast_palette=` overrides exactly as before.
+
+- **A continuous `hue=` no longer repaints an explicitly coloured
+ forecast on matplotlib.** Under a continuous `hue=` the matplotlib
+ backend recoloured every forecast in its trace's final hue colour,
+ discarding `forecast_hue=`/`forecast_cluster=`/`forecast_palette=` (and a
+ colour letter in `forecast_fmt=`), static and animated, while plotly kept
+ them -- the two backends drew the same call in different colours. The
+ explicit colour now wins on both, as `forecast_fmt=`'s docs say.
+
+- **No leftover "install the extra first" instructions.** Every optional
+ dependency goes through the on-demand installer, and the stale prose
+ that told users to install an extra by hand is gone: the `plot()`
+ reducer docstring's `pip install "hypertools[torch]"`, the autoencoder
+ and gensim gallery examples' "pre-install it with ...", and the LSL
+ tutorial's inline command; the `reduce()` torch error now says the
+ on-demand install was tried. The API reference gained a *Set
+ autoinstall* entry (`set_autoinstall`, how it works, and a pointer to the
+ guide). The `projectile_kalman` and `streaming_data` tutorials no longer
+ show a pip upgrade notice with a local interpreter path as the output of
+ their Colab install cell.
+- **`hyp.load(..., offline=True)` never downloads and opens no network
+ connection.** The hosted example datasets (`'spiral'`, `'weights'`, the
+ `*_model` pipelines, ...) bypassed `offline`: a cache miss downloaded,
+ and a cached file failing its SHA-256 pin was deleted and re-downloaded.
+ Offline now serves only a hash-valid copy from `~/hypertools_data` and
+ raises `HypertoolsOfflineError` naming the file for a missing or corrupt
+ one, leaving the file in place; any other source that cannot be served
+ from disk raises it too. URLs skip the seaborn dataset-name listing.
+ That listing fetch now has a timeout, and a failed fetch is remembered
+ for 5 minutes (`hypertools.io.sources.reset_seaborn_names_cache()` retries
+ sooner). Also fixed the `hypertools.load` docstring example, which failed
+ under Sphinx's doctest builder (`NameError: hypertools`); the docs CI job
+ now runs that builder.
+- **Plotly animation export honours `hyp.set_autoinstall(False)` and
+ raises the documented exception types.** The frames of a plotly
+ animation's GIF/PNG/video export are rendered in a separate worker
+ process, which now inherits the caller's installation setting: with
+ installation off, a missing kaleido raises the `ImportError` naming the
+ manual command and no pip runs (the worker used to start a fresh
+ interpreter that installed anyway). That `ImportError` reaches the caller
+ as an `ImportError`, and a missing Chrome as `HypertoolsIOError`, instead
+ of a `RuntimeError` wrapping the worker's traceback.
+- **`alignment_score` rejects degenerate input with a clear error.** A
+ 1-D series, a non-numeric array, or NaN/inf values raise `ValueError`
+ naming the dataset (they hit numpy's own shape errors or returned a NaN
+ score), and `metric='dispersion'` on all-constant datasets raises like
+ `'isc'` already did instead of returning NaN with a RuntimeWarning.
+- `docs/doc_requirements.txt` carries the same core floors as
+ `pyproject.toml` (scikit-learn 1.5.2, pandas 2.2.3, matplotlib 3.9.2,
+ scipy 1.14.1, numpy 2.1.0, statsmodels 0.14.3).
+- **A list of `{category: color}` dicts works with a regrouping `hue=`.**
+ Each dataset naming its own categories (the documented per-dataset dict
+ form) was rejected as "2 per-dataset palettes but 4 dataset(s)" once
+ `hue=` split the datasets into more runs than dicts; the dicts name
+ categories and now resolve by name on both backends.
+- **Overlapping `set_autoinstall` blocks keep the newest setting in force,
+ and direct calls are no longer retained.** Two `with
+ hyp.set_autoinstall(False)` blocks open at once (two threads, say) used
+ to switch installation back on inside the later block when the earlier
+ one exited, because exit restored a value saved before either. A block
+ now removes only its own setting; the setting is process-global and
+ documented as such. Every direct call also kept a handle alive until a
+ block's exit removed it, so a long session of direct calls accumulated
+ them; a superseded direct call is now released. A handle made for a
+ `with` block is never collapsed before it is entered, so creating a block
+ in one thread and entering it later is safe.
+- **The plotly backend honours the colour letter of a data `fmt=` string**
+ (`'r-'`, `['g--', 'b:']`) exactly as matplotlib does, including animated
+ plots and `panels=` cells; it drew the palette colour before.
+- **Matrix colormaps, image palettes and mixed `manip` lists.** The
+ matrix colormap honours every inherited Colormap operation (integer
+ sampling, `resampled()`, `reversed()`, `set_under`/`set_over`, bad and
+ NaN entries per element); image palettes interpolate exactly at any
+ count, so more than 256 categories still get distinct colours; a polars
+ `forecast_hue` Series is partitioned under `panels=` like a pandas one;
+ and `manip` lists mixing an unnamed array with named frames keep every
+ frame's index (dated or irregular) while lists of named frames pass
+ through untouched.
+- **Series and mixed lists through the manipulators.** A
+ pandas or polars Series keeps its index and name through the Manipulator
+ classes and `hyp.Pipeline` (a `Pipeline([Smooth, Resample])` on an
+ irregularly sampled Series resampled at positions 0..n-1 instead of the
+ Series' own; `ZScore`/`Normalize`/`Resample` used directly never took a
+ Series at all), and a polars Series beside an array in a `hyp.manip`
+ list works. A mixed list keeps every frame's own feature names for every
+ model: the shared-statistics manipulators (`ZScore`, `Normalize`) match
+ columns by position themselves when labels differ (an unnamed array
+ beside a named frame, or two frames named differently) and reject
+ different widths with a clear message; the independent ones never
+ relabel anything. A 1-D array is one column (n observations of one
+ feature) for `hyp.manip`, as it already was for `hyp.normalize`,
+ `hyp.reduce` and the Manipulator classes; `hyp.manip` used to read it as
+ a single row. `MatrixColormap` follows matplotlib's full extreme-colour
+ rules (under/over/bad keep the alpha they were set with, `-inf`/`+inf`
+ are under/over rather than bad, and an `alpha=` override reaches the
+ extremes but not a transparent bad colour). `legend=` accepts a polars
+ Series (any series-like) of labels, on both backends.
+- **A repeated metric in `metrics=` raises `ValueError` that says which
+ metric is repeated.** `hyp.predict(..., holdout=k, metrics=['mae', 'MAE'])`
+ and the matching `hyp.impute(..., truth=)` call used to fail with a
+ `TypeError` from inside the scoring code.
+- **`holdout=True` with `t=0` reports `t` as the problem.** `holdout=True`
+ takes its size from `t`, so the error now says that `t` must be at least
+ 1 row.
+- **The "left N scored value(s) missing" warning is attributed to the
+ caller's line**, like every other warning `hyp.predict` and `hyp.impute`
+ emit, instead of to a line inside the library.
+- **`return_score=True` works on ragged input that `hyp.align` trims.** The
+ "before" score is computed on the row-trimmed datasets, the same ones the
+ aligner sees.
+- **`HypertoolsOfflineError` and `HypertoolsTrustError` are importable from
+ `hypertools`** (`HypertoolsOfflineError` from `hypertools.io` as well),
+ so a caller of `hyp.load(..., offline=True)` can catch the error without
+ reaching into `hypertools.io.sources`. The API reference documents
+ `HypertoolsTrustError` and `io.synthetic_outlet` under these public
+ names; their source-view links pointed at anchors that did not exist.
+- **`panels=` partitions every per-dataset and per-forecast argument.** A
+ per-dataset list of palette names (`palette=['viridis', 'magma']`), a
+ `legend=` list, an `alpha=` list, `forecast_fmt=`, `forecast_palette=`,
+ a model-major `forecast_hue=` and a forecaster fitted on every dataset
+ (`hyp.predict(x, return_model=True)`) now reach each panel as its own
+ entry, in both `panel_fit` modes and on both backends, matching the
+ single-axes figure; previously they raised inside the panel or drew
+ every forecast in the first colour. Shared-fit grids also hand back
+ their one fitted pipeline as `bundle['pipeline']` and in every
+ `panel_models[i]['pipeline']`, so held-out data can be projected without
+ refitting (it was `None`).
+- **`panels=` keeps forecast labels that share a colour.** `forecast_hue=` /
+ `forecast_cluster=` with a `forecast_palette=` that gives two labels the
+ same colour (`['red', 'red']`, or a palette name that cycles) raised
+ `ValueError: palette= supplies N color(s)` inside the panels; each panel
+ now receives one palette slot per label, matching the single-axes figure.
+- **`panels=` on 2-column or 1-column data with the default `ndims=` draws
+ 2-D cells** on both backends, instead of raising `Trace type 'scatter' is
+ not compatible with subplot type 'scene'` (plotly) or drawing flat
+ trajectories inside cubes (matplotlib).
+- **`panels=` no longer re-clusters each panel.** Every cell reuses the
+ clustering already fitted for it, so seeded memberships equal the
+ individual call's on both backends in every fit mode and no extra
+ clusterer fits run (each panel used to re-fit its clusterer without the
+ seed the first fit used); `return_model=True` bundles report
+ `models['cluster_labels']`. Plotly composition keeps the palette offset
+ across a `color=` or categorical `hue=` call. Independent panels mixing
+ one- and three-column datasets draw the series as row index against value
+ on the 3-D cell's floor instead of crashing.
+- **`panels=` keeps the joint figure's cluster colours, forecasts narrow
+ panels in their own space, and a marker-only hue no longer advances the
+ palette.** Shared clustered panels keep the joint cluster-to-colour
+ mapping and legend names when a slice lacks a cluster (two panels each
+ drew red); a 1-/2-column panel of a mixed-width independent grid
+ forecasts, reads `truth=` and reports its bundle on its own analyzed
+ rows (the individual call's numbers; the display padding used to feed
+ the forecaster) and only its drawing is lifted into the 3-D cell; a
+ categorical `hue=` with a marker-only fmt consumes no palette slot on a
+ composed axes/figure/cell, and its group colours beat a fmt colour letter
+ on the marker path as on the line path.
+- **`panels=` decides each cell's projection from the analyzed data** (after
+ `manip=`/`pipeline=`/`reduce=`), not the raw column count: a Delay-expanded
+ 2-column dataset reduced to 3 components draws 3-D panels again in the
+ shared, independent and reducer-comparison modes on both backends, keeping
+ the requested `ndims` and each panel's fitted pipeline with no second
+ fit. Composing a call into a figure, axes or cell after a `fmt='r-'`,
+ `color=` or `hue=` call continues the palette from the slots actually
+ consumed, identically on both backends.
+- **`panels=` fixes.** Works with `predict=` plus `truth=` in both
+ `panel_fit` modes; shared mode keeps a DataFrame's dates and column names
+ under `ndims=1` and accepts three-column frames; nested `hue=` and
+ `labels=` narrow per panel in both modes; `ndims>3` draws 3-D panels on
+ both backends; `save_path` accepts `~` and `pathlib.Path` and fails
+ before drawing when the directory is missing; plotly panels return the
+ same figure wrapper as a single-axes plot and display once per cell.
+- **`panels=True` picks its grid from the figure's aspect ratio** and
+ prefers a grid with no spare cell: three panels form a row in a
+ default or wide figure (they were a 2x2 with a hole, and in a wide
+ figure each square 3-D axes shrank to the short cell height), four
+ form 2x2, six form 2x3. Explicit `(nrows, ncols)` and column counts
+ are unchanged.
+- **`panels=` on the plotly backend keeps each panel whole.** The plotly
+ grid (`plotly.subplots.make_subplots`) received only each panel's
+ traces, so 2-D panels lost their unit frame, hidden ticks and
+ DataFrame-column axis labels; `legend=True` merged every panel into one
+ legend ('1, 1, 1' for three panels, the digit groups listed twice for a
+ reducer comparison); and two `colorbar=True` panels drew both colorbars
+ on the same spot. Each panel now moves into its cell with its axis
+ layout, frame square and `labels=` annotations re-referenced to that
+ cell, its own legend beside the cell (plotly's multiple legends), its
+ own colorbar (on whichever side `colorbar=` asked for), its `title=` as
+ formatted, styled and positioned by the ordinary title path
+ (`title_wrap=`, `title_kwargs=`, with the multi-line top margin that
+ path computes), and its `font=` materialized on the cell's own text;
+ 3-D cells back the camera off so the cube stays
+ inside a narrow cell; room for the legends/colorbars is reserved
+ beside every cell (the default-sized figure is widened by it, an
+ explicit `size=` is honoured verbatim).
+- **`panels=` colorbars on matplotlib take their room from their own
+ panel.** A `colorbar=True` panel grid ran the single-axes
+ figure-widening placement once per panel, stacking every colorbar over
+ the last panel and leaving `tight_layout` warning about axes it could
+ not place; a colorbar drawn into a caller-supplied `ax=` (every panel,
+ every `hyp.subplots` cell) now uses matplotlib's own `ax=`-attached
+ placement, so each panel keeps its colorbar and the figure its size.
+- **`panels=` on plotly is laid out like the matplotlib grid.** The plotly
+ grid used `make_subplots`' default spacing (10-15 % of the figure
+ between cells) and full-height cells, and backed the camera off ~1.5x
+ further than a narrow cell needed (the constant was the cube's width
+ relative to its OWN height rather than the scene's), so three 3-D
+ panels sat far apart with small cubes and titles floating well above
+ them. 3-D cells are now square and centred (an `Axes3D`'s equal box
+ aspect), the gaps are `tight_layout`'s 20 px (40 px between 2-D cells,
+ for tick labels), each row reserves what its titles need, and the cube
+ fills its cell within a few pixels of the matplotlib panel's.
+- **Default-size `panels=` figures make room for their legends and
+ colorbars.** Three 10-entry legends beside three default-size panels
+ shrank the cubes to 1.3 in; the matplotlib figure now widens by the
+ same 1.1 in per column the plotly grid reserves, and an explicit
+ `size=` is honoured verbatim.
+- **`palette=` colour lists behave as in 1.0.0 again.** A list shorter than
+ the dataset count cycles when there is no `hue=`; an empty palette raises
+ `ValueError` instead of `StopIteration`; a per-dataset list whose entries
+ are `{category: color}` dicts merges them by category name; and any other
+ per-dataset form under a categorical `hue=` raises an error carrying the
+ real dataset and category counts.
+- **NaN in a continuous `hue=` no longer poisons the colour range.** The
+ `vmin`/`vmax` of the colour scale and the colorbar are computed over the
+ finite values only.
+- **Legend and colour details.** `legend_kwargs={'fontsize': ...}` is
+ honoured together with `font=`; `bundle['colors']['categories']` contains
+ RGB tuples for the blend kind when `legend_colors=` is passed; and a nested
+ `hue=` whose sub-list does not match its dataset is identified in the
+ error.
+- **`dataset_fade=` and `on_frame=` mutations reach the drawn collections
+ under a continuous `hue=` on matplotlib.** A fade or a per-frame artist
+ change was a silent no-op there.
+- **`loop=True` accepts a per-segment `rotations=` list** of the documented
+ `2(n+1)-1` length.
+- **`companion=` panels and `{index}` titles advance monotonically under
+ `order='serial'`** with several datasets, and the `start` in
+ `FrameContext.window_bounds` reflects the comet-head window on serial
+ reveals.
+- **Animation errors say what went wrong.** A bad `companion=` or
+ `dataset_fade=` value raises an error that quotes the keyword; an
+ `on_frame=` hook that raises during `.save()` propagates its own
+ exception; and a `title=` callable that raises no longer leaves an
+ "Animation was deleted without rendering anything" warning behind it.
+- **`title_wrap=` applies to dynamic titles** (a callable, or a `{index}`
+ format) and preserves explicit newlines. plotly draws a `\n` in a title as
+ a line break and reserves top margin for every title line at the
+ requested size.
+- **Labels and titles validate their input.** A nested tuple `labels=`
+ annotates like a nested list; `labels='str'`, a bad `label_anchor=`, a
+ title list with a non-string entry, a title callable that returns a
+ non-string, `title_color=` alongside `title_kwargs={'color': ...}`, a
+ static `{index}` title with no index to fill it, and a malformed `{index}`
+ format each raise an error saying so.
+- **`yahoo:` bars are dated by the exchange-local trading day.** The
+ exchange's `gmtoffset` is applied to the bar timestamps; Sydney and Tokyo
+ tickers were dated one day early.
+- **Synthetic datasets accept more seed types.** Every synthetic dataset
+ accepts `random_state=np.random.RandomState(...)`, and the scikit-learn
+ backed ones (`blobs`, `moons`, `swiss_roll`, `s_curve`) also accept a
+ `Generator`, a `SeedSequence` or a NumPy integer with `n_datasets=1`;
+ reusing one `SeedSequence` across calls gives the same data each time.
+ `n_datasets=1.5` raises instead of being truncated to 1.
+- **`hyp.load(..., streaming=True)` on a source other than a Hugging Face
+ dataset raises `ValueError`** instead of returning the whole dataset as if
+ the keyword had not been passed.
+- **`hyp.text_windows` accepts NumPy integers** for `size=` and `step=`.
+- **`hypertools.tools.text2mat` reads a flat list of strings as one
+ dataset.** Since 1.0 it returned one `(N, d)` matrix followed by one empty
+ `(0, d)` matrix per string. Ragged nested lists work, mixed inputs raise,
+ and a dict `semantic=` spec with a gensim vectorizer warns and skips the
+ step like the string form does.
+- **Warnings raised while formatting input data are attributed to the
+ caller's line**: the PPCA missing-data fill and the mixed text-and-numbers
+ notice now point at the `hyp.plot`/`hyp.analyze` call that triggered them.
+- **`fit()` returns the fitted model** on the manipulator and aligner
+ bases (the imputer base already did), so sklearn-style chains such as
+ `Smooth().fit(x).transform(y)` and `HyperAlign().fit(xs).transform(ys)`
+ work instead of raising `AttributeError` on `None`.
+- **`predict='ARIMA'` on an animated plot no longer crashes.** The early
+ frames reveal two-row histories, and statsmodels raised an `IndexError`
+ on them. Forecasters now carry a `min_history` (ARIMA derives its own
+ from its order), `fit` raises a clear `ValueError` for a shorter history,
+ and the animated schedule waits until enough rows are revealed. The same
+ fix covers `predict=['Kalman', 'ARIMA']` under `animate=`.
+- **A datetime-like `t=` works inside `hyp.plot`** (static and animated),
+ resolved against each dataset's `DatetimeIndex`, as the docstring said.
+- **`predict=` lists and dicts work on row- and column-MultiIndex frames**
+ (one forecast per trace per model; the bundle is keyed by model name)
+ instead of failing an internal consistency check.
+- **`ndims=1` on a dated column-MultiIndex frame** draws dates for every
+ leaf, not only the first.
+- **`forecast_hue=` with a model collection** is one value per dataset,
+ shared across the models; a model-major list is also accepted, and a
+ mismatch names both counts.
+- **Series-mode `return_model`** returns one `(t, n_columns)` forecast array
+ per input dataset in `predict['forecasts']`, the shape `hyp.predict`
+ returns.
+- **`ndims=1` fixes.** `fmt=` lists are one entry per drawn column; `xlim=`
+ on a date axis accepts date strings and datetimes on both backends
+ (floats are matplotlib day numbers on both); no `'dataset 1'` y label
+ after a reducing `reduce=`; a 3-D `ax=` with `ndims<=2` raises instead
+ of drawing a flat 3-D line; a `TimedeltaIndex` is drawn in a readable
+ unit with a labelled axis.
+- **NaN rows introduced by a trailing `Smooth(center=False)`** are reported
+ as the manip stage's doing, with the `min_periods=1` hint, instead of
+ the all-features-missing message.
+- **Docstrings:** `font=` explains weights (bold resolves to the bundled
+ Bold face); `HyperAnimation.drawn_extent` documents its parameters;
+ `HyperAnimation.save` lists the supported extensions plainly.
+- **A marker-plus-line format string keeps its marker in the legend.** A
+ dataset drawn with `'s--'` (or `'o-'`) is split into a smoothed line and
+ markers at the raw sample points; the legend handle showed only the
+ line. It now shows the marker and the line, on static and animated
+ plots, and the line itself still draws no markers.
+- **`return_model=True` no longer fits the pipeline a second time.** The
+ bundle's `pipeline` is the one the figure was drawn with (the
+ cluster stage, which runs on the reduced scores, is appended as a
+ fitted step), so a UMAP or Isomap plot with `return_model=True`, and
+ every `panels=` grid built with it, fits once and warns once.
+- **Seeded UMAP no longer warns about `n_jobs`.** A `random_state=`
+ hypertools injects made umap-learn override `n_jobs` and say so;
+ hypertools now passes the `n_jobs=1` umap uses anyway, unless the
+ caller chose one.
+- **Isomap fits stay quiet about scipy's sparse-matrix efficiency.** The
+ dozen `SparseEfficiencyWarning`s scikit-learn's graph completion
+ triggers are silenced during the fit; sklearn's own warning about a
+ disconnected neighbour graph (the user's `n_neighbors`) still shows.
+- **A `truth=` overlay's legend glyph shows its markers.** The truth is
+ drawn as a solid line with a marker on every observation, but its
+ `'truth'` legend entry was a bare solid line in the trace's own colour,
+ identical to the observed trace's entry. The curve now keeps the marker
+ (drawing none of its own) so the legend can tell them apart.
+- **2-D `density=` layers fade out inside their own grid.** Each KDE grid
+ stopped 15% past its own dataset's bounding box, where the density is
+ still clearly visible, so a wide, flat cloud's glow was cut off in a
+ hard band well inside the frame. The grid now also reaches four kernel
+ widths past the data on both backends, where the density has faded to
+ nothing, while staying local to its own cloud (so a small cloud beside
+ a huge one keeps its resolution).
+- **`hyp.subplots(..., backend='plotly')` and `ax=`.** The
+ compose-it-yourself grid (`fig, axes = hyp.subplots(); hyp.plot(d,
+ ax=axes[i])`) had no plotly form: `ax=` took a plotly Figure to append
+ traces to, but could not target a `make_subplots` cell. `hyp.subplots`
+ gained `backend=` and, on plotly, returns the grid figure plus a flat
+ array of cells that `hyp.plot(..., ax=cell)` draws into -- the whole
+ panel, `title=` included -- returning the grid; several cell calls in
+ one notebook cell display the grid once.
+- **The slow-forecast-schedule notice no longer fires from timer noise.**
+ Its projection drew a slope through the first two timed fits, one row
+ apart at 2 and 3 rows -- tens of milliseconds each -- and on a slow CI
+ runner projected 10 s for a 30-row schedule that finished in well under
+ one. It now fits every timed length by least squares and waits for a fit
+ of at least 10 rows before projecting.
+- **A fitted forecaster reuses its parameters on a short context.** The
+ minimum-history check added for fitting (`Forecaster.min_history`) was
+ also applied when an already-fitted model was passed back as `model=`,
+ so a fitted `ARIMA(order=(4, 0, 0))` refused two new rows it can
+ condition on with its learned parameters. Only the refit path is held
+ to the fit floor now.
+- **Every `predict=` forecast is listed in the legend.** Only a collection
+ of models was; `predict='Kalman'` drew its faded continuation with no
+ key, and `truth=` then listed `observed` and `truth` beside an unnamed
+ dotted line. The single-model form now lists its forecast once, under
+ the model's name (the name `hyp.predict(x, model=[spec])` gives it),
+ static and animated, on both backends, in the order data, forecasts,
+ truth. The entry's glyph wears the forecasts' own style, in their colour
+ when they share one and in a neutral gray when one model's forecasts of
+ several datasets are drawn in several colours (the first dataset's
+ colour used to pose as the model's).
+- **A collection of models keeps each dataset's colour and takes a
+ linestyle per model.** `predict=['Kalman', 'ARIMA']` coloured every
+ forecast by model from a `'husl'` palette whose first colour was the
+ first dataset's own, so two datasets under two models were four lines
+ in two indistinguishable pairs, and which series a forecast continued
+ could not be read at all. Forecasts now inherit their dataset's colour
+ (as the single-model form always did) and cycle solid, dashed, dotted,
+ dash-dot by model; `forecast_palette=` opts back into one colour per
+ model, and `forecast_fmt=` still replaces the cycle.
+- **`truth=` on plotly marks every observation, not every vertex.** The
+ antialiased truth curve carried a marker on each of its ~900 drawn
+ vertices, so it rendered as a thick line; markers now sit on the raw
+ rows only, at the size the matplotlib overlay draws them.
+- **A caller's axes draw in the palette, and a second call continues
+ it.** `hyp.plot(x, ax=ax)` and every matplotlib `panels=` cell drew
+ the datasets in the colour cycle their figure was created with
+ (matplotlib's default blue/orange) while `return_model`'s `colors` and
+ the plotly grid reported the hls palette; the axes now take the
+ palette. Drawing a second time into the same axes or plotly figure
+ restarted the palette, so two composed walks were both red; the second
+ call now continues it from where the first stopped, on both backends.
+- **A recoloured forecast keeps its trace's alpha.** `forecast_palette=`,
+ `forecast_hue=` and `forecast_cluster=` recoloured the forecasts and
+ still halved their alpha, so Set1 forecasts at 0.35 over a hierarchy's
+ 0.7 leaves could not be found; the colour is what tells them apart, so
+ they are drawn at the trace's own alpha. The forecast legend glyph is
+ never drawn below 0.8 alpha either (it copied its forecasts' 0.35 and
+ vanished).
+- **The 2-D frame square clears the data.** Static 2-D plots rescale the
+ data into the unit box and drew the frame square AT its edge, so the
+ extreme observations sat on the frame line and looked clipped; the
+ square now has a 12.5 % margin (the axes stay 10 % beyond it) on both
+ backends.
+- **A 3-D figure's axis labels are inside its tight bbox.** `Axes3D`
+ measures its axes for layout only, dropping the labels, so a
+ `bbox_inches='tight'` save -- every notebook's inline render -- cut the
+ `zlabel=` off at the right edge; a figure artist now carries the three
+ labels' extents into the bbox.
+- **`legend_kwargs={'loc': ...}` places the legend there.** A `loc=`
+ without a `bbox_to_anchor=` kept hypertools' outside-right anchor, so
+ `'upper left'` hung the legend off the right edge; the anchor is
+ dropped when a location is named.
+- **Plotly legend keys are readable for tiny markers.** A `'.'` marker's
+ legend key reproduced the 2 px dot; legends now use plotly's constant
+ key size, as a matplotlib legend does.
+- **A collection of models under `hue=`/`cluster=` regrouping continues
+ the right run.** One dataset split into two runs under two models gives
+ two forecasts for two runs, so the step that matches each forecast to
+ its run -- which only ran when the counts differed -- was skipped and
+ forecast i continued run i: Kalman took the earlier run's colour, ARIMA
+ the final run's. It runs for every collection under regrouping now. The
+ animated modes also looked the reveal schedule up by forecast index
+ rather than by source dataset, so two models x regrouping x
+ `forecast_trail=` raised `IndexError` on both backends.
+- **A forecaster fitted on several datasets animates.** The animated
+ schedule forecasts each dataset's revealed history on its own, which a
+ `hyp.predict([a, b], return_model=True)` forecaster refused as a
+ dataset-count mismatch; `Forecaster.for_dataset(i)` now binds the view
+ the schedule needs.
+- **Plotly honours a colour letter and markers in `forecast_fmt=`.**
+ `forecast_fmt='ro:'` drew red dotted forecasts with round markers on
+ matplotlib and inherited-colour dotted lines without markers on plotly,
+ static and animated, legend keys included.
+- **Plotly animations keep a recoloured forecast's alpha too.** The
+ animated branch computed the halved alpha before the recolouring rule
+ applied, so `forecast_palette=` forecasts animated at 0.35 while the
+ static figure drew them at 0.7.
+- **A second call into the same plotly grid cell continues the palette**,
+ as a second call into the same matplotlib axes does.
+- **Plotly forecast legend keys compare colour, not opacity.** With
+ `alpha=[1, .4]` and an all-red forecast palette every key turned gray
+ because the RGBA strings differed only in alpha.
+- **`legend_colors=` keeps its contract beside forecasts.** Explicit
+ `(label, color)` pairs define the legend outright, so no forecast or
+ `truth` entry is added to them (and on plotly the data traces stay out
+ of it too); a plain colour list is applied to the FINAL legend, after
+ the forecast/truth entries, instead of being refused against the data
+ entries alone.
+- **Matplotlib panel legends clear their colorbars.** A panel with
+ `legend=True` and `colorbar=True` drew the attached colorbar under the
+ outside-right legend (~10 px overlap); the colorbar is padded past the
+ legend's measured overhang.
+- **Plotly grid cells keep an explicit legend position and multi-line
+ title room.** `legend_kwargs` x/y are translated into the cell instead
+ of being replaced by the default placement beside the cell, and
+ re-laying out the grid keeps the top margin a multi-line title had
+ reserved.
+- **A `forecast_fmt=` colour letter survives a regrouped animation.**
+ Under `hue=`/`cluster=` the animated modes repaint each live forecast
+ in its head run's colour unless the colour is pinned, and only
+ `forecast_hue=`/`forecast_cluster=`/`forecast_palette=` counted as
+ pinning: `forecast_fmt='ro:'` forecasts animated cyan under red legend
+ keys on matplotlib, and plotly's per-frame colours halved the alpha a
+ recoloured forecast keeps.
+- **Mixture-hue legends list forecasts and `truth`.** A matrix `hue=`
+ builds its legend from swatches (clearing `legend=` on the way), and
+ the forecast/truth entries were only added when `legend=` was still
+ set, so those legends read `1, 2` alone on both backends.
+- **Repeated calls into one axes, figure or grid cell compose.** The
+ forecast and `truth=` overlays styled themselves from the FIRST call's
+ lines on a reused matplotlib axes (three walks, three red forecasts),
+ and the legend accumulated one `truth` per call while losing earlier
+ forecast keys; the overlays now take this call's lines and the legend
+ is rebuilt by role -- the data entries, one key per model over every
+ call's forecasts, one `truth` -- on both backends and in plotly cells.
+- **A plotly cell's legend and colorbar from separate calls sit side by
+ side**, and a multi-line title re-lays out the rows at once. A colorbar
+ added after a legend used to land on it, and a three-line title widened
+ the top margin but left the rows 37 px apart; the grid now keeps track
+ of what each cell has drawn beside and above it, and sizes the space
+ beside every cell for the busiest one.
+- **Plotly draws a marker-only `forecast_fmt` as markers**, as matplotlib
+ does, and its animated collection traces tag their source dataset in
+ `trace.meta['hyp_dataset']` rather than the model-major forecast index.
+- **A `hyp.subplots(backend='plotly')` grid grows its legend room only
+ when a cell asks for it.** The grid reserved 118 px beside every cell
+ up front (it cannot know which cells will draw a legend), so a
+ legend-less pair of cells sat left-heavy with cubes three quarters the
+ size of the matplotlib pair's. It is now built as tight as `panels=`
+ draws it, and the first cell that receives a legend or colorbar
+ re-lays out the grid with that room, moving the cells already drawn
+ (their legends, colorbars and titles included).
+- **Streaming plots work when plotly is the render backend.** Colab and
+ Kaggle select plotly by default, and there every streaming `hyp.plot`
+ (a generator, a Hugging Face `IterableDataset`, `hyp.io.lsl_stream()`)
+ raised `AttributeError: 'HyperPlotlyFigure' object has no attribute
+ 'axes'`, as did any stream after `hyp.set_interactive_backend('plotly')`.
+ The head plot now always renders with matplotlib, as the streaming
+ docstring states. The `streaming_data`, `lsl_streaming` and `io`
+ tutorials failed at their streaming cells on Colab because of it. Present
+ since 1.0.0.
+- **`xlabel=`, `ylabel=` and `zlabel=` join the font-coverage scan.** The
+ scan that picks an installed font for characters the default font stack
+ lacks read `labels=`, `legend=`, `title=`, `hue=` and the colorbar text,
+ but not the axis labels. An axis label in such a script (Javanese on
+ stock macOS; CJK on a Linux machine whose CJK font is outside the stack)
+ drew as empty boxes, while the same text as a title rendered. Present
+ since 1.0.0.
+- **A `hue=` surface matches the points beneath it.** Each hull vertex
+ blended every point in its dataset with inverse-squared-distance weights;
+ in 3-D the many distant points outweighed the near ones, so the hull took
+ the dataset's washed-out mean colour. Vertices now blend their nearest
+ points, on both backends.
+- **Markers sit on the observations.** `'o-'`, `markers=` and
+ `forecast_fmt='ro:'` put a marker on every antialiased vertex (about 900
+ for a 40-row path), drawing the line as a solid tube; now only the samples
+ are marked, static and animated (in an animation, the frame-grid vertex
+ nearest each sample), on both backends. An explicit `marker=` wins over
+ the fmt marker on matplotlib, and a continuous hue with `'o-'` in 1-D/2-D
+ shows its markers on plotly.
+- **Hue transparency.** Continuous-hue markers honour `alpha=` on both
+ backends, and translucent plotly 3-D lines keep their colour instead of
+ washing out to cyan.
+- **Plotly hover labels name what you point at.** They read "trace 0"; every
+ data trace now carries its legend label (category, dataset, series column,
+ model or 'truth'), a lone unlabelled dataset shows only its coordinates,
+ and animated legends no longer grow entry by entry.
+- **Plotly subplot cells.** Colorbars no longer land on the next cell, an
+ untitled call keeps the cell's title, a dimensionality mismatch raises a
+ clear error, your own traces are left untouched, and a plotly `ax=`
+ implies the plotly backend.
+- **Plotly `frame_kwargs=`, `zoom=` and legend position.** `frame_kwargs=`
+ styles the plotly frame, static figures ignore `zoom=` (animation-only, as
+ documented), and `legend_kwargs={'x': 0, 'y': 1}` anchors the legend at
+ that corner.
+- **Plotly date axes show the same dates in every time zone.** Numeric dates
+ were drawn in the viewer's local time, so a series starting at midnight on
+ 1 January began on the evening of 31 December in New York.
+- **Composing into `ax=` no longer repeats a palette colour.** `'hls'` drawn
+ 2 + 2 now gives the four-colour `'hls'` set, and the bundle's `colors` and
+ the colorbar show the colours actually drawn.
+- **Dict-list palettes colour marker plots.** `fmt='o'` ignored a per-
+ dataset list of `{category: color}` dicts.
+- **Per-dataset and nested `labels=` survive `hue=`/`cluster=`** instead of
+ crashing on both backends; label arrays and Series are accepted.
+- **Legends.** A nested-list input's legend names its outer groups instead
+ of four leaves in two colours; cluster and integer-hue line legends list
+ categories in order (0, 1, 2), as the marker path did; `legend=False` wins
+ over `names=`; `panels=` splits a plain `legend_colors=` list per panel;
+ and `legend_colors=` accepts one colour per data entry beside forecast and
+ truth entries.
+- **Label connectors and box edges are visible on matplotlib.** Under the
+ seaborn style they were drawn white, so labels floated with no visible
+ link and cut notches through markers.
+- **Caller-axes and panel titles and axis labels use the Noto Sans stack**
+ instead of DejaVu Sans. `font='Noto Sans'` (the bundled face) works in a
+ fresh process.
+- **Two-column data draws into a 2-D `ax=`** instead of raising "the plot is
+ 3D".
+- **0-255 colour lists raise `ValueError`.** `palette=[[255, 128, 0], ...]`
+ was silently read as a data matrix, reordering and rescaling the colours;
+ the error says to divide by 255 or pass a DataFrame.
+- **The NaN-hue warning counts observations** (it counted antialiased
+ vertices) and points at the caller's line.
+- **Forecasts and truth keep their own dataset's style with `'o-'`.** Each
+ marker-plus-line dataset was drawn as two artists, so three datasets'
+ forecasts came out red, red, green.
+- **A one-column trace is drawn against its row index.** Antialiasing put a
+ 40-row line at x 0..936, squashing its forecast 24x; `axis_scale='data'`
+ also gave the value range to x.
+- **`ndims=1` `truth=` takes one column of values per trace.** A two-column
+ truth used its first column as x, stretching a date axis back to 1970; it
+ now raises `ValueError`.
+- **Marker-only `hue=`/`cluster=` always refuses forecasts and warns**, even
+ when the category count equals the dataset count (the forecasts were
+ silently drawn in the wrong category's colour).
+- **The 'truth' legend key is gray when truths span several colours**,
+ instead of always showing dataset 0's colour.
+- **Animated forecasts on two-column data no longer crash** with "too many
+ values to unpack".
+- **`xlim=(None, date)` works on date axes**; the open side takes the data
+ bound.
+- **`panels=` accepts `forecast_trail=`** alongside `predict=`.
+- **`transform=` fixes.** A bare array is one dataset instead of crashing, a
+ DataFrame with its own index no longer gives all-zero forecasts, and a
+ polars frame no longer raises `SchemaError`.
+- **A shuffled time index is drawn in time order**, so the forecast joins
+ the end of the line (with a warning).
+- **`ndims=1` date ticks no longer collide**; matplotlib uses concise date
+ labels.
+- **Regular calendar data are forecast on their own calendar.** Business-
+ day, month-start, weekly, quarterly and tz-aware daily indexes, and
+ `PeriodIndex` data, are fitted on their own rows and forecast onto the
+ next business days, month starts or periods. Before, business-day bars
+ were interpolated onto calendar days and forecast onto weekends, month
+ starts drifted, a fall DST change duplicated a day, and periods came back
+ as timestamps.
+- **A fitted forecaster works across index kinds again.** A model fitted on
+ an array and reused on dated rows, or the reverse, raised an error about
+ `step`; it now steps in the new data's own units, as 1.0 did.
+- **ARIMA's minimum history includes `seasonal_order`.** A short seasonal
+ fit gets the "needs N observations" message instead of a bare `IndexError`
+ or `LinAlgError`.
+- **Time warnings appear once, and only when they apply.** A stacked panel
+ warns "not sorted" once per call instead of three times, and an explicit
+ `step=` on evenly spaced data no longer calls them irregular.
+- **`yahoo:` intraday bars keep their timestamps.** `interval='1h'` put
+ every bar at midnight, so `hyp.predict` rejected the index; intraday bars
+ are tz-aware in the exchange's time zone.
+- **A dict model spec with a flat parameter raises instead of silently
+ running defaults.** `{'model': 'PCA', 'whiten': True}` or
+ `cluster={'model': 'KMeans', 'n_clusters': 4, 'random_state': 0}` dropped
+ the extra keys; they now raise `ValueError` naming them and showing the
+ `'kwargs'` form, in `reduce` (including streaming), `cluster`, `manip`,
+ `align`, `impute`, `Pipeline`, `apply_model` and `text2mat`. The
+ documented `n_clusters` shortcut still works. Outer `**kwargs` next to a
+ dict spec now reach `manip`/`align` models, and a spec's `'args'` reach
+ streaming and `text2mat` models.
+- **`hyp.plot(x, pipeline=p)` draws the pipeline's clusters.** A fitted
+ trailing cluster step colours the figure with the fit figure's colours; it
+ was dropped silently.
+- **Aligner classes accept arrays.** `HyperAlign().fit(xs).transform(ys)` on
+ a list of NumPy arrays, or a single array, raised "Unsupported datatype";
+ the aligners accept what `hyp.align` does and return each dataset in its
+ input's form.
+- **`alignment_score(metric='dispersion')` rejects all-constant datasets.**
+ Datasets each constant at a different value scored exactly 1.0; they now
+ raise, like `'isc'`.
+- **Rows a `manip=` stage empties stop the pipeline at that stage.** A
+ trailing `Smooth(center=False)` no longer triggers misleading PPCA
+ imputation warnings or sklearn NaN errors; the error names the stage and
+ suggests `min_periods=1`.
+- **A fitted `Normalizer` accepts 1-D data.** A 1-D array, Series or list of
+ numbers is one column in both fit and transform.
+- **Offline errors say what happened.** A missing 25+ character bare name
+ lists the full resolution chain instead of a Google Drive cache miss, a
+ cached copy that fails to parse raises `HypertoolsIOError` naming the
+ file, and an extensionless remote `.npz` reports the `trust=True` error
+ instead of a parquet one.
+- **`load()`'s TypeError names polars frames**, which it accepts.
+- **`set_autoinstall` handles are quiet at exit.** A live handle printed
+ "Exception ignored ... TypeError" at interpreter shutdown; a re-entered
+ handle keeps its creation order.
+- **Align, impute and manip warnings point at your own line**, so deprecated
+ spellings are no longer hidden inside the library.
+- **`[density3d]` needs `scikit-image>=0.25.0`**, the first release with
+ Python 3.13 wheels.
+- **Core dependency floors have wheels for Python 3.10 through 3.13.**
+ The floors rise to numpy 2.1.0, pandas 2.2.3, scipy 1.14.1, matplotlib
+ 3.9.2, scikit-learn 1.5.2, statsmodels 0.14.3 and pillow 10.4.0, the first
+ release of each with CPython 3.13 wheels (the previous floors shipped
+ cp310-cp312 only, so a lowest-version install on 3.13 built them from
+ source). The README and `docs/doc_requirements.txt` match.
+- **`animate_market_sectors` reads ExxonMobil's full share history.** SEC's
+ ticker map now sends XOM to a new holding-company CIK (2115436) with a
+ single 2026-06-30 share count, so every earlier XOM month was
+ back-filled from that one value. The example pins XOM to Exxon Mobil
+ Corp's CIK (34088), whose reported counts start on 2009-06-30, and its
+ SEC cache files are keyed by CIK so a file fetched under the old mapping
+ is not reused. The example and tutorial also note that HON's 2026-06-30
+ SEC share count (316,940,010) is half its 2026-03-31 count (633,653,119).
+- **Animated lines keep every observation.** Lines were resampled onto one
+ row per frame, so a dataset with more rows than frames was drawn through
+ only some of its points (a 36-row helix in a 9-frame animation became a
+ zig-zag star at 46% of its radius, and labels were dropped). Every
+ observation is now a vertex of the animated line, `animate='spin'` draws
+ the rows unchanged, and reveal timing and frame counts are unchanged.
+- **Every morph transition frame moves.** Transitions sampled their own
+ endpoints, so a 2-frame transition only repeated the hold clouds.
+ Transition frames now fall strictly between the clouds in position, colour
+ and `alpha=`. The default morph dot is 4 pt on both backends (it was 1.5
+ pt, sub-pixel on plotly).
+- **Titles set in an `on_frame=` callback are visible on 3-D animations.**
+ With no `title=`, the `plot()` docstring's own example drew its title
+ above the canvas on matplotlib and cut it off on plotly; `on_frame=` now
+ reserves the title margin.
+- **`companion=` panels use the trajectory's colour** instead of
+ matplotlib's default blue; an explicit `color=` still wins.
+- **Short streams warn about clamped samples.** The warning needed 20 post-
+ head samples, so a short stream could draw most of its points on the box
+ surface silently; the clamped fraction is now checked again when streaming
+ stops.
+- **Plotly animations keep a continuous hue on the moving data.** 3-D
+ windows were painted in the trajectory's first colours, and 2-D lines
+ never animated: the whole trajectory stayed on screen while one segment
+ flickered. Heads and trails now carry their own colours in every reveal
+ style.
+- **Plotly 3-D lines are as thick as you ask.** WebGL drew Scatter3d lines
+ at half the requested width; they now match the 2-D line and matplotlib,
+ and plotly animations default to the documented 1 pt.
+- **Plotly 3-D density no longer speckles the cube.** The density volume
+ extended past the scene and broke the cube edges into dots; it is now
+ clipped to the cube.
+- **Plotly Play/Pause sit below the axis labels.** On a date or data x axis
+ they covered the tick labels.
+- **Forecast time warnings name your line, once.** The interpolation and
+ "not sorted" warnings pointed at hypertools' own files (tutorials printed
+ `.../hypertools/predict/common.py:435`), a calendar step read
+ `step=` and a float step `0.04000000000000001`, and a
+ shuffled index under `hyp.plot` warned twice. They now point at the
+ caller, print `step='B'` or `step=0.04`, and appear once per `plot()`
+ call.
+- **`hyp.describe`'s `'average'` is the average.** It was the curve for
+ every dataset stacked into one point cloud, which adds the
+ between-dataset distances, so the dashed "average" line was not the
+ average of the curves drawn beside it (on `weights_sample` at two
+ components: 0.756, against individual curves of 0.732, 0.781 and 0.829).
+ `'average'` is now the element-wise mean of the `'individual'` curves
+ (0.781 there), and the stacked curve is
+ still returned, as `'pooled'`, without being drawn. For one dataset all
+ three are equal. The x axis is ticked at whole component counts on both
+ backends (it showed 2.25, 2.5, ...). A bare array passed with
+ `format_data=False` is one dataset; it used to be split into rows and
+ raise `IndexError`.
+- **`hyp.describe(show=False)` returns its figure**, undisplayed, like
+ `hyp.plot(show=False)`: a matplotlib figure is closed out of pyplot but
+ stays savable, and a plotly figure is not shown. It used to skip drawing
+ and return `fig=None`, so `show=False` now costs the (small) drawing time
+ as well.
+- **matplotlib `axis_scale='data'` plots have no grid.** Seaborn's
+ `whitegrid` style, which hypertools draws under, left a grey grid on
+ data-scale axes (static, animated and `ndims=1` series plots)
+ that the plotly backend never drew. Both backends now draw none. A
+ caller's own `ax=` keeps whatever grid it already has.
+- **Two `plot()` docstring corrections.** A datetime `t=` on a flat list
+ was documented as having to resolve to the same number of steps for
+ every dataset; each dataset is in fact forecast up to that time on its
+ own index (only a hierarchical input needs one shared count). And the
+ `animate=` entry now says how large an animated plotly figure gets: a
+ 3000-row, 300-frame 3-D animation is about 6.7 MB of JSON, and
+ `resample=1000` brings it to about 2.3 MB.
+- **`hyp.describe` sweeps the dimensionality of a reducer instance.** A
+ configured instance such as `reduce=PCA(n_components=2)`, and a dict spec
+ that pins it (`{'model': 'PCA', 'kwargs': {'n_components': 2}}`), were
+ reduced to that one dimensionality at every point of the sweep while the
+ curve labelled the points 2, 3, 4, ... (on 60 x 6 random data: 0.739 four
+ times, against 0.739, 0.852, 0.930, 0.974 for `reduce='PCA'`; also in
+ 1.0). The sweep's dimensionality now always wins: an unfitted instance is
+ cloned for each point with `n_components` set to that point, keeping its
+ other settings and leaving the instance you passed unmodified and
+ unfitted, and an `n_components` in a dict spec's kwargs is ignored. Both
+ give the same curve as the name, without the "Unequal values passed to
+ dims and n_components" warnings. An already fitted model (including the
+ `Reducer` or `Pipeline` that `hyp.reduce(..., return_model=True)`
+ returns), whose dimensionality is fixed by its fit, and an instance with
+ no `n_components` parameter now raise a `ValueError` that says to pass a
+ name, a dict spec or an unfitted instance; they used to draw the flat
+ curve. `reduce=None`, which leaves nothing to sweep and drew a flat 1.0,
+ raises a `ValueError` too.
+- **`sklearn.base.clone` works on a `hyp.Pipeline`.** `Pipeline` is a
+ scikit-learn `BaseEstimator`, but `clone(pipeline)` raised `RuntimeError:
+ Cannot clone object ... as the constructor either does not set or
+ modifies parameter steps` (also in 1.0). It now returns an unfitted
+ pipeline with the same step names, order and settings and independent
+ copies of every step, including nested pipelines and the pipelines that
+ `return_model=True` returns. `hyp.apply_model(data, pipeline,
+ stack=False, return_model=True)`, which clones the model for each
+ dataset, therefore returns one fitted pipeline per dataset and leaves the
+ one you passed unfitted; it used to return the same pipeline for every
+ dataset, holding only the last dataset's fit (the transformed data is
+ unchanged). `set_params(steps=...)` resolves and names
+ the new steps as the constructor does (it used to store them raw), and a
+ nested `__` name is refused with an error that says to
+ use `named_steps`.
+- **`Normalize(mode='isotropic')` docstring correction.** It said a rotated
+ copy of a cloud is rescaled by the same scalar. The scalar is the largest
+ absolute coordinate deviation from the centroid, which depends on the
+ cloud's orientation (9.03 for a 200-point 2-D cloud, 7.21 for the same
+ cloud turned 45 degrees). The shape is preserved either way; the
+ behaviour is unchanged.
+
### Documented limitations
- Ragged groups (unequal feature counts per group) are rejected by both
entry points, by an error naming the missing and unexpected features. That
error's escape-hatch remedy is spelled for `hyp.plot`, so a `hyp.predict`
caller has to translate it: group with `group_columns(df,
- feature_correspondence='position')` and forecast the leaves
+ feature_correspondence='position')` (`from hypertools.core.hierarchy
+ import group_columns`) and forecast the leaves
(`hyp.predict([leaf.to_numpy() for leaf in leaves], model, t)`, verified).
- Unequal-length row groups are averaged over their overlapping prefix, with
one aggregated warning.
@@ -730,15 +1736,12 @@ input too.
datasets. Pass `reduce='PCA'` when block order must not matter.
- Continuous `hue=` over a **row** hierarchy is still warned-and-ignored;
only column hierarchies honour it in 1.1.
-- A forecast under a continuous `hue=` takes its source trajectory's **final
- observed hue colour**, in the animated case as well as the static one, on
- both backends. (Animated forecasts briefly wore the per-dataset palette
- colour instead -- the colour of the hidden artist driving the reveal,
- which nothing visible is drawn in, so the forecast appeared to continue a
- colour its trajectory never had and a paused animation disagreed with the
- static plot of the same call.) A **categorical** regrouping is unchanged:
- there the live forecast still takes the colour of the run drawing the
- head, which is what the viewer actually sees.
+- Under a **categorical** `hue=`/`cluster=` regrouping, an animated live
+ forecast takes the colour of the run drawing the head, which is what the
+ viewer actually sees, so its colour can change as the head crosses a
+ category boundary. Under a continuous `hue=` a forecast instead takes its
+ source trajectory's final observed hue colour, animated and static alike
+ (see *Added*).
- Duplicate innermost feature names inside one group are **kept** rather
than rejected or de-duplicated, and matched across groups by
`(label, occurrence)`: all such columns are plotted and forecast. Rename
@@ -754,16 +1757,20 @@ input too.
## 1.0.1 (unreleased)
-Small, additive plotting features and fixes. Public APIs are unchanged; two
-items under **Changed** below alter how existing figures LOOK.
+Small, additive plotting features and fixes. Public APIs are unchanged;
+three changes alter how existing figures LOOK: smoothed lines
+(`antialias=True`, the new default, under **New features**) and the two
+items under **Changed**. Where 1.1.0 later refined one of these behaviours,
+the entry below says so.
> 1.0.1 was never published on its own. These changes were developed as a
> patch release and now ship as part of 1.1.0, which is what `pyproject.toml`
> declares; they are kept in their own section because they are separable
> from the hierarchy work above. Because 1.0.1 is not a version anyone can
> install, every guide and docstring that dates one of these behaviours dates
-> it to **1.1.0**; this heading is the only place the shipped package names
-> the patch line.
+> it to **1.1.0**; this section is the only user-facing place that names
+> the patch line. If you are upgrading from 1.0.0, everything from here down
+> to the `## 1.0.0` heading is new to you as well.
### New features
@@ -808,8 +1815,8 @@ items under **Changed** below alter how existing figures LOOK.
- **`predict=` now works with the time-progressing animations too**
(`animate=True`/`'parallel'`/`'serial'`/`'window'`). The forecast is
- recomputed from the history revealed so far and re-anchored on the last
- revealed observation, so the forecast trace grows with the animation instead
+ recomputed from the history revealed so far and drawn from the endpoint of
+ the current frame, so the forecast trace grows with the animation instead
of standing still. Because the data is static -- all of it known before the
first frame, merely revealed over time -- every forecast the animation will
ever draw is computed up front. Two things follow: the whole fan is folded
@@ -852,10 +1859,14 @@ items under **Changed** below alter how existing figures LOOK.
grows with the DATA, not the frame count: 3 datasets x 60 rows x 900 frames
is 177 fits (~5 s), while 3 x 500 x 900 is 1497 fits (~330 s) -- a longer
series has both more distinct histories and a costlier fit each. `plot()`
- now times the first real fit and warns if the projection exceeds
- `slow_warning_seconds=` (default 10; pass `None` to silence), so a long
- wait is expected rather than mysterious. The notice arrives before the
- wait, not after it.
+ now times the fits as they run and warns if its projection of the total
+ exceeds `slow_warning_seconds=` (default 10; pass `None` to silence), so
+ a long wait is expected rather than mysterious. The notice arrives before
+ the wait, not after it. (As first written the projection came from the
+ first timed fit; 1.1.0 waits for fits at two or more history lengths,
+ one of them at least 10 rows long (or the longest the schedule has), and
+ projects from a least-squares line through their timings -- see *Fixed
+ during the release review*.)
Deliberately NOT solved by sampling the reveal: striding the schedule would
render a different animation than the one asked for. The outcome is not
@@ -866,7 +1877,11 @@ items under **Changed** below alter how existing figures LOOK.
the data.** Inheritance stays the default -- a forecast is its observed
trace projected forward at half its alpha -- and each of these replaces
exactly one aspect of it, so observed and forecast data may differ in
- style, grouping, palette, or any combination.
+ style, grouping, palette, or any combination. (1.1.0 refines this: a
+ forecast recoloured by `forecast_palette=`, `forecast_hue=` or
+ `forecast_cluster=` keeps its trace's full alpha, and a collection of
+ models takes one linestyle per model; see *Fixed during the release
+ review*.)
**`forecast_cluster=` clusters the forecast ENDPOINTS**, so a forecast's
colour answers *which of these series are heading to the same place?* --
@@ -1010,14 +2025,15 @@ items under **Changed** below alter how existing figures LOOK.
- **Per-dataset `alpha=`, alongside the existing per-dataset
`color=`/`linewidth=`.** Inputs that assign alpha internally (row
- `MultiIndex` frames, nested lists) keep their own values and now say so
- with a warning instead of losing silently.
+ `MultiIndex` frames, nested lists of varying depth) keep their own values
+ and now say so with a warning instead of losing silently.
- **Per-segment `title=` for serial-style animations, on both backends.**
Pass a list of strings (one per dataset) to name each segment of a
serial-style animation as it is revealed; for `animate='morph'` the holds
are named and the transitions are left blank automatically. Anywhere else
- a non-string `title=` raises `TypeError`.
+ a `title=` that is neither a string nor a callable raises `TypeError`
+ (1.1.0 added callable titles; see *Titles that follow the data*).
- **`simplify=` on `plot()` (default `True`).** Today it governs
`animate='morph'` tractability only: over clouds larger than 2000 points
@@ -1037,7 +2053,11 @@ items under **Changed** below alter how existing figures LOOK.
`alpha=` is matplotlib's opaque 1.0, so the default forecast alpha is
`0.5`). Per-dataset styling carries through dataset by dataset --
`alpha=[1.0, 0.4]` gives forecasts at `[0.5, 0.2]`, and a dotted dataset
- gets a dotted forecast.
+ gets a dotted forecast. (1.1.0 refines this: a forecast recoloured by
+ `forecast_palette=`, `forecast_hue=` or `forecast_cluster=` is drawn at
+ its trace's own alpha, and a collection of models keeps each dataset's
+ colour and cycles the linestyle per model; see *Fixed during the release
+ review*.)
This is a **visible change to existing forecast figures**, and it
deliberately replaces the previous rule: every forecast used to be drawn
@@ -1144,8 +2164,9 @@ items under **Changed** below alter how existing figures LOOK.
style, or a static plot with the default `antialias=True`). Bridged labels
now grow in lockstep with the bridged data.
-- **`title=` no longer stringifies a list onto the axes.** A non-string
- `title=` now raises `TypeError` instead of drawing the literal
+- **`title=` no longer stringifies a list onto the axes.** A `title=` that
+ is neither a string nor a callable (1.1.0 added callable titles) now
+ raises `TypeError` instead of drawing the literal
`"['a', 'b', 'c']"` text, and the check runs before the analyze pipeline,
so streaming plots (`plot_stream`) get it too.
diff --git a/CLAUDE.md b/CLAUDE.md
index 0d46a4fb..eaf00601 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -67,7 +67,7 @@ The dev-1.0 refactor moved several tools into their own top-level subpackages (e
- `hypertools/predict/` - Forecasting models (`predict.py`, `arima.py`, `autoreg.py`, `gp.py`, `kalman.py`, `laplace.py`, `chronos.py`, `common.py`)
- `hypertools/impute/` - Imputation models (`impute.py`, `ppca.py`, `kalman.py`, `sklearn_imputers.py`, `common.py`)
- `hypertools/core/` - Shared config/exceptions and `apply_model()`/`Pipeline` (`configurator.py`, `exceptions.py`, `model.py`, `pipeline.py`, `shared.py`)
-- `hypertools/_shared/lazy_import.py` - On-demand installation of optional extras: `lazy_import(module, purpose=)` imports a module and, if it is missing, pip-installs the hypertools extra that provides it (requirement strings read from the installed package metadata, so `pyproject.toml` is the single declaration; only the import-name -> extra map lives in the module), then imports again. `HYPERTOOLS_AUTO_INSTALL=0` disables it. `ensure_kaleido_chrome()` provisions Chrome (and, on Debian/Ubuntu images, its system libraries) for plotly static export. Every optional-dependency site goes through it; never hand-write a `pip install` hint elsewhere.
+- `hypertools/_shared/lazy_import.py` - On-demand installation of optional extras: `lazy_import(module, purpose=)` imports a module and, if it is missing, pip-installs the hypertools extra that provides it (requirement strings read from the installed package metadata, so `pyproject.toml` is the single declaration; only the import-name -> extra map lives in the module), then imports again. `hyp.set_autoinstall(False)` (public, also a context manager) disables it; `HYPERTOOLS_AUTO_INSTALL=0` sets the starting value. `ensure_kaleido_chrome()` provisions Chrome (and, on Debian/Ubuntu images, its system libraries) for plotly static export. Every optional-dependency site goes through it; never hand-write a `pip install` hint elsewhere.
**Plot Module** (`hypertools/plot/`)
- `plot.py` - Main plotting interface and logic
diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md
index c40a6076..3cf41942 100644
--- a/RELEASE_CHECKLIST.md
+++ b/RELEASE_CHECKLIST.md
@@ -1,75 +1,109 @@
# HyperTools 1.1 release checklist
-The docs, notebooks, and README deliberately ship in **dev form** on the
-`dev-1.0` branch and must flip to **release form** at publish. Several of those
-flips cannot be done earlier (PyPI has 1.0.0 but not 1.1.0; the `v1.1.0` tag does
-not exist yet), so they are done here, on `master`, in order — and the
-**`release-gate` CI job** (runs only on `master` / tags) hard-fails until every
-flip is done, so nothing can be forgotten.
+The docs, notebooks, and README ship in **release form** on `master` (the 1.1.0
+draft: `master` == tag `v1.1.0` == `96ac8b7f`, gallery published under
+`docs-notebooks/v1.1.0/` from that commit, a DRAFT GitHub release with wheel +
+sdist attached, nothing on PyPI yet). Fixes found by the 1.1 release review
+land through PR #286 (`fix/1.1-release-review`), so the release is **re-cut**
+from the merge commit: the gallery manifest pins an exact `source_commit`, and
+the `release-gate` CI job (runs only on `master` / tags) hard-fails until the
+manifest, the tag and the artifacts all point at the final commit.
Run everything from a **clean `master` checkout on the `master` branch** (not a
detached tag checkout — the notebook migrator detects the branch via
`git rev-parse --abbrev-ref HEAD`, which returns `HEAD` when detached).
-## 0. Pre-flight (on `dev-1.0`)
+## 0. Pre-flight (on the fix branch, before merging)
-- [ ] **Push `dev-1.0` and open the integration PR (`dev-1.0` → `master`) FIRST.**
- The 1.1 line was developed with no hosted CI at all (the remote branch
- last moved 2026-07-23; ~185 commits since), so the PR's matrix CI is
- the first time these commits meet Linux, Windows and every supported
- Python. Nothing below happens until it is green.
-- [ ] `dev-1.0` CI fully green (push + PR workflows).
-- [ ] Full suite green locally: `pytest` (3700+ passed, 0 failed — including
- `tests/test_examples_are_native.py`, the Plan 4 gate, at 0 failed).
+- [ ] PR #286 CI fully green (matrix, `wheel-smoke`, `docs-clean`,
+ `dataset-gate`, `live-source-gate`).
+- [ ] Full suite green locally: `pytest` (about 6,800 passed, 0 failed —
+ including `tests/test_examples_are_native.py`, the native-usage gate,
+ at 0 failed).
+- [ ] Example smoke gate: `HYPERTOOLS_EXAMPLE_SMOKE=1 pytest tests/test_examples_are_native.py`
+ runs the six launch/forecast examples (`STATED_ARTIFACT`:
+ `animate_conversation`, `animate_forecast`, `animate_market_sectors`,
+ `animate_morph_zoo`, `animate_painting_embeddings`,
+ `animate_weather_decades`) end to end with their real loaders. The
+ other gallery scripts run in the sphinx-gallery build below. No CI job
+ runs the smoke gate, so it is a manual pre-release step.
- [ ] Local release validation, all green in the same tree: `ruff check .`,
- `cd docs && MPLBACKEND=Agg ../.venv/bin/python -m sphinx -b html -W -E -a . _build/html`
- (0 warnings), `pytest tests/test_packaging_artifacts.py`, and the five
- launch examples headless (`MPLBACKEND=Agg python examples/animate_*.py`).
-- [ ] Decide the release date and the version (`1.1.0`; `pyproject.toml`
- already says so, and `CHANGELOG.md` has a `## 1.0.1 (unreleased)` section
- that its own note explains was never published on its own — leave it).
+ `cd docs && rm -rf auto_examples && MPLBACKEND=Agg ../.venv/bin/python -m sphinx -b html -W -E -a . _build/html`
+ (0 warnings; delete `auto_examples/` first or stale pages of removed
+ examples fail `-W`; this build executes all 51 gallery scripts),
+ `pytest tests/test_packaging_artifacts.py`, and the five launch
+ examples headless: `for f in animate_market_sectors animate_weather_decades animate_painting_embeddings animate_conversation animate_morph_zoo; do MPLBACKEND=Agg python examples/$f.py || break; done`
+ (the `examples/animate_*.py` glob matches 11 scripts, not these five).
- [ ] Notebook hygiene: every committed tutorial was executed with
`scripts/execute_tutorial.py` (which skips the Colab install cell so the
- venv is not overwritten by the stale remote branch — see its docstring)
+ venv is not overwritten by a stale remote branch — see its docstring)
and `pip show hypertools` still reports an editable install afterwards.
+ Stored outputs carry no `/Users/...` paths (`git grep -n '/Users/' -- docs/tutorials`).
+- [ ] **Candidate feature tour on Colab (before sign-off and merge).** The
+ tour is a maintainer-local review artifact, not part of the repo:
+ `notes/colab/` is gitignored, so a clean checkout does not have it.
+ Its working copy is `notes/colab/hypertools_1.1_feature_tour.ipynb` in
+ the maintainer's checkout (kept in sync by
+ `scripts/update_feature_tour.py`). Set its `REVIEW_COMMIT` to the
+ candidate commit, which must be pushed (the tour installs
+ `hypertools[...] @ git+…@`), and upload ONE copy to
+ Colab, saved as `notes/colab/hypertools_1.1_candidate_.ipynb`
+ (one is kept per reviewed candidate; the newest is the current one).
+ Run it in a fresh runtime. It tests a Git candidate with comprehensive
+ extras, not a published wheel or missing-extra installation. Run it
+ locally too: `scripts/execute_tutorial.py --out-dir /tmp/tour-check
+ notes/colab/hypertools_1.1_feature_tour.ipynb`. Retain the executed
+ notebook, JSON/CSV reports, source hashes, dependency versions, decoded
+ exports and visual verdicts. Inspect early previews after Run all, then
+ exercise the shared interactive viewer and downloads. Resolve
+ failures, blocked checks, and any explicitly deferred manual checks
+ before release sign-off.
+- [ ] **Missing-extra policy.** In an isolated clean environment, check a
+ friendly error with autoinstall disabled and a real installation with
+ it enabled: `python scripts/verify_optional_install.py --output /tmp/optional-check`
+ installs the current source into a NEW temporary venv and never
+ touches the caller's interpreter. The comprehensive tour's eager
+ extras are not evidence for this behavior. Never remove packages from
+ a working research environment to manufacture the missing-extra
+ condition.
- [ ] **conda-forge** is no longer a prerequisite: the feedstock
(`conda-forge/hypertools-feedstock`) exists since 1.0.0 and its bot bumps
on each PyPI release (step 7).
## 1. Merge to `master`
-- [ ] Merge `dev-1.0` → `master` (the integration PR from step 0). Do NOT
- delete `dev-1.0` yet (the pre-release notebooks still reference it until
- step 2 runs).
+- [ ] Merge PR #286 → `master`. Its description carries `Closes #284` and
+ `Closes #285`, so both tracking issues close on merge; confirm they did.
## 2. Flip everything to release form (on `master`)
- [ ] **Notebooks → PyPI spec + clean note (automated).**
`python scripts/add_colab_install_cell.py`
- Retargets every committed tutorial install cell `... @ git+…@dev-1.0` →
+ Retargets any committed tutorial install cell `... @ git+…@` →
`hypertools[]` (extras preserved) and strips the
- `( preview)` / "On release this becomes …" note. The gallery
+ `( preview)` / "On release this becomes …" note. The 1.1.0
+ notebooks are already in this form, so this is a no-op check. The gallery
(`docs/auto_examples/*.ipynb`) is gitignored and REGENERATED by
`docs/conf.py` on each build, which emits the identical PyPI line on a
`master`/tag build (the migrator only retargets any on-disk copy, and the
`docs-clean` CI job release-gates the generated gallery).
-- [ ] **README images: commit SHA → `v1.1.0` tag (8 URLs).**
- `sed -E -i '' 's#(/ContextLab/hypertools/)[^/]+(/images/)#\1v1.1.0\2#g' readme.md`
- (drop the `''` after `-i` on GNU sed). This retargets WHATEVER ref is
- pinned (robust to the SHA having drifted), not just `fc2429cb`. Verify the
- POSITIVE: `grep -c '/ContextLab/hypertools/v1.1.0/images/' readme.md` → 8
+- [ ] **README images: pinned to the `v1.1.0` tag (8 URLs).** The 1.1.0
+ README already pins `v1.1.0`, so this is a verify-only step: check
+ `grep -c '/ContextLab/hypertools/v1.1.0/images/' readme.md` → 8
and `grep -Ec '/ContextLab/hypertools/[0-9a-f]{7,40}/images/' readme.md` → 0.
-- [ ] **CHANGELOG date.** Edit `CHANGELOG.md`: `## 1.1.0 (unreleased)` →
- `## 1.1.0 (YYYY-MM-DD)` with the real release date.
-- [ ] (Optional prose) `docs/tutorials/stock_forecasting.ipynb` has a
- free-text "hypertools 1.0 preview" comment the migrator does not touch —
- reword if desired (not gate-enforced).
+ Only if a ref has drifted, retarget it:
+ `sed -E -i '' 's#(/ContextLab/hypertools/)[^/]+(/images/)#\1v1.1.0\2#g' readme.md`
+ (drop the `''` after `-i` on GNU sed), then re-run both greps.
+- [ ] **CHANGELOG date.** Edit `CHANGELOG.md`: the `## 1.1.0 (YYYY-MM-DD)`
+ heading must carry the date of the FINAL release commit (the draft is
+ dated 2026-09-04; a re-cut on a later day updates it). The release
+ gate fails a heading dated earlier than the release commit.
- [ ] **Verify the file-content gates locally BEFORE committing.** Exclude the
gallery-resolve gate — it checks the *remote* `docs-notebooks` branch,
published in the next step, so it cannot pass yet:
`HYPERTOOLS_REQUIRE_RELEASE=1 pytest -v tests/test_notebook_install_gate.py tests/test_release_readiness_gate.py -k 'not gallery_colab_notebooks_are_published'`
→ all green (no branch installs, no preview note, images on the tag,
- CHANGELOG dated).
+ CHANGELOG dated no earlier than the release commit).
- [ ] Commit all of the above on `master` in one release commit.
- [ ] **Publish the gallery notebooks NOW — before any release gate needs them
(this is what breaks the publish-order deadlock).** The gallery "Open in
@@ -79,8 +113,9 @@ detached tag checkout — the notebook migrator detects the branch via
be published before you push, not after. Build the gallery and publish:
`cd docs && make html` then, from the repo root,
`python scripts/publish_gallery_notebooks.py --ref v1.1.0 --notebooks-dir docs/auto_examples --push`
- (the `docs-notebooks` branch already exists from 1.0.0; this adds the
- `v1.1.0/` namespace and writes `v1.1.0/manifest.json`). Publishing static
+ (the `docs-notebooks` branch already exists; the script deletes and
+ rewrites the whole `v1.1.0/` namespace and its `manifest.json`, so a
+ re-cut republishes cleanly over the draft's publish). Publishing static
notebooks before PyPI is harmless: their `%pip install hypertools[...]`
cells resolve 1.1 the moment PyPI is updated (step 6).
Both the `master` "latest" docs and the `v1.1.0` "stable" docs resolve to
@@ -109,9 +144,11 @@ same artifacts you verify are the ones you publish.
`dist/hypertools-1.1.0.tar.gz` + `…-py3-none-any.whl`.
- [ ] `twine check dist/*` → PASSED.
- [ ] Artifacts bundle the fonts + all license materials (font OFL, Apache-2.0
- license + third-party notices for the vendored brainiak/ppca, CHANGELOG):
+ license + third-party notices for the vendored brainiak/ppca):
`tar tzf dist/*.tar.gz | grep -E 'NotoSans|OFL|LICENSE-APACHE|THIRD_PARTY|CHANGELOG'`
- (5+ hits) and the same on the wheel via `unzip -l dist/*.whl`.
+ (6 hits: the CHANGELOG ships in the sdist only, via `MANIFEST.in`)
+ and `unzip -l dist/*.whl | grep -E 'NotoSans|OFL|LICENSE-APACHE|THIRD_PARTY'`
+ (5 hits).
- [ ] Fresh-venv smoke: install the wheel in a throwaway venv, `import hypertools`, `hypertools.__version__ == '1.1.0'`.
- [ ] Record artifact digests: `shasum -a 256 dist/*` (keep with the build
commit; verify these exact files are the ones uploaded in step 6).
@@ -127,12 +164,17 @@ same artifacts you verify are the ones you publish.
## 5. Tag the green commit + wait for tag CI
-- [ ] `git tag -a v1.1.0 -m "HyperTools 1.1.0"` at the **exact commit that just
- went green** on `master`.
-- [ ] `git push origin v1.1.0` (the workflow's `tags: ['v*']` trigger runs CI
- on the tag).
-- [ ] Wait for the `v1.1.0` tag CI to go GREEN (same jobs; `release-gate` +
- `docs-clean` gallery scan run on the tag too).
+- [ ] The `v1.1.0` tag already exists on `origin` at the draft commit
+ (`96ac8b7f`). Move it to the **exact commit that just went green** on
+ `master`: `git tag -fa v1.1.0 -m "HyperTools 1.1.0" ` then
+ `git push --force origin refs/tags/v1.1.0`. A moved tag is safe ONLY
+ because nothing has been published from the old one (PyPI still has
+ 1.0.0, the GitHub release is a draft); once PyPI has 1.1.0 the tag is
+ frozen.
+- [ ] Confirm: `git ls-remote origin refs/tags/v1.1.0^{}` == the new sha.
+- [ ] Wait for the `v1.1.0` tag CI to go GREEN (the workflow's `tags: ['v*']`
+ trigger runs the same jobs; `release-gate` + `docs-clean` gallery scan
+ run on the tag too).
## 6. Publish to PyPI (the already-verified artifacts) + smoke
@@ -147,8 +189,17 @@ same artifacts you verify are the ones you publish.
stale artifact): `twine upload dist/hypertools-1.1.0.tar.gz dist/hypertools-1.1.0-py3-none-any.whl`.
(The static notebooks briefly resolving the previous PyPI release before
this upload is harmless.)
-- [ ] Create a **GitHub Release** for the `v1.1.0` tag with the 1.1 release
- notes (from `CHANGELOG.md`).
+- [ ] **GitHub Release**: the DRAFT release for `v1.1.0` already exists with
+ the draft commit's wheel + sdist attached. Replace both assets with the
+ step-3 files (`gh release upload v1.1.0 dist/hypertools-1.1.0.tar.gz dist/hypertools-1.1.0-py3-none-any.whl --clobber`),
+ replace its body with `notes/release_notes_v1.1.0_draft.md` (re-check
+ it against the final `CHANGELOG.md` first, and curl its "Links": the
+ `/en/stable/tutorials/` pages 404 until the Read the Docs tag build
+ below, and the PyPI release exists once
+ `https://pypi.org/pypi/hypertools/1.1.0/json` stops answering 404 (the
+ HTML project page answers 200 for any version number); the draft body
+ attached to the release predates the release review), confirm it
+ targets the moved tag, then publish it.
- [ ] `pip install hypertools` in a clean env → installs `1.1.0`; run the
README quick-start snippet.
- [ ] **Gallery notebooks: confirm still resolved.** They were already
@@ -158,12 +209,34 @@ same artifacts you verify are the ones you publish.
(Publication is a MANUAL step today — there is no CI job for it; a
`contents: write` `publish-gallery-notebooks` job on master/tags could
automate it once token/environment handling is decided.)
-- [ ] **Read the Docs**: trigger/confirm a build of the `v1.1.0` tag (and
- point the "stable"/default version at it, replacing `v1.0.0`). The released docs' Colab
- install cells must show `%pip install "hypertools[interactive]"`
- (no `git+`) — `docs/conf.py` emits this automatically on a tag build.
+- [ ] **Read the Docs: build BOTH `latest` and the `v1.1.0` tag by hand.**
+ Pushes do not reach RTD at the moment: the GitHub → RTD webhook
+ (`https://readthedocs.org/api/v2/webhook/github/hypertools/`) last
+ answered HTTP 400 in GitHub's delivery log, and RTD has built nothing
+ since the 1.0.0 release (`latest`/`stable` at 647ce929, 2026-07-24),
+ although `master` moved on 2026-09-05. Re-sync the GitHub
+ integration in the RTD admin (Admin → Integrations,
+ https://app.readthedocs.org/dashboard/hypertools/integrations/), then
+ trigger builds of `latest` (master) and of the `v1.1.0` tag from
+ https://app.readthedocs.org/projects/hypertools/builds/, and point the
+ "stable"/default version at `v1.1.0`, replacing `v1.0.0`. Verify:
+ `curl -sI https://hypertools.readthedocs.io/en/latest/optional_dependencies.html | head -1`
+ → `HTTP/2 200` (it is 404 until `latest` is rebuilt, and the README
+ and PyPI page link it), and the same page under `/en/stable/`. The
+ released docs' Colab install cells must show
+ `%pip install -q "hypertools[interactive]"` (no `git+`) —
+ `docs/conf.py` emits this automatically on a tag build; the tutorials
+ carry the version-guarded `%pip install -q "hypertools[...]>=1.1.0"`.
- [ ] PyPI project page renders the README with all 8 images resolving (they
now point at the `v1.1.0` tag).
+- [ ] **Published-wheel smoke (only AFTER approved publication).** In a fresh
+ environment, install `hypertools[interactive]==1.1.0` with
+ `--only-binary=hypertools --report wheel-install.json`; do not fall back
+ to a tag/source checkout. Check the report's wheel URL/hash, installed
+ version and import path, then run representative plotting and forecasting
+ examples. Keep this evidence separate from candidate Git verification
+ (the Colab tour and the missing-extra check run in step 0, before any
+ upload).
## 7. conda-forge bump (the feedstock exists since 1.0.0)
@@ -177,19 +250,27 @@ feedstock automatically, usually within hours of the PyPI upload.
1.0.0 tag and carry any NEW or RAISED floor into `run:` by hand
(`matplotlib` stays `matplotlib-base`; `noarch: python`; the three
bundled licenses stay in `license_file`).
-- [ ] The `[predict]` / `[predict-hf]` / `[lsl]` extras stay pip-only
- (`skaters`, `chronos-forecasting`, `pylsl` are not on conda-forge); the
- base package is unaffected.
+- [ ] The `[predict]` and `[lsl]` extras stay pip-only: `skaters` and
+ `pylsl` are not on conda-forge (checked 2026-09-11). `chronos-forecasting`
+ is (2.3.2 on 2026-09-11), so `[predict-hf]` could be expressed in the
+ recipe if wanted. The base package is unaffected either way.
- [ ] Merge; after the feedstock builds, verify in a clean env:
`conda install -c conda-forge hypertools` installs `1.1.0` and
`import hypertools` works.
-## 8. Cleanup
+## 8. Announce
+
+- [ ] Bluesky launch thread from `notes/bluesky-launch/` (gitignored): re-verify
+ the atproto limits with curl before posting (they have moved between
+ drafts), count graphemes with the `regex` module's `\X`, and expect the
+ tutorial "Full code" links to 404 until Read the Docs has built the tag.
+
+## 9. Cleanup
- [ ] After the release is confirmed good, delete the `dev-1.0` /
- `dev-1.0-refactor` branches if desired (maintainer's call; 1.0.0's
- checklist deferred it too) (the released artifacts no longer
- reference them; the `release-gate` guarantees this).
+ `dev-1.0-refactor` / `fix/1.1-release-review` branches if desired
+ (maintainer's call; the released artifacts do not reference them; the
+ `release-gate` guarantees this).
## What the `release-gate` enforces (so you can't forget)
@@ -203,7 +284,7 @@ run with `HYPERTOOLS_REQUIRE_RELEASE=1` by the `release-gate` CI job on
| notebook install-cell note | no `(… preview)` / "On release this becomes …" |
| README image URLs | `…/ContextLab/hypertools/v/images/…` — the tag EXACTLY equal to `v` + pyproject version, not any semver or a commit SHA |
| README branch refs | no `dev-1.0-refactor` / `hypertools.git@dev…` |
-| CHANGELOG heading | `## (YYYY-MM-DD)` — version == pyproject, and a REAL calendar date (not `(unreleased)`, not `2026-99-99`) |
+| CHANGELOG heading | `## (YYYY-MM-DD)` — version == pyproject, a REAL calendar date (not `(unreleased)`, not `2026-99-99`), and not earlier than the release commit's date (a stale draft date fails) |
| generated gallery (`docs-clean` job) | every built `docs/auto_examples/*.ipynb` carries the PyPI spec (covers every published notebook, at the build layer) |
| gallery Colab notebooks published | `docs-notebooks/v/manifest.json` present, its `source_commit` == the release HEAD, its inventory == the built gallery, and the branch's actual `.ipynb` set (one GitHub tree request) == the manifest — so stale (old-RC), partial, or mismatched publishes all fail. Requires step 2's publish to have run FROM the release commit, BEFORE the master/tag push — see the deadlock note there. |
diff --git a/docs/_gallery_scrapers.py b/docs/_gallery_scrapers.py
new file mode 100644
index 00000000..a61ebf2c
--- /dev/null
+++ b/docs/_gallery_scrapers.py
@@ -0,0 +1,66 @@
+"""Image scrapers the sphinx-gallery build uses (see ``image_scrapers`` in
+conf.py).
+
+Kept in its own module, free of conf.py's import-time side effects, so
+tests/test_docs_site_fixes.py can drive the scraper the way sphinx-gallery
+does (once per code block) and count what it emits.
+"""
+import weakref
+
+#: every animation already handled for the running build. Weak, so an
+#: animation that an example rebinds and drops is forgotten with it (an
+#: ``id()`` set would mistake a new animation at a recycled address for an
+#: old one and skip it).
+_SCRAPED = weakref.WeakSet()
+
+
+def matplotlib_and_hyperanimation_scraper(block, block_vars, gallery_conf,
+ **kwargs):
+ """sphinx-gallery's matplotlib scraper, plus the animations it misses.
+
+ sphinx-gallery's matplotlib scraper walks ``plt.get_fignums()`` and pairs
+ each MANAGED figure with any ``matplotlib.animation.Animation`` in the
+ example's namespace. A ``hyp.plot(..., show=False)`` animation leaves its
+ figure unmanaged by pyplot, so the launch examples (which bind the
+ ``HyperAnimation`` wrapper the call returns) rendered NOTHING in the
+ gallery -- measured 2026-09-03: their pages carried no image block at
+ all. This scraper therefore also renders, through sphinx-gallery's own
+ animation writer, every ``HyperAnimation`` (or bare ``Animation``) in the
+ namespace that the matplotlib scraper did not.
+
+ Each animation is rendered exactly ONCE. Two things used to break that
+ (built site, 2026-10: examples/animate.py showed five videos for its two
+ ``hyp.plot`` calls) when this ran as a separate scraper AFTER the
+ matplotlib one:
+
+ * the matplotlib scraper closes every pyplot figure when it finishes, so
+ by then the animation it had just rendered no longer looked "managed"
+ and was rendered a second time;
+ * an example's namespace persists across its code blocks, so every
+ animation bound by an earlier block was rendered again in each later
+ block.
+
+ So the set of managed figures is read BEFORE the matplotlib scraper runs,
+ and every animation handled (by either path) is remembered.
+ """
+ from pathlib import PurePosixPath
+ import matplotlib.pyplot as plt
+ from matplotlib.animation import Animation
+ from sphinx_gallery.scrapers import _anim_rst, matplotlib_scraper
+ from hypertools.plot.hyper_animation import HyperAnimation
+
+ managed = {plt.figure(num) for num in plt.get_fignums()}
+ pending = []
+ for value in list(block_vars['example_globals'].values()):
+ ani = value.animation if isinstance(value, HyperAnimation) else value
+ if not isinstance(ani, Animation) or ani in _SCRAPED:
+ continue
+ _SCRAPED.add(ani)
+ if ani._fig not in managed: # else the matplotlib scraper has it
+ pending.append(ani)
+
+ rst = [matplotlib_scraper(block, block_vars, gallery_conf, **kwargs)]
+ for ani in pending:
+ image_path = PurePosixPath(next(block_vars['image_path_iterator']))
+ rst.append(_anim_rst(ani, image_path, gallery_conf))
+ return '\n'.join(part for part in rst if part)
diff --git a/docs/_static/custom.css b/docs/_static/custom.css
index 52be82f4..1d05c0ff 100644
--- a/docs/_static/custom.css
+++ b/docs/_static/custom.css
@@ -10,82 +10,142 @@
font-size: 2rem !important;
}
-/* Gallery styling improvements */
-.sphx-glr-thumbcontainer {
+/* ---- Gallery cards --------------------------------------------------
+ sphinx-gallery emits, per card:
+
+
+
+ and its own stylesheet stretches the invisibly over the whole card
+ while showing the (unlinked) title . An earlier rule here hid that
+ outright, which removed the ONLY link to the example page: the card's
+ title and body did nothing when clicked. Instead, show the as the
+ card's visible title link, drop the duplicate title , and stretch the
+ link's hit area over the card. The thumbnail's Colab link sits above that
+ hit area, so: image -> notebook on Colab, anything else -> example page. */
+.sphx-glr-thumbcontainer p {
+ position: static;
+ width: auto;
+ height: auto;
+ margin: 0;
+ text-align: center;
+ font-size: 14px;
+ line-height: 1.4;
+}
+
+.sphx-glr-thumbcontainer p a.reference {
+ position: static;
+ display: inline;
+ width: auto;
+ height: auto;
+ padding: 0;
+ color: var(--color-foreground-primary);
+ text-decoration: none;
+}
+
+.sphx-glr-thumbcontainer p a.reference span {
+ display: inline;
+}
+
+.sphx-glr-thumbcontainer p a.reference::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ z-index: 1;
+}
+
+.sphx-glr-thumbcontainer:hover p a.reference,
+.sphx-glr-thumbcontainer p a.reference:focus-visible {
+ color: var(--color-brand-content);
+ text-decoration: underline;
+}
+
+.sphx-glr-thumbcontainer .sphx-glr-thumbnail-title {
+ display: none;
+}
+
+.sphx-glr-thumbcontainer .hypertools-thumb-link {
position: relative;
- display: inline-block;
- cursor: pointer;
- margin: 10px;
+ z-index: 2;
+ line-height: 0;
}
.sphx-glr-thumbcontainer img {
- cursor: pointer;
transition: opacity 0.2s;
}
-.sphx-glr-thumbcontainer:hover img {
- opacity: 0.8;
+.sphx-glr-thumbcontainer .hypertools-thumb-link:hover img {
+ opacity: 0.7;
}
-/* Fix tooltips being cut off */
-.sphx-glr-thumbcontainer[tooltip] {
- position: relative;
+.sphx-glr-thumbcontainer:hover {
+ border-color: var(--color-brand-primary);
}
-.sphx-glr-thumbcontainer[tooltip]:before {
+/* Tooltip (the example's intro paragraph): a floating box below the card,
+ in the theme's own colours so it is legible in light and dark. This
+ replaces sphinx-gallery's in-card tooltip -- a ::before veil plus an
+ ::after text layer that cover the thumbnail and title -- rather than
+ adding a second one on top of it (both used to show at once). Below, not
+ above: above, the first row's tooltips ran off the top of the page. */
+.sphx-glr-thumbcontainer[tooltip]::before {
+ content: none;
+}
+
+.sphx-glr-thumbcontainer[tooltip]::after,
+.sphx-glr-thumbcontainer[tooltip]:hover::after {
content: attr(tooltip);
position: absolute;
- bottom: 100%;
+ top: calc(100% + 6px);
+ bottom: auto;
left: 50%;
transform: translateX(-50%);
- background: rgba(0, 0, 0, 0.9);
- color: white;
+ box-sizing: border-box;
+ width: max-content;
+ max-width: min(280px, 80vw);
+ max-height: none;
padding: 8px 12px;
+ /* show the whole intro (sphinx-gallery clamps it to 6 lines) */
+ display: block;
+ -webkit-line-clamp: none;
+ overflow: visible;
+ border: 1px solid var(--color-background-border);
border-radius: 6px;
+ background: var(--color-background-secondary);
+ color: var(--color-foreground-primary);
font-size: 11px;
- white-space: normal;
- max-width: 280px;
- width: max-content;
- word-wrap: break-word;
line-height: 1.3;
+ text-align: left;
+ white-space: normal;
+ overflow-wrap: break-word;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ pointer-events: none;
visibility: hidden;
opacity: 0;
transition: opacity 0.3s, visibility 0.3s;
z-index: 1000;
- box-shadow: 0 2px 8px rgba(0,0,0,0.3);
- pointer-events: none;
}
-.sphx-glr-thumbcontainer[tooltip]:hover:before {
+.sphx-glr-thumbcontainer[tooltip]:hover::after {
visibility: visible;
opacity: 1;
}
-/* If tooltip would go off-screen, position it differently */
-.sphx-glr-thumbcontainer[tooltip]:last-child:before,
-.sphx-glr-thumbcontainer[tooltip]:nth-last-child(2):before {
- left: auto;
- right: 0;
- transform: none;
-}
-
-/* Make the entire thumbnail container clickable */
-.sphx-glr-thumbcontainer {
- cursor: pointer;
-}
-
-/* Handle click events on the entire container using JavaScript */
-.sphx-glr-thumbcontainer p {
- display: none; /* Hide the broken reference text */
+/* A touch screen has no hover: tapping a card would flash the tooltip over
+ its neighbours while the page navigates. */
+@media (hover: none) {
+ .sphx-glr-thumbcontainer[tooltip]::after,
+ .sphx-glr-thumbcontainer[tooltip]:hover::after {
+ display: none;
+ }
}
-/* Style the thumbnail title */
-.sphx-glr-thumbnail-title {
- text-align: center;
- font-size: 14px;
- margin-top: 5px;
- color: #333;
-}
/* ---- ContextLab brand (ported from ContextLab/scheduler, based on the
lab's website): Nunito Sans everywhere, lowercase thin headings with
slight letter-spacing, lab green for links/brand ---- */
@@ -105,12 +165,25 @@ h1, h2, h3, h4, h5, h6,
font-weight: 300;
}
+/* ...except where the heading is a Python name, which is case-sensitive:
+ the lowercase look turned the API pages' titles into names that do not
+ exist ("hypertools.io.lslstream", "hypertools.hyperanimation"). An
+ autosummary page is a whose is the object's dotted name
+ and whose next child is the object's ; inline code in
+ any heading is exempt for the same reason. */
+section:has(> dl.py) > h1,
+h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
+ text-transform: none;
+}
+
a {
color: var(--color-brand-content, #007030);
}
+/* set per theme in conf.py (a fixed dark green is unreadable on furo's
+ dark background) */
a:hover {
- color: #005524;
+ color: var(--color-link--hover);
}
pre {
@@ -135,3 +208,31 @@ article .sphinx-contrib-video-container video {
max-width: 100%;
height: auto;
}
+
+/* plotly figures are laid out at a fixed pixel size (640px wide by default),
+ in a wrapper -- wider than a phone's content
+ column, where the right-hand side was simply cut off. Let the wrapper
+ shrink to the column and scroll the figure inside it. No effect on
+ desktop, where the column is wider than the figure. */
+article div:has(> .plotly-graph-div) {
+ max-width: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+}
+
+/* Tutorial pages: the notebook's install cell, moved under the title and
+ collapsed by docs/post_build.py */
+details.hypertools-setup {
+ margin: 0.5rem 0 1.25rem;
+ border: 1px solid var(--color-background-border);
+ border-radius: 0.25rem;
+ padding: 0.35rem 0.75rem;
+}
+details.hypertools-setup > summary {
+ cursor: pointer;
+ font-size: var(--font-size--small);
+ color: var(--color-foreground-secondary);
+}
+details.hypertools-setup[open] > summary {
+ margin-bottom: 0.5rem;
+}
diff --git a/docs/_static/gallery-fixes.js b/docs/_static/gallery-fixes.js
index ab1ef1e3..a3cde439 100644
--- a/docs/_static/gallery-fixes.js
+++ b/docs/_static/gallery-fixes.js
@@ -4,3 +4,44 @@
// The previous runtime click-handler here targeted sphinx-gallery <= 0.16
// markup (.xref spans) that no longer exists, so clicks silently did
// nothing.
+
+// plotly figures are drawn at a fixed pixel size (640x480 by default) inside
+// a wrapper . On a phone the content
+// column is narrower than that; custom.css caps the wrapper at the column
+// width and lets the figure scroll inside it, but a drag on a 3D figure
+// rotates it rather than scrolling, so half the figure stayed out of reach.
+// Redraw each figure at the width it actually has (same aspect ratio), and
+// back at its natural size when there is room again. Desktop is untouched:
+// the wrapper is as wide as the figure there, so nothing is relaid out.
+(function () {
+ function fitPlotlyFigures() {
+ if (!window.Plotly) { return; }
+ document.querySelectorAll('.plotly-graph-div').forEach(function (gd) {
+ var wrap = gd.parentElement;
+ var full = gd._fullLayout;
+ if (!wrap || !full || !full.width || !full.height) { return; }
+ if (!gd.dataset.hypNaturalSize) {
+ gd.dataset.hypNaturalSize = full.width + 'x' + full.height;
+ }
+ var natural = gd.dataset.hypNaturalSize.split('x').map(Number);
+ var width = Math.min(natural[0], wrap.clientWidth);
+ if (width < 50 || Math.abs(width - full.width) < 1) { return; }
+ var height = Math.round(natural[1] * width / natural[0]);
+ wrap.style.height = height + 'px';
+ window.Plotly.relayout(gd, {width: width, height: height});
+ });
+ }
+
+ var pending = null;
+ function schedule() {
+ clearTimeout(pending);
+ pending = setTimeout(fitPlotlyFigures, 150);
+ }
+ window.addEventListener('load', function () {
+ fitPlotlyFigures();
+ // figures whose plotly.js arrives after `load` (slow CDN)
+ setTimeout(fitPlotlyFigures, 1000);
+ setTimeout(fitPlotlyFigures, 4000);
+ });
+ window.addEventListener('resize', schedule);
+}());
diff --git a/docs/_static/pipeline_order.svg b/docs/_static/pipeline_order.svg
index 12462478..e049da7f 100644
--- a/docs/_static/pipeline_order.svg
+++ b/docs/_static/pipeline_order.svg
@@ -21,251 +21,251 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -273,189 +273,189 @@ z
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -474,117 +474,117 @@ z
-
-
-
-
-
-
+
+
+
+
+
@@ -602,26 +602,26 @@ z
-
@@ -636,17 +636,17 @@ z
-
@@ -665,25 +665,25 @@ z
-
@@ -699,38 +699,38 @@ z
-
@@ -745,35 +745,35 @@ z
-
@@ -788,26 +788,26 @@ z
-
@@ -852,31 +852,31 @@ z
-
-
+
@@ -907,19 +907,19 @@ z
-
@@ -938,17 +938,17 @@ z
-
@@ -979,19 +979,19 @@ z
-
@@ -1018,173 +1018,173 @@ z
-
-
-
-
-
-
-
+
+
+
+
+
+
@@ -1239,676 +1239,676 @@ z
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -2080,154 +2080,154 @@ z
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
diff --git a/docs/_static/thumbnails/sphx_glr_animate_conversation_thumb.gif b/docs/_static/thumbnails/sphx_glr_animate_conversation_thumb.gif
index 92caea2a..ede675e6 100644
Binary files a/docs/_static/thumbnails/sphx_glr_animate_conversation_thumb.gif and b/docs/_static/thumbnails/sphx_glr_animate_conversation_thumb.gif differ
diff --git a/docs/_static/thumbnails/sphx_glr_animate_forecast_thumb.gif b/docs/_static/thumbnails/sphx_glr_animate_forecast_thumb.gif
index 6d7eccf7..9415c159 100644
Binary files a/docs/_static/thumbnails/sphx_glr_animate_forecast_thumb.gif and b/docs/_static/thumbnails/sphx_glr_animate_forecast_thumb.gif differ
diff --git a/docs/_static/thumbnails/sphx_glr_animate_market_sectors_thumb.gif b/docs/_static/thumbnails/sphx_glr_animate_market_sectors_thumb.gif
index 697855be..f0be489e 100644
Binary files a/docs/_static/thumbnails/sphx_glr_animate_market_sectors_thumb.gif and b/docs/_static/thumbnails/sphx_glr_animate_market_sectors_thumb.gif differ
diff --git a/docs/_static/thumbnails/sphx_glr_animate_morph_zoo_thumb.gif b/docs/_static/thumbnails/sphx_glr_animate_morph_zoo_thumb.gif
index f8a97bc7..c8f0d595 100644
Binary files a/docs/_static/thumbnails/sphx_glr_animate_morph_zoo_thumb.gif and b/docs/_static/thumbnails/sphx_glr_animate_morph_zoo_thumb.gif differ
diff --git a/docs/_static/thumbnails/sphx_glr_animate_painting_embeddings_thumb.gif b/docs/_static/thumbnails/sphx_glr_animate_painting_embeddings_thumb.gif
index 5975bf2d..6f0a1067 100644
Binary files a/docs/_static/thumbnails/sphx_glr_animate_painting_embeddings_thumb.gif and b/docs/_static/thumbnails/sphx_glr_animate_painting_embeddings_thumb.gif differ
diff --git a/docs/_static/thumbnails/sphx_glr_animate_spin_thumb.gif b/docs/_static/thumbnails/sphx_glr_animate_spin_thumb.gif
index 887f8690..cab93ad7 100644
Binary files a/docs/_static/thumbnails/sphx_glr_animate_spin_thumb.gif and b/docs/_static/thumbnails/sphx_glr_animate_spin_thumb.gif differ
diff --git a/docs/_static/thumbnails/sphx_glr_animate_thumb.gif b/docs/_static/thumbnails/sphx_glr_animate_thumb.gif
index 21fa14b1..b5873d47 100644
Binary files a/docs/_static/thumbnails/sphx_glr_animate_thumb.gif and b/docs/_static/thumbnails/sphx_glr_animate_thumb.gif differ
diff --git a/docs/_static/thumbnails/sphx_glr_animate_trails_thumb.gif b/docs/_static/thumbnails/sphx_glr_animate_trails_thumb.gif
index 294663ad..36a58226 100644
Binary files a/docs/_static/thumbnails/sphx_glr_animate_trails_thumb.gif and b/docs/_static/thumbnails/sphx_glr_animate_trails_thumb.gif differ
diff --git a/docs/_static/thumbnails/sphx_glr_animate_weather_decades_thumb.gif b/docs/_static/thumbnails/sphx_glr_animate_weather_decades_thumb.gif
index 79e81172..1aa84907 100644
Binary files a/docs/_static/thumbnails/sphx_glr_animate_weather_decades_thumb.gif and b/docs/_static/thumbnails/sphx_glr_animate_weather_decades_thumb.gif differ
diff --git a/docs/_static/thumbnails/sphx_glr_plot_story_trajectories_thumb.gif b/docs/_static/thumbnails/sphx_glr_plot_story_trajectories_thumb.gif
index e0f1f765..b8bb2707 100644
Binary files a/docs/_static/thumbnails/sphx_glr_plot_story_trajectories_thumb.gif and b/docs/_static/thumbnails/sphx_glr_plot_story_trajectories_thumb.gif differ
diff --git a/docs/_static/thumbnails/sphx_glr_save_movie_thumb.gif b/docs/_static/thumbnails/sphx_glr_save_movie_thumb.gif
index 55e8b904..3bf4d57f 100644
Binary files a/docs/_static/thumbnails/sphx_glr_save_movie_thumb.gif and b/docs/_static/thumbnails/sphx_glr_save_movie_thumb.gif differ
diff --git a/docs/animation.rst b/docs/animation.rst
index 2314a6bb..c97ac52d 100644
--- a/docs/animation.rst
+++ b/docs/animation.rst
@@ -416,8 +416,8 @@ Forecasting during an animation
``predict=`` works with the time-progressing animation styles
(``animate=True``, ``'parallel'``, ``'serial'``, ``'window'``) on **both**
backends. The forecast is recomputed from the history revealed so far and
-re-anchored on the last revealed observation, so the forecast trace grows with
-the animation instead of standing still:
+drawn from the endpoint the current frame draws, so the forecast trace grows
+with the animation instead of standing still:
.. code-block:: python
@@ -427,8 +427,16 @@ the animation instead of standing still:
``t`` is measured in **raw observations of the analyzed data** -- not in
animation frames, and not in drawn vertices. ``t=1`` forecasts the next
observation. Because an animation is paced on a resampled frame grid (see
-``duration``/``frame_rate``), an animated forecast joins the drawn trajectory
-to within one raw observation rather than exactly.
+``duration``/``frame_rate``), a frame's drawn head usually falls *between* two
+observations. The forecast starts exactly there, so it meets the trajectory it
+continues on every frame; the points it predicts stay where the model put
+them, ``t`` observations on from the last one revealed.
+
+.. versionchanged:: 1.1.0
+ The forecast used to hang off the last raw observation at or before the
+ head, so it stood still for as many frames as the head took to reach the
+ next observation and trailed the line's tip by up to a whole step -- most
+ visibly on data with fewer rows than frames.
Everything is computed before the first frame
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -480,8 +488,10 @@ An observed line with no ``alpha=`` set is matplotlib's *opaque*, i.e. 1.0, so
the default forecast alpha is 0.5. Per-dataset styling carries through
dataset by dataset: ``alpha=[1.0, 0.4]`` gives forecasts at ``[0.5, 0.2]``, and
a dotted dataset gets a dotted forecast. Both backends apply the identical
-rule (on plotly, colour/width/dash with the alpha baked into the ``rgba(...)``
-line colour and echoed in ``meta['hyp_forecast_alpha']``).
+rule. On plotly the forecast trace copies the colour, width and dash, and
+carries the alpha in the ``rgba(...)`` line colour of a 2-D trace or in the
+trace ``opacity`` of a 3-D one; either way the value is echoed in
+``meta['hyp_forecast_alpha']``.
.. versionchanged:: 1.1.0
Before 1.1.0 every forecast was drawn ``linestyle='--'`` at a hard-coded
@@ -493,6 +503,27 @@ line colour and echoed in ``meta['hyp_forecast_alpha']``).
a floor proportional to it -- so a retained forecast is never more opaque than
the live forecast it decays from, however faint the dataset.
+A forecast given its **own colour** -- by ``forecast_palette=``,
+``forecast_hue=``, ``forecast_cluster=``, or a colour letter in
+``forecast_fmt=`` -- keeps its trace's alpha instead of halving it: the colour
+is then what tells it apart, and fading it as well hid it among translucent
+traces.
+
+A **collection of models** (``predict=['Kalman', 'ARIMA', ...]``) draws one
+overlay per model on every trace. Each keeps its dataset's colour (which series
+it continues) and takes a linestyle per model -- solid, dashed, dotted,
+dash-dot, in model order -- so the two questions are answered by two
+encodings; ``forecast_palette=`` colours by model instead, and
+``forecast_fmt=`` (one entry per model) replaces the cycle.
+
+Every ``predict=`` form lists its forecast **once in the legend**, static or
+animated, under the model's name (the same name ``hyp.predict(x, model=[...])``
+gives it), after the data entries and before ``truth``. The key wears the
+forecasts' linestyle and their colour when they share one; when one model's
+forecasts of several datasets wear several colours the key is a neutral gray,
+and it is never drawn below 0.8 alpha, so it stays legible however faint the
+forecasts are.
+
Animated forecasts under ``hue=``/``cluster=``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -563,7 +594,8 @@ Everything they do not name stays inherited.
* - *(nothing)*
- the identity of the observed trace it continues
* - ``forecast_palette=``
- - the same, in a palette of its own
+ - one colour per forecast from a palette of its own (one per *model*
+ for a collection), drawn at the trace's own alpha
* - ``forecast_hue=``
- a grouping you supply, one value per forecast (see below)
* - ``forecast_cluster=``
@@ -575,7 +607,8 @@ regroups the data: ``plot()`` forecasts every final trace, so a hierarchy
wants one value per leaf group **plus** one per derived mean.
``forecast_fmt=`` sets the line/marker style, in the same format-string
-grammar as ``fmt``, and changes nothing else:
+grammar as ``fmt``, and changes nothing else -- unless the string carries a
+colour letter (``'r:'``), which recolours the forecast too, on both backends:
.. code-block:: python
diff --git a/docs/api.rst b/docs/api.rst
index e567f544..d2083a79 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -114,6 +114,91 @@ also groups by the outer levels but treats the innermost one as the time
axis, which survives as each group's index. The result is a list of
forecasts, one per group -- see :doc:`hierarchy`.
+.. _observation-times:
+
+Observation times and future steps
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Forecasting uses each dataset's own observation times. Datetime, timedelta,
+period and unique numeric indexes are sorted together with their values before
+fitting. Periods are fitted on their start timestamps and forecast as periods of
+the same frequency. Duplicate time
+stamps are rejected; repeated numeric row IDs retain their positional meaning
+(for example, stacked runs). Arrays and categorical row labels use observation
+order. Shuffling timed rows therefore does not change the fitted forecast.
+
+A datetime index with a **calendar frequency** -- stored in ``index.freq``,
+inferable with ``pd.infer_freq`` (business days, month starts, weeks, quarters,
+hours, tz-aware days across DST), or a ``PeriodIndex``'s own -- steps on that
+calendar, so it is regular: it is fitted on its own rows and forecast onto the
+next business days, month starts or periods. Weekday-only sessions that skip a
+few weekdays (exchange holidays) step in business days. Otherwise one future
+step is the **median positive gap** between sorted timestamps.
+Override it with ``hyp.predict(data, step='1h')`` for datetime/duration indexes
+(or a calendar frequency such as ``step='B'`` for datetime indexes),
+or ``step=0.5`` for numeric coordinates. Each dataset gets its own inferred
+interval. A fitted model keeps its training interval when applied to new data,
+so a learned one-hour transition never silently becomes a three-hour transition;
+reused on a different kind of index (fit on an array, applied to dated rows, or
+the reverse) it steps in the new data's own units.
+
+GaussianProcess fits the actual times, expressed as elapsed multiples of the
+model's step. Kalman, ARIMA, AutoRegressor, Laplace and Chronos assume regular
+steps: irregular data are **linearly interpolated** column by column onto a
+regular grid ending at the latest observation, with a warning. The grid stays
+inside the observed time span; training values are never extrapolated. Existing
+missing values are not imputed by this operation. Interpolation can smooth
+short-lived changes; choose the interval for your data, or use GaussianProcess
+to retain the original observation times without interpolation. Models retain
+their existing univariate/multivariate behavior.
+
+``hyp.plot(..., predict=...)`` follows the same policy. In ``ndims=1`` mode,
+the time index supplies predictor coordinates rather than becoming another
+signal column to forecast. The columns of each dataset are forecast together,
+then split into lines for drawing. For a step override in a plot, use
+``predict={'model': 'Kalman', 'kwargs': {'step': '1h'}}``. Animated forecasts
+use only the observations revealed so far and wait until the model has enough
+history, including enough interpolated grid points. If preprocessing changes
+the row count and discards the corresponding timestamps, pass the analyzed
+data with its updated index explicitly instead of guessing its times.
+
+Backtesting at observation times
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``hyp.predict(..., holdout=...)`` sorts timed observations before holding out
+the last rows. The model and its interval are fitted on the remaining training
+rows only. GaussianProcess evaluates predictions at the actual held-out times.
+Regular-grid models forecast far enough to cover those times, then select
+matching grid points or **linearly interpolate predictions** between them.
+For a held-out time before the first full forecast step, interpolation starts
+at the last observed training value. Missing endpoints remain missing;
+held-out values are never used in fitting or interpolation.
+
+Returned forecasts, the naive baseline and ``'truth'`` share the held-out
+index. ``horizon`` in the scores table counts held-out observations, which can
+differ from the number of regular forecast steps. Arrays, categorical row
+labels and repeated numeric row IDs are scored by observation position.
+Choosing trading-day positions instead of calendar dates is therefore an
+explicit modeling choice; the stock-forecasting tutorial demonstrates both.
+
+For example, these observations are scored at times 12 and 20, rather than
+being compared with the model's next two regular steps at times 9 and 10:
+
+.. doctest::
+
+ >>> import pandas as pd
+ >>> import hypertools as hyp
+ >>> from sklearn.gaussian_process.kernels import DotProduct
+ >>> timed = pd.DataFrame({'value': [0., 1., 2., 4., 7., 8., 12., 20.]},
+ ... index=[0., 1., 2., 4., 7., 8., 12., 20.])
+ >>> scores, evaluated = hyp.predict(
+ ... timed, model='GaussianProcess', holdout=2, return_forecasts=True,
+ ... kernel=DotProduct(sigma_0=1, sigma_0_bounds='fixed'))
+ >>> evaluated['GaussianProcess'].index.tolist()
+ [12.0, 20.0]
+ >>> evaluated['GaussianProcess'].index.equals(evaluated['truth'].index)
+ True
+
Plot
------------------
@@ -142,6 +227,26 @@ Colors
.. autofunction:: hypertools.plot.colors.continuous_colormap
+Colors extracted from an image are put in a deterministic order before they
+become a plot palette -- by value, dark to bright, unless ``palette_sort=``
+(or ``?sort=`` in an ``'image:'`` spec) asks for another key -- while
+``image_palette`` itself, and the lead color of a dataset an image stands
+for, keep the most-salient-first order.
+
+.. autofunction:: hypertools.plot.colors.sort_colors
+
+A t x k data matrix is a palette too. ``matrix_palette`` reduces it to three
+dimensions with ``hypertools.reduce`` (``palette_reduce=`` in ``plot``,
+default ``'PCA'``, with ``palette_manip=``/``palette_normalize=``/
+``palette_align=`` passed through), scales each reduced column to [0, 1] as
+an RGB channel, sorts the rows (default ``'columns'``: along the first
+component) and returns a colormap that a plot resamples by interpolation to
+as many colors as it needs.
+
+.. autofunction:: hypertools.plot.colors.matrix_palette
+
+.. autoclass:: hypertools.plot.colors.MatrixColormap
+
Set interactive backend
------------------------
@@ -150,6 +255,26 @@ Set interactive backend
set_interactive_backend
+Set autoinstall
+------------------------
+
+.. autosummary::
+ :toctree:
+
+ set_autoinstall
+
+The optional features (the plotly backend, text embeddings, ``Laplace`` and
+``Chronos`` forecasting, the torch autoencoders, gensim models, Kaggle and
+Hugging Face loading, LSL streaming, 3-D density iso-surfaces, ``.xlsx``
+files) are ``pip`` extras that install themselves on demand: the first call
+that needs one installs that extra's requirements, prints a one-line
+``hypertools:`` notice and carries on. ``set_autoinstall(False)`` turns
+this off (for the session, or for one block as a context manager); a
+missing extra then raises ``ImportError`` naming the manual ``pip install
+"hypertools[]"`` command. See :doc:`optional_dependencies` for the
+extras, the Chrome step behind static plotly export, and how to
+pre-install everything.
+
Analyze
------------------
@@ -223,14 +348,21 @@ I/O
io.lsl_stream
io.LSLStream
- io.lsl.synthetic_outlet
+ io.synthetic_outlet
Exceptions
------------------
-HyperTools' I/O, backend, and remote-load/trust errors derive from
-`hypertools.HypertoolsError`. Input-validation errors (invalid parameters or
-data shapes) raise standard `ValueError`/`TypeError` with actionable messages.
+HyperTools' I/O and backend errors derive from `hypertools.HypertoolsError`.
+`HypertoolsOfflineError` (``hyp.load(..., offline=True)`` with no cached copy
+to read) is a `HypertoolsIOError`, importable from ``hypertools`` and
+``hypertools.io``. `HypertoolsTrustError` is raised by `load` when a remote
+payload would have to be unpickled (a pickle, or an object-array .npy/.npz)
+and ``trust=True`` was not passed; it subclasses `ValueError`, not
+`HypertoolsError`, and is importable from ``hypertools`` (it is defined in
+``hypertools.io.sources``). Input-validation
+errors (invalid parameters or data shapes) raise standard
+`ValueError`/`TypeError` with actionable messages.
.. autosummary::
:toctree:
@@ -238,6 +370,8 @@ data shapes) raise standard `ValueError`/`TypeError` with actionable messages.
HypertoolsError
HypertoolsBackendError
HypertoolsIOError
+ HypertoolsOfflineError
+ HypertoolsTrustError
Tools
------------------
diff --git a/docs/conf.py b/docs/conf.py
index 1e21a4ed..56b60fd5 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -108,10 +108,12 @@ def _install_notebook_cell():
'sphinx.ext.autosummary',
'sphinx.ext.viewcode',
# provides the `.. doctest::` directive docs/hierarchy.rst uses for its
- # worked examples. Those examples are EXECUTED by
- # tests/test_docs_hierarchy_guide.py (via doctest.testfile), not by this
- # builder -- the extension is here so the directive renders instead of
- # raising "Unknown directive type", which -W turns into a build failure.
+ # worked examples. The html build only RENDERS them; they run under
+ # `make doctest` (the `-b doctest` builder) in this directory. The
+ # pytest suite pins the guide's structure, links and quoted messages
+ # (tests/test_docs_hierarchy_guide.py) but does not execute the blocks.
+ # The extension is here so the directive renders instead of raising
+ # "Unknown directive type", which -W turns into a build failure.
'sphinx.ext.doctest',
# (see doctest_global_setup below -- running `-b doctest` from the repo
# root used to litter it with the files those examples write)
@@ -157,6 +159,10 @@ def _install_notebook_cell():
# rendering), just without the broken per-method stub links.
numpydoc_class_members_toctree = False
+# The autosummary class stubs already list each class's methods and attributes;
+# numpydoc's own tables put a second copy of both on every class page.
+numpydoc_show_class_members = False
+
# Never execute notebooks during the docs build: tutorial notebooks are
# committed pre-executed (with outputs), and 'auto' also re-executed every
# sphinx-gallery-generated .ipynb -- doubling build time and hanging on
@@ -274,12 +280,14 @@ def _install_notebook_cell():
'light_css_variables': {
'color-brand-primary': '#007030',
'color-brand-content': '#007030',
+ 'color-link--hover': '#005524',
'font-stack': "'Nunito Sans', -apple-system, BlinkMacSystemFont, "
"'Segoe UI', Helvetica, Arial, sans-serif",
},
'dark_css_variables': {
'color-brand-primary': '#4CAF50',
'color-brand-content': '#4CAF50',
+ 'color-link--hover': '#81C784',
},
'footer_icons': [
{
@@ -361,38 +369,31 @@ def _install_notebook_cell():
]
-def hyperanimation_scraper(block, block_vars, gallery_conf, **kwargs):
- """Scrape the animations ``hyp.plot(..., show=False)`` returns.
+# The gallery's matplotlib scraper lives in docs/_gallery_scrapers.py (a
+# side-effect-free sibling module, importable by the test suite): it is
+# sphinx-gallery's own matplotlib scraper plus the ``show=False``
+# HyperAnimations that one cannot see, each rendered exactly once.
+from _gallery_scrapers import ( # noqa: E402 (needs the sys.path entry above)
+ matplotlib_and_hyperanimation_scraper)
+
- sphinx-gallery's matplotlib scraper walks ``plt.get_fignums()`` and pairs
- each MANAGED figure with any ``matplotlib.animation.Animation`` in the
- example's namespace. A ``show=False`` plot leaves its figure unmanaged
- by pyplot, so the five launch examples (which bind the ``HyperAnimation``
- wrapper the call returns) rendered NOTHING in the gallery -- measured
- 2026-09-03: their generated pages carried no image block at all, and no
- ``.mp4`` for a thumbnail. This scraper finds every ``HyperAnimation`` (or
- bare ``Animation``) whose figure the matplotlib scraper will not see and
- renders it through sphinx-gallery's own animation writer, so the page
- gets the same embedded video as the managed-figure examples.
+def _quiet_gallery_only_warnings(gallery_conf, fname):
+ """sphinx-gallery ``reset_modules`` hook, run before every example.
+
+ The docs build renders with the non-interactive Agg backend, so
+ examples/explore.py's ``explore=True`` makes hypertools warn -- correctly
+ -- that hover labels need an interactive backend. sphinx-gallery prints a
+ warning into the page's output block together with the build machine's
+ absolute path to the example. The warning describes the build
+ environment, not anything a reader of the page did, and the page's own
+ text says the picture is a static render; so it is filtered here, for the
+ gallery build only. Library behaviour is unchanged.
"""
- from pathlib import PurePosixPath
- import matplotlib.pyplot as plt
- from matplotlib.animation import Animation
- from sphinx_gallery.scrapers import _anim_rst
- from hypertools.plot.hyper_animation import HyperAnimation
-
- managed = {plt.figure(num) for num in plt.get_fignums()}
- seen, rst = set(), []
- for value in block_vars['example_globals'].values():
- ani = value.animation if isinstance(value, HyperAnimation) else value
- if not isinstance(ani, Animation) or id(ani) in seen:
- continue
- seen.add(id(ani))
- if ani._fig in managed:
- continue # the matplotlib scraper renders it
- image_path = PurePosixPath(next(block_vars['image_path_iterator']))
- rst.append(_anim_rst(ani, image_path, gallery_conf))
- return '\n'.join(rst)
+ import warnings
+ warnings.filterwarnings(
+ 'ignore', category=UserWarning,
+ message=r'explore=True shows labels on hover, which needs an '
+ r'interactive matplotlib backend')
# Gallery page order. sphinx-gallery's default (`NumberOfCodeLinesSortKey`)
@@ -453,11 +454,15 @@ def _gallery_order(filename):
'hypertools GALLERY_ORDER'),
# Abort on first failure
'abort_on_example_error': False,
- # Execute code to generate plots
- 'plot_gallery': True,
+ # Execute code to generate plots. HYPERTOOLS_DOCS_PLOT_GALLERY=0 turns
+ # the gallery off as a real boolean (the doctest builder in CI and the
+ # local release pipeline use it; `-D plot_gallery=0` on the command line
+ # is a string and makes sphinx-gallery warn about the type).
+ 'plot_gallery': os.environ.get('HYPERTOOLS_DOCS_PLOT_GALLERY', '1')
+ not in ('0', 'false', 'no', 'off'),
# execute EVERY example (the sphinx-gallery default only executes
- # files named plot_*, which left animate*/chemtrails/precog/explore/
- # save_*/analyze pages with code but no rendered output)
+ # files named plot_*, which would leave the animate*/explore/save_*/
+ # analyze pages with code but no rendered output)
'filename_pattern': r'.*\.py',
# render matplotlib FuncAnimations (exposed as variables in the
# examples) as embedded HTML5 video via ffmpeg
@@ -470,10 +475,25 @@ def _gallery_order(filename):
'expected_failing_examples': [],
# Performance optimizations
'capture_repr': ('_repr_html_',),
- # scrape BOTH matplotlib figures and plotly figures (the plotly scraper
+ # scrape BOTH matplotlib figures (including `show=False` animations,
+ # see docs/_gallery_scrapers.py) and plotly figures (the plotly scraper
# renders interactive figures into the gallery via kaleido)
- 'image_scrapers': ('matplotlib', plotly_sg_scraper,
- hyperanimation_scraper),
+ 'image_scrapers': (matplotlib_and_hyperanimation_scraper,
+ plotly_sg_scraper),
+ # A plotly figure that is a block's last expression was shown TWICE: once
+ # by the plotly scraper above (hyp.plot shows the figure, which is what
+ # the scraper collects) and once more through `capture_repr`, because a
+ # plotly Figure also has a `_repr_html_`. Keep the scraped copy -- it is
+ # the one that also yields the page's thumbnail PNG.
+ # (hyp.plot returns `HyperPlotlyFigure`, a plotly `Figure` subclass;
+ # sphinx-gallery matches this against `str(type(obj))`.)
+ 'ignore_repr_types': r'plotly\.graph_objs\._figure\.Figure'
+ r'|HyperPlotlyFigure',
+ # `# sphinx_gallery_thumbnail_path = ...` lines configure the build; they
+ # are not part of the example and should not be shown in its code
+ 'remove_config_comments': True,
+ # the defaults, plus the gallery-only warning filter defined above
+ 'reset_modules': ('matplotlib', 'seaborn', _quiet_gallery_only_warnings),
# Limit memory usage display
'show_memory': False,
# Ensure proper thumbnail linking
@@ -493,7 +513,60 @@ def _gallery_order(filename):
}
+class _GallerySourceLink:
+ """A per-page `source_edit_link` / `source_view_link` for gallery pages.
+
+ furo's "Edit this page" / "View this page" buttons build their URL from
+ `source_directory` + pagename + suffix, which for a sphinx-gallery page
+ is `docs/auto_examples/.rst` -- a file that is generated at build
+ time and gitignored, so every gallery page's link 404'd. The theme
+ consults `theme_source_edit_link` (`theme_source_view_link`) FIRST and
+ calls its `.format(filename=pagename + page_source_suffix)`, so this
+ object stands in for that string on gallery pages only (set from
+ `_gallery_page_context` below) and maps the generated page back to the
+ tracked source under examples/: `auto_examples/` ->
+ `examples/.py`, and the gallery index -> `examples/README.txt`.
+ """
+
+ def __init__(self, url_template):
+ self.url_template = url_template
+
+ def format(self, filename):
+ page = filename.rsplit('.', 1)[0] # strip the .rst suffix
+ stem = page[len('auto_examples/'):]
+ source = 'README.txt' if stem == 'index' else stem + '.py'
+ return self.url_template.format(path='examples/' + source)
+
+
+_GALLERY_REPO = html_theme_options['source_repository'].rstrip('/')
+_GALLERY_BRANCH = html_theme_options['source_branch']
+_GALLERY_EDIT_LINK = _GallerySourceLink(
+ f'{_GALLERY_REPO}/edit/{_GALLERY_BRANCH}/{{path}}')
+_GALLERY_VIEW_LINK = _GallerySourceLink(
+ f'{_GALLERY_REPO}/blob/{_GALLERY_BRANCH}/{{path}}?plain=true')
+_EXAMPLES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
+ '..', 'examples')
+
+
+def _gallery_page_context(app, pagename, templatename, context, doctree):
+ """Point gallery pages' edit/view links at examples/ (see
+ `_GallerySourceLink`), and drop the links on the generated pages that
+ have no tracked source at all (`sg_execution_times`, a stale page whose
+ example was deleted): furo hides both buttons when
+ `page_source_suffix` is empty."""
+ if not pagename.startswith('auto_examples/'):
+ return
+ stem = pagename[len('auto_examples/'):]
+ source = 'README.txt' if stem == 'index' else stem + '.py'
+ if '/' in stem or not os.path.exists(os.path.join(_EXAMPLES_DIR, source)):
+ context['page_source_suffix'] = ''
+ return
+ context['theme_source_edit_link'] = _GALLERY_EDIT_LINK
+ context['theme_source_view_link'] = _GALLERY_VIEW_LINK
+
+
def setup(app):
+ app.connect('html-page-context', _gallery_page_context)
# Keep the strict (-W) docs-clean CI gate robust to TRANSIENT third-party
# doc-site outages: sphinx-gallery fetches each `reference_url` site's
# searchindex.js to hyperlink API names, and a 503 there would otherwise
diff --git a/docs/doc_requirements.txt b/docs/doc_requirements.txt
index 1052e0a7..249db441 100644
--- a/docs/doc_requirements.txt
+++ b/docs/doc_requirements.txt
@@ -7,13 +7,15 @@ nbsphinx>=0.8.0
jupyter_client>=6.0.0
wikipedia
ipython
-# Main package dependencies
-scikit-learn>=1.4.0
-pandas>=2.2.0
+# Main package dependencies (same floors as pyproject.toml, which is the
+# declaration: the docs image installs the package first, so these only
+# need to agree with it)
+scikit-learn>=1.5.2
+pandas>=2.2.3
seaborn>=0.13.0
-matplotlib>=3.8.0
-scipy>=1.13.0
-numpy>=2.0.0
+matplotlib>=3.9.2
+scipy>=1.14.1
+numpy>=2.1.0
umap-learn>=0.5.5
requests>=2.31.0
ipympl>=0.9.3
@@ -32,12 +34,12 @@ kaleido>=1.0
# (e.g. `brew install ffmpeg` / `apt install ffmpeg`) before building docs.
# hyp.predict/[predict] models used by gallery examples (plot_predict, plot_impute)
pykalman>=0.11
-statsmodels>=0.14
+statsmodels>=0.14.3
skaters>=0.11
# plot(..., density=True) 3-D iso-surfaces ([density3d] extra); gallery
# examples build with it installed so the docs show the iso-surface path
# rather than the scatter-fog fallback
-scikit-image>=0.22.0
+scikit-image>=0.25.0
# hypertools.reduce.autoencoders (GH #162) and hypertools.tools.gensim_models
# (GH #198) import torch/gensim unconditionally at module level, so both are
# needed for autodoc/autosummary to import those modules (docs/api.rst's
@@ -45,7 +47,7 @@ scikit-image>=0.22.0
# examples/plot_autoencoders.py and examples/plot_gensim_text.py in the
# gallery build
torch>=2.0
-gensim>=4.3
+gensim>=4.4.0
# hyp.load('kaggle//') (GH #116), used by
# examples/plot_datasets_tour.py
kagglehub>=0.3
diff --git a/docs/hierarchy.rst b/docs/hierarchy.rst
index e6142d05..4856ca19 100644
--- a/docs/hierarchy.rst
+++ b/docs/hierarchy.rst
@@ -20,10 +20,6 @@ anything else:
Everything below follows from that. Every example on this page is
executable and its output is checked by ``tests/test_docs_hierarchy_guide.py``.
-.. contents:: On this page
- :local:
- :depth: 1
-
Row versus column semantics
----------------------------
@@ -741,9 +737,17 @@ unless it already carries one of its own.
[(40, 3), (40, 3)]
Bundled forecasts always correspond to ``trace_data``, so ``forecasts[i]``
-equals ``hyp.predict(trace_data[i], model=..., t=t)`` for every ``i`` --
+equals ``hyp.predict(trace_data[i], model=..., t=t)`` for positional input --
including the means, each forecast from its own averaged trajectory rather
-than from an average of its members' forecasts:
+than from an average of its members' forecasts. With a column hierarchy and
+an observation-time index, attach that original index to the trace before
+calling ``hyp.predict`` to reproduce the same fit. In ``ndims=1`` series
+mode, ``trace_data`` contains display pairs ``[time, value]``; forecasts
+contain only predicted signal values. Use the value column and the original
+observation-time index to reproduce those forecasts. See
+:ref:`observation-times` for the sorting and interpolation policy.
+
+For this example's positional input:
.. doctest::
diff --git a/docs/hypertools.FrameContext.rst b/docs/hypertools.FrameContext.rst
index 679e192d..65d2c34c 100644
--- a/docs/hypertools.FrameContext.rst
+++ b/docs/hypertools.FrameContext.rst
@@ -1,4 +1,4 @@
-hypertools.FrameContext
+hypertools.FrameContext
=======================
.. currentmodule:: hypertools
diff --git a/docs/hypertools.HypertoolsOfflineError.rst b/docs/hypertools.HypertoolsOfflineError.rst
new file mode 100644
index 00000000..d5863f5a
--- /dev/null
+++ b/docs/hypertools.HypertoolsOfflineError.rst
@@ -0,0 +1,6 @@
+hypertools.HypertoolsOfflineError
+=================================
+
+.. currentmodule:: hypertools
+
+.. autoexception:: HypertoolsOfflineError
\ No newline at end of file
diff --git a/docs/hypertools.HypertoolsTrustError.rst b/docs/hypertools.HypertoolsTrustError.rst
new file mode 100644
index 00000000..3ff0cd8e
--- /dev/null
+++ b/docs/hypertools.HypertoolsTrustError.rst
@@ -0,0 +1,6 @@
+hypertools.HypertoolsTrustError
+===============================
+
+.. currentmodule:: hypertools
+
+.. autoexception:: HypertoolsTrustError
\ No newline at end of file
diff --git a/docs/hypertools.io.lsl.synthetic_outlet.rst b/docs/hypertools.io.lsl.synthetic_outlet.rst
deleted file mode 100644
index c87c3bae..00000000
--- a/docs/hypertools.io.lsl.synthetic_outlet.rst
+++ /dev/null
@@ -1,6 +0,0 @@
-hypertools.io.lsl.synthetic\_outlet
-===================================
-
-.. currentmodule:: hypertools.io.lsl
-
-.. autofunction:: synthetic_outlet
\ No newline at end of file
diff --git a/docs/hypertools.io.synthetic_outlet.rst b/docs/hypertools.io.synthetic_outlet.rst
new file mode 100644
index 00000000..328976ee
--- /dev/null
+++ b/docs/hypertools.io.synthetic_outlet.rst
@@ -0,0 +1,6 @@
+hypertools.io.synthetic\_outlet
+===============================
+
+.. currentmodule:: hypertools.io
+
+.. autofunction:: synthetic_outlet
\ No newline at end of file
diff --git a/docs/hypertools.set_autoinstall.rst b/docs/hypertools.set_autoinstall.rst
new file mode 100644
index 00000000..34371600
--- /dev/null
+++ b/docs/hypertools.set_autoinstall.rst
@@ -0,0 +1,22 @@
+hypertools.set\_autoinstall
+===========================
+
+.. currentmodule:: hypertools
+
+.. autoclass:: set_autoinstall
+
+
+ .. automethod:: __init__
+
+
+ .. rubric:: Methods
+
+ .. autosummary::
+
+ ~set_autoinstall.__init__
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/index.rst b/docs/index.rst
index 6cc7bcf6..a536a79f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -25,8 +25,8 @@ and ``Chronos`` forecasters, autoencoder reducers, gensim vectorizers, Kaggle
loading, LSL streaming, 3-D density iso-surfaces, ``.xlsx`` loading) are
``pip`` extras of ``hypertools``, and they install themselves on demand: the
first call that needs one installs that extra's requirements and carries on,
-printing a one-line notice. Set ``HYPERTOOLS_AUTO_INSTALL=0`` to disable
-this; a missing extra then raises ``ImportError`` with the manual
+printing a one-line notice. ``hypertools.set_autoinstall(False)`` turns
+this off; a missing extra then raises ``ImportError`` with the manual
``pip install "hypertools[]"`` command. See
:doc:`optional_dependencies` for the full list.
@@ -51,6 +51,9 @@ Some key features of HyperTools are:
7. Applying topic models and other text vectorization methods to text
data
+What changed in each release is listed in the
+`changelog `_.
+
.. toctree::
:maxdepth: 2
:caption: Contents:
diff --git a/docs/optional_dependencies.rst b/docs/optional_dependencies.rst
index 48d59d2f..84db63e2 100644
--- a/docs/optional_dependencies.rst
+++ b/docs/optional_dependencies.rst
@@ -5,7 +5,7 @@ Optional dependencies
``pip install hypertools`` installs everything the core functionality needs:
plotting with matplotlib, dimensionality reduction, alignment, clustering,
-normalization, and ``Kalman``/``ARIMA`` forecasting and imputation. The
+normalization, ``Kalman``/``ARIMA`` forecasting, and missing-data imputation. The
heavier model families are declared as ``pip`` extras of ``hypertools`` in
``pyproject.toml``. You can install them ahead of time, or let hypertools
install them the first time a call needs one.
@@ -65,7 +65,7 @@ The extras
the plotly backend renders a volume either way)
* - ``io``
- openpyxl
- - ``.xlsx`` support for ``hyp.load``
+ - ``.xlsx`` reading with ``hyp.load`` and writing with ``hyp.save``
Extras combine: ``pip install "hypertools[interactive,torch]"``. The ``dev``
extra holds the test and development dependencies and is not installed on
@@ -89,15 +89,77 @@ If the install fails (no network, no permission to write to the
environment), the call raises ``ImportError`` naming the manual command,
e.g. ``pip install "hypertools[interactive]"``.
+An extra that is installed but too old
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``pip install "hypertools[interactive]"`` always resolves versions that
+work together. An environment that already held an older copy of one of
+those packages (a notebook image with an older plotly, say) may not. So an
+installed extra is also checked, once per process, against the requirements
+hypertools declares, and every package of the extra is checked, because a
+feature needs them together: kaleido 1.x cannot export a static image with
+a plotly older than 6.1.1.
+
+A package below its declared requirement is upgraded the same way a missing
+one is installed, with a notice naming the installed version and the
+requirement::
+
+ hypertools: upgrading plotly 5.24.1 to plotly>=6.1.1 (needed for the plotly backend) ...
+
+This happens only while that package has not been imported yet. Python
+cannot replace a package that is already imported in a running process, so
+if your code (or another library) imported the old version first, nothing
+is installed and the call raises ``ImportError``::
+
+ plotly 5.24.1 is installed, but hypertools needs plotly>=6.1.1 (needed
+ for the plotly backend). plotly is already imported in this Python
+ process, so hypertools did not upgrade it in place. Run `pip install
+ "hypertools[interactive]"` and restart Python (in a notebook, restart
+ the kernel or runtime).
+
+With installation turned off (below) the call raises ``ImportError`` with
+the same three facts: the installed version, the requirement, and the
+command. That command upgrades the extra's packages and leaves hypertools
+itself as it is. With ``backend='auto'`` (the default), where plotly is
+chosen only because the environment has it, a plotly that is too old and
+cannot be upgraded produces a warning with that message and the plot is
+drawn with matplotlib instead.
+
+A development or pre-release build of the required version (``6.1.1.dev0``,
+``6.1.1rc1``) counts as that version, and a package whose version cannot be
+read (an untagged source build reporting ``0+unknown``) is never reported
+as too old.
+
Turning it off
~~~~~~~~~~~~~~
-Set the environment variable ``HYPERTOOLS_AUTO_INSTALL=0`` (``false``,
-``no`` and ``off`` also work); it is read at each call. A missing extra
-then raises ``ImportError`` with the manual ``pip install
-"hypertools[]"`` command, and nothing is installed.
-This is the setting to use in locked-down environments, in CI images built
-ahead of time, and anywhere pip should not run inside a Python process.
+Call ``hypertools.set_autoinstall(False)``. A missing extra, or one that
+is installed but older than hypertools requires, then raises
+``ImportError`` with the manual ``pip install "hypertools[]"``
+command, and nothing is installed or upgraded. It works like
+``set_interactive_backend``: called directly it applies to the rest of the
+session (``set_autoinstall(True)`` turns installation back on), and used
+with ``with`` it applies to one block::
+
+ import hypertools as hyp
+
+ hyp.set_autoinstall(False) # for the rest of the session
+
+ with hyp.set_autoinstall(False): # for one block
+ hyp.predict(data, model='Chronos', t=5) # ImportError if missing
+
+This is the setting for locked-down environments and anywhere pip should
+not run inside a Python process. Where no Python runs before hypertools is
+imported (a CI image built ahead of time), the environment variable
+``HYPERTOOLS_AUTO_INSTALL=0`` (``false``, ``no`` and ``off`` also work)
+sets the starting value; a ``set_autoinstall`` call overrides it.
+The setting is process-global (shared by every thread; the newest call
+still in force decides, and a ``with`` block removes only its own setting
+on exit), and it also reaches the subprocess that renders a plotly
+animation's frames for export, which starts from the parent's effective
+value, whichever of the two set it. That export raises ``ImportError``
+naming the manual command when kaleido is missing and installation is off,
+like any other call.
Chrome for static plotly export
-------------------------------
@@ -116,7 +178,7 @@ provisions what is missing:
- a Chrome build for kaleido (about 150 MB), via ``plotly.io.get_chrome()``.
Both steps print a one-line ``hypertools:`` notice, and both are
-skipped when ``HYPERTOOLS_AUTO_INSTALL=0``. When no working Chrome could be
+skipped after ``set_autoinstall(False)``. When no working Chrome could be
provided, the export raises ``HypertoolsIOError`` with the commands to run
yourself: ``import plotly.io as pio; pio.get_chrome()`` and, on
Debian/Ubuntu, ``apt-get install -y libatk1.0-0 libatk-bridge2.0-0
diff --git a/docs/post_build.py b/docs/post_build.py
index 81e46c93..50d9a61e 100755
--- a/docs/post_build.py
+++ b/docs/post_build.py
@@ -98,7 +98,8 @@ def find_build_dirs():
# Also check environment variables that Read the Docs might set
rtd_output = os.environ.get('READTHEDOCS_OUTPUT', '')
if rtd_output:
- possible_build_dirs.insert(0, rtd_output)
+ # Read the Docs writes the site to $READTHEDOCS_OUTPUT/html
+ possible_build_dirs[:0] = [os.path.join(rtd_output, 'html'), rtd_output]
for build_dir in possible_build_dirs:
if build_dir and os.path.exists(build_dir):
@@ -324,6 +325,141 @@ def inject_notebook_badges():
return n
+def strip_rst_markup(text):
+ """Plain text for a gallery tooltip: drop the RST inline markup
+ sphinx-gallery leaves in an example's intro paragraph.
+
+ sphinx-gallery's own ``_sanitize_rst`` only recognises markup that is
+ preceded by whitespace, so a literal that follows ``(`` or ``/`` keeps
+ its backticks, and a tooltip is a plain HTML attribute -- the backticks
+ were shown literally on 15 gallery cards. A role with a ``~`` target
+ keeps only the last dotted name, any other role keeps its target, and
+ double- or single-backtick literals lose their backticks. ``text`` is
+ the (HTML-escaped) attribute value and stays escaped: only backticks and
+ role prefixes are removed."""
+ def _role(match):
+ target = match.group(1)
+ if target.startswith('~'):
+ return target[1:].rsplit('.', 1)[-1]
+ return target
+ text = re.sub(r':[\w:+.-]+:`([^`]+)`', _role, text)
+ text = re.sub(r'``(.+?)``', r'\1', text)
+ text = re.sub(r'`([^`]+)`_{0,2}', r'\1', text)
+ return text
+
+
+def clean_gallery_tooltips(gallery_html=None):
+ """Strip RST markup from every gallery card's ``tooltip="..."`` attribute
+ (see ``strip_rst_markup``). Returns the number of tooltips changed."""
+ gallery_html = gallery_html or GALLERY_HTML
+ if not gallery_html or not os.path.exists(gallery_html):
+ print(" Skipping tooltip cleanup (no build dir)")
+ return 0
+ with open(gallery_html, encoding='utf-8') as f:
+ html = f.read()
+ changed = 0
+
+ def _clean(match):
+ nonlocal changed
+ cleaned = strip_rst_markup(match.group(2))
+ changed += cleaned != match.group(2)
+ return f'{match.group(1)}{cleaned}"'
+
+ html = re.sub(r'(]*?\stooltip=")'
+ r'([^"]*)"', _clean, html)
+ if changed:
+ with open(gallery_html, 'w', encoding='utf-8') as f:
+ f.write(html)
+ print(f" Cleaned RST markup out of {changed} gallery tooltips")
+ return changed
+
+
+# plotly's notebook renderers store two things in a notebook's saved outputs
+# that do not survive being embedded in a Sphinx page (nbsphinx copies the
+# stored HTML verbatim; the tutorials are committed pre-executed):
+# * `` -- the renderer's one-time "notebook_connected" bootstrap.
+# The URL has no `.js` and the CDN answers 403. Each figure's own output
+# carries a regular `')
+_PLOTLY_MATHJAX2 = re.compile(
+ r'')
+
+
+def clean_notebook_plotly_scripts(build_root=None):
+ """Remove plotly's dead CDN bootstrap and its MathJax 2 \n",
- " \n",
- "
+
+
+
+
+
+ |