diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a4bdb03 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +# Build and test on every push and pull request to main. +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache cargo registry and build + uses: Swatinem/rust-cache@v2 + + - name: Build + run: cargo build --locked --verbose + + - name: Test + run: cargo test --locked --verbose + + - name: Clippy (no warnings as errors; informational) + run: cargo clippy --all-targets || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..946a178 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +name: Release + +# Build optimized `lume` binaries for all major platforms and attach them to a +# GitHub Release. Triggers on a pushed version tag (e.g. `v0.12.0`), or manually +# against an existing tag via the Actions tab. +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Existing tag to build and release (e.g. v0.12.0)" + required: true + +permissions: + contents: write + +jobs: + build: + name: ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + linker: gcc-aarch64-linux-gnu + - os: macos-latest + target: x86_64-apple-darwin + - os: macos-latest + target: aarch64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo registry and build + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Install cross linker (Linux aarch64) + if: matrix.linker != '' + run: | + sudo apt-get update + sudo apt-get install -y ${{ matrix.linker }} + mkdir -p .cargo + echo "[target.aarch64-unknown-linux-gnu]" >> .cargo/config.toml + echo "linker = \"aarch64-linux-gnu-gcc\"" >> .cargo/config.toml + + - name: Build release binary + run: cargo build --release --locked --target ${{ matrix.target }} + + - name: Package (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + VERSION="${{ github.event.inputs.tag || github.ref_name }}" + ARCHIVE="lume-${VERSION}-${{ matrix.target }}.tar.gz" + tar -czf "$ARCHIVE" -C "target/${{ matrix.target }}/release" lume + sha256sum "$ARCHIVE" > "$ARCHIVE.sha256" + echo "ASSET=$ARCHIVE" >> "$GITHUB_ENV" + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + VERSION="${{ github.event.inputs.tag || github.ref_name }}" + ARCHIVE="lume-${VERSION}-${{ matrix.target }}.zip" + 7z a "$ARCHIVE" "./target/${{ matrix.target }}/release/lume.exe" + certutil -hashfile "$ARCHIVE" SHA256 > "$ARCHIVE.sha256" + echo "ASSET=$ARCHIVE" >> "$GITHUB_ENV" + + - name: Upload to release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.tag || github.ref_name }} + files: | + ${{ env.ASSET }} + ${{ env.ASSET }}.sha256 + fail_on_unmatched_files: true + generate_release_notes: true diff --git a/.gitignore b/.gitignore index 7c972bb..c861151 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,13 @@ crawl_cache/ # Local evaluation scratch (corpus copy + tag dictionary) eval-tmp/ +# Viz app build output and deps +viz/node_modules/ +viz/dist/ + +# Secrets (NUTS_SERVICES_TOKEN, etc.) — never commit +.env + # Build and archive directories target/ target-cli/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7324b44..92d5279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ co-occur with everything are damped toward zero; genuine associations rise. Computed directly from the roaring-bitmap intersection counts already used for Jaccard (no extra scan). New `cooccurrence_relatedness` in `semantic_mesh.rs`. + The z-score is log-compressed before the tanh bound so strong edges on large + corpora keep their gradation instead of all saturating at ±1. - **`--scoring` flag** on `lume search` (and `lume eval`): choose `relatedness` (significance, default) or `jaccard` (raw overlap) for the SKG walk. The graph walk and edge sort now key on significance by default, Jaccard as tie-breaker. @@ -22,6 +24,28 @@ modes and prints the delta. New `src/eval.rs` (pure, unit-tested) plus `handle_eval` wiring. UTF-8-tolerant Q&A loading (cp1252 files don't abort). +### Agentic answering +- **`lume answer` + viz "Ask" mode**: an agentic plan → retrieve → evaluate → + refine → answer loop over a local Ollama model (default gpt-4o-mini). The model + plans search queries, the field is retrieved and animated, the model judges + whether the passages suffice and refines the queries if not (up to N rounds), + then synthesizes a **cited** answer. Streams NDJSON events (`question`, `plan`, + `evaluate`, relaxation frames, `answer`) through the same bridge. In the viz, + the answer panel shows the per-round plan log and the answer; nodes fed to the + model get a soft halo, the ones it **cited** glow, and clicking a source chip + highlights its orb. New `src/answer.rs`. + +### Visualization +- **`lume stream` + `viz/`**: live 3D visualizer for the search dynamics. + `lume stream ` runs a phase-binding + Weber relaxation over the query's + top-K candidates (shivvr embeddings, read-only) and emits one NDJSON frame per + step on stdout — each node's 3D PCA position, velocity, **acceleration**, phase, + cluster, and **approach-acceleration toward the query** (the `d̈` static cosine + discards). `viz/` is a Node WebSocket bridge + React/three.js app that renders + it: candidates as a force field, green/red arrows for accelerating toward/away + from the query, emergent phase clusters, and a Kuramoto coherence meter. New + `src/stream.rs`; no new Rust dependencies (std + existing serde_json). + ### Tests - New unit tests for the significance function, hub down-weighting (significance flips a ranking Jaccard gets wrong), and the eval metrics/relevance judging. diff --git a/Cargo.toml b/Cargo.toml index adedc1d..0e080ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "lume" version = "0.12.0" edition = "2021" description = "Lume: A high-performance, zero-dependency, FST-backed tagger and on-demand BM25 hybrid search engine." +license = "BSD-3-Clause" [dependencies] tantivy-fst = "0.5" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5093555 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, DeepBlue Dynamics + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/docs/blog/how-lume-works-part1-primitives.md b/docs/blog/how-lume-works-part1-primitives.md new file mode 100644 index 0000000..c319dca --- /dev/null +++ b/docs/blog/how-lume-works-part1-primitives.md @@ -0,0 +1,341 @@ +--- +title: "How Lume Works, Part 1: The Retrieval Primitives" +date: 2026-06-19 +series: "How Lume Works" +part: 1 +tags: [search, retrieval, bm25, vector-search, semantic-knowledge-graph, rust, query-tuning] +description: > + A grounded walk through Lume's retrieval core — field-aware BM25, two-stage + roaring/Gödel pruning, local GTR-T5 vectors, a significance-scored entity graph, + the multiplicative blend that fuses them, and the knobs that tune it all. No + visualization here; that's Part 2. +--- + +# How Lume Works, Part 1: The Retrieval Primitives + +Lume’s 3D hyperspace visualization is a window into a structured retrieval core. The visual field isn't a mock-up; it is driven by the candidate sets and scores produced by a layered ranking engine where each signal acts as an independent, testable **primitive**. + +This post is **Part 1**: the retrieval primitives, end to end, with references to the real codebase. For the physics-based spatial rendering, see [Part 2: The Visualization Field](./hyperspace-search-deep-dive.md). By the end, you’ll understand exactly how Lume transforms a raw query string into a ranked candidate list. + +A few principles up front, because they explain the design: + +- **Local-first.** Lexical search and the entity graph run entirely on your + machine. Dense vectors are fetched from Shivvr through `SHIVVR_BASE_URL`, + which defaults to a local endpoint. +- **Layered, not monolithic.** BM25, semantic, and graph are independent signals + with their own scores. The blend is one line; each input is replaceable. +- **Auditable.** The engine prints what it pruned, what it ranked, and why it + rejected the rest. + +--- + +## 0. The unit of retrieval: a Section + +Lume indexes Markdown, cut into **sections** at `#` headers +(`parse_markdown` in `src/bm25.rs:211`). A `Section` (`src/bm25.rs:106`) is the atom +everything ranks over: + +```rust +pub struct Section { + pub title: String, + pub body: String, + pub line_number: usize, + pub filename: Option, + pub entities: Vec, // resolved named entities, for the graph +} +``` + +Title and body are **separate fields** with separate statistics — that distinction +shows up immediately in scoring. The whole index lives in memory as a +`Bm25Index` (`src/bm25.rs:147`): per-field term-frequency maps, document +frequencies, field lengths, **roaring-bitmap posting lists**, prime/Gödel signature +filters, and the entity posting lists that feed the graph. + +--- + +## 1. Primitive: field-aware BM25 + +The lexical core is a field-aware BM25 with three selectable variants. The tuning +defaults (`Bm25Params` in `src/bm25.rs:125`) are deliberately classic: + +```rust +Self { k1: 1.2, b: 0.75, delta: 1.0, title_weight: 2.0, body_weight: 1.0 } +``` + +`k1` controls term-frequency saturation; `b` controls length normalization. The one +opinionated choice is **`title_weight: 2.0`**: a title hit contributes twice as much +as a body hit before the coordination factor is applied. That is useful, but it can +overweight chapter titles when a query token is broad. Treat it as a knob, not a law. + +IDF is the standard smoothed form, floored at zero, and each term's contribution is +computed per field then summed with the field weights +(`calculate_bm25_term_score` in `src/bm25.rs:728`): + +```rust +let len_normalization = 1.0 - b + b * (doc_len / avgdl); +match variant { + SearchVariant::Classic => idf * (tf * (k1 + 1.0)) / (tf + k1 * len_normalization), + SearchVariant::Plus => idf * ((tf*(k1+1.0))/(tf + k1*len_normalization) + params.delta), + SearchVariant::L => { let s = tf / len_normalization; + idf * (s*(k1+1.0))/(s + k1) }, +} +// total_score += title_weight * title_score + body_weight * body_score; (src/bm25.rs:635) +``` + +- **Classic** is textbook BM25. +- **Plus** adds a `delta` floor so a matched term never contributes *nothing*, + countering BM25's over-penalty of long documents. +- **L** moves length normalization inside the saturation, smoothing very long docs. + +Lume runs `Classic` by default (`src/main.rs:1430`). + +--- + +## 2. Two-stage pruning: roaring union, then Gödel signatures + +You don't want to BM25-score all 1,926 sections of a book for every query. Lume's +`search` (`src/bm25.rs:445`) is **two-stage**. + +**Stage 1 — candidate gather.** Union the roaring-bitmap posting lists of the query +terms. This is a handful of bitset ORs and instantly narrows the corpus to sections +that contain *any* query term: + +```rust +// src/bm25.rs:460 +let mut candidate_set = MiniRoaring::new(); +let mut first = true; +for q_tok in &query_tokens { + if let Some(list) = self.posting_lists.get(&q_tok.bytes) { + if first { + candidate_set = list.clone(); + first = false; + } else { + candidate_set = candidate_set.union(list); + } + } +} +``` + +**Stage 1b — Gödel tag-signature pruning.** If the query tagger recognizes entities, each candidate section is verified against a **prime-factored signature filter** (`PrimeFilter::test_tag_prime` in `src/fast_retrieval.rs:449`, evaluated in `src/bm25.rs:538`). Each known tag output maps to a prime; a section's tag signature is the product of its tag primes, so inclusion is checked by divisibility. Unknown query tags deliberately receive a dummy prime and fail closed. Candidates that fail are dropped as `TagSignatureMismatch` before heavier scoring. + +**Stage 2 — heavy scoring** runs only on survivors. And the engine *tells you* the +shape of the funnel on stderr (`src/bm25.rs:557`): + +```text +[Two-Stage Pruning] Pruned candidate space from 1926 to 302 (roaring generated: 609) sections in 54.70µs +Candidates: 609 +Ranked: 302 +Rejected: + TagSignatureMismatch: 307 + ... +``` + +That accounting is not decoration — it's the first thing you read when a query +returns the wrong thing. + +--- + +## 3. Query hygiene: stopwords and coordination + +Two small primitives have outsized effects on quality. + +**Query-side stopword filtering** (`filter_query_stopwords` in `src/bm25.rs:98`). +Function and question words are stripped from the **query only**, never the index. +Without it, "how does Dantès know Mercédès" is dominated by *how/does/know*, which +match unrelated sections (a chapter literally titled *"How a Gardener…"*). The +safety net: if *every* token is a stopword ("how are you"), the originals are kept +so you still get results. + +**The coordination factor** (`src/bm25.rs:638`). A document matching more of the +*distinct* query terms should beat one that repeats a single common term. Lume +multiplies the score by a coverage-based factor: + +```rust +let coverage = matched_terms.len() as f64 / num_distinct as f64; +let coord = COORD_FLOOR + (1.0 - COORD_FLOOR) * coverage; // COORD_FLOOR = 0.5 +total_score *= coord; +``` + +So a section matching all three query terms keeps 100% of its score; one matching a +single term out of three keeps ~⅔. For single-term queries `coverage == 1.0`, so +ordinary lookups are untouched. It's a gentle nudge, not a hard AND. + +--- + +## 4. Primitive: dense vectors (local GTR-T5) + +Lexical search can't bridge a vocabulary gap — "starved to death" vs "gastro- +enteritis." That's the semantic primitive's job. + +Lume embeds text to **768-dimensional GTR-T5** vectors via **Shivvr**. The default +base URL is `http://localhost:8085` (`src/hybrid.rs:777`), and the request still +requires a service token (`src/hybrid.rs:784`). There's no dedicated embed endpoint, +so `embed_text` (`src/hybrid.rs:43`) ingests into a throwaway scratch store and +reads the vector straight off the response: + +```rust +// 768-d GTR-T5 ("organize") vector, asserted on the way out: +if emb.len() != 768 { return Err(format!("Expected 768-d GTR-T5 vector, got {}", emb.len())); } +``` + +At index time, sections are pushed into a **semantic session** +(`ensure_semantic_session` in `src/hybrid.rs:581`). The clever part is incrementality: +each section gets a content hash (`section_hash` in `src/hybrid.rs:407`) used as the +remote chunk id, with line numbers deliberately excluded — so **moving** a section +doesn't force a re-embed. Re-indexing diffs by hash and tops up only what changed; a +matching corpus fingerprint is a no-op. Query results are cached too +(`.lume-semantic-cache.json`). + +At query time, `query_semantic_search` (`src/hybrid.rs:637`) asks Shivvr for `n=60` +neighbors. If the index was built without semantic vectors, or the token is missing, +search degrades cleanly to lexical BM25 and *says so*: an `alpha > 0` request against +a lexical-only index is told it got lexical-only (`src/main.rs:1389`). + +--- + +## 5. Primitive: the Semantic Knowledge Graph (significance, not co-counts) + +The third signal is structural. Lume builds an **entity co-occurrence graph** from +pairwise roaring-bitset intersections — "counting the counts of things" +(`src/graph_search.rs:1`). On its own that's a write-only export; `graph_search` +turns it into a **query-time ranking signal**, fully local. + +The subtle, important bit is **how edges are weighted**. Raw co-occurrence and even +Jaccard reward *promiscuous hubs* — an entity that appears everywhere co-occurs with +everything. Lume defaults to a **significance** score instead +(`cooccurrence_relatedness` in `src/semantic_mesh.rs:558`): of `n` docs, A appears in +`a`, B in `b`, both in `k`; compare observed `k` against the independence +expectation `E = a·b/n` as a z-score, **log-compress** it to preserve dynamic range, +then squash with `tanh`: + +```rust +let expected = a * b / n; +let variance = expected * (1.0 - a/n) * (1.0 - b/n); +let z = (k - expected) / variance.sqrt(); +let compressed = z.signum() * (1.0 + z.abs()).ln(); // keep z=10 vs z=100 distinct +(compressed / 3.0).tanh() // → [-1, 1]; negative = avoidance +``` + +The result lives in `[-1, 1]`: positive for real association, **negative for +avoidance** (entities that co-occur *below* chance). This is Trey Grainger's +foreground-vs-background SKG relatedness, reduced to the pairwise case. A regression +test pins the behavior: a true pairing (edmond–dantès) outranks a promiscuous hub +even when the hub has perfect Jaccard overlap +(`src/graph_search.rs:305`). You can still pick legacy Jaccard with +`--scoring jaccard`; the edge `weight()` (`src/semantic_mesh.rs:532`) selects, and +clamps negative significance to `0` so avoidance never *boosts*. + +The walk itself (`compute_skg_scores` in `src/graph_search.rs:154`): + +1. **Resolve** the query's entities by sliding n-grams longest-first (so "edmond + dantes" matches the stored "edmond dantès"), `max_ngram = 4`. +2. **Seed** them at weight `1.0`; walk **one hop** to the top-`k` (8) strongest + neighbors, each carrying `decay * weight` (`decay = 0.5`), taking the **max** + across seeds — so a shared hub neighbor can't be summed into dominance. +3. **Accumulate** per-section mass from the entity posting lists and **normalize to + `[0, 1]`**. + +--- + +## 6. The blend: one multiplicative line (HATCHERIK) + +Three signals — lexical, semantic, graph — fuse in `blend_hybrid_scores` +(`src/hybrid.rs:673`). The default is **multiplicative**, so the lexical match leads +and the other two *lift* it: + +```rust +hybrid = bm25 * (1.0 + alpha * semantic + beta * skg); +``` + +This keeps strong lexical hits on top while letting semantic and graph signals lift +them. Recall expansion comes from two paths: semantic-only hits are admitted with +`bm25_score = 0`, and SKG-only sections are admitted only when their normalized graph +mass reaches `SKG_EXPAND_MIN = 0.5` (`src/graph_search.rs:22`). In the lexical-only +path those SKG-only sections are scored `beta*skg`, below comparable lexical matches +(`apply_skg_boost` in `src/graph_search.rs:218`). Set `beta = 0` and the graph term +is removed. + +There's an alternate mode for when you want semantic/graph to be able to *overtake* +lexical, gated behind `LUME_BLEND_NORM=1`, which puts all three on a comparable +`[0,1]` scale (`src/hybrid.rs:745`): + +```rust +// src/hybrid.rs:745 +let hybrid_score = if normalize { + (bm25_score / bm25_max) + alpha * sem_score + beta * skg_score +} else if bm25_score > 0.0 { + bm25_score * (1.0 + alpha * sem_score + beta * skg_score) +} else { + sem_score + beta * skg_score // fallback for semantic-only recall expansion +}; +``` + +--- + +## 7. The tuning surface: what each knob actually does + +Everything above is exposed on `lume search` (`handle_search` in `src/main.rs:1295`). +Here's the practical guide: + +| Knob | Flag / env | Default | What it changes | +|------|-----------|---------|-----------------| +| **Lexical ↔ semantic** | `-a, --alpha` / `ALPHA` | `0.5` in `lume search` | `0.0` = pure BM25; higher values increase the GTR-T5 term. Raise it when the answer uses different words than the query. | +| **Graph boost** | `-g, --graph` / `GRAPH_ALPHA` | `0.4` | Weight of the SKG term. Raise to pull in entity-related sections; `0` disables the graph (pure lexical+semantic). | +| **Edge scoring** | `--scoring` | `relatedness` | `relatedness` (significance, hub-resistant) vs `jaccard` (raw overlap). Use significance unless you're reproducing old numbers. | +| **Spell correction** | `-c, --spell-check` | off | Corrects each query word against the index vocabulary (`correct_query` in `src/main.rs:1273`) before searching. | +| **BM25 length/saturation** | `Bm25Params` | `k1=1.2, b=0.75` | Lower `b` for corpora with wildly varying section lengths; raise `k1` to reward repeated terms more. | +| **Field weight** | `title_weight` | `2.0` | How much a title hit beats a body hit. Lower it if chapter/section titles are hijacking results. | +| **Query inversion (debug)** | `LUME_QUERY_INVERSION=1` | off | Round-trips the query's GTR-T5 vector back to text (`invert_vector` in `src/inversion.rs:30`) so you can inspect the embedding. Costs an extra embed plus invert request and does not affect ranking. | + +A worked example of the inversion trick: if semantic search keeps missing, set +`LUME_QUERY_INVERSION=1` and read what your query embeds *back* to. If "how does the +prisoner escape" inverts to something about gardens, your embedding isn't anchored on +"escape" — reword, or lean lexical with a lower `alpha`. + +--- + +## 8. Case Study: The Retrieval Bug That Confused the Agent + +To understand how these primitives interact, consider a real query run during testing over the full text of *The Count of Monte Cristo*: **"How does Edmond Dantès's father die?"** + +On Lume's initial run, the answering agent returned: *"The provided passages do not contain information regarding how Edmond Dantès's father died."* + +However, the death is fully detailed in the corpus. We traced this failure to three compounding issues at the query and retrieval parameter boundary: + +1. **Proper-Noun Bias:** The query planner generated keyword queries like `"Dantès father death"`. In Chapter 26, the father is referred to as "the old man" or "the elder Dantès", and his death is described without the word "death" (*"died of starvation"*). Meanwhile, the literal chapter title *"Father and Son"* and broad entity matches on `Dantès` pulled retrieval toward passages about the father while alive, pushing the target scene out of the evidence window. +2. **Hardcoded Retrieval Depth:** The number of passages fed to the LLM (`n_feed`) was hardcoded to 10. When the target passage was pushed down in rank by proper-noun bias, it was clipped before the model could evaluate it. +3. **Context Pressure:** Local model calls ran without an explicit `num_ctx`, so prompts containing multiple passages could be truncated by the model runtime. + +### The Fix + +We corrected the behavior by adjusting the boundaries of our primitives: +* **Query Diversification:** We updated the planner (`plan_queries` in `src/answer.rs:49`) to generate multiple query angles: one targeting explicit entities, one using event synonyms (e.g. `"died of starvation grief"`), and one using secondary characters or details. +* **Dynamic Feedback Depth:** We scaled `n_feed` dynamically based on requested candidates: `candidates.clamp(10, 20)` in `src/main.rs:1797`. +* **Explicit Context Bounds:** We set `"num_ctx": 16384` explicitly in `ollama_chat` (`src/answer.rs:21`) and widened snippet sizes to 180 words. + +With these changes, the planner's query `"died of starvation grief"` surfaced the death scene in the retrieval set. The agent could then judge the evidence sufficient and generate a cited answer. + +--- + +## How the pieces fit + +For a single query, Lume executes: + +1. **Tokenize & Filter:** `tokenize` (`src/bm25.rs:452`) and strip stopwords from the query string using `filter_query_stopwords` (`src/bm25.rs:98`). +2. **Stage 1 (Prune):** Perform a roaring-bitmap union of term posting lists, then check Gödel tag signatures using `test_tag_prime` (`src/fast_retrieval.rs:449`). +3. **Stage 2 (Lexical):** Compute field-aware BM25 scores via `calculate_bm25_term_score` (`src/bm25.rs:728`) and apply coordination factor (`src/bm25.rs:644`) over survivors. +4. **Semantic primitive:** Fetch GTR-T5 local dense neighbor scores using `query_semantic_search` (`src/hybrid.rs:637`). +5. **Graph primitive:** Walk the significance-weighted graph using `compute_skg_scores` (`src/graph_search.rs:154`). +6. **Blend:** Merge signals with `blend_hybrid_scores` (`src/hybrid.rs:673`) and return the candidates. + +The result is a ranking pipeline where each component can be inspected, turned down, or disabled without rewriting the rest of the system. + +--- + +**Next — [How Lume Works, Part 2: The Visualization Field](./hyperspace-search-deep-dive.md):** how Lume takes this ranked field of candidates, projects their 768-D vectors into 3D, and runs a phase-binding relaxation so you can *see* the structure of an answer space. + +`#Rust` · `#InformationRetrieval` · `#BM25` · `#VectorSearch` · `#SemanticKnowledgeGraph` · `#QueryTuning` + +--- + +*Line numbers are against the current tree and drift as code moves — grep the symbol names (`Bm25Index::search`, `blend_hybrid_scores`, `compute_skg_scores`, `cooccurrence_relatedness`, `handle_search`) if they've shifted.* diff --git a/docs/blog/hyperspace-search-deep-dive.md b/docs/blog/hyperspace-search-deep-dive.md new file mode 100644 index 0000000..9837043 --- /dev/null +++ b/docs/blog/hyperspace-search-deep-dive.md @@ -0,0 +1,176 @@ +--- +title: "How Lume Works, Part 2: The Visualization Field" +date: 2026-06-19 +series: "How Lume Works" +part: 2 +tags: [search, visualization, rust, three.js, kuramoto, weber, physics-simulation, websocket] +description: > + A deep dive into Lume's visualization engine — projecting 768-D vectors into 3D, + running a Weber-style relaxation and Kuramoto phase-binding simulation in Rust, + relaying NDJSON frames over WebSockets, and rendering a hyperspace warp-in using + Three.js. +--- + +# How Lume Works, Part 2: The Visualization Field + +In [Part 1: The Retrieval Primitives](./how-lume-works-part1-primitives.md), we went inside Lume's retrieval core to see how BM25, dense embeddings, and entity graphs fuse into a single ranked list. Ranking is only half the story. + +Lume renders this ranked list as a living 3D vector field. This is Part 2: the visualization. We'll show how a Rust core simulates multi-query vector-space relaxation, streams frames over WebSockets, and drives a Three.js front end where results don't just load — they warp in from hyperspace. + +> **Stack at a glance.** A Rust core runs the spatial relaxation simulation and streams frames as **NDJSON** on stdout (`lume stream`, `lume answer`). A Node **WebSocket bridge** (`viz/server.js:91`) spawns this binary and relays each frame to the client. A **React + Three.js** front end (`viz/src/App.jsx:178`) interpolates frames and drives a custom useFrame warp-in animation (per-frame transform, no GLSL) (`viz/src/VectorField.jsx:84`). + +--- + +## 1. Mapping Candidates to 3D Coordinates + +Lume does not use pre-computed layout coordinates or static snapshots. The candidate nodes we visualize are generated dynamically by the retrieval union. + +Each surviving search hit is represented as a `Candidate` struct (`Candidate` in `src/stream.rs:43`). The stream layer receives the union produced by retrieval (`retrieve_union` in `src/main.rs:1704`): section id, score, and the query indices that surfaced it. The renderer uses that set directly; the dynamic part is position, phase, cluster id, and visual layout. + +--- + +## 2. The Layout is a Simulation, Not a t-SNE Snapshot + +Static projection methods throw away the *dynamics* of how results relate to a query. Lume preserves some of that motion. The module `src/stream.rs:150` runs a **phase-binding + Weber relaxation** simulation, emitting one frame per simulation step. + +### 2a. Single-Basis PCA Projection +All candidates and queries are projected into 3D using a top-3 **PCA basis** fitted via power iteration (`Pca::fit` in `src/stream.rs:97`). By sharing a single projection space, multi-query overlap orbs land in the exact same physical coordinates for all queries, rather than requiring reconciled separate coordinates. + +### 2b. Weber Coupling +For each non-query node against every other node, Lume calculates relative vector distances and their finite differences, then computes a **Weber-style** coupling term. This term modulates the phase coupling and vector warp: + +```rust +// src/stream.rs:339 +let term1 = (rdot * rdot) / (2.0 * params.c_weber * params.c_weber); +let term2 = (d * rddot) / (params.c_weber * params.c_weber); +let b_ij = (1.0 - term1 + term2).clamp(-2.0, 2.0); + +// Gated by phase alignment and a semantic Gaussian: +let sem = distance(&vi, &snapshot[j]); +let s_ij = (-(sem * sem) / (params.sigma_v * params.sigma_v)).exp(); +couplings[i][j] = params.k0 * wij * s_ij * b_ij; +``` + +### 2c. Kuramoto Phase Synchronization +To make semantic clusters visually bundle together, each node carries an oscillator phase `θ`. The coupling force drives a Kuramoto update: + +```rust +// src/stream.rs:369 +let mut torque = 0.0; +for j in 0..m { + if i != j { + torque += couplings[i][j] * (thetas[j] - thetas[i]).sin(); + } +} +let xi = (rng.next_u64() as f64 / u64::MAX as f64 - 0.5) * 2.0; +next_theta[i] = thetas[i] + (nodes[i].omega + torque) * DT + params.noise * DT.sqrt() * xi; +``` + +The global **order parameter** `r_global` (the "phase coherence" bar in the UI) tracks the overall synchronization of non-query nodes: + +```rust +// src/stream.rs:408 +let r_global = if cnt > 0.0 { ((cs / cnt).powi(2) + (sn / cnt).powi(2)).sqrt() } else { 0.0 }; +``` + +### 2d. Approach Acceleration +A node's radius from its query anchor is a function of cosine similarity (`radius = 0.7 + 3.0 * (1.0 - cos)`, `src/stream.rs:264`). To expose the motion, Lume computes **approach acceleration** (`approach_acc` in `src/stream.rs:397`) as the second finite difference of cosine distance to the anchor: + +```rust +// src/stream.rs:396 +let nav = (dq - nodes[i].dq) / DT; +nodes[i].approach_acc = (nav - nodes[i].approach_vel) / DT; +nodes[i].approach_vel = nav; +nodes[i].dq = dq; +``` + +--- + +## 3. The WebSocket Bridge: Spawn, Stream, and Relay + +Since browsers cannot directly spawn native binaries or read child-process stdout, `viz/server.js` acts as the bridge. On receiving a WebSocket request, it spawns `lume` in a child process, forwards stdout JSON lines verbatim, and keeps stderr for status/error messages: + +```js +// viz/server.js:91 +ws.send(JSON.stringify({ type: "status", state: "running", bin: LUME_BIN, args })); +child = spawn(LUME_BIN, args, { cwd: join(__dirname, "..") }); + +const rl = createInterface({ input: child.stdout }); +rl.on("line", (line) => { + const t = line.trim(); + if (t.startsWith("{") && ws.readyState === ws.OPEN) ws.send(t); // forward frame verbatim +}); +``` + +Because a new process is spawned per request, rebuilding the Rust core takes effect immediately on the next search without requiring a bridge server restart. + +--- + +## 4. Visualizing the Answering Agent's Orchestration + +When you ask a question (`lume answer` in `src/main.rs:1740`), the binary runs an agentic loop: **Plan → Retrieve → Evaluate → Refine → Answer**. + +Rather than hiding this workflow behind a loading spinner, Lume streams the state changes directly into the spatial field: + +1. **`type: "plan"`**: The planner's generated search queries are shown in the control panel. +2. **`type: "frame"`**: The results retrieved for these queries warp into the field. +3. **`type: "evaluate"`**: The model's evaluation notes (indicating whether the retrieved passages are sufficient) appear as status text. +4. **`type: "answer"`**: The final inline-cited answer is displayed over the final field. The citations are parsed via `parse_citations` in `src/answer.rs:107`, causing the cited source orbs in the 3D field to light up as provenance anchors. + +--- + +## 5. Front-End Rendering: Interpolate, Lay Out, and Warp + +The browser stores streamed frames in a React ref, then uses its own animation clock to play them smoothly. + +### 5a. Frame Interpolation +To achieve sub-frame fluid motion, each animation tick interpolates selected node fields between successive frames: + +```jsx +// viz/src/App.jsx:178 +const nodes = frames.length ? layout(interpolate(meta, frames[lo], frames[hi], frac), spread) : []; +``` + +The `interpolate` function (`viz/src/App.jsx:60`) linearly interpolates `pos`, `acc`, `cos_q`, and `approach_acc`. It does not interpolate velocity or phase; those are either unused by the renderer or represented through the interpolated position/acceleration visuals. + +### 5b. Collision Separation +To prevent overlapping results, the `layout` function (`viz/src/App.jsx:12`) applies iterative 3D sphere separation after interpolation and optional cluster spread. It pads each render radius by 25% to leave room for stretched orb visuals, resolving intersections in up to 26 iterations. + +### 5c. The Hyperspace Warp-In +Every new result set triggers a warp entrance animation. Nodes start far out along their radial lines from the center, stretched thin along their axis of travel, and decelerate hard into place: + +```jsx +// viz/src/VectorField.jsx:84 +useFrame(({ clock }) => { + const g = grpRef.current; + if (!g) return; + const t0 = warpRef.current.t0; + let p = t0 <= -100 ? 1 : (clock.elapsedTime - t0 - warpIn.delay) / WARP_DUR; + p = p < 0 ? 0 : p > 1 ? 1 : p; + const k = 1 - easeOutExpo(p); // 1 → 0 over the jump + g.position.set(warpIn.off[0] * k, warpIn.off[1] * k, warpIn.off[2] * k); + if (p < 1 && meshRef.current) { + // Streak: stretch along the radial travel axis, thinning out the sides + meshRef.current.quaternion.copy(warpIn.quat); + meshRef.current.scale.set(1 / (1 + 2.2 * k), 1 + 7 * k, 1 / (1 + 2.2 * k)); + } +}); +``` + +A staggered delay is computed using `hash01(node.id) * WARP_STAGGER` (`WARP_STAGGER = 0.30` in `viz/src/VectorField.jsx:18`) to create a cascade effect, and the stretch is applied mesh-locally so layout logic remains uncorrupted. + +--- + +## Why Visual Search Matters + +* **Spatial Semantics:** The layout relaxation puts related documents near their query anchors and exposes cluster membership frame by frame. +* **Auditable Answering:** The source orbs that light up on the screen are the passages fed to the answerer or cited by it. Visual citations act as a provenance log. +* **Responsive Interactions:** NDJSON streaming combined with an independent animation clock ensures the layout begins animating immediately, without waiting for the full simulation to complete. + +Cue *Derezzed* and watch it jump to hyperspace. 🌌 + +--- + +*Line numbers are against the current tree and drift as code moves — grep the symbol names (`stream::run`, `handle_answer`, `plan_queries`, `parse_citations`) if they've shifted.* + +`#Rust` · `#ThreeJS` · `#VectorSearch` · `#RAG` · `#AgenticAI` · `#Kuramoto` · `#WeberRelaxation` diff --git a/docs/blog/hyperspace-search.md b/docs/blog/hyperspace-search.md new file mode 100644 index 0000000..e82590d --- /dev/null +++ b/docs/blog/hyperspace-search.md @@ -0,0 +1,95 @@ +--- +title: "Search at Light Speed: Inside Lume's Hyperspace Vector Field" +date: 2026-06-19 +tags: [search, AI, vector-search, RAG, agentic-ai, visualization, rust, three.js] +description: > + We turned search into a living 3D vector field where results warp in from + hyperspace and an agent searches itself until it finds the cited answer. + Here's how it works — and the one-line bug that nearly hid it. +--- + +# Search at Light Speed: Inside Lume's Hyperspace Vector Field + +I put on *Derezzed* by Daft Punk, hit search, and watched the results **warp in from hyperspace** — orbs streaking out of the void, decelerating hard, and snapping into formation like a fleet dropping out of lightspeed. + +This is **Lume**. It doesn't return a static list of blue links. It renders your query as a living 3D vector field, then runs an answering loop over the evidence it retrieves. + +--- + +## Search You Fly Through, Not Scroll Past + +Traditional search engines hand you ten blue links and wish you luck. Lume hands you a spatial **field**. + +Every result is an orb positioned dynamically in 3D space: +* **Size** encodes relevance weight. +* **Color** encodes which query it answered (Lume supports additive, multi-facet search — stack as many queries as you like). +* **Position** comes from the shared simulation space: clusters separate, related passages pull toward their query anchors, and **overlap halos** glow gold where multiple queries surface the same passage. + +The layout isn't a static snapshot. The field relaxes frame by frame using a phase-binding and Weber-style simulation until the structure of the answer space becomes visible. You orbit it, zoom in, and hover over orbs to read the underlying text. + +When a new query is executed, the results **jump in via hyperspace**: each orb is flung far out along its radial vector, stretched into a thin streak, and eased back into place in a staggered cascade. The motion reveals the topological shape of the result set as it forms. + +--- + +## An Agent That Argues With Itself + +Visualization is the surface. Underneath is **agentic answering**: Lume plans searches, checks whether the retrieved passages answer the question, and refines when they do not. + +When you ask a question, Lume runs a loop: +1. **Plan:** Write its own keyword search queries. +2. **Retrieve:** Pull candidates into the field (which you watch warp in). +3. **Evaluate:** Judge its own evidence: *do these passages actually answer the question?* +4. **Refine:** If not, rewrite queries from a different angle and search again. +5. **Answer:** Synthesize a concise, inline-cited response, lighting up the cited orbs in the field as provenance anchors. + +I tested Lume on the full text of *The Count of Monte Cristo* (1,926 passages). + +**"How does Edmond Dantès's father die?"** The answer is buried 26 chapters deep, in a scene where the father is never called "father" and his death is never called "death" — Caderousse simply recounts that the old man *"died of downright starvation."* Lume planned, retrieved, judged the evidence sufficient, and quoted it with a citation to Chapter 26 in seconds. + +Then I threw a curveball: **"How does Caderousse die?"** + +Its first query plan guessed *"killed by Danglars."* Finding no evidence, it ruled the retrieved passages **insufficient**, refined the query, searched again, ruled *that* insufficient, and refined a third time to find the correct answer: + +> *"Caderousse dies from mortal wounds after being murdered by his comrade in the galleys at Toulon, the Corsican Benedetto (who also calls himself Andrea Cavalcanti)."* — cited to Chapter 83. + +A search loop that can form a bad hypothesis, reject it against retrieved evidence, and try a different angle is materially different from a one-shot lookup. + +--- + +## The One-Line Bug That Nearly Hid All of This + +Here is the honest engineering footnote: the failure was more instructive than the success. + +The first time I asked about Dantès's father, Lume answered: *"The provided passages do not contain information regarding how Edmond Dantès's father died."* + +But the answer was in the corpus. We traced the failure. The raw retrieval was fine; the death scene ranked high for keyword content. The problem was upstream in the **query planner**. + +Every query the planner generated restated the question's proper nouns — "Dantès," "father," "death." In a richly indexed corpus, those tokens had too much pull: the literal chapter title *"Father and Son"* and broad entity matches pushed the top evidence toward passages about the father while alive. The agent was fed the wrong slice of the corpus and concluded the answer was missing. + +The planner couldn't bridge the vocabulary gap between the user's question and the prose's narrative of the event. + +To fix it, we diversified the query planner to generate synonyms and event descriptions, scaled the LLM's feedback window with candidate depth, and explicitly configured the local model's context window. The technical details are in the code post-mortem in [Part 1: The Retrieval Primitives](./how-lume-works-part1-primitives.md#8-case-study-the-retrieval-bug-that-confused-the-agent). + +With the fix in place, the planner can emit `"died of starvation grief"`, the death scene enters the retrieved field, and the evaluator has the evidence it needs. The answer was in the data all along; the system just had to ask from the right angle. + +--- + +## Why This Matters + +The future of search isn't a smarter text box. It is: +* **Spatial:** Meaning has structure. Show it. +* **Multi-Query:** Real questions have facets. Search all of them at once and watch where they overlap. +* **Agentic and Honest:** A system that plans, evaluates, refines, and cites — and can say when the retrieved passages do not answer the question. +* **Fast Enough to Feel Alive:** Streaming simulation coordinates so results don't just load — they warp in. + +Now cue *Derezzed* and watch it jump to hyperspace. 🌌 + +--- + +> [!NOTE] +> **Go Under the Hood** +> If you want to see the code and equations behind Lume, check out our technical series: +> - [How Lume Works, Part 1: The Retrieval Primitives](./how-lume-works-part1-primitives.md) — Fusing field-aware BM25, GTR-T5 semantic vectors, and significance-weighted entity graphs. +> - [How Lume Works, Part 2: The Visualization Field](./hyperspace-search-deep-dive.md) — Projecting 768-D vectors into 3D, Weber-style layout relaxation, and Three.js rendering. + +`#SearchEngine` · `#VectorSearch` · `#AgenticAI` · `#RAG` · `#DataVisualization` · `#Rust` · `#ThreeJS` · `#FutureOfSearch` · `#LLM` · `#DeepTech` diff --git a/src/answer.rs b/src/answer.rs new file mode 100644 index 0000000..b470292 --- /dev/null +++ b/src/answer.rs @@ -0,0 +1,145 @@ +//! Agentic question-answering over the retrieved field (`lume answer`). +//! +//! Drives a plan → retrieve → evaluate → refine → answer loop with a local +//! Ollama model (default gpt-4o-mini). The orchestration + retrieval + the +//! relaxation animation live in `main.rs::handle_answer`; this module holds the +//! model I/O (plan / evaluate / synthesize) and the citation parsing, so they +//! stay testable and the loop reads cleanly. + +use serde_json::{json, Value}; + +/// Non-streaming Ollama `/api/chat` call. Returns the assistant message content. +pub fn ollama_chat(url: &str, model: &str, system: &str, user: &str, temperature: f64) -> Result { + let endpoint = format!("{}/api/chat", url.trim_end_matches('/')); + let payload = json!({ + "model": model, + "messages": [ + { "role": "system", "content": system }, + { "role": "user", "content": user }, + ], + "stream": false, + "options": { "temperature": temperature, "num_ctx": 16384 }, + }); + let resp = ureq::post(&endpoint) + .set("Content-Type", "application/json") + .timeout(std::time::Duration::from_secs(300)) + .send_json(&payload) + .map_err(|e| format!("Ollama request failed: {}", e))?; + let v: Value = resp.into_json().map_err(|e| format!("Ollama response parse failed: {}", e))?; + v.get("message") + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| "Ollama response had no message.content".to_string()) +} + +/// Extracts the first balanced `[...]` or `{...}` span from model text (which may +/// wrap JSON in prose or ``` fences). +fn first_json_span(s: &str, open: char, close: char) -> Option<&str> { + let start = s.find(open)?; + let mut depth = 0i32; + for (i, c) in s[start..].char_indices() { + if c == open { depth += 1; } + else if c == close { depth -= 1; if depth == 0 { return Some(&s[start..start + i + c.len_utf8()]); } } + } + None +} + +/// Plans 1–3 search queries for the question. +pub fn plan_queries(url: &str, model: &str, question: &str) -> Result, String> { + let sys = "You are a search query planner for a full-text search engine over a long narrative \ +document. Given a question, output a JSON array of 2 to 4 SHORT keyword queries (3-6 words each, \ +NOT full sentences) that would retrieve the passage answering it. The text usually describes what \ +happens WITHOUT repeating the question's proper nouns, so DIVERSIFY the queries: \ +(a) one using the key names/entities from the question; \ +(b) one or two using SYNONYMS and the EVENT or ACTION itself, phrased the way the narrative would \ +(e.g. for a death: 'died of starvation grief', 'passed away in poverty'); \ +(c) one naming a likely SECONDARY character, place, or concrete detail involved in the scene. \ +Do NOT repeat the same proper noun in every query. Output ONLY the JSON array, nothing else."; + let out = ollama_chat(url, model, sys, question, 0.1)?; + let arr = first_json_span(&out, '[', ']').ok_or_else(|| format!("planner gave no JSON array: {}", out.trim()))?; + let v: Value = serde_json::from_str(arr).map_err(|e| format!("planner JSON invalid: {}", e))?; + let queries: Vec = v.as_array().map(|a| { + a.iter().filter_map(|x| x.as_str().map(|s| s.trim().to_string())).filter(|s| !s.is_empty()).collect() + }).unwrap_or_default(); + if queries.is_empty() { Err("planner produced no queries".to_string()) } else { Ok(queries) } +} + +/// The evaluator's verdict on whether the retrieved passages can answer the +/// question, plus any new queries to try if not. +pub struct Verdict { + pub sufficient: bool, + pub queries: Vec, + pub note: String, +} + +/// Judges whether `passages` answer `question`; if not, proposes new queries. +pub fn evaluate(url: &str, model: &str, question: &str, passages: &str) -> Result { + let sys = "You judge whether retrieved passages can answer a question. Respond with ONLY a \ +JSON object: {\"sufficient\": true|false, \"queries\": [..], \"note\": \"..\"}. If the passages \ +clearly contain the answer, set sufficient=true and queries=[]. If not, set sufficient=false and \ +give 2-3 NEW short keyword queries that approach from a DIFFERENT angle than what evidently failed: \ +use synonyms, the event/action as the narrative would phrase it, or a secondary character, place, \ +or concrete detail — rather than rephrasing the question's proper nouns again."; + let user = format!("QUESTION: {}\n\nPASSAGES:\n{}", question, passages); + let out = ollama_chat(url, model, sys, &user, 0.1)?; + let obj = first_json_span(&out, '{', '}').ok_or_else(|| format!("evaluator gave no JSON object: {}", out.trim()))?; + let v: Value = serde_json::from_str(obj).map_err(|e| format!("evaluator JSON invalid: {}", e))?; + let sufficient = v.get("sufficient").and_then(|x| x.as_bool()).unwrap_or(true); + let queries = v.get("queries").and_then(|x| x.as_array()).map(|a| { + a.iter().filter_map(|x| x.as_str().map(|s| s.trim().to_string())).filter(|s| !s.is_empty()).collect() + }).unwrap_or_default(); + let note = v.get("note").and_then(|x| x.as_str()).unwrap_or("").to_string(); + Ok(Verdict { sufficient, queries, note }) +} + +/// Synthesizes a cited answer from numbered passages. +pub fn synthesize(url: &str, model: &str, question: &str, numbered_passages: &str) -> Result { + let sys = "Answer the question using ONLY the numbered passages provided. Cite every passage \ +you draw on inline with its number in square brackets, e.g. [2]. Be concise and factual. If the \ +passages do not contain the answer, say so plainly."; + let user = format!("QUESTION: {}\n\nPASSAGES:\n{}\n\nAnswer with inline [n] citations:", question, numbered_passages); + ollama_chat(url, model, sys, &user, 0.3) +} + +/// Parses the `[n]` markers actually used in an answer, returning the distinct +/// 1-based passage numbers in first-appearance order. +pub fn parse_citations(answer: &str, max_n: usize) -> Vec { + let mut out = Vec::new(); + let bytes = answer.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'[' { + let mut j = i + 1; + while j < bytes.len() && bytes[j].is_ascii_digit() { j += 1; } + if j > i + 1 && j < bytes.len() && bytes[j] == b']' { + if let Ok(n) = answer[i + 1..j].parse::() { + if n >= 1 && n <= max_n && !out.contains(&n) { out.push(n); } + } + i = j + 1; + continue; + } + } + i += 1; + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_json_spans_from_prose() { + assert_eq!(first_json_span("sure: [\"a\", \"b\"] ok", '[', ']'), Some("[\"a\", \"b\"]")); + assert_eq!(first_json_span("```json\n{\"x\":1}\n```", '{', '}'), Some("{\"x\":1}")); + assert_eq!(first_json_span("none here", '[', ']'), None); + } + + #[test] + fn parses_distinct_in_range_citations() { + assert_eq!(parse_citations("From [2] and [5], also [2] again.", 6), vec![2, 5]); + assert_eq!(parse_citations("[9] is out of range", 6), Vec::::new()); + assert_eq!(parse_citations("no cites", 6), Vec::::new()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 165d403..b0ef612 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,8 @@ pub mod fast_retrieval; pub mod graph_search; pub mod semantic_mesh; pub mod eval; +pub mod stream; +pub mod answer; pub mod regex; pub mod spelling; pub mod inversion; diff --git a/src/main.rs b/src/main.rs index 81d6b12..1c08bb1 100755 --- a/src/main.rs +++ b/src/main.rs @@ -106,6 +106,18 @@ fn main() { std::process::exit(1); } } + "stream" => { + if let Err(e) = handle_stream(&args[2..]) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } + "answer" => { + if let Err(e) = handle_answer(&args[2..]) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + } "summarize" => { if let Err(e) = handle_summarize(&args[2..]) { eprintln!("Error: {}", e); @@ -223,6 +235,8 @@ SUBCOMMANDS: summarize Agentic document summarizer via planning, search exploration, and synthesis crawl Stealth crawl webpage content and save to personal search collection eval Measure retrieval quality (Hit@k, MRR, nDCG@k) against a Q&A file + stream Stream the live phase/Weber search relaxation as NDJSON for the 3D visualizer + answer Agentic plan→retrieve→answer loop with citations, streamed for the visualizer "#, env!("CARGO_PKG_VERSION")); } @@ -1624,6 +1638,287 @@ fn print_eval_compare(jac: &lume::eval::EvalAggregate, rel: &lume::eval::EvalAgg println!(" Δ = relatedness − jaccard (positive favors significance scoring)."); } +/// `lume stream ` — streams the phase-binding + Weber relaxation over the +/// query's top-K candidates as NDJSON frames on stdout (one per step). Diagnostics +/// go to stderr so stdout stays a clean frame stream for the viz bridge. +fn handle_stream(args: &[String]) -> Result<(), String> { + if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") { + print_stream_help(); + return Ok(()); + } + + let mut db_dir = String::from(".lume-index"); + let mut candidates = 24usize; + let mut steps = 160usize; + let mut beta = 0.4f64; + let mut queries: Vec = Vec::new(); + + let mut idx = 0; + while idx < args.len() { + let arg = &args[idx]; + match arg.as_str() { + "--db" if idx + 1 < args.len() => { db_dir = args[idx + 1].clone(); idx += 2; } + "-k" | "--candidates" if idx + 1 < args.len() => { + candidates = args[idx + 1].parse().map_err(|_| format!("Invalid candidates: {}", args[idx + 1]))?; idx += 2; + } + "--steps" if idx + 1 < args.len() => { + steps = args[idx + 1].parse().map_err(|_| format!("Invalid steps: {}", args[idx + 1]))?; idx += 2; + } + "-g" | "--graph" if idx + 1 < args.len() => { + beta = args[idx + 1].parse().map_err(|_| format!("Invalid graph weight: {}", args[idx + 1]))?; idx += 2; + } + "--add" if idx + 1 < args.len() => { queries.push(args[idx + 1].clone()); idx += 2; } + other if other.starts_with('-') => return Err(format!("Unknown option: {}", other)), + _ => { queries.push(arg.clone()); idx += 1; } + } + } + if queries.is_empty() { + return Err(String::from("Missing search query")); + } + + let db_path = Path::new(&db_dir); + let state_file_path = db_path.join("state.json"); + if !state_file_path.exists() { + return Err(format!("Index state file not found at {}. Index the corpus first.", state_file_path.display())); + } + lume::hybrid::set_cache_dir(db_path); + let _state: IndexState = load_json(&state_file_path)?; + let bm25: Bm25Index = load_json(&db_path.join("bm25.json"))?; + + // Quiet candidate retrieval per query (BM25 + optional SKG), unioned with + // overlap membership. Nothing here touches stdout (the NDJSON channel). + let graph: Option = if beta > 0.0 { load_json(&db_path.join("entity_graph.json")).ok() } else { None }; + let cands = retrieve_union(&bm25, graph.as_ref(), beta, &queries, candidates); + if cands.is_empty() { + return Err("no candidates retrieved for any query".to_string()); + } + + let sp = lume::stream::StreamParams { steps, candidates, ..Default::default() }; + eprintln!("[stream] queries={:?} union_candidates={} steps={}", queries, cands.len(), steps); + lume::stream::run(&bm25, &queries, &cands, &sp, true) +} + +/// Per-query BM25 (+ optional SKG) retrieval, unioned by section id with the set +/// of query indices that surfaced each (the overlap membership). Order is first- +/// seen. Shared by `lume stream` and the `lume answer` loop. +fn retrieve_union( + bm25: &Bm25Index, + graph: Option<&EntityGraph>, + beta: f64, + queries: &[String], + candidates: usize, +) -> Vec { + use std::collections::HashMap; + let params = Bm25Params::default(); + let mut union: HashMap)> = HashMap::new(); + let mut order: Vec = Vec::new(); + for (qi, q) in queries.iter().enumerate() { + let mut hits = bm25.search(q, SearchVariant::Classic, ¶ms, None); + if let Some(g) = graph { + if beta > 0.0 { + let skg = lume::graph_search::SkgBoostParams { beta, ..Default::default() }; + let walk = lume::graph_search::compute_skg_scores(bm25, g, q, &skg); + lume::graph_search::apply_skg_boost(&mut hits, &walk.scores, beta); + } + } + hits.truncate(candidates); + for h in &hits { + let e = union.entry(h.section_index).or_insert_with(|| { order.push(h.section_index); (h.score, Vec::new()) }); + if h.score > e.0 { e.0 = h.score; } + if !e.1.contains(&qi) { e.1.push(qi); } + } + } + order.iter().map(|sid| { + let (score, members) = union[sid].clone(); + lume::stream::Candidate { section_id: *sid, score, members } + }).collect() +} + +/// `lume answer ` — agentic plan → retrieve → evaluate → refine → +/// answer loop over a local Ollama model, streaming NDJSON events (plan rounds, +/// the relaxation frames, and a cited answer) for the visualizer. +fn handle_answer(args: &[String]) -> Result<(), String> { + if args.is_empty() || args.iter().any(|a| a == "-h" || a == "--help") { + print_answer_help(); + return Ok(()); + } + + let mut db_dir = String::from(".lume-index"); + let mut candidates = 18usize; + let mut steps = 140usize; + let mut beta = 0.4f64; + let mut max_rounds = 3usize; + let mut model = String::from("gpt-4o-mini:latest"); + let mut ollama_url = std::env::var("OLLAMA_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let mut words: Vec = Vec::new(); + + let mut idx = 0; + while idx < args.len() { + let arg = &args[idx]; + match arg.as_str() { + "--db" if idx + 1 < args.len() => { db_dir = args[idx + 1].clone(); idx += 2; } + "-k" | "--candidates" if idx + 1 < args.len() => { candidates = args[idx + 1].parse().map_err(|_| "bad -k")?; idx += 2; } + "--steps" if idx + 1 < args.len() => { steps = args[idx + 1].parse().map_err(|_| "bad --steps")?; idx += 2; } + "-g" | "--graph" if idx + 1 < args.len() => { beta = args[idx + 1].parse().map_err(|_| "bad -g")?; idx += 2; } + "--rounds" if idx + 1 < args.len() => { max_rounds = args[idx + 1].parse().map_err(|_| "bad --rounds")?; idx += 2; } + "--model" if idx + 1 < args.len() => { model = args[idx + 1].clone(); idx += 2; } + "--ollama-url" if idx + 1 < args.len() => { ollama_url = args[idx + 1].clone(); idx += 2; } + other if other.starts_with('-') => return Err(format!("Unknown option: {}", other)), + _ => { words.push(arg.clone()); idx += 1; } + } + } + let question = words.join(" "); + if question.trim().is_empty() { + return Err(String::from("Missing question")); + } + + let db_path = Path::new(&db_dir); + if !db_path.join("state.json").exists() { + return Err(format!("Index not found at {}. Index the corpus first.", db_dir)); + } + lume::hybrid::set_cache_dir(db_path); + let bm25: Bm25Index = load_json(&db_path.join("bm25.json"))?; + let graph: Option = if beta > 0.0 { load_json(&db_path.join("entity_graph.json")).ok() } else { None }; + + let emit = |v: serde_json::Value| println!("{}", v); + emit(serde_json::json!({ "type": "question", "text": question, "model": model })); + eprintln!("[answer] question={:?} model={}", question, model); + + // --- Plan --- + let mut queries = match lume::answer::plan_queries(&ollama_url, &model, &question) { + Ok(q) => q, + Err(e) => { eprintln!("[answer] planner failed ({e}); using the question verbatim"); vec![question.clone()] } + }; + emit(serde_json::json!({ "type": "plan", "round": 1, "queries": queries, "note": "" })); + + let sp = lume::stream::StreamParams { steps, candidates, ..Default::default() }; + // Passages handed to the model. Scale with -k (capped) so raising candidates + // actually widens what the evaluator/answerer can see, instead of a fixed 10. + let n_feed = candidates.clamp(10, 20); + + let mut cands: Vec = Vec::new(); + let mut round = 1usize; + loop { + cands = retrieve_union(&bm25, graph.as_ref(), beta, &queries, candidates); + if cands.is_empty() { + emit(serde_json::json!({ "type": "evaluate", "round": round, "sufficient": false, "note": "no candidates retrieved" })); + break; + } + // Animate this round's field (no terminal "done" — the loop continues). + lume::stream::run(&bm25, &queries, &cands, &sp, false)?; + + if round >= max_rounds { break; } + let passages = numbered_passages(&bm25, &cands, n_feed); + match lume::answer::evaluate(&ollama_url, &model, &question, &passages) { + Ok(v) => { + emit(serde_json::json!({ "type": "evaluate", "round": round, "sufficient": v.sufficient, "note": v.note })); + if v.sufficient || v.queries.is_empty() { break; } + let mut added = false; + for q in v.queries { + if !queries.iter().any(|e| e.eq_ignore_ascii_case(&q)) { queries.push(q); added = true; } + } + if !added { break; } + round += 1; + emit(serde_json::json!({ "type": "plan", "round": round, "queries": queries, "note": "refined" })); + } + Err(e) => { eprintln!("[answer] evaluate failed ({e}); answering with what we have"); break; } + } + } + + // --- Answer over the final field --- + let nq = queries.len(); + // Feed top-N candidates by score; track marker(1-based) -> node id (= nq + cands index). + let mut ranked: Vec = (0..cands.len()).collect(); + ranked.sort_by(|&a, &b| cands[b].score.partial_cmp(&cands[a].score).unwrap_or(std::cmp::Ordering::Equal)); + let fed: Vec = ranked.into_iter().take(n_feed).collect(); + let mut numbered = String::new(); + for (k, &ci) in fed.iter().enumerate() { + if let Some(sec) = bm25.sections.get(cands[ci].section_id) { + let snip: String = sec.body.split_whitespace().take(180).collect::>().join(" "); + numbered.push_str(&format!("[{}] {}: {}\n", k + 1, sec.title.trim(), snip)); + } + } + let used_ids: Vec = fed.iter().map(|&ci| nq + ci).collect(); + + let answer_text = lume::answer::synthesize(&ollama_url, &model, &question, &numbered) + .unwrap_or_else(|e| format!("(answer generation failed: {})", e)); + let cite_markers = lume::answer::parse_citations(&answer_text, fed.len()); + let cited_ids: Vec = cite_markers.iter().map(|&m| nq + fed[m - 1]).collect(); + + emit(serde_json::json!({ + "type": "answer", "text": answer_text, "model": model, + "used": used_ids, "cites": cited_ids, + })); + emit(serde_json::json!({ "type": "done" })); + Ok(()) +} + +/// Numbered passage block for the evaluator/answerer prompts (top-N by score). +fn numbered_passages(bm25: &Bm25Index, cands: &[lume::stream::Candidate], n: usize) -> String { + let mut ranked: Vec = (0..cands.len()).collect(); + ranked.sort_by(|&a, &b| cands[b].score.partial_cmp(&cands[a].score).unwrap_or(std::cmp::Ordering::Equal)); + let mut s = String::new(); + for (k, &ci) in ranked.iter().take(n).enumerate() { + if let Some(sec) = bm25.sections.get(cands[ci].section_id) { + let snip: String = sec.body.split_whitespace().take(180).collect::>().join(" "); + s.push_str(&format!("[{}] {}: {}\n", k + 1, sec.title.trim(), snip)); + } + } + s +} + +fn print_answer_help() { + println!(r#"lume-answer +Agentic question answering over the index: plan search queries, retrieve and +animate the field, evaluate/refine, then synthesize a cited answer with a local +Ollama model. Streams NDJSON (question, plan, evaluate, relaxation frames, answer) +for the 3D visualizer. + +USAGE: + lume answer [OPTIONS] + +OPTIONS: + --db Persisted index metadata [default: .lume-index] + -k, --candidates Top-N candidates per query [default: 18] + --steps Relaxation steps per round [default: 140] + -g, --graph SKG graph boost weight [default: 0.4] + --rounds Max plan/refine rounds [default: 3] + --model Ollama model [default: gpt-4o-mini:latest] + --ollama-url Ollama endpoint [default: $OLLAMA_URL or http://localhost:11434] + --shivvr-url Shivvr endpoint URL [default: http://localhost:8085] + +ARGS: + The question to answer (remaining args joined) +"#); +} + +fn print_stream_help() { + println!(r#"lume-stream +Stream the live phase-binding + Weber search relaxation as NDJSON frames (one per +step) on stdout, for the 3D vector visualizer. Requires a reachable shivvr +endpoint (used read-only to embed the query and candidates). + +USAGE: + lume stream [OPTIONS] [--add ...] + +OPTIONS: + --db Path to the persisted index metadata [default: .lume-index] + -k, --candidates Top-N retrieved candidates per query to animate [default: 24] + --steps Relaxation steps (frames) to emit [default: 160] + -g, --graph SKG graph boost weight for candidate selection [default: 0.4] + --add Additional query (additive search); results union into one field + --shivvr-url Shivvr endpoint URL [default: http://localhost:8085] + +ARGS: + Search query string (more via --add); candidates retrieved by + more than one query are flagged as overlaps (members) + +Each frame is a JSON object: {{type:"frame", step, r_global, nodes:[{{id, pos[3], +vel[3], acc[3], phase, cos_q, approach_vel, approach_acc, cluster, is_query}}]}}. +A leading {{type:"meta"}} frame carries node labels; a trailing {{type:"done"}}. +"#); +} + /// Loads the SKG graph and walks it for `query`, returning per-section boost /// scores. Returns an empty map (no boost) when `beta <= 0`, the graph file is /// missing, or no query entities resolve. Emits a stderr "SKG walk" trace. diff --git a/src/semantic_mesh.rs b/src/semantic_mesh.rs index 0264947..8033b0e 100644 --- a/src/semantic_mesh.rs +++ b/src/semantic_mesh.rs @@ -547,10 +547,14 @@ impl EntityEdge { /// `var = E·(1 − a/n)·(1 − b/n)` so the score does not depend on edge direction, /// then take the z-score `z = (k − E) / √var` and squash it with `tanh(z / Z0)`. /// -/// `Z0 = 3.0` anchors the curve to standard-deviation units: a 3σ association -/// (~99.7% significant) maps to ≈0.76, 6σ to ≈0.96. This is the foreground-vs- -/// background relatedness primitive from Trey Grainger's Semantic Knowledge -/// Graph, reduced to the pairwise-edge case. +/// On a large corpus raw z-scores explode (strong edges reach z = 50–100+), so +/// squashing `z` directly would saturate every real association at ±1 and throw +/// away all gradation among them. Instead we **log-compress** the z-score first +/// — `sign(z)·ln(1 + |z|)` — which preserves dynamic range (a z of 10 and a z of +/// 100 stay distinguishable) and only then bound it with `tanh(·/L)`, `L = 3.0`. +/// The result still passes through `0` at independence and goes negative for +/// avoidance. This is the foreground-vs-background relatedness primitive from +/// Trey Grainger's Semantic Knowledge Graph, reduced to the pairwise-edge case. pub fn cooccurrence_relatedness(n: usize, a: usize, b: usize, k: usize) -> f64 { if n == 0 || a == 0 || b == 0 { return 0.0; @@ -563,8 +567,9 @@ pub fn cooccurrence_relatedness(n: usize, a: usize, b: usize, k: usize) -> f64 { return 0.0; } let z = (k - expected) / variance.sqrt(); - const Z0: f64 = 3.0; - (z / Z0).tanh() + const L: f64 = 3.0; + let compressed = z.signum() * (1.0 + z.abs()).ln(); + (compressed / L).tanh() } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] diff --git a/src/stream.rs b/src/stream.rs new file mode 100644 index 0000000..559f86a --- /dev/null +++ b/src/stream.rs @@ -0,0 +1,498 @@ +//! Live search-dynamics stream (`lume stream`). +//! +//! Runs a phase-binding + Weber relaxation over one or more queries and the +//! union of their top-K retrieved candidates, emitting one NDJSON frame per +//! simulation step to stdout. Each frame carries, per node: a 3D PCA projection +//! of its (warped) 768-D vector, its 3D velocity and acceleration, its Kuramoto +//! phase, and — the thing static retrieval throws away — its approach +//! acceleration toward its anchor query (`d̈` of the cosine distance). +//! +//! Multiple queries (additive search) share one PCA frame and one relaxation, so +//! candidates retrieved by more than one query (`members.len() > 1`) are genuine +//! overlaps in the same space. A browser bridge relays frames to a React/three.js +//! view. Read-only against shivvr (embeddings only); the dynamics mirror +//! `gte_weber_teacher.run_teacher` from the psyche research line. + +use std::collections::HashMap; + +use crate::bm25::Bm25Index; +use crate::hybrid::{embed_text, load_nuts_token}; +use crate::semantic_mesh::SimpleRng; + +const DT: f64 = 0.05; +const EPS: f64 = 1e-12; + +/// Tuning knobs for the relaxation, mirroring the Weber teacher defaults. +pub struct StreamParams { + pub steps: usize, + pub candidates: usize, + pub beta_warp: f64, + pub k0: f64, + pub noise: f64, + pub c_weber: f64, + pub sigma_v: f64, +} + +impl Default for StreamParams { + fn default() -> Self { + Self { steps: 160, candidates: 24, beta_warp: 0.02, k0: 1.6, noise: 0.04, c_weber: 1.5, sigma_v: 1.2 } + } +} + +/// A retrieved candidate and which query indices surfaced it (its overlap set). +pub struct Candidate { + pub section_id: usize, + pub score: f64, + pub members: Vec, +} + +fn dot(a: &[f64], b: &[f64]) -> f64 { + a.iter().zip(b).map(|(x, y)| x * y).sum() +} +fn norm(v: &[f64]) -> f64 { + dot(v, v).max(EPS).sqrt() +} +fn normalize(v: &[f64]) -> Vec { + let n = norm(v); + v.iter().map(|x| x / n).collect() +} +fn cosine(a: &[f64], b: &[f64]) -> f64 { + dot(a, b) / (norm(a) * norm(b)) +} +fn distance(a: &[f64], b: &[f64]) -> f64 { + a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum::().sqrt() +} +fn wrap_phase(a: f64) -> f64 { + a - 2.0 * std::f64::consts::PI * ((a + std::f64::consts::PI) / (2.0 * std::f64::consts::PI)).floor() +} + +/// A concept node: its 768-D vector plus oscillator state. +struct Node { + label: String, + score: f64, + text: String, + jitter: [f64; 3], + v: Vec, + theta: f64, + omega: f64, + is_query: bool, + query_index: usize, // which query (for query nodes) + members: Vec, // query indices that retrieved this candidate + anchor: usize, // node index of the candidate's nearest query + pos: [f64; 3], + vel: [f64; 3], + acc: [f64; 3], + dq: f64, + approach_vel: f64, + approach_acc: f64, +} + +/// Top-3 PCA basis fitted once over the initial vectors (centered). +struct Pca { + mean: Vec, + axes: [Vec; 3], +} + +impl Pca { + fn fit(vectors: &[Vec], rng: &mut SimpleRng) -> Pca { + let d = vectors[0].len(); + let n = vectors.len() as f64; + let mut mean = vec![0.0; d]; + for v in vectors { + for (m, x) in mean.iter_mut().zip(v) { + *m += x / n; + } + } + let centered: Vec> = vectors + .iter() + .map(|v| v.iter().zip(&mean).map(|(x, m)| x - m).collect()) + .collect(); + + let mut axes: Vec> = Vec::new(); + for _ in 0..3 { + let mut vec: Vec = (0..d).map(|_| rng.next_u64() as f64 / u64::MAX as f64 - 0.5).collect(); + for _ in 0..40 { + let mut next = vec![0.0; d]; + for row in ¢ered { + let proj = dot(row, &vec); + for (nx, rx) in next.iter_mut().zip(row) { + *nx += proj * rx; + } + } + for prev in &axes { + let p = dot(&next, prev); + for (nx, px) in next.iter_mut().zip(prev) { + *nx -= p * px; + } + } + let nn = norm(&next); + if nn < EPS { + break; + } + vec = next.iter().map(|x| x / nn).collect(); + } + axes.push(vec); + } + Pca { mean, axes: [axes[0].clone(), axes[1].clone(), axes[2].clone()] } + } + + fn project(&self, v: &[f64]) -> [f64; 3] { + let c: Vec = v.iter().zip(&self.mean).map(|(x, m)| x - m).collect(); + [dot(&c, &self.axes[0]), dot(&c, &self.axes[1]), dot(&c, &self.axes[2])] + } +} + +fn emit(line: &serde_json::Value) { + println!("{}", line); +} + +/// Runs the relaxation for `queries` over the union `cands` and streams frames. +pub fn run( + bm25: &Bm25Index, + queries: &[String], + cands: &[Candidate], + params: &StreamParams, + emit_done: bool, +) -> Result<(), String> { + let token = load_nuts_token().ok_or_else(|| { + "shivvr token unavailable (need a local shivvr endpoint or NUTS_SERVICES_TOKEN)".to_string() + })?; + + eprintln!("[stream] embedding {} queries + {} candidates via shivvr…", queries.len(), cands.len()); + let mut rng = SimpleRng::new(); + let mut nodes: Vec = Vec::new(); + + // Query anchor nodes (fixed). One per query. + for (qi, q) in queries.iter().enumerate() { + let qv = normalize(&embed_text(q, &token)?); + nodes.push(Node { + label: format!("◆ {}", truncate(q, 42)), + score: 0.0, + text: q.clone(), + jitter: [0.0, 0.0, 0.0], + v: qv, + theta: 0.0, + omega: 0.0, + is_query: true, + query_index: qi, + members: vec![qi], + anchor: qi, + pos: [0.0; 3], vel: [0.0; 3], acc: [0.0; 3], + dq: 0.0, approach_vel: 0.0, approach_acc: 0.0, + }); + } + let nq = nodes.len(); + if nq == 0 { + return Err("no queries given".to_string()); + } + + // Candidate nodes (warped). Anchor = nearest query. + for c in cands { + let sec = match bm25.sections.get(c.section_id) { + Some(s) => s, + None => continue, + }; + let snippet: String = sec.body.split_whitespace().take(80).collect::>().join(" "); + let v = match embed_text(&snippet, &token) { + Ok(v) => normalize(&v), + Err(_) => continue, + }; + // Nearest query among this candidate's members (fall back to all queries). + let pool: Vec = if c.members.is_empty() { (0..nq).collect() } else { c.members.clone() }; + let anchor = pool + .iter() + .copied() + .max_by(|&a, &b| cosine(&v, &nodes[a].v).partial_cmp(&cosine(&v, &nodes[b].v)).unwrap()) + .unwrap_or(0); + let text: String = sec.body.split_whitespace().take(70).collect::>().join(" "); + nodes.push(Node { + label: section_label(sec, c.section_id), + score: c.score, + text: truncate(&text, 320), + jitter: jitter_for(c.section_id), + v, + theta: (rng.next_u64() as f64 / u64::MAX as f64) * 2.0 * std::f64::consts::PI - std::f64::consts::PI, + omega: (rng.next_u64() as f64 / u64::MAX as f64 - 0.5) * 0.1, + is_query: false, + query_index: anchor, + members: c.members.clone(), + anchor, + pos: [0.0; 3], vel: [0.0; 3], acc: [0.0; 3], + dq: 0.0, approach_vel: 0.0, approach_acc: 0.0, + }); + } + if nodes.len() <= nq { + return Err("not enough embeddable candidates to run the dynamics".to_string()); + } + let m = nodes.len(); + + // Anchor vectors are fixed (query nodes never warp), so snapshot them. + let anchor_v: Vec> = nodes.iter().map(|n| nodes[n.anchor].v.clone()).collect(); + + // PCA frame, centered on the query centroid so the field sits among the + // anchors; scaled so the median anchor-distance is ~1 unit of a few. + let pca = Pca::fit(&nodes.iter().map(|n| n.v.clone()).collect::>(), &mut rng); + let mut center = [0.0f64; 3]; + for n in nodes.iter().take(nq) { + let p = pca.project(&n.v); + center[0] += p[0] / nq as f64; + center[1] += p[1] / nq as f64; + center[2] += p[2] / nq as f64; + } + let mut scale = 1.0; + { + let mut ds: Vec = nodes.iter().skip(nq).map(|n| { + let p = pca.project(&n.v); + ((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2)).sqrt() + }).filter(|d| *d > EPS).collect(); + ds.sort_by(|a, b| a.partial_cmp(b).unwrap()); + if let Some(med) = ds.get(ds.len() / 2) { + if *med > EPS { scale = 5.5 / med; } + } + } + let project = |pca: &Pca, v: &[f64]| { + let p = pca.project(v); + [(p[0] - center[0]) * scale, (p[1] - center[1]) * scale, (p[2] - center[2]) * scale] + }; + + // Query anchors are laid out by PCA so multiple queries separate in space. + // Each candidate then orbits its anchor query on a fixed ray, at a radius + // that shrinks as it becomes more similar to that query — so results cluster + // AROUND their query (nearest = most relevant), and binding visibly pulls + // them inward, which makes the acceleration arrows read as true approach. + let qpos: Vec<[f64; 3]> = (0..nq).map(|i| project(&pca, &nodes[i].v)).collect(); + let radius = |cos: f64| 0.7 + 3.0 * (1.0 - cos).max(0.0); + let mut dirs: Vec<[f64; 3]> = vec![[0.0, 1.0, 0.0]; m]; + for i in nq..m { + let p = project(&pca, &nodes[i].v); + let a = qpos[nodes[i].anchor]; + let mut d = [p[0] - a[0], p[1] - a[1], p[2] - a[2]]; + let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); + if len < 1e-6 { + d = nodes[i].jitter; // degenerate (candidate ≈ query): deterministic ray + } + let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6); + dirs[i] = [d[0] / len, d[1] / len, d[2] / len]; + } + + for i in 0..m { + nodes[i].dq = 1.0 - cosine(&nodes[i].v, &anchor_v[i]); + nodes[i].pos = if nodes[i].is_query { + qpos[nodes[i].query_index] + } else { + let r = radius(1.0 - nodes[i].dq); + let a = qpos[nodes[i].anchor]; + [a[0] + dirs[i][0] * r, a[1] + dirs[i][1] * r, a[2] + dirs[i][2] * r] + }; + } + + emit(&serde_json::json!({ + "type": "meta", + "queries": queries, + "steps": params.steps, + "nodes": nodes.iter().enumerate().map(|(i, n)| serde_json::json!({ + "id": i, "label": n.label, "score": n.score, "text": n.text, + "is_query": n.is_query, "query_index": n.query_index, "members": n.members, + })).collect::>(), + })); + + let mut prev_d = vec![vec![0.0f64; m]; m]; + let mut prev2_d = vec![vec![0.0f64; m]; m]; + for i in 0..m { + for j in 0..m { + if i != j { + let d = distance(&nodes[i].v, &nodes[j].v); + prev_d[i][j] = d; + prev2_d[i][j] = d; + } + } + } + let mut weights: HashMap<(usize, usize), f64> = HashMap::new(); + for i in 0..m { + for j in 0..m { + if i != j { + weights.insert((i, j), 0.2 + cosine(&nodes[i].v, &nodes[j].v).max(0.0)); + } + } + } + + for step in 0..params.steps { + let mut couplings = vec![vec![0.0f64; m]; m]; + let snapshot: Vec> = nodes.iter().map(|n| n.v.clone()).collect(); + + for i in 0..m { + if nodes[i].is_query { + continue; + } + let mut vi = nodes[i].v.clone(); + for j in 0..m { + if i == j { + continue; + } + let d = distance(&vi, &snapshot[j]); + let rdot = (d - prev_d[i][j]) / DT; + let rdot_prev = (prev_d[i][j] - prev2_d[i][j]) / DT; + let rddot = (rdot - rdot_prev) / DT; + prev2_d[i][j] = prev_d[i][j]; + prev_d[i][j] = d; + + let term1 = (rdot * rdot) / (2.0 * params.c_weber * params.c_weber); + let term2 = (d * rddot) / (params.c_weber * params.c_weber); + let b_ij = (1.0 - term1 + term2).clamp(-2.0, 2.0); + + let align = (nodes[j].theta - nodes[i].theta).cos(); + let w = weights.get_mut(&(i, j)).unwrap(); + *w += 0.1 * (align - *w) * DT; + *w = w.clamp(0.05, 5.0); + let wij = *w; + + let delta: Vec = snapshot[j].iter().zip(&vi).map(|(x, y)| x - y).collect(); + if align > 0.5 { + vi = normalize(&vi.iter().zip(&delta).map(|(x, dx)| x + params.beta_warp * DT * dx).collect::>()); + } else if align < -0.5 { + vi = normalize(&vi.iter().zip(&delta).map(|(x, dx)| x - params.beta_warp * DT * dx).collect::>()); + } + + let sem = distance(&vi, &snapshot[j]); + let s_ij = (-(sem * sem) / (params.sigma_v * params.sigma_v)).exp(); + couplings[i][j] = params.k0 * wij * s_ij * b_ij; + } + nodes[i].v = vi; + } + + let thetas: Vec = nodes.iter().map(|n| n.theta).collect(); + let mut next_theta = thetas.clone(); + for i in 0..m { + if nodes[i].is_query { + continue; + } + let mut torque = 0.0; + for j in 0..m { + if i != j { + torque += couplings[i][j] * (thetas[j] - thetas[i]).sin(); + } + } + let xi = (rng.next_u64() as f64 / u64::MAX as f64 - 0.5) * 2.0; + next_theta[i] = thetas[i] + (nodes[i].omega + torque) * DT + params.noise * DT.sqrt() * xi; + } + for (n, t) in nodes.iter_mut().zip(next_theta) { + n.theta = wrap_phase(t); + } + + for i in 0..m { + let dq = 1.0 - cosine(&nodes[i].v, &anchor_v[i]); + let new_pos = if nodes[i].is_query { + qpos[nodes[i].query_index] + } else { + let r = radius(1.0 - dq); + let a = qpos[nodes[i].anchor]; + [a[0] + dirs[i][0] * r, a[1] + dirs[i][1] * r, a[2] + dirs[i][2] * r] + }; + let new_vel = [new_pos[0] - nodes[i].pos[0], new_pos[1] - nodes[i].pos[1], new_pos[2] - nodes[i].pos[2]]; + nodes[i].acc = [new_vel[0] - nodes[i].vel[0], new_vel[1] - nodes[i].vel[1], new_vel[2] - nodes[i].vel[2]]; + nodes[i].vel = new_vel; + nodes[i].pos = new_pos; + + let nav = (dq - nodes[i].dq) / DT; + nodes[i].approach_acc = (nav - nodes[i].approach_vel) / DT; + nodes[i].approach_vel = nav; + nodes[i].dq = dq; + } + + let (mut cs, mut sn, mut cnt) = (0.0, 0.0, 0.0); + for n in nodes.iter().filter(|n| !n.is_query) { + cs += n.theta.cos(); + sn += n.theta.sin(); + cnt += 1.0; + } + let r_global = if cnt > 0.0 { ((cs / cnt).powi(2) + (sn / cnt).powi(2)).sqrt() } else { 0.0 }; + let clusters = cluster_ids(&nodes); + + let frame = serde_json::json!({ + "type": "frame", + "step": step, + "r_global": r_global, + "nodes": nodes.iter().enumerate().map(|(i, n)| serde_json::json!({ + "id": i, + "pos": [n.pos[0] + n.jitter[0], n.pos[1] + n.jitter[1], n.pos[2] + n.jitter[2]], + "vel": n.vel, + "acc": n.acc, + "phase": n.theta, + "cos_q": cosine(&n.v, &anchor_v[i]), + "approach_vel": n.approach_vel, + "approach_acc": n.approach_acc, + "cluster": clusters[i], + "is_query": n.is_query, + "query_index": n.query_index, + "members": n.members, + })).collect::>(), + }); + emit(&frame); + } + + if emit_done { + emit(&serde_json::json!({ "type": "done" })); + } + Ok(()) +} + +/// Union-find clustering on the current warped cosine (> 0.62). +fn cluster_ids(nodes: &[Node]) -> Vec { + let m = nodes.len(); + let mut parent: Vec = (0..m).collect(); + fn find(p: &mut Vec, x: usize) -> usize { + let mut r = x; + while p[r] != r { + r = p[r]; + } + p[x] = r; + r + } + for i in 0..m { + for j in (i + 1)..m { + if cosine(&nodes[i].v, &nodes[j].v) > 0.62 { + let (ri, rj) = (find(&mut parent, i), find(&mut parent, j)); + if ri != rj { + parent[ri] = rj; + } + } + } + } + let mut remap: HashMap = HashMap::new(); + (0..m).map(|i| { + let r = find(&mut parent, i); + let next = remap.len(); + *remap.entry(r).or_insert(next) + }).collect() +} + +/// Deterministic small 3D offset per section id so near-identical vectors don't +/// render exactly on top of each other. +fn jitter_for(sid: usize) -> [f64; 3] { + let mut h = (sid as u64).wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(0x123456789); + let mut comp = || { + h ^= h >> 33; + h = h.wrapping_mul(0xFF51AFD7ED558CCD); + h ^= h >> 33; + ((h as f64 / u64::MAX as f64) - 0.5) * 0.5 + }; + [comp(), comp(), comp()] +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + format!("{}…", s.chars().take(max - 1).collect::()) + } +} + +fn section_label(sec: &crate::bm25::Section, sid: usize) -> String { + let title = sec.title.trim(); + if !title.is_empty() { + truncate(title, 48) + } else { + let head: String = sec.body.split_whitespace().take(6).collect::>().join(" "); + format!("§{} {}", sid, truncate(&head, 40)) + } +} diff --git a/viz/README.md b/viz/README.md new file mode 100644 index 0000000..c6cc329 --- /dev/null +++ b/viz/README.md @@ -0,0 +1,57 @@ +# Lume · Vector Field (live 3D search-dynamics visualizer) + +Watch Lume's **phase-binding + Weber relaxation** play out in 3D: the query and +its top-K retrieved candidates as a force field, where you can *see the +acceleration* of each candidate toward (or away from) the query as the dynamics +settle — the second-order signal that static cosine layouts (UMAP/t-SNE) throw +away. + +``` + lume stream ──NDJSON──▶ server.js (bridge) ──WebSocket──▶ React + three.js + (Rust: phase/Weber spawns lume, points + acceleration + relaxation, PCA→3D) relays frames arrows, clusters, R-meter +``` + +## What you see + +- **Spheres** = the query (white, large) and candidate passages. Candidate size + + glow scale with cosine-to-query. +- **Colors** = emergent phase clusters (assemblies that synchronize during the run). +- **Acceleration arrows** = each candidate's 3D acceleration this step. **Green = + accelerating toward the query, red = away.** Crank the *acc arrow scale* slider. +- **R meter** = global Kuramoto phase coherence; it climbs as assemblies bind. +- **Scrubber** = replay/scrub the relaxation; play/pause and speed on the bottom bar. + +## Prerequisites + +- The `lume` release binary built: from the repo root, `cargo build --release`. +- A reachable **shivvr** endpoint (used read-only to embed the query + candidates). + Defaults to `http://localhost:8085`. +- An index with content, e.g. the eval index used in the demo: + `lume index --db .lume-eval-index --tag-dict eval-tmp/dict/character.csv eval-tmp/corpus` + +## Run + +```bash +cd viz +npm install + +# Production: build the app, then serve it + the bridge on one port +npm run build +npm run bridge # http://localhost:8086 + +# OR dev mode (hot reload): two terminals +npm run bridge # terminal A — WebSocket + lume spawner on :8086 +npm run dev # terminal B — Vite UI on :5173, talks to the bridge +``` + +Open the page, type a query, pick the `--db`, hit **Search**. The bridge runs +`lume stream` and streams frames in. + +## Config + +- `PORT` (bridge HTTP/WS port, default `8086`). +- `LUME_BIN` (path to the lume binary; defaults to `../target/release/lume[.exe]`). + +The bridge runs `lume` with the repo root as CWD, so `--db` paths are relative to +the repo root (e.g. `.lume-eval-index`). diff --git a/viz/index.html b/viz/index.html new file mode 100644 index 0000000..d9729ba --- /dev/null +++ b/viz/index.html @@ -0,0 +1,12 @@ + + + + + + Lume · Vector Field + + +
+ + + diff --git a/viz/package-lock.json b/viz/package-lock.json new file mode 100644 index 0000000..5fce147 --- /dev/null +++ b/viz/package-lock.json @@ -0,0 +1,2572 @@ +{ + "name": "lume-viz", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lume-viz", + "version": "0.1.0", + "dependencies": { + "@react-three/drei": "^9.114.0", + "@react-three/fiber": "^8.17.10", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "three": "^0.169.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "vite": "^5.4.10", + "ws": "^8.18.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz", + "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", + "license": "MIT" + }, + "node_modules/@react-three/drei": { + "version": "9.122.0", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz", + "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@react-spring/three": "~9.7.5", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^2.9.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "react-composer": "^5.0.3", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.7.8", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.0", + "tunnel-rat": "^0.1.2", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^8", + "react": "^18", + "react-dom": "^18", + "three": ">=0.137" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", + "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz", + "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==", + "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.151.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/viz/package.json b/viz/package.json new file mode 100644 index 0000000..310cf76 --- /dev/null +++ b/viz/package.json @@ -0,0 +1,25 @@ +{ + "name": "lume-viz", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Live 3D visualizer for Lume's phase/Weber search relaxation.", + "scripts": { + "dev": "vite", + "build": "vite build", + "bridge": "node server.js", + "start": "vite build && node server.js" + }, + "dependencies": { + "@react-three/drei": "^9.114.0", + "@react-three/fiber": "^8.17.10", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "three": "^0.169.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "vite": "^5.4.10", + "ws": "^8.18.0" + } +} diff --git a/viz/server.js b/viz/server.js new file mode 100644 index 0000000..a9f85be --- /dev/null +++ b/viz/server.js @@ -0,0 +1,119 @@ +// Bridge between the browser and `lume stream`. +// +// Serves the built React app (dist/) over HTTP and exposes a WebSocket. When a +// client sends {type:"search", query, db, candidates, steps}, the bridge spawns +// lume stream --db -k --steps +// reads its NDJSON stdout line by line, and forwards each frame to the client. +// The browser can't read a child process or a raw socket; this is the relay. +// +// Config via env: PORT (default 8086), LUME_BIN (default ../target/release/lume[.exe]). + +import http from "node:http"; +import { spawn } from "node:child_process"; +import { createReadStream, existsSync } from "node:fs"; +import { extname, join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createInterface } from "node:readline"; +import { WebSocketServer } from "ws"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PORT = Number(process.env.PORT || 8086); +const DIST = join(__dirname, "dist"); + +function resolveLumeBin() { + if (process.env.LUME_BIN) return process.env.LUME_BIN; + const win = join(__dirname, "..", "target", "release", "lume.exe"); + const nix = join(__dirname, "..", "target", "release", "lume"); + return existsSync(win) ? win : nix; +} +const LUME_BIN = resolveLumeBin(); + +const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", + ".json": "application/json", ".svg": "image/svg+xml", ".ico": "image/x-icon" }; + +const server = http.createServer((req, res) => { + let path = decodeURIComponent((req.url || "/").split("?")[0]); + if (path === "/") path = "/index.html"; + const file = join(DIST, path); + if (!file.startsWith(DIST) || !existsSync(file)) { + // SPA fallback to index.html when the build exists. + const index = join(DIST, "index.html"); + if (existsSync(index)) { + res.writeHead(200, { "Content-Type": "text/html" }); + createReadStream(index).pipe(res); + return; + } + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Run `npm run build` first, or use `npm run dev` for the dev server."); + return; + } + res.writeHead(200, { "Content-Type": MIME[extname(file)] || "application/octet-stream" }); + createReadStream(file).pipe(res); +}); + +const wss = new WebSocketServer({ server }); + +wss.on("connection", (ws) => { + let child = null; + + const killChild = () => { + if (child) { + try { child.kill(); } catch {} + child = null; + } + }; + + ws.on("message", (raw) => { + let msg; + try { msg = JSON.parse(raw.toString()); } catch { return; } + + const db = msg.db || ".lume-index"; + const k = String(msg.candidates || 24); + const steps = String(msg.steps || 160); + let args; + + if (msg.type === "ask" && msg.question) { + // Agentic answer loop: plan → retrieve → evaluate → refine → cited answer. + args = ["answer", msg.question, "--db", db, "-k", k, "--steps", steps]; + if (msg.model) args.push("--model", msg.model); + } else if (msg.type === "search") { + const queries = (Array.isArray(msg.queries) ? msg.queries : [msg.query]).filter(Boolean); + if (!queries.length) return; + args = ["stream", queries[0], "--db", db, "-k", k, "--steps", steps]; + for (const q of queries.slice(1)) args.push("--add", q); + } else { + return; + } + + killChild(); + + ws.send(JSON.stringify({ type: "status", state: "running", bin: LUME_BIN, args })); + child = spawn(LUME_BIN, args, { cwd: join(__dirname, "..") }); + + const rl = createInterface({ input: child.stdout }); + rl.on("line", (line) => { + const t = line.trim(); + if (t.startsWith("{") && ws.readyState === ws.OPEN) ws.send(t); // forward frame verbatim + }); + + let err = ""; + child.stderr.on("data", (d) => { err += d.toString(); }); + child.on("close", (code) => { + if (ws.readyState === ws.OPEN) { + ws.send(JSON.stringify({ type: "status", state: "closed", code, stderr: err.slice(-2000) })); + } + child = null; + }); + child.on("error", (e) => { + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify({ type: "status", state: "error", message: e.message })); + }); + }); + + ws.on("close", killChild); +}); + +server.listen(PORT, () => { + console.log(`lume-viz bridge on http://localhost:${PORT}`); + console.log(` lume binary: ${LUME_BIN}${existsSync(LUME_BIN) ? "" : " (NOT FOUND — set LUME_BIN)"}`); + console.log(existsSync(DIST) ? ` serving dist/` : ` dist/ not built — run \`npm run build\`, or use \`npm run dev\``); +}); diff --git a/viz/src/App.jsx b/viz/src/App.jsx new file mode 100644 index 0000000..8f1dedd --- /dev/null +++ b/viz/src/App.jsx @@ -0,0 +1,293 @@ +import React, { useEffect, useRef, useState, useCallback } from "react"; +import VectorField from "./VectorField.jsx"; +import ResultsPanel from "./ResultsPanel.jsx"; +import { QHUES } from "./colors.js"; + +const WS_URL = `ws://${location.hostname}:8086`; +const QCHIP = QHUES.map((h) => `hsl(${h}, 85%, 62%)`); + +// Per-node render radius (query fixed; candidates scale with weight), an optional +// cluster "spread" that scales each cluster out from its query for viewing, then +// a position-based collision-separation so no orbs intersect. +function layout(nodes, spread) { + if (!nodes.length) return nodes; + let lo = Infinity, hi = -Infinity; + for (const n of nodes) if (!n.is_query) { lo = Math.min(lo, n.score); hi = Math.max(hi, n.score); } + const span = hi - lo || 1; + for (const n of nodes) n.r = n.is_query ? 0.3 : 0.15 + 0.4 * ((n.score - lo) / span); + + if (spread !== 1) { + const origQ = new Map(), newQ = new Map(); + let cx = 0, cy = 0, cz = 0, nq = 0; + for (const n of nodes) if (n.is_query) { origQ.set(n.query_index, n.pos.slice()); cx += n.pos[0]; cy += n.pos[1]; cz += n.pos[2]; nq++; } + const c = [nq ? cx / nq : 0, nq ? cy / nq : 0, nq ? cz / nq : 0]; + for (const n of nodes) if (n.is_query) { + n.pos = [c[0] + (n.pos[0] - c[0]) * spread, c[1] + (n.pos[1] - c[1]) * spread, c[2] + (n.pos[2] - c[2]) * spread]; + newQ.set(n.query_index, n.pos.slice()); + } + for (const n of nodes) if (!n.is_query) { + const oq = origQ.get(n.query_index) || c, nqp = newQ.get(n.query_index) || c; + n.pos = [nqp[0] + (n.pos[0] - oq[0]) * spread, nqp[1] + (n.pos[1] - oq[1]) * spread, nqp[2] + (n.pos[2] - oq[2]) * spread]; + } + } + + // Collision radius padded ~25% over the sphere so velocity-warped ellipsoids + // don't intersect either; iterate to convergence. + const margin = 0.12; + for (let iter = 0; iter < 26; iter++) { + let moved = false; + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i], b = nodes[j]; + const dx = b.pos[0] - a.pos[0], dy = b.pos[1] - a.pos[1], dz = b.pos[2] - a.pos[2]; + const d = Math.hypot(dx, dy, dz) || 1e-4; + const min = a.r * 1.25 + b.r * 1.25 + margin; + if (d < min) { + moved = true; + const push = min - d, ux = dx / d, uy = dy / d, uz = dz / d; + const aw = a.is_query ? 0 : b.is_query ? 1 : 0.5; + const bw = b.is_query ? 0 : a.is_query ? 1 : 0.5; + a.pos = [a.pos[0] - ux * push * aw, a.pos[1] - uy * push * aw, a.pos[2] - uz * push * aw]; + b.pos = [b.pos[0] + ux * push * bw, b.pos[1] + uy * push * bw, b.pos[2] + uz * push * bw]; + } + } + } + if (!moved) break; + } + return nodes; +} + +function interpolate(meta, a, b, t) { + if (!a) return []; + const lerp3 = (p, q) => [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t]; + return a.nodes.map((na, i) => { + const nb = (b && b.nodes[i]) || na; + const mn = meta?.nodes?.[i] || {}; + return { + id: na.id, label: mn.label, score: mn.score ?? 0, text: mn.text || "", + is_query: na.is_query, query_index: na.query_index ?? 0, members: mn.members || na.members || [], + pos: lerp3(na.pos, nb.pos), acc: lerp3(na.acc, nb.acc), + cos_q: na.cos_q + (nb.cos_q - na.cos_q) * t, + approach_acc: na.approach_acc + (nb.approach_acc - na.approach_acc) * t, + cluster: nb.cluster, + }; + }); +} + +export default function App() { + const [input, setInput] = useState("Dantès in prison at the Château d'If"); + const [queries, setQueries] = useState([]); + const [db, setDb] = useState(".lume-eval-index"); + const [candidates, setCandidates] = useState(20); + const [steps, setSteps] = useState(160); + + const [meta, setMeta] = useState(null); + const framesRef = useRef([]); + const [frameCount, setFrameCount] = useState(0); + const [idx, setIdx] = useState(0); + const [playing, setPlaying] = useState(true); + const [speed, setSpeed] = useState(1.2); + const [accScale, setAccScale] = useState(120); + const [warp, setWarp] = useState(14); + const [spread, setSpread] = useState(1); + const [sortKey, setSortKey] = useState("relevance"); + const [hoveredId, setHoveredId] = useState(null); + const [conn, setConn] = useState("connecting"); + const [status, setStatus] = useState(""); + const [answer, setAnswer] = useState(null); // { text, used:Set, cites:[ids], model } + const [agentLog, setAgentLog] = useState([]); // plan/evaluate round lines + const [question, setQuestion] = useState(""); + const [warpKey, setWarpKey] = useState(0); // bumps per new field → triggers warp-in + + const wsRef = useRef(null); + + useEffect(() => { + const ws = new WebSocket(WS_URL); + wsRef.current = ws; + ws.onopen = () => setConn("connected"); + ws.onclose = () => setConn("disconnected"); + ws.onerror = () => setConn("error"); + ws.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.type === "question") { + setQuestion(m.text); setAnswer(null); setAgentLog([]); + } else if (m.type === "plan") { + setQueries(m.queries); + setAgentLog((l) => [...l, { kind: "plan", round: m.round, queries: m.queries, note: m.note }]); + } else if (m.type === "evaluate") { + setAgentLog((l) => [...l, { kind: "eval", round: m.round, sufficient: m.sufficient, note: m.note }]); + } else if (m.type === "answer") { + setAnswer({ text: m.text, used: new Set(m.used || []), cites: m.cites || [], model: m.model }); + setStatus("answered"); + } else if (m.type === "meta") { + setMeta(m); + setWarpKey((k) => k + 1); // new field jumps in via hyperspace + framesRef.current = []; setFrameCount(0); setIdx(0); setPlaying(true); + const nq = m.nodes.filter((n) => n.is_query).length; + setStatus(`running · ${nq} quer${nq === 1 ? "y" : "ies"} · ${m.nodes.length - nq} candidates`); + } else if (m.type === "frame") { + framesRef.current.push(m); setFrameCount(framesRef.current.length); + } else if (m.type === "status") { + if (m.state === "closed" && m.code !== 0) setStatus(`lume exited ${m.code}: ${(m.stderr || "").split("\n").pop()}`); + else if (m.state === "running") setStatus("running…"); + else if (m.state === "error") setStatus(`bridge error: ${m.message}`); + else if (m.state === "closed") setStatus(`done · ${framesRef.current.length} frames`); + } else if (m.type === "done") { + setStatus(`done · ${framesRef.current.length} frames`); + } + }; + return () => ws.close(); + }, []); + + useEffect(() => { + let raf, last = performance.now(); + const tick = (now) => { + const dt = (now - last) / 1000; last = now; + if (playing && framesRef.current.length > 1) setIdx((p) => Math.min(framesRef.current.length - 1, p + dt * speed * 24)); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [playing, speed]); + + const runSearch = useCallback((qs) => { + const ws = wsRef.current; + const list = qs.filter(Boolean); + setQueries(list); setAnswer(null); setAgentLog([]); setQuestion(""); + if (!list.length) { setMeta(null); framesRef.current = []; setFrameCount(0); setIdx(0); return; } + if (!ws || ws.readyState !== ws.OPEN) { setStatus("not connected to bridge"); return; } + framesRef.current = []; setFrameCount(0); setIdx(0); setMeta(null); + ws.send(JSON.stringify({ type: "search", queries: list, db, candidates: Number(candidates), steps: Number(steps) })); + }, [db, candidates, steps]); + + const search = () => runSearch([input]); + const addSearch = () => runSearch([...queries, input]); + const removeQuery = (i) => runSearch(queries.filter((_, k) => k !== i)); // delete + rerun + + const ask = useCallback(() => { + const ws = wsRef.current; + if (!input.trim()) return; + if (!ws || ws.readyState !== ws.OPEN) { setStatus("not connected to bridge"); return; } + framesRef.current = []; setFrameCount(0); setIdx(0); setMeta(null); + setAnswer(null); setAgentLog([]); setQueries([]); setQuestion(input); + ws.send(JSON.stringify({ type: "ask", question: input, db, candidates: Number(candidates), steps: Number(steps) })); + }, [input, db, candidates, steps]); + + const frames = framesRef.current; + const lo = Math.floor(idx), hi = Math.min(frames.length - 1, lo + 1), frac = idx - lo; + const nodes = frames.length ? layout(interpolate(meta, frames[lo], frames[hi], frac), spread) : []; + const rGlobal = frames.length ? frames[Math.round(idx)].r_global : 0; + const multi = queries.length > 1; + const usedIds = answer ? answer.used : undefined; + const citedIds = answer ? new Set(answer.cites) : undefined; + const labelOf = (id) => (meta?.nodes?.[id]?.label) || `#${id}`; + + return ( +
+
+ +
+ +
+

LUME · Vector Field

+

phase-binding + Weber relaxation · additive search

+ +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.ctrlKey ? addSearch() : search())} placeholder="search query" /> +
+
+ setDb(e.target.value)} placeholder="--db" /> + setCandidates(e.target.value)} title="candidates/query" /> + setSteps(e.target.value)} title="steps" /> +
+
+ + + +
+ + {queries.length > 0 && ( +
+ {queries.map((q, i) => ( + + + {q.length > 22 ? q.slice(0, 21) + "…" : q} + + + ))} +
+ )} + +
phase coherence (R){rGlobal.toFixed(3)}
+
+
frame{frames.length ? `${Math.round(idx)} / ${frameCount - 1}` : "—"}
+ +
cluster spread{spread.toFixed(1)}×
+ setSpread(Number(e.target.value))} /> +
acc arrow scale{accScale}
+ setAccScale(Number(e.target.value))} /> +
orb warp{warp}
+ setWarp(Number(e.target.value))} /> + +
+
labels = weight · hover for the passage · each search has its own colour
+
haloed = overlap (2+ searches)
+
Enter = search · Ctrl-Enter = add · × on a chip removes + reruns · Ctrl-drag = pan
+
+
+ + {nodes.length > 0 && ( + + )} + + {(question || agentLog.length > 0) && ( +
+
✦ {question}
+ {agentLog.length > 0 && ( +
+ {agentLog.map((e, i) => e.kind === "plan" + ?
plan {e.round}: {e.queries.join(" · ")}{e.note ? ` (${e.note})` : ""}
+ :
→ {e.sufficient ? "sufficient" : "insufficient — refining"}{e.note ? `: ${e.note}` : ""}
)} +
+ )} + {answer ? ( + <> +
{answer.text}
+ {answer.cites.length > 0 && ( +
+ sources: + {answer.cites.map((id) => ( + + ))} +
+ )} +
— {answer.model}
+ + ) :
running agent…
} +
+ )} + +
+ + { setPlaying(false); setIdx(Number(e.target.value)); }} /> + × + setSpeed(Number(e.target.value))} /> +
+ +
+ bridge: {conn}{status ? ` · ${status}` : ""} +
+
+ ); +} diff --git a/viz/src/ResultsPanel.jsx b/viz/src/ResultsPanel.jsx new file mode 100644 index 0000000..2107a77 --- /dev/null +++ b/viz/src/ResultsPanel.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import { colorOfNode, nodeColors, OVERLAP } from "./colors.js"; + +const fmt = (n) => (Math.abs(n) >= 100 ? n.toFixed(0) : n.toFixed(2)); + +const SORTS = { + relevance: { label: "relevance", cmp: (a, b) => b.cos_q - a.cos_q }, + weight: { label: "weight", cmp: (a, b) => b.score - a.score }, + binding: { label: "binding", cmp: (a, b) => a.approach_acc - b.approach_acc }, // most negative = toward + overlap: { label: "overlap", cmp: (a, b) => (b.members?.length || 1) - (a.members?.length || 1) || b.cos_q - a.cos_q }, +}; + +export default function ResultsPanel({ nodes, multi, sortKey, setSortKey, hoveredId, onHover }) { + const colors = React.useMemo(() => nodeColors(nodes, multi), [nodes, multi]); + + const cands = nodes.filter((n) => !n.is_query).slice().sort(SORTS[sortKey].cmp); + + return ( +
+
+ results · {cands.length} +
+ {Object.entries(SORTS).map(([k, s]) => ( + + ))} +
+
+
+ {cands.map((n) => { + const c = colorOfNode(n, colors, multi); + const overlap = n.members && n.members.length > 1; + const toward = n.approach_acc < 0; + return ( +
onHover(n.id)} + onMouseLeave={() => onHover(null)} + > + + {fmt(n.score)} + + {overlap && } + {n.label} + + + + {toward ? "▼" : "▲"}{Math.abs(n.approach_acc).toFixed(2)} + +
+ ); + })} +
+
+ ); +} diff --git a/viz/src/VectorField.jsx b/viz/src/VectorField.jsx new file mode 100644 index 0000000..6858f19 --- /dev/null +++ b/viz/src/VectorField.jsx @@ -0,0 +1,246 @@ +import React, { useMemo, useState, useEffect, useRef } from "react"; +import { Canvas, useFrame } from "@react-three/fiber"; +import { OrbitControls, Line, Html, Billboard, Text } from "@react-three/drei"; +import * as THREE from "three"; +import { qHsl, OVERLAP, nodeColors, colorOfNode } from "./colors.js"; + +const fmtWeight = (n) => (Math.abs(n) >= 100 ? n.toFixed(0) : n.toFixed(2)); + +const UP = new THREE.Vector3(0, 1, 0); +const _v = new THREE.Vector3(); +const _q = new THREE.Quaternion(); +const EMPTY = new Set(); + +// --- Hyperspace warp-in --- +// On each new field the orbs jump in: they start flung far out along their radial +// direction (stretched into thin streaks) and decelerate hard into place. +const WARP_DUR = 0.85; // seconds per orb +const WARP_STAGGER = 0.30; // max extra delay, spread across orbs for a cascade +const easeOutExpo = (p) => (p >= 1 ? 1 : 1 - Math.pow(2, -10 * p)); +// Deterministic 0..1 hash from an integer id (for per-orb stagger / fallback dir). +const hash01 = (n) => { const x = Math.sin((n + 1) * 12.9898) * 43758.5453; return x - Math.floor(x); }; + +// Sets the shared warp start-time the first frame after a new field arrives. +// Registered before the orbs so their useFrame reads a fresh t0 the same frame. +function WarpClock({ warpRef }) { + useFrame(({ clock }) => { + if (warpRef.current.pending) { warpRef.current.t0 = clock.elapsedTime; warpRef.current.pending = false; } + }); + return null; +} + +// One white radial-gradient texture, tinted per-halo via the material colour. +function radialTexture() { + if (typeof document === "undefined") return null; + const s = 128, c = document.createElement("canvas"); + c.width = c.height = s; + const ctx = c.getContext("2d"); + const g = ctx.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2); + g.addColorStop(0, "rgba(255,255,255,0.95)"); + g.addColorStop(0.35, "rgba(255,255,255,0.4)"); + g.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, s, s); + return new THREE.CanvasTexture(c); +} +const HALO_TEX = radialTexture(); + +function Halo({ pos, r, color, k = 1 }) { + return ( + + + + + + + ); +} + +function Node({ node, color, halo, haloK, accScale, warp, onHover, warpRef, center }) { + const pos = node.pos; + const isQ = node.is_query; + const r = node.r ?? (isQ ? 0.3 : 0.2); + + const grpRef = useRef(); + const meshRef = useRef(); + + // Where this orb starts its hyperspace jump: far out along its radial line + // from the field centre (random direction if it sits dead-centre), plus its + // stagger delay and the unit radial used to orient the streak. + const warpIn = useMemo(() => { + let dx = pos[0] - center[0], dy = pos[1] - center[1], dz = pos[2] - center[2]; + let m = Math.hypot(dx, dy, dz); + if (m < 1e-3) { // dead-centre: pick a stable pseudo-random direction + const a = hash01(node.id) * Math.PI * 2, b = hash01(node.id + 7) * Math.PI - Math.PI / 2; + dx = Math.cos(a) * Math.cos(b); dy = Math.sin(b); dz = Math.sin(a) * Math.cos(b); m = 1; + } + const nx = dx / m, ny = dy / m, nz = dz / m; + const dist = 12 + m * 5; + const quat = new THREE.Quaternion().setFromUnitVectors(UP, _v.set(nx, ny, nz)); + return { off: [nx * dist, ny * dist, nz * dist], quat, delay: hash01(node.id) * WARP_STAGGER }; + }, [pos[0], pos[1], pos[2], center[0], center[1], center[2], node.id]); + + useFrame(({ clock }) => { + const g = grpRef.current; + if (!g) return; + const t0 = warpRef.current.t0; + let p = t0 <= -100 ? 1 : (clock.elapsedTime - t0 - warpIn.delay) / WARP_DUR; + p = p < 0 ? 0 : p > 1 ? 1 : p; + const k = 1 - easeOutExpo(p); // 1 → 0 over the jump + g.position.set(warpIn.off[0] * k, warpIn.off[1] * k, warpIn.off[2] * k); + if (p < 1 && meshRef.current) { + // Streak: stretch along the radial travel axis, thinning out the sides. + meshRef.current.quaternion.copy(warpIn.quat); + meshRef.current.scale.set(1 / (1 + 2.2 * k), 1 + 7 * k, 1 / (1 + 2.2 * k)); + } + }); + + // Warp the orb to show motion through the vector space: stretch into an + // ellipsoid along velocity (with an acceleration pulse). + const { quat, scale } = useMemo(() => { + const vel = node.vel || [0, 0, 0]; + const speed = Math.hypot(vel[0], vel[1], vel[2]); + const accMag = Math.hypot(node.acc[0], node.acc[1], node.acc[2]); + const s = Math.min(1.6, (speed + 0.5 * accMag) * warp); + let quat = [0, 0, 0, 1]; + if (speed > 1e-7) { + _v.set(vel[0], vel[1], vel[2]).normalize(); + _q.setFromUnitVectors(UP, _v); + quat = [_q.x, _q.y, _q.z, _q.w]; + } + return { quat, scale: [1 - 0.35 * s, 1 + s, 1 - 0.35 * s] }; + }, [node.vel, node.acc, warp]); + + const accEnd = useMemo(() => { + const a = node.acc; + const mag = Math.hypot(a[0], a[1], a[2]); + if (mag < 1e-9) return null; + const len = Math.min(4, mag * accScale); + const k = len / mag; + return [pos[0] + a[0] * k, pos[1] + a[1] * k, pos[2] + a[2] * k]; + }, [node.acc, pos, accScale]); + + return ( + + {halo && } + { e.stopPropagation(); onHover(node); }} + onPointerOut={() => onHover(null)} + > + + + + + {accEnd && !isQ && ( + + )} + + + + {isQ ? "◆" : fmtWeight(node.score)} + + + + ); +} + +function CtrlPanControls() { + const controls = useRef(); + useEffect(() => { + const onDown = (e) => { if (e.key === "Control" && controls.current) controls.current.mouseButtons.LEFT = THREE.MOUSE.PAN; }; + const onUp = (e) => { if (e.key === "Control" && controls.current) controls.current.mouseButtons.LEFT = THREE.MOUSE.ROTATE; }; + window.addEventListener("keydown", onDown); + window.addEventListener("keyup", onUp); + return () => { window.removeEventListener("keydown", onDown); window.removeEventListener("keyup", onUp); }; + }, []); + return ( + + ); +} + +function Tooltip({ node, color }) { + if (!node) return null; + const acc = node.approach_acc < 0 ? "#36d399" : "#ff5b6e"; + return ( + +
+
+ weight {node.is_query ? "—" : fmtWeight(node.score)} + cosq {node.cos_q.toFixed(3)} + d̈ {node.approach_acc >= 0 ? "+" : ""}{node.approach_acc.toFixed(3)} +
+ {node.members && node.members.length > 1 && ( +
★ overlap · queries {node.members.map((q) => q + 1).join(" + ")}
+ )} +
{node.text || node.label}
+
+ + ); +} + +export default function VectorField({ nodes, accScale, warp, queryCount, hoveredId, onHover, usedIds, citedIds, warpKey }) { + const multi = (queryCount || 1) > 1; + const used = usedIds || EMPTY, cited = citedIds || EMPTY; + + // Warp-in clock: arm a fresh start time whenever a new field arrives. + const warpRef = useRef({ t0: -999, pending: false }); + useEffect(() => { warpRef.current.pending = true; }, [warpKey]); + + // Field centre the orbs jump in toward (mean of current positions). + const center = useMemo(() => { + if (!nodes.length) return [0, 0, 0]; + let x = 0, y = 0, z = 0; + for (const n of nodes) { x += n.pos[0]; y += n.pos[1]; z += n.pos[2]; } + return [x / nodes.length, y / nodes.length, z / nodes.length]; + }, [nodes]); + + // Shared colour logic (see colors.js) so list + orbs match. + const colorById = useMemo(() => nodeColors(nodes, multi), [nodes, multi]); + const colorOf = (nd) => colorOfNode(nd, colorById, multi); + // Halo priority: hover > answer citation > query > overlap > answer "used". + const haloOf = (nd) => { + if (nd.id === hoveredId) return "#ffffff"; + if (cited.has(nd.id)) return "#9be7ff"; // cited by the answer — brightest + if (nd.is_query) return multi ? qHsl(nd.query_index, 66) : "#9fb4ff"; + if (nd.members && nd.members.length > 1) return OVERLAP; + if (used.has(nd.id)) return "#46506e"; // considered for the answer — soft + return null; + }; + const haloK = (nd) => (cited.has(nd.id) ? 1.3 : used.has(nd.id) && !nd.is_query ? 0.5 : 1); + const hoveredLive = hoveredId != null ? nodes.find((n) => n.id === hoveredId) : null; + + return ( + + + + + + + + + + {nodes.map((n) => ( + onHover(node ? node.id : null)} warpRef={warpRef} center={center} /> + ))} + + + + + ); +} diff --git a/viz/src/colors.js b/viz/src/colors.js new file mode 100644 index 0000000..3696341 --- /dev/null +++ b/viz/src/colors.js @@ -0,0 +1,26 @@ +// Shared colour logic so the 3D orbs and the results list agree. + +export const QHUES = [205, 32, 288, 150, 48]; +export const qHsl = (qi, L = 62) => `hsl(${QHUES[qi % QHUES.length]}, 85%, ${L}%)`; +export const OVERLAP = "#ffd23b"; + +// Map node id -> colour. Single query → full spectrum ordered by relatedness. +// Multiple queries → coloured by anchor query (overlaps gold). +export function nodeColors(nodes, multi) { + const map = new Map(); + if (!multi) { + const cands = nodes.filter((n) => !n.is_query).slice().sort((a, b) => b.cos_q - a.cos_q); + const n = Math.max(1, cands.length - 1); + cands.forEach((nd, i) => map.set(nd.id, `hsl(${(i / n) * 300}, 85%, 58%)`)); + } else { + for (const nd of nodes) { + if (nd.is_query) continue; + if (nd.members && nd.members.length > 1) map.set(nd.id, OVERLAP); + else map.set(nd.id, qHsl(nd.query_index, 46 + 30 * Math.max(0, Math.min(1, nd.cos_q)))); + } + } + return map; +} + +export const colorOfNode = (nd, map, multi) => + nd.is_query ? (multi ? qHsl(nd.query_index, 66) : "#ffffff") : (map.get(nd.id) || "#888"); diff --git a/viz/src/main.jsx b/viz/src/main.jsx new file mode 100644 index 0000000..4ecd9cf --- /dev/null +++ b/viz/src/main.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.jsx"; +import "./styles.css"; + +createRoot(document.getElementById("root")).render( + + + +); diff --git a/viz/src/styles.css b/viz/src/styles.css new file mode 100644 index 0000000..2a08222 --- /dev/null +++ b/viz/src/styles.css @@ -0,0 +1,87 @@ +* { box-sizing: border-box; } +html, body, #root { margin: 0; height: 100%; width: 100%; background: #05060a; color: #e6e8ef; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; } + +.app { position: relative; height: 100%; width: 100%; } +.canvas-wrap { position: absolute; inset: 0; } + +.panel { position: absolute; top: 16px; left: 16px; z-index: 10; width: 340px; + background: rgba(12, 14, 22, 0.82); border: 1px solid #1d2233; border-radius: 12px; + padding: 14px 16px; backdrop-filter: blur(8px); } +.panel h1 { margin: 0 0 2px; font-size: 15px; letter-spacing: 0.5px; } +.panel .sub { margin: 0 0 12px; font-size: 11px; color: #7b829a; } +.row { display: flex; gap: 8px; margin-bottom: 8px; } +.row input[type="text"] { flex: 1; min-width: 0; background: #0c0e16; color: #e6e8ef; + border: 1px solid #232a3d; border-radius: 8px; padding: 8px 10px; font: inherit; font-size: 12px; } +.row input.small { width: 64px; flex: none; } +button { background: #2b5cff; color: white; border: 0; border-radius: 8px; padding: 8px 12px; + font: inherit; font-size: 12px; cursor: pointer; } +button.ghost { background: #1a2030; } +button:disabled { opacity: 0.5; cursor: default; } + +.stat { display: flex; justify-content: space-between; font-size: 11px; color: #9aa1b8; padding: 2px 0; } +.stat b { color: #e6e8ef; font-weight: 600; } +.bar { height: 6px; background: #161b29; border-radius: 4px; overflow: hidden; margin-top: 2px; } +.bar > i { display: block; height: 100%; background: linear-gradient(90deg,#2b5cff,#7c4dff); } + +.chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; } +.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; color: #cfd6ee; + background: #0c0e16; border: 1px solid #232a3d; border-radius: 10px; padding: 2px 4px 2px 8px; } +.chip i { width: 7px; height: 7px; border-radius: 50%; flex: none; } +.chip-x { background: transparent; border: 0; color: #7b829a; cursor: pointer; font-size: 14px; + line-height: 1; padding: 0 3px; } +.chip-x:hover { color: #ff5b6e; } + +/* Right-side results list with its own scrollbar; re-sorts live by the values shown. */ +.results { position: absolute; top: 16px; right: 16px; z-index: 10; width: 320px; + max-height: calc(100% - 92px); display: flex; flex-direction: column; overflow: hidden; + background: rgba(12, 14, 22, 0.82); border: 1px solid #1d2233; border-radius: 12px; backdrop-filter: blur(8px); } +.results-head { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px 8px; + border-bottom: 1px solid #1d2233; font-size: 11px; color: #9aa1b8; } +.sorts { display: flex; gap: 4px; } +.sorts button { background: #141a28; color: #9aa1b8; border: 1px solid #232a3d; border-radius: 6px; + padding: 3px 7px; font-size: 10px; } +.sorts button.on { background: #2b5cff; color: #fff; border-color: #2b5cff; } +.results-list { overflow-y: auto; padding: 4px; } +.result { display: grid; grid-template-columns: 12px 38px 1fr 46px 46px; gap: 6px; align-items: center; + padding: 4px 6px; border-radius: 6px; font-size: 11px; } +.result.hot { background: rgba(255, 255, 255, 0.10); } +.result:hover { background: rgba(255, 255, 255, 0.05); } +.rdot { width: 10px; height: 10px; border-radius: 50%; } +.rweight { color: #fff; font-variant-numeric: tabular-nums; } +.rlabel { color: #cfd6ee; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rbar { height: 6px; background: #161b29; border-radius: 3px; overflow: hidden; } +.rbar > i { display: block; height: 100%; } +.racc { text-align: right; font-variant-numeric: tabular-nums; font-size: 10px; } +.results-list::-webkit-scrollbar { width: 8px; } +.results-list::-webkit-scrollbar-thumb { background: #2a3147; border-radius: 4px; } +.results-list::-webkit-scrollbar-track { background: transparent; } + +.legend { margin-top: 10px; font-size: 10px; color: #7b829a; line-height: 1.5; } +.legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; } + +button.ask { background: linear-gradient(135deg, #7c4dff, #2b5cff); color: #fff; } + +/* Agentic answer panel (bottom-left), above the playback controls. */ +.answer-panel { position: absolute; left: 16px; bottom: 64px; z-index: 10; width: 360px; + max-height: 44%; overflow-y: auto; background: rgba(12, 14, 22, 0.86); border: 1px solid #2a2150; + border-radius: 12px; padding: 12px 14px; backdrop-filter: blur(8px); } +.ap-q { font-size: 13px; color: #c9b8ff; margin-bottom: 6px; } +.ap-log { font-size: 10px; color: #7b829a; margin-bottom: 8px; border-left: 2px solid #2a2150; padding-left: 8px; } +.ap-plan { color: #9aa1b8; } .ap-eval { color: #6f779a; } +.ap-answer { font-size: 12.5px; line-height: 1.55; color: #e6e8ef; white-space: pre-wrap; } +.ap-thinking { font-size: 12px; color: #9aa1b8; } +.ap-sources { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; font-size: 10px; color: #7b829a; } +.src { background: #141a28; color: #9be7ff; border: 1px solid #2a3147; border-radius: 8px; + padding: 2px 7px; font-size: 10px; cursor: pointer; } +.src:hover { background: #1c2740; border-color: #9be7ff; } +.ap-model { margin-top: 6px; font-size: 9px; color: #565d77; text-align: right; } +.answer-panel::-webkit-scrollbar { width: 8px; } +.answer-panel::-webkit-scrollbar-thumb { background: #2a2150; border-radius: 4px; } + +.controls { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 10; + display: flex; align-items: center; gap: 10px; background: rgba(12,14,22,0.82); + border: 1px solid #1d2233; border-radius: 10px; padding: 8px 12px; } +.controls input[type=range] { width: 320px; } +.status { position: absolute; bottom: 16px; right: 16px; z-index: 10; font-size: 11px; color: #7b829a; } +.status .ok { color: #36d399; } .status .err { color: #ff5b6e; } diff --git a/viz/vite.config.js b/viz/vite.config.js new file mode 100644 index 0000000..02596f3 --- /dev/null +++ b/viz/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Dev server proxies nothing; the React app talks to the bridge WebSocket +// directly at ws://localhost:8086. `vite build` emits to dist/, which the +// bridge (server.js) also serves in production. +export default defineConfig({ + plugins: [react()], + server: { port: 5173 }, + build: { outDir: "dist" }, +});