Skip to content

feat: sphereql-vis crate — offline 3D visualization (+ configurable radial)#34

Draft
bkahan wants to merge 25 commits into
mainfrom
feat/sphereql-vis
Draft

feat: sphereql-vis crate — offline 3D visualization (+ configurable radial)#34
bkahan wants to merge 25 commits into
mainfrom
feat/sphereql-vis

Conversation

@bkahan

@bkahan bkahan commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Summary

Adds sphereql-vis, a pure (sphereql-core-only) crate that turns pipeline output into a self-contained, offline, interactive 3D sphere viewer — making visualization first-class in Rust (it was Python-only and structurally weak before). Consumed by the Python bindings (API unchanged), a new visualize_corpus example, the migrated e2e_transformer, and the umbrella sphereql crate behind a vis feature.

This is the foundation PR; a follow-on roadmap (drag-drop arbitrary scenes, a WASM lingua-live studio, compare/morph, query box, ruler, shareable URLs, density/pins) builds on it.

What's here

  • Crate: serializable Scene (point cloud + SceneStats + a #[non_exhaustive] Overlay set — centroids, classified bridges, slerp geodesic paths, Voronoi caps, antipodes, coverage, domain groups, globs, manifold slices) + a hardened HTML emitter. Three.js + OrbitControls inlined → opens offline; to_html_cdn() for a smaller file.
  • Viewer: observatory console UI — (θ,φ) graticule globe, crisp point shader, tabbed Domains/Overlays/Settings deck, 2D sky-chart minimap with click-to-aim, click-to-inspect + neighbor lines, floating clickable labels (per-kind toggles, focus/solo), great-circle-aware navigation, scale/zoom-to-cursor/spread controls, color schemes, UI scale, TOML save/load, touch + responsive/embeddable layout, hide-UI toggle.
  • Configurable radial coordinate (PipelineConfig.radial: Magnitude/Fixed/Stretch) — fixes r clustering in a narrow near-max band on sparse corpora.
  • Hardening: centralized <script>-breakout escaping, large-N min/max crash fix, NaN filtering, decimation, projection-aware stats labels.

Tests

sphereql-vis emit/XSS/NaN/offline/radius-alignment suite + doctest; a CI-run overlay-join smoke test; embed radial-stretch tests; Python test_viz (incl. projection-aware label + offline). All four gate scripts, clippy -D warnings, and fmt green.

Draft — opening as a checkpoint ahead of the roadmap work.

🤖 Generated with Claude Code

bkahan and others added 7 commits June 15, 2026 16:54
Introduce `sphereql-vis`, a pure (`sphereql-core`-only) emitter that owns a
serializable `Scene` (point cloud + `SceneStats` + a `#[non_exhaustive]`
`Overlay` set) and one hardened Three.js renderer. `to_html()` inlines the
Three.js + OrbitControls runtime so files open offline (the old Python
viewer claimed "self-contained" but loaded from a CDN); `to_html_cdn()`
emits a smaller CDN-backed file.

Fixes at the source:
- projection-aware stats label (no more hardcoded "PCA variance" on a
  UMAP/Laplacian pipeline)
- reduce-based min/max (fixes a latent large-N RangeError crash)
- single-place non-finite filtering, deterministic per-category decimation,
  centralized </script> escaping, empty-scene handling

Draw the structure the engine computes: 9 overlay kinds (centroids,
classified bridges, slerp geodesic paths, Voronoi caps, antipodes, coverage
maps, domain-group spokes, globs, manifold slices) with legend/overlay
toggles, OrbitControls, and click-to-inspect.

Consumers consolidated onto the shared crate:
- sphereql-python `visualize`/`visualize_pipeline`: API byte-identical, now
  offline + projection-aware; old viz_template.html removed
- new CI-safe `visualize_corpus` example (the true e2e viz demo)
- `e2e_transformer` migrated off its duplicated inline template
- umbrella `sphereql --features vis` (does not leak into --no-default-features)
- shared example helpers extracted into a testable sphereql-examples lib

Tests: sphereql-vis (11 + doctest), a CI-run overlay-join smoke test, XSS,
NaN, offline-URL-absence, CDN-mode, radius-alignment, and a geodesic
no-duplicate-vertex regression. CI/docs wired: crate tables, dependency
edge list, publish order, feature matrix, CHANGELOG.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks sphereql-vis/src/template.html into a celestial-observatory console
(fully offline; no web fonts/assets):

- Atmosphere: nebula gradient + film grain + vignette; a (θ,φ)-aligned
  lat/long graticule globe with a backdrop shell; crisp solid-disc point
  shader with a bright core.
- Instrument chrome: hairline panels with HUD corner brackets, monospace
  typography, a wordmark + projection-kind pill badge, a hover reticle, and
  a staggered load reveal (prefers-reduced-motion aware).
- Tabbed control deck — Domains / Overlays / Settings — with select all/none.
- Settings: Scale (default 5×, up to 50×; the model spreads apart on screen
  so overlapping blobs separate), Radial spread (thickens the r shell),
  Domain spread (angular de-clumping), point size (down to 0.5), a Reference
  globe toggle, auto-rotate, and Reset to defaults.
- 2D sky-chart minimap (equirectangular θ→φ) with a live camera reticle and
  click-to-aim navigation.
- Reliable click-to-select via pointerdown/up + movement threshold (the
  `click` event is unreliable under OrbitControls); auto-rotate defaults off.

All data/placeholder/escaping contracts preserved; 11 tests + doctest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- UI scale slider (0.70×–1.60×) — zooms the HUD panels independently of the
  3D scene (CSS zoom).
- Color schemes — Aurora / Spectral / Viridis / Sunset / Ice, switchable
  live (recolors points, legend, minimap, selected point).
- Nearest-chord neighbor rows tinted with the neighbor's domain color.
- Pick threshold now scales with the model (maxR · 0.07 · scale) so points
  stay clickable at any Scale (incl. 50×).
- Clickable "© bkahan/sphereQL" credit linking to the public GitHub repo.
- All covered by Reset to defaults.

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

- Default Scale 12× (max 100×); camera frames the 12× model on load.
- Zoom-speed control (slider; default 0.5) — less sensitive scroll zoom.
- Save/Load view settings as a TOML file (scale, zoom, radial, domain
  spread, point size, UI scale, color scheme, globe, auto-rotate).
- Anchored, domain-colored label pinned on the selected sphere; the hover
  reticle now snaps onto the actual point so the click target is unambiguous.
- Click a point to auto-center the orbit on it; Esc / empty-click returns to
  the main sphere (smooth target tween).
- Spread-aware bridges: endpoints follow the spread/radial transform.

Adversarial review fixes: robust behind-camera reject for screen labels
(getWorldDirection in-front test), clear hover on select, re-apply selection
highlight in place on palette change (no panel flicker), and surfaced config
load errors (console + button feedback) instead of swallowing them.

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

Bridges:
- Tie each bridge to its exact source-point index (full-precision coord
  match) so its endpoint follows that sphere precisely under spread/scale,
  instead of a nearest-centroid approximation.
- Highlighting a sphere now draws its bridges from the sphere to the
  connected domains (classification-colored) regardless of the global
  toggle, with a `bridges: N` count in the info panel.

Navigation / input:
- Zoom-to-cursor: intercept the wheel and dolly while keeping the world
  point under the pointer fixed (OrbitControls r128 has no zoomToCursor);
  touch pinch still uses the built-in zoom.

Touch / mobile / embed:
- canvas touch-action:none; panels are position:fixed and the page is locked
  to the viewport (overflow:hidden, overscroll-behavior:none).
- Responsive layout ≤720px: header across the top, Domains/Info share the
  upper row, stats above a full-width filter bar pinned to the bottom;
  minimap + hints drop out; a ⛶ hide-UI toggle collapses all panels.
- visualize_corpus prints a ready-to-paste <iframe> embed snippet.

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

The radial coordinate r was a faithful pass-through of the raw embedding
magnitude, which on sparse/uniform corpora clusters in a narrow near-maximum
band (e.g. HandCrafted/UMAP r ∈ [0.95, 2.13]). Add a way to remap it.

- `PipelineConfig.radial` (`RadialConfig` { mode, lo, hi, percentile }) with
  `RadialMode::{Magnitude (default), Fixed, Stretch}`.
- `RadialStrategy::MagnitudeStretch { m_lo, m_hi, r_lo, r_hi }` — affine map of
  the embedding magnitude from a corpus source band onto [r_lo, r_hi], clamped.
- The fit path resolves the mode once (two-pass percentile scan of corpus
  magnitudes) and bakes the bounds into the strategy, so corpus points AND
  later queries get consistent r. UmapGraph now carries per-point magnitudes
  so the graph-reuse (tuner) fit path can resolve bounds too.
- PCA's volumetric mode (which overrides any RadialStrategy) is auto-disabled
  when a non-default radial mode is chosen, so Stretch/Fixed take effect.
- `visualize_corpus --radial lo:hi`; the example prints the resulting r range.

Default is unchanged (Magnitude pass-through, identical to before). Verified:
`--radial 0.2:1.9` on HandCrafted/UMAP gives r ∈ [0.200, 1.900] with the same
winning projection + score. Tests for the stretch math, the percentile bounds,
mode resolution, and a pipeline integration check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- DOM labels anchored above centroid / antipode / domain-group markers, with
  a connector tick. Projected each frame, scaled by camera distance
  (0.7–1.7×), hidden behind the globe (front-hemisphere occlusion) and when
  the matching overlay layer is off.
- Clickable: click any label to tween-focus the camera on it; clicking a
  domain (centroid) label solos that domain (others dimmed; click to restore).
- Settings → "Labels": one checkbox per label kind present in the scene
  (Centroids / Antipodes / Domain groups), each toggling independently.
  Covered by Reset.

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

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 57306f06-48a5-4351-a880-990aa55d8d89

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sphereql-vis

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

bkahan and others added 18 commits June 16, 2026 12:44
refactor(vis): extract viewer.js + add ScenePoint.id and Scene::to_json

Cross-cutting foundation for the visualization roadmap (decouple viewer
from generator so it can rebuild from any Scene at runtime).

- ScenePoint gains optional `id` (stable identity for cross-scene k-NN
  highlight and morph; viewer will key on id, not array position) plus a
  `with_id` builder. Serialized only when present.
- Scene::to_json / from_json: canonical JSON wire form (doctested) for the
  drag-drop loader and the future WASM bridge.
- Viewer runtime moved out of template.html into src/viewer.js, inlined at
  a new /*__SPHEREQL_VIEWER__*/ placeholder, so the baked HTML and the
  upcoming WASM studio share one implementation and cannot drift. Still a
  single self-contained offline page.
- Re-anchor tests/emit.rs::embedded_payload: the data line is no longer
  adjacent to `const pts=D.points` (now in viewer.js); read the single
  `const D=` line directly. All 11 emit tests + 2 doctests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
Split the monolithic viewer init into persistent setup + a rebuild(sc)
/ teardown() core so the viewer can swap to any Scene at runtime (a
dropped file, or a live WASM pipeline) without reloading the page — the
foundation for the generalize/studio/compare roadmap.

- Persistent (created once): renderer/scene/camera/controls/lights/
  raycaster and every static DOM event listener. Per-scene state moved
  to module-level lets that rebuild() reassigns and listeners read live
  (never a stale snapshot).
- teardown() disposes the prior scene's GPU geometries/materials (points,
  globe, lines, every overlay group), removes them from the THREE scene,
  and clears generated DOM; only ever called synchronously at the top of
  rebuild() so animate() never sees half-torn state.
- rebuild(sc) reads the Scene shape (points/overlays/stats/title/
  surface_radius/show_axes) and resets all view settings to defaults, so
  a swapped scene starts clean. Added null guards (getHovered/search/
  drawMinimapBase) for the transient null window.
- Baked page just calls rebuild(D) once at boot.
- Re-anchor tests/emit.rs::embedded_payload on the payload script's
  opening (the const pts=D.points line it keyed on moved into rebuild()).

Verified: node --check passes; end-to-end emission unchanged (1097 KB,
self-contained, 0 external scripts); adversarial diff-review vs the
pre-refactor extraction found no behavioral regressions, leaks, TDZ, or
stale-state carryover. All 11 emit tests + 2 doctests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The viewer is now a general 3D sphere/embedding explorer: drop a Scene
JSON anywhere on the page (or use Settings ▸ Open Scene JSON…) and it
teardown()s the current scene and rebuild()s around the new one — no
reload, no regenerate.

- parseScene(obj): normalizes arbitrary input into the Scene shape.
  Accepts the full Scene::to_json shape OR a bare points array, and
  tolerates minimal points — each needs only finite x/y/z OR finite
  r/theta/phi; the missing pair is derived with the sphereql-core
  convention (x=r·sinφ·cosθ …; θ=atan2(y,x)∈[0,2π), φ=acos(z/r)).
  surface_radius falls back to the median ‖xyz‖, stats/overlays/title
  get sensible defaults. Non-placeable points are dropped; empty or
  shapeless input throws a human-readable message.
- Drag-drop overlay (#dropzone) bound on window with a file-drag guard
  and an enter/leave depth counter; hidden #scene-file input + an
  Open Scene button reuse the soft-fail (✓/✗) feedback pattern.
- Documented the public Scene JSON runtime contract in the crate docs.

Verified with a headless THREE+DOM harness booting the real viewer.js:
20 checks incl. full-scene/bare-array/spherical-only/mixed/error inputs,
a clean second-scene rebuild() (the drag-drop swap path, recomputing
categories), and a real 775-point emitted scene round-tripping through
parseScene. Rust: all sphereql-vis tests + doctests green; doc-snippets
gate green; node --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three pure-client explorer tools, added as header buttons:

- Ruler (⟀): click two points; reports the great-circle angle (deg + rad)
  and chord, and draws the connecting geodesic on the shell via slerp
  (same arc construction as overlay.rs geodesic_path). Esc clears.
- PNG (⤓): preserveDrawingBuffer renderer → canvas.toDataURL → download.
- Share (🔗): copies a link whose #v= hash is base64(JSON) of camera
  pose + view settings + tool toggles (NEVER scene data); applyViewHash()
  restores it once after the boot rebuild, reading only validated
  numbers/known keys.

Folds in fixes from an adversarial review workflow (8 confirmed of 21):
- MAJOR: ruler slerp blew up for (near-)antipodal picks (1/sin(om)→∞,
  arc collapsed through the globe). Now special-cased: coincident →
  short segment; antipodal → clean great semicircle about a ⟂ axis;
  else slerp. Arc verified on-shell for all cases.
- MAJOR: stored XSS via stats.sampled_from / dropped_nonfinite on the
  drag-drop path — now coerced to numbers in parseScene AND escHtml-ed
  at the render chokepoint.
- MAJOR: rulerOn wasn't reset on scene swap — teardown now fully disarms
  the tool (flag + button + picks).
- MINOR: dropped the deprecated escape/unescape from the hash codec
  (encodeURIComponent/decodeURIComponent round-trip verified).
- MINOR: malformed overlays in a dropped scene no longer abort rebuild
  (per-overlay try/catch + kind filter in parseScene).
- Documented the median-shell parity with Scene::surface_radius_for and
  the preserveDrawingBuffer tradeoff (kept for cross-browser PNG).

Verified via the headless THREE+DOM harness (24 checks: ruler 0/90/180°
+ on-shell antipodal arc, PNG download, hash round-trip, ruler-reset,
stats coercion, overlay filtering) plus all sphereql-vis Rust tests +
doctests; page stays fully offline (1.1 MB, 0 external scripts).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decouples the viewer's data source: a fitted pipeline (and free text) can
now produce a sphereql-vis Scene entirely in the browser, feeding the same
viewer as the baked HTML and drag-drop.

- New optional deps behind features `scene` (sphereql-vis) and `lingua`
  (sphereql-lingua, implies `scene`), both added to `default` so
  `cargo --all-features` and the wasm jobs build them.
- Pipeline.buildSceneJson(title) → JSON: maps exported_points →
  ScenePoint (stable id + certainty/intensity), category centroids,
  classified bridges, Voronoi territory caps, and domain-group spokes,
  with projection-kind/evr stats. Colors use the viewer's aurora palette
  + sorted-category assignment so overlays match the point colors.
- LinguaStudio.process(text) → JSON: runs LinguaPipeline in-browser; each
  placed concept becomes a point (domain → category, surface form →
  label, normalized → id), each typed relation a geodesic path, with mean
  salience as the headline stat.
- Both return JSON strings (not tsify): a Scene is deep and the viewer
  parses JSON anyway, keeping the generated .d.ts small.

Sizes (measured): raw .wasm 1.5M → 2.3M after wasm-bindgen (no wasm-opt;
wasm-pack's -Oz trims ~30-40%, gzip far more) — separate-file packaging
is the right default; single-file base64 stays opt-in (Phase 5).

Verified: native --all-features build + clippy -D warnings clean; new
native tests (build_scene_json shape + ids/quality/overlays/stats;
LinguaStudio smoke); wasm32 release build + wasm-bindgen confirm
buildSceneJson / LinguaStudio.process are exported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A live, in-browser projection studio under sphereql-wasm/studio/: paste
prose and sphereql-lingua places each concept on the sphere as you type,
or switch to Corpus JSON mode to run the full embedding pipeline and pick
a projection — all client-side via WASM, re-projecting live.

- Shared viewer, zero drift: the studio shell is emitted from
  Scene::to_html() (three.js + the SAME viewer.js inlined, empty initial
  scene) by examples/build_studio.rs, with the studio chrome injected
  after <body> and studio.js before </body>. studio.js drives the
  viewer's global rebuild()/parseScene().
- Off-thread compute: a classic Web Worker (worker.js) importScripts the
  --target no-modules wasm glue and runs LinguaStudio.process /
  Pipeline.buildSceneJson, posting Scene JSON back. Requests are debounced
  (~320ms) and id-tagged so the freshest paste wins; pre-init requests are
  queued (newest only) and replayed on ready.
- build.sh assembles dist/ (self-contained index.html + worker/driver +
  pkg/*.wasm); prefers wasm-pack, falls back to wasm-bindgen + wasm-opt.
  Separate-file packaging is the default; single-file (base64 wasm) is a
  documented future opt-in. dist/ is gitignored.

Headless-verified: studio shell emits with all chrome IDs + inlined
viewer globals + empty scene + offline three; studio.js/worker.js pass
node --check; a worker-protocol harness (stubbed wasm) covers
queue-before-ready, ready, lingua/corpus dispatch, newWithConfig, and the
unknown-kind error path; build.sh assembles dist end-to-end and the
no-modules glue exposes wasm_bindgen/LinguaStudio/Pipeline/buildSceneJson.
Remaining: a manual browser smoke-test (paste → worker → render).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Corpus mode gains a nearest-neighbor query: enter a query vector, the
studio runs pipeline.nearest off-thread and the viewer highlights the
hits by stable id.

viewer.js:
- rebuild() builds an idToIndex map (stable id → point index).
- highlightByIds(ids) emphasizes the matches (closest largest), dims the
  rest, and fans geodesics from the nearest match to the others on the
  shell. Accepts raw ids or {id,…} objects (NearestOut). Returns the
  resolved count; [] clears. Persistent queryGroup wired into scalables
  and cleared on teardown / re-query.
- Factored the robust great-circle builder out of the ruler into a shared
  shellArc(a,b) (coincident → segment, antipodal → ⟂-axis semicircle,
  else slerp), now reused by the query fans.

studio (worker.js / studio.js / chrome.html):
- The worker keeps the last corpus Pipeline alive and adds a `query`
  handler → pipeline.nearest(query, k) → posts neighbors; a query before
  any corpus fails gracefully.
- A query row (vector input + k + Find, Enter to submit) shows in corpus
  mode; onmessage now branches scene-rebuild vs neighbor-highlight.

Headless-verified: a viewer harness covers id resolution (raw + object
ids), the geodesic-fan count, unknown-id skipping, clear, and
idToIndex/queryGroup reset on rebuild; the worker harness covers
corpus-keeps-pipeline → query → neighbors (honoring k) and the
query-before-corpus error. sphereql-vis Rust tests + doctests green; the
studio rebuilds with the query chrome. No Rust source changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two ways to compare projections of the same corpus, both id-aligned.

viewer.js:
- Morph: setMorphTarget(sceneB) keys B's points by stable id;
  applyMorph(t) slerps each A-point's direction toward its id-matched
  B-direction and lerps the radius by t∈[0,1] (t=0 = A, t=1 = B).
  Unmatched points stay; t=0 falls back to the normal spread/radial view.
  Cleared on teardown.
- Compare embedding (opt-in via #embed): the viewer accepts an injected
  scene + a synced camera over postMessage and broadcasts its own camera
  moves to the parent. The broadcast is epsilon-gated (not a bare flag),
  so the OrbitControls damping that follows an applied update can't start
  a feedback storm. Fully inert without #embed (baked viewer unaffected).

studio:
- A morph control (corpus mode): pick a second projection → the worker
  builds it as a morph target → a slider drives applyMorph.
- compare.html / compare.js: two embed.html#embed iframes fed two
  projections by one worker; the parent relays each pane's camera to the
  other for synced orbiting. build_studio.rs now also emits embed.html
  (a plain, chrome-less viewer for the panes); build.sh copies the
  compare assets.

Headless-verified: morph endpoints exact (t=0→A, t=1→B, t=0.5 on the
geodesic with lerped radius), unmatched points untouched, clear/rebuild
reset; embed sync covers scene injection, camera broadcast, the epsilon
gate, the echo guard (no feedback storm), and inertness without #embed.
All 6 JS harness suites + sphereql-vis Rust tests green; clippy clean.
Remaining: a manual browser smoke-test of the live studio panes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two isolated viewer additions, both persisted in the TOML config.

- Density shading: rebuild() bins each point's (original) direction into a
  θ×φ grid (the minimap's scheme) and stores its bin's normalized count as
  a per-point `density` attribute. A "Density shading" toggle flips a
  shader uniform that recolors the cloud as a cold→hot heatmap (and fades
  sparse points), so dense regions of the sphere read at a glance.
- Pins: a 📍 pin-mode tool — click the globe shell to drop an annotated
  (θ,φ) marker with a floating, depth-culled label (clickable to remove).
  Pins survive scale/zoom (angular coords, re-placed on the current
  shell). Esc exits pin mode; rebuild clears pins; reset clears them.
- Persistence: both ride the existing flat TOML — `density` as a bool and
  `pins` as a single base64-JSON value (not array-of-tables), encoded with
  the same encodeURIComponent codec as the share hash. Loaded pins are
  validated (finite θ/φ) and rendered via textContent (no innerHTML), so a
  hostile config can't inject markup.

Headless-verified: density attribute carries correct normalized bin
counts (clustered=1.0, lone=0.25), the toggle flips the uniform; pins add
markers, keep explicit/auto(ASCII) labels, round-trip through the TOML
base64 key, and reset on rebuild. All 7 JS harness suites + sphereql-vis
Rust tests green; the studio rebuilds with the new controls.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The studio opened on an empty sphere (then auto-ran a tiny lingua
example); it now opens on the rich 775-point HandCrafted corpus — the
same auto-tuned, fully-overlaid scene visualize_corpus renders — because
it simply looks better as a landing view.

- New sphereql_examples::demo_scene(CorpusId) factors the load → embed →
  auto-tune (deterministic seed) → build_corpus_scene flow so it can be
  reused without duplicating the pipeline setup.
- build_studio bakes demo_scene(HandCrafted).to_html() as the studio's
  (and the compare panes') opening scene; sphereql-examples + sphereql-
  corpus are dev-deps (native example only — not compiled into the wasm
  cdylib, so the shipped bundle is unchanged).
- studio.js no longer auto-runs the lingua example on boot, so the baked
  scene stays until the user acts (type / example / corpus / morph).

Verified: build_studio emits index.html + embed.html with 775 points /
1020 overlays (umap_sphere); sphereql-wasm bridge tests pass; clippy +
fmt clean on the changed crates.

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

Two studio fixes:
- The studio now OPENS in Corpus JSON mode (lingua is the alternate),
  matching the baked 775-point demo scene that's already on screen.
- Switching to lingua and back to corpus no longer runs the tiny sparse
  6-point example (the bug that replaced the 775). Corpus mode now
  restores the baked demo scene via rebuild(D) — instant, no recompute —
  so the 775 is always the corpus default.

Mechanics: per-mode input buffers (corpus starts empty for your own
{categories,embeddings}; lingua seeds the prose example); switching modes
saves/restores each buffer; entering corpus mode calls restoreDemoScene()
(rebuild of the baked global D); the debounced live re-projection now only
runs in lingua mode (corpus JSON runs explicitly via Run/example). The
demo corpus itself (2.1 MB of dense 128-dim vectors) is intentionally NOT
dumped into the textarea — the box is for pasting your own corpus, and the
'example' button drops a small editable template.

node --check clean; build.sh assembles dist with the 775 baked + Corpus
mode active.

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

Addresses three studio issues:

1. Remove the sparse corpus entirely. The 6-point CORPUS_EXAMPLE is gone;
   the "example" button is now lingua-only (hidden in corpus mode), so
   there's no path back to the sparse scene.
2. Return from the side-by-side: compare.html gains a "← studio" link.
3. Compare did nothing because it had no corpus to project. The demo
   corpus is now shipped as a fetched dist/demo-corpus.json (emitted by
   build_studio via demo_corpus_json) — NOT dumped in any textarea. The
   worker can now re-project / compare / morph it:
   - compare.js: empty box → compare the demo corpus (projA vs projB),
     so it works out of the box; robust injection waits for each pane's
     new `sphereql-embed-ready` signal (no lost scene if a pane is still
     loading); also relays cameras as before.
   - studio.js: run / morph fall back to the demo corpus when the box is
     empty, so they act on the 775-point demo.
   - viewer.js: the #embed block posts `sphereql-embed-ready` to the
     compare host once its message listener is live.

The textarea stays empty for your own paste (per the earlier choice);
the 2.1 MB corpus lives only in the fetched file + the worker.

Verified: all 7 JS harness suites pass (incl. embed sync with the new
ready ping); build.sh assembles dist with demo-corpus.json (2.1 MB),
both drivers fetching it, the back link present, and no CORPUS_EXAMPLE;
clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Query needs a worker pipeline, so it previously failed on the default
demo until you ran a corpus. The studio now primes the demo pipeline in
the background on boot — without touching the textarea or the displayed
scene:

- worker.js: new `load` kind builds the pipeline (kept for queries) but
  returns no scene, so the baked demo scene on screen is untouched.
- studio.js: primeDemo() sends `load` with the fetched demo corpus once
  wasm + the corpus are ready (config:null → fast PCA build). Because
  nearest() works in embedding space, the projection used for priming
  doesn't matter — query highlights the baked scene correctly by id.
  Guarded by demoPrimed + corpusRan so it never overwrites a corpus the
  user has run/morphed themselves.

Net: open the studio → query immediately finds neighbors in the 775-point
demo; paste your own corpus + Run and query switches to it. The textarea
stays empty (no 2.1 MB dump).

Verified: worker harness covers the new load kind (load → {ok,loaded},
then query works); all query/morph/embed harness suites still pass;
build.sh assembles dist with the priming wired in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bridge classification was gated on the outer projection's EVR, but for
UmapSphere `explained_variance_ratio()` returns a kNN-recall score
(neighbourhood preservation) orthogonal to relation validity, and ~0.09
on real corpora -- so it suppressed EVERY bridge to Weak (0 Genuine
wormholes). Remove the gate; classify from projection-independent
full-dim signals, implementing the documented balanced_affinity_quantile
intent: Genuine when min(affinity_to_source, affinity_to_target) clears
the home-affinity quantile AND the two category centroids are distinct
(new overlap_separation_quantile floor). territorial_factor stays a soft
discount; min_evr_for_classification deprecated (kept for serde).
Replaces the misleading low-EVR test with one asserting classification
is independent of the deprecated gate.

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

Four UI fixes:

1. Compare bar no longer shifts on "compare": #status had a variable
   width (margin-left:auto) so its text changes reflowed the flex bar,
   pushing the controls right. It's now a fixed-width, right-aligned,
   ellipsised cell. Also reflowed the compare page to a flex column (bar
   auto-height + panes fill the rest) so a wrapping bar can't overlap the
   panes.
2. SphereQL header fits the screen: #hdr gains max-width:calc(100vw-32px)
   + box-sizing, and the subtitle flexes/shrinks (min-width:0), so the
   centered header never overflows the viewport.
3. Resizable minimap: #mini gets CSS `resize:both` (min/max bounded); a
   ResizeObserver keeps the drawing buffer matched to the element size
   (MW/MH are now mutable) so the sky chart stays crisp at any size.
4. Independent orbit / zoom locks in side-by-side compare: two toggles
   ("⟲ orbit", "⊙ zoom") broadcast a `sphereql-lock` message to both
   panes; the viewer's #embed block maps them to controls.enableRotate /
   enableZoom + a wheel-zoom `zoomLocked` flag. Locks are re-sent to a
   pane when it (re)loads.

Verified: all 7 JS harness suites pass (incl. a new lock-message test:
lockRotate→enableRotate=false independently of lockZoom→pinch off +
wheel zoomLocked); sphereql-vis Rust tests green; build.sh assembles the
dist with all four wired in.

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

From the adversarial production-readiness audit:

- BLOCKER (correctness): parseScene computed the median surface_radius
  over ALL norms, but Rust Scene::surface_radius_for filters finite &&
  >0. A dropped scene containing origin points landed on a different
  shell than baked. JS now applies the same filter — exact parity.
- BLOCKER (robustness): a missing/failed demo-corpus.json fetch was
  swallowed silently while the status still claimed "demo corpus",
  leaving query broken with a misleading message. Now tracked
  (demoFailed) in studio.js + compare.js: honest status, and run/find/
  morph give a clear "demo unavailable — paste/run your own" instead of
  a confusing failure.
- MAJOR (robustness): a wasm load failure ({type:'fatal'}) left the UI
  interactive but inert (pendingRun stuck, no recovery signal). Now a
  wasmFailed flag short-circuits run/find/morph/build with "wasm
  unavailable — reload the page" (studio.js + compare.js).
- MINOR (security): the #embed viewer accepted scene/cam/lock postMessage
  from any window. It now requires e.source === parent (the compare
  host); scene data was already parseScene-sanitized, so this closes
  camera/lock spoofing.
- MINOR (correctness): morph's antipodal branch used lerp+normalize,
  which collapses to the origin at the midpoint. Replaced with the
  perpendicular-axis great-circle sweep (same as shellArc) so the path
  stays on the sphere. Also corrected the "upper-median" comment.
- MINOR (robustness): compare shows "✓ compared" only once BOTH panes
  receive a scene (was set on the first); build.sh errors clearly if
  neither wasm-pack nor wasm-bindgen is installed.

Verified: all 7 JS harness suites pass (added a surface_radius-parity
test + a non-parent-source rejection test); sphereql-vis Rust tests +
doctests green; node --check + bash -n clean. The earlier deterministic
sweep (fmt/clippy -D/4 CI gates/wasm32 build) was already green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The viewer runtime (sphereql-vis/src/viewer.js) and the studio worker are
browser JS with no Rust around their logic, so the Rust suite never
covered them — the harness that caught real bugs during development lived
only in a scratch dir. This commits it as js-tests/ and runs it in CI.

- js-tests/harness.cjs boots the real viewer.js in a Node vm against thin
  THREE + DOM stubs and exposes its internals for assertions.
- 7 suites (01-parse-scene … 07-worker) cover parseScene + surface_radius
  Rust-parity, the ruler/PNG/share tools, semantic query, morph, the
  compare embed-sync (incl. the echo guard + non-parent rejection),
  density+pins, and the worker message protocol.
- run-all.cjs runs each suite in its own process and exits non-zero on
  any failure; a new `js-tests` CI job runs `node js-tests/run-all.cjs`.
- Paths are repo-relative; the one integration check that needs a real
  emitted page is env-gated (SPHEREQL_EMIT_HTML) and skipped otherwise,
  so CI needs no Rust artifacts.

Verified: `node js-tests/run-all.cjs` → all 7 suites pass; check-docs OK
(js-tests is not a crate, no table/floor impact).

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

The rust-api audit flagged Scene::to_json's `.expect` as a potential
panic on NaN/Inf. It isn't: serde_json renders non-finite floats as JSON
`null` rather than erroring, so a built — or hand-constructed — Scene
with non-finite coordinates serializes without panicking (the WASM
bridge's buildSceneJson relies on this to never trap the worker). This
adds a regression test that constructs a Scene with NaN/±Inf via the
public fields, asserts to_json doesn't panic and yields valid JSON with
nulls, and documents that Rust from_json won't read those nulls back
(serde null↛f64) — fine, since the runtime consumer is the viewer's JS
parseScene, which coerces null→0.

No code change — the audit finding was a false positive; this verifies
and pins the behavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant