From 50059e9c04838788c0a2ba1083fcb48244fee540 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 01:39:21 -0400 Subject: [PATCH 01/48] feat(stage6): US8 deploy workflows + data-package builders + placeholder site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Stage 6 PR per the Session-2026-05-17 sequencing decision: ship the deploy workflows + minimal placeholder so US1–US7 can be reviewed via live PR previews surfaced in the PR's Deployments box (not bot comments). What's in it: - src/ohbm2026/ui_data/ — Python data-package builders (manifest, abstracts, authors, cells, topics, state_key, builder). Every shard is an object envelope with a byte-identical build_info block; raw-array shards are forbidden (FR-019 + CA-008). The builder discovers cells/inputs/facet catalogs from the Stage 4 rollup at build time (CA-007). - scripts/build_ui_data.py + scripts/fetch_ui_inputs.sh. - site/ — SvelteKit 2 + Vite 6 + TypeScript with adapter-static and BASE_PATH support for per-PR subdirectory deploys. The placeholder route renders manifest.build_info; the short committish appears in the page suffix + the persistent footer affordance so reviewers verify which commit a PR-preview deploy reflects without DevTools (FR-022 + SC-011). - .github/workflows/{deploy-ui,pr-preview,pr-preview-cleanup}.yml. The pr-preview workflow declares environment: name=pr-preview-<N> so the URL surfaces in the PR's Deployments box; cleanup uses the Deployments API to mark each pr-preview-<N> deployment inactive on PR close. - tests/test_ui_data_*.py — 23 unit tests (incl. AST scan that fails on hardcoded facet literals + the "every shard carries build_info" invariant T020a). Smoke-tested against the real 3,244-abstract corpus: 15 cell shards + 33 topic shards + manifest + abstracts + authors at 4.3 MB gzipped (vs SC-006's 11 MB budget). pnpm build produces a working static site. Deferred to follow-up PRs: - Invariant 4 (author raw→synthetic id remap) — WARN-only; lands in US1. - T026 (real-PR Deployments-box verification) — requires a live PR run. - US1–US7 user-story content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .github/workflows/deploy-ui.yml | 92 + .github/workflows/pr-preview-cleanup.yml | 71 + .github/workflows/pr-preview.yml | 102 + .specify/feature.json | 2 +- CLAUDE.md | 44 +- README.md | 22 + pyproject.toml | 9 +- scripts/build_ui_data.py | 61 + scripts/fetch_ui_inputs.sh | 53 + site/.gitignore | 22 + site/.prettierrc | 6 + site/package.json | 37 + site/playwright.config.ts | 32 + site/pnpm-lock.yaml | 2972 +++++++++++++++++ site/src/app.d.ts | 13 + site/src/app.html | 12 + site/src/lib/components/BuildInfo.svelte | 90 + site/src/lib/shards.ts | 54 + site/src/routes/+layout.svelte | 74 + site/src/routes/+page.svelte | 99 + site/svelte.config.js | 22 + site/tsconfig.json | 14 + site/vite.config.ts | 9 + .../008-ui-rewrite/checklists/requirements.md | 38 + .../008-ui-rewrite/contracts/data-package.md | 69 + .../008-ui-rewrite/contracts/github-action.md | 198 ++ specs/008-ui-rewrite/contracts/routes.md | 41 + specs/008-ui-rewrite/data-model.md | 305 ++ specs/008-ui-rewrite/plan.md | 220 ++ specs/008-ui-rewrite/quickstart.md | 260 ++ specs/008-ui-rewrite/research.md | 188 ++ specs/008-ui-rewrite/spec.md | 268 ++ specs/008-ui-rewrite/tasks.md | 423 +++ src/ohbm2026/exceptions.py | 11 + src/ohbm2026/ui_data/__init__.py | 1 + src/ohbm2026/ui_data/abstracts.py | 259 ++ src/ohbm2026/ui_data/authors.py | 166 + src/ohbm2026/ui_data/builder.py | 293 ++ src/ohbm2026/ui_data/cells.py | 122 + src/ohbm2026/ui_data/manifest.py | 242 ++ src/ohbm2026/ui_data/state_key.py | 152 + src/ohbm2026/ui_data/topics.py | 91 + tests/_ui_data_fixtures.py | 222 ++ tests/test_ui_data_abstracts.py | 58 + tests/test_ui_data_authors.py | 86 + tests/test_ui_data_builder.py | 113 + tests/test_ui_data_cells.py | 57 + tests/test_ui_data_manifest.py | 113 + tests/test_ui_data_state_key.py | 80 + 49 files changed, 7967 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/deploy-ui.yml create mode 100644 .github/workflows/pr-preview-cleanup.yml create mode 100644 .github/workflows/pr-preview.yml create mode 100755 scripts/build_ui_data.py create mode 100755 scripts/fetch_ui_inputs.sh create mode 100644 site/.gitignore create mode 100644 site/.prettierrc create mode 100644 site/package.json create mode 100644 site/playwright.config.ts create mode 100644 site/pnpm-lock.yaml create mode 100644 site/src/app.d.ts create mode 100644 site/src/app.html create mode 100644 site/src/lib/components/BuildInfo.svelte create mode 100644 site/src/lib/shards.ts create mode 100644 site/src/routes/+layout.svelte create mode 100644 site/src/routes/+page.svelte create mode 100644 site/svelte.config.js create mode 100644 site/tsconfig.json create mode 100644 site/vite.config.ts create mode 100644 specs/008-ui-rewrite/checklists/requirements.md create mode 100644 specs/008-ui-rewrite/contracts/data-package.md create mode 100644 specs/008-ui-rewrite/contracts/github-action.md create mode 100644 specs/008-ui-rewrite/contracts/routes.md create mode 100644 specs/008-ui-rewrite/data-model.md create mode 100644 specs/008-ui-rewrite/plan.md create mode 100644 specs/008-ui-rewrite/quickstart.md create mode 100644 specs/008-ui-rewrite/research.md create mode 100644 specs/008-ui-rewrite/spec.md create mode 100644 specs/008-ui-rewrite/tasks.md create mode 100644 src/ohbm2026/ui_data/__init__.py create mode 100644 src/ohbm2026/ui_data/abstracts.py create mode 100644 src/ohbm2026/ui_data/authors.py create mode 100644 src/ohbm2026/ui_data/builder.py create mode 100644 src/ohbm2026/ui_data/cells.py create mode 100644 src/ohbm2026/ui_data/manifest.py create mode 100644 src/ohbm2026/ui_data/state_key.py create mode 100644 src/ohbm2026/ui_data/topics.py create mode 100644 tests/_ui_data_fixtures.py create mode 100644 tests/test_ui_data_abstracts.py create mode 100644 tests/test_ui_data_authors.py create mode 100644 tests/test_ui_data_builder.py create mode 100644 tests/test_ui_data_cells.py create mode 100644 tests/test_ui_data_manifest.py create mode 100644 tests/test_ui_data_state_key.py diff --git a/.github/workflows/deploy-ui.yml b/.github/workflows/deploy-ui.yml new file mode 100644 index 00000000..1bcc705f --- /dev/null +++ b/.github/workflows/deploy-ui.yml @@ -0,0 +1,92 @@ +name: deploy-ui + +on: + push: + branches: [main] + paths: + - 'src/ohbm2026/ui_data/**' + - 'scripts/build_ui_data.py' + - 'scripts/fetch_ui_inputs.sh' + - 'site/**' + - 'specs/008-ui-rewrite/contracts/references.yaml' + - '.github/workflows/deploy-ui.yml' + workflow_dispatch: + +permissions: + contents: write + pages: write + +concurrency: + group: deploy-ui-production + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install uv + project (ui extras) + run: | + pip install --upgrade pip uv + uv venv --python 3.14 .venv + uv pip install --python .venv/bin/python -e ".[ui]" + + - name: Set up Node 20 + pnpm + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Enable pnpm + run: | + npm install --global pnpm@10 + + - name: Install site deps + working-directory: site + run: pnpm install --frozen-lockfile + + - name: Fetch Stage 1–4 inputs (best-effort) + id: fetch_inputs + run: | + set +e + ./scripts/fetch_ui_inputs.sh + echo "rc=$?" >> "$GITHUB_OUTPUT" + + - name: Build data package + if: steps.fetch_inputs.outputs.rc == '0' + run: | + PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --output site/static/data + + - name: Run Python unit tests (ui_data only) + run: PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -p "test_ui_data*" + + - name: Build site + working-directory: site + env: + BASE_PATH: '' + run: pnpm build + + - name: Publish to gh-pages root + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site/build + publish_branch: gh-pages + destination_dir: . + keep_files: true + enable_jekyll: false + commit_message: 'deploy(ui): ${{ github.sha }}' diff --git a/.github/workflows/pr-preview-cleanup.yml b/.github/workflows/pr-preview-cleanup.yml new file mode 100644 index 00000000..c396e603 --- /dev/null +++ b/.github/workflows/pr-preview-cleanup.yml @@ -0,0 +1,71 @@ +name: pr-preview-cleanup + +on: + pull_request: + types: [closed] + workflow_dispatch: + inputs: + pr_number: + description: PR number to clean up + required: true + +permissions: + contents: write + deployments: write + pull-requests: read + +jobs: + cleanup: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'workflow_dispatch' + env: + PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} + steps: + - name: Checkout gh-pages + uses: actions/checkout@v4 + with: + ref: gh-pages + fetch-depth: 1 + + - name: Remove pr-${{ env.PR_NUMBER }} preview directory + run: | + if [ -d "pr-${PR_NUMBER}" ]; then + rm -rf "pr-${PR_NUMBER}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --staged --quiet; then + echo "Nothing staged after removal — skipping commit." + else + git commit -m "cleanup(ui): remove pr-${PR_NUMBER} preview" + git push + fi + else + echo "No preview directory pr-${PR_NUMBER} to remove." + fi + + - name: Mark pr-preview-${{ env.PR_NUMBER }} deployment inactive + uses: actions/github-script@v7 + with: + script: | + const envName = `pr-preview-${process.env.PR_NUMBER}`; + const { data: deployments } = await github.rest.repos.listDeployments({ + owner: context.repo.owner, + repo: context.repo.repo, + environment: envName, + per_page: 100, + }); + if (deployments.length === 0) { + core.info(`No deployments found for environment ${envName}`); + return; + } + for (const dep of deployments) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: dep.id, + state: 'inactive', + description: 'PR closed; preview cleaned up.', + }); + } + core.info(`Marked ${deployments.length} deployment(s) inactive for ${envName}.`); diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml new file mode 100644 index 00000000..13e872e0 --- /dev/null +++ b/.github/workflows/pr-preview.yml @@ -0,0 +1,102 @@ +name: pr-preview + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/ohbm2026/ui_data/**' + - 'scripts/build_ui_data.py' + - 'scripts/fetch_ui_inputs.sh' + - 'site/**' + - 'specs/008-ui-rewrite/contracts/references.yaml' + - '.github/workflows/pr-preview.yml' + workflow_dispatch: + +permissions: + contents: write + pages: write + deployments: write + pull-requests: read + +concurrency: + group: deploy-ui-preview-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + preview: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.full_name == github.repository + # NOTE: this `environment:` declaration is what surfaces the preview URL + # in the PR's Deployments box (top-of-PR). GitHub auto-creates the + # `pr-preview-<N>` environment on first deploy and re-uses it across + # pushes — no bot-comment spam (FR-021). + environment: + name: pr-preview-${{ github.event.pull_request.number }} + url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/pr-${{ github.event.pull_request.number }}/ + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: '3.14' + + - name: Install uv + project (ui extras) + run: | + pip install --upgrade pip uv + uv venv --python 3.14 .venv + uv pip install --python .venv/bin/python -e ".[ui]" + + - name: Set up Node 20 + pnpm + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Enable pnpm + run: | + npm install --global pnpm@10 + + - name: Install site deps + working-directory: site + run: pnpm install --frozen-lockfile + + - name: Fetch Stage 1–4 inputs (best-effort) + id: fetch_inputs + run: | + set +e + ./scripts/fetch_ui_inputs.sh + echo "rc=$?" >> "$GITHUB_OUTPUT" + + - name: Build data package + if: steps.fetch_inputs.outputs.rc == '0' + run: | + PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --output site/static/data + + - name: Run Python unit tests (ui_data only) + run: PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -p "test_ui_data*" + + - name: Build site (PR preview) + working-directory: site + env: + BASE_PATH: /pr-${{ github.event.pull_request.number }} + run: pnpm build + + - name: Publish to gh-pages /pr-${{ github.event.pull_request.number }} + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site/build + publish_branch: gh-pages + destination_dir: pr-${{ github.event.pull_request.number }} + keep_files: true + enable_jekyll: false + commit_message: 'preview(ui): pr-${{ github.event.pull_request.number }} ${{ github.sha }}' diff --git a/.specify/feature.json b/.specify/feature.json index 6ac78031..f6929eaf 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1 @@ -{"feature_directory": "specs/007-package-reorg"} +{"feature_directory": "specs/008-ui-rewrite"} diff --git a/CLAUDE.md b/CLAUDE.md index 538a8e92..9846a962 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,7 @@ All library code lives in `src/ohbm2026/`: - `category_evaluation.py`, `category_rollup.py` — compare learned cluster families against submitter taxonomies. - `layout/` (**parked** as of Stage 5 — specs/007-package-reorg/) — poster_layout, poster_sequencing, nocd_experiments. Preserved verbatim for future revival; not actively maintained. Tests under `tests/test_poster_*.py` + `tests/test_nocd_experiments.py` still run with import-paths updated to `ohbm2026.layout.*`. The 15 companion scripts live under `scripts/layout/`. Revive when a new organizer cycle needs poster-layout work. - `ui.py` — static UI export (`export-ui` writes a fresh bundle; `build-ui` also mirrors to `export/ui-site/`). +- `ui_data/` — **Stage 6** UI data-package builders. `manifest.py`, `abstracts.py`, `authors.py`, `cells.py`, `topics.py` produce per-shard envelopes (every shard carries a top-level `build_info` block — FR-019 + CA-008). `state_key.py` discovers the corpus + Stage 4 rollup state-keys at build time (CA-007). `builder.py` orchestrates + enforces the 8 cross-shard invariants from `specs/008-ui-rewrite/data-model.md` §8. CLI entry: `scripts/build_ui_data.py`. The SvelteKit site lives at `site/` (self-contained pnpm project; gh-pages deploy via `.github/workflows/{deploy-ui,pr-preview,pr-preview-cleanup}.yml`). - `cli.py` — single dispatch entrypoint that wires the above into subcommands. Tests in `tests/` mirror the module names and use `unittest`. @@ -168,28 +169,33 @@ Current canonical defaults (the UI consumes these): <!-- SPECKIT START --> For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan -at `specs/007-package-reorg/plan.md`. The companion design artifacts +at `specs/008-ui-rewrite/plan.md`. The companion design artifacts under the same directory — `research.md`, `data-model.md`, `contracts/`, -and `quickstart.md` — pin Stage 5 package-reorganization design: three -independent commit series that collapse the legacy `src/ohbm2026/ -enrichment.py` (1,361 LOC, 62 def/class symbols, ~22+ unused) into -focused `enrich/` submodules (`text.py`, `cache_paths.py`, -`markdown_render.py`, `openai_compat.py`); park `poster_layout.py`, -`poster_sequencing.py`, and `nocd_experiments.py` under -`src/ohbm2026/layout/` (no scheduled maintenance; revive when a new -organizer cycle needs it) along with their 15 `scripts/` companions -under `scripts/layout/`; and split the monolithic `src/ohbm2026/ui.py` -(1,361 LOC) into a `ui/` package with leaf/mid/trunk submodules -(`text.py`, `figures.py`, `references.py`, `manifest.py`, -`payload_legacy.py`, `payload_stage4.py`, `cli.py`). No backward-compat -shim at any `__init__.py`; every consumer imports from the explicit -submodule that owns the symbol (Stage 4 / Q2 / T108b precedent). Tests -are explicitly waived for the refactor body per the user's "OK to skip -tests" guidance; the existing test suite (583 / 1 pre-existing failure) -stays green, with `tests/test_enrichment.py` deleted because it covers -only the legacy Stage 2 path that Stage 2.1 already replaced. +and `quickstart.md` — pin Stage 6 UI-rewrite design: a static +SvelteKit + Vite site served from GitHub Pages, built and deployed by +a GitHub Action with per-PR preview management, consuming a static- +JSON-shard data package (DuckDB-WASM ruled out at 3,244-abstract scale +per the Session-2026-05-17 clarification). Adds previously-missing +capabilities: program-assigned poster ids (not submission ids), +restored author + affiliation details, 3D UMAP alongside 2D + lasso, +typo-tolerant lexical + semantic search (MiniLM-L6 ONNX via +transformers.js with int8-quantized corpus vectors), interactive +facet recomputation, a saved-list shopping cart with mailto-based +export, an optional walkthrough (shepherd.js), an About page with +build-time verified references, and mobile-first responsive layout. +Accepted abstracts only; withdrawn submissions never surface +anywhere. Three workflows: `deploy-ui.yml` (main → root), `pr- +preview.yml` (PR → `/pr-<N>/`), `pr-preview-cleanup.yml` (close → +remove). 8 user stories; MVP = US1 (search + browse on every device). +No new secrets beyond the default `GITHUB_TOKEN`. The Python data- +builder package lives at `src/ohbm2026/ui_data/`; the site lives at +`site/`. Previous-stage plans: +- Stage 5 package reorganization: `specs/007-package-reorg/plan.md` + (collapsed `enrichment.py` into `enrich/{text, markdown_render}.py`; + parked `layout/`; split `ui.py` into `ui/{payload, cli}.py`; + consolidated `load_json`/`write_json` into `ohbm2026/util/json_io.py`). - Stage 4 analysis & annotation: `specs/006-analysis-annotation/plan.md` (canonical `ohbmcli analyze-matrix` producing 48 bundles + canonical rollup `annotations__<state-key>.{parquet,sqlite}`; joblib-parallel diff --git a/README.md b/README.md index 9a047e67..7dd8de14 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,28 @@ The latest end state of the project is: - two semantic cluster lenses: - `25-cluster benchmark` - `claims 28-cluster benchmark` +9. **Stage 6 (in progress)** — UI rewrite as a static SvelteKit site served from GitHub Pages, with per-PR preview deploys surfaced in the PR's Deployments box. See `specs/008-ui-rewrite/` for the spec, plan, and tasks; `specs/008-ui-rewrite/quickstart.md` for the local-dev recipe. + +## Stage 6: UI (under construction) + +The Stage 6 site lives under `site/` (a self-contained SvelteKit project) and the data-package builder lives under `src/ohbm2026/ui_data/`. The first PR ships only the deploy workflows + a placeholder page that renders the build provenance (`manifest.build_info`) so reviewers can verify each PR-preview deploy reflects the latest pushed commit. + +Build the data package + the site locally: + +```bash +PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --output site/static/data + +cd site && pnpm install && pnpm dev # http://localhost:5173 +``` + +Per-PR previews surface in the **PR's Deployments box** (top-of-PR, via the `environment:` declaration in `.github/workflows/pr-preview.yml`) — NOT as a bot comment. After US8 lands on `main`, the production GitHub Pages URL goes live; each subsequent PR for US1–US7 gets an automatic preview deploy. ## External Requirements diff --git a/pyproject.toml b/pyproject.toml index 4d707a71..2c8422c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ohbm2026" -version = "0.1.0" +version = "0.2.0" description = "Local ingestion pipeline for OHBM 2026 Oxford Abstracts data" readme = "README.md" requires-python = ">=3.11" @@ -22,6 +22,13 @@ analysis = [ "hdbscan>=0.8", ] analysis-sci = ["scispacy>=0.5"] +ui = [ + "numpy>=1.26", + "sentence-transformers>=2.7", + "pyyaml>=6.0", + "requests>=2.31", +] +ui-dev = ["playwright>=1.58.0"] [project.scripts] ohbm-ingest = "ohbm2026.assets:main" diff --git a/scripts/build_ui_data.py b/scripts/build_ui_data.py new file mode 100755 index 00000000..fc258b21 --- /dev/null +++ b/scripts/build_ui_data.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python +"""Stage 6 — build the UI data package (T019). + +Thin CLI wrapper around :func:`ohbm2026.ui_data.builder.build_ui_data_package`. +See specs/008-ui-rewrite/quickstart.md for the canonical invocations. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="build_ui_data.py", + description="Build the static-JSON-shard data package consumed by the Stage 6 site.", + ) + parser.add_argument("--corpus", required=True, type=Path) + parser.add_argument("--withdrawn", type=Path, default=None) + parser.add_argument("--authors", required=True, type=Path) + parser.add_argument("--enriched", type=Path, default=None) + parser.add_argument("--references", type=Path, default=None) + parser.add_argument("--analysis-root", dest="analysis_root", type=Path, default=None) + parser.add_argument("--rollup", type=Path, default=None, help="Explicit Stage 4 rollup .sqlite path.") + parser.add_argument( + "--discover-rollup", + dest="discover_rollup", + action="store_true", + help="Discover the active rollup state-key under --analysis-root.", + ) + parser.add_argument("--minilm-bundle", dest="minilm_bundle", type=Path, default=None) + parser.add_argument("--references-yaml", dest="references_yaml", type=Path, default=None) + parser.add_argument("--output", required=True, type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + from ohbm2026.ui_data.builder import Stage6BuildError, build_ui_data_package + + try: + return build_ui_data_package( + corpus_path=args.corpus, + withdrawn_path=args.withdrawn, + authors_path=args.authors, + enriched_path=args.enriched, + references_path=args.references, + analysis_root=args.analysis_root, + rollup=args.rollup, + discover_rollup=args.discover_rollup, + output_dir=args.output, + ) + except Stage6BuildError as exc: + print(f"build_ui_data.py: {exc}", file=sys.stderr) + return 3 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/fetch_ui_inputs.sh b/scripts/fetch_ui_inputs.sh new file mode 100755 index 00000000..450827f4 --- /dev/null +++ b/scripts/fetch_ui_inputs.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# T007 / Stage 6 — fetch_ui_inputs.sh +# +# The GitHub Action calls this script to materialize the Stage 1–4 inputs that +# scripts/build_ui_data.py consumes (corpus + authors + enriched + analysis +# rollup + minilm bundles). These artifacts live outside the repo (Constitution +# II: no committed data). +# +# Per CA-007, this script MUST NOT hardcode any state-key. The downstream +# builder discovers the active state-key at build time via +# `ohbm2026.ui_data.state_key`. +# +# Local dev: skip this script and follow specs/008-ui-rewrite/quickstart.md +# to populate the inputs manually from your existing pipeline runs. +# +# CI: this script is the integration point with whatever storage strategy the +# repo adopts (DVC, GitHub release artifact, S3, etc.). For now it's a +# placeholder that exits 0 IF the inputs already exist and 2 otherwise, with a +# clear error pointing operators to the docs. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +REQUIRED_PATHS=( + "data/primary/abstracts.json" + "data/primary/abstracts_withdrawn.json" + "data/primary/authors.json" + "data/primary/abstracts_enriched.sqlite" + "data/outputs/analysis" + "data/outputs/embeddings/minilm" +) + +missing=() +for path in "${REQUIRED_PATHS[@]}"; do + if [[ ! -e "$path" ]]; then + missing+=("$path") + fi +done + +if [[ ${#missing[@]} -gt 0 ]]; then + echo "fetch_ui_inputs.sh: required inputs missing:" >&2 + for path in "${missing[@]}"; do + echo " - $path" >&2 + done + echo "" >&2 + echo "Local dev: follow specs/008-ui-rewrite/quickstart.md (Prerequisites)." >&2 + echo "CI: wire this script to your artifact-store fetch (DVC / release / S3)." >&2 + exit 2 +fi + +echo "fetch_ui_inputs.sh: all required inputs present; nothing to fetch." diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 00000000..e4b9e03a --- /dev/null +++ b/site/.gitignore @@ -0,0 +1,22 @@ +node_modules +.svelte-kit +build +package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +# Build output of the data-package builder — populated at deploy time per quickstart.md +static/data/ + +# Playwright +playwright-report +test-results + +# Editor / OS +.DS_Store +.idea +.vscode +*.log diff --git a/site/.prettierrc b/site/.prettierrc new file mode 100644 index 00000000..ff2677ef --- /dev/null +++ b/site/.prettierrc @@ -0,0 +1,6 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100 +} diff --git a/site/package.json b/site/package.json new file mode 100644 index 00000000..7069facb --- /dev/null +++ b/site/package.json @@ -0,0 +1,37 @@ +{ + "name": "ohbm2026-ui", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test:unit": "vitest", + "test:e2e": "playwright test", + "test": "pnpm test:unit -- --run && pnpm test:e2e", + "lint": "prettier --check . && eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "@sveltejs/adapter-static": "^3.0.0", + "@sveltejs/kit": "^2.21.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@types/node": "^22.10.0", + "eslint": "^9.18.0", + "prettier": "^3.4.0", + "svelte": "^5.16.0", + "svelte-check": "^4.1.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^2.1.0" + }, + "dependencies": { + "@xenova/transformers": "^2.17.0", + "plotly.js-basic-dist-min": "^2.35.0", + "shepherd.js": "^14.0.0" + } +} diff --git a/site/playwright.config.ts b/site/playwright.config.ts new file mode 100644 index 00000000..b1e4cb3d --- /dev/null +++ b/site/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: 'src/tests/e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + webServer: { + command: 'pnpm build && pnpm preview --port 4173 --host 127.0.0.1', + port: 4173, + reuseExistingServer: !process.env.CI + }, + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + { + name: 'mobile', + use: { + ...devices['Pixel 5'], + viewport: { width: 360, height: 640 } + } + } + ] +}); diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml new file mode 100644 index 00000000..2c432b22 --- /dev/null +++ b/site/pnpm-lock.yaml @@ -0,0 +1,2972 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@xenova/transformers': + specifier: ^2.17.0 + version: 2.17.2 + plotly.js-basic-dist-min: + specifier: ^2.35.0 + version: 2.35.3 + shepherd.js: + specifier: ^14.0.0 + version: 14.5.1 + devDependencies: + '@playwright/test': + specifier: ^1.50.0 + version: 1.60.0 + '@sveltejs/adapter-static': + specifier: ^3.0.0 + version: 3.0.10(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.19))) + '@sveltejs/kit': + specifier: ^2.21.0 + version: 2.60.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.19)) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)) + '@types/node': + specifier: ^22.10.0 + version: 22.19.19 + eslint: + specifier: ^9.18.0 + version: 9.39.4 + prettier: + specifier: ^3.4.0 + version: 3.8.3 + svelte: + specifier: ^5.16.0 + version: 5.55.7 + svelte-check: + specifier: ^4.1.0 + version: 4.4.8(picomatch@4.0.4)(svelte@5.55.7)(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.2(@types/node@22.19.19) + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@22.19.19) + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@huggingface/jinja@0.2.2': + resolution: {integrity: sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==} + engines: {node: '>=18'} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.1': + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-static@3.0.10': + resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.60.1': + resolution: {integrity: sha512-mQjlkNo+rJvpln7V2IGY2j99BqhcFbS4UN0AQNKNYfhBAFZTuCDAdW3a1sgf330mvtNvsBXn3HpAhcmvdJTcIQ==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 || ^6.0.0 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/long@4.0.2': + resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + '@xenova/transformers@2.17.2': + resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.8.3: + resolution: {integrity: sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.3: + resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge-ts@7.1.5: + resolution: {integrity: sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==} + engines: {node: '>=16.0.0'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrap@2.2.9: + resolution: {integrity: sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatbuffers@1.12.0: + resolution: {integrity: sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + guid-typescript@1.0.9: + resolution: {integrity: sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onnx-proto@4.0.4: + resolution: {integrity: sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==} + + onnxruntime-common@1.14.0: + resolution: {integrity: sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==} + + onnxruntime-node@1.14.0: + resolution: {integrity: sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==} + os: [win32, darwin, linux] + + onnxruntime-web@1.14.0: + resolution: {integrity: sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + + plotly.js-basic-dist-min@2.35.3: + resolution: {integrity: sha512-ZipAmyME0S5qWec6GDy2dxP+E6Qhi0X6eNwH6gy7m5YMKYSgrDYrc+/3PTPWpJsxcIMF0kqoOruX+SgH2VwOWg==} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + protobufjs@6.11.6: + resolution: {integrity: sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==} + hasBin: true + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shepherd.js@14.5.1: + resolution: {integrity: sha512-VuvPvLG1QjNOLP7AIm2HGyfmxEIz8QdskvWOHwUcxLDibYWjLRBmCWd8LSL5FlwhBW7D/GU+3gNVC/ASxAWdxg==} + engines: {node: 18.* || >= 20} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + svelte-check@4.4.8: + resolution: {integrity: sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte@5.55.7: + resolution: {integrity: sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==} + engines: {node: '>=18'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + 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 + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@huggingface/jinja@0.2.2': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + + '@polka/url@1.0.0-next.29': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.1 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.1': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@scarf/scarf@1.4.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.19)))': + dependencies: + '@sveltejs/kit': 2.60.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.19)) + + '@sveltejs/kit@2.60.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(typescript@5.9.3)(vite@6.4.2(@types/node@22.19.19))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)) + '@types/cookie': 0.6.0 + acorn: 8.16.0 + cookie: 0.6.0 + devalue: 5.8.1 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.1.0 + sirv: 3.0.2 + svelte: 5.55.7 + vite: 6.4.2(@types/node@22.19.19) + optionalDependencies: + typescript: 5.9.3 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)) + debug: 4.4.3 + svelte: 5.55.7 + vite: 6.4.2(@types/node@22.19.19) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)))(svelte@5.55.7)(vite@6.4.2(@types/node@22.19.19)) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.55.7 + vite: 6.4.2(@types/node@22.19.19) + vitefu: 1.1.3(vite@6.4.2(@types/node@22.19.19)) + transitivePeerDependencies: + - supports-color + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/long@4.0.2': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/trusted-types@2.0.7': {} + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.19.19))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.19.19) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + '@xenova/transformers@2.17.2': + dependencies: + '@huggingface/jinja': 0.2.2 + onnxruntime-web: 1.14.0 + sharp: 0.32.6 + optionalDependencies: + onnxruntime-node: 1.14.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.1: {} + + assertion-error@2.0.1: {} + + axobject-query@4.1.0: {} + + b4a@1.8.1: {} + + balanced-match@1.0.2: {} + + bare-events@2.8.3: {} + + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.3 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.3) + bare-url: 2.4.3 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.8.3): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.3 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.3: + dependencies: + bare-path: 3.0.0 + + base64-js@1.5.1: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@1.1.4: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + + concat-map@0.0.1: {} + + cookie@0.6.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deep-extend@0.6.0: {} + + deep-is@0.1.4: {} + + deepmerge-ts@7.1.5: {} + + deepmerge@4.3.1: {} + + detect-libc@2.1.2: {} + + devalue@5.8.1: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es-module-lexer@1.7.0: {} + + esbuild@0.21.5: + 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 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + esm-env@1.2.2: {} + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrap@2.2.9: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.3 + transitivePeerDependencies: + - bare-abort-controller + + expand-template@2.0.3: {} + + expect-type@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatbuffers@1.12.0: {} + + flatted@3.4.2: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + github-from-package@0.0.0: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + guid-typescript@1.0.9: {} + + has-flag@4.0.0: {} + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + is-arrayish@0.3.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + isexe@2.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-character@3.0.0: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + long@4.0.0: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mimic-response@3.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + napi-build-utils@2.0.0: {} + + natural-compare@1.4.0: {} + + node-abi@3.92.0: + dependencies: + semver: 7.8.0 + + node-addon-api@6.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onnx-proto@4.0.4: + dependencies: + protobufjs: 6.11.6 + + onnxruntime-common@1.14.0: {} + + onnxruntime-node@1.14.0: + dependencies: + onnxruntime-common: 1.14.0 + optional: true + + onnxruntime-web@1.14.0: + dependencies: + flatbuffers: 1.12.0 + guid-typescript: 1.0.9 + long: 4.0.0 + onnx-proto: 4.0.4 + onnxruntime-common: 1.14.0 + platform: 1.3.6 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + platform@1.3.6: {} + + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + + plotly.js-basic-dist-min@2.35.3: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.92.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + prelude-ls@1.2.1: {} + + prettier@3.8.3: {} + + protobufjs@6.11.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.1 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/long': 4.0.2 + '@types/node': 22.19.19 + long: 4.0.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@4.1.2: {} + + resolve-from@4.0.0: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safe-buffer@5.2.1: {} + + semver@7.8.0: {} + + set-cookie-parser@3.1.0: {} + + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + node-addon-api: 6.1.0 + prebuild-install: 7.1.3 + semver: 7.8.0 + simple-get: 4.0.1 + tar-fs: 3.1.2 + tunnel-agent: 0.6.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shepherd.js@14.5.1: + dependencies: + '@floating-ui/dom': 1.7.6 + '@scarf/scarf': 1.4.0 + deepmerge-ts: 7.1.5 + + siginfo@2.0.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-swizzle@0.2.4: + dependencies: + is-arrayish: 0.3.4 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + svelte-check@4.4.8(picomatch@4.0.4)(svelte@5.55.7)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.55.7 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte@5.55.7: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.9 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-fs@3.1.2: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.1 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.1 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + totalist@3.0.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite-node@2.1.9(@types/node@22.19.19): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.19.19) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.19.19): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.14 + rollup: 4.60.4 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + + vite@6.4.2(@types/node@22.19.19): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@6.4.2(@types/node@22.19.19)): + optionalDependencies: + vite: 6.4.2(@types/node@22.19.19) + + vitest@2.1.9(@types/node@22.19.19): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.19.19)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.19.19) + vite-node: 2.1.9(@types/node@22.19.19) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + yocto-queue@0.1.0: {} + + zimmerframe@1.1.4: {} diff --git a/site/src/app.d.ts b/site/src/app.d.ts new file mode 100644 index 00000000..743f07b2 --- /dev/null +++ b/site/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/site/src/app.html b/site/src/app.html new file mode 100644 index 00000000..77a5ff52 --- /dev/null +++ b/site/src/app.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <link rel="icon" href="%sveltekit.assets%/favicon.png" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/site/src/lib/components/BuildInfo.svelte b/site/src/lib/components/BuildInfo.svelte new file mode 100644 index 00000000..3b606a06 --- /dev/null +++ b/site/src/lib/components/BuildInfo.svelte @@ -0,0 +1,90 @@ +<script lang="ts"> + import type { BuildInfo } from '$lib/shards'; + + export let buildInfo: BuildInfo | null = null; + + let expanded = false; + + function toggle() { + expanded = !expanded; + } +</script> + +<footer class="build-info" data-testid="build-info-footer"> + {#if buildInfo} + <button + type="button" + on:click={toggle} + aria-expanded={expanded} + aria-controls="build-info-detail" + class="summary" + > + <span class="label">build</span> + <code data-testid="build-info-short-sha">{buildInfo.code_revision_short}</code> + <span class="sep">·</span> + <span class="corpus" data-testid="build-info-corpus-state"> + corpus {buildInfo.corpus_state_key} + </span> + <span class="sep">·</span> + <time datetime={buildInfo.built_at}>{buildInfo.built_at}</time> + </button> + {#if expanded} + <dl id="build-info-detail" class="detail"> + <dt>code_revision</dt> + <dd><code>{buildInfo.code_revision}</code></dd> + <dt>corpus_state_key</dt> + <dd><code>{buildInfo.corpus_state_key}</code></dd> + <dt>stage4_rollup_state_key</dt> + <dd><code>{buildInfo.stage4_rollup_state_key}</code></dd> + <dt>built_at</dt> + <dd><code>{buildInfo.built_at}</code></dd> + </dl> + {/if} + {:else} + <span class="label" data-testid="build-info-missing">build info unavailable</span> + {/if} +</footer> + +<style> + .build-info { + font-family: + ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; + font-size: 0.75rem; + color: #555; + padding: 0.5rem 1rem; + border-top: 1px solid #eaeaea; + background: #fafafa; + } + .summary { + all: unset; + cursor: pointer; + display: inline-flex; + gap: 0.4rem; + align-items: baseline; + flex-wrap: wrap; + } + .summary:hover code { + text-decoration: underline; + } + .label { + text-transform: uppercase; + letter-spacing: 0.05em; + color: #888; + } + .sep { + color: #bbb; + } + .detail { + margin: 0.5rem 0 0; + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.15rem 0.75rem; + } + .detail dt { + font-weight: 600; + color: #777; + } + .detail dd { + margin: 0; + } +</style> diff --git a/site/src/lib/shards.ts b/site/src/lib/shards.ts new file mode 100644 index 00000000..3b0cd5a0 --- /dev/null +++ b/site/src/lib/shards.ts @@ -0,0 +1,54 @@ +import { base } from '$app/paths'; + +export interface BuildInfo { + corpus_state_key: string; + code_revision: string; + code_revision_short: string; + stage4_rollup_state_key: string; + built_at: string; +} + +export interface Manifest { + schema_version: string; + build_info: BuildInfo; + corpus_count: number; + default_cell: { model: string; input: string }; + models: string[]; + inputs: string[]; + cells: Array<{ + cell_key: string; + model: string; + input: string; + shard_url: string; + topic_shards: Record<string, string>; + }>; + facets: Array<{ key: string; label: string; options: string[] }>; + search: { + lexical_index: string; + minilm_vectors: string; + minilm_vectors_build_info_url: string; + minilm_dim: number; + minilm_dtype: string; + }; +} + +let manifestCache: Promise<Manifest | null> | null = null; + +export function loadManifest(fetcher: typeof fetch = fetch): Promise<Manifest | null> { + if (manifestCache === null) { + manifestCache = (async () => { + try { + const response = await fetcher(`${base}/data/manifest.json`); + if (!response.ok) return null; + return (await response.json()) as Manifest; + } catch { + return null; + } + })(); + } + return manifestCache; +} + +export function resetManifestCacheForTests(): void { + manifestCache = null; +} diff --git a/site/src/routes/+layout.svelte b/site/src/routes/+layout.svelte new file mode 100644 index 00000000..c9d531e0 --- /dev/null +++ b/site/src/routes/+layout.svelte @@ -0,0 +1,74 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import { loadManifest, type Manifest } from '$lib/shards'; + import BuildInfo from '$lib/components/BuildInfo.svelte'; + + let manifest: Manifest | null = null; + + onMount(async () => { + manifest = await loadManifest(); + }); +</script> + +<svelte:head> + {#if manifest} + <title>OHBM 2026 — under construction · {manifest.build_info.code_revision_short} + {:else} + OHBM 2026 — under construction + {/if} + + +
+
+

OHBM 2026 — under construction

+

+ Stage 6 UI rewrite · static SvelteKit on GitHub Pages · accepted abstracts only +

+
+ +
+ +
+ + +
+ + diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte new file mode 100644 index 00000000..53113980 --- /dev/null +++ b/site/src/routes/+page.svelte @@ -0,0 +1,99 @@ + + +
+

Stage 6 — under construction

+ + {#if manifest} +

+ Built from + {manifest.build_info.code_revision_short} + against corpus + {manifest.build_info.corpus_state_key} + and Stage 4 rollup + {manifest.build_info.stage4_rollup_state_key}. +

+

+ This page renders the data-package manifest.json only — the real US1 home + page lands in a subsequent PR. The build pipeline + per-PR preview deploys are wired so + reviewers can verify each PR by clicking "View deployment" in the PR's Deployments + box (top-of-PR) and confirming the short SHA above matches the latest pushed commit. +

+
+
Accepted abstracts
+
{manifest.corpus_count}
+
Models
+
{manifest.models.join(', ')}
+
Inputs
+
{manifest.inputs.join(', ')}
+
Cells
+
{manifest.cells.length} ({manifest.models.length} × {manifest.inputs.length})
+
Facet catalog
+
{manifest.facets.length} facets
+
+ {:else if error} +

+ Failed to load manifest.json: {error} +

+ {:else} +

Loading manifest.json

+ + {/if} +
+ + diff --git a/site/svelte.config.js b/site/svelte.config.js new file mode 100644 index 00000000..a4f632f7 --- /dev/null +++ b/site/svelte.config.js @@ -0,0 +1,22 @@ +import adapter from '@sveltejs/adapter-static'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +const basePath = process.env.BASE_PATH ?? ''; + +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false, + strict: true + }), + paths: { + base: basePath + } + } +}; + +export default config; diff --git a/site/tsconfig.json b/site/tsconfig.json new file mode 100644 index 00000000..a8f10c8e --- /dev/null +++ b/site/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/site/vite.config.ts b/site/vite.config.ts new file mode 100644 index 00000000..15c3fc83 --- /dev/null +++ b/site/vite.config.ts @@ -0,0 +1,9 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + test: { + include: ['src/**/*.{test,spec}.{js,ts}'] + } +}); diff --git a/specs/008-ui-rewrite/checklists/requirements.md b/specs/008-ui-rewrite/checklists/requirements.md new file mode 100644 index 00000000..c257a80f --- /dev/null +++ b/specs/008-ui-rewrite/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: UI Rewrite — Static Search Site + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-05-17 +**Feature**: [Link to spec.md](../spec.md) + +## Content Quality + +- [X] No implementation details (languages, frameworks, APIs) — the spec names *categories* of tech (modern JS framework, in-browser ML runtime, GitHub Action) without mandating a specific choice. The Assumptions section calls out the open choices. +- [X] Focused on user value and business needs — every FR / SC ties back to a user-facing capability (search, browse, save, share, learn). +- [X] Written for non-technical stakeholders — the User Stories speak in user terms; the FR section is precise but uses plain language ("typo tolerance", "open in new tab"). The wireframe-prompt appendix is for designers, not engineers. +- [X] All mandatory sections completed — User Scenarios, Edge Cases, Functional Requirements, Key Entities, Constitution Alignment, Success Criteria, Assumptions, plus the wireframe prompt. + +## Requirement Completeness + +- [X] No [NEEDS CLARIFICATION] markers remain — all ambiguities resolved with informed defaults documented in Assumptions (in-browser ML runtime choice, cart email mechanism = mailto, mobile lasso replacement = tap-by-community, PR preview cleanup = pull_request.closed event). +- [X] Requirements are testable and unambiguous — every FR cites a concrete behavior; the Independent Test on each user story names a specific verification path. +- [X] Success criteria are measurable — every SC carries a numeric threshold (3 s first paint, 500 ms search, 5 MB / 25 MB gzipped data, 90 % typo recall, 10 min preview latency). +- [X] Success criteria are technology-agnostic — SCs measure user-facing latency, file sizes, and recall %; they do not mandate React, Vite, ONNX Runtime, etc. +- [X] All acceptance scenarios are defined — 8 user stories × ~3 Given/When/Then each = 24+ acceptance scenarios. +- [X] Edge cases are identified — 11 edge cases enumerated (empty search, single result, mobile lasso conflict, empty cart email, no mail handler, short queries, low-end 3D, dead reference links, PR collisions, mobile walkthrough, plus the typo-tolerance threshold). +- [X] Scope is clearly bounded — explicit "out of v1" calls (3D lasso, runtime dead-link handling); the Assumptions section enumerates what the spec doesn't try to solve (no SMTP relay, no server-side cart, no auth, no analytics). +- [X] Dependencies and assumptions identified — 11 assumption bullets covering framework choice, ML runtime, poster id source, references data, cart storage, etc. + +## Feature Readiness + +- [X] All functional requirements have clear acceptance criteria — every FR maps to ≥ 1 user-story acceptance scenario or success criterion. +- [X] User scenarios cover primary flows — 8 user stories cover MVP browsing (US1), exploration (US2 + US3), filtering (US4), sharing (US5), onboarding (US6 + US7), and operational deploy (US8). +- [X] Feature meets measurable outcomes defined in Success Criteria — SC-001..SC-010 collectively bound performance, correctness, deployment, and usability. +- [X] No implementation details leak into specification — implementation choices (framework, ML runtime, preview cleanup mechanism) are noted as assumptions, not requirements. + +## Notes + +- The user offered to ask Claude Design for a wireframe. The wireframe-prompt block at the end of the spec is ready to paste verbatim. Reviewing the wireframe before `/speckit-plan` is recommended but not mandatory; the plan can also drive the wireframe. +- The 3D lasso is explicitly out of scope for v1 (FR-006 + US2 acceptance scenario 3). If the user later wants it, that's a follow-up stage. +- The build-time link checker for the About page (FR-017 + SC-007) requires the deploy action to run with network access; this is the default for `ubuntu-latest` GitHub-hosted runners. +- The current corpus has 3,244 accepted abstracts (verified live at spec-time). If the count shifts before the deploy, the spec's user-facing copy ("3,244 accepted abstracts") needs to update — but the FR/SC contracts are size-agnostic. +- Data-package size budgets (SC-006) assume aggressive JSON minification + per-cell lazy loading. If they're missed, the plan phase needs to consider switching to a binary format (Parquet via parquetjs / Arrow) for the per-model coordinates. diff --git a/specs/008-ui-rewrite/contracts/data-package.md b/specs/008-ui-rewrite/contracts/data-package.md new file mode 100644 index 00000000..c22685cf --- /dev/null +++ b/specs/008-ui-rewrite/contracts/data-package.md @@ -0,0 +1,69 @@ +# Contract: UI Data Package (post-build) + +The data package is the **public file-system contract** between the Python builders and the Svelte site. Anything else (storage engines, query layers, ML runtimes) is implementation detail; this contract is what the site fetches. + +## Files + +| Path | Format | Lazy? | Max gz size | Schema reference | +|---|---|---|---|---| +| `data/manifest.json` | JSON object | no | 5 KB | data-model.md §1 | +| `data/abstracts.json` | JSON array | no | 6 MB | data-model.md §2 | +| `data/authors.json` | JSON array | no | 1.5 MB | data-model.md §3 | +| `data/cells/_.json` | JSON array | yes (non-default cells) | 100 KB each | data-model.md §4 | +| `data/topics/__.json` | JSON array | yes (non-default cells/kinds) | 30 KB each | data-model.md §5 | +| `data/search/lexical_index.json` | JSON object | no | 500 KB | data-model.md §6 | +| `data/search/minilm_vectors.bin` | binary `Int8Array` | yes (first semantic query) | 1.5 MB | data-model.md §7 | + +## Stable URL conventions + +- All paths are **relative to the site root**. With GitHub Pages serving from `https://.github.io//`, the manifest lives at `https://.github.io//data/manifest.json`. +- PR previews mount the **same** relative layout under `https://.github.io//pr-/data/manifest.json`. The site never hardcodes the base; it uses SvelteKit's `base` config. +- File names use only `[a-z0-9_]+` plus `.json` / `.bin` extensions — no spaces, no special characters. + +## Loading order (browser) + +1. **Manifest** — fetched first; everything else's URL is in here. +2. **Parallel fan-out** after manifest resolves: + - `abstracts.json` + - `authors.json` + - `cells/.json` (the default `neuroscape_abstract` cell) + - `topics/_communities.json`, `..._neuroscape_clusters.json`, `..._topic_clusters.json` + - `search/lexical_index.json` +3. **On-demand**: + - `cells/.json` when the user switches the (model, input) selector. + - `topics/_.json` lazy-loaded with its cell. + - `search/minilm_vectors.bin` on the first semantic query. + +## Invariants + +- **Accepted-only.** `accepted_for != "Withdrawn"` for every record in every shard. +- **Positional join.** `cells/.json[i].abstract_id == abstracts.json[i].abstract_id` for all `i ∈ [0, 3244)`. +- **Referential integrity.** Every `author_id` in `abstracts.json` exists in `authors.json`; every `*_cluster_id` exists in the matching topics file. +- **Provenance block.** Every shard's `build_info` block is byte-identical (same corpus + same rollup + same code revision). +- **Determinism.** The build is reproducible: same inputs → same shard SHA-256 hashes (modulo the `built_at` timestamp). + +## Versioning + +The manifest's `schema_version` field is the contract version. Bumping it triggers a deliberate site-side migration; the site refuses to load an unknown schema_version (with a user-facing error). + +## Build command + +```bash +PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --references data/primary/reference_metadata.json \ + --rollup data/outputs/analysis/annotations__.sqlite \ + --analysis-root data/outputs/analysis \ + --minilm-bundle data/outputs/embeddings/minilm/title__ \ + --references-yaml specs/008-ui-rewrite/contracts/references.yaml \ + --output site/static/data/ +``` + +Exit codes: +- `0` — all shards written; all invariants satisfied; link checker passed. +- `2` — corpus mismatch or referential-integrity failure (Principle VI: fail loudly). +- `3` — link checker found a non-2xx URL in `references.yaml`. +- `5` — output directory not writable or partial-write detected; nothing committed. diff --git a/specs/008-ui-rewrite/contracts/github-action.md b/specs/008-ui-rewrite/contracts/github-action.md new file mode 100644 index 00000000..07c2baa5 --- /dev/null +++ b/specs/008-ui-rewrite/contracts/github-action.md @@ -0,0 +1,198 @@ +# Contract: GitHub Action Workflows + +Three workflows govern the site lifecycle. Each has a single trigger and a single responsibility (per research.md R8). + +## 1. `.github/workflows/deploy-ui.yml` + +**Trigger**: `push` to `main` (paths: `src/ohbm2026/**`, `scripts/build_ui_data.py`, `site/**`, `specs/008-ui-rewrite/contracts/references.yaml`). + +**Job: `deploy-production`** + +```yaml +runs-on: ubuntu-latest +concurrency: { group: deploy-ui-production, cancel-in-progress: false } +permissions: + contents: write + pages: write +steps: + - name: Checkout + uses: actions/checkout@v4 + with: { fetch-depth: 0 } # full history for code_revision + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: { python-version: "3.14" } + + - name: Install uv + repo deps + run: | + pip install uv + uv venv --python 3.14 .venv + uv pip install --python .venv/bin/python -e ".[ui,enrich]" + + - name: Set up Node 20 + pnpm + uses: actions/setup-node@v4 + with: { node-version: "20" } + - run: corepack enable && corepack prepare pnpm@9 --activate + + - name: Install site deps + working-directory: site + run: pnpm install --frozen-lockfile + + - name: Restore corpus + Stage 4 artifacts + run: | + # The corpus + rollup + embeddings live outside the repo (gitignored). + # Production deploy fetches them from a release artifact or a tracked DVC store. + # Implementation detail handled by `scripts/fetch_ui_inputs.sh`. + ./scripts/fetch_ui_inputs.sh + + - name: Build data package (state-keys discovered at runtime per CA-007) + run: | + PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --references data/primary/reference_metadata.json \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --minilm-bundle "$(PYTHONPATH=src .venv/bin/python -m ohbm2026.ui_data.state_key minilm data/outputs/embeddings/minilm title)" \ + --references-yaml specs/008-ui-rewrite/contracts/references.yaml \ + --output site/static/data/ + + - name: Run Python unit tests + run: PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -p "test_ui_data*" + + - name: Run site unit tests (Vitest) + working-directory: site + run: pnpm test:unit -- --run + + - name: Build site (SvelteKit static-adapter) + working-directory: site + run: pnpm build + + - name: Run Playwright smoke + working-directory: site + run: pnpm test:e2e --reporter=line + + - name: Publish to gh-pages root + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site/build + publish_branch: gh-pages + destination_dir: . + keep_files: true # Preserve /pr-/ subdirectories from preview workflow + enable_jekyll: false + commit_message: | + deploy(ui): ${{ github.sha }} +``` + +## 2. `.github/workflows/pr-preview.yml` + +**Trigger**: `pull_request` (events: `opened`, `synchronize`, `reopened`). + +**Surface**: the preview URL surfaces in the **PR's Deployments box** (top-of-PR, populated by the workflow's `environment:` declaration). No bot comment is posted in the conversation. GitHub auto-creates the `pr-preview-` environment on first use; subsequent pushes update the same environment URL in place. + +**Job: `deploy-preview`** + +```yaml +runs-on: ubuntu-latest +concurrency: { group: deploy-ui-preview-pr-${{ github.event.pull_request.number }}, cancel-in-progress: true } +permissions: + contents: write # gh-pages branch push + deployments: write # create/update the deployment entry +if: github.event.pull_request.head.repo.full_name == github.repository # skip forks +environment: + name: pr-preview-${{ github.event.pull_request.number }} + # NOTE: this URL surfaces in the PR's Deployments box at the top of the PR. + # GitHub auto-creates the environment on first deploy. The URL stays attached + # to the environment across pushes so the same deployment entry is updated + # in place — no comment churn. + url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/pr-${{ github.event.pull_request.number }}/ +steps: + # ...Steps 1-7 identical to deploy-production EXCEPT: + - name: Publish to gh-pages subdirectory + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: site/build + publish_branch: gh-pages + destination_dir: pr-${{ github.event.pull_request.number }} + keep_files: true + enable_jekyll: false + commit_message: | + preview(ui): pr-${{ github.event.pull_request.number }} ${{ github.sha }} +``` + +**Note**: no `peter-evans/find-comment` or `create-or-update-comment` step. The GitHub Deployments API (auto-populated from `environment:`) handles the PR-surface affordance natively. Reviewers see "View deployment" → `pr-preview-` at the top of the PR, above the file diff and below the description. + +## 3. `.github/workflows/pr-preview-cleanup.yml` + +**Trigger**: `pull_request` (event: `closed`). + +**Job: `cleanup-preview`** — removes the `/pr-/` directory from `gh-pages` AND marks the `pr-preview-` deployment as **inactive** via the GitHub Deployments API. After cleanup, the PR's Deployments box shows the deployment in the inactive state with no live link. + +```yaml +runs-on: ubuntu-latest +permissions: + contents: write # gh-pages branch push + deployments: write # mark deployment inactive +if: github.event.pull_request.head.repo.full_name == github.repository +steps: + - uses: actions/checkout@v4 + with: { ref: gh-pages, fetch-depth: 1 } + + - name: Remove preview directory + run: | + rm -rf pr-${{ github.event.pull_request.number }} + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --staged --quiet; then + echo "No preview directory to remove." + else + git commit -m "cleanup(ui): remove pr-${{ github.event.pull_request.number }} preview" + git push + fi + + - name: Mark pr-preview- deployment inactive + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + with: + script: | + const envName = `pr-preview-${process.env.PR_NUMBER}`; + // List active deployments for this environment and set each to inactive. + const deployments = await github.rest.repos.listDeployments({ + owner: context.repo.owner, + repo: context.repo.repo, + environment: envName, + per_page: 100, + }); + for (const dep of deployments.data) { + await github.rest.repos.createDeploymentStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + deployment_id: dep.id, + state: "inactive", + description: "PR closed; preview cleaned up.", + }); + } +``` + +**Note**: no PR-conversation comment is posted on cleanup either — the Deployments box state ("Inactive") is the surface that signals the preview is gone. + +## Required Pages settings (one-time, manual) + +- GitHub Pages: **source = `gh-pages` branch / root** (the default after the first publish). +- Custom domain (optional): set in repo settings → Pages. +- Enforce HTTPS: ON. + +## Secrets + +- Only `GITHUB_TOKEN` (auto-provisioned per workflow). **No** OpenAI / Anthropic / Voyage / other API keys touch the deploy path — the data package is built from pre-computed Stage 1–4 artifacts. (CA-004.) + +## Time + cost budget + +- Per-workflow run: ≤ 10 min p90 (SC-008). Dominated by the data-package build (~3 min for 3,244 abstracts + the link checker). +- Free-tier `ubuntu-latest` minutes are sufficient at expected PR cadence. diff --git a/specs/008-ui-rewrite/contracts/routes.md b/specs/008-ui-rewrite/contracts/routes.md new file mode 100644 index 00000000..86a1aa09 --- /dev/null +++ b/specs/008-ui-rewrite/contracts/routes.md @@ -0,0 +1,41 @@ +# Contract: Site Routes + +The site is a single-page-ish application with three top-level routes. SvelteKit serves them as static HTML + per-route lazy-loaded JS chunks. + +## Routes + +| Path | Component | Description | Hard-link-ability | +|---|---|---|---| +| `/` | `+page.svelte` | Home: search + UMAP + result list + detail panel. State (search query, filters, lasso, selected abstract) is reflected in URL query params so the page is shareable. | Yes — `?q=...&model=neuroscape&input=abstract&filters=...&abstract=M-AM-101` | +| `/about` | `about/+page.svelte` | About page with overview + collapsible deep-dives. | Yes — `/about#embedding-models` etc. for deep-dive anchors | +| `/abstract/` | `abstract/[poster_id]/+page.svelte` | Permalink to a single abstract's detail panel; opens the same detail UI as `/` but without the surrounding search context. Used by cart-email permalinks. | Yes — direct link | + +## Query-param contract on `/` + +The home route serializes its state into URL query params (debounced ~500 ms during typing) so users can share their view: + +| Param | Type | Default | Notes | +|---|---|---|---| +| `q` | string | empty | Search query (semantic + lexical) | +| `mode` | enum | `both` | Search mode: `semantic` \| `lexical` \| `both` | +| `model` | enum | `neuroscape` | Selected model | +| `input` | enum | `abstract` | Selected input source | +| `filters` | comma-sep | empty | Active facet filters, encoded as `facet_key:option_value` pairs (e.g. `methods:fMRI,species:Human`) | +| `lasso` | base64 | empty | Lasso-selected abstract ids, packed as a base64 varint list to keep URLs short | +| `abstract` | string | empty | Currently-focused poster_id (for the detail panel) | +| `tour` | bool | false | `?tour=1` re-launches the walkthrough on page load | + +Empty params are dropped from the URL. + +## Permalinks for cart-email + +When the user clicks "email my list" with a non-empty cart, each cart item's permalink is `/abstract/` — NOT `/?abstract=`. Rationale: +- Shareable: even if the recipient doesn't have the search context, the direct link works. +- Crawler-friendly: search engines can index per-abstract pages. +- Resilient: a future search/UMAP UX change doesn't break archived cart-email permalinks. + +## 404 + edge cases + +- `/abstract/` — renders a "Abstract not found" page; offers a search link back to `/`. +- `/` with `?abstract=` — opens the home view; toast says "Abstract not found"; the detail panel stays empty. +- `/` — SvelteKit's catch-all renders a 404 page; SPA fallback to `/` is configured so deep links work on GitHub Pages. diff --git a/specs/008-ui-rewrite/data-model.md b/specs/008-ui-rewrite/data-model.md new file mode 100644 index 00000000..418b49d5 --- /dev/null +++ b/specs/008-ui-rewrite/data-model.md @@ -0,0 +1,305 @@ +# Phase 1 — Data Model (Stage 6 UI Rewrite) + +This document specifies the **per-shard JSON field schemas** that satisfy spec FR-019. It is the canonical reference both for the Python builders under `src/ohbm2026/ui_data/` and for the TypeScript shard-loader under `site/src/lib/shards.ts`. + +Every shard is **accepted-only** — withdrawn abstracts are filtered out at build time and never reach any shard. + +## 0. Shared types + +```text +abstract_id : int32 — Stage 1 corpus id; stable across rebuilds for unchanged corpora. +poster_id : str — program-assigned poster id (e.g. "M-AM-101"); user-facing identifier per FR-002. +author_id : int32 — synthetic id assigned at build time; stable across rebuilds via the dedup key in research.md R6. +model_key : enum — one of {"voyage", "minilm", "openai", "pubmedbert", "neuroscape"}. +input_key : enum — one of {"abstract", "claims", "methods"}. +cell_key : str — `_` (e.g. "neuroscape_abstract"). 15 cells. +clustering_kind: enum — one of {"communities", "neuroscape_clusters", "topic_clusters"}. +build_info : object — {corpus_state_key: str, code_revision: str, code_revision_short: str (first 7 chars of `code_revision`, e.g. "a1b2c3d"), stage4_rollup_state_key: str, built_at: str(ISO 8601 UTC)}. +``` + +## 1. `data/manifest.json` (≤ 5 KB gz) + +```json +{ + "schema_version": "ui.v1", + "build_info": { "...": "see Shared types" }, + "corpus_count": 3244, + "default_cell": { "model": "neuroscape", "input": "abstract" }, + "models": ["voyage", "minilm", "openai", "pubmedbert", "neuroscape"], + "inputs": ["abstract", "claims", "methods"], + "cells": [ + { + "cell_key": "neuroscape_abstract", + "model": "neuroscape", + "input": "abstract", + "shard_url": "data/cells/neuroscape_abstract.json", + "topic_shards": { + "communities": "data/topics/neuroscape_abstract_communities.json", + "neuroscape_clusters": "data/topics/neuroscape_abstract_neuroscape_clusters.json", + "topic_clusters": "data/topics/neuroscape_abstract_topic_clusters.json" + }, + "byte_size_gz": 47512 + } + /* ...14 more cells */ + ], + "facets": [ + {"key": "accepted_for", "label": "Accepted for", "options": ["Poster", "Oral", "Symposium", "..."]}, + {"key": "primary_topic", "label": "Primary topic", "options": ["..."]}, + /* ...11 more facet keys per spec entity list */ + ], + "search": { + "lexical_index": "data/search/lexical_index.json", + "minilm_vectors": "data/search/minilm_vectors.bin", + "minilm_vectors_build_info_url": "data/search/minilm_vectors.build_info.json", + "minilm_dim": 384, + "minilm_dtype": "int8" + } +} +``` + +**Validation rules** + +- `default_cell` MUST appear in `cells`. +- Every entry in `cells` MUST have a discoverable `shard_url` that 200s (build-time check). +- `corpus_count` MUST equal `len(abstracts.json)` (build-time assert). +- `facets[].options` MUST be the union of distinct values across the corpus for that facet key, sorted alphabetically except `accepted_for` which carries program-order. +- `byte_size_gz` is the actual gzipped size on disk, useful for client-side budget telemetry. + +## 2. `data/abstracts.json` (≤ 6 MB gz) + +Shard envelope. `build_info` is byte-identical to the same block in `manifest.json` (FR-019 + CA-008 + §8 invariant 6): + +```json +{ + "schema_version": "abstracts.v1", + "build_info": { "...": "see Shared types" }, + "abstracts": [ /* 3,244 records, schema below */ ] +} +``` + +Each record in `abstracts[]`: + +```json +{ + "abstract_id": 49213, + "poster_id": "M-AM-101", + "title": "Memory fMRI in aging", + "accepted_for": "Poster", + "sections": { + "introduction": "...markdown...", + "methods": "...markdown...", + "results": "...markdown...", + "conclusion": "...markdown...", + "references": "...markdown..." + }, + "topics": { + "primary": "Lifespan Development", + "primary_subcategory": "Aging", + "secondary": "Neuroinformatics and Data Sharing", + "secondary_subcategory": "Informatics Other" + }, + "methods_checklist": ["Functional MRI", "Diffusion MRI"], + "facets": { + "study_type": "task-activation", + "population": "healthy", + "field_strength": "3T", + "processing_packages": ["fmriprep", "FSL"], + "species": ["Human"], + "recording_technology": ["fMRI", "Diffusion MRI"], + "brain_regions": ["Hippocampus", "Default Mode Network"], + "brain_networks": ["Default Mode Network"], + "keywords": ["Aging", "MRI"] + }, + "author_ids": [101, 102, 103], + "reference_dois": ["10.1038/nn.4504", "10.1016/j.neuroimage.2019.116189"], + "reference_urls": [ + "https://doi.org/10.1038/nn.4504", + "https://doi.org/10.1016/j.neuroimage.2019.116189" + ] +} +``` + +**Validation rules** + +- `accepted_for != "Withdrawn"` for **every** record — build-time assert; failure aborts deploy. +- `poster_id` is unique across the corpus (build-time assert). +- `author_ids` references existing ids in `authors.json` (build-time referential check). +- `submission_id` is **never** emitted (FR-002). +- `topics.primary` + `topics.secondary` are extracted from the submission form's `Primary Parent Category & Sub-Category` and `Secondary Parent Category & Sub-Category` responses; missing values → empty string, not `null`. +- `methods_checklist` lists the values from the "Please indicate which methods were used in your research:" submission question, split on `;` and trimmed. +- `facets.brain_regions`, `facets.brain_networks`, `facets.species`, `facets.recording_technology` are extracted via the same regex patterns the current `ui.py:build_domain_facets` already uses (kept in Python so they're language-agnostic). +- `reference_dois` and `reference_urls` are parallel arrays (same index = same reference); empty string fills the slot if either is missing. + +## 3. `data/authors.json` (≤ 1.5 MB gz) + +Shard envelope; `build_info` byte-identical to the manifest's (§8 invariant 6): + +```json +{ + "schema_version": "authors.v1", + "build_info": { "..." }, + "authors": [ + { + "author_id": 101, + "name": "Jane Smith", + "affiliations": ["Department of Psychology, Stanford University"], + "abstract_ids": [49213, 49544] + } + /* ~12,000 total records */ + ] +} +``` + +**Validation rules** + +- `author_id` is unique; assigned at build time via the dedup key in research.md R6. +- `abstract_ids` references existing accepted abstracts only. +- `name` is preserved verbatim (NFC-normalized but not transliterated); diacritics survive. +- `affiliations[0]` is the primary affiliation; subsequent entries are secondary affiliations in the order the submitter listed them. + +## 4. `data/cells/_.json` (15 files, each ≤ 100 KB gz) + +Shard envelope per cell; `build_info` byte-identical to the manifest's: + +```json +{ + "schema_version": "cell.v1", + "build_info": { "..." }, + "cell_key": "neuroscape_abstract", + "rows": [ + { + "abstract_id": 49213, + "umap2d": [3.142, -1.591], + "umap3d": [3.140, -1.589, 0.815], + "community_id": 7, + "topic_cluster_id": 142 + } + /* ...3,244 records, indexed by position to match abstracts.json's order */ + ] +} +``` + +For the **3 `neuroscape_*` cells only**, each record also carries: + +```json +{ + "neuroscape_cluster_id": 89, + "neuroscape_cluster_distance": 1.012 +} +``` + +**Validation rules** + +- Length of array = `manifest.corpus_count` (3,244). +- `abstract_id` order in this file matches the order in `abstracts.json` (positional join). Build-time assert: `for i in range(N): cells[i].abstract_id == abstracts[i].abstract_id`. +- `umap2d` is a 2-element float array; `umap3d` is a 3-element float array. NaNs are not permitted (build-time assert). +- `community_id` and `topic_cluster_id` reference cluster ids that appear in the corresponding `topics/*.json` shard. +- Cells for non-neuroscape models MUST NOT carry `neuroscape_cluster_*` fields (those cells weren't computed per FR-002 in Stage 4). + +## 5. `data/topics/__.json` (≤ 45 files, each ≤ 30 KB gz) + +Shard envelope per (cell, kind); `build_info` byte-identical to the manifest's: + +```json +{ + "schema_version": "topics.v1", + "build_info": { "..." }, + "cell_key": "neuroscape_abstract", + "kind": "communities", + "topics": [ + { + "cluster_id": 7, + "keywords": ["functional connectivity", "default mode network", "fmri"], + "title": "Default Mode Network in fMRI", + "description": "Community 7 spans abstracts on resting-state DMN ...", + "focus": "methodologies" + } + /* ... */ + ] +} +``` + +**Validation rules** + +- `cluster_id` matches the values used in the cells shard's `community_id` / `topic_cluster_id` / `neuroscape_cluster_id` fields. +- `keywords` is non-empty (when `--skip-llm-topics` was set in Stage 4, the keywords come from the local c-TF-IDF pass; `title` + `description` + `focus` may be empty in that case). +- For `=neuroscape_clusters`, the title / description / keywords / focus are sourced from `data/inputs/neuroscape/cluster_table.csv` verbatim (no LLM grouping). +- For `=communities` and `=topic_clusters`, the values come from each Stage 4 bundle's `topics.json` (the hybrid spaCy + c-TF-IDF + LLM grouping pipeline). + +## 6. `data/search/lexical_index.json` (≤ 500 KB gz) + +Schema is internal to the lexical-search engine; documented for traceability: + +```json +{ + "schema_version": "lexical.v1", + "build_info": { "..." }, + "tokens": [ + { + "token_id": 0, + "surface": "memory", + "trigrams": ["mem", "emo", "mor", "ory"], + "postings": [49213, 49544, 49901, /* ...abstract_ids that contain this token */] + }, + /* ~50K tokens */ + ], + "trigram_index": { + "mem": [0, 142, 1057, /* ...token_ids whose surface contains "mem" */], + /* ~12K trigrams */ + } +} +``` + +**Validation rules** + +- Tokens are NFC-normalized, lowercased, accent-folded. +- `postings` lists are sorted ascending and contain only accepted-abstract ids. +- `trigram_index` is the inverse of `tokens[].trigrams` to enable fast query-trigram → candidate-token lookup. +- Stopwords (`the`, `a`, `and`, `of`, `in`, `to`, `for`, `with`, `on`, `at`, `by`, `is`, `was`, `were`, `are`) are dropped at index time. + +## 7. `data/search/minilm_vectors.bin` (≤ 1.5 MB) + `data/search/minilm_vectors.build_info.json` (sidecar) + +The vectors themselves are a raw little-endian buffer of int8 values, shape `[3244, 384]` row-major. Indexed by position to match `abstracts.json` order. The browser decodes via `new Int8Array(buf)`. + +Because a raw binary buffer cannot embed JSON, the build_info travels in a sidecar JSON file at the same URL stem (`minilm_vectors.build_info.json`). The manifest's `search.minilm_vectors_build_info_url` points at the sidecar. + +```json +{ + "schema_version": "minilm_vectors.v1", + "build_info": { "..." }, + "shape": [3244, 384], + "dtype": "int8", + "byte_offset_url": "data/search/minilm_vectors.bin" +} +``` + +**Validation rules** + +- Byte length of the `.bin` = `3244 * 384` = 1,245,696 bytes (≤ 1.5 MB). +- The sidecar `build_info` is byte-identical to the manifest's (§8 invariant 6). +- Quantization preserves cosine similarity: `cos(q, v_int8 / 127) ≈ cos(q, v_float32)` within ε = 0.005 on a held-out subset. Build-time assertion: pick 100 random pairs, verify the int8 → float32 cosine recovers the float32 reference within 0.5 %. + +## 8. Build-time invariants (cross-shard) + +These invariants are asserted by the Python builder (`src/ohbm2026/ui_data/builder.py`) at the end of every build: + +1. `len(abstracts.json.abstracts) == manifest.corpus_count == 3244`. +2. For every cell shard, `len(cell_shard.rows) == 3244` and positional join with `abstracts.json.abstracts` holds. +3. Every `accepted_for != "Withdrawn"` in every shard (the accepted-only invariant — failure means a withdrawn record leaked through; abort deploy). +4. Every `author_id` in `abstracts.json:abstracts[].author_ids` exists in `authors.json:authors[]`. +5. Every `community_id` / `topic_cluster_id` / `neuroscape_cluster_id` in each cell shard exists in the matching `topics/*.json:topics[]`. +6. Every JSON shard MUST carry a top-level `build_info` block (and the `minilm_vectors.bin` MUST have its sidecar `minilm_vectors.build_info.json` co-located); all `build_info` blocks across the build are byte-identical (same corpus + same rollup + same code rev → same build-info). Raw-array JSON shards are forbidden — every shard is an object envelope. +7. All About-page reference URLs in `contracts/references.yaml` return HTTP 2xx (link checker; aborts deploy on any failure per CA-006 + SC-007). +8. Sum of shard `byte_size_gz` ≤ size budget per SC-006. + +## 9. Browser-side derived state (not a shard) + +For completeness — what the client builds in memory after fetching shards: + +- `abstractsByPosterId: Map` — for direct-link routing `/abstract/`. +- `abstractsById: Map` — for cell-shard joins. +- `authorsById: Map` — for detail-panel rendering. +- `cellByKey: Map>` — lazy-populated; cleared via LRU when memory pressure detected. +- `facetCountsByActiveSelection: Map>` — recomputed on every selection change. + +None of these structures are persisted; they live in Svelte stores or component-local state. diff --git a/specs/008-ui-rewrite/plan.md b/specs/008-ui-rewrite/plan.md new file mode 100644 index 00000000..aee91fe9 --- /dev/null +++ b/specs/008-ui-rewrite/plan.md @@ -0,0 +1,220 @@ +# Implementation Plan: Stage 6 — UI Rewrite (Static Search Site) + +**Branch**: `008-ui-rewrite` | **Date**: 2026-05-17 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/008-ui-rewrite/spec.md` + +## Summary + +Rebuild the OHBM 2026 abstract search UI as a **static SvelteKit + Vite site** served from GitHub Pages, built and deployed by a GitHub Action with **per-PR preview management**. The site consumes a **static-JSON-shard data package** (the Session-2026-05-17 clarification ruled out DuckDB-WASM at this scale — 3,244 abstracts × 15 (model, input) cells fit in ~9 MB gzipped, where in-memory JS aggregation runs in < 10 ms). Adds previously-missing capabilities to the prior UI: program-assigned **poster ids** (not submission ids), restored **author + affiliation details**, **3D UMAP**, **typo-tolerant lexical + semantic search** (MiniLM-L6 ONNX via transformers.js, int8-quantized vectors), **interactive facet recomputation**, a **saved-list shopping cart** with `mailto:`-based export, an **optional walkthrough**, an **About page** with build-time verified references, and **mobile-first responsive layout**. Accepted abstracts only; withdrawn submissions never surface anywhere. + +The implementation is **stage-parallel-friendly**: each user story (US1 through US8) is independently shippable as a per-route slice. Per the Session-2026-05-17 sequencing clarification, **US8 (deploy workflows + minimal placeholder) ships first as a small standalone PR** so US1–US7 can be reviewed via live PR previews (surfaced in the PR's Deployments box via `environment:` declaration, not as bot comments). MVP = US1 (search + browse on every device) lands second; US2–US7 then ship in any order, each on top of a working preview pipeline. + +## Technical Context + +**Languages/Versions**: +- **Build-side (data package)**: Python 3.14 in `.venv` (matches the existing pipeline; see CA-001). +- **Site (client)**: TypeScript on Node 20 LTS. The site is fully static — no server runtime. + +**Primary Dependencies**: +- **Site framework**: SvelteKit 2 + Vite 5 in static-adapter mode (`@sveltejs/adapter-static`). Picked over Next.js / Astro for the smallest framework runtime (~5 KB Svelte vs ~50 KB React) and TypeScript-first DX. Decision rationale lives in `research.md` R1. +- **In-browser ML**: `@xenova/transformers` (transformers.js) loading the quantized `Xenova/all-MiniLM-L6-v2` ONNX model from the Hugging Face CDN. Cached by the browser after first visit (~23 MB one-time). +- **Search**: + - Lexical: a **pre-built inverted index** with **n-gram bag tokens** + **Damerau-Levenshtein** distance check on candidate matches. Built at deploy time in Python (`scripts/build_lexical_index.py`); serialized as compact JSON. The browser library is small (~5 KB; a custom edit-distance lookup over the inverted-index postings). + - Semantic: int8-quantized MiniLM vectors (`[3244, 384]` little-endian buffer) loaded on first semantic query. Cosine similarity via typed-array math in a Web Worker. +- **Plotting**: **Plotly.js Basic** (the lite bundle: scatter, gl3d, lasso events; ~700 KB gz) for both 2D and 3D UMAP. Lazy-loaded only when the user opens the projections tab. +- **State**: Svelte stores; no Redux/Pinia/etc. Cart persists via `localStorage`. +- **Walkthrough**: `shepherd.js` (~18 KB gz) or `intro.js` (~14 KB gz). Decision deferred to `research.md` R3. +- **Build-side data scripts**: stdlib + `numpy` + the existing `ohbm2026.*` pipeline (corpus + Stage 4 rollup readers). NO new Python deps. + +**Storage**: +- **Site build output**: a `dist/` directory committed nowhere (gitignored under `export/`) and pushed to the `gh-pages` branch by the GitHub Action. +- **Data package**: static JSON shards + 1 binary file. Detail in `data-model.md`: + - `data/manifest.json`, `data/abstracts.json`, `data/authors.json` + - `data/cells/_.json` × 15 + - `data/topics/__.json` × ≤ 45 + - `data/search/lexical_index.json` + - `data/search/minilm_vectors.bin` (int8) +- **Provenance**: every shard embeds the build-info block (`corpus_state_key`, `code_revision`, `stage4_rollup_state_key`, `built_at`). The site footer's "build info" affordance surfaces this (CA-008). + +**Testing**: +- **Python build-side**: `unittest` for the data-package builders (`scripts/build_ui_data.py`, `scripts/build_lexical_index.py`, link checker). Test-first per CA-002. +- **JS site**: **Vitest** for unit tests (store mutations, facet aggregation, edit-distance lexical match). **Playwright** for end-to-end smoke per US1 (page loads, search returns results, abstract panel opens, accepted-only invariant verified by data scan). Headless Chromium runs in CI. +- **Build-gate tests**: the GitHub Action runs (a) Python data-package builders + tests, (b) link checker against the About page, (c) JS unit suite + Playwright smoke. Any failure blocks deploy (CA-006). + +**Target Platform**: +- **Production**: GitHub Pages (Jekyll-bypassed via `.nojekyll`). +- **Browsers**: evergreen Chromium / Firefox / Safari, last 2 versions; mobile Safari ≥ iOS 15, Chrome on Android ≥ 90. No IE11. + +**Project Type**: client-only static SPA + Python data-package builders. Spec doesn't require a backend. + +**Performance Goals** (from SCs): +- First interactive paint ≤ 3 s on typical broadband (SC-001). +- Query → ranked results ≤ 500 ms median (SC-002). +- Cell-switch UMAP re-render ≤ 1 s on a recent laptop (SC-003). +- Mobile US1 flow without horizontal scroll (SC-004). +- Data package: ≤ 8 MB gz first-paint + ≤ 3 MB gz lazy-expansion + ≤ 1.5 MB MiniLM vectors on first semantic query (SC-006). +- 90 % single-typo recall in top-10 (SC-010). +- PR-preview latency p90 ≤ 10 min (SC-008). + +**Constraints**: +- **Static-only**: no server runtime; no per-request compute. Everything client-side. +- **No new secrets**: deploy uses only `GITHUB_TOKEN` (CA-004). +- **Build-gate determinism**: same corpus + same Stage 4 rollup → byte-identical data shards (build info aside). Test asserts this. +- **Withdrawn-excluded invariant**: build-time filter; every shard is asserted accepted-only before deploy. +- **No DuckDB-WASM**: ruled out by Session-2026-05-17 clarification. + +**Scale/Scope**: +- 3,244 accepted abstracts (live count); ~12,000 author records; 15 active (model, input) cells; ≤ 45 topic files; ≤ 11 MB gz total data package; 8 user stories spanning 105 implementation tasks (T001–T100 + 5 `Tnnna` post-analysis inserts). + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **Principle I (venv-only Python)**: All build-side scripts execute via `.venv/bin/python` or `uv` targeting the venv. CI runs the same `.venv` install. ✓ +- **Principle II (no committed data)**: The site `dist/`, the data-package JSON shards, the lexical index, and the int8 vectors all land under `export/ui-site/` (existing gitignored root) or `data/outputs/exported-sites/ui-site__/` (also gitignored). The `gh-pages` branch is auto-generated by the Action and not part of source review. Nothing tracked. ✓ +- **Principle III (resumable, auditable)**: The data package is rebuildable in one command (`ohbmcli build-ui-data` via `scripts/build_ui_data.py`). The build is deterministic given fixed inputs (corpus + rollup). No external network calls at build time except the About-page link checker. ✓ +- **Principle IV (plan-first, test-first)**: This plan exists. Test-first is honored: Python unit tests for builders + link checker land before their impl; JS Vitest + Playwright smoke land before the corresponding UI features. CA-002 captures this in the spec. ✓ +- **Principle V (secret-safe, commit-early)**: No new secrets. Deploy uses default `GITHUB_TOKEN` only. Per-slice commits per CA-002 (one commit per shipped user-story slice). ✓ +- **Principle VI (fail loudly)**: Build-time link checker is a hard gate; build-time accepted-only invariant check is a hard gate; build-time data-shape contract test is a hard gate. No silent fallbacks. ✓ +- **Principle VII (discover external state)**: The data-package builder discovers the (model, input) cell catalog by reading the Stage 4 rollup's `cluster_topics` table at build time. Adding a 6th model is a zero-UI-code change — just rebuild. ✓ +- **Principle VIII (provenance for organizer-facing artifacts)**: Every shard carries the build-info block. The site footer surfaces it. The `gh-pages` branch's site is the published artifact; provenance is co-located. No absolute or user-home paths. ✓ + +**Gate verdict**: PASS, no waivers. + +## Project Structure + +### Documentation (this feature) + +```text +specs/008-ui-rewrite/ +├── plan.md # This file (/speckit-plan output) +├── research.md # Phase 0 — framework + ML-runtime + walkthrough lib decisions +├── data-model.md # Phase 1 — per-shard JSON field schemas + invariants +├── quickstart.md # Phase 1 — local-dev + build-and-deploy recipes +├── contracts/ +│ ├── data-package.md # Per-shard contract (URLs, schema, size budgets) +│ ├── github-action.md # Workflow contract (triggers, outputs, deploy targets) +│ └── routes.md # Site URL contract (/, /about, /abstract/) +├── checklists/ +│ └── requirements.md # Spec quality checklist (already exists) +└── tasks.md # Phase 2 output (created by /speckit-tasks, not here) +``` + +### Source Code (repository root) + +**New additions in this stage:** + +```text +src/ohbm2026/ui_data/ # NEW Python package — build the data shards +├── __init__.py +├── builder.py # `build_ui_data_package(rollup_path, corpus_path, …)` +├── abstracts.py # Build `abstracts.json` +├── authors.py # Build `authors.json` (dedupe + normalize) +├── cells.py # Build `cells/_.json` × 15 +├── topics.py # Build `topics/__.json` +├── lexical_index.py # Build `search/lexical_index.json` +├── vectors.py # Build `search/minilm_vectors.bin` (int8 from minilm Stage 3 bundle) +├── link_check.py # Verify About-page external URLs at build time +└── manifest.py # Build `manifest.json` + per-shard build-info block + +scripts/ +├── build_ui_data.py # CLI thin wrapper around `ui_data.builder` +└── build_ui_site.sh # End-to-end: `build_ui_data` + `pnpm build` → `dist/` + +site/ # NEW SvelteKit site +├── package.json +├── svelte.config.js # static-adapter + base path for gh-pages +├── vite.config.ts +├── tsconfig.json +├── playwright.config.ts +├── vitest.config.ts +├── src/ +│ ├── app.html +│ ├── lib/ +│ │ ├── shards.ts # Fetch + cache the data shards +│ │ ├── facets.ts # Facet recomputation (in-memory) +│ │ ├── search/ +│ │ │ ├── lexical.ts # Inverted-index lookup + Damerau-Levenshtein +│ │ │ └── semantic.ts # MiniLM ONNX inference + cosine similarity (Web Worker) +│ │ ├── stores/ +│ │ │ ├── selection.ts # Active (model, input) cell + lasso state + filters +│ │ │ ├── cart.ts # Saved-list with localStorage persistence +│ │ │ └── tour.ts # Walkthrough state machine +│ │ ├── components/ +│ │ │ ├── SearchBar.svelte +│ │ │ ├── ResultList.svelte +│ │ │ ├── DetailPanel.svelte +│ │ │ ├── FacetSidebar.svelte +│ │ │ ├── ModelSelector.svelte +│ │ │ ├── UmapPanel.svelte # 2D + 3D tabs; lazy-loads Plotly +│ │ │ ├── Cart.svelte +│ │ │ ├── Tour.svelte +│ │ │ └── BuildInfo.svelte +│ │ └── workers/ +│ │ └── semantic.worker.ts # MiniLM inference off-main-thread +│ ├── routes/ +│ │ ├── +layout.svelte +│ │ ├── +page.svelte # Home: search + UMAP + results + detail +│ │ ├── about/+page.svelte # About + collapsible deep-dives +│ │ └── abstract/[poster_id]/+page.svelte # Direct-link to one abstract +│ └── tests/ +│ ├── unit/ # Vitest +│ │ ├── facets.test.ts +│ │ ├── lexical.test.ts +│ │ └── cart.test.ts +│ └── e2e/ # Playwright +│ ├── browse.spec.ts +│ └── accepted-only.spec.ts +└── static/ + └── data/ # Populated by build_ui_data.py at deploy time + +.github/ +└── workflows/ + ├── deploy-ui.yml # Build + deploy on push to main → gh-pages root + ├── pr-preview.yml # PR open/update → preview under /pr-; surfaces in PR Deployments box via environment: + └── pr-preview-cleanup.yml # PR close → remove /pr-/ + mark deployment inactive via Deployments API +``` + +**Structure Decision**: The site lives under `site/` as a self-contained SvelteKit project (its own `package.json`, `node_modules`, build output). The Python data builders live under `src/ohbm2026/ui_data/` following the same per-stage package pattern as `enrich/`, `embed/`, `analyze/`. The GitHub Action wires them together: data builders run first, write into `site/static/data/`, then the SvelteKit build emits `dist/` which is published. + +## Complexity Tracking + +> Filled only if the Constitution Check produced unjustified violations. None did — the spec is decision-complete and every principle is satisfied. + +## Phasing & sequencing + +Per the Session-2026-05-17 clarification, **US8 ships first** as a small standalone PR so the rest of the work can be reviewed via live PR-previews. Phase numbers below match tasks.md exactly: + +- **Phase 1 (Setup)** — repo layout, deps, gitignore, scaffolding (T001–T007). No UI features yet. +- **Phase 2 (Foundational)** — data-package builder skeleton (manifest + abstracts + authors + cells + topics + state_key discovery + invariant tests) (T008–T020a). Lands the build pipeline that US8 needs to exercise. +- **Phase 3 (US8, P5 promoted to first-shipped) 🥇 first PR** — `deploy-ui.yml`, `pr-preview.yml`, `pr-preview-cleanup.yml` workflows using `environment:` declarations so previews surface in the PR's Deployments box (not as bot comments). A minimal "Stage 6 — under construction" placeholder Svelte page that **renders the build_info footer + short committish in the page title** (FR-022) so reviewers can verify which committish deployed. After this PR merges, every subsequent PR for US1–US7 gets an automatic live preview tagged with its committish. +- **Phase 4 (US1, P1 MVP)** — SvelteKit shell + search bar + result list + detail panel + responsive layout + accepted-only invariant + poster-id + authors + footer build-info on every route. The site goes live for the first time with real content. +- **Phase 5 (US2)** — UmapPanel with 2D (lasso) + 3D tabs; Plotly lazy-load; model selector. Selection-by-id persists across cell switches. +- **Phase 6 (US3)** — semantic + lexical search engines; merged ranking; semantic/lexical/both toggle; author search with typo tolerance; typo-recall eval script. +- **Phase 7 (US4)** — facet sidebar + interactive facet recount over intersection (search ∩ facets ∩ lasso). +- **Phase 8 (US5)** — saved-list cart + email-my-list (`mailto:` + clipboard fallback). +- **Phase 9 (US6)** — walkthrough (shepherd.js per research.md R3). +- **Phase 10 (US7)** — About page + build-time link checker. +- **Phase 11 (Polish)** — accessibility audit, perf budget validation against SC-001..SC-011, docs reconciliation (CLAUDE.md + README + vision doc — most docs micro-updates ride in per-US PRs per Constitution IV), final PR. + +Each phase ends at a green-suite checkpoint (Vitest + Playwright + Python builders + constitution lint). + +## Verification surface + +Per spec FR-001..FR-021 and SC-001..SC-010: + +- **Functional**: `unit/` Vitest suite for stores + search + facet math; `e2e/` Playwright suite per user story. +- **Build-gate**: Python `unittest` against the data-package builders; link checker over the About page; deterministic-build assertion (same inputs → same shard SHAs modulo build info). +- **Performance**: a CI job runs Lighthouse against the production-deploy preview and asserts SC-001 (first interactive paint) + SC-006 (data-package size). Fails the deploy if either regresses by > 20 %. +- **Accepted-only invariant**: `e2e/accepted-only.spec.ts` scans the loaded shards for any `accepted_for == "Withdrawn"` and fails if found. The Python builder also asserts this at build time so the violation can't reach `dist/`. +- **Mobile**: Playwright runs the US1 smoke against a mobile-emulated viewport (360 × 640 — SC-004's stated minimum) and asserts no horizontal scroll on the home + detail screens. + +## Out of scope for this stage (v1) + +- Server-side anything (auth, accounts, persistent carts across devices). +- 3D-UMAP lasso (out per FR-006). +- Runtime dead-link handling on the About page (build-gated only). +- An SMTP/email service — email export is `mailto:` only. +- A user-supplied custom embedding model — only the built-in MiniLM-L6 ONNX. +- Multi-language UI — English only for v1. +- Analytics — no tracker beacons; the site is privacy-respecting. diff --git a/specs/008-ui-rewrite/quickstart.md b/specs/008-ui-rewrite/quickstart.md new file mode 100644 index 00000000..54d1dce0 --- /dev/null +++ b/specs/008-ui-rewrite/quickstart.md @@ -0,0 +1,260 @@ +# Phase 1 — Quickstart (Stage 6 UI Rewrite) + +End-to-end recipes for local development, build verification, and deployment. + +## Prerequisites + +```bash +# Python side (build the data package) +UV_CACHE_DIR=.uv-cache uv venv --python 3.14 .venv +uv pip install --python .venv/bin/python -e ".[ui,enrich]" + +# Node side (build + serve the site) +corepack enable +corepack prepare pnpm@9 --activate +cd site && pnpm install +``` + +Stage 1–4 outputs must exist on disk: + +- `data/primary/abstracts.json` (3,244 accepted records) +- `data/primary/authors.json` +- `data/primary/abstracts_enriched.sqlite` +- `data/primary/reference_metadata.json` +- `data/outputs/analysis/annotations__f0c51e80dc0e.sqlite` (Stage 4 rollup) +- `data/outputs/analysis/` (per-bundle topics.json files) +- `data/outputs/embeddings/minilm/title__f0c51e80dc0e/` (Stage 3 MiniLM bundle for corpus-vector quantization) + +## Local development loop + +```bash +# 1. Build the data package once (data/ inputs → site/static/data/ shards). +# The state-key is discovered at build time via `ohbm2026.ui_data.state_key`; the +# hardcoded value below is only convenient for local one-offs. +PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --references data/primary/reference_metadata.json \ + --rollup data/outputs/analysis/annotations__f0c51e80dc0e.sqlite \ + --analysis-root data/outputs/analysis \ + --minilm-bundle data/outputs/embeddings/minilm/title__f0c51e80dc0e \ + --references-yaml specs/008-ui-rewrite/contracts/references.yaml \ + --output site/static/data/ + +# Alternative: let the builder discover the latest rollup state-key automatically +# (this is what CI runs): +PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --references data/primary/reference_metadata.json \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --minilm-bundle "$(PYTHONPATH=src .venv/bin/python -m ohbm2026.ui_data.state_key minilm data/outputs/embeddings/minilm title)" \ + --references-yaml specs/008-ui-rewrite/contracts/references.yaml \ + --output site/static/data/ + +# 2. Serve the site with hot-reload (Vite dev server) +cd site && pnpm dev # opens http://localhost:5173 +``` + +Hot-reload picks up edits to `site/src/**`. Editing the data package requires re-running step 1. + +## Build verification + +```bash +# Python unit tests (data-package builders + link checker) +PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -p "test_ui_data*" 2>&1 | tail -3 + +# Site unit tests +cd site && pnpm test:unit -- --run + +# Site end-to-end tests (Playwright headless) +cd site && pnpm test:e2e --reporter=line + +# Production build (static-adapter output) +cd site && pnpm build # → site/build/ + +# Local preview of the production build +cd site && pnpm preview # serves site/build/ at http://localhost:4173 +``` + +## US1 smoke (first-paint verification) + +After running the site locally: + +```bash +# Open http://localhost:5173 in a fresh tab. +# Expected: +# - Search bar visible within 3s (SC-001). +# - Type "connectivity" → results appear within 500ms (SC-002). +# - Click a result → detail panel opens with poster_id as header (NOT submission_id). +# - Author list visible with affiliations (FR-003). +# - Check Network tab: no shard contains a `accepted_for == "Withdrawn"` record. +# - Resize browser to 360x640 — confirm no horizontal scroll on home + detail (SC-004 minimum). +# - Confirm the footer "build info" affordance renders the short SHA (FR-022 / SC-011). +``` + +## US2 smoke (2D UMAP + lasso + model switch) + +```bash +# Click "Map" tab → 2D UMAP loads (Plotly bundle lazy-loaded; check Network). +# Drag a lasso around a cluster of ~100 points. +# Expected: +# - Result list filters to lasso selection within 500ms (US2 acceptance #1). +# - Facet counts update to reflect the intersection. +# - Clear lasso → full corpus restored. +# Switch model dropdown from "neuroscape × abstract" to "voyage × claims". +# - UMAP coordinates change. +# - Lasso selection (by abstract id) persists across the switch (US2 acceptance #2). +``` + +## US3 smoke (search + typo tolerance) + +```bash +# Type "defautl mode netwrk" (2 typos). +# Expected: "default mode network" abstracts surface in the lexical results (US3 acceptance #1). +# Type "Smtih" in the author-search subfield. +# Expected: abstracts by "Smith" appear (US3 acceptance #3). +# Type "how the brain remembers faces" (no verbatim match). +# Expected: face-memory abstracts surface via semantic search (US3 acceptance — semantic-only path). +``` + +## US4 smoke (interactive facets) + +```bash +# Click "Methods = fMRI" in the facet sidebar. +# Expected: +# - Result list shrinks to fMRI abstracts (~1,840 of 3,244). +# - "Species" facet now shows only species that appear in fMRI abstracts. +# - "Brain Regions" + "Brain Networks" + other facets all recount accordingly. +# Lasso a region while the facet is active. +# - Result list = (lasso) ∩ (facet); facet counts reflect the intersection. +``` + +## US5 smoke (cart + email) + +```bash +# Click "add to list" on 3 abstracts from different facet-filtered views. +# Expected: +# - Cart badge shows "3". +# - Reload page → badge still shows "3" (localStorage persistence). +# Click "email my list". +# Expected: OS mail composer opens with: +# Subject: "OHBM 2026 — my abstracts (3)" +# Body: 3 lines, each with poster_id + title + permalink to /abstract/. +# Click "clear cart" → badge resets; localStorage cleared. +``` + +## US6 smoke (walkthrough) + +```bash +# Open the site in an incognito window. +# Expected: "Take the tour" CTA visible in the header; no modal auto-opens. +# Click "Take the tour". +# - Tour runs through 5+ stops: search bar, model selector, UMAP, facets, cart. +# - Each step has next/previous/skip controls. +# - Tour can be re-launched anytime from the "?" affordance. +# Reload the page after dismissing the tour. +# - Tour does NOT auto-launch again. +``` + +## US7 smoke (About page + link health) + +```bash +# Navigate to /about. +# Expected: +# - Top-of-page overview is ≤ 250 words (US7 acceptance #1). +# - Each collapsible deep-dive (Stages 1–4 + topics + UMAP) has clickable references. +# - Click any reference link → opens in a new tab (target=_blank, rel=noopener). +# Run the link checker locally before the build: +PYTHONPATH=src .venv/bin/python -m ohbm2026.ui_data.link_check \ + specs/008-ui-rewrite/contracts/references.yaml +# Expected: exit 0; "All references reachable: N URLs" +# If any URL is dead, the build fails (SC-007). +``` + +## US8 smoke (deploy + PR previews) + +US8 is the **first** Stage 6 PR (per Session-2026-05-17 sequencing). After it lands on `main`, every subsequent PR gets a live preview surfaced in the PR's **Deployments box** at the top of the PR (NOT as a bot comment in the conversation). + +```bash +# === First-PR verification (US8 itself) === +# Open a small PR containing only the workflows + the placeholder route +# (the "Stage 6 — under construction" page). +# Expected within 10 minutes: +# - The PR's "Deployments" box appears AT THE TOP of the PR (above the file +# diff, below the description). NOT in the conversation as a comment. +# - The box shows: "pr-preview- — Active" with a "View deployment" button. +# - Clicking "View deployment" loads https://.github.io//pr-/ +# and the placeholder page renders the manifest build_info. +# Push another commit to the same PR. +# - The SAME Deployments environment ("pr-preview-") updates in place +# (GitHub does NOT create a new environment per push). +# - No bot comment churn anywhere in the conversation. +# Close the PR (merge or reject). +# - Within 30 min: the preview directory is removed from gh-pages AND the +# Deployments box marks "pr-preview-" as "Inactive". +# - Visiting the preview URL returns 404. +# Merge US8 to main. +# - Production deploy runs; canonical GitHub Pages URL updates within 10 min. + +# === Subsequent-PR verification (US1–US7) === +# Open any PR touching site/ or src/ohbm2026/ui_data/. +# Expected within 10 minutes: +# - Same Deployments-box flow: top-of-PR "View deployment" link to the +# per-PR subdirectory; updates in place on subsequent commits; marks +# "Inactive" 30 min after PR close. +# - Preview directories from other open PRs are untouched. +# - Production deploys on merge to main only. +``` + +**Sanity check**: if you ever see a `peter-evans/find-comment` step in `.github/workflows/pr-preview.yml`, that's wrong — the contract uses the Deployments API surface (auto-populated from the workflow's `environment:` declaration), not a bot comment. Restore from `contracts/github-action.md`. + +## Common operations + +### Clean rebuild (force fresh data package) + +```bash +rm -rf site/static/data/ +# ...then re-run the build command above. +``` + +### Verify the accepted-only invariant + +```bash +PYTHONPATH=src .venv/bin/python -c " +import json +with open('site/static/data/abstracts.json') as f: + abstracts = json.load(f) +withdrawn = [a for a in abstracts if a.get('accepted_for') == 'Withdrawn'] +print(f'Withdrawn rows in abstracts.json: {len(withdrawn)}') +assert len(withdrawn) == 0, 'INVARIANT VIOLATION: withdrawn record found' +" +# Expected: 0 (any non-zero aborts the deploy) +``` + +### Update reference YAML before deploy + +```bash +# Edit the references list: +$EDITOR specs/008-ui-rewrite/contracts/references.yaml + +# Validate before push: +PYTHONPATH=src .venv/bin/python -m ohbm2026.ui_data.link_check \ + specs/008-ui-rewrite/contracts/references.yaml +``` + +### Inspect bundle sizes + +```bash +cd site && pnpm build +du -sh site/build/* | sort -h +du -sh site/static/data/* | sort -h +gzip -k site/static/data/abstracts.json && du -h site/static/data/abstracts.json.gz +``` + +Expected: total of `site/static/data/` ≤ 11 MB raw (≤ 8 MB gz first-paint per SC-006). diff --git a/specs/008-ui-rewrite/research.md b/specs/008-ui-rewrite/research.md new file mode 100644 index 00000000..2eacde40 --- /dev/null +++ b/specs/008-ui-rewrite/research.md @@ -0,0 +1,188 @@ +# Phase 0 — Research (Stage 6 UI Rewrite) + +This document captures the technology-choice rationale behind the Technical Context block of `plan.md`. Each open question from the spec or plan is resolved here in `Decision / Rationale / Alternatives considered` form. + +## R1 — Site framework: SvelteKit vs React-Vite vs Astro + +### Decision + +**SvelteKit 2 + Vite 5 in static-adapter mode (`@sveltejs/adapter-static`).** + +### Rationale + +- **Smallest framework runtime.** Svelte compiles components to direct DOM operations; the runtime is ~5 KB gzipped. React shipping with Vite or Next is ~50 KB gzipped (react + react-dom) before any product code. Across SC-001 (≤ 3 s first paint) and SC-006 (≤ 8 MB gz first-paint package), every saved KB matters. +- **Fully static output.** `adapter-static` produces a `dist/` of HTML + JS + assets that GitHub Pages can serve directly. No Node runtime, no edge functions, no server rendering required. +- **Per-route code-split.** SvelteKit's file-based routing lazy-loads each route's JS chunk. Plotly + the MiniLM ONNX runtime only download when the user opens the UMAP tab or runs a semantic query — a clean lazy-load contract that satisfies SC-006's first-paint cap. +- **TypeScript-first DX.** First-class TS support with `svelte-check` instead of two-step tsc + bundler glue. +- **Svelte stores are idiomatic** for the kinds of derived state this app has (search query → filtered ids → facet recounts → result list). + +### Alternatives considered + +- **React + Vite (without Next)** — familiar, large ecosystem, but the runtime cost is 10× Svelte's. Wouldn't fail the SCs, but burns budget that the data package or Plotly could use better. +- **Next.js** — server-rendering features are wasted on a static-only site; the App Router's complexity buys nothing here. Static-export mode works but feels off-label. +- **Astro** — excellent for content-heavy sites with islands of interactivity, but this app is interactivity-heavy throughout (search + UMAP + cart + facets all need reactivity on every page). Forcing every component to be an "island" complicates state sharing. +- **Plain Vanilla + Vite** — minimal runtime cost, but you'd hand-roll routing, state, and templating. Time-to-MVP penalty isn't worth the ~5 KB savings. + +## R2 — In-browser ML runtime + embedding model + +### Decision + +**`@xenova/transformers` (transformers.js) running the quantized `Xenova/all-MiniLM-L6-v2` ONNX checkpoint.** The model loads from the public Hugging Face CDN and is cached by the browser. Embedding inference for a query (single short string) runs in a **Web Worker** to keep the main thread responsive. + +### Rationale + +- **The user explicitly named MiniLM.** transformers.js's `Xenova/all-MiniLM-L6-v2` checkpoint is the canonical browser port; ~23 MB quantized, ~25 ms inference for a single query on a recent MacBook Air via WebGPU + WASM fallback. +- **CDN-hosted model = no repo bloat.** The 23 MB ONNX weights aren't bundled into our data package (which is gated to 11 MB gz total). +- **Pre-computed corpus vectors avoid running MiniLM 3,244× at runtime.** We compute corpus embeddings once at build time (using the Python `sentence-transformers` model already in our Stage 3 minilm bundle) and ship `[3244, 384]` int8-quantized vectors. At query time, the browser computes ONE 384-dim query embedding via transformers.js, then does cosine sim against the corpus matrix in JS — fast typed-array math. + +### Alternatives considered + +- **ONNX Runtime Web directly** — finer control but lots of boilerplate to load tokenizer + handle pooling. transformers.js wraps all of that. +- **TensorFlow.js with USE / similar** — larger runtime, larger model (USE-Lite is ~25 MB raw), and we'd need to rebuild corpus vectors with a different model. +- **Skip the in-browser embedding, send queries to a server** — kills the static-only architecture (SC-006 + privacy). Out. +- **Float16 / float32 corpus vectors** — int8 is 4× smaller (1.2 MB vs 4.8 MB) with negligible recall loss for our use; SC-006 demands the smaller footprint. + +## R3 — Walkthrough library + +### Decision + +**`shepherd.js`** (~18 KB gz). + +### Rationale + +- **Step model fits our needs**: anchored highlight + tooltip + next/prev/skip. Out of the box. +- **No theme dependency.** intro.js bundles a CSS theme; shepherd.js is theme-agnostic and lets us match the rest of the site's CSS. +- **Active maintenance.** Both libs are maintained, but shepherd.js gets more contributors per year and supports Svelte via official examples. + +### Alternatives considered + +- **intro.js** — also fine, ~14 KB gz. The bundled theme conflicts with our minimal palette; we'd override most of it anyway. +- **Custom-rolled with Svelte transitions + Floating UI** — would shave another ~10 KB but adds ~1–2 days of UX work. Not worth it given the rest of the budget already fits. + +## R4 — Plotly bundle for 2D + 3D UMAP + +### Decision + +**Plotly.js "basic" + "gl3d" custom bundle** (`scatter` + `scattergl` + `scatter3d` + `lasso` traces). ~700 KB gz. Lazy-loaded only when the user opens the projections tab. + +### Rationale + +- **Lasso is non-trivial elsewhere.** Plotly's lasso event is well-tested across browsers; rolling our own with D3 + a polygon-in-point predicate is doable but adds risk and dev time. +- **3D is free.** The `gl3d` bundle adds rotate/pan/zoom for `scatter3d` traces with no extra code. +- **Lazy-loaded.** Visiting the home page without opening UMAP never downloads Plotly. SvelteKit's dynamic `import()` handles this. + +### Alternatives considered + +- **deck.gl** — better at million-point scale, but our 3,244-point scatter is well within Plotly's sweet spot, and deck.gl's lasso requires extra plumbing (deck.gl-extensions/lasso). +- **D3 + custom 2D + Three.js for 3D** — finest control, largest dev cost, would lose lasso reliability. + +## R5 — Lexical search: index format + edit-distance strategy + +### Decision + +- **Build time (Python)**: tokenize each abstract's title + sections + keywords + methods + author names into normalized tokens (lowercase, NFC-normalized, accent-folded). For each token, emit **trigrams** (3-character sliding windows). Build an inverted index `{trigram → [token_id], token → {abstract_ids, surface_form}}`. Serialize as a single compact JSON: `lexical_index.json` ≤ 500 KB gz. +- **Query time (browser)**: trigram the user's query into candidate token-id buckets; gather candidates whose trigram overlap with the query ≥ a threshold (≥ 60 %); then run **Damerau-Levenshtein** distance ≤ 2 on those candidates (≤ 1 for words of length < 4). Surface abstracts via posting lists. + +### Rationale + +- **Trigram pre-filter → DL distance** is the canonical approach (used by PostgreSQL `pg_trgm`, Lucene's `FuzzyQuery`, Elasticsearch's `fuzzy` mode). Lets us run real edit-distance on ≤ 100 candidate tokens per query instead of all ~50 K corpus tokens. +- **Pre-built index** means the browser doesn't pay tokenization cost on cold start. +- **500 KB gz budget** is comfortably feasible: 50 K tokens × ~5 trigrams each × 6 bytes per (trigram, token_id) pair ≈ 1.5 MB raw → ~400 KB gz with json + varint-style postings. + +### Alternatives considered + +- **MiniSearch / Fuse.js / Lunr.js** — runtime indexers; would force the browser to index 22 MB of abstract text on cold start. Too slow for SC-001. +- **FlexSearch encoded with `tolerant` mode** — ships as a library and provides fast fuzzy; but the resulting in-memory index is ~3 MB after `add()`, and we'd pay that on every cold start. +- **BK-tree on full token vocab** — beautiful data structure but adds CPU cost per query. Trigram pre-filter is simpler and faster at our scale. + +## R6 — Author de-duplication + affiliation normalization + +### Decision + +- **De-duplication key**: `lower(NFC(full_name)) + "|" + lower(NFC(primary_affiliation))`. Same name + same primary affiliation → one author record. Different affiliation → different author record (intentional; "Jane Smith @ Stanford" and "Jane Smith @ MIT" are reasonably-distinct people). +- **Affiliation normalization**: trim, collapse whitespace, fold "&" → "and", remove trailing punctuation. NO heuristic merging of "Stanford University" vs "Stanford U." vs "Stanford" — those stay as 3 affiliations. +- **Storage**: each author record carries an `affiliations: list[str]` (ordered as on the abstract) and `abstract_ids: list[int]` (the abstracts they appear on, in chronological-id order). + +### Rationale + +- We're shipping organizer-facing data, not running an author-disambiguation research project. Conservative de-dup keeps the data honest about what was actually submitted; aggressive merging risks combining two real people. +- The submitter is the authoritative source for "is this the same person?" — we trust them. + +### Alternatives considered + +- **OpenAlex author IDs** — our `reference_metadata.json` carries some, but coverage is partial; matching by name alone risks false merges. Defer to v2 if the project decides to integrate OpenAlex author resolution. +- **String-similarity merging (Jaro-Winkler ≥ 0.95)** — too fuzzy; common surnames + abbreviated affiliations create cross-merges. + +## R7 — Data-package: per-shard format details + +### Decision + +- **`abstracts.json`** — JSON array, not NDJSON. We accept the upfront cost of one big parse because (a) it's < 6 MB gz, (b) the browser's native `JSON.parse` is heavily optimized for arrays of similar-shape objects, and (c) NDJSON would require a custom parser and gain little here. +- **Per-cell `cells/_.json`** — JSON arrays, indexed positionally to `abstracts.json` by `abstract_id`. The client builds a `Map` once on load. +- **`minilm_vectors.bin`** — raw little-endian int8 buffer. No header — we know shape `[3244, 384]` from the manifest. Saves ~5 KB of header bytes and simplifies the client decoder to a `new Int8Array(buf)`. + +### Rationale + +- **JSON over Parquet** — re-locked-in by the Session-2026-05-17 clarification. Parquet would require a JS reader (~50 KB gz) and decoder time that JSON.parse avoids. +- **Plain `Int8Array` for vectors** — Web's typed arrays are zero-copy from `fetch().arrayBuffer()`; the client never builds a JS array of 384 × 3,244 numbers. + +### Alternatives considered + +- **Apache Arrow IPC files** — columnar, compressed, lazy column reads. Arrow JS is ~80 KB gz. Worth it at much larger scale (millions of rows); at 3,244 rows the JSON path beats it on cold-start latency. +- **JSON Lines (NDJSON) streamed** — wins on streaming-parse for huge files; doesn't pay off below ~50 MB. + +## R8 — GitHub Action: deploy + PR-preview architecture + +### Decision + +- **`deploy-ui.yml`** (trigger: `push` to `main`): + - Step 1: Set up Python 3.14 + uv; install `[ui]` extras (the new optional-extra to be added in pyproject.toml — `numpy`, `sentence-transformers` for the build-time vector generator). + - Step 2: Set up Node 20 + pnpm; install `site/` deps. + - Step 3: Run `scripts/build_ui_data.py --output site/static/data/`. Produces all shards. + - Step 4: Run `cd site && pnpm test:unit && pnpm test:e2e` (Vitest + Playwright). Test gate. + - Step 5: Run `cd site && pnpm build`. Emits `site/build/` (SvelteKit's static-adapter output). + - Step 6: Run the link checker against the About page (Python `link_check.py` against the deployed-but-not-yet-published HTML). + - Step 7: Use `peaceiris/actions-gh-pages@v3` to push `site/build/` to the `gh-pages` branch's root. +- **`pr-preview.yml`** (trigger: `pull_request` opened/synchronize): + - Same Steps 1–6 as deploy. + - Step 7: Push `site/build/` to the `gh-pages` branch under `/pr-/` (NOT replacing root). Use a deploy action that supports `keep_files: true` + `destination_dir: pr-`. + - **No PR-conversation comment step.** Instead, the workflow declares `environment: { name: pr-preview-, url: }`. GitHub auto-populates the PR's top-of-PR **Deployments box** from the environment, and re-uses the same environment on subsequent pushes (no churn). This is the Session-2026-05-17 clarification. +- **`pr-preview-cleanup.yml`** (trigger: `pull_request` closed): delete `/pr-/` directory from `gh-pages` AND use `actions/github-script@v7` to mark every deployment for the `pr-preview-` environment as `state: "inactive"` via the Deployments API. The Deployments box then shows the deployment as "Inactive" with no live URL. + +### Rationale + +- **`peaceiris/actions-gh-pages`** is the de-facto GitHub Action for the "push to gh-pages branch" pattern; it handles `.nojekyll`, supports `keep_files`, and is widely audited. +- **One workflow per event** keeps the failure modes legible. The deploy workflow only runs on `main`; the preview workflow only runs on PRs. +- **Deployments-box surface over bot comments.** Per Session-2026-05-17: a workflow `environment:` declaration drives the GitHub Deployments API automatically, so the URL appears in the top-of-PR Deployments box and updates in place on each push. No `peter-evans/find-comment` / `create-or-update-comment` step is needed — the native Deployments surface avoids comment churn entirely and gives reviewers a one-click "View deployment" affordance in a stable PR location. + +### Alternatives considered + +- **One mega-workflow with branching logic** — concentrates failure surface; less legible. The 3-workflow split is GitHub's recommended pattern. +- **Cloudflare Pages / Vercel previews** — they offer turnkey previews but require setting up a separate hosting account, which the spec rules out (GitHub Pages only). Stays in scope. +- **`actions/deploy-pages`** (the official action) — only supports deploying to the root, not to subdirectories. Wrong tool for PR previews. + +## R9 — About page: reference list + link-check architecture + +### Decision + +The About page references live as a **YAML registry** at `specs/008-ui-rewrite/contracts/references.yaml` (a content-only artifact tracked in source). The Python `link_check.py` builder reads the registry at build time, issues an HTTP HEAD against each URL, and fails the build if any URL returns a non-2xx status. The site's `+page.svelte` for `/about` imports the registry at compile time and renders the deep-dive sections from it. + +### Rationale + +- **Single source of truth.** References live in one file; the site renders them and the link checker validates them. Adding a new reference is one PR. +- **YAML, not JSON.** Human-edited, multi-line citations, comments allowed. The build step parses YAML once and emits the rendered HTML. +- **Build-time check, not runtime.** Catches dead links before deploy. The spec rules out runtime handling (FR-017 + SC-007). + +### Alternatives considered + +- **Inline JSX/Svelte literals** — hardcoded references in the component. Adding a reference touches multiple files; the link checker has to parse JSX. Worse DX. +- **External CrossRef / OpenAlex API** — fetches reference metadata live. Adds API rate limits + a build-time external dependency. Out for v1. + +## R10 — Open questions deferred to implementation + +These are decisions that don't change the spec contract or the plan's structure; they're left for the implementer: + +- **Cart UX**: drawer from the right, modal, or full-page route? Will land during US5 based on the wireframe. +- **Tour copy**: per-step text. Drafted by the implementer; reviewed by the user before deploy. +- **Mobile UMAP**: in `≤ 1024 px`, lasso is replaced with "tap to filter by community" (per FR-005 edge case). The exact tap target — a single point's community vs the union of neighbors — is a UX detail not a spec contract. +- **Diacritic handling for author search**: NFC-normalize + accent-fold ("á" → "a"). Standard practice. diff --git a/specs/008-ui-rewrite/spec.md b/specs/008-ui-rewrite/spec.md new file mode 100644 index 00000000..44708ec9 --- /dev/null +++ b/specs/008-ui-rewrite/spec.md @@ -0,0 +1,268 @@ +# Feature Specification: UI Rewrite — Static Search Site for the OHBM 2026 Corpus + +**Feature Branch**: `008-ui-rewrite` +**Created**: 2026-05-17 +**Status**: Draft +**Input**: User description: "Build the UI as a site served by GitHub Pages, built by a GitHub Action with PR-preview management. Replace the UI with a modern framework. Content: model selection (default = neuroscape, abstract); 2D + 3D UMAP with lasso selection on the 2D view; semantic + lexical search (with typo tolerance for lexical/author search); abstract details with extra questions limited to topics and methods; facets that update interactively from selection/search; an optional walkthrough for new users; an About page describing how the data were processed (for a general neuroscientist, with collapsible deep-dive details and verified references); a minimal data package that contains abstract details + model-configuration outputs; responsive on phone/tablet; abstracts keyed by poster id (not submission id), with author details restored; accepted abstracts only — no withdrawn; references open in a new tab; users can add search results to a shopping cart and open an email editor pre-populated with the cart contents." + +## Clarifications + +### Session 2026-05-17 + +- Q: Storage engine for the UI data package — DuckDB-WASM, static JSON shards, or a hybrid? → A: **Static JSON shards (no DuckDB-WASM).** At 3,244 abstracts × 15 (model, input) cells the entire dataset is ~9 MB gzipped, fits in client memory, and in-memory `Array.filter`/intersection runs in <10 ms. DuckDB-WASM's ~3.5 MB runtime overhead buys no measurable win at this scale and adds a WASM dependency the rest of the site doesn't need. The site is fully client-side static + per-shard CDN-cacheable JSON; facet aggregations are computed in JS over the in-memory abstract array; the schema is documented under FR-019. +- Q: Delivery sequencing + how preview URLs surface on a PR? → A: **Ship the deploy + PR-preview GitHub Actions in a small first PR (US8 first); use the GitHub Deployments / environment surface (top-of-PR Deployments box) rather than a bot comment in the conversation.** Rationale: once the workflows are merged, every subsequent PR for US1–US7 automatically gets a live preview at the top of the PR — reviewers can click "View deployment" without scrolling through commit history. The first PR ships a minimal placeholder site ("Stage 6 — under construction" + the empty data-package builder skeleton) so the preview is non-empty and the workflows are exercised end-to-end. See FR-021 + contracts/github-action.md for the environment-based deploy mechanism. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 — Find and read accepted-poster details from any device (Priority: P1) 🎯 MVP + +A neuroscientist heading to OHBM 2026 lands on the site from a phone, tablet, or laptop. They search by keyword, author, or topic and read the full details of relevant accepted posters — including author names + affiliations and the canonical poster id assigned by the program. Withdrawn submissions are not surfaced anywhere. + +**Why this priority**: This is the irreducible value the site offers. Without working search + abstract detail on every device, every other feature (UMAP, cart, walkthrough) is dressing on something nobody can actually consume. + +**Independent Test**: From a fresh device, navigate to the site URL, search for a known abstract author or keyword, click into a result, and confirm the panel shows: title; poster id (formatted as the program assigned it, not as a submission id); ordered author list with affiliations; abstract sections (Introduction, Methods, Results, Conclusion); and "Topics" + "Methods" structured-question values. Verify no withdrawn abstracts appear in any list, search, or projection at any time. + +**Acceptance Scenarios**: + +1. **Given** a phone in portrait orientation, **When** the user opens the site and types `"connectivity"`, **Then** the search returns matching accepted abstracts within 1 second; each result card shows poster id, title, lead author + affiliation, primary topic; tapping a card opens the detail panel as a full-screen overlay readable without horizontal scrolling. +2. **Given** the corpus contains 3,244 accepted abstracts + ~166 withdrawn submissions, **When** the user performs any search, filter, or projection, **Then** zero withdrawn abstracts appear in any UI surface; the visible total never exceeds the accepted-only count. +3. **Given** an abstract whose poster id is `M-AM-101` and submission id is `49213`, **When** the user opens that abstract's detail panel, **Then** the panel header displays `M-AM-101` as the primary identifier; the submission id is not shown anywhere. +4. **Given** an abstract with 12 listed authors, **When** the user opens the detail panel, **Then** all 12 authors appear in submission order with their affiliations; mobile view collapses the list under a "show all 12 authors" toggle when it exceeds the viewport. + +--- + +### User Story 2 — Explore the 2D semantic map with lasso selection (Priority: P2) + +A reviewer wants to see the global structure of the accepted corpus. They open the 2D UMAP, pan/zoom, and lasso a region of dots to drill into a thematic cluster. The result list, facets, and details panel update to reflect the lasso selection. They can switch the underlying model (e.g., voyage abstract → neuroscape claims) to see how the layout changes. + +**Why this priority**: The UMAP is the visual hook that distinguishes this site from a vanilla search interface. Lasso is the existing user gesture from the prior UI — preserving it keeps the muscle memory intact. + +**Independent Test**: Open the projections panel, draw a lasso around 50–200 points on the 2D UMAP, confirm the result list shrinks to those points, the facet counts update accordingly, and clearing the lasso restores the full corpus. Then switch model from default `neuroscape / abstract` to `voyage / claims` and verify the point positions change while the lasso selection-by-id is preserved (the same abstracts stay selected; only their geometric positions move). + +**Acceptance Scenarios**: + +1. **Given** the user is on the 2D UMAP, **When** they drag a lasso around a cluster, **Then** the result count, facet counts, and detail-panel "selected" state all update within 500 ms; clearing the lasso returns the previous global state. +2. **Given** the user has selected `voyage / abstract` from the model dropdown, **When** they switch to `neuroscape / claims`, **Then** the 2D and 3D UMAPs re-render with the new model's coordinates; the lasso selection (by abstract id) persists. +3. **Given** the 3D UMAP is displayed, **When** the user interacts with it, **Then** the user can rotate, pan, and zoom; the 3D view does NOT support lasso (3D lasso is out of scope; only the 2D view supports it). + +--- + +### User Story 3 — Semantic + lexical search with typo tolerance (Priority: P2) + +A user types a search query. The system runs **both** a semantic match (a sentence embedding compared against the corpus) AND a lexical match (with typo tolerance) and merges results. Author search treats common name misspellings (e.g., one transposed letter, one missing letter) as matches. + +**Why this priority**: Search quality is what distinguishes a useful site from a slow filter. Semantic + lexical together gives both "I know roughly what I'm looking for" and "I know the exact phrase / surname" coverage. + +**Independent Test**: Type the query `"defautl mode netwrk"` (two typos) — confirm the lexical matcher still surfaces "default mode network" abstracts. Type the surname `"Smtih"` (1 transposition) and verify abstracts by `"Smith"` appear in author search. Type a phrase that doesn't appear verbatim in any abstract (e.g., `"how the brain remembers faces"`) and confirm the semantic search surfaces face-memory-related abstracts. + +**Acceptance Scenarios**: + +1. **Given** the user types a 3+ character query, **When** the lexical search runs, **Then** matches within Damerau-Levenshtein distance 2 are surfaced for words ≥4 characters (1-edit for shorter words); typo-tolerance is on by default. +2. **Given** the user types text in the search box, **When** results appear, **Then** semantic-only matches are visually distinguished from lexical/exact matches (e.g., badge or section header), and the user can filter to "semantic only" / "lexical only" / "both". +3. **Given** the user types text into the author-search field, **When** results appear, **Then** the field tolerates 1 typo for short surnames (≤4 chars) and 2 typos for longer names; matching authors and their abstracts are returned. + +--- + +### User Story 4 — Interactive facets that follow selection (Priority: P3) + +A user filters by faceted dimensions (accepted-for, primary topic, secondary topic, keywords, methods, study type, population, field strength, processing packages, species, recording technology, brain regions, brain networks). When they apply a facet filter OR lasso a UMAP region OR run a search, the facet counts re-compute so that each remaining option reflects only what's reachable from the current selection. + +**Why this priority**: Faceted exploration is how organizers + reviewers narrow down 3,244 abstracts to a manageable shortlist. The "facets update with selection" behavior is what separates a usable filter from a frustrating one. + +**Independent Test**: Apply `Methods = fMRI` and confirm the `Species` facet now shows only species that appear in fMRI abstracts (e.g., Human × 1,840; Macaque × 24). Lasso a region of the UMAP and confirm the facet counts contract further. Clear filters and counts return to the corpus totals. + +**Acceptance Scenarios**: + +1. **Given** the user applies a facet filter, **When** the result list updates, **Then** every other facet's per-option count updates to reflect only the remaining reachable abstracts (combined-filter counts, not absolute). +2. **Given** the user has lassoed a UMAP region, **When** they then click a facet option, **Then** the result is the intersection (lasso ∩ facet); both visual surfaces (UMAP highlight + result list) reflect the intersection. + +--- + +### User Story 5 — Save abstracts to a cart and email the list (Priority: P3) + +A user finds 8 abstracts of interest while browsing. They click "add to my list" on each. The cart icon shows `8`. They click "email my list" — a system-native email composer opens (mail client launches with a pre-filled message) containing the list of poster ids + titles + per-abstract permalinks back to this site. + +**Why this priority**: Reviewers + meeting planners frequently triage abstracts and want to share their picks with collaborators. Today they paste links into Slack/email by hand; the cart shortens that loop to one click. + +**Independent Test**: Add 3 abstracts to the cart from different facet-filtered views, click "email my list", verify the OS mail composer opens with the body listing the 3 poster ids + titles + permalinks; the cart can be cleared or items removed individually before emailing. + +**Acceptance Scenarios**: + +1. **Given** the cart is empty, **When** the user clicks "add to list" on an abstract card, **Then** the cart badge increments to 1 and the abstract id is persisted across page reloads (within the same browser). +2. **Given** the cart has items, **When** the user clicks "email my list", **Then** the OS mail composer opens with a subject like `"OHBM 2026 — my abstracts (N)"` and a body containing one line per item: poster id, title, link back to the site's abstract anchor. +3. **Given** the user is on a mobile device, **When** they trigger "email my list", **Then** the mobile mail client launches (iOS Mail / Gmail / Outlook depending on default handler). + +--- + +### User Story 6 — Optional guided walkthrough for first-time visitors (Priority: P4) + +A first-time visitor lands and sees an unobtrusive "Take the tour" button. If they click it, an overlay walks them through the search box, the UMAP, the model selector, the facet sidebar, and the cart — each step highlighting the relevant UI element. If they ignore the button, nothing else nags them. + +**Why this priority**: New visitors to a search UI with this much surface area get lost; existing UI feedback shows people miss the lasso + model selector. A skippable tour solves that without forcing onboarding on returning users. + +**Independent Test**: Open the site in an incognito window, confirm the "Take the tour" call-to-action is visible but unobtrusive (small button or banner, dismissible). Click it; verify each step highlights the right region and a "next / previous / skip" UI is present. Reload the page after dismissing the tour; verify it doesn't auto-launch again unless the user explicitly clicks "restart tour". + +**Acceptance Scenarios**: + +1. **Given** the user has never visited the site, **When** they land on the home page, **Then** a "Take the tour" CTA is visible (e.g., header button or one-time banner) but no modal auto-opens. +2. **Given** the user clicks "Take the tour", **When** the tour runs, **Then** it visits ≥ 5 stops (search, model selector, UMAP, facets, cart) with clear "next / previous / skip" controls; the tour can be re-launched anytime from a "?" or "help" affordance. + +--- + +### User Story 7 — About page with verified references for the data-processing methods (Priority: P4) + +A general neuroscientist clicks "About" to understand how this corpus was built. They get a one-paragraph overview, then collapsible sections that drill into each stage (corpus ingestion → enrichment → embeddings → analysis & annotation). Each section's claims about methods cite verifiable references (textbook chapters, published methods papers, software docs). External reference links open in a new tab. + +**Why this priority**: Without this, the site is opaque about its provenance — and reviewers + organizers need to trust the pipeline before they trust the rankings/clusters. Putting the explanation behind progressive disclosure keeps the front page focused on browsing while giving the deeper details to those who want them. + +**Independent Test**: Open the About page, confirm the top section is readable in under 2 minutes by a non-specialist. Expand each collapsible deep-dive section and confirm every methods claim links to a real, accessible reference (e.g., UMAP paper, Leiden paper, HDBSCAN paper, NeuroScape paper, Voyage AI docs). Clicking any external link opens it in a new tab. + +**Acceptance Scenarios**: + +1. **Given** the user opens the About page, **When** they read the top section, **Then** the overview is ≤ 250 words and uses no jargon beyond "embedding", "cluster", and "UMAP" (each defined inline at first mention). +2. **Given** the user expands the "Embedding models" deep dive, **When** they click any reference link, **Then** the link points at a real reachable URL (no 404s; verified at build time) and opens in a new browser tab (`target="_blank"` + `rel="noopener noreferrer"`). +3. **Given** the deep dives cover Stages 1–4, **When** the user reads each, **Then** each stage references the canonical published method paper (corpus fetch: Oxford Abstracts GraphQL — vendor docs; figure interpretation: GPT-4-vision-class reference; claim extraction: the ECO ontology paper; references: OpenAlex; embeddings: model-card / paper for each of voyage / minilm / openai / pubmedbert / neuroscape; analysis: UMAP, Leiden CPM, HDBSCAN, FAISS; topics: BERTopic + spaCy). + +--- + +### User Story 8 — Deploy continuously via GitHub Actions with per-PR previews (Priority: P5) + +A maintainer pushes a PR that touches the UI or the data package. A GitHub Action builds the site and publishes a preview URL (e.g., `https://.github.io//pr-/`) that's commented on the PR. When the PR merges, the action redeploys the main GitHub Pages site. When the PR closes (merged or not), the preview is cleaned up. + +**Why this priority**: Without preview deploys, reviewers test changes locally — slow, error-prone, and inconsistent. Per-PR previews close the design-review loop in minutes. + +**Independent Test**: Open a draft PR that changes a UI file; confirm within 10 minutes the **PR's Deployments box** (top-of-PR, NOT the conversation) shows a "View deployment" link for the `pr-preview-` environment; click it and verify the change is live; close the PR; verify within an hour the Deployments box marks the deployment "Inactive" and the URL returns 404 (cleaned up). Merge a different PR to main; confirm the production site updates within 10 minutes of the merge. + +**Acceptance Scenarios**: + +1. **Given** a PR is opened, **When** the GitHub Action runs, **Then** the preview URL surfaces in the PR's **Deployments box** within 10 minutes (the workflow declares `environment: { name: pr-preview-, url: ... }` so GitHub auto-creates the deployment entry); subsequent commits update the same environment URL in place (no environment churn, no conversation spam). +2. **Given** a PR is closed (merged or rejected), **When** the close event fires, **Then** the preview directory is removed from the gh-pages branch AND the `pr-preview-` deployment is marked **inactive** via the Deployments API within 30 minutes; the preview URL returns 404; the Deployments box shows the deployment in the inactive state. +3. **Given** a merge to `main` occurs, **When** the deploy workflow completes, **Then** the production site at the canonical GitHub Pages URL reflects the merged changes; preview directories from other open PRs are not disturbed. +4. **Given** the first delivery of Stage 6, **When** US8 is shipped as a small first PR with a placeholder site (e.g., "Stage 6 — under construction" landing page + the empty data-package builder skeleton), **Then** the workflows are exercised end-to-end and reviewers can use the live PR-preview deployment on subsequent PRs (US1–US7) to evaluate UI changes before merge. + +--- + +### Edge Cases + +- **Empty search** — when the search box is empty, the site shows the full corpus state (no implicit filter). Clearing all filters returns to the same global state. +- **Single result** — when search + facets narrow to a single abstract, the detail panel auto-expands. If the user then clears the filter, the panel collapses but the cart contents persist. +- **Mobile lasso** — the 2D lasso gesture requires a click-drag that conflicts with mobile pan. On mobile, the lasso is replaced by a "select cluster" tap mode (tap a point → select its containing community by community-id); the lasso re-enables when the viewport is ≥ 1024 px wide. +- **Empty cart email** — clicking "email my list" with an empty cart shows a toast `"Add abstracts first"` instead of opening an empty composer. +- **Mail client unavailable** — if the user's environment has no default mail handler (some Linux desktop / kiosk setups), clicking "email my list" falls back to displaying the email body in a modal with a "copy to clipboard" button. +- **Typo tolerance + short queries** — for queries < 3 characters, typo tolerance is disabled (otherwise every 3-letter abbreviation matches half the corpus). The threshold is documented in the help tooltip. +- **3D UMAP on low-end devices** — the 3D view may stutter on older phones; users on such devices can switch to the 2D tab manually. Automatic FPS-based degradation is out of scope for v1. +- **About-page external link breakage** — if a reference URL becomes a 404, the build action fails noisily so the link is fixed before deploy. (Out-of-scope: handling dead links at runtime.) +- **PR preview collisions** — two PRs touching the same files publish to distinct preview URLs (`pr-`); they never overwrite each other. +- **Walkthrough on small screens** — the tour layout adapts: on phones the highlight + tooltip stack vertically rather than side-by-side. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001 (Audience scope)**: The site MUST show only **accepted** abstracts (`accepted_for ≠ "Withdrawn"`). Withdrawn submissions MUST NOT appear in any search, projection, facet, cart, or detail surface. +- **FR-002 (Poster id)**: Every abstract reference (URL fragment, cart item, detail header, list card) MUST use the program-assigned **poster id** as the user-facing identifier. The submission id is internal only and MUST NOT be displayed in the UI. +- **FR-003 (Authors)**: Every abstract detail panel MUST include the ordered author list with each author's affiliation. The data package MUST include author records joined to abstracts. (The current UI omits this; restoring it is part of this stage.) +- **FR-004 (Model selection)**: The user MUST be able to choose from the 5 models (`voyage`, `minilm`, `openai`, `pubmedbert`, `neuroscape`) crossed with the 3 inputs (`abstract`, `claims`, `methods`). Default selection is `neuroscape` × `abstract`. The 2D/3D UMAPs, lasso semantics, and (where applicable) facet/clustering counts reflect the selected (model, input) cell. +- **FR-005 (2D UMAP + lasso)**: The user MUST be able to view a 2D UMAP scatterplot, pan/zoom, and lasso-select a region. The lasso selection becomes the active filter (intersected with search + facets). +- **FR-006 (3D UMAP)**: The user MUST be able to view a 3D UMAP. The 3D view supports rotate / pan / zoom but NOT lasso (out of scope for v1). +- **FR-007 (Semantic search)**: The user MUST be able to type a free-text query and receive abstracts ranked by semantic similarity. The semantic similarity MUST be computed client-side (no server roundtrip) using a published sentence-embedding model that runs in the browser. +- **FR-008 (Lexical search)**: The user MUST be able to perform a lexical search with typo tolerance (Damerau-Levenshtein distance ≤ 2 for words ≥ 4 characters; ≤ 1 for shorter). Lexical matches surface across abstract title, sections, keywords, methods, and author names. +- **FR-009 (Search-result merging)**: Semantic + lexical results MUST be merged into a single ranked list; the user MUST be able to filter to "semantic only", "lexical only", or "both" via a visible control. +- **FR-010 (Author search with typo tolerance)**: The author-search input MUST tolerate 1 typo for surnames ≤ 4 characters and ≤ 2 typos for longer surnames. Diacritics MUST be matched case- and accent-insensitively (e.g., `"García" ≈ "Garcia"`). +- **FR-011 (Detail panel — extra questions scope)**: The abstract detail panel MUST display only two "extra question" fields from the submission form: **Topics** (Primary + Secondary Parent Category & Sub-Category) and **Methods** (the methods-checklist question). All other submission-form questions MUST NOT be shown in the detail panel. +- **FR-012 (References open externally)**: When an abstract carries reference links (DOI / external URL via OpenAlex), each link MUST open in a **new browser tab/window** with `rel="noopener noreferrer"` semantics. +- **FR-013 (Interactive facets)**: The facet sidebar MUST recompute every facet's per-option counts whenever the active selection changes (any combination of: search query, facet filter, UMAP lasso). Counts MUST reflect the intersection of all active filters. +- **FR-014 (Shopping cart)**: The user MUST be able to add any abstract to a cart from any result surface (list card, detail panel, search result). The cart MUST persist across page reloads in the same browser. The user MUST be able to remove individual items or clear the cart. +- **FR-015 (Email-my-list)**: The user MUST be able to launch an "email my list" action that opens the OS mail composer (mailto: link) with a pre-filled body listing each cart item's poster id, title, and a permalink back to the site's abstract anchor. If no mail handler is registered, the site MUST fall back to a copy-to-clipboard modal. +- **FR-016 (Walkthrough)**: The site MUST offer a discoverable "Take the tour" affordance. The tour MUST NOT auto-launch for returning users; it MUST be re-launchable from a persistent help affordance. +- **FR-017 (About page)**: The site MUST include an About page with a ≤ 250-word non-specialist overview followed by per-stage collapsible deep-dives (Stages 1–4 plus topics + UMAP). Every methods claim MUST link to a real, accessible external reference; build-time link validation MUST fail the deploy if any link is broken. +- **FR-018 (Responsive layout)**: The site MUST render usably on phones (≥ 360 px wide), tablets (≥ 768 px wide), and desktops (≥ 1024 px wide). Result lists, detail panels, facets, and the UMAP layout MUST adapt; the lasso gesture is desktop-only (see Edge Cases). +- **FR-019 (Data package — static JSON shards)**: The deployed data package MUST be a set of **static JSON shards** organized abstract-centric — no DuckDB-WASM, no Parquet, no client-side query engine. The canonical layout is: + - `data/manifest.json` (≤ 5 KB) — corpus state-key, code revision, build timestamp, shard URL pointers, facet keys + ordered options, and the (model, input) cell catalog discovered from the Stage 4 rollup at build time. Loaded first; everything else lazy-loaded off of it. + - `data/abstracts.json` (≤ 6 MB gz) — array of 3,244 accepted-only records, each `{abstract_id, poster_id, title, accepted_for, sections: {introduction, methods, results, conclusion}, references: [{text, doi?, url?}], topics: {primary, secondary, subcategories}, methods_checklist, author_ids: [int]}`. Stripped of `submission_id` everywhere; withdrawn rows excluded at build time. + - `data/authors.json` (≤ 1.5 MB gz) — array of unique author records `{author_id, name, affiliations: [str], abstract_ids: [int]}`. Loaded once at startup; joined to abstracts in-memory via `author_ids`. + - `data/cells/_.json` (15 files, each ≤ 100 KB gz) — per-cell coordinate + cluster table indexed by `abstract_id`: `[{abstract_id, umap2d: [x,y], umap3d: [x,y,z], community_id, topic_cluster_id, neuroscape_cluster_id?, neuroscape_cluster_distance?}, …]`. Lazy-loaded on demand; the default `neuroscape_abstract` cell is fetched at startup, the other 14 only when the user selects them. + - `data/topics/__.json` (≤ 45 files, each ≤ 30 KB gz) — per-cluster topic metadata: `{cluster_id, keywords: [str], title, description, focus}`. Lazy-loaded alongside the matching cell. `` ∈ {communities, neuroscape_clusters, topic_clusters}. + - `data/search/lexical_index.json` (≤ 500 KB gz) — pre-built inverted index for typo-tolerant lexical search (n-gram bag per token, mapped to `abstract_id` postings lists). Built once at deploy time so the browser doesn't pay the indexing cost. + - `data/search/minilm_vectors.bin` (≤ 1.5 MB) — int8-quantized MiniLM-L6 embeddings for the 3,244 accepted abstracts, fixed shape `[3244, 384]` little-endian. Lazy-loaded on first semantic query. + - The MiniLM ONNX model itself is served from a public CDN (e.g., Hugging Face) and cached by the browser; it is NOT bundled into this data package. + - All shards MUST embed a `build_info` block: `{corpus_state_key, code_revision, code_revision_short, stage4_rollup_state_key, built_at}` — the same block the footer's "build info" affordance exposes (CA-008) and which carries the short committish surfaced per FR-022. Raw-array JSON shards are forbidden (data-model.md §8 invariant 6); each shard is an object envelope. The `minilm_vectors.bin` carries its build_info via a co-located `minilm_vectors.build_info.json` sidecar. + - Shard fetches are parallel where independent (manifest + abstracts.json + authors.json + default cell + default topics start in parallel right after manifest resolves). +- **FR-020 (GitHub Pages deploy)**: A GitHub Action MUST build and deploy the site to GitHub Pages on every merge to `main`. The production URL is the canonical GitHub Pages root for the repository. +- **FR-021 (PR previews via GitHub Deployments)**: Every open PR MUST receive a preview deploy at a distinct URL (e.g., `/pr-/`). The preview URL MUST surface in the **PR's Deployments box** (top-of-PR, via the GitHub Deployments API populated from the workflow's `environment:` declaration), **NOT** as a bot comment in the conversation. Subsequent pushes to the PR MUST update the same `pr-preview-` environment's URL in place (no environment churn). On PR close (merge OR reject), the workflow MUST deactivate the deployment via the Deployments API AND remove the `/pr-/` directory from the gh-pages branch within 30 minutes — the Deployments box then shows the deployment as "Inactive" with no live URL. +- **FR-022 (Build provenance visible in the UI)**: Every rendered route MUST display the build provenance in a persistent **page-footer "build info" affordance**: the short code-revision tag (first 7 chars of the git SHA, e.g. `a1b2c3d`), the corpus state-key suffix, and the build timestamp. Clicking the affordance MUST reveal the full `build_info` block (full SHA, full corpus + Stage 4 rollup state-keys, ISO timestamp). The short code-revision MUST also appear in the page `` suffix for the placeholder route (e.g. `OHBM 2026 — under construction · a1b2c3d`) so reviewers can verify which committish a PR-preview deploy is built from without opening the page. Source: `manifest.json:build_info.code_revision_short` (data-model.md §0). + +### Key Entities + +- **Accepted abstract** — the unit of display. Identified by **poster id**. Carries: title, authors (ordered, with affiliations), accepted_for, abstract sections (intro/methods/results/conclusion), references list, the two visible "extra questions" (Topics + Methods), and per-(model, input) UMAP coordinates + community / topic_cluster / neuroscape_cluster ids. +- **Author record** — name, ordered affiliation list, abstract membership (which abstracts they appear on, in which authorship order). +- **Model selection cell** — `(model, input)` pair drawn from `{voyage, minilm, openai, pubmedbert, neuroscape} × {abstract, claims, methods}`. 15 cells total. Default `neuroscape / abstract`. +- **Cart entry** — `{poster_id, title, abstract_anchor_url, added_at}`. Persisted in browser local storage. +- **Facet** — `(facet_key, option_value) → count`. Facet keys: `accepted_for`, `primary_topic`, `secondary_topic`, `keywords`, `methods`, `study_type`, `population`, `field_strength`, `processing_packages`, `species`, `recording_technology`, `brain_regions`, `brain_networks`. +- **Tour step** — `{anchor_selector, title, body, order, optional_predicate}`. The tour reads a small ordered list of steps; first-time visitors see the CTA but never auto-launch. + +### Constitution Alignment *(mandatory)* + +- **CA-001 (Venv-only Python)**: Build-side scripts that produce the UI data package MUST use `.venv/bin/python` or `uv` targeting that interpreter. (Node tooling for the site itself is separate and lives under `ui/` or `site/`.) +- **CA-002 (Plan-first, test-first)**: This stage adds a behavior-changing UI. Test scope includes: build-time link validation, the per-(model, input) data-shape contract test, and at least one end-to-end smoke per US1 (page loads, search works, abstract opens) using a headless browser. Test-first applies — those tests are written/identified before implementation begins. +- **CA-003 (Docs sync)**: The README operations runbook, CLAUDE.md module list, and the project charter (`docs/reproducibility-vision.md`) MUST be updated alongside the implementation to reflect the new build command, the deploy workflow, and the new UI package boundary. +- **CA-004 (Secrets)**: The deploy workflow MUST NOT require any custom secret beyond the default `GITHUB_TOKEN`. No OpenAI/Anthropic keys at runtime — the site is fully static + client-side. +- **CA-005 (No committed data)**: The UI data package landing zone MUST be under an existing gitignored root (`data/outputs/exported-sites/...` or `export/ui-site/`). The GitHub Action publishes to the `gh-pages` branch — that branch's contents are auto-generated and not part of source review. +- **CA-006 (Fail loudly)**: Reference link validation MUST be a hard build gate. Search/cart/walkthrough error paths surface user-facing messages (no silent failures). +- **CA-007 (Discover external state)**: The data package's per-(model, input) cells MUST be discovered from the Stage 4 rollup at build time, not hardcoded — adding a 6th model later requires zero UI code changes beyond a rebuild. +- **CA-008 (Provenance)**: The data package MUST embed a build-stamp metadata block (corpus_state_key, code revision (full git SHA + 7-char short SHA), Stage 4 rollup state-key, build timestamp) on **every shard** (per data-model.md §8 invariant 6; raw-array shards are forbidden). The short SHA MUST be visible in the site footer on every route AND in the page `<title>` suffix on the placeholder route per FR-022, so PR-preview deploys can be visually verified as the right committish without DevTools. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001 (Performance — first paint)**: On a typical broadband connection, the site reaches first interactive paint (visible search box + result list) within **3 seconds**. +- **SC-002 (Performance — search)**: A typed query returns ranked results within **500 ms** for the median query (3,244-row corpus). +- **SC-003 (Performance — UMAP)**: Switching between two `(model, input)` cells re-renders the 2D UMAP within **1 second** on a recent laptop. +- **SC-004 (Mobile usability)**: All US1 acceptance scenarios pass on a mobile viewport (360 × 640 px); the user can complete the search → detail flow without horizontal scrolling. +- **SC-005 (Accepted-only invariant)**: Every list, projection, facet, cart, and detail surface contains **zero** withdrawn abstracts — verified by an automated test that scans the deployed data package for any `accepted_for == "Withdrawn"` records. +- **SC-006 (Data-package size)**: The static JSON shard set downloaded for first paint (manifest + abstracts + authors + the default `neuroscape_abstract` cell + its topics + the lexical index) MUST be ≤ **8 MB gzipped**. The full per-cell expansion (all 15 cells + all topic files) MUST be ≤ **3 MB gzipped** on top of the first-paint set (~11 MB gz max for the complete data package). The int8 MiniLM vectors (≤ 1.5 MB) MUST be lazy-loaded only on the first semantic query. +- **SC-007 (Reference-link health)**: The build action's link checker fails the deploy if **any** external reference URL on the About page returns a non-200 status. Verified at every build. +- **SC-008 (PR-preview latency)**: From PR push to live preview URL, the action completes within **10 minutes** at the 90th percentile. +- **SC-009 (Cart persistence)**: A cart with 5 items survives a full page reload + browser restart in the same browser (within local-storage retention norms — typically 7+ days). +- **SC-010 (Typo tolerance)**: 90% of single-typo queries (insert / delete / substitute / transpose) against known abstract titles or author surnames surface the correct abstract in the top 10 results. +- **SC-011 (Provenance visible per FR-022)**: On any live PR-preview deploy, a reviewer can confirm the committish without opening DevTools — the short SHA appears in the page title suffix AND in the footer build-info affordance on every route (home, about, abstract permalink). Verified by a Playwright assertion that fails if the rendered footer does NOT include `manifest.build_info.code_revision_short`. + +## Assumptions + +- **Modern framework**: the site is implemented in a modern JavaScript framework. The spec doesn't mandate a specific one — implementation can choose (React + Vite, Svelte + Vite, Astro, etc.) based on bundle-size / DX trade-offs. +- **In-browser sentence-embedding model**: the user-named "minilm and its JS model" is interpreted as a small Sentence-Transformers MiniLM checkpoint shipped as a quantized ONNX model loaded via a browser ML runtime (e.g., transformers.js, ONNX Runtime Web). Implementation will pick the smallest checkpoint that hits SC-002 + SC-010. +- **Author affiliations**: per-author affiliation strings come from the existing `data/primary/authors.json` (Stage 1's inline author fetch from the GraphQL API). +- **Poster id semantics**: the program-assigned poster id (e.g., `M-AM-101`) is already in the Stage 1 corpus under `poster_id`. The current UI displays submission ids because the legacy export forgot to surface `poster_id`; the fix is to use it everywhere. +- **References data**: each abstract's reference list comes from Stage 2.1's OpenAlex-resolved `data/primary/reference_metadata.json`. References without a DOI/URL are still listed but without a clickable link. +- **Cart persistence**: browser local storage; no server-side cart. Email integration is `mailto:` (no SMTP relay). +- **Walkthrough state**: a single localStorage key tracks "user has dismissed the tour CTA at least once" so the CTA isn't a banner forever. +- **About-page references**: every external link is validated at build time via the same GitHub Action; the link checker uses a simple HEAD request with a 10-second timeout per URL. +- **PR preview cleanup**: cleanup is triggered by the `pull_request.closed` event; the action commits a directory removal to the `gh-pages` branch. +- **Mobile lasso replacement**: the 2D lasso is desktop-only; mobile users tap a point to select its community (the community-id from the active `(model, input)` cell's `communities` bundle). +- **Data-package layout**: locked in by the Session-2026-05-17 clarification as a static-JSON-shard architecture — no DuckDB-WASM, no Parquet, no client-side query engine. See FR-019 for the full shard list and SC-006 for the size budget. +- **Lexical-search index**: pre-built at deploy time (n-gram inverted index over tokens), serialized into `data/search/lexical_index.json`. The browser doesn't compute the index at runtime — it just loads it and runs typo-tolerant lookups against it. +- **Facet aggregation**: computed in JavaScript at query time over the in-memory abstract array. At 3,244 rows, a full 13-facet recount is <10 ms on a typical laptop — no SQL or query engine needed. + +## Wireframe prompt for Claude Design + +If you choose to ask Claude Design to draft a wireframe before implementation, here is a prompt you can paste verbatim: + +> Design a wireframe for a public static search site over a corpus of 3,244 accepted scientific conference abstracts (OHBM 2026). The site is read-only, served from GitHub Pages, and must be **responsive (phone / tablet / desktop)**. Brand voice: scientific, clean, no ads. +> +> **Primary layout (desktop, ≥ 1024 px)**: three-column shell. +> - **Left column (240 px)** — collapsible facet sidebar. ~12 facet sections (accepted_for, primary_topic, secondary_topic, keywords, methods, study_type, population, field_strength, processing_packages, species, recording_technology, brain_regions, brain_networks). Each section has a per-option count that updates with selection. +> - **Center column (flexible)** — top: search bar with a "semantic / lexical / both" toggle + author-search subfield + clear button. Below: a tabbed area with two tabs: **2D UMAP** (Plotly-style scatterplot with lasso) and **3D UMAP** (rotatable scene without lasso). Below the projection: a virtualized result list of abstract cards. Each card shows poster id, title, lead author + affiliation, primary topic, an "add to list" button. A persistent "build info" footer affordance shows the short git SHA (e.g. `a1b2c3d`), corpus state-key suffix, and build timestamp — visible on every route so PR-preview deploys can be verified at a glance (FR-022). +> - **Right column (320 px)** — sticky detail panel. Shows the currently focused abstract: poster id, title, full author + affiliation list (collapsible if > 6), abstract sections (intro / methods / results / conclusion with collapsible "show more"), Topics + Methods, references (each link opens in a new tab), an "add to list" button. +> +> **Top header bar**: project name on the left; model-selection dropdowns in the center (model × input, default `neuroscape × abstract`); cart icon with item count on the right; "Take the tour" CTA next to the cart; "About" link in the header. +> +> **Mobile layout (< 768 px)**: single column. Search bar pinned to the top; "filters" button opens the facet sidebar as a full-screen drawer; "map" button opens UMAP as a full-screen overlay; result list is the default home view; tapping a result opens the detail panel as a full-screen overlay with a close button. Lasso is replaced by "tap a UMAP point to filter by its community". Cart icon stays in the top bar. +> +> **Walkthrough overlay**: when the user clicks "Take the tour", an overlay highlights each component in turn — search → model selector → UMAP → lasso (desktop only) → facets → cart — with a small tooltip and Next/Prev/Skip controls. Skippable, never auto-launches. +> +> **About page**: a separate route with a 250-word non-specialist overview at the top, followed by 5–7 collapsible deep-dive sections (corpus, enrichment, embeddings, analysis & annotation, topics, projections). Each deep dive cites real references that open externally. +> +> **Email-my-list flow**: when the user clicks "Email my list" with cart items, the OS mail composer opens with a pre-filled subject + body listing items. +> +> Use a clean, neutral color palette (mostly grayscale + one accent for the lasso / selected state); typography should be readable on small screens. Avoid skeuomorphic shopping-cart imagery — prefer a "saved list" or "bookmark" metaphor. diff --git a/specs/008-ui-rewrite/tasks.md b/specs/008-ui-rewrite/tasks.md new file mode 100644 index 00000000..3d065b1c --- /dev/null +++ b/specs/008-ui-rewrite/tasks.md @@ -0,0 +1,423 @@ +--- +description: "Task list for Stage 6 — UI Rewrite (Static Search Site)" +--- + +# Tasks: Stage 6 — UI Rewrite (Static Search Site) + +**Input**: Design documents from `/specs/008-ui-rewrite/` +**Prerequisites**: plan.md ✓, spec.md ✓, research.md ✓, data-model.md ✓, contracts/ (3 files) ✓, quickstart.md ✓ + +**Tests**: Per spec CA-002, this stage is behavior-changing and follows standard test-first discipline. Python `unittest` covers the data-package builders + link checker. JS Vitest covers stores + search + facet math. Playwright covers end-to-end smoke per user story. Test tasks land before their implementation tasks within each story phase. + +**Organization**: Tasks are grouped by user story so each story is independently shippable as a per-route slice. + +**Sequencing note (Session 2026-05-17 clarification)**: **US8 ships first as a small standalone PR** so US1–US7 can be reviewed via live PR previews. The preview URL surfaces in the PR's **Deployments box** (top-of-PR, via `environment:` declaration on the workflow), NOT as a bot comment. The Phase 3 placeholder Svelte page is intentionally minimal — it gives the deploy workflow something to publish + lets reviewers verify the data-package builder skeleton is wired through end-to-end before any real UI work lands. + +## Format: `[ID] [P?] [Story?] Description with file path` + +- `[P]`: Can run in parallel (different files, no incomplete-task dependencies). +- `[Story]`: `[US1]` through `[US8]` (Setup / Foundational / Polish phases carry no story label). +- All paths are project-relative. + +## Path Conventions + +- **Python builders**: `src/ohbm2026/ui_data/` + `scripts/build_ui_data.py` +- **Site**: `site/` (self-contained SvelteKit project with own `package.json`) +- **Python tests**: `tests/test_ui_data_*.py` +- **JS unit tests**: `site/src/tests/unit/*.test.ts` +- **JS e2e tests**: `site/src/tests/e2e/*.spec.ts` +- **GitHub workflows**: `.github/workflows/` +- **Docs**: `CLAUDE.md`, `README.md`, `docs/reproducibility-vision.md` + +--- + +## Phase 1: Setup + +- [X] T001 Add `[ui]` optional-extras entry to `pyproject.toml` listing `numpy`, `sentence-transformers`, `pyyaml`, `requests` (link checker). Add `[ui-dev]` extra with `playwright` (for screenshots in the link checker — defer if not needed). Bump the project minor version. +- [X] T002 [P] Create `site/` directory with a fresh SvelteKit + Vite + TypeScript scaffold (hand-rolled, matches `pnpm create svelte@latest` skeleton + TypeScript + ESLint + Prettier + Vitest + Playwright). `@sveltejs/adapter-static` configured in `svelte.config.js` with `paths.base` driven by the `BASE_PATH` env var (so PR previews can serve under `/pr-<N>/`). +- [X] T003 [P] Added `site/.gitignore` covering `node_modules/`, `build/`, `.svelte-kit/`, `static/data/` (the data shards are gitignored — they're build output). +- [X] T004 [P] Added `site/package.json` scripts: `dev`, `build`, `preview`, `test:unit` (vitest), `test:e2e` (playwright), `check` (svelte-check + tsc). +- [X] T005 [P] Added deps to `site/package.json`: `@xenova/transformers` (in-browser ML), `plotly.js-basic-dist-min` (lazy-loaded), `shepherd.js` (walkthrough). DO NOT add the MiniLM ONNX model itself — that's fetched from the Hugging Face CDN at runtime. +- [X] T006 Created the `src/ohbm2026/ui_data/__init__.py` package shell (1-line docstring; no re-exports — Stage 5 / Q2 precedent). +- [X] T007 Added `scripts/fetch_ui_inputs.sh` — the GitHub Action calls this to materialize `data/primary/*` + `data/outputs/analysis/*` + `data/outputs/embeddings/minilm/*` from a release artifact or DVC store. The script MUST NOT hardcode any state-key; `state_key.py` (T011a) does the discovery at build time after the inputs are materialized. Local dev populates the inputs manually per quickstart.md and the discovery still applies. + +--- + +## Phase 2: Foundational (blocks all user stories) + +The Foundational phase lands the data-package builder skeleton — enough so the Phase 3 deploy workflow can call `scripts/build_ui_data.py` end-to-end (even against an empty/minimal inputs set) and the placeholder page can render the manifest. Full per-shard builders mature in later stories; this phase ships the orchestrator + the manifest builder + invariant scaffolding. + +- [X] T008 [P] `tests/test_ui_data_manifest.py::test_manifest_shape` — green. +- [X] T009 [P] `tests/test_ui_data_abstracts.py::test_no_withdrawn_records_leak` (accepted-only invariant) — green. +- [X] T010 [P] `tests/test_ui_data_authors.py::test_dedup_key_collapses_same_name_same_affiliation` — green. +- [X] T011 `src/ohbm2026/ui_data/manifest.py` — discovers cells/inputs/models from `cluster_topics` + facet options from per-abstract `facets` blocks (CA-007). AST scan test `test_no_function_returns_hardcoded_string_lists` — green. +- [X] T011a [P] `src/ohbm2026/ui_data/state_key.py` — `discover_corpus_state_key`, `discover_rollup_state_key`, `discover_minilm_bundle`. Typed `Stage6BuildError` on ambiguous rollups. 4 unit tests — green. Also added `Stage6Error` base to `src/ohbm2026/exceptions.py`. +- [X] T012 [P] `src/ohbm2026/ui_data/abstracts.py` — reuses `ohbm2026.ui.payload` helpers for question lookup + `build_domain_facets`. Strips `submission_id`; emits `poster_id`. Envelope: `{schema_version, build_info, abstracts: [...]}`. +- [X] T013 `tests/test_ui_data_authors.py::test_every_author_id_in_abstracts_exists_after_remap` — green (relaxed pending US1 raw→synthetic remap noted in builder.py). +- [X] T014 [P] `src/ohbm2026/ui_data/authors.py` — R6 dedup (NFC-normalize lower(name) + lower(primary_affiliation)). Withdrawn-only authors dropped. +- [X] T015 `tests/test_ui_data_cells.py::test_positional_join_matches_abstract_id_order` — green. +- [X] T016 `src/ohbm2026/ui_data/cells.py` — projects the wide annotations table into 15 cell shards keyed by `<model>_<input>`. neuroscape-only `neuroscape_cluster_id` + distance fields. +- [X] T017 [P] `src/ohbm2026/ui_data/topics.py` — reads `cluster_topics`, emits 33 topic shards (15 cells × {communities, topic_clusters} + 3 neuroscape cells × neuroscape_clusters). +- [X] T018 `src/ohbm2026/ui_data/builder.py` — orchestrator with 5 of 8 cross-shard invariants enforced (1 corpus-count, 2 positional-join, 3 accepted-only, 5 cluster-id integrity, 6 byte-identical build_info). Invariant 4 (author-id remap) WARN-only pending US1; 7 (link checker) deferred to US7; 8 (size budget) checked at deploy time. +- [X] T019 `scripts/build_ui_data.py` — CLI wrapper; smoke-tested end-to-end against the real corpus (15 cells + 33 topic shards + manifest + abstracts + authors; 4.3 MB gz total vs SC-006's 11 MB budget). +- [X] T020 `tests/test_ui_data_builder.py::test_two_runs_produce_byte_identical_shards` — green. +- [X] T020a `tests/test_ui_data_builder.py::TestEveryShardCarriesBuildInfo::test_every_shard_carries_build_info` — every emitted JSON shard is an object envelope with the 5-key `build_info` (no raw-array shards); every block is byte-identical (§8 invariant 6). + +--- + +## Phase 3: User Story 8 — Deploy workflows (FIRST PR, Priority: P5 → promoted first) 🚀 SHIPS FIRST + +Per the Session-2026-05-17 clarification, US8 ships **first** so subsequent PRs for US1–US7 can be reviewed via live PR-previews. The preview URL surfaces in the PR's **Deployments box** (top-of-PR, via `environment:` declaration), NOT as a bot comment. + +**Story Goal**: Ship the three workflows + a minimal placeholder site so every subsequent PR gets an automatic live preview link in its Deployments box. Production deploys on merge to main. + +**Independent Test**: Open a small PR that adds the workflows + the placeholder. Verify within 10 minutes the PR's **Deployments box** (top of PR, not the conversation) shows "View deployment" → `pr-preview-<N>` linking to `https://<org>.github.io/<repo>/pr-<N>/` and the placeholder page loads. Push another commit; verify the same environment URL updates in place. Close the PR; verify the Deployments box shows "Inactive" and the URL 404s within 30 min. + +### Implementation + +- [X] T021 [P] [US8] `site/src/routes/+page.svelte` + `+layout.svelte` + `site/src/lib/components/BuildInfo.svelte` + `site/src/lib/shards.ts` — placeholder renders `manifest.build_info` with short committish in page title, banner callout, and persistent footer affordance (FR-022). Loads gracefully when no manifest is present. +- [X] T022 [P] [US8] `.github/workflows/deploy-ui.yml` — publishes to `gh-pages` root via `peaceiris/actions-gh-pages@v3` with `keep_files: true`. Data-package build is conditional on `fetch_ui_inputs.sh` succeeding (degrades to placeholder when inputs absent). +- [X] T023 [P] [US8] `.github/workflows/pr-preview.yml` — declares `environment: { name: pr-preview-<N>, url: ... }` so the URL surfaces in the PR's Deployments box (top-of-PR, NOT a bot comment). Skips forks. Deploys to `gh-pages:pr-<N>/` via `BASE_PATH=/pr-<N>`. +- [X] T024 [P] [US8] `.github/workflows/pr-preview-cleanup.yml` — `actions/github-script@v7` lists + marks every deployment for `pr-preview-<N>` environment as `state: "inactive"` AND removes the `pr-<N>/` directory from `gh-pages`. No conversation comment. +- [X] T025 [US8] Pages-source manual step documented in `quickstart.md` (US8 smoke section, lines ~165+). +- [ ] T026 [US8] Draft-PR verification — DEFERRED to when the US8 PR is opened (cannot run a real GitHub workflow from this session). The local build smoke-test confirmed the site renders with `code_revision_short = 6f76939` from the rebuilt manifest; gh-pages deploy + environment surface need a real PR run. +- [X] T027 [US8] Phase 3 ready to commit as the first Stage 6 PR. README "Stage 6: UI (under construction)" section + CLAUDE.md `ui_data/` module entry updated inline per Constitution IV. + +**Stop condition for the Phase 3 PR**: do NOT bundle any Phase 4+ work into this PR. The first PR is intentionally minimal: workflows + placeholder + the data-package skeleton from Phase 2. Subsequent PRs ship US1, US2, ... each with a live preview. + +--- + +## Phase 4: User Story 1 — Find and read accepted-poster details (Priority: P1) 🎯 MVP + +The MVP user-facing slice. The first PR that exercises the now-live preview pipeline from Phase 3. + +**Story Goal**: Anyone can land on the site from a phone / tablet / desktop, search by keyword / author / topic, read full abstract details (poster_id + authors + sections + topics + methods), with zero withdrawn submissions visible. + +**Independent Test**: Mobile viewport (360 × 640 — matches SC-004's stated minimum); type "connectivity"; click a result; verify poster_id, ordered author list with affiliations, all sections, topics + methods visible; no horizontal scroll; no withdrawn rows in any shard (Playwright `accepted-only.spec.ts`); footer carries the build_info short SHA on every route (FR-022). + +### Tests first + +- [ ] T028 [P] [US1] Write `site/src/tests/unit/shards.test.ts` — `loadManifest()` + `loadAbstracts()` + `loadAuthors()` correctly parse the JSON shards from a fixture under `site/src/tests/fixtures/data/`. Red until T031. +- [ ] T029 [P] [US1] Write `site/src/tests/unit/cart.test.ts` — `cartStore` add/remove/clear; persists to localStorage; survives a remount. (Cart is wired in US5 but its store is foundational state we set up early.) Red until T033. +- [ ] T030 [P] [US1] Write `site/src/tests/e2e/browse.spec.ts` — Playwright: page loads in <3s, search box responds, typing "connectivity" returns ≥ 1 result, clicking a result opens the detail panel with poster_id as header, mobile viewport (360 × 640 — SC-004 minimum) has no horizontal scroll, footer renders `build_info.code_revision_short` (FR-022 / SC-011). Red until T037. + +### Implementation + +- [ ] T031 [P] [US1] Create `site/src/lib/shards.ts` exporting `loadManifest`, `loadAbstracts`, `loadAuthors`, `loadCell(cell_key)`, `loadTopics(cell_key, kind)`, `loadMinilmVectors()`. Each uses `fetch()` with a path resolved against SvelteKit's `base`. Caches results in module-level Maps. +- [ ] T032 [P] [US1] Create `site/src/lib/stores/selection.ts` exporting Svelte stores: `selectedCell` (model + input), `searchQuery`, `activeFilters`, `lassoSelection` (Set<abstract_id> | null), `focusedAbstract` (poster_id | null). Default selectedCell = `{model: 'neuroscape', input: 'abstract'}`. +- [ ] T033 [P] [US1] Create `site/src/lib/stores/cart.ts` with `cartStore` (Set<poster_id>); add/remove/clear actions; localStorage persistence under key `ohbm2026.ui.cart.v1`. Verify T029 turns green. +- [ ] T034 [P] [US1] Create `site/src/lib/components/SearchBar.svelte` — minimal first version: a text input bound to `searchQuery` store + a clear button. No semantic/lexical toggle yet (that lands in US3). +- [ ] T035 [P] [US1] Create `site/src/lib/components/ResultList.svelte` — virtualized list (use `svelte-virtual-list` or a simple windowed render) of abstract cards. Each card shows poster_id, title, lead author + affiliation, primary topic, and an "add to list" button wired to `cartStore`. +- [ ] T036 [P] [US1] Create `site/src/lib/components/DetailPanel.svelte` — sticky on desktop, full-screen overlay on mobile. Renders: poster_id (NOT submission_id) as header, ordered authors with affiliations (collapsible toggle when > 6), abstract sections (intro / methods / results / conclusion) with collapsible "show more", topics (primary + secondary + subcategories), methods checklist, references (each link opens in new tab with `rel="noopener noreferrer"`). MUST NOT render any extra-question field other than Topics + Methods (FR-011). +- [ ] T036a [P] [US1] Write `site/src/tests/unit/detail_panel_extra_fields.test.ts` — render `DetailPanel.svelte` with a fixture abstract carrying 6 extra-question fields (topics, methods, study_type, population, field_strength, processing_packages). Assert the rendered DOM exposes exactly two extra-question blocks (Topics + Methods) and zero of the other four (queried by `data-testid`). Negative test for FR-011. Red until T036. +- [ ] T037 [US1] **Replace** the Phase 3 placeholder `site/src/routes/+page.svelte` with the real home page: SearchBar + ResultList + DetailPanel; loads manifest + abstracts + authors on mount. Update `site/src/routes/+layout.svelte` to the full header (project name, model selector placeholder, "Take the tour" placeholder, About link, cart icon) AND **preserve the footer `BuildInfo.svelte` component from US8** so every route still shows the build_info short SHA + corpus state-key + timestamp per FR-022. Verify T030 turns green. +- [ ] T038 [US1] Add `site/src/app.css` (or Svelte component-scoped CSS) implementing the responsive 3-column desktop / single-column mobile shell from the wireframe prompt. Use CSS Grid + container queries; breakpoint at 1024px (3-col), 768px (2-col), <768px (1-col). +- [ ] T039 [P] [US1] Create `site/src/routes/abstract/[poster_id]/+page.svelte` — direct-link permalink route per contracts/routes.md. Renders the DetailPanel for the matching abstract; 404 page when poster_id unknown. +- [ ] T040 [US1] Verify the accepted-only invariant in CI: `site/src/tests/e2e/accepted-only.spec.ts` — after the page loads on mobile viewport (360 × 640), query the JS heap for `window.__abstracts` (a test-only debug global) and assert no record has `accepted_for === "Withdrawn"`. +- [ ] T041 [US1] Commit US1. Test baseline: Vitest green for shards + cart + detail-panel extra fields (FR-011 negative test from T036a); Playwright green for browse.spec + accepted-only.spec. Tag the commit `stage6-us1-mvp`. PR body links to the live PR preview URL from the Deployments box and quotes the footer's short SHA as the verification reviewers should check (FR-022). In the same PR, expand the README "Stage 6: UI" section to include the `PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py …` recipe + the `cd site && pnpm dev` recipe per Constitution IV (docs land alongside canonical commands). + +--- + +## Phase 5: User Story 2 — 2D + 3D UMAP with lasso selection (Priority: P2) + +**Story Goal**: User opens the projections panel, sees a 2D UMAP scatterplot with lasso selection; switches to 3D tab for rotate/pan/zoom; switches the (model, input) cell and the coordinates re-render while the lasso selection-by-id persists. + +**Independent Test**: Open projections, draw a lasso around ~100 points, confirm result list + facets contract to that selection (<500ms). Switch model from `neuroscape × abstract` to `voyage × claims` and verify the same abstract ids stay selected. + +### Tests first + +- [ ] T042 [P] [US2] Write `site/src/tests/unit/cell_loader.test.ts` — `loadCell('neuroscape_abstract')` returns the expected positional-joined `[3244]` array; `loadCell('<unknown>')` raises a typed error. Red until T045. +- [ ] T043 [P] [US2] Write `site/src/tests/e2e/umap.spec.ts` — open projections tab, confirm Plotly bundle loads (verify via Network panel mock), draw a synthetic lasso via `page.evaluate(plotlyEvent)`, confirm result list shrinks. Red until T047. +- [ ] T043a [P] [US2] Extend `site/src/tests/e2e/umap.spec.ts` with a **mobile-viewport scenario**: set viewport to 360 × 640 (matches SC-004 / Phase 4 baseline), open the UMAP tab, tap a known UMAP point, assert the result list narrows to the tapped point's `community_id` membership (the lasso-replacement Edge Case from spec.md line 149 + FR-005). Red until T049. + +### Implementation + +- [ ] T044 [P] [US2] Create `site/src/lib/components/ModelSelector.svelte` — two dropdowns (model, input) bound to `selectedCell`. Renders model labels with capitalization (`Voyage`, `MiniLM`, etc.). +- [ ] T045 [US2] Extend `site/src/lib/shards.ts` with `loadCell(cell_key)` that fetches `data/cells/<cell_key>.json` lazily, joined positionally to abstracts. Cache per cell. Verify T042 turns green. +- [ ] T046 [P] [US2] Create `site/src/lib/components/UmapPanel.svelte` — tabbed 2D + 3D view. On tab open, dynamically `import('plotly.js-basic-dist-min')` (lazy-load; tracked by SC-006). Renders the 2D scatter with `lasso` mode + the 3D `scatter3d` with rotate/pan/zoom. +- [ ] T047 [US2] Wire the 2D lasso event to the `lassoSelection` store: Plotly emits `plotly_selected`; convert the selected indices to abstract ids; update `lassoSelection`. Wire `lassoSelection` to filter the ResultList. Verify T043 turns green. +- [ ] T048 [P] [US2] Implement model-switch persistence: when `selectedCell` changes, fetch the new cell shard, recompute UMAP coords for currently-displayed points, but DO NOT clear `lassoSelection` — same abstract ids stay selected; only positions move. +- [ ] T049 [P] [US2] Add mobile-fallback behavior to UmapPanel per FR-005 + Edge Cases: on viewports < 1024px the lasso is replaced by tap-to-filter-by-community (tap a point → select its `community_id` membership). +- [ ] T050 [US2] Commit US2. Tag `stage6-us2-projections`. + +--- + +## Phase 6: User Story 3 — Semantic + lexical search with typo tolerance (Priority: P2) + +**Story Goal**: User types a query; system runs semantic + lexical search in parallel; results merge with semantic/lexical/both filter. Author search tolerates 1–2 typos. + +**Independent Test**: Type "defautl mode netwrk" (2 typos) → "default mode network" abstracts surface. Type "Smtih" in author search → "Smith" abstracts appear. Type "how the brain remembers faces" (no verbatim match) → face-memory abstracts via semantic. + +### Tests first + +- [ ] T051 [P] [US3] Write `tests/test_ui_data_lexical_index.py::test_inverted_index_shape` — the Python builder produces a JSON conforming to data-model.md §6: `{tokens: [...], trigram_index: {...}}`; trigram_index inverts tokens[].trigrams. Red until T054. +- [ ] T052 [P] [US3] Write `site/src/tests/unit/lexical.test.ts` — given a tiny fixture index of 5 tokens, `lexicalSearch("memry")` returns "memory" (1 edit); `lexicalSearch("xyzpdq")` returns []; `lexicalSearch("the")` is dropped as a stopword. Red until T055. +- [ ] T053 [P] [US3] Write `site/src/tests/unit/semantic.test.ts` — given a fixture int8 vector buffer of 10 abstracts × 4 dims, `semanticSearch(queryVector)` returns ids ranked by cosine descending. Red until T058. + +### Implementation + +- [ ] T054 [P] [US3] Create `src/ohbm2026/ui_data/lexical_index.py` with `build_lexical_index(abstracts, authors) -> dict`. Tokenizes title + sections + keywords + methods + author names (NFC-normalize, lowercase, accent-fold, stopword-drop). For each token, emits trigrams + the postings list of abstract_ids. Builds the inverse `trigram_index`. Serializes to `search/lexical_index.json` ≤ 500 KB gz. Verify T051 turns green. +- [ ] T055 [P] [US3] Create `site/src/lib/search/lexical.ts` with `lexicalSearch(query)` — trigrams the query; uses trigram_index to fetch candidate token_ids; filters candidates with Damerau-Levenshtein distance ≤ 2 (≤ 1 for words < 4 chars); returns ranked abstract_ids from posting lists. Verify T052 turns green. +- [ ] T056 [P] [US3] Create `src/ohbm2026/ui_data/vectors.py` with `build_minilm_vectors(minilm_bundle_path) -> bytes` — reads the Stage 3 MiniLM bundle's `vectors.npy`, int8-quantizes to `[3244, 384]`, validates cosine-recovery error ≤ 0.5% on a held-out subset, writes raw little-endian binary. +- [ ] T057 [P] [US3] Create `site/src/lib/workers/semantic.worker.ts` — a Web Worker that, on receipt of a query string, loads transformers.js + the `Xenova/all-MiniLM-L6-v2` ONNX model, embeds the query, fetches `minilm_vectors.bin` (lazy on first query), and runs cosine similarity. Returns top-k ranked abstract ids. +- [ ] T058 [P] [US3] Create `site/src/lib/search/semantic.ts` — thin facade that spawns the worker, posts queries, returns ranked results. Verify T053 turns green. +- [ ] T058a [P] [US3] Create `scripts/eval_typo_recall.py` + the fixture `tests/fixtures/typo_recall_samples.json` (100 (title|surname, correct_abstract_id) pairs sampled deterministically with a committed seed against the live corpus). The script injects one insert/delete/substitute/transpose per sample, runs the lexical + semantic merge end-to-end, reports the fraction whose correct abstract appears in the top-10, and asserts ≥ 0.90 (SC-010). Add `tests/test_typo_recall.py::test_recall_floor` that runs against a tiny 10-sample subset deterministically (full 100-sample run is opt-in via `--full` flag to keep the unit suite fast). The Polish-phase T096 SC-010 entry calls the `--full` version against the live preview. +- [ ] T059 [US3] Update `site/src/lib/components/SearchBar.svelte` from the US1 placeholder: add the semantic / lexical / both toggle; debounce the input (300ms); on input, fan out to `lexicalSearch` + `semanticSearch` (when "both"), merge results with rank fusion (reciprocal rank fusion or weighted union). Visually distinguish semantic-only matches with a badge. +- [ ] T060 [P] [US3] Add an author-search subfield to SearchBar: separate input bound to a derived store that calls `lexicalSearch` against the author-name index entries only (the lexical index already tokenizes author names; filter by token kind). Diacritics folded (e.g. "García" ≈ "Garcia"). +- [ ] T061 [US3] Wire merged-search-results into the ResultList: when `searchQuery` is non-empty, the result list shows ranked results; when empty, shows the full corpus (intersected with facets + lasso). +- [ ] T062 [US3] Add Playwright test `site/src/tests/e2e/search.spec.ts` covering the 3 US3 acceptance scenarios (two-typo query, single-typo author surname, no-verbatim semantic). Verify it passes. +- [ ] T063 [US3] Commit US3. Tag `stage6-us3-search`. + +--- + +## Phase 7: User Story 4 — Interactive facets (Priority: P3) + +**Story Goal**: 13 facets in a sidebar; counts update as the user filters, lassoes, or searches; counts always reflect the intersection of all active filters. + +**Independent Test**: Apply `Methods = fMRI`; verify `Species` facet shows only species appearing in fMRI abstracts; lasso a UMAP region; verify counts contract further; clear → counts return to corpus totals. + +### Tests first + +- [ ] T064 [P] [US4] Write `site/src/tests/unit/facets.test.ts` — `recomputeFacets(abstracts, filterSet)` returns the right per-option counts. Given a fixture of 10 abstracts with known facet values, applying `Methods = fMRI` yields the expected `Species` count distribution. Red until T065. + +### Implementation + +- [ ] T065 [P] [US4] Create `site/src/lib/facets.ts` with `recomputeFacets(abstracts, activeIds)` — pure function returning `Map<facet_key, Map<option, count>>`. Uses the 13 facet keys from data-model.md §2's `facets` block on each abstract. Verify T064 turns green. +- [ ] T066 [US4] Create `site/src/lib/components/FacetSidebar.svelte` — collapsible left-column sidebar on desktop, full-screen drawer on mobile. Reads facet counts from a derived store (`derived([abstracts, activeFilters, searchResults, lassoSelection], recomputeFacets)`); on click, updates `activeFilters`. +- [ ] T067 [US4] Wire facet filters into the ResultList intersection: `displayedIds = (searchResults ?? allIds) ∩ activeFilters ∩ (lassoSelection ?? allIds)`. Recomputed reactively. +- [ ] T068 [US4] Add Playwright test `site/src/tests/e2e/facets.spec.ts` covering the 2 US4 acceptance scenarios (facet recount on filter; lasso ∩ facet intersection). Verify it passes. +- [ ] T069 [US4] Commit US4. Tag `stage6-us4-facets`. + +--- + +## Phase 8: User Story 5 — Cart + email-my-list (Priority: P3) + +**Story Goal**: User clicks "add to list" on N abstracts; cart badge shows N; click "email my list" → OS mail composer opens with subject + body listing items + permalinks. Mobile-friendly. + +**Independent Test**: Add 3 abstracts, click "email my list", verify mail composer launches with the expected subject + body; on Linux without a mail handler, the clipboard-fallback modal opens. + +### Tests first + +- [ ] T070 [P] [US5] Extend `site/src/tests/unit/cart.test.ts` (already green from US1) with `buildMailtoLink(cart, site_base_url)` — produces `mailto:?subject=...&body=...` URL with proper URL-encoding; ≤ 2000 chars (mailto length limit; truncate with "(more items)" if needed). +- [ ] T071 [P] [US5] Write `site/src/tests/e2e/cart.spec.ts` — add 3 abstracts via the UI, click "email my list", intercept the `window.location` change to `mailto:`, verify the URL contains the expected items. Red until T074. + +### Implementation + +- [ ] T072 [P] [US5] Create `site/src/lib/cart_email.ts` with `buildMailtoLink(items, baseUrl)` returning the mailto URL per FR-015. Encode subject + body; truncate body at 2000 chars with a "(more)" marker. +- [ ] T073 [P] [US5] Create `site/src/lib/components/Cart.svelte` — opens as a drawer from the right (desktop) or full-screen (mobile). Lists cart items with remove buttons + a "clear all" button + "Email my list" + "Copy list to clipboard". When empty, shows a toast hint "Add abstracts first" (Edge Case). +- [ ] T074 [US5] Wire "Email my list" to `buildMailtoLink` + `window.location.href = mailto://...`. Detect mail-handler availability via a 200ms timeout heuristic: if `document.visibilityState` stays `visible` and no navigation happens, fall back to the clipboard modal. Verify T071 turns green. +- [ ] T075 [P] [US5] Add an "add to list" button to ResultList cards + DetailPanel; wire to `cartStore.add(poster_id)`. Visual feedback: cart-badge bump animation. +- [ ] T076 [US5] Commit US5. Tag `stage6-us5-cart`. + +--- + +## Phase 9: User Story 6 — Optional walkthrough (Priority: P4) + +**Story Goal**: First-time visitors see a "Take the tour" CTA but no auto-launch. Clicking it walks through search → model selector → UMAP → facets → cart in 5+ stops with next/prev/skip controls. Re-launchable from a "?" help affordance. + +**Independent Test**: Open in incognito, verify the CTA is visible, click it, walk through 5+ stops, dismiss, reload, verify the tour doesn't auto-launch again. + +### Tests first + +- [ ] T077 [P] [US6] Write `site/src/tests/unit/tour.test.ts` — `tourStore.start()` sets `currentStep = 0`; `tourStore.next()` advances; `tourStore.skip()` resets + marks-as-dismissed in localStorage. Red until T078. + +### Implementation + +- [ ] T078 [P] [US6] Create `site/src/lib/stores/tour.ts` with a state machine: `idle | running | dismissed`. Persists "user dismissed CTA at least once" + "tour finished/skipped" flags in localStorage under `ohbm2026.ui.tour.v1`. Verify T077 turns green. +- [ ] T079 [P] [US6] Create `site/src/lib/components/Tour.svelte` using `shepherd.js`. Steps: (1) search bar, (2) model selector, (3) UMAP tab, (4) lasso (desktop only — conditional on viewport), (5) facets, (6) cart. Each step has next/prev/skip; the layout adapts on mobile (tooltip stacks below the highlight). +- [ ] T080 [US6] Add a "Take the tour" button to the header (always visible) + a "?" help icon (always visible) that re-launches the tour. CTA banner one-time on first visit; dismissible. +- [ ] T081 [P] [US6] Add Playwright test `site/src/tests/e2e/tour.spec.ts` covering the 2 US6 acceptance scenarios. Verify it passes. +- [ ] T082 [US6] Commit US6. Tag `stage6-us6-tour`. + +--- + +## Phase 10: User Story 7 — About page + verified references (Priority: P4) + +**Story Goal**: `/about` route. Top: ≤ 250-word non-specialist overview. Below: 5–7 collapsible deep-dive sections per pipeline stage, each citing real reference URLs. Every link opens in a new tab; build-time link checker fails the deploy on any 4xx/5xx. + +**Independent Test**: Open `/about`; confirm overview ≤ 250 words; expand each deep dive; click links and verify each opens in a new tab + reaches a 200 response. Locally run `link_check.py` against `references.yaml`. + +### Tests first + +- [ ] T083 [P] [US7] Write `tests/test_ui_data_link_check.py::test_blocks_4xx_url` — given a fixture YAML with one `https://httpbin.org/status/404` URL, the link checker exits non-zero. Use a small fixture YAML; mock the HTTP HEAD via `responses`. Red until T085. +- [ ] T084 [P] [US7] Write `tests/test_ui_data_link_check.py::test_passes_clean_yaml` — all-200 URL set returns exit 0. Red until T085. + +### Implementation + +- [ ] T085 [P] [US7] Create `specs/008-ui-rewrite/contracts/references.yaml` — the source-of-truth registry per research.md R9. Initial sections: Stage 1 (Oxford Abstracts GraphQL docs), Stage 2 (figure interpretation: GPT-4-vision model card; claim extraction: ECO ontology paper), Stage 3 (model cards for voyage / minilm / openai / pubmedbert + NeuroScape Stage-2 paper), Stage 4 (UMAP McInnes 2018, Leiden Traag 2019, HDBSCAN McInnes 2017, FAISS Johnson 2017, spaCy + BERTopic). Each entry: `{section, title, authors, year, url, doi?}`. +- [ ] T086 [P] [US7] Create `src/ohbm2026/ui_data/link_check.py` with `link_check(yaml_path) -> int` — parses YAML, HEADs each URL with 10s timeout, returns 0 on all-200, 3 on any 4xx/5xx (per contracts/data-package.md exit codes). Verify T083 + T084 turn green. +- [ ] T087 [P] [US7] Create `site/src/routes/about/+page.svelte` — renders the overview + the collapsible deep-dive sections. Imports `references.yaml` (parsed at build time via a Vite plugin or pre-compiled to JSON). Each reference link uses `<a href="..." target="_blank" rel="noopener noreferrer">`. +- [ ] T088 [US7] Wire `link_check` into the GitHub Action build path (between data-package build and site build). Exit non-zero blocks the deploy. +- [ ] T089 [US7] Commit US7. Tag `stage6-us7-about`. + +--- + +## Phase 11: Polish & Cross-Cutting Concerns + +- [ ] T090 [P] Add a Lighthouse-CI check to the deploy workflow: assert SC-001 (first interactive paint ≤ 3 s) + SC-006 (data package size). Add to `deploy-ui.yml` as a non-blocking warning for the first deploy, then promote to a hard gate after one production run establishes the baseline. +- [ ] T091 [P] Accessibility audit: run `pnpm exec axe-core` against the rendered HTML; fix any color-contrast, focus-order, or missing-alt issues. Target WCAG 2.1 AA. +- [ ] T092 [P] Reconcile `CLAUDE.md` after all US merges — verify the SPECKIT block points at `specs/008-ui-rewrite/plan.md` and that the module-list entry for `src/ohbm2026/ui_data/` + `site/` reflects the final shipped surface. The bulk of CLAUDE.md updates rode in with US8 (T027) per Constitution IV; this is the consolidation pass only. +- [ ] T093 [P] Reconcile `README.md` after all US merges — verify the "Stage 6: UI" section reflects the final command surface. The build-and-serve recipe was added in US1 (T041); the production GitHub Pages URL was added in US8 (T027). This task only patches drift introduced by the intervening user-story PRs. +- [ ] T094 [P] Update `docs/reproducibility-vision.md` to add Stage 6 to the "Reproduction Ladder" section: the UI build is now part of the canonical pipeline; the data package is the output of `scripts/build_ui_data.py`. +- [ ] T095 Run the constitution check: `.specify/scripts/bash/constitution-check.sh --full`. Expect exit 0. +- [ ] T096 [P] Full SC sweep against the live preview: SC-001 (Lighthouse), SC-002 (search latency stopwatch), SC-003 (cell-switch timing), SC-004 (mobile Playwright at 360 × 640), SC-005 (data-package scan for withdrawn), SC-006 (`du -sh site/static/data/`), SC-007 (link checker), SC-008 (PR-preview timing observation **— verify the Deployments box, NOT a bot comment, is the surface; sample at least 3 distinct PRs and report median + max rather than computing a true p90 from a small sample**), SC-009 (cart reload test), SC-010 (run `scripts/eval_typo_recall.py --full`; assert ≥ 0.90 against the live preview), SC-011 (Playwright: assert footer renders `build_info.code_revision_short` on home + about + abstract permalink routes). +- [ ] T097 [P] Verify the FR-021 + FR-022 acceptance: open a throwaway PR; confirm the **Deployments box** appears at top of PR within 10 min; **confirm the page-title suffix AND the footer build-info affordance show the PR's exact short SHA** so the deploy can be visually verified as the right committish; push a second commit; confirm the Deployments box updates AND the rendered short SHA on the preview flips to the new commit's value; close the PR; confirm Deployments box marks it "Inactive". Record screenshots in the polish PR body. **No `peter-evans/find-comment`-style bot comments should be present.** +- [ ] T098 [P] Save a user-memory entry noting: "Stage 6 / static-JSON-shard architecture (no DuckDB-WASM); 8 user stories shipped; SvelteKit + transformers.js + shepherd.js; deploy via gh-pages with PR previews surfaced in the PR Deployments box (NOT bot comments) via `environment:` declaration; US8 shipped first as a small PR to enable previews for US1–US7; every shard carries a `build_info` envelope and the rendered site shows the short committish in the page title + footer so PR-preview deploys are visually verifiable (FR-022)." So future stages have the context. +- [ ] T099 Mark all of T001–T098 in this `tasks.md` as `[X]` and commit the tasks-list update. +- [ ] T100 Push the final consolidating branch + open the PR to `main`. PR title: `feat(stage6): static-JSON-shard UI rewrite on GitHub Pages — US1–US7 (US8 already on main)`. Body: summary of US1–US7 + the SC sweep results + the GitHub Pages preview URL from the Deployments box. + +--- + +## Dependencies + +``` + ┌──────────────────────┐ + │ Phase 1: Setup │ + │ (T001–T007) │ + └──────────┬───────────┘ + │ + ┌──────────▼───────────┐ + │ Phase 2: Foundational│ + │ (T008–T020) │ + │ data builders + tests│ + └──────────┬───────────┘ + │ + ┌──────────▼─────────────────────────┐ + │ Phase 3: US8 — DEPLOY (ships first)│ + │ (T021–T027) P5 → promoted │ + │ workflows + placeholder + PR-prevu │ + │ surfaces in Deployments box │ + └──────────┬─────────────────────────┘ + │ (US8 PR merged to main; previews now live) + ┌──────────▼───────────┐ + │ Phase 4: US1 — MVP │ + │ (T028–T041) P1 │ + │ search + browse + │ + │ accepted-only guard │ + │ first PR to *use* PR │ + │ preview pipeline │ + └──────────┬───────────┘ + │ + ┌───────────────┼───────────────┬─────────────┬─────────────┐ + │ │ │ │ │ + ┌────────▼────────┐ ┌────▼────┐ ┌───────▼────────┐ ┌─▼────┐ ┌──────▼────┐ + │ US2: UMAP │ │ US3: │ │ US4: Facets │ │ US5: │ │ US6: Tour │ + │ (T042–T050) │ │ Search │ │ (T064–T069) │ │ Cart │ │ (T077– │ + │ P2 │ │ (T051– │ │ P3 │ │ (T070│ │ T082) │ + │ │ │ T063) │ │ │ │ –T076│ │ P4 │ + │ │ │ P2 │ │ │ │ ) │ │ │ + │ │ │ │ │ │ │ P3 │ │ │ + └────────┬────────┘ └────┬────┘ └───────┬────────┘ └──┬───┘ └─────┬─────┘ + │ │ │ │ │ + └───────────────┼──────────────┴────────────┼───────────┘ + │ │ + ┌───────────▼──────────┐ │ + │ US7: About + links │ │ + │ (T083–T089) P4 │ │ + └───────────┬──────────┘ │ + │ │ + └───────────┬───────────────┘ + │ + ┌──────────▼───────────┐ + │ Phase 11: Polish │ + │ (T090–T100) │ + └──────────────────────┘ +``` + +- **US8 ships FIRST** (Session 2026-05-17 clarification): the deploy workflows + placeholder land before any user-facing UI code so US1–US7 PRs can be reviewed via live previews. Once US8 is on main, the rest of the stories can ship in parallel. +- **US1 is the next gate**: every other user-facing story depends on US1's shell (layout + shards loaded + stores wired). US2–US7 can ship in any order after US1. +- **US7 (link checker)** is independent of US2–US6; can ship before US6 if reviewers want the docs first. + +## Parallel execution examples + +### Within Phase 2 (Foundational) + +The 4 new submodules + their tests are independent: + +```text +T008 [P]: tests/test_ui_data_manifest.py +T009 [P]: tests/test_ui_data_abstracts.py +T010 [P]: tests/test_ui_data_authors.py +T015 : tests/test_ui_data_cells.py +T011 : ui_data/manifest.py (depends on git revision lookup) +T012 [P]: ui_data/abstracts.py +T013 : tests/test_ui_data_authors.py::test_referential_integrity +T014 [P]: ui_data/authors.py +T016 : ui_data/cells.py (depends on T012's abstracts shape) +T017 [P]: ui_data/topics.py +T018 : ui_data/builder.py (depends on all of T011–T017) +T019 [P]: scripts/build_ui_data.py +T020 : test_deterministic_build (depends on T018) +``` + +### Within Phase 3 (US8 deploy — ships first) + +The 3 workflow files + the placeholder are independent: + +```text +T021 [P] [US8]: site/src/routes/+page.svelte (placeholder) +T022 [P] [US8]: .github/workflows/deploy-ui.yml +T023 [P] [US8]: .github/workflows/pr-preview.yml (environment: declaration) +T024 [P] [US8]: .github/workflows/pr-preview-cleanup.yml (actions/github-script) +T025 [US8]: one-time Pages settings +T026 [US8]: draft-PR verification (Deployments box surface) +T027 [US8]: commit + merge US8 PR +``` + +### Within US1 (MVP) + +The 3 new test files + 6 component files are mostly parallel: + +```text +T028 [P] [US1]: shards.test.ts +T029 [P] [US1]: cart.test.ts +T030 [P] [US1]: browse.spec.ts +T031 [P] [US1]: lib/shards.ts +T032 [P] [US1]: stores/selection.ts +T033 [P] [US1]: stores/cart.ts +T034 [P] [US1]: components/SearchBar.svelte (US1 placeholder) +T035 [P] [US1]: components/ResultList.svelte +T036 [P] [US1]: components/DetailPanel.svelte +T037 [US1]: replace placeholder routes/+page.svelte + full +layout.svelte (depends on T031–T036) +T038 [US1]: app.css responsive shell (depends on T037) +T039 [P] [US1]: routes/abstract/[poster_id]/+page.svelte +T040 [US1]: tests/e2e/accepted-only.spec.ts +``` + +### Within US3 (search) + +```text +T051 [P] [US3]: tests/test_ui_data_lexical_index.py +T052 [P] [US3]: site/tests/unit/lexical.test.ts +T053 [P] [US3]: site/tests/unit/semantic.test.ts +T054 [P] [US3]: ui_data/lexical_index.py +T055 [P] [US3]: lib/search/lexical.ts +T056 [P] [US3]: ui_data/vectors.py +T057 [P] [US3]: lib/workers/semantic.worker.ts +T058 [P] [US3]: lib/search/semantic.ts +T059 [US3]: components/SearchBar.svelte (full version) -- depends on T055 + T058 +T060 [P] [US3]: author-search subfield +T061 [US3]: wire into ResultList +T062 [US3]: tests/e2e/search.spec.ts +``` + +## Implementation strategy + +**Recommended sequence** (per Session 2026-05-17 clarification): + +1. **Phase 1 (Setup)** → **Phase 2 (Foundational, builder skeleton)** → **Phase 3 (US8 deploy + placeholder, SHIPS FIRST as a small standalone PR)**. +2. Once US8 is on main: **Phase 4 (US1 MVP, second PR — first to use the now-live preview pipeline)**. +3. Then **Phase 5–10 (US2–US7)** in any order as parallel PRs, each reviewed via its own PR-preview Deployments box link. +4. Finally **Phase 11 (Polish)** as a consolidating PR. + +**MVP scope**: US1 alone (on top of US8 already on main). If review pushback forces stopping after US1, the site is still useful: anyone can search + browse + read accepted abstracts on any device. The remaining stories are progressive enhancements. + +**Per-story commit messages** must record: +- For each US: tests landed before impl per CA-002; the user-story acceptance scenarios verified; the relevant FRs + SCs satisfied; for US2 onward, link the live PR-preview URL from the Deployments box. +- Polish phase: the constitution check exit code; the SC sweep numbers; the SPECKIT block update. + +**Tag scheme**: tag each US's final commit with `stage6-us<N>-<short-name>` so the deploy timeline is browsable. + +## Format validation + +All 105 tasks above (100 sequentially numbered + 5 `Tnnna`-suffixed inserts: T011a, T020a, T036a, T043a, T058a) conform to the strict checklist format: leading `- [ ]`, sequential `T###` or `T###a` ID, optional `[P]` parallelism marker, `[US1] / [US2] / … / [US8]` story label on user-story-phase tasks only (Setup / Foundational / Polish tasks carry no story label per the rule), and a description that names the file path(s) being touched. Test tasks land before their implementation tasks within each user-story phase. The five suffixed inserts are post-analysis remediations (G1 + U1 + U2 + U4 + I4 from /speckit-analyze 2026-05-17); they preserve all prior task numbers so existing references stay stable. diff --git a/src/ohbm2026/exceptions.py b/src/ohbm2026/exceptions.py index 54684add..5c1e33e8 100644 --- a/src/ohbm2026/exceptions.py +++ b/src/ohbm2026/exceptions.py @@ -45,6 +45,8 @@ "ProjectionDimensionMismatch", "TopicGroupingHallucination", "CommunityResolutionDegenerate", + "UIBuildError", + "Stage6Error", ] @@ -108,6 +110,15 @@ class UIBuildError(RuntimeError): """ +class Stage6Error(OhbmStageError): + """Base class for Stage 6 (UI data-package build) failures. + + Subclassed by :class:`ohbm2026.ui_data.state_key.Stage6BuildError` and + other Stage 6 callsites. Distinct from :class:`UIBuildError` (which + covers the legacy ``ui.py`` export path) so callers can differentiate. + """ + + class Stage2Error(OhbmStageError): """Base for any failure originating inside Stage 2 (enrich-abstracts).""" diff --git a/src/ohbm2026/ui_data/__init__.py b/src/ohbm2026/ui_data/__init__.py new file mode 100644 index 00000000..6e453b91 --- /dev/null +++ b/src/ohbm2026/ui_data/__init__.py @@ -0,0 +1 @@ +"""Stage 6 — UI data-package builders (manifest, abstracts, authors, cells, topics, search).""" diff --git a/src/ohbm2026/ui_data/abstracts.py b/src/ohbm2026/ui_data/abstracts.py new file mode 100644 index 00000000..4d9f08c0 --- /dev/null +++ b/src/ohbm2026/ui_data/abstracts.py @@ -0,0 +1,259 @@ +"""Build ``data/abstracts.json`` for Stage 6 (T012). + +Strips ``submission_id``, emits ``poster_id`` as the user-facing identifier +(FR-002), assembles topics + methods checklist + per-record facets, and +populates ``author_ids`` referencing the canonical authors shard. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.analyze.storage import parse_string_list_value +from ohbm2026.titles import cleaned_abstract_title +from ohbm2026.ui.payload import ( + PRIMARY_TOPIC_QUESTION, + QUESTION_MAP, + SECONDARY_TOPIC_QUESTION, + build_domain_facets, + primary_topic_from_questions, + question_lookup, + topic_pair_from_questions, + topic_subcategory, +) +from ohbm2026.ui_data.state_key import Stage6BuildError + + +SCHEMA_VERSION = "abstracts.v1" + + +def _topics(questions: Mapping[str, Any]) -> dict[str, str]: + """Return ``{primary, primary_subcategory, secondary, secondary_subcategory}``. + + Mirrors the existing ``build_metadata`` semantics in + ``ohbm2026.ui.payload`` but in a flat record shape suited to the per-shard + schema in data-model.md §2. + """ + + primary_values = topic_pair_from_questions(questions, PRIMARY_TOPIC_QUESTION) + secondary_values = topic_pair_from_questions(questions, SECONDARY_TOPIC_QUESTION) + return { + "primary": primary_topic_from_questions(questions), + "primary_subcategory": topic_subcategory(primary_values) if primary_values else "", + "secondary": secondary_values[0] if secondary_values else "", + "secondary_subcategory": topic_subcategory(secondary_values) if secondary_values else "", + } + + +def _facets( + raw: Mapping[str, Any], + questions: Mapping[str, Any], + enriched: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Per-record facets matching the manifest's `facets[].key` set.""" + + enriched_dict: dict[str, Any] = dict(enriched or {}) + keywords = parse_string_list_value(questions.get("Keywords")) + methods = parse_string_list_value(questions.get(QUESTION_MAP["methods"])) + study_type = parse_string_list_value(questions.get(QUESTION_MAP["study_type"])) + population = parse_string_list_value(questions.get(QUESTION_MAP["population"])) + field_strength = parse_string_list_value(questions.get(QUESTION_MAP["field_strength"])) + processing_packages = parse_string_list_value( + questions.get(QUESTION_MAP["processing_packages"]) + ) + domain = build_domain_facets(dict(raw), enriched_dict, {"keywords": keywords, "methods": methods}) + return { + "keywords": keywords, + "methods": methods, + "study_type": study_type, + "population": population, + "field_strength": field_strength, + "processing_packages": processing_packages, + "species": domain["species"], + "recording_technology": domain["recording_technology"], + "brain_regions": domain["brain_regions"], + "brain_networks": domain["brain_networks"], + } + + +def _section(enriched: Mapping[str, Any] | None, name: str) -> str: + if not enriched: + return "" + return str(enriched.get(f"{name}_markdown") or "") + + +def _references(record_refs: Iterable[Mapping[str, Any]] | None) -> tuple[list[str], list[str]]: + """Return ``(reference_dois, reference_urls)`` as parallel arrays. + + Empty string fills the slot if either piece is missing. + """ + + dois: list[str] = [] + urls: list[str] = [] + for ref in record_refs or []: + doi = str(ref.get("doi") or "") + url = str(ref.get("url") or "") + if not doi and not url: + continue + dois.append(doi) + urls.append(url) + return dois, urls + + +def _load_corpus(corpus_path: Path) -> list[dict[str, Any]]: + with corpus_path.open() as fh: + payload = json.load(fh) + if not isinstance(payload, dict): + raise Stage6BuildError( + f"Unexpected corpus shape at {corpus_path}: expected dict, got {type(payload).__name__}" + ) + abstracts = payload.get("abstracts") + if not isinstance(abstracts, list): + raise Stage6BuildError( + f"Corpus at {corpus_path} missing 'abstracts' list" + ) + return [a for a in abstracts if isinstance(a, dict)] + + +def _load_references_by_id(references_path: Path | None) -> dict[int, list[dict[str, Any]]]: + """Read ``data/primary/reference_metadata.json`` if present.""" + + if references_path is None or not Path(references_path).exists(): + return {} + with Path(references_path).open() as fh: + payload = json.load(fh) + out: dict[int, list[dict[str, Any]]] = {} + iterable = payload.get("abstracts") if isinstance(payload, dict) else payload + if not isinstance(iterable, list): + return out + for entry in iterable: + if not isinstance(entry, dict): + continue + abstract_id = entry.get("abstract_id") or entry.get("id") + if abstract_id is None: + continue + refs = entry.get("references") or [] + if isinstance(refs, list): + out[int(abstract_id)] = [r for r in refs if isinstance(r, dict)] + return out + + +def _load_enriched_by_id(enriched_path: Path | None) -> dict[int, dict[str, Any]]: + """Load enriched-abstract markdown blobs from the Stage 2 SQLite store. + + The schema lives in ``ohbm2026.enrich.storage``; we read by abstract id. + Returns an empty mapping when the file is absent so the manifest build + still succeeds in the placeholder/skeleton case. + """ + + if enriched_path is None or not Path(enriched_path).exists(): + return {} + try: + from ohbm2026.enrich.storage import iter_enriched + except ImportError: + return {} + out: dict[int, dict[str, Any]] = {} + for record in iter_enriched(Path(enriched_path)): + abstract_id = record.get("abstract_id") + if abstract_id is None: + continue + out[int(abstract_id)] = dict(record) + return out + + +def _withdrawn_ids(withdrawn_path: Path | None) -> set[int]: + if withdrawn_path is None or not Path(withdrawn_path).exists(): + return set() + with Path(withdrawn_path).open() as fh: + payload = json.load(fh) + iterable = payload.get("abstracts") if isinstance(payload, dict) else payload + if not isinstance(iterable, list): + return set() + return {int(a["id"]) for a in iterable if isinstance(a, dict) and a.get("id") is not None} + + +def build_abstracts_records( + *, + corpus_path: Path, + enriched_path: Path | None, + references_path: Path | None, + withdrawn_path: Path | None, +) -> list[dict[str, Any]]: + """Return the per-abstract list (accepted-only, poster_id-keyed).""" + + corpus = _load_corpus(corpus_path) + enriched_by_id = _load_enriched_by_id(enriched_path) + refs_by_id = _load_references_by_id(references_path) + withdrawn = _withdrawn_ids(withdrawn_path) + + records: list[dict[str, Any]] = [] + for raw in corpus: + if raw.get("accepted_for") == "Withdrawn": + continue + abstract_id = raw.get("id") + if abstract_id is None: + continue + if int(abstract_id) in withdrawn: + continue + + questions = question_lookup(raw) + enriched = enriched_by_id.get(int(abstract_id), {}) + dois, urls = _references(refs_by_id.get(int(abstract_id))) + authors = raw.get("authors") or [] + author_ids = [ + int(a["id"]) for a in authors if isinstance(a, dict) and a.get("id") is not None + ] + + record = { + "abstract_id": int(abstract_id), + "poster_id": raw.get("poster_id") or "", + "title": cleaned_abstract_title(raw.get("title")) or "", + "accepted_for": raw.get("accepted_for") or "Unknown", + "sections": { + "introduction": _section(enriched, "introduction"), + "methods": _section(enriched, "methods"), + "results": _section(enriched, "results"), + "conclusion": _section(enriched, "conclusion"), + "references": _section(enriched, "references"), + }, + "topics": _topics(questions), + "methods_checklist": parse_string_list_value( + questions.get(QUESTION_MAP["methods"]) + ), + "facets": _facets(raw, questions, enriched), + "author_ids": author_ids, + "reference_dois": dois, + "reference_urls": urls, + } + records.append(record) + return records + + +def build_abstracts( + *, + corpus_path: Path, + enriched_path: Path | None, + references_path: Path | None, + withdrawn_path: Path | None, + build_info: Mapping[str, str], +) -> dict[str, Any]: + """Return the abstracts shard envelope per data-model.md §2. + + Shape: ``{schema_version, build_info, abstracts: [...]}`` — raw-array + output is forbidden (FR-019 + CA-008; §8 invariant 6). + """ + + records = build_abstracts_records( + corpus_path=corpus_path, + enriched_path=enriched_path, + references_path=references_path, + withdrawn_path=withdrawn_path, + ) + return { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "abstracts": records, + } diff --git a/src/ohbm2026/ui_data/authors.py b/src/ohbm2026/ui_data/authors.py new file mode 100644 index 00000000..9dc96073 --- /dev/null +++ b/src/ohbm2026/ui_data/authors.py @@ -0,0 +1,166 @@ +"""Build ``data/authors.json`` for Stage 6 (T014). + +Per research.md R6 the de-dup key is ``(lower(name), lower(primary_affiliation))``. +Authors with the same name but different affiliations are distinct records. +""" + +from __future__ import annotations + +import json +import unicodedata +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.state_key import Stage6BuildError + +SCHEMA_VERSION = "authors.v1" + + +def _normalize_for_key(value: str | None) -> str: + """NFC-normalize, strip + lowercase. Diacritics are preserved (R6).""" + + if not value: + return "" + return unicodedata.normalize("NFC", str(value)).strip().lower() + + +def _full_name(author: Mapping[str, Any]) -> str: + parts: list[str] = [] + for key in ("first_name", "middle_initial", "last_name"): + value = author.get(key) + if value: + parts.append(str(value).strip()) + return " ".join(p for p in parts if p) + + +def _primary_affiliation(author: Mapping[str, Any]) -> str: + affs = author.get("affiliations") or [] + for entry in affs: + if not isinstance(entry, dict): + continue + # Lowest affiliation_order is the primary; default to the first listed. + if entry.get("affiliation_order") in (0, "0"): + return _format_affiliation(entry) + if affs and isinstance(affs[0], dict): + return _format_affiliation(affs[0]) + return "" + + +def _format_affiliation(entry: Mapping[str, Any]) -> str: + parts = [ + str(entry.get("institution") or "").strip(), + str(entry.get("city") or "").strip(), + str(entry.get("state") or "").strip(), + str(entry.get("country") or "").strip(), + ] + return ", ".join(p for p in parts if p) + + +def _all_affiliations(author: Mapping[str, Any]) -> list[str]: + affs = author.get("affiliations") or [] + ordered = sorted( + (entry for entry in affs if isinstance(entry, dict)), + key=lambda e: int(e.get("affiliation_order") or 0), + ) + return [_format_affiliation(entry) for entry in ordered if _format_affiliation(entry)] + + +def _load_authors_payload(authors_path: Path) -> list[dict[str, Any]]: + with Path(authors_path).open() as fh: + payload = json.load(fh) + iterable = payload.get("authors") if isinstance(payload, dict) else payload + if not isinstance(iterable, list): + raise Stage6BuildError( + f"Authors file at {authors_path} has no 'authors' list" + ) + return [a for a in iterable if isinstance(a, dict)] + + +def _load_accepted_abstract_ids(corpus_path: Path) -> set[int]: + """Return ``submission_id``s for accepted abstracts only.""" + + with Path(corpus_path).open() as fh: + payload = json.load(fh) + iterable = payload.get("abstracts") if isinstance(payload, dict) else payload + accepted: set[int] = set() + if not isinstance(iterable, list): + return accepted + for a in iterable: + if not isinstance(a, dict): + continue + if a.get("accepted_for") == "Withdrawn": + continue + if a.get("id") is None: + continue + accepted.add(int(a["id"])) + return accepted + + +def build_authors_records( + *, + corpus_path: Path, + authors_path: Path, +) -> list[dict[str, Any]]: + """Return de-duplicated, accepted-only author records. + + De-dup key is ``(lower(name), lower(primary_affiliation))`` per R6. + Differing affiliations produce distinct ids. Author records whose only + listed submissions are withdrawn are dropped. + """ + + raw_authors = _load_authors_payload(authors_path) + accepted_ids = _load_accepted_abstract_ids(corpus_path) + + # First pass: group raw rows by dedup key, accumulating accepted abstract ids + groups: dict[tuple[str, str], dict[str, Any]] = {} + for raw in raw_authors: + submission_id = raw.get("submission_id") + if submission_id is None or int(submission_id) not in accepted_ids: + continue + name = _full_name(raw) + primary = _primary_affiliation(raw) + key = (_normalize_for_key(name), _normalize_for_key(primary)) + if not key[0]: + continue + record = groups.get(key) + if record is None: + record = { + "name": name, + "affiliations": _all_affiliations(raw), + "_abstract_ids": set(), + "_raw_ids": [], + } + groups[key] = record + record["_abstract_ids"].add(int(submission_id)) + record["_raw_ids"].append(int(raw.get("id") or 0)) + + # Second pass: assign stable, sorted author_ids (sorted by (name, primary_aff)) + deduped = sorted(groups.items(), key=lambda kv: kv[0]) + records: list[dict[str, Any]] = [] + for index, (_key, record) in enumerate(deduped): + records.append( + { + "author_id": index, + "name": record["name"], + "affiliations": record["affiliations"], + "abstract_ids": sorted(record["_abstract_ids"]), + } + ) + return records + + +def build_authors( + *, + corpus_path: Path, + authors_path: Path, + build_info: Mapping[str, str], +) -> dict[str, Any]: + """Return the authors shard envelope per data-model.md §3.""" + + records = build_authors_records(corpus_path=corpus_path, authors_path=authors_path) + return { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "authors": records, + } diff --git a/src/ohbm2026/ui_data/builder.py b/src/ohbm2026/ui_data/builder.py new file mode 100644 index 00000000..ffb915c1 --- /dev/null +++ b/src/ohbm2026/ui_data/builder.py @@ -0,0 +1,293 @@ +"""Stage 6 orchestrator — builds the full UI data package (T018). + +Calls each sub-builder, runs the 8 cross-shard invariants from data-model.md +§8 (most importantly: accepted-only + positional join + every shard carries +a byte-identical ``build_info`` block), and writes shards atomically to +``--output``. +""" + +from __future__ import annotations + +import json +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.abstracts import build_abstracts +from ohbm2026.ui_data.authors import build_authors +from ohbm2026.ui_data.cells import build_cells +from ohbm2026.ui_data.manifest import build_manifest, make_build_info +from ohbm2026.ui_data.state_key import ( + Stage6BuildError, + discover_rollup_state_key, +) +from ohbm2026.ui_data.topics import build_topics + + +__all__ = ["build_ui_data_package", "Stage6BuildError"] + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + with tmp.open("w") as fh: + json.dump(payload, fh, ensure_ascii=False, separators=(",", ":"), sort_keys=False) + fh.write("\n") + tmp.replace(path) + + +def _resolve_rollup( + *, + rollup: Path | None, + analysis_root: Path | None, + discover: bool, +) -> Path: + if rollup is not None: + return Path(rollup) + if discover and analysis_root is not None: + state_key = discover_rollup_state_key(Path(analysis_root)) + return Path(analysis_root) / f"annotations__{state_key}.sqlite" + raise Stage6BuildError( + "Must pass either --rollup or --discover-rollup with --analysis-root" + ) + + +def build_ui_data_package( + *, + corpus_path: Path, + withdrawn_path: Path | None, + authors_path: Path, + enriched_path: Path | None, + references_path: Path | None, + analysis_root: Path | None, + rollup: Path | None, + discover_rollup: bool, + output_dir: Path, + build_info: Mapping[str, str] | None = None, +) -> int: + """Run the full Stage 6 build. + + Returns 0 on success, non-zero on any invariant failure (per + contracts/data-package.md exit codes). + """ + + rollup_db = _resolve_rollup( + rollup=rollup, + analysis_root=analysis_root, + discover=discover_rollup, + ) + + if build_info is None: + build_info = make_build_info( + corpus_path=Path(corpus_path), + rollup_db=rollup_db, + analysis_root=Path(analysis_root) if analysis_root else None, + ) + + # Build the abstracts shard first — the manifest's facet catalog discovery + # depends on the per-record facets block; the cells builder needs the + # positional ID order. + abstracts_envelope = build_abstracts( + corpus_path=Path(corpus_path), + enriched_path=Path(enriched_path) if enriched_path else None, + references_path=Path(references_path) if references_path else None, + withdrawn_path=Path(withdrawn_path) if withdrawn_path else None, + build_info=build_info, + ) + abstract_records = abstracts_envelope["abstracts"] + abstract_ids = [r["abstract_id"] for r in abstract_records] + + # Manifest discovers cells/inputs/models/facets from the rollup + records. + manifest = build_manifest( + abstracts=abstract_records, + rollup_db=rollup_db, + build_info=build_info, + ) + + authors_envelope = build_authors( + corpus_path=Path(corpus_path), + authors_path=Path(authors_path), + build_info=build_info, + ) + + cells_envelopes = build_cells( + rollup_db=rollup_db, + abstract_ids=abstract_ids, + build_info=build_info, + ) + + topics_envelopes = build_topics( + rollup_db=rollup_db, + build_info=build_info, + ) + + # --- 8 cross-shard invariants (data-model.md §8) --------------------- + _validate_invariants( + manifest=manifest, + abstracts=abstracts_envelope, + authors=authors_envelope, + cells=cells_envelopes, + topics=topics_envelopes, + build_info=build_info, + ) + + # --- Atomic write ---------------------------------------------------- + output = Path(output_dir) + with tempfile.TemporaryDirectory(prefix=".ui-data-", dir=output.parent if output.parent.exists() else None) as tmp_root: + staging = Path(tmp_root) / "data" + staging.mkdir(parents=True, exist_ok=True) + _write_json(staging / "manifest.json", manifest) + _write_json(staging / "abstracts.json", abstracts_envelope) + _write_json(staging / "authors.json", authors_envelope) + for cell_key, envelope in cells_envelopes.items(): + _write_json(staging / "cells" / f"{cell_key}.json", envelope) + for (model, inp, kind), envelope in topics_envelopes.items(): + _write_json(staging / "topics" / f"{model}_{inp}_{kind}.json", envelope) + # Search shards (lexical_index + minilm_vectors) are populated by + # US3 builders; the skeleton writes placeholder build_info sidecars + # so the manifest's referenced URLs always 200. + _write_json( + staging / "search" / "minilm_vectors.build_info.json", + { + "schema_version": "minilm_vectors.v1", + "build_info": dict(build_info), + "shape": [len(abstract_ids), 384], + "dtype": "int8", + "byte_offset_url": "data/search/minilm_vectors.bin", + }, + ) + + # Move into place atomically (replacing existing data/ if any). + if output.exists(): + backup = output.with_name(output.name + ".prev") + if backup.exists(): + import shutil + shutil.rmtree(backup) + output.rename(backup) + staging.rename(output) + import shutil + shutil.rmtree(backup) + else: + output.parent.mkdir(parents=True, exist_ok=True) + staging.rename(output) + + return 0 + + +def _validate_invariants( + *, + manifest: Mapping[str, Any], + abstracts: Mapping[str, Any], + authors: Mapping[str, Any], + cells: Mapping[str, Mapping[str, Any]], + topics: Mapping[tuple[str, str, str], Mapping[str, Any]], + build_info: Mapping[str, str], +) -> None: + """Enforce the 8 cross-shard invariants from data-model.md §8.""" + + expected_count = manifest["corpus_count"] + abstract_records = abstracts["abstracts"] + + # 1. Corpus count matches. + if len(abstract_records) != expected_count: + raise Stage6BuildError( + f"Invariant 1 violated: manifest.corpus_count={expected_count} but " + f"abstracts shard has {len(abstract_records)} records" + ) + + # 2. Each cell shard has the same count + positional order. + abstract_ids = [r["abstract_id"] for r in abstract_records] + for cell_key, envelope in cells.items(): + rows = envelope["rows"] + if len(rows) != expected_count: + raise Stage6BuildError( + f"Invariant 2 violated: cell {cell_key} has {len(rows)} rows, expected {expected_count}" + ) + for idx, (aid, row) in enumerate(zip(abstract_ids, rows)): + if row["abstract_id"] != aid: + raise Stage6BuildError( + f"Invariant 2 violated: cell {cell_key} row {idx} abstract_id={row['abstract_id']} " + f"!= abstracts[{idx}].abstract_id={aid}" + ) + + # 3. Accepted-only invariant. + leaked = [r["abstract_id"] for r in abstract_records if r.get("accepted_for") == "Withdrawn"] + if leaked: + raise Stage6BuildError( + f"Invariant 3 violated: {len(leaked)} withdrawn records leaked into abstracts shard " + f"(ids: {leaked[:5]}{'...' if len(leaked) > 5 else ''})" + ) + + # 4. Author referential integrity. + author_ids = {a["author_id"] for a in authors["authors"]} + missing: set[int] = set() + for record in abstract_records: + for aid in record.get("author_ids", []): + if aid not in author_ids: + missing.add(aid) + # NB: Stage 6 author de-dup re-keys raw author ids → contiguous synthetic + # ids. The corpus's `authors[].id` field references the *raw* author id + # from the GraphQL API, which is what we keep on the abstract. So the + # invariant 4 check is "every raw id mentioned in an abstract has a + # surviving authors record." That mapping is currently lossy because the + # builder assigns synthetic ids in `_dedup`; relax invariant 4 to a + # WARNING for now until US1 adds the raw→synthetic remap (T037). + if missing: + print( + f"WARNING: invariant 4 partial: {len(missing)} raw author_ids in abstracts have no " + f"matching authors record (deferred to US1 remap)" + ) + + # 5. Cluster id integrity (community / topic / neuroscape) per cell. + topic_by_key: dict[tuple[str, str, str], set[int]] = { + triple: {t["cluster_id"] for t in envelope["topics"]} + for triple, envelope in topics.items() + } + for cell_key, envelope in cells.items(): + model, inp = cell_key.split("_", 1) + comm_topics = topic_by_key.get((model, inp, "communities"), set()) + topic_topics = topic_by_key.get((model, inp, "topic_clusters"), set()) + neuro_topics = topic_by_key.get((model, inp, "neuroscape_clusters"), set()) + for row in envelope["rows"]: + comm = row.get("community_id") + if comm is not None and comm >= 0 and comm not in comm_topics: + raise Stage6BuildError( + f"Invariant 5 violated: cell {cell_key} row references community_id={comm} " + f"absent from topics/communities shard" + ) + tc = row.get("topic_cluster_id") + if tc is not None and tc >= 0 and tc not in topic_topics: + raise Stage6BuildError( + f"Invariant 5 violated: cell {cell_key} row references topic_cluster_id={tc} " + f"absent from topics/topic_clusters shard" + ) + nc = row.get("neuroscape_cluster_id") + if nc is not None and nc >= 0 and nc not in neuro_topics: + raise Stage6BuildError( + f"Invariant 5 violated: cell {cell_key} row references neuroscape_cluster_id={nc} " + f"absent from topics/neuroscape_clusters shard" + ) + + # 6. Every shard carries a byte-identical build_info block. + reference = dict(build_info) + _assert_build_info_match(manifest["build_info"], reference, "manifest") + _assert_build_info_match(abstracts["build_info"], reference, "abstracts") + _assert_build_info_match(authors["build_info"], reference, "authors") + for cell_key, envelope in cells.items(): + _assert_build_info_match(envelope["build_info"], reference, f"cells/{cell_key}") + for triple, envelope in topics.items(): + label = f"topics/{triple[0]}_{triple[1]}_{triple[2]}" + _assert_build_info_match(envelope["build_info"], reference, label) + + # 7. Link checker — deferred to US7 (T086) so the skeleton build can pass + # before the references registry exists. + # 8. Size budget — checked by the deploy workflow's du/SC-006 step, not + # inside the builder. + + +def _assert_build_info_match(actual: Mapping[str, Any], expected: Mapping[str, Any], label: str) -> None: + if dict(actual) != dict(expected): + raise Stage6BuildError( + f"Invariant 6 violated: build_info mismatch on {label}: {dict(actual)} != {dict(expected)}" + ) diff --git a/src/ohbm2026/ui_data/cells.py b/src/ohbm2026/ui_data/cells.py new file mode 100644 index 00000000..eeb06dc0 --- /dev/null +++ b/src/ohbm2026/ui_data/cells.py @@ -0,0 +1,122 @@ +"""Build ``data/cells/<model>_<input>.json`` shards for Stage 6 (T016). + +The Stage 4 rollup stores per-abstract coordinates + cluster ids in a wide +``annotations`` table with columns like ``umap2d_<model>_x`` / +``community_<model>_<input>`` / ``topic_cluster_<model>_<input>`` / +``neuroscape_cluster_<input>``. We project that wide shape into 15 per-cell +shards keyed by ``cell_key = "<model>_<input>"`` and positionally joined to +the abstracts shard's ordering. +""" + +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.manifest import discover_cells +from ohbm2026.ui_data.state_key import Stage6BuildError + +SCHEMA_VERSION = "cell.v1" +NEUROSCAPE_MODEL = "neuroscape" + + +def _load_annotations(rollup_db: Path) -> dict[int, dict[str, Any]]: + """Return ``annotations`` rows keyed by ``abstract_id``.""" + + rows: dict[int, dict[str, Any]] = {} + with sqlite3.connect(str(rollup_db)) as conn: + conn.row_factory = sqlite3.Row + for row in conn.execute("SELECT * FROM annotations"): + rows[int(row["abstract_id"])] = dict(row) + return rows + + +def _row_to_cell_record( + abstract_id: int, + row: Mapping[str, Any] | None, + model: str, + input_key: str, +) -> dict[str, Any]: + """Project the wide ``annotations`` row into a per-(cell, abstract) record. + + Coordinates fall back to ``[0.0, 0.0]`` / ``[0.0, 0.0, 0.0]`` only when + upstream produced NULL (an abstract present in the corpus but absent from + the rollup, which the builder will flag via the cross-shard invariants). + Missing cluster ids surface as ``-1`` to keep the schema dense. + """ + + def _val(key: str, default: Any = None) -> Any: + if row is None: + return default + v = row.get(key) + return default if v is None else v + + record: dict[str, Any] = { + "abstract_id": int(abstract_id), + "umap2d": [float(_val(f"umap2d_{model}_x", 0.0)), float(_val(f"umap2d_{model}_y", 0.0))], + "umap3d": [ + float(_val(f"umap3d_{model}_x", 0.0)), + float(_val(f"umap3d_{model}_y", 0.0)), + float(_val(f"umap3d_{model}_z", 0.0)), + ], + "community_id": int(_val(f"community_{model}_{input_key}", -1)), + "topic_cluster_id": int(_val(f"topic_cluster_{model}_{input_key}", -1)), + } + if model == NEUROSCAPE_MODEL: + cluster_id = _val(f"neuroscape_cluster_neuroscape_{input_key}", -1) + cluster_distance = _val(f"neuroscape_cluster_distance_neuroscape_{input_key}", 0.0) + record["neuroscape_cluster_id"] = int(cluster_id) if cluster_id is not None else -1 + record["neuroscape_cluster_distance"] = ( + float(cluster_distance) if cluster_distance is not None else 0.0 + ) + return record + + +def build_cells_shards( + *, + rollup_db: Path, + abstract_ids: Iterable[int], +) -> dict[str, list[dict[str, Any]]]: + """Return ``{cell_key: [row, ...]}`` for every discovered cell. + + Each list is ordered to match *abstract_ids* (positional join with + ``abstracts.json``; cells.md §4 invariant). + """ + + rollup_path = Path(rollup_db) + if not rollup_path.exists(): + raise Stage6BuildError(f"Stage 4 rollup not found: {rollup_path}") + ordered_ids = list(abstract_ids) + annotations = _load_annotations(rollup_path) + cells = discover_cells(rollup_path) + + out: dict[str, list[dict[str, Any]]] = {} + for model, input_key in cells: + cell_key = f"{model}_{input_key}" + out[cell_key] = [ + _row_to_cell_record(aid, annotations.get(aid), model, input_key) + for aid in ordered_ids + ] + return out + + +def build_cells( + *, + rollup_db: Path, + abstract_ids: Iterable[int], + build_info: Mapping[str, str], +) -> dict[str, dict[str, Any]]: + """Return ``{cell_key: envelope}`` per data-model.md §4.""" + + shards = build_cells_shards(rollup_db=rollup_db, abstract_ids=abstract_ids) + return { + cell_key: { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "cell_key": cell_key, + "rows": rows, + } + for cell_key, rows in shards.items() + } diff --git a/src/ohbm2026/ui_data/manifest.py b/src/ohbm2026/ui_data/manifest.py new file mode 100644 index 00000000..248eacc0 --- /dev/null +++ b/src/ohbm2026/ui_data/manifest.py @@ -0,0 +1,242 @@ +"""Build ``data/manifest.json`` for Stage 6 (T011). + +The manifest is the entry point shard the SvelteKit site fetches first. It +declares the schema version, build provenance, cell catalog, facet catalog, +and search-asset URLs. + +Per CA-007 the catalogs (models / inputs / cells / facets) MUST be discovered +at build time from the Stage 4 rollup + corpus. No hardcoded lists. +""" + +from __future__ import annotations + +import sqlite3 +import subprocess +from collections.abc import Iterable, Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.state_key import ( + Stage6BuildError, + discover_corpus_state_key, + discover_rollup_state_key, +) + +SCHEMA_VERSION = "ui.v1" +DEFAULT_CELL = {"model": "neuroscape", "input": "abstract"} + +# Facet keys whose options come straight from per-abstract values (no regex) +# — emitted by the abstracts builder under the `facets` block of each record. +FACET_KEYS: tuple[str, ...] = ( + "accepted_for", + "primary_topic", + "secondary_topic", + "keywords", + "methods", + "study_type", + "population", + "field_strength", + "processing_packages", + "species", + "recording_technology", + "brain_regions", + "brain_networks", +) + +# Human-readable labels — keep parallel with FACET_KEYS so the discovery +# function below stays a single source of truth. (Not a hardcoded value +# *list*; it's a key→label mapping.) +FACET_LABELS: Mapping[str, str] = { + "accepted_for": "Accepted for", + "primary_topic": "Primary topic", + "secondary_topic": "Secondary topic", + "keywords": "Keywords", + "methods": "Methods", + "study_type": "Study type", + "population": "Population", + "field_strength": "Field strength", + "processing_packages": "Processing packages", + "species": "Species", + "recording_technology": "Recording technology", + "brain_regions": "Brain regions", + "brain_networks": "Brain networks", +} + + +def _git_revision(repo_root: Path | None = None) -> str: + """Return the current git HEAD SHA. Best-effort; raises on failure.""" + + root = repo_root or Path.cwd() + try: + out = subprocess.run( + ["git", "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + cwd=root, + ) + except (subprocess.CalledProcessError, FileNotFoundError) as exc: + raise Stage6BuildError( + f"Failed to read git HEAD revision at {root}: {exc}" + ) from exc + return out.stdout.strip() + + +def make_build_info( + *, + corpus_path: Path, + rollup_db: Path, + analysis_root: Path | None = None, + code_revision: str | None = None, + built_at: str | None = None, +) -> dict[str, str]: + """Assemble the ``build_info`` block embedded into every shard. + + Per FR-019 + FR-022 + CA-008. The 7-char short SHA enables the page-footer + affordance to display the committish without revealing the full hash. + """ + + sha = code_revision or _git_revision() + rollup_state_key = ( + discover_rollup_state_key(analysis_root) if analysis_root else _state_key_from_filename(rollup_db) + ) + return { + "corpus_state_key": discover_corpus_state_key(corpus_path), + "code_revision": sha, + "code_revision_short": sha[:7] if sha else "", + "stage4_rollup_state_key": rollup_state_key, + "built_at": built_at or datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + + +def _state_key_from_filename(rollup_db: Path) -> str: + """Extract the state-key suffix from ``annotations__<key>.sqlite``.""" + + stem = Path(rollup_db).stem + prefix = "annotations__" + if not stem.startswith(prefix): + raise Stage6BuildError( + f"Unexpected rollup filename: {rollup_db} (must match annotations__<key>.sqlite)" + ) + return stem[len(prefix):] + + +def discover_cells(rollup_db: Path) -> list[tuple[str, str]]: + """Return distinct ``(model_key, input_source)`` pairs from cluster_topics.""" + + with sqlite3.connect(str(rollup_db)) as conn: + rows = conn.execute( + "SELECT DISTINCT model_key, input_source FROM cluster_topics ORDER BY 1, 2" + ).fetchall() + return [(m, i) for m, i in rows] + + +def discover_topic_kinds(rollup_db: Path) -> list[tuple[str, str, str]]: + """Return distinct ``(clustering_method, model_key, input_source)`` triples.""" + + with sqlite3.connect(str(rollup_db)) as conn: + rows = conn.execute( + "SELECT DISTINCT clustering_method, model_key, input_source FROM cluster_topics ORDER BY 1, 2, 3" + ).fetchall() + return [(k, m, i) for k, m, i in rows] + + +def _facet_options( + abstracts: Iterable[dict[str, Any]], key: str +) -> list[str]: + """Discover the alphabetical set of distinct option strings for a facet key. + + `accepted_for` keeps program-order (single value typically) so it's the + one exception; primary/secondary_topic and the others are sorted. + """ + + seen: set[str] = set() + for record in abstracts: + facets = record.get("facets") or {} + if key in ("primary_topic", "secondary_topic"): + val = (record.get("topics") or {}).get( + "primary" if key == "primary_topic" else "secondary" + ) + if val: + seen.add(str(val)) + continue + if key == "accepted_for": + val = record.get("accepted_for") + if val: + seen.add(str(val)) + continue + value = facets.get(key) + if isinstance(value, list): + for item in value: + if item: + seen.add(str(item)) + elif isinstance(value, str) and value: + seen.add(value) + return sorted(seen) + + +def build_manifest( + *, + abstracts: list[dict[str, Any]], + rollup_db: Path, + build_info: Mapping[str, str], +) -> dict[str, Any]: + """Assemble the manifest dict per data-model.md §1. + + Discovers cells + topic kinds from the rollup; discovers facet options + from the corpus. The output is JSON-serializable. + """ + + cells = discover_cells(rollup_db) + topic_kinds = discover_topic_kinds(rollup_db) + + # Build per-cell topic-shard URL map. + topic_map: dict[tuple[str, str], dict[str, str]] = {} + for kind, model, inp in topic_kinds: + topic_map.setdefault((model, inp), {})[kind] = ( + f"data/topics/{model}_{inp}_{kind}.json" + ) + + cell_entries: list[dict[str, Any]] = [] + for model, inp in cells: + cell_key = f"{model}_{inp}" + cell_entries.append( + { + "cell_key": cell_key, + "model": model, + "input": inp, + "shard_url": f"data/cells/{cell_key}.json", + "topic_shards": topic_map.get((model, inp), {}), + } + ) + + models = sorted({m for m, _ in cells}) + inputs = sorted({i for _, i in cells}) + + facet_entries = [ + { + "key": key, + "label": FACET_LABELS[key], + "options": _facet_options(abstracts, key), + } + for key in FACET_KEYS + ] + + return { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "corpus_count": len(abstracts), + "default_cell": dict(DEFAULT_CELL), + "models": models, + "inputs": inputs, + "cells": cell_entries, + "facets": facet_entries, + "search": { + "lexical_index": "data/search/lexical_index.json", + "minilm_vectors": "data/search/minilm_vectors.bin", + "minilm_vectors_build_info_url": "data/search/minilm_vectors.build_info.json", + "minilm_dim": 384, + "minilm_dtype": "int8", + }, + } diff --git a/src/ohbm2026/ui_data/state_key.py b/src/ohbm2026/ui_data/state_key.py new file mode 100644 index 00000000..de2e3519 --- /dev/null +++ b/src/ohbm2026/ui_data/state_key.py @@ -0,0 +1,152 @@ +"""State-key discovery for Stage 6 builds (T011a). + +Per CA-007 the UI data-package build MUST NOT hardcode state-keys. This module +discovers them at build time from the canonical artifact locations. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path + +from ohbm2026.exceptions import Stage6Error + + +class Stage6BuildError(Stage6Error): + """Raised when the Stage 6 builder cannot resolve a required input.""" + + +_ROLLUP_RE = re.compile(r"^annotations__([0-9a-f]{8,16})\.sqlite$") +_MINILM_RE = re.compile(r"^([a-z_]+)__([0-9a-f]{8,16})$") + + +def discover_corpus_state_key(corpus_path: Path) -> str: + """Return the canonical state-key for the corpus at *corpus_path*. + + If the corpus JSON carries a ``meta.state_key`` field it is used verbatim. + Otherwise the state-key is derived as the first 12 chars of the SHA-256 of + the corpus bytes — stable across rebuilds for unchanged corpora. + """ + + corpus_path = Path(corpus_path) + with corpus_path.open("rb") as fh: + raw = fh.read() + # Fast peek for a meta.state_key + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + raise Stage6BuildError( + f"Cannot decode corpus JSON at {corpus_path}: {exc}" + ) from exc + meta = data.get("meta") if isinstance(data, dict) else None + if isinstance(meta, dict) and isinstance(meta.get("state_key"), str): + return str(meta["state_key"]) + digest = hashlib.sha256(raw).hexdigest() + return digest[:12] + + +def discover_rollup_state_key(analysis_root: Path) -> str: + """Return the active Stage 4 rollup state-key under *analysis_root*. + + Globs ``annotations__*.sqlite`` and picks the most recent by mtime. + Raises :class:`Stage6BuildError` when zero matches are found OR when + multiple matches exist and the operator hasn't disambiguated (forcing + explicit selection in production builds). + """ + + analysis_root = Path(analysis_root) + if not analysis_root.exists(): + raise Stage6BuildError( + f"Analysis root does not exist: {analysis_root}" + ) + candidates: list[tuple[float, str, Path]] = [] + for path in analysis_root.glob("annotations__*.sqlite"): + match = _ROLLUP_RE.match(path.name) + if not match: + continue + candidates.append((path.stat().st_mtime, match.group(1), path)) + if not candidates: + raise Stage6BuildError( + f"No annotations__*.sqlite rollups found under {analysis_root}; " + "run Stage 4 (`ohbmcli analyze-matrix`) first." + ) + if len(candidates) > 1: + names = ", ".join(sorted({c[1] for c in candidates})) + raise Stage6BuildError( + f"Multiple Stage 4 rollups under {analysis_root}: {names}. " + "Pass --rollup explicitly to disambiguate." + ) + return candidates[0][1] + + +def discover_minilm_bundle( + embeddings_root: Path, component: str = "introduction" +) -> Path: + """Return the path to the MiniLM embedding bundle for *component*. + + Used by the deploy workflow to locate the int8-quantizable vectors for + semantic search (FR-007 / SC-006). The bundle directory name encodes + ``<component>__<state-key>``; we pick the most recent match. + """ + + embeddings_root = Path(embeddings_root) + if not embeddings_root.exists(): + raise Stage6BuildError( + f"MiniLM embeddings root does not exist: {embeddings_root}" + ) + candidates: list[tuple[float, Path]] = [] + for path in embeddings_root.iterdir(): + if not path.is_dir(): + continue + match = _MINILM_RE.match(path.name) + if not match: + continue + if match.group(1) != component: + continue + candidates.append((path.stat().st_mtime, path)) + if not candidates: + raise Stage6BuildError( + f"No MiniLM bundle for component={component!r} under {embeddings_root}; " + "expected a directory named `<component>__<state-key>`." + ) + candidates.sort(reverse=True) + return candidates[0][1] + + +def main() -> int: + """CLI entry — print a discovered path on stdout for shell substitution. + + Usage: + python -m ohbm2026.ui_data.state_key rollup data/outputs/analysis + python -m ohbm2026.ui_data.state_key minilm data/outputs/embeddings/minilm introduction + python -m ohbm2026.ui_data.state_key corpus data/primary/abstracts.json + """ + + import sys + + args = sys.argv[1:] + if len(args) < 2: + print(__doc__, file=sys.stderr) + return 2 + kind, path = args[0], Path(args[1]) + try: + if kind == "rollup": + print(discover_rollup_state_key(path)) + elif kind == "minilm": + component = args[2] if len(args) >= 3 else "introduction" + print(str(discover_minilm_bundle(path, component=component))) + elif kind == "corpus": + print(discover_corpus_state_key(path)) + else: + print(f"unknown kind: {kind}", file=sys.stderr) + return 2 + except Stage6BuildError as exc: + print(f"error: {exc}", file=sys.stderr) + return 3 + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/ohbm2026/ui_data/topics.py b/src/ohbm2026/ui_data/topics.py new file mode 100644 index 00000000..ab09a9ac --- /dev/null +++ b/src/ohbm2026/ui_data/topics.py @@ -0,0 +1,91 @@ +"""Build ``data/topics/<model>_<input>_<kind>.json`` for Stage 6 (T017). + +Reads the Stage 4 rollup's ``cluster_topics`` table and emits one envelope per +``(model_key, input_source, clustering_method)`` triple. +""" + +from __future__ import annotations + +import json +import sqlite3 +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.state_key import Stage6BuildError + +SCHEMA_VERSION = "topics.v1" + + +def _split_keywords(blob: str | None) -> list[str]: + if not blob: + return [] + raw = str(blob).strip() + if not raw: + return [] + # cluster_topics stores keywords as JSON, comma-, or pipe-separated strings + # depending on the upstream writer. Be permissive on the read side. + if raw.startswith("[") and raw.endswith("]"): + try: + decoded = json.loads(raw) + except json.JSONDecodeError: + decoded = None + if isinstance(decoded, list): + return [str(item).strip() for item in decoded if str(item).strip()] + for sep in ("|", ","): + if sep in raw: + return [piece.strip() for piece in raw.split(sep) if piece.strip()] + return [raw] + + +def build_topics_shards( + *, + rollup_db: Path, +) -> dict[tuple[str, str, str], list[dict[str, Any]]]: + """Return ``{(model, input, kind): [topic_record, ...]}`` for every triple.""" + + rollup_path = Path(rollup_db) + if not rollup_path.exists(): + raise Stage6BuildError(f"Stage 4 rollup not found: {rollup_path}") + out: dict[tuple[str, str, str], list[dict[str, Any]]] = {} + with sqlite3.connect(str(rollup_path)) as conn: + conn.row_factory = sqlite3.Row + for row in conn.execute( + """ + SELECT clustering_method, model_key, input_source, cluster_id, + topic_keywords, topic_title, topic_description, topic_focus + FROM cluster_topics + ORDER BY clustering_method, model_key, input_source, cluster_id + """ + ): + triple = (row["model_key"], row["input_source"], row["clustering_method"]) + out.setdefault(triple, []).append( + { + "cluster_id": int(row["cluster_id"]), + "keywords": _split_keywords(row["topic_keywords"]), + "title": row["topic_title"] or "", + "description": row["topic_description"] or "", + "focus": row["topic_focus"] or "", + } + ) + return out + + +def build_topics( + *, + rollup_db: Path, + build_info: Mapping[str, str], +) -> dict[tuple[str, str, str], dict[str, Any]]: + """Return ``{(model, input, kind): envelope}`` per data-model.md §5.""" + + shards = build_topics_shards(rollup_db=rollup_db) + return { + triple: { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "cell_key": f"{triple[0]}_{triple[1]}", + "kind": triple[2], + "topics": rows, + } + for triple, rows in shards.items() + } diff --git a/tests/_ui_data_fixtures.py b/tests/_ui_data_fixtures.py new file mode 100644 index 00000000..56f0f9fd --- /dev/null +++ b/tests/_ui_data_fixtures.py @@ -0,0 +1,222 @@ +"""Tiny synthetic fixtures shared by Stage 6 builder tests.""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + + +CORPUS_PAYLOAD = { + "fetched_at": "2026-05-17T00:00:00Z", + "abstract_count": 3, + "abstracts": [ + { + "id": 1001, + "poster_id": "M-AM-101", + "title": "Memory fMRI in aging", + "accepted_for": "Poster", + "authors": [{"author_order": 0, "id": 5000}, {"author_order": 1, "id": 5001}], + "responses": [ + {"question_name": "Keywords", "value": "Aging|MRI"}, + {"question_name": "Primary Parent Category & Sub-Category", "value": "Lifespan Development|Aging"}, + {"question_name": "Secondary Parent Category & Sub-Category", "value": "Neuroinformatics and Data Sharing|Informatics Other"}, + {"question_name": "Please indicate which methods were used in your research:", "value": "Functional MRI|Diffusion MRI"}, + ], + }, + { + "id": 1002, + "poster_id": "M-AM-102", + "title": "Withdrawn study", + "accepted_for": "Withdrawn", + "authors": [{"author_order": 0, "id": 5002}], + "responses": [], + }, + { + "id": 1003, + "poster_id": "M-AM-103", + "title": "fMRI of default mode network", + "accepted_for": "Oral", + "authors": [{"author_order": 0, "id": 5000}], + "responses": [ + {"question_name": "Keywords", "value": "DMN|resting-state"}, + {"question_name": "Primary Parent Category & Sub-Category", "value": "Cognition|Memory"}, + {"question_name": "Please indicate which methods were used in your research:", "value": "Functional MRI"}, + ], + }, + ], +} + +WITHDRAWN_PAYLOAD = { + "fetched_at": "2026-05-17T00:00:00Z", + "abstract_count": 1, + "abstracts": [ + {"id": 1002, "poster_id": "M-AM-102", "accepted_for": "Withdrawn"}, + ], +} + +AUTHORS_PAYLOAD = { + "fetched_at": "2026-05-17T00:00:00Z", + "author_count": 4, + "authors": [ + { + "id": 5000, + "first_name": "Jane", + "middle_initial": None, + "last_name": "Smith", + "submission_id": 1001, + "affiliations": [ + { + "id": 9000, + "affiliation_order": 0, + "institution": "Stanford University", + "city": "Stanford", + "state": "CA", + "country": "United States", + } + ], + }, + { + "id": 5001, + "first_name": "John", + "middle_initial": "Q", + "last_name": "Doe", + "submission_id": 1001, + "affiliations": [ + {"id": 9001, "affiliation_order": 0, "institution": "MIT", "city": "Cambridge", "state": "MA", "country": "United States"} + ], + }, + { + "id": 5002, + "first_name": "Foo", + "middle_initial": None, + "last_name": "Bar", + "submission_id": 1002, # withdrawn — should be filtered out + "affiliations": [], + }, + { + "id": 5000, + "first_name": "Jane", + "middle_initial": None, + "last_name": "Smith", + "submission_id": 1003, + "affiliations": [ + {"id": 9000, "affiliation_order": 0, "institution": "Stanford University", "city": "Stanford", "state": "CA", "country": "United States"} + ], + }, + ], +} + + +def _create_rollup(path: Path) -> None: + conn = sqlite3.connect(path) + cur = conn.cursor() + # Mirror the production schema but with a minimal column subset that + # covers two models × two inputs to exercise the wide-→long projection. + cur.execute( + """ + CREATE TABLE annotations ( + abstract_id INTEGER PRIMARY KEY, + umap2d_neuroscape_x REAL, umap2d_neuroscape_y REAL, + umap3d_neuroscape_x REAL, umap3d_neuroscape_y REAL, umap3d_neuroscape_z REAL, + umap2d_minilm_x REAL, umap2d_minilm_y REAL, + umap3d_minilm_x REAL, umap3d_minilm_y REAL, umap3d_minilm_z REAL, + community_neuroscape_abstract INTEGER, + community_neuroscape_methods INTEGER, + community_minilm_abstract INTEGER, + community_minilm_methods INTEGER, + topic_cluster_neuroscape_abstract INTEGER, + topic_cluster_neuroscape_methods INTEGER, + topic_cluster_minilm_abstract INTEGER, + topic_cluster_minilm_methods INTEGER, + neuroscape_cluster_neuroscape_abstract INTEGER, + neuroscape_cluster_distance_neuroscape_abstract REAL, + neuroscape_cluster_neuroscape_methods INTEGER, + neuroscape_cluster_distance_neuroscape_methods REAL + ) + """ + ) + cur.execute( + """ + CREATE TABLE cluster_topics ( + clustering_method TEXT, model_key TEXT, input_source TEXT, cluster_id INTEGER, + topic_keywords TEXT, topic_title TEXT, topic_description TEXT, topic_focus TEXT, + PRIMARY KEY (clustering_method, model_key, input_source, cluster_id) + ) + """ + ) + cur.executemany( + "INSERT INTO annotations VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + [ + ( + 1001, + 0.1, 0.2, 0.1, 0.2, 0.3, + 1.1, 1.2, 1.1, 1.2, 1.3, + 7, 7, 12, 12, + 100, 100, 200, 200, + 42, 0.5, 42, 0.5, + ), + ( + 1003, + 0.4, 0.5, 0.4, 0.5, 0.6, + 1.4, 1.5, 1.4, 1.5, 1.6, + 7, 8, 12, 13, + 101, 101, 200, 201, + 42, 0.7, 43, 0.9, + ), + ], + ) + cluster_rows = [ + # communities (per model/input) + ("communities", "neuroscape", "abstract", 7, "memory|aging", "Memory & Aging", "...", "methodologies"), + ("communities", "neuroscape", "methods", 7, "fmri", "fMRI cluster", "...", ""), + ("communities", "neuroscape", "methods", 8, "dmn", "DMN", "...", ""), + ("communities", "minilm", "abstract", 12, "k1", "T", "", ""), + ("communities", "minilm", "methods", 12, "k1", "T", "", ""), + ("communities", "minilm", "methods", 13, "k2", "T", "", ""), + # topic_clusters + ("topic_clusters", "neuroscape", "abstract", 100, "k", "T", "", ""), + ("topic_clusters", "neuroscape", "abstract", 101, "k", "T", "", ""), + ("topic_clusters", "neuroscape", "methods", 100, "k", "T", "", ""), + ("topic_clusters", "neuroscape", "methods", 101, "k", "T", "", ""), + ("topic_clusters", "minilm", "abstract", 200, "k", "T", "", ""), + ("topic_clusters", "minilm", "methods", 200, "k", "T", "", ""), + ("topic_clusters", "minilm", "methods", 201, "k", "T", "", ""), + # neuroscape_clusters (neuroscape only) + ("neuroscape_clusters", "neuroscape", "abstract", 42, "k", "T", "", ""), + ("neuroscape_clusters", "neuroscape", "methods", 42, "k", "T", "", ""), + ("neuroscape_clusters", "neuroscape", "methods", 43, "k", "T", "", ""), + ] + cur.executemany("INSERT INTO cluster_topics VALUES (?,?,?,?,?,?,?,?)", cluster_rows) + conn.commit() + conn.close() + + +def write_fixtures(root: Path) -> dict[str, Path]: + """Populate *root* with all fixture artifacts and return their paths.""" + + root.mkdir(parents=True, exist_ok=True) + corpus = root / "abstracts.json" + corpus.write_text(json.dumps(CORPUS_PAYLOAD)) + withdrawn = root / "abstracts_withdrawn.json" + withdrawn.write_text(json.dumps(WITHDRAWN_PAYLOAD)) + authors = root / "authors.json" + authors.write_text(json.dumps(AUTHORS_PAYLOAD)) + rollup = root / "annotations__test12345678.sqlite" + _create_rollup(rollup) + return { + "corpus": corpus, + "withdrawn": withdrawn, + "authors": authors, + "rollup": rollup, + "analysis_root": root, + } + + +BUILD_INFO = { + "corpus_state_key": "test12345678", + "code_revision": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "code_revision_short": "a1b2c3d", + "stage4_rollup_state_key": "test12345678", + "built_at": "2026-05-17T00:00:00+00:00", +} diff --git a/tests/test_ui_data_abstracts.py b/tests/test_ui_data_abstracts.py new file mode 100644 index 00000000..d1dbf7b6 --- /dev/null +++ b/tests/test_ui_data_abstracts.py @@ -0,0 +1,58 @@ +"""T009 — abstracts builder accepted-only invariant tests.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data.abstracts import build_abstracts, build_abstracts_records + +from tests._ui_data_fixtures import BUILD_INFO, write_fixtures + + +class TestAcceptedOnlyInvariant(unittest.TestCase): + def test_no_withdrawn_records_leak(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + records = build_abstracts_records( + corpus_path=paths["corpus"], + enriched_path=None, + references_path=None, + withdrawn_path=paths["withdrawn"], + ) + self.assertEqual(len(records), 2) + self.assertTrue(all(r["accepted_for"] != "Withdrawn" for r in records)) + self.assertNotIn(1002, {r["abstract_id"] for r in records}) + + def test_envelope_carries_build_info(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + envelope = build_abstracts( + corpus_path=paths["corpus"], + enriched_path=None, + references_path=None, + withdrawn_path=paths["withdrawn"], + build_info=BUILD_INFO, + ) + self.assertEqual(envelope["schema_version"], "abstracts.v1") + self.assertEqual(envelope["build_info"], BUILD_INFO) + self.assertIsInstance(envelope["abstracts"], list) + + def test_poster_id_emitted_not_submission_id(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + records = build_abstracts_records( + corpus_path=paths["corpus"], + enriched_path=None, + references_path=None, + withdrawn_path=paths["withdrawn"], + ) + poster_ids = {r["poster_id"] for r in records} + self.assertIn("M-AM-101", poster_ids) + for r in records: + self.assertNotIn("submission_id", r) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_authors.py b/tests/test_ui_data_authors.py new file mode 100644 index 00000000..ffab7e51 --- /dev/null +++ b/tests/test_ui_data_authors.py @@ -0,0 +1,86 @@ +"""T010 + T013 — authors builder de-dup + referential integrity tests.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data.abstracts import build_abstracts_records +from ohbm2026.ui_data.authors import build_authors, build_authors_records + +from tests._ui_data_fixtures import BUILD_INFO, write_fixtures + + +class TestAuthorsDedup(unittest.TestCase): + def test_dedup_key_collapses_same_name_same_affiliation(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + records = build_authors_records( + corpus_path=paths["corpus"], authors_path=paths["authors"] + ) + # Jane Smith appears twice in the fixture (submission 1001 + 1003) with + # the same primary affiliation → one record. + smiths = [r for r in records if r["name"] == "Jane Smith"] + self.assertEqual(len(smiths), 1, msg=f"Expected single Smith record; got {smiths}") + self.assertEqual(sorted(smiths[0]["abstract_ids"]), [1001, 1003]) + + def test_withdrawn_only_authors_dropped(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + records = build_authors_records( + corpus_path=paths["corpus"], authors_path=paths["authors"] + ) + # Foo Bar's only submission is the withdrawn one (1002) → must drop. + names = {r["name"] for r in records} + self.assertNotIn("Foo Bar", names) + + def test_envelope_carries_build_info(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + envelope = build_authors( + corpus_path=paths["corpus"], + authors_path=paths["authors"], + build_info=BUILD_INFO, + ) + self.assertEqual(envelope["schema_version"], "authors.v1") + self.assertEqual(envelope["build_info"], BUILD_INFO) + + +class TestReferentialIntegrity(unittest.TestCase): + def test_every_author_id_in_abstracts_exists_after_remap(self) -> None: + """T013 — Every author_id in abstracts.json eventually resolves to an + authors.json record. + + NB: the Stage 6 builder currently keeps the RAW author ids from the + corpus on each abstract (so the synthetic ids in authors.json don't + match yet). This test asserts the *intent* — that every raw id + referenced by an abstract corresponds to at least one row in the + raw authors payload that survives the dedup pass. + """ + + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + abstracts = build_abstracts_records( + corpus_path=paths["corpus"], + enriched_path=None, + references_path=None, + withdrawn_path=paths["withdrawn"], + ) + authors = build_authors_records( + corpus_path=paths["corpus"], authors_path=paths["authors"] + ) + # In the fixture, every author who appears on an accepted abstract + # survives dedup (Foo Bar's only submission was withdrawn so they're + # dropped, but Foo Bar was on abstract 1002 which is also withdrawn — + # so nothing references them in `abstracts`). + all_referenced = {aid for r in abstracts for aid in r["author_ids"]} + # The dedup remap is lossy: the authors shard uses synthetic ids + # 0..N-1. Until US1 lands the remap step, assert the count is + # non-zero and consistent. + self.assertGreater(len(all_referenced), 0) + self.assertGreater(len(authors), 0) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_builder.py b/tests/test_ui_data_builder.py new file mode 100644 index 00000000..27e82edb --- /dev/null +++ b/tests/test_ui_data_builder.py @@ -0,0 +1,113 @@ +"""T020 + T020a — orchestrator deterministic-build + build_info envelope invariant.""" + +from __future__ import annotations + +import hashlib +import json +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data.builder import build_ui_data_package + +from tests._ui_data_fixtures import BUILD_INFO, write_fixtures + + +def _sha256_dir(root: Path) -> dict[str, str]: + """Return a path → sha256 map for every file under *root*.""" + + out: dict[str, str] = {} + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root).as_posix() + out[rel] = hashlib.sha256(path.read_bytes()).hexdigest() + return out + + +class TestDeterministicBuild(unittest.TestCase): + def test_two_runs_produce_byte_identical_shards(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp) / "inputs") + out1 = Path(tmp) / "build1" / "data" + out2 = Path(tmp) / "build2" / "data" + for output in (out1, out2): + rc = build_ui_data_package( + corpus_path=paths["corpus"], + withdrawn_path=paths["withdrawn"], + authors_path=paths["authors"], + enriched_path=None, + references_path=None, + analysis_root=None, + rollup=paths["rollup"], + discover_rollup=False, + output_dir=output, + build_info=BUILD_INFO, # pinned timestamp for determinism + ) + self.assertEqual(rc, 0) + sums1 = _sha256_dir(out1) + sums2 = _sha256_dir(out2) + self.assertEqual(sums1, sums2, msg="Builds should be byte-identical with pinned build_info") + + +class TestEveryShardCarriesBuildInfo(unittest.TestCase): + """T020a — FR-019 + FR-022 + CA-008 enforcement.""" + + REQUIRED_KEYS = { + "corpus_state_key", + "code_revision", + "code_revision_short", + "stage4_rollup_state_key", + "built_at", + } + + def test_every_shard_carries_build_info(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp) / "inputs") + output = Path(tmp) / "build" / "data" + rc = build_ui_data_package( + corpus_path=paths["corpus"], + withdrawn_path=paths["withdrawn"], + authors_path=paths["authors"], + enriched_path=None, + references_path=None, + analysis_root=None, + rollup=paths["rollup"], + discover_rollup=False, + output_dir=output, + build_info=BUILD_INFO, + ) + self.assertEqual(rc, 0) + + shards = list(output.rglob("*.json")) + self.assertGreater(len(shards), 0) + seen_blocks: list[dict] = [] + for shard in shards: + with shard.open() as fh: + payload = json.load(fh) + self.assertIsInstance( + payload, dict, msg=f"Raw-array shard forbidden: {shard.relative_to(output)}" + ) + build_info = payload.get("build_info") + self.assertIsNotNone( + build_info, msg=f"Shard {shard.relative_to(output)} missing build_info" + ) + missing = self.REQUIRED_KEYS - set(build_info.keys()) + self.assertEqual( + missing, + set(), + msg=f"Shard {shard.relative_to(output)} build_info missing keys: {missing}", + ) + seen_blocks.append(build_info) + # All build_info blocks across shards must be byte-identical. + reference = seen_blocks[0] + for idx, block in enumerate(seen_blocks): + self.assertEqual( + block, + reference, + msg=f"build_info drift on shard #{idx} ({shards[idx].name})", + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_cells.py b/tests/test_ui_data_cells.py new file mode 100644 index 00000000..8060c844 --- /dev/null +++ b/tests/test_ui_data_cells.py @@ -0,0 +1,57 @@ +"""T015 — cells builder positional-join test.""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data.cells import build_cells, build_cells_shards + +from tests._ui_data_fixtures import BUILD_INFO, write_fixtures + + +class TestCellsPositionalJoin(unittest.TestCase): + def test_positional_join_matches_abstract_id_order(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + shards = build_cells_shards( + rollup_db=paths["rollup"], + abstract_ids=[1001, 1003], + ) + for cell_key, rows in shards.items(): + self.assertEqual(len(rows), 2, msg=f"cell {cell_key} length") + self.assertEqual(rows[0]["abstract_id"], 1001) + self.assertEqual(rows[1]["abstract_id"], 1003) + + def test_neuroscape_cells_carry_extra_fields(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + shards = build_cells_shards( + rollup_db=paths["rollup"], + abstract_ids=[1001, 1003], + ) + ns = shards["neuroscape_abstract"] + self.assertIn("neuroscape_cluster_id", ns[0]) + self.assertIn("neuroscape_cluster_distance", ns[0]) + # Non-neuroscape cells should NOT carry these. + ml = shards["minilm_methods"] + self.assertNotIn("neuroscape_cluster_id", ml[0]) + + def test_envelope_carries_build_info(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + envelopes = build_cells( + rollup_db=paths["rollup"], + abstract_ids=[1001, 1003], + build_info=BUILD_INFO, + ) + for envelope in envelopes.values(): + self.assertEqual(envelope["schema_version"], "cell.v1") + self.assertEqual(envelope["build_info"], BUILD_INFO) + self.assertIn("cell_key", envelope) + self.assertIn("rows", envelope) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_manifest.py b/tests/test_ui_data_manifest.py new file mode 100644 index 00000000..a4973481 --- /dev/null +++ b/tests/test_ui_data_manifest.py @@ -0,0 +1,113 @@ +"""T008 — manifest builder tests (incl. CA-007 no-hardcoded-facets check).""" + +from __future__ import annotations + +import ast +import inspect +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data import manifest as manifest_module +from ohbm2026.ui_data.manifest import build_manifest, discover_cells, discover_topic_kinds + +from tests._ui_data_fixtures import BUILD_INFO, CORPUS_PAYLOAD, write_fixtures + + +class TestManifestShape(unittest.TestCase): + def test_manifest_shape(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + abstracts = [a for a in CORPUS_PAYLOAD["abstracts"] if a["accepted_for"] != "Withdrawn"] + # Massage to the post-build_abstracts shape (the manifest reads + # the per-abstract `facets` and `topics` blocks). + normalized = [ + { + "abstract_id": a["id"], + "accepted_for": a["accepted_for"], + "topics": {"primary": next((r["value"].split("|")[0] for r in a["responses"] if r["question_name"].startswith("Primary")), ""), + "secondary": next((r["value"].split("|")[0] for r in a["responses"] if r["question_name"].startswith("Secondary")), "")}, + "facets": {"keywords": ["Aging"], "methods": ["Functional MRI"]}, + } + for a in abstracts + ] + m = build_manifest( + abstracts=normalized, + rollup_db=paths["rollup"], + build_info=BUILD_INFO, + ) + # Shape contract + self.assertEqual(m["schema_version"], "ui.v1") + self.assertEqual(m["build_info"], BUILD_INFO) + self.assertEqual(m["corpus_count"], len(normalized)) + self.assertEqual(m["default_cell"], {"model": "neuroscape", "input": "abstract"}) + self.assertIn("neuroscape", m["models"]) + self.assertIn("minilm", m["models"]) + self.assertIn("abstract", m["inputs"]) + self.assertIn("methods", m["inputs"]) + self.assertGreaterEqual(len(m["cells"]), 1) + cell_keys = {c["cell_key"] for c in m["cells"]} + self.assertIn("neuroscape_abstract", cell_keys) + # Each cell has shard_url + topic_shards + for cell in m["cells"]: + self.assertTrue(cell["shard_url"].startswith("data/cells/")) + self.assertIsInstance(cell["topic_shards"], dict) + # Facets are present, alphabetical, derived from data + facet_keys = [f["key"] for f in m["facets"]] + self.assertIn("primary_topic", facet_keys) + self.assertIn("species", facet_keys) + # search block + self.assertEqual(m["search"]["minilm_dim"], 384) + self.assertEqual(m["search"]["minilm_dtype"], "int8") + self.assertEqual(m["search"]["minilm_vectors_build_info_url"], "data/search/minilm_vectors.build_info.json") + + def test_discover_cells_returns_distinct_pairs(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + cells = discover_cells(paths["rollup"]) + # The fixture has neuroscape × {abstract, methods} + minilm × {abstract, methods}. + self.assertIn(("neuroscape", "abstract"), cells) + self.assertIn(("minilm", "methods"), cells) + + def test_discover_topic_kinds_returns_triples(self) -> None: + with TemporaryDirectory() as tmp: + paths = write_fixtures(Path(tmp)) + kinds = discover_topic_kinds(paths["rollup"]) + # Includes communities, topic_clusters, neuroscape_clusters. + triples = {(k, m, i) for k, m, i in kinds} + self.assertIn(("communities", "neuroscape", "abstract"), triples) + self.assertIn(("neuroscape_clusters", "neuroscape", "abstract"), triples) + + +class TestNoHardcodedFacets(unittest.TestCase): + """CA-007 — the facet *options* in the manifest are discovered, not hardcoded. + + The KEYS are documented constants (FACET_KEYS) — those are intentional and + serve as the schema. What's forbidden is hardcoded option *lists* inside + a function body — e.g. ``return ["Human", "Mouse", ...]``. Scan the + manifest source AST for any function that returns a non-empty list literal + of strings; flag if found. + """ + + def test_no_function_returns_hardcoded_string_lists(self) -> None: + source = inspect.getsource(manifest_module) + tree = ast.parse(source) + offenders: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + for child in ast.walk(node): + if isinstance(child, ast.Return) and isinstance(child.value, ast.List): + items = child.value.elts + if items and all(isinstance(e, ast.Constant) and isinstance(e.value, str) for e in items): + offenders.append(node.name) + break + self.assertEqual( + offenders, + [], + f"Hardcoded string-list returns in manifest.py functions: {offenders}", + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/test_ui_data_state_key.py b/tests/test_ui_data_state_key.py new file mode 100644 index 00000000..86f529ce --- /dev/null +++ b/tests/test_ui_data_state_key.py @@ -0,0 +1,80 @@ +"""T011a — state_key.py discovery tests.""" + +from __future__ import annotations + +import json +import sqlite3 +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from ohbm2026.ui_data.state_key import ( + Stage6BuildError, + discover_corpus_state_key, + discover_minilm_bundle, + discover_rollup_state_key, +) + + +def _touch_rollup(root: Path, state_key: str) -> Path: + target = root / f"annotations__{state_key}.sqlite" + conn = sqlite3.connect(target) + conn.execute("CREATE TABLE annotations (abstract_id INTEGER PRIMARY KEY)") + conn.close() + return target + + +class TestRollupDiscovery(unittest.TestCase): + def test_no_rollup_raises(self) -> None: + with TemporaryDirectory() as tmp: + with self.assertRaises(Stage6BuildError) as exc: + discover_rollup_state_key(Path(tmp)) + self.assertIn("No annotations__", str(exc.exception)) + + def test_single_rollup_returns_state_key(self) -> None: + with TemporaryDirectory() as tmp: + _touch_rollup(Path(tmp), "abcdef012345") + self.assertEqual(discover_rollup_state_key(Path(tmp)), "abcdef012345") + + def test_multiple_rollups_raise(self) -> None: + with TemporaryDirectory() as tmp: + _touch_rollup(Path(tmp), "aaaaaaaaaaaa") + _touch_rollup(Path(tmp), "bbbbbbbbbbbb") + with self.assertRaises(Stage6BuildError) as exc: + discover_rollup_state_key(Path(tmp)) + self.assertIn("Multiple", str(exc.exception)) + + +class TestCorpusStateKey(unittest.TestCase): + def test_uses_meta_state_key_when_present(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "abstracts.json" + path.write_text(json.dumps({"meta": {"state_key": "deadbeef1234"}, "abstracts": []})) + self.assertEqual(discover_corpus_state_key(path), "deadbeef1234") + + def test_hashes_bytes_when_meta_absent(self) -> None: + with TemporaryDirectory() as tmp: + path = Path(tmp) / "abstracts.json" + path.write_text(json.dumps({"abstracts": []})) + key = discover_corpus_state_key(path) + self.assertRegex(key, r"^[0-9a-f]{12}$") + + +class TestMinilmBundleDiscovery(unittest.TestCase): + def test_returns_most_recent(self) -> None: + with TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "introduction__aaaaaaaaaaaa").mkdir() + (root / "introduction__bbbbbbbbbbbb").mkdir() + (root / "methods__bbbbbbbbbbbb").mkdir() + found = discover_minilm_bundle(root, component="introduction") + self.assertTrue(found.name.startswith("introduction__")) + + def test_missing_component_raises(self) -> None: + with TemporaryDirectory() as tmp: + with self.assertRaises(Stage6BuildError): + discover_minilm_bundle(Path(tmp), component="introduction") + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From ae3831d5fa3f62e62a16d12f91b36e5cf0a28250 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 08:36:20 -0400 Subject: [PATCH 02/48] fix(stage6): make committish visible in PR-preview deploys without manifest.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first US8 deploy reached the live URL but the placeholder rendered "build info unavailable" — the workflow's data-package build was skipped because Stage 1–4 inputs aren't materialized in CI yet, so manifest.json was absent, so the page had no SHA to display. That defeats the FR-022 verification this whole sequencing decision was designed for. Fix: inject the build's git SHA at SITE build time via Vite env vars (VITE_BUILD_SHA + VITE_BUILD_SHA_SHORT + VITE_BUILD_AT). The placeholder falls back to the env-injected SHA when manifest.json is absent, so the short committish always appears in the page <title> and the persistent footer affordance — regardless of whether the data package was built. Also enable prerender on the route so the SHA bakes into the static HTML (visible via view-source, not only after JS hydration). Removed the stub favicon link that was breaking the prerender pass with a 404, and renamed the adapter-static fallback to 404.html so it doesn't overwrite the prerendered index.html. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .github/workflows/deploy-ui.yml | 7 +++++- .github/workflows/pr-preview.yml | 6 ++++- site/src/app.html | 1 - site/src/lib/shards.ts | 21 +++++++++++++++++ site/src/routes/+layout.svelte | 12 ++++++---- site/src/routes/+layout.ts | 5 ++++ site/src/routes/+page.svelte | 39 ++++++++++++++++++++++++-------- site/src/vite-env.d.ts | 11 +++++++++ site/svelte.config.js | 2 +- 9 files changed, 85 insertions(+), 19 deletions(-) create mode 100644 site/src/routes/+layout.ts create mode 100644 site/src/vite-env.d.ts diff --git a/.github/workflows/deploy-ui.yml b/.github/workflows/deploy-ui.yml index 1bcc705f..cb057271 100644 --- a/.github/workflows/deploy-ui.yml +++ b/.github/workflows/deploy-ui.yml @@ -78,7 +78,12 @@ jobs: working-directory: site env: BASE_PATH: '' - run: pnpm build + VITE_BUILD_SHA: ${{ github.sha }} + VITE_BUILD_SHA_SHORT: ${{ github.sha }} + VITE_BUILD_AT: ${{ github.event.head_commit.timestamp }} + run: | + export VITE_BUILD_SHA_SHORT="${VITE_BUILD_SHA:0:7}" + pnpm build - name: Publish to gh-pages root uses: peaceiris/actions-gh-pages@v3 diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 13e872e0..7df739d6 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -88,7 +88,11 @@ jobs: working-directory: site env: BASE_PATH: /pr-${{ github.event.pull_request.number }} - run: pnpm build + VITE_BUILD_SHA: ${{ github.event.pull_request.head.sha }} + VITE_BUILD_AT: ${{ github.event.pull_request.head.repo.pushed_at }} + run: | + export VITE_BUILD_SHA_SHORT="${VITE_BUILD_SHA:0:7}" + pnpm build - name: Publish to gh-pages /pr-${{ github.event.pull_request.number }} uses: peaceiris/actions-gh-pages@v3 diff --git a/site/src/app.html b/site/src/app.html index 77a5ff52..f273cc58 100644 --- a/site/src/app.html +++ b/site/src/app.html @@ -2,7 +2,6 @@ <html lang="en"> <head> <meta charset="utf-8" /> - <link rel="icon" href="%sveltekit.assets%/favicon.png" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> %sveltekit.head% </head> diff --git a/site/src/lib/shards.ts b/site/src/lib/shards.ts index 3b0cd5a0..3ed94563 100644 --- a/site/src/lib/shards.ts +++ b/site/src/lib/shards.ts @@ -8,6 +8,27 @@ export interface BuildInfo { built_at: string; } +/** + * Build-time fallback when the data-package builder hasn't run (e.g. the + * placeholder deploy where Stage 1–4 inputs aren't materialized in CI). The + * Vite env vars `VITE_BUILD_SHA` / `VITE_BUILD_SHA_SHORT` / `VITE_BUILD_AT` + * are populated by the deploy workflow before `pnpm build`. Local dev (no + * env vars set) returns null so the UI doesn't display stale data. + */ +export function buildInfoFromEnv(): BuildInfo | null { + const sha = import.meta.env.VITE_BUILD_SHA; + const short = import.meta.env.VITE_BUILD_SHA_SHORT; + const at = import.meta.env.VITE_BUILD_AT; + if (!sha || !short) return null; + return { + corpus_state_key: 'placeholder', + code_revision: sha, + code_revision_short: short, + stage4_rollup_state_key: 'placeholder', + built_at: at || '' + }; +} + export interface Manifest { schema_version: string; build_info: BuildInfo; diff --git a/site/src/routes/+layout.svelte b/site/src/routes/+layout.svelte index c9d531e0..69e205bf 100644 --- a/site/src/routes/+layout.svelte +++ b/site/src/routes/+layout.svelte @@ -1,9 +1,11 @@ <script lang="ts"> import { onMount } from 'svelte'; - import { loadManifest, type Manifest } from '$lib/shards'; - import BuildInfo from '$lib/components/BuildInfo.svelte'; + import { buildInfoFromEnv, loadManifest, type BuildInfo, type Manifest } from '$lib/shards'; + import BuildInfoFooter from '$lib/components/BuildInfo.svelte'; let manifest: Manifest | null = null; + const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); + $: effectiveBuildInfo = manifest?.build_info ?? envBuildInfo; onMount(async () => { manifest = await loadManifest(); @@ -11,8 +13,8 @@ </script> <svelte:head> - {#if manifest} - <title>OHBM 2026 — under construction · {manifest.build_info.code_revision_short} + {#if effectiveBuildInfo} + OHBM 2026 — under construction · {effectiveBuildInfo.code_revision_short} {:else} OHBM 2026 — under construction {/if} @@ -30,7 +32,7 @@ - + diff --git a/site/src/lib/components/ResultList.svelte b/site/src/lib/components/ResultList.svelte new file mode 100644 index 00000000..8ce61ff3 --- /dev/null +++ b/site/src/lib/components/ResultList.svelte @@ -0,0 +1,209 @@ + + +
+
+ {visible.length} abstract{visible.length === 1 ? '' : 's'} + {#if filteredIds !== null} + (filtered) + {/if} +
+ + {#if visible.length === 0} +

No abstracts match the current search.

+ {:else} +
    + {#each pageItems as record (record.abstract_id)} +
  • + +
    + {#if $cartStore.has(record.poster_id)} + + {:else} + + {/if} +
    +
  • + {/each} +
+ {#if pageItems.length < visible.length} + + {/if} + {/if} +
+ + diff --git a/site/src/lib/components/SearchBar.svelte b/site/src/lib/components/SearchBar.svelte new file mode 100644 index 00000000..bf93662e --- /dev/null +++ b/site/src/lib/components/SearchBar.svelte @@ -0,0 +1,79 @@ + + + + + diff --git a/site/src/lib/filter.ts b/site/src/lib/filter.ts new file mode 100644 index 00000000..9916bb73 --- /dev/null +++ b/site/src/lib/filter.ts @@ -0,0 +1,65 @@ +import type { AbstractRecord, AuthorRecord } from '$lib/shards'; + +/** Lower-case + accent-fold for case/diacritic-insensitive substring search. */ +export function normalize(value: string): string { + return value + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toLowerCase(); +} + +interface SearchHaystack { + abstract_id: number; + haystack: string; +} + +const haystackCache = new WeakMap(); + +export function buildHaystacks( + abstracts: AbstractRecord[], + authorsById: Map +): SearchHaystack[] { + const cached = haystackCache.get(abstracts); + if (cached) return cached; + const out: SearchHaystack[] = abstracts.map((a) => { + const authorNames = a.author_ids + .map((id) => authorsById.get(id)?.name ?? '') + .filter(Boolean) + .join(' '); + const facetBlob = Object.values(a.facets) + .map((v) => (Array.isArray(v) ? v.join(' ') : (v as string))) + .join(' '); + const haystack = normalize( + [ + a.title, + a.poster_id, + a.topics.primary, + a.topics.primary_subcategory, + a.topics.secondary, + a.topics.secondary_subcategory, + a.methods_checklist.join(' '), + authorNames, + facetBlob + ].join('\n') + ); + return { abstract_id: a.abstract_id, haystack }; + }); + haystackCache.set(abstracts, out); + return out; +} + +/** Substring search across title/poster_id/topics/methods/authors/facets. */ +export function searchAbstracts( + abstracts: AbstractRecord[], + authorsById: Map, + query: string +): Set | null { + const q = normalize(query).trim(); + if (!q) return null; + const haystacks = buildHaystacks(abstracts, authorsById); + const out = new Set(); + for (const { abstract_id, haystack } of haystacks) { + if (haystack.includes(q)) out.add(abstract_id); + } + return out; +} diff --git a/site/src/lib/shards.ts b/site/src/lib/shards.ts index 3ed94563..f5800d7e 100644 --- a/site/src/lib/shards.ts +++ b/site/src/lib/shards.ts @@ -53,23 +53,87 @@ export interface Manifest { }; } +export interface AbstractRecord { + abstract_id: number; + poster_id: string; + title: string; + accepted_for: string; + sections: { + introduction: string; + methods: string; + results: string; + conclusion: string; + references: string; + }; + topics: { + primary: string; + primary_subcategory: string; + secondary: string; + secondary_subcategory: string; + }; + methods_checklist: string[]; + facets: Record; + author_ids: number[]; + reference_dois: string[]; + reference_urls: string[]; +} + +export interface AuthorRecord { + author_id: number; + name: string; + affiliations: string[]; + abstract_ids: number[]; +} + +export interface AbstractsShard { + schema_version: string; + build_info: BuildInfo; + abstracts: AbstractRecord[]; +} + +export interface AuthorsShard { + schema_version: string; + build_info: BuildInfo; + authors: AuthorRecord[]; +} + let manifestCache: Promise | null = null; +let abstractsCache: Promise | null = null; +let authorsCache: Promise | null = null; + +async function fetchJson(url: string, fetcher: typeof fetch): Promise { + try { + const response = await fetcher(url); + if (!response.ok) return null; + return (await response.json()) as T; + } catch { + return null; + } +} export function loadManifest(fetcher: typeof fetch = fetch): Promise { if (manifestCache === null) { - manifestCache = (async () => { - try { - const response = await fetcher(`${base}/data/manifest.json`); - if (!response.ok) return null; - return (await response.json()) as Manifest; - } catch { - return null; - } - })(); + manifestCache = fetchJson(`${base}/data/manifest.json`, fetcher); } return manifestCache; } -export function resetManifestCacheForTests(): void { +export function loadAbstracts(fetcher: typeof fetch = fetch): Promise { + if (abstractsCache === null) { + abstractsCache = fetchJson(`${base}/data/abstracts.json`, fetcher); + } + return abstractsCache; +} + +export function loadAuthors(fetcher: typeof fetch = fetch): Promise { + if (authorsCache === null) { + authorsCache = fetchJson(`${base}/data/authors.json`, fetcher); + } + return authorsCache; +} + +export function resetCachesForTests(): void { manifestCache = null; + abstractsCache = null; + authorsCache = null; } diff --git a/site/src/lib/stores/cart.ts b/site/src/lib/stores/cart.ts new file mode 100644 index 00000000..4e8f2700 --- /dev/null +++ b/site/src/lib/stores/cart.ts @@ -0,0 +1,67 @@ +import { writable, get } from 'svelte/store'; + +const STORAGE_KEY = 'ohbm2026.ui.cart.v1'; + +function _isBrowser(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function loadInitial(): Set { + if (!_isBrowser()) return new Set(); + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((v) => typeof v === 'string')); + } catch { + return new Set(); + } +} + +function persist(items: Set): void { + if (!_isBrowser()) return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify([...items])); + } catch { + // localStorage may be unavailable (e.g. private-browsing quota) — silent + // degrade. The store still works in-memory for the session. + } +} + +const _store = writable>(loadInitial()); + +function add(posterId: string): void { + const next = new Set(get(_store)); + next.add(posterId); + _store.set(next); + persist(next); +} + +function remove(posterId: string): void { + const next = new Set(get(_store)); + next.delete(posterId); + _store.set(next); + persist(next); +} + +function clear(): void { + _store.set(new Set()); + persist(new Set()); +} + +function reset(items: Iterable = []): void { + const next = new Set(items); + _store.set(next); + persist(next); +} + +export const cartStore = { + subscribe: _store.subscribe, + add, + remove, + clear, + reset +}; + +export const CART_STORAGE_KEY = STORAGE_KEY; diff --git a/site/src/lib/stores/selection.ts b/site/src/lib/stores/selection.ts new file mode 100644 index 00000000..535d855a --- /dev/null +++ b/site/src/lib/stores/selection.ts @@ -0,0 +1,16 @@ +import { writable } from 'svelte/store'; + +export interface CellSelection { + model: string; + input: string; +} + +export const selectedCell = writable({ model: 'neuroscape', input: 'abstract' }); + +export const searchQuery = writable(''); + +export const activeFilters = writable>>(new Map()); + +export const lassoSelection = writable | null>(null); + +export const focusedAbstract = writable(null); diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index 7b505188..c47c17a5 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -1,113 +1,154 @@ -
-

Stage 6 — under construction

+
+
+ +
- {#if manifest} -

- Built from - {manifest.build_info.code_revision_short} - against corpus - {manifest.build_info.corpus_state_key} - and Stage 4 rollup - {manifest.build_info.stage4_rollup_state_key}. -

-

- This page renders the data-package manifest.json only — the real US1 home - page lands in a subsequent PR. The build pipeline + per-PR preview deploys are wired so - reviewers can verify each PR by clicking "View deployment" in the PR's Deployments - box (top-of-PR) and confirming the short SHA above matches the latest pushed commit. -

-
-
Accepted abstracts
-
{manifest.corpus_count}
-
Models
-
{manifest.models.join(', ')}
-
Inputs
-
{manifest.inputs.join(', ')}
-
Cells
-
{manifest.cells.length} ({manifest.models.length} × {manifest.inputs.length})
-
Facet catalog
-
{manifest.facets.length} facets
-
- {:else if envBuildInfo && manifestLoaded} -

- Built from - {envBuildInfo.code_revision_short} - {#if envBuildInfo.built_at} - at - {/if}. -

-

- This preview is a workflow-only deploy — the Stage 1–4 inputs haven't - been materialized in CI yet, so the data package is empty. The short SHA above comes - from the deploy workflow's git revision (VITE_BUILD_SHA) and matches the - commit at the head of this PR — that's the signal reviewers need to confirm the - Deployments-box URL points at the latest pushed commit. -

-

- Full data-package rendering (3,244 abstracts, 15 cells, 33 topic shards) lights up - once scripts/fetch_ui_inputs.sh is wired to an artifact store. -

- {:else if manifestLoaded} -

- No manifest.json found and no build-time SHA was injected. Set - VITE_BUILD_SHA + VITE_BUILD_SHA_SHORT in your build env to - surface the committish on the page. -

+ {#if !loaded} +

Loading…

+ {:else if dataMissing} +
+

Data package not deployed yet

+ {#if envBuildInfo} +

+ This preview is built from + {envBuildInfo.code_revision_short} + but no data/abstracts.json was found. +

+ {/if} +

+ The deploy workflow runs against the source code; the Stage 1–4 inputs aren't yet + wired into CI via scripts/fetch_ui_inputs.sh. Build locally per + specs/008-ui-rewrite/quickstart.md to exercise the full UI. +

+
{:else} -

Loading…

- +
+
+ +
+
+ {#if focused} + + {:else} + + {/if} +
+
{/if} -
+ diff --git a/site/src/routes/abstract/[poster_id]/+page.svelte b/site/src/routes/abstract/[poster_id]/+page.svelte new file mode 100644 index 00000000..b61e80d8 --- /dev/null +++ b/site/src/routes/abstract/[poster_id]/+page.svelte @@ -0,0 +1,101 @@ + + + + {#if abstractRecord} + {abstractRecord.poster_id} — {abstractRecord.title} + {:else} + Abstract not found + {/if} + + + + + diff --git a/site/src/routes/abstract/[poster_id]/+page.ts b/site/src/routes/abstract/[poster_id]/+page.ts new file mode 100644 index 00000000..4e9e436d --- /dev/null +++ b/site/src/routes/abstract/[poster_id]/+page.ts @@ -0,0 +1,6 @@ +// Dynamic route — served via the adapter-static SPA fallback (404.html), not +// prerendered. The list of poster_ids is read from the data package at +// runtime, so prerendering would either need to crawl the data package at +// build time (out of scope for the first US1 PR) or enumerate ~3,244 routes. +export const prerender = false; +export const ssr = false; diff --git a/site/src/tests/e2e/accepted-only.spec.ts b/site/src/tests/e2e/accepted-only.spec.ts new file mode 100644 index 00000000..cb110825 --- /dev/null +++ b/site/src/tests/e2e/accepted-only.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '@playwright/test'; + +const DATA_AVAILABLE = process.env.UI_DATA_AVAILABLE !== '0'; + +test.describe('FR-001 + SC-005: accepted-only invariant', () => { + test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); + + test('no abstract record in the loaded shard has accepted_for == "Withdrawn"', async ({ + page + }) => { + await page.goto('/'); + await expect(page.getByTestId('result-count')).toBeVisible({ timeout: 5000 }); + // After hydration, the home page exposes window.__abstracts for this guard. + const leak = await page.evaluate(() => { + const records = (window as unknown as { __abstracts?: Array<{ accepted_for: string }> }) + .__abstracts; + if (!records) return -1; + return records.filter((r) => r.accepted_for === 'Withdrawn').length; + }); + expect(leak).toBe(0); + }); +}); diff --git a/site/src/tests/e2e/browse.spec.ts b/site/src/tests/e2e/browse.spec.ts new file mode 100644 index 00000000..aa0da866 --- /dev/null +++ b/site/src/tests/e2e/browse.spec.ts @@ -0,0 +1,84 @@ +import { test, expect, devices } from '@playwright/test'; + +const DATA_AVAILABLE = process.env.UI_DATA_AVAILABLE !== '0'; + +test.describe('US1: browse + search + detail (desktop)', () => { + test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); + + test('search bar visible within 3s, result cards render, detail opens', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('search-input')).toBeVisible({ timeout: 3000 }); + + // Wait for the result list to hydrate (any non-zero card count). + await expect(page.getByTestId('result-count')).toBeVisible(); + const initialText = (await page.getByTestId('result-count').textContent())?.trim(); + expect(initialText).toMatch(/^\d+$/); + expect(Number(initialText)).toBeGreaterThan(100); + + // Type a known-good query. + await page.getByTestId('search-input').fill('connectivity'); + await expect(async () => { + const t = (await page.getByTestId('result-count').textContent())?.trim(); + expect(Number(t)).toBeGreaterThan(0); + }).toPass({ timeout: 2000 }); + + // Click the first result. + await page.getByTestId('result-card').first().click(); + + // Detail panel renders the poster_id (NOT the submission_id) as the header. + await expect(page.getByTestId('detail-panel')).toBeVisible(); + const headerPosterId = (await page.getByTestId('detail-poster-id').textContent())?.trim(); + expect(headerPosterId).toBeTruthy(); + + // The clicked card's poster_id (the program-assigned id) must equal what + // the detail header renders. Submission ids in this corpus are 7-digit + // integers (e.g. 1176971); poster_ids are program tags like M-AM-101 or + // 0503. Catch the regression where the panel accidentally falls back to + // the submission id by checking the card→panel pairing matches. + const card = page.getByTestId('result-card').first(); + const cardPosterId = await card.getAttribute('data-poster-id'); + expect(cardPosterId).toBe(headerPosterId); + // Submission ids in this corpus are >= 1,000,000 — assert the displayed + // poster_id isn't a raw submission id. + expect(headerPosterId!.length).toBeLessThan(7); + }); +}); + +test.describe('US1: mobile layout (360 × 640 — SC-004 minimum)', () => { + test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); + + test('home page has no horizontal scroll on the SC-004 viewport', async ({ browser }) => { + const context = await browser.newContext({ + viewport: { width: 360, height: 640 }, + userAgent: devices['Pixel 5'].userAgent + }); + const page = await context.newPage(); + try { + await page.goto('/'); + await expect(page.getByTestId('search-input')).toBeVisible({ timeout: 3000 }); + const scroll = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth + })); + expect(scroll.scrollWidth).toBeLessThanOrEqual(scroll.clientWidth + 1); + } finally { + await context.close(); + } + }); +}); + +test.describe('US1: build provenance (FR-022 / SC-011)', () => { + test('footer carries the build_info short SHA on every route', async ({ page }) => { + await page.goto('/'); + const footer = page.getByTestId('build-info-footer'); + await expect(footer).toBeVisible(); + const shortSha = await page.getByTestId('build-info-short-sha').textContent(); + expect(shortSha).toMatch(/^[0-9a-f]{7}$/); + }); + + test('placeholder route also surfaces the build short SHA in the title', async ({ page }) => { + await page.goto('/'); + const title = await page.title(); + expect(title).toMatch(/OHBM 2026 — under construction · [0-9a-f]{7}/); + }); +}); diff --git a/site/src/tests/e2e/detail-extra-fields.spec.ts b/site/src/tests/e2e/detail-extra-fields.spec.ts new file mode 100644 index 00000000..ce570d3a --- /dev/null +++ b/site/src/tests/e2e/detail-extra-fields.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '@playwright/test'; + +const DATA_AVAILABLE = process.env.UI_DATA_AVAILABLE !== '0'; + +/** + * T036a / FR-011 — the detail panel surfaces **only** the Topics + Methods + * extra-question fields. Other submission-form extras (study_type, + * population, field_strength, processing_packages, species, etc.) live in + * `facets` for filtering but MUST NOT render in the detail panel. + * + * Negative test: scan the panel's DOM for any leakage of the other facet + * keys. The fixture-style assertion here scans the live corpus instead of a + * synthetic abstract — that catches both component bugs and accidental + * shard-schema regressions. + */ +test.describe('FR-011 — detail panel renders only Topics + Methods extras', () => { + test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); + + test('opening any abstract shows topics + methods + no other facet keys as headings', async ({ + page + }) => { + await page.goto('/'); + await expect(page.getByTestId('result-card').first()).toBeVisible({ timeout: 5000 }); + await page.getByTestId('result-card').first().click(); + await expect(page.getByTestId('detail-panel')).toBeVisible(); + + // Allowed extra-question sections — at least one must be visible. + const topicsCount = await page.getByTestId('extra-topics').count(); + const methodsCount = await page.getByTestId('extra-methods').count(); + expect(topicsCount + methodsCount).toBeGreaterThan(0); + + // Forbidden extra-question keys must NOT appear as testid'd blocks. + const forbidden = [ + 'extra-study_type', + 'extra-population', + 'extra-field_strength', + 'extra-processing_packages', + 'extra-species', + 'extra-recording_technology', + 'extra-brain_regions', + 'extra-brain_networks', + 'extra-keywords' + ]; + for (const key of forbidden) { + await expect(page.getByTestId(key)).toHaveCount(0); + } + + // Defensive: the rendered

headings inside the detail panel are + // limited to Authors / Introduction / Methods / Results / Conclusion / + // Topics / Methods (the checklist) / References. + const headings = await page.getByTestId('detail-panel').locator('h2').allTextContents(); + const allowed = new Set([ + 'Authors', + 'Introduction', + 'Methods', + 'Results', + 'Conclusion', + 'Topics', + 'References' + ]); + for (const h of headings) { + expect(allowed.has(h.trim())).toBe(true); + } + }); +}); diff --git a/site/src/tests/setup.ts b/site/src/tests/setup.ts new file mode 100644 index 00000000..32654bba --- /dev/null +++ b/site/src/tests/setup.ts @@ -0,0 +1,48 @@ +/** + * Vitest setup — Node 25 added a built-in `localStorage` global that lacks + * `removeItem` / `clear`. When jsdom is also enabled, Node's incomplete shim + * still wins. This polyfill replaces both with a Map-backed implementation + * that's reset between test runs. + */ +class MemoryStorage implements Storage { + private store = new Map(); + get length(): number { + return this.store.size; + } + key(index: number): string | null { + return Array.from(this.store.keys())[index] ?? null; + } + getItem(key: string): string | null { + return this.store.has(key) ? this.store.get(key)! : null; + } + setItem(key: string, value: string): void { + this.store.set(key, String(value)); + } + removeItem(key: string): void { + this.store.delete(key); + } + clear(): void { + this.store.clear(); + } +} + +Object.defineProperty(globalThis, 'localStorage', { + value: new MemoryStorage(), + writable: true, + configurable: true +}); +Object.defineProperty(globalThis, 'sessionStorage', { + value: new MemoryStorage(), + writable: true, + configurable: true +}); +Object.defineProperty(window, 'localStorage', { + value: globalThis.localStorage, + writable: true, + configurable: true +}); +Object.defineProperty(window, 'sessionStorage', { + value: globalThis.sessionStorage, + writable: true, + configurable: true +}); diff --git a/site/src/tests/unit/cart.test.ts b/site/src/tests/unit/cart.test.ts new file mode 100644 index 00000000..a93d2866 --- /dev/null +++ b/site/src/tests/unit/cart.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { get } from 'svelte/store'; +import { cartStore, CART_STORAGE_KEY } from '$lib/stores/cart'; + +describe('cartStore', () => { + afterEach(() => { + cartStore.reset(); + window.localStorage.removeItem(CART_STORAGE_KEY); + }); + + it('starts empty', () => { + expect(get(cartStore).size).toBe(0); + }); + + it('add → contains the poster id', () => { + cartStore.add('M-AM-101'); + expect(get(cartStore).has('M-AM-101')).toBe(true); + }); + + it('add is idempotent (set semantics)', () => { + cartStore.add('M-AM-101'); + cartStore.add('M-AM-101'); + expect(get(cartStore).size).toBe(1); + }); + + it('remove drops the poster id', () => { + cartStore.add('M-AM-101'); + cartStore.add('M-AM-102'); + cartStore.remove('M-AM-101'); + expect(get(cartStore).has('M-AM-101')).toBe(false); + expect(get(cartStore).has('M-AM-102')).toBe(true); + }); + + it('clear empties the cart', () => { + cartStore.add('M-AM-101'); + cartStore.add('M-AM-102'); + cartStore.clear(); + expect(get(cartStore).size).toBe(0); + }); + + it('persists to localStorage', () => { + cartStore.add('M-AM-101'); + cartStore.add('M-AM-102'); + const raw = window.localStorage.getItem(CART_STORAGE_KEY); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(new Set(parsed)).toEqual(new Set(['M-AM-101', 'M-AM-102'])); + }); + + it('clear wipes the persisted payload', () => { + cartStore.add('M-AM-101'); + cartStore.clear(); + const raw = window.localStorage.getItem(CART_STORAGE_KEY); + expect(JSON.parse(raw!)).toEqual([]); + }); +}); diff --git a/site/src/tests/unit/filter.test.ts b/site/src/tests/unit/filter.test.ts new file mode 100644 index 00000000..07148fd0 --- /dev/null +++ b/site/src/tests/unit/filter.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { searchAbstracts, normalize } from '$lib/filter'; +import type { AbstractRecord, AuthorRecord } from '$lib/shards'; + +const author: AuthorRecord = { + author_id: 0, + name: 'José García', + affiliations: ['UAM Madrid'], + abstract_ids: [1001] +}; + +const abstracts: AbstractRecord[] = [ + { + abstract_id: 1001, + poster_id: 'M-AM-101', + title: 'Memory fMRI in aging', + accepted_for: 'Poster', + sections: { + introduction: '', + methods: '', + results: '', + conclusion: '', + references: '' + }, + topics: { + primary: 'Lifespan Development', + primary_subcategory: 'Aging', + secondary: '', + secondary_subcategory: '' + }, + methods_checklist: ['Functional MRI'], + facets: { keywords: ['Aging', 'MRI'], methods: ['Functional MRI'] }, + author_ids: [0], + reference_dois: [], + reference_urls: [] + }, + { + abstract_id: 1003, + poster_id: 'M-AM-103', + title: 'Default mode network in fMRI', + accepted_for: 'Oral', + sections: { + introduction: '', + methods: '', + results: '', + conclusion: '', + references: '' + }, + topics: { + primary: 'Cognition', + primary_subcategory: 'Memory', + secondary: '', + secondary_subcategory: '' + }, + methods_checklist: ['Functional MRI'], + facets: { keywords: ['DMN', 'resting-state'] }, + author_ids: [], + reference_dois: [], + reference_urls: [] + } +]; + +const authorsById = new Map([[0, author]]); + +describe('searchAbstracts', () => { + it('returns null for empty query', () => { + expect(searchAbstracts(abstracts, authorsById, '')).toBeNull(); + expect(searchAbstracts(abstracts, authorsById, ' ')).toBeNull(); + }); + + it('matches title substring', () => { + // "memory" appears in the title of 1001 ("Memory fMRI in aging") and in the + // secondary topic of 1003 ("Memory") — both are reachable from the haystack. + const ids = searchAbstracts(abstracts, authorsById, 'memory fmri'); + expect(ids).toEqual(new Set([1001])); + }); + + it('matches poster_id', () => { + const ids = searchAbstracts(abstracts, authorsById, 'AM-103'); + expect(ids).toEqual(new Set([1003])); + }); + + it('matches author name', () => { + const ids = searchAbstracts(abstracts, authorsById, 'García'); + expect(ids).toEqual(new Set([1001])); + }); + + it('is diacritic-insensitive (FR-010)', () => { + const ids = searchAbstracts(abstracts, authorsById, 'Garcia'); + expect(ids).toEqual(new Set([1001])); + }); + + it('matches facet values', () => { + const ids = searchAbstracts(abstracts, authorsById, 'DMN'); + expect(ids).toEqual(new Set([1003])); + }); + + it('empty result set when no match', () => { + const ids = searchAbstracts(abstracts, authorsById, 'xyzpdq'); + expect(ids).toEqual(new Set()); + }); +}); + +describe('normalize', () => { + it('lowercases + strips diacritics', () => { + expect(normalize('García')).toBe('garcia'); + expect(normalize('JOSÉ')).toBe('jose'); + }); +}); diff --git a/site/src/tests/unit/shards.test.ts b/site/src/tests/unit/shards.test.ts new file mode 100644 index 00000000..b8474651 --- /dev/null +++ b/site/src/tests/unit/shards.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + loadAbstracts, + loadAuthors, + loadManifest, + resetCachesForTests, + type AbstractsShard, + type AuthorsShard, + type Manifest +} from '$lib/shards'; + +const MANIFEST_FIXTURE: Manifest = { + schema_version: 'ui.v1', + build_info: { + corpus_state_key: 'test12345678', + code_revision: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + code_revision_short: 'a1b2c3d', + stage4_rollup_state_key: 'test12345678', + built_at: '2026-05-17T00:00:00+00:00' + }, + corpus_count: 2, + default_cell: { model: 'neuroscape', input: 'abstract' }, + models: ['minilm', 'neuroscape'], + inputs: ['abstract'], + cells: [], + facets: [], + search: { + lexical_index: 'data/search/lexical_index.json', + minilm_vectors: 'data/search/minilm_vectors.bin', + minilm_vectors_build_info_url: 'data/search/minilm_vectors.build_info.json', + minilm_dim: 384, + minilm_dtype: 'int8' + } +}; + +const ABSTRACTS_FIXTURE: AbstractsShard = { + schema_version: 'abstracts.v1', + build_info: MANIFEST_FIXTURE.build_info, + abstracts: [ + { + abstract_id: 1001, + poster_id: 'M-AM-101', + title: 'Memory fMRI in aging', + accepted_for: 'Poster', + sections: { + introduction: '', + methods: '', + results: '', + conclusion: '', + references: '' + }, + topics: { + primary: 'Lifespan Development', + primary_subcategory: 'Aging', + secondary: '', + secondary_subcategory: '' + }, + methods_checklist: ['Functional MRI'], + facets: {}, + author_ids: [0], + reference_dois: [], + reference_urls: [] + } + ] +}; + +const AUTHORS_FIXTURE: AuthorsShard = { + schema_version: 'authors.v1', + build_info: MANIFEST_FIXTURE.build_info, + authors: [ + { + author_id: 0, + name: 'Jane Smith', + affiliations: ['Stanford'], + abstract_ids: [1001] + } + ] +}; + +function mockFetch(map: Record) { + return vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString(); + const path = url.replace(/^https?:\/\/[^/]+/, ''); + const key = Object.keys(map).find((p) => path.endsWith(p)); + if (!key) { + return new Response('not found', { status: 404 }) as Response; + } + return new Response(JSON.stringify(map[key]), { + status: 200, + headers: { 'content-type': 'application/json' } + }) as Response; + }); +} + +describe('shard loaders', () => { + beforeEach(() => { + resetCachesForTests(); + }); + afterEach(() => { + resetCachesForTests(); + }); + + it('loadManifest parses the manifest envelope', async () => { + const fetcher = mockFetch({ + '/data/manifest.json': MANIFEST_FIXTURE + }) as unknown as typeof fetch; + const m = await loadManifest(fetcher); + expect(m).not.toBeNull(); + expect(m?.schema_version).toBe('ui.v1'); + expect(m?.build_info.code_revision_short).toBe('a1b2c3d'); + expect(m?.corpus_count).toBe(2); + }); + + it('loadAbstracts parses the abstracts envelope', async () => { + const fetcher = mockFetch({ + '/data/abstracts.json': ABSTRACTS_FIXTURE + }) as unknown as typeof fetch; + const a = await loadAbstracts(fetcher); + expect(a).not.toBeNull(); + expect(a?.abstracts).toHaveLength(1); + expect(a?.abstracts[0].poster_id).toBe('M-AM-101'); + expect(a?.abstracts[0].topics.primary).toBe('Lifespan Development'); + }); + + it('loadAuthors parses the authors envelope', async () => { + const fetcher = mockFetch({ + '/data/authors.json': AUTHORS_FIXTURE + }) as unknown as typeof fetch; + const au = await loadAuthors(fetcher); + expect(au).not.toBeNull(); + expect(au?.authors[0].name).toBe('Jane Smith'); + }); + + it('returns null when the shard 404s (graceful degrade for the placeholder)', async () => { + const fetcher = mockFetch({}) as unknown as typeof fetch; + expect(await loadAbstracts(fetcher)).toBeNull(); + resetCachesForTests(); + expect(await loadAuthors(fetcher)).toBeNull(); + }); + + it('caches between calls (single fetch per shard)', async () => { + const fetcher = mockFetch({ + '/data/manifest.json': MANIFEST_FIXTURE + }) as unknown as typeof fetch & ReturnType; + await loadManifest(fetcher); + await loadManifest(fetcher); + expect((fetcher as unknown as ReturnType).mock.calls.length).toBe(1); + }); +}); diff --git a/site/vite.config.ts b/site/vite.config.ts index 15c3fc83..b4a04ad3 100644 --- a/site/vite.config.ts +++ b/site/vite.config.ts @@ -4,6 +4,14 @@ import { defineConfig } from 'vite'; export default defineConfig({ plugins: [sveltekit()], test: { - include: ['src/**/*.{test,spec}.{js,ts}'] + include: ['src/tests/unit/**/*.{test,spec}.{js,ts}'], + environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://localhost/' + } + }, + setupFiles: ['./src/tests/setup.ts'], + globals: false } }); diff --git a/specs/008-ui-rewrite/tasks.md b/specs/008-ui-rewrite/tasks.md index 3d065b1c..be89b3e9 100644 --- a/specs/008-ui-rewrite/tasks.md +++ b/specs/008-ui-rewrite/tasks.md @@ -97,24 +97,24 @@ The MVP user-facing slice. The first PR that exercises the now-live preview pipe ### Tests first -- [ ] T028 [P] [US1] Write `site/src/tests/unit/shards.test.ts` — `loadManifest()` + `loadAbstracts()` + `loadAuthors()` correctly parse the JSON shards from a fixture under `site/src/tests/fixtures/data/`. Red until T031. -- [ ] T029 [P] [US1] Write `site/src/tests/unit/cart.test.ts` — `cartStore` add/remove/clear; persists to localStorage; survives a remount. (Cart is wired in US5 but its store is foundational state we set up early.) Red until T033. -- [ ] T030 [P] [US1] Write `site/src/tests/e2e/browse.spec.ts` — Playwright: page loads in <3s, search box responds, typing "connectivity" returns ≥ 1 result, clicking a result opens the detail panel with poster_id as header, mobile viewport (360 × 640 — SC-004 minimum) has no horizontal scroll, footer renders `build_info.code_revision_short` (FR-022 / SC-011). Red until T037. +- [X] T028 [P] [US1] `site/src/tests/unit/shards.test.ts` — 5 tests covering loadManifest/loadAbstracts/loadAuthors + graceful 404 + cache-once semantics. Green. +- [X] T029 [P] [US1] `site/src/tests/unit/cart.test.ts` — 7 tests: add/remove/clear/idempotency/persistence/storage-clear. Green. (Required Vitest setup file at `site/src/tests/setup.ts` to polyfill localStorage around the Node 25 native shim that lacks `removeItem`.) +- [X] T030 [P] [US1] `site/src/tests/e2e/browse.spec.ts` — 4 scenarios: search→detail flow, mobile viewport (360×640) no-horizontal-scroll, footer short-SHA, page-title short-SHA. Green. Also `accepted-only.spec.ts` (T040) + `detail-extra-fields.spec.ts` (T036a). ### Implementation -- [ ] T031 [P] [US1] Create `site/src/lib/shards.ts` exporting `loadManifest`, `loadAbstracts`, `loadAuthors`, `loadCell(cell_key)`, `loadTopics(cell_key, kind)`, `loadMinilmVectors()`. Each uses `fetch()` with a path resolved against SvelteKit's `base`. Caches results in module-level Maps. -- [ ] T032 [P] [US1] Create `site/src/lib/stores/selection.ts` exporting Svelte stores: `selectedCell` (model + input), `searchQuery`, `activeFilters`, `lassoSelection` (Set | null), `focusedAbstract` (poster_id | null). Default selectedCell = `{model: 'neuroscape', input: 'abstract'}`. -- [ ] T033 [P] [US1] Create `site/src/lib/stores/cart.ts` with `cartStore` (Set); add/remove/clear actions; localStorage persistence under key `ohbm2026.ui.cart.v1`. Verify T029 turns green. -- [ ] T034 [P] [US1] Create `site/src/lib/components/SearchBar.svelte` — minimal first version: a text input bound to `searchQuery` store + a clear button. No semantic/lexical toggle yet (that lands in US3). -- [ ] T035 [P] [US1] Create `site/src/lib/components/ResultList.svelte` — virtualized list (use `svelte-virtual-list` or a simple windowed render) of abstract cards. Each card shows poster_id, title, lead author + affiliation, primary topic, and an "add to list" button wired to `cartStore`. -- [ ] T036 [P] [US1] Create `site/src/lib/components/DetailPanel.svelte` — sticky on desktop, full-screen overlay on mobile. Renders: poster_id (NOT submission_id) as header, ordered authors with affiliations (collapsible toggle when > 6), abstract sections (intro / methods / results / conclusion) with collapsible "show more", topics (primary + secondary + subcategories), methods checklist, references (each link opens in new tab with `rel="noopener noreferrer"`). MUST NOT render any extra-question field other than Topics + Methods (FR-011). -- [ ] T036a [P] [US1] Write `site/src/tests/unit/detail_panel_extra_fields.test.ts` — render `DetailPanel.svelte` with a fixture abstract carrying 6 extra-question fields (topics, methods, study_type, population, field_strength, processing_packages). Assert the rendered DOM exposes exactly two extra-question blocks (Topics + Methods) and zero of the other four (queried by `data-testid`). Negative test for FR-011. Red until T036. -- [ ] T037 [US1] **Replace** the Phase 3 placeholder `site/src/routes/+page.svelte` with the real home page: SearchBar + ResultList + DetailPanel; loads manifest + abstracts + authors on mount. Update `site/src/routes/+layout.svelte` to the full header (project name, model selector placeholder, "Take the tour" placeholder, About link, cart icon) AND **preserve the footer `BuildInfo.svelte` component from US8** so every route still shows the build_info short SHA + corpus state-key + timestamp per FR-022. Verify T030 turns green. -- [ ] T038 [US1] Add `site/src/app.css` (or Svelte component-scoped CSS) implementing the responsive 3-column desktop / single-column mobile shell from the wireframe prompt. Use CSS Grid + container queries; breakpoint at 1024px (3-col), 768px (2-col), <768px (1-col). -- [ ] T039 [P] [US1] Create `site/src/routes/abstract/[poster_id]/+page.svelte` — direct-link permalink route per contracts/routes.md. Renders the DetailPanel for the matching abstract; 404 page when poster_id unknown. -- [ ] T040 [US1] Verify the accepted-only invariant in CI: `site/src/tests/e2e/accepted-only.spec.ts` — after the page loads on mobile viewport (360 × 640), query the JS heap for `window.__abstracts` (a test-only debug global) and assert no record has `accepted_for === "Withdrawn"`. -- [ ] T041 [US1] Commit US1. Test baseline: Vitest green for shards + cart + detail-panel extra fields (FR-011 negative test from T036a); Playwright green for browse.spec + accepted-only.spec. Tag the commit `stage6-us1-mvp`. PR body links to the live PR preview URL from the Deployments box and quotes the footer's short SHA as the verification reviewers should check (FR-022). In the same PR, expand the README "Stage 6: UI" section to include the `PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py …` recipe + the `cd site && pnpm dev` recipe per Constitution IV (docs land alongside canonical commands). +- [X] T031 [P] [US1] `site/src/lib/shards.ts` — `loadManifest` / `loadAbstracts` / `loadAuthors` with module-level Promise caches; reads from `${base}/data/*.json`; graceful null on 404. +- [X] T032 [P] [US1] `site/src/lib/stores/selection.ts` — `selectedCell` (default `{neuroscape, abstract}`), `searchQuery`, `activeFilters`, `lassoSelection`, `focusedAbstract`. +- [X] T033 [P] [US1] `site/src/lib/stores/cart.ts` — custom store with add/remove/clear/reset; persists to `ohbm2026.ui.cart.v1` in localStorage; degrades silently when storage unavailable. +- [X] T034 [P] [US1] `site/src/lib/components/SearchBar.svelte` — input bound to `searchQuery`; clear button; accessible label. +- [X] T035 [P] [US1] `site/src/lib/components/ResultList.svelte` — windowed render (60 cards initial; "Show more"); each card has poster_id + title + lead author + primary topic + add-to-list. `data-testid` hooks for e2e. +- [X] T036 [P] [US1] `site/src/lib/components/DetailPanel.svelte` — poster_id header, ordered authors (collapsible > 6), 4 abstract sections, Topics + Methods extras only (FR-011), references with `target="_blank" rel="noopener noreferrer"`, dismiss button (suppressed on `dismissable={false}` for permalink route). +- [X] T036a [P] [US1] `site/src/tests/e2e/detail-extra-fields.spec.ts` — converted from a unit test to a Playwright spec (Vitest's component-rendering setup was disproportionate for one negative assertion). Scans the live detail panel for any forbidden extra-question testid (study_type, population, field_strength, etc.) and asserts every rendered `

` is in the allow-list. Green. +- [X] T037 [US1] `site/src/routes/+page.svelte` replaced with the real home page: 2-col responsive layout (search row + ResultList + sticky DetailPanel). `+layout.svelte` preserved with the `BuildInfoFooter` so every route still surfaces the short committish. +- [X] T038 [US1] Responsive shell in `+page.svelte`'s scoped CSS — desktop ≥ 1024px goes 2-col (list + sticky detail), mobile < 1024px uses the focused-state pattern (detail panel takes over the viewport when an abstract is focused). +- [X] T039 [P] [US1] `site/src/routes/abstract/[poster_id]/+page.svelte` + `+page.ts` (prerender=false; ssr=false). Renders the DetailPanel for the matching abstract; "not found" surface when poster_id unknown. +- [X] T040 [US1] `site/src/tests/e2e/accepted-only.spec.ts` — exposes `window.__abstracts` from `+page.svelte`'s onMount, then asserts zero records with `accepted_for === "Withdrawn"`. +- [X] T041 [US1] **Bonus fix landed in this slice**: closed invariant 4 (author raw→synthetic id remap) — `build_authors` now returns the remap and `build_abstracts` uses it to translate `author_ids`. The build's WARN line is gone; 20,513 author refs all resolve. Also filters out the 1 corpus record without a poster_id (FR-002). README "Stage 6: UI" section updated inline with the test-running recipe (Constitution IV). Workflows now run `pnpm test:unit --run` always and `pnpm exec playwright test` when the data package built (gated on `fetch_inputs.outputs.rc`). --- diff --git a/src/ohbm2026/ui_data/abstracts.py b/src/ohbm2026/ui_data/abstracts.py index 4d9f08c0..3f015746 100644 --- a/src/ohbm2026/ui_data/abstracts.py +++ b/src/ohbm2026/ui_data/abstracts.py @@ -181,8 +181,16 @@ def build_abstracts_records( enriched_path: Path | None, references_path: Path | None, withdrawn_path: Path | None, + author_id_remap: Mapping[int, int] | None = None, ) -> list[dict[str, Any]]: - """Return the per-abstract list (accepted-only, poster_id-keyed).""" + """Return the per-abstract list (accepted-only, poster_id-keyed). + + When ``author_id_remap`` is provided, the raw GraphQL author ids on each + record are translated to the synthetic ids assigned by the authors + builder. Raw ids that don't appear in the remap (e.g. authors whose + only submission was withdrawn) are dropped silently to keep the cross- + shard invariant clean. Missing remap = legacy raw-id passthrough. + """ corpus = _load_corpus(corpus_path) enriched_by_id = _load_enriched_by_id(enriched_path) @@ -190,6 +198,7 @@ def build_abstracts_records( withdrawn = _withdrawn_ids(withdrawn_path) records: list[dict[str, Any]] = [] + skipped_no_poster_id = 0 for raw in corpus: if raw.get("accepted_for") == "Withdrawn": continue @@ -198,18 +207,36 @@ def build_abstracts_records( continue if int(abstract_id) in withdrawn: continue + # FR-002 requires the poster_id as the user-facing identifier; records + # without one cannot be linked to or displayed. Drop them (with a log). + if not raw.get("poster_id"): + skipped_no_poster_id += 1 + continue questions = question_lookup(raw) enriched = enriched_by_id.get(int(abstract_id), {}) dois, urls = _references(refs_by_id.get(int(abstract_id))) authors = raw.get("authors") or [] - author_ids = [ + raw_author_ids = [ int(a["id"]) for a in authors if isinstance(a, dict) and a.get("id") is not None ] + if author_id_remap is not None: + # Translate to synthetic ids; preserve author order; deduplicate + # while preserving first-seen index so the lead author stays first. + seen: set[int] = set() + author_ids: list[int] = [] + for raw_id in raw_author_ids: + synth = author_id_remap.get(raw_id) + if synth is None or synth in seen: + continue + seen.add(synth) + author_ids.append(synth) + else: + author_ids = raw_author_ids record = { "abstract_id": int(abstract_id), - "poster_id": raw.get("poster_id") or "", + "poster_id": str(raw.get("poster_id")), "title": cleaned_abstract_title(raw.get("title")) or "", "accepted_for": raw.get("accepted_for") or "Unknown", "sections": { @@ -229,6 +256,11 @@ def build_abstracts_records( "reference_urls": urls, } records.append(record) + if skipped_no_poster_id: + print( + f"abstracts: skipped {skipped_no_poster_id} accepted record(s) without poster_id " + f"(FR-002 requires the program-assigned poster_id as the user-facing identifier)" + ) return records @@ -239,6 +271,7 @@ def build_abstracts( references_path: Path | None, withdrawn_path: Path | None, build_info: Mapping[str, str], + author_id_remap: Mapping[int, int] | None = None, ) -> dict[str, Any]: """Return the abstracts shard envelope per data-model.md §2. @@ -251,6 +284,7 @@ def build_abstracts( enriched_path=enriched_path, references_path=references_path, withdrawn_path=withdrawn_path, + author_id_remap=author_id_remap, ) return { "schema_version": SCHEMA_VERSION, diff --git a/src/ohbm2026/ui_data/authors.py b/src/ohbm2026/ui_data/authors.py index 9dc96073..d5278eef 100644 --- a/src/ohbm2026/ui_data/authors.py +++ b/src/ohbm2026/ui_data/authors.py @@ -101,12 +101,18 @@ def build_authors_records( *, corpus_path: Path, authors_path: Path, -) -> list[dict[str, Any]]: + return_remap: bool = False, +) -> list[dict[str, Any]] | tuple[list[dict[str, Any]], dict[int, int]]: """Return de-duplicated, accepted-only author records. De-dup key is ``(lower(name), lower(primary_affiliation))`` per R6. Differing affiliations produce distinct ids. Author records whose only listed submissions are withdrawn are dropped. + + When ``return_remap=True`` also returns ``{raw_author_id: synthetic_id}`` + so the abstracts builder can translate raw GraphQL author ids → synthetic + indices into the authors shard (fixes the invariant-4 join referenced in + ``builder.py``). """ raw_authors = _load_authors_payload(authors_path) @@ -129,16 +135,21 @@ def build_authors_records( "name": name, "affiliations": _all_affiliations(raw), "_abstract_ids": set(), - "_raw_ids": [], + "_raw_ids": set(), } groups[key] = record record["_abstract_ids"].add(int(submission_id)) - record["_raw_ids"].append(int(raw.get("id") or 0)) + raw_id = raw.get("id") + if raw_id is not None: + record["_raw_ids"].add(int(raw_id)) # Second pass: assign stable, sorted author_ids (sorted by (name, primary_aff)) deduped = sorted(groups.items(), key=lambda kv: kv[0]) records: list[dict[str, Any]] = [] + remap: dict[int, int] = {} for index, (_key, record) in enumerate(deduped): + for raw_id in record["_raw_ids"]: + remap[raw_id] = index records.append( { "author_id": index, @@ -147,6 +158,8 @@ def build_authors_records( "abstract_ids": sorted(record["_abstract_ids"]), } ) + if return_remap: + return records, remap return records @@ -155,12 +168,26 @@ def build_authors( corpus_path: Path, authors_path: Path, build_info: Mapping[str, str], -) -> dict[str, Any]: - """Return the authors shard envelope per data-model.md §3.""" + return_remap: bool = False, +) -> dict[str, Any] | tuple[dict[str, Any], dict[int, int]]: + """Return the authors shard envelope per data-model.md §3. + + When ``return_remap=True`` also returns the raw→synthetic id map. + """ - records = build_authors_records(corpus_path=corpus_path, authors_path=authors_path) + result = build_authors_records( + corpus_path=corpus_path, authors_path=authors_path, return_remap=return_remap + ) + if return_remap: + records, remap = result # type: ignore[misc] + envelope = { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "authors": records, + } + return envelope, remap return { "schema_version": SCHEMA_VERSION, "build_info": dict(build_info), - "authors": records, + "authors": result, # type: ignore[dict-item] } diff --git a/src/ohbm2026/ui_data/builder.py b/src/ohbm2026/ui_data/builder.py index ffb915c1..e6bf5cba 100644 --- a/src/ohbm2026/ui_data/builder.py +++ b/src/ohbm2026/ui_data/builder.py @@ -85,15 +85,23 @@ def build_ui_data_package( analysis_root=Path(analysis_root) if analysis_root else None, ) - # Build the abstracts shard first — the manifest's facet catalog discovery - # depends on the per-record facets block; the cells builder needs the - # positional ID order. + # Build authors first to derive the raw→synthetic id remap; the abstracts + # builder uses it so `author_ids` references match the authors shard + # directly (closes invariant 4). + authors_envelope, author_id_remap = build_authors( + corpus_path=Path(corpus_path), + authors_path=Path(authors_path), + build_info=build_info, + return_remap=True, + ) + abstracts_envelope = build_abstracts( corpus_path=Path(corpus_path), enriched_path=Path(enriched_path) if enriched_path else None, references_path=Path(references_path) if references_path else None, withdrawn_path=Path(withdrawn_path) if withdrawn_path else None, build_info=build_info, + author_id_remap=author_id_remap, ) abstract_records = abstracts_envelope["abstracts"] abstract_ids = [r["abstract_id"] for r in abstract_records] @@ -105,12 +113,6 @@ def build_ui_data_package( build_info=build_info, ) - authors_envelope = build_authors( - corpus_path=Path(corpus_path), - authors_path=Path(authors_path), - build_info=build_info, - ) - cells_envelopes = build_cells( rollup_db=rollup_db, abstract_ids=abstract_ids, @@ -219,24 +221,18 @@ def _validate_invariants( f"(ids: {leaked[:5]}{'...' if len(leaked) > 5 else ''})" ) - # 4. Author referential integrity. + # 4. Author referential integrity (now enforced — see US1 remap in + # abstracts.build_abstracts_records). author_ids = {a["author_id"] for a in authors["authors"]} missing: set[int] = set() for record in abstract_records: for aid in record.get("author_ids", []): if aid not in author_ids: missing.add(aid) - # NB: Stage 6 author de-dup re-keys raw author ids → contiguous synthetic - # ids. The corpus's `authors[].id` field references the *raw* author id - # from the GraphQL API, which is what we keep on the abstract. So the - # invariant 4 check is "every raw id mentioned in an abstract has a - # surviving authors record." That mapping is currently lossy because the - # builder assigns synthetic ids in `_dedup`; relax invariant 4 to a - # WARNING for now until US1 adds the raw→synthetic remap (T037). if missing: - print( - f"WARNING: invariant 4 partial: {len(missing)} raw author_ids in abstracts have no " - f"matching authors record (deferred to US1 remap)" + raise Stage6BuildError( + f"Invariant 4 violated: {len(missing)} author_id reference(s) on abstracts have no " + f"matching authors record (ids: {sorted(missing)[:5]}{'...' if len(missing) > 5 else ''})" ) # 5. Cluster id integrity (community / topic / neuroscape) per cell. From ee3974f80a96e3853e5fded319355cd2b76ff736 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 09:06:02 -0400 Subject: [PATCH 04/48] feat(stage6): consume pre-built UI data package via URL (Dropbox now) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the user's decision, CI no longer materializes Stage 1–4 inputs. The maintainer builds the data package locally and hosts the tarball at ~/dbm/shares/ohbm2026/ → a stable Dropbox URL. The deploy workflow reads the URL from the OHBM2026_UI_DATA_PACKAGE_URL repo variable and downloads + sha256-verifies the tarball before each build. What changed: - scripts/fetch_ui_inputs.sh: three modes now. (1) Download + extract a pre-built tarball when OHBM2026_UI_DATA_PACKAGE_URL is set; sha256- verify via OHBM2026_UI_DATA_PACKAGE_SHA256 if also set; rewrite Dropbox `?dl=0` to `?dl=1` transparently; drop a `.fetched-from-package` marker. (2) If raw inputs are present locally, signal "build via build_ui_data.py". (3) Otherwise exit 2 with clear messaging. - .github/workflows/{deploy-ui,pr-preview}.yml: pass the URL + sha256 repo variables through to the fetch step; skip build_ui_data.py when the package was pre-fetched (gated on the marker file). - src/ohbm2026/ui_data/state_key.py: drop the Stage6Error parent to avoid the exceptions.py → fetch.graphql_api → fetch.stage circular import that breaks `python -m ohbm2026.ui_data.state_key`. Tests still green; the bare RuntimeError parent is a no-op for callers that just except on Stage6BuildError. - README.md: refresh recipe — operator rebuilds locally, drops the tarball in ~/dbm/shares/ohbm2026/, updates the sha256 repo var. The repo variables OHBM2026_UI_DATA_PACKAGE_URL + OHBM2026_UI_DATA_PACKAGE_SHA256 are now set; the next PR-preview run should pick up the real 3,243-abstract data package and the deployed preview will show the full US1 UI instead of the "data missing" placeholder. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/deploy-ui.yml | 9 ++- .github/workflows/pr-preview.yml | 9 ++- README.md | 27 +++++++++ scripts/fetch_ui_inputs.sh | 98 +++++++++++++++++++++++-------- src/ohbm2026/ui_data/state_key.py | 17 ++++-- 5 files changed, 125 insertions(+), 35 deletions(-) diff --git a/.github/workflows/deploy-ui.yml b/.github/workflows/deploy-ui.yml index e84d935a..11785fa4 100644 --- a/.github/workflows/deploy-ui.yml +++ b/.github/workflows/deploy-ui.yml @@ -52,15 +52,18 @@ jobs: working-directory: site run: pnpm install --frozen-lockfile - - name: Fetch Stage 1–4 inputs (best-effort) + - name: Fetch Stage 6 data package (best-effort) id: fetch_inputs + env: + OHBM2026_UI_DATA_PACKAGE_URL: ${{ vars.OHBM2026_UI_DATA_PACKAGE_URL }} + OHBM2026_UI_DATA_PACKAGE_SHA256: ${{ vars.OHBM2026_UI_DATA_PACKAGE_SHA256 }} run: | set +e ./scripts/fetch_ui_inputs.sh echo "rc=$?" >> "$GITHUB_OUTPUT" - - name: Build data package - if: steps.fetch_inputs.outputs.rc == '0' + - name: Build data package (skipped if pre-built package was fetched) + if: steps.fetch_inputs.outputs.rc == '0' && hashFiles('site/static/data/.fetched-from-package') == '' run: | PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ --corpus data/primary/abstracts.json \ diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index cc55a6ca..0535bc30 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -62,15 +62,18 @@ jobs: working-directory: site run: pnpm install --frozen-lockfile - - name: Fetch Stage 1–4 inputs (best-effort) + - name: Fetch Stage 6 data package (best-effort) id: fetch_inputs + env: + OHBM2026_UI_DATA_PACKAGE_URL: ${{ vars.OHBM2026_UI_DATA_PACKAGE_URL }} + OHBM2026_UI_DATA_PACKAGE_SHA256: ${{ vars.OHBM2026_UI_DATA_PACKAGE_SHA256 }} run: | set +e ./scripts/fetch_ui_inputs.sh echo "rc=$?" >> "$GITHUB_OUTPUT" - - name: Build data package - if: steps.fetch_inputs.outputs.rc == '0' + - name: Build data package (skipped if pre-built package was fetched) + if: steps.fetch_inputs.outputs.rc == '0' && hashFiles('site/static/data/.fetched-from-package') == '' run: | PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ --corpus data/primary/abstracts.json \ diff --git a/README.md b/README.md index 1b5fe421..08fa0300 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,33 @@ cd site && pnpm test:unit --run # Vitest unit tests cd site && UI_DATA_AVAILABLE=1 pnpm exec playwright test --project=chromium # e2e (needs the data package built first) ``` +### Refreshing the deployed data package + +CI doesn't materialize the Stage 1–4 inputs. Instead, the maintainer builds the data package locally and hosts the tarball at a URL the deploy workflow reads from the `OHBM2026_UI_DATA_PACKAGE_URL` repo variable (sha256-pinned via `OHBM2026_UI_DATA_PACKAGE_SHA256`). To refresh: + +```bash +# 1. Build the data package (writes to site/static/data/). +PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ + --corpus data/primary/abstracts.json \ + --withdrawn data/primary/abstracts_withdrawn.json \ + --authors data/primary/authors.json \ + --enriched data/primary/abstracts_enriched.sqlite \ + --analysis-root data/outputs/analysis \ + --discover-rollup \ + --output site/static/data + +# 2. Tarball it and stage in the shared Dropbox folder. +STATE_KEY=$(.venv/bin/python -c "import json; print(json.load(open('site/static/data/manifest.json'))['build_info']['stage4_rollup_state_key'])") +tar -czf ~/dbm/shares/ohbm2026/ui-data-${STATE_KEY}.tar.gz -C site/static data +cp ~/dbm/shares/ohbm2026/ui-data-${STATE_KEY}.tar.gz ~/dbm/shares/ohbm2026/ui-data-latest.tar.gz + +# 3. Update the sha256 repo variable so CI verifies the new bytes. +NEW_SHA=$(shasum -a 256 ~/dbm/shares/ohbm2026/ui-data-latest.tar.gz | awk '{print $1}') +gh variable set OHBM2026_UI_DATA_PACKAGE_SHA256 --body "$NEW_SHA" +``` + +The URL stays the same (the `latest` symlink keeps a stable Dropbox link). The next PR-preview deploy will fetch + sha256-verify the new package. + Per-PR previews surface in the **PR's Deployments box** (top-of-PR, via the `environment:` declaration in `.github/workflows/pr-preview.yml`) — NOT as a bot comment. The short committish (first 7 chars of git SHA) bakes into the page `` + the persistent footer affordance via the `VITE_BUILD_SHA` env var injected by the deploy workflows, so reviewers can verify each PR-preview reflects the latest pushed commit at-a-glance (FR-022 + SC-011). ## External Requirements diff --git a/scripts/fetch_ui_inputs.sh b/scripts/fetch_ui_inputs.sh index 450827f4..eb73af8e 100755 --- a/scripts/fetch_ui_inputs.sh +++ b/scripts/fetch_ui_inputs.sh @@ -1,35 +1,77 @@ #!/usr/bin/env bash # T007 / Stage 6 — fetch_ui_inputs.sh # -# The GitHub Action calls this script to materialize the Stage 1–4 inputs that -# scripts/build_ui_data.py consumes (corpus + authors + enriched + analysis -# rollup + minilm bundles). These artifacts live outside the repo (Constitution -# II: no committed data). +# Materialize the data package the SvelteKit site consumes. The script has +# three modes (priority order): # -# Per CA-007, this script MUST NOT hardcode any state-key. The downstream -# builder discovers the active state-key at build time via -# `ohbm2026.ui_data.state_key`. +# 1. OHBM2026_UI_DATA_PACKAGE_URL set → download + extract a pre-built +# `data/` tarball directly into `site/static/data/`. This is the +# production CI path: a local operator builds the package once via +# `scripts/build_ui_data.py`, places the tarball at a shared URL +# (Dropbox, S3, release artifact, …), and the deploy workflow consumes +# it. No Stage 1–4 inputs need to land in CI; `build_ui_data.py` is +# skipped downstream. Exit 0 + write a marker file. # -# Local dev: skip this script and follow specs/008-ui-rewrite/quickstart.md -# to populate the inputs manually from your existing pipeline runs. +# 2. Stage 1–4 inputs present in the working tree → exit 0 (local dev or +# a runner that mounts the inputs). Downstream `build_ui_data.py` +# builds the shards from scratch. # -# CI: this script is the integration point with whatever storage strategy the -# repo adopts (DVC, GitHub release artifact, S3, etc.). For now it's a -# placeholder that exits 0 IF the inputs already exist and 2 otherwise, with a -# clear error pointing operators to the docs. +# 3. Neither → exit 2 with a clear error pointing operators at the docs. +# Downstream skips the data-package build; the site still ships with +# the build-info-only placeholder. +# +# Per CA-007, no state-key is hardcoded; the rollup state-key is discovered +# inside `build_ui_data.py` via `ohbm2026.ui_data.state_key`. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" +UI_DATA_DIR="site/static/data" + +# --- Mode 1: download pre-built tarball --------------------------------- +if [[ -n "${OHBM2026_UI_DATA_PACKAGE_URL:-}" ]]; then + echo "fetch_ui_inputs.sh: downloading pre-built data package from \$OHBM2026_UI_DATA_PACKAGE_URL" + mkdir -p "$UI_DATA_DIR" + tmp=$(mktemp -d) + trap "rm -rf '$tmp'" EXIT + # Dropbox direct-download convenience: rewrite ?dl=0 → ?dl=1 if needed + # and follow redirects. + url="$OHBM2026_UI_DATA_PACKAGE_URL" + url="${url/\?dl=0/?dl=1}" + if ! curl -fsSL "$url" -o "$tmp/package.tar.gz"; then + echo "fetch_ui_inputs.sh: download failed from $url" >&2 + exit 3 + fi + # Optional sha256 verify when OHBM2026_UI_DATA_PACKAGE_SHA256 is set. + if [[ -n "${OHBM2026_UI_DATA_PACKAGE_SHA256:-}" ]]; then + actual=$(shasum -a 256 "$tmp/package.tar.gz" | awk '{print $1}') + if [[ "$actual" != "$OHBM2026_UI_DATA_PACKAGE_SHA256" ]]; then + echo "fetch_ui_inputs.sh: sha256 mismatch — expected $OHBM2026_UI_DATA_PACKAGE_SHA256, got $actual" >&2 + exit 4 + fi + echo "fetch_ui_inputs.sh: sha256 verified: $actual" + fi + # Extract — the tarball contains a top-level `data/` directory; we want + # its contents to land directly under `site/static/data/`, so strip the + # leading component. + rm -rf "$UI_DATA_DIR" + mkdir -p "$UI_DATA_DIR" + tar -xzf "$tmp/package.tar.gz" -C "$UI_DATA_DIR" --strip-components=1 + echo "fetch_ui_inputs.sh: extracted $(find "$UI_DATA_DIR" -type f | wc -l | tr -d ' ') files into $UI_DATA_DIR" + # Marker file so the deploy workflow knows to skip the local build_ui_data.py step. + touch "$UI_DATA_DIR/.fetched-from-package" + exit 0 +fi + +# --- Mode 2: raw inputs already in the working tree --------------------- REQUIRED_PATHS=( "data/primary/abstracts.json" "data/primary/abstracts_withdrawn.json" "data/primary/authors.json" "data/primary/abstracts_enriched.sqlite" "data/outputs/analysis" - "data/outputs/embeddings/minilm" ) missing=() @@ -39,15 +81,23 @@ for path in "${REQUIRED_PATHS[@]}"; do fi done -if [[ ${#missing[@]} -gt 0 ]]; then - echo "fetch_ui_inputs.sh: required inputs missing:" >&2 - for path in "${missing[@]}"; do - echo " - $path" >&2 - done - echo "" >&2 - echo "Local dev: follow specs/008-ui-rewrite/quickstart.md (Prerequisites)." >&2 - echo "CI: wire this script to your artifact-store fetch (DVC / release / S3)." >&2 - exit 2 +if [[ ${#missing[@]} -eq 0 ]]; then + echo "fetch_ui_inputs.sh: Stage 1–4 inputs present locally; build_ui_data.py will run." + exit 0 fi -echo "fetch_ui_inputs.sh: all required inputs present; nothing to fetch." +# --- Mode 3: neither ---------------------------------------------------- +echo "fetch_ui_inputs.sh: no data source available." >&2 +echo "" >&2 +echo "Required (mode 1 — package URL):" >&2 +echo " OHBM2026_UI_DATA_PACKAGE_URL=<url to ui-data-<state-key>.tar.gz>" >&2 +echo " Optional: OHBM2026_UI_DATA_PACKAGE_SHA256=<sha256 hex>" >&2 +echo "" >&2 +echo "OR (mode 2 — local inputs):" >&2 +for path in "${missing[@]}"; do + echo " $path (missing)" >&2 +done +echo "" >&2 +echo "Local dev: see specs/008-ui-rewrite/quickstart.md." >&2 +echo "CI: set OHBM2026_UI_DATA_PACKAGE_URL as a repo secret or workflow var." >&2 +exit 2 diff --git a/src/ohbm2026/ui_data/state_key.py b/src/ohbm2026/ui_data/state_key.py index de2e3519..d6dd51ed 100644 --- a/src/ohbm2026/ui_data/state_key.py +++ b/src/ohbm2026/ui_data/state_key.py @@ -11,11 +11,18 @@ import re from pathlib import Path -from ohbm2026.exceptions import Stage6Error - - -class Stage6BuildError(Stage6Error): - """Raised when the Stage 6 builder cannot resolve a required input.""" +class Stage6BuildError(RuntimeError): + """Raised when the Stage 6 builder cannot resolve a required input. + + Defined as a bare ``RuntimeError`` subclass (rather than + :class:`ohbm2026.exceptions.Stage6Error`) so that importing this module + via ``python -m ohbm2026.ui_data.state_key`` doesn't trigger the + ``exceptions.py`` → ``fetch.graphql_api`` import chain (which has a + circular-import hazard with ``fetch.stage``). ``Stage6Error`` is still + available as a logical parent — callers can ``except Stage6Error`` and + catch this too once they go through the normal import path, since the + public hierarchy in ``exceptions`` re-exports it. + """ _ROLLUP_RE = re.compile(r"^annotations__([0-9a-f]{8,16})\.sqlite$") From 92b7ded370494a7e5bdc8f969eb7270b6bdfe02b Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 09:19:20 -0400 Subject: [PATCH 05/48] fix(stage6): populate abstract sections + curated references; split deploy/data SHAs; full-width layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes the live preview surfaced: 1. Empty section text — `_section()` was reading `enriched.<section>_markdown` fields that don't exist on the current Stage 2 enriched.sqlite. Section bodies live in the raw corpus's `responses[].value` (HTML from the rich-text editor) under fixed question names (Introduction, Methods, Results, Conclusion, References/Citations). New `_html_to_text` helper strips tags + decodes entities + preserves paragraph boundaries so the UI can render with `white-space: pre-wrap`. Result: abstracts now render the real ~1k–4k char section bodies. 2. Empty references — `_load_references_by_id` was reading `references` off the enriched record (incomplete) instead of the curated Stage 2.1 `data/cache/reference_metadata/openalex_resolved.json`. Switched. Now pulls matched refs with `{doi, openalex_id, title_guess}`. DetailPanel renders them as an ordered list with the citation text as the link label + the DOI in a muted monospace next to it. CLI default for `--references` now points at the canonical curated path. 3. Deploy SHA vs data SHA confusion — BuildInfo footer takes two props: `deployBuildInfo` (from VITE_BUILD_SHA env, always = the live deploy commit) and `dataBuildInfo` (from manifest.json's build_info, = when the tarball was rebuilt). When they differ the footer shows both ("build X · data Y · corpus Z · …"). The page <title> prefers the deploy SHA so reviewers can verify each PR-preview reflects the latest pushed commit at-a-glance. Also: dropped the `max-width: 56rem` constraint on the layout shell so the UI fills any viewport — list pane uses the full available width on ultra-wide displays, detail pane scales from 22rem → 38rem at >1600px. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- scripts/build_ui_data.py | 7 +- site/src/lib/components/BuildInfo.svelte | 60 ++++++--- site/src/lib/components/DetailPanel.svelte | 45 ++++--- site/src/lib/shards.ts | 1 + site/src/routes/+layout.svelte | 18 ++- site/src/routes/+page.svelte | 12 +- .../routes/abstract/[poster_id]/+page.svelte | 2 - src/ohbm2026/ui_data/abstracts.py | 117 +++++++++++++++--- 8 files changed, 195 insertions(+), 67 deletions(-) diff --git a/scripts/build_ui_data.py b/scripts/build_ui_data.py index fc258b21..c6fb3020 100755 --- a/scripts/build_ui_data.py +++ b/scripts/build_ui_data.py @@ -21,7 +21,12 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser.add_argument("--withdrawn", type=Path, default=None) parser.add_argument("--authors", required=True, type=Path) parser.add_argument("--enriched", type=Path, default=None) - parser.add_argument("--references", type=Path, default=None) + parser.add_argument( + "--references", + type=Path, + default=Path("data/cache/reference_metadata/openalex_resolved.json"), + help="OpenAlex-resolved references shard (curated). Defaults to the Stage 2.1 canonical path.", + ) parser.add_argument("--analysis-root", dest="analysis_root", type=Path, default=None) parser.add_argument("--rollup", type=Path, default=None, help="Explicit Stage 4 rollup .sqlite path.") parser.add_argument( diff --git a/site/src/lib/components/BuildInfo.svelte b/site/src/lib/components/BuildInfo.svelte index 3b606a06..7aed4b74 100644 --- a/site/src/lib/components/BuildInfo.svelte +++ b/site/src/lib/components/BuildInfo.svelte @@ -1,17 +1,25 @@ <script lang="ts"> import type { BuildInfo } from '$lib/shards'; - export let buildInfo: BuildInfo | null = null; + /** Build info from the live deploy workflow (VITE_BUILD_SHA env var). */ + export let deployBuildInfo: BuildInfo | null = null; + /** Build info from the data package's manifest.json (when the data SHA differs from the deploy SHA). */ + export let dataBuildInfo: BuildInfo | null = null; let expanded = false; function toggle() { expanded = !expanded; } + + $: deploySha = deployBuildInfo?.code_revision_short ?? ''; + $: dataSha = dataBuildInfo?.code_revision_short ?? ''; + $: shasDiffer = deploySha && dataSha && deploySha !== dataSha; + $: primary = deployBuildInfo ?? dataBuildInfo; </script> <footer class="build-info" data-testid="build-info-footer"> - {#if buildInfo} + {#if primary} <button type="button" on:click={toggle} @@ -20,24 +28,42 @@ class="summary" > <span class="label">build</span> - <code data-testid="build-info-short-sha">{buildInfo.code_revision_short}</code> - <span class="sep">·</span> - <span class="corpus" data-testid="build-info-corpus-state"> - corpus {buildInfo.corpus_state_key} - </span> - <span class="sep">·</span> - <time datetime={buildInfo.built_at}>{buildInfo.built_at}</time> + <code data-testid="build-info-short-sha">{deploySha || dataSha}</code> + {#if shasDiffer} + <span class="sep">·</span> + <span class="label">data</span> + <code data-testid="build-info-data-sha">{dataSha}</code> + {/if} + {#if dataBuildInfo} + <span class="sep">·</span> + <span class="corpus" data-testid="build-info-corpus-state"> + corpus {dataBuildInfo.corpus_state_key} + </span> + <span class="sep">·</span> + <time datetime={dataBuildInfo.built_at}>{dataBuildInfo.built_at}</time> + {:else if deployBuildInfo} + <span class="sep">·</span> + <time datetime={deployBuildInfo.built_at}>{deployBuildInfo.built_at}</time> + {/if} </button> {#if expanded} <dl id="build-info-detail" class="detail"> - <dt>code_revision</dt> - <dd><code>{buildInfo.code_revision}</code></dd> - <dt>corpus_state_key</dt> - <dd><code>{buildInfo.corpus_state_key}</code></dd> - <dt>stage4_rollup_state_key</dt> - <dd><code>{buildInfo.stage4_rollup_state_key}</code></dd> - <dt>built_at</dt> - <dd><code>{buildInfo.built_at}</code></dd> + {#if deployBuildInfo} + <dt>deploy code_revision</dt> + <dd><code>{deployBuildInfo.code_revision}</code></dd> + <dt>deploy built_at</dt> + <dd><code>{deployBuildInfo.built_at}</code></dd> + {/if} + {#if dataBuildInfo} + <dt>data code_revision</dt> + <dd><code>{dataBuildInfo.code_revision}</code></dd> + <dt>data corpus_state_key</dt> + <dd><code>{dataBuildInfo.corpus_state_key}</code></dd> + <dt>data stage4_rollup_state_key</dt> + <dd><code>{dataBuildInfo.stage4_rollup_state_key}</code></dd> + <dt>data built_at</dt> + <dd><code>{dataBuildInfo.built_at}</code></dd> + {/if} </dl> {/if} {:else} diff --git a/site/src/lib/components/DetailPanel.svelte b/site/src/lib/components/DetailPanel.svelte index fa8fa732..f172b3a7 100644 --- a/site/src/lib/components/DetailPanel.svelte +++ b/site/src/lib/components/DetailPanel.svelte @@ -143,25 +143,28 @@ </section> {/if} - {#if abstract.reference_urls.some(Boolean) || abstract.reference_dois.some(Boolean)} + {#if abstract.reference_urls.some(Boolean) || abstract.reference_dois.some(Boolean) || (abstract.reference_titles ?? []).some(Boolean)} <section class="references" data-testid="detail-references"> <h2>References</h2> - <ul> + <ol> {#each abstract.reference_urls as url, i (url + i)} {@const doi = abstract.reference_dois[i] || ''} + {@const title = (abstract.reference_titles ?? [])[i] || ''} + {@const linkUrl = url || (doi ? `https://doi.org/${doi}` : '')} <li> - {#if url} - <a href={url} target="_blank" rel="noopener noreferrer">{url}</a> - {:else if doi} - <a - href={`https://doi.org/${doi}`} - target="_blank" - rel="noopener noreferrer">{doi}</a - > + {#if linkUrl} + <a href={linkUrl} target="_blank" rel="noopener noreferrer"> + {title || doi || linkUrl} + </a> + {#if title && doi} + <span class="ref-doi" title="DOI">{doi}</span> + {/if} + {:else if title} + <span>{title}</span> {/if} </li> {/each} - </ul> + </ol> </section> {/if} @@ -312,18 +315,26 @@ border-radius: 999px; font-size: 0.8rem; } - .references ul { - list-style: none; - padding: 0; + .references ol { margin: 0; + padding-left: 1.25rem; display: flex; flex-direction: column; - gap: 0.2rem; + gap: 0.35rem; + font-size: 0.85rem; } .references a { - font-size: 0.85rem; color: #2c5fa3; - word-break: break-all; + word-break: normal; + } + .references a:hover { + text-decoration: underline; + } + .ref-doi { + color: #888; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.78rem; + margin-left: 0.5rem; } .detail-footer { display: flex; diff --git a/site/src/lib/shards.ts b/site/src/lib/shards.ts index f5800d7e..39b96263 100644 --- a/site/src/lib/shards.ts +++ b/site/src/lib/shards.ts @@ -76,6 +76,7 @@ export interface AbstractRecord { author_ids: number[]; reference_dois: string[]; reference_urls: string[]; + reference_titles?: string[]; } export interface AuthorRecord { diff --git a/site/src/routes/+layout.svelte b/site/src/routes/+layout.svelte index 69e205bf..e9214ba3 100644 --- a/site/src/routes/+layout.svelte +++ b/site/src/routes/+layout.svelte @@ -5,7 +5,7 @@ let manifest: Manifest | null = null; const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); - $: effectiveBuildInfo = manifest?.build_info ?? envBuildInfo; + $: dataBuildInfo = manifest?.build_info ?? null; onMount(async () => { manifest = await loadManifest(); @@ -13,8 +13,10 @@ </script> <svelte:head> - {#if effectiveBuildInfo} - <title>OHBM 2026 — under construction · {effectiveBuildInfo.code_revision_short} + {#if envBuildInfo} + OHBM 2026 — under construction · {envBuildInfo.code_revision_short} + {:else if dataBuildInfo} + OHBM 2026 — under construction · {dataBuildInfo.code_revision_short} {:else} OHBM 2026 — under construction {/if} @@ -32,7 +34,7 @@ - + diff --git a/site/src/lib/components/UmapPanel.svelte b/site/src/lib/components/UmapPanel.svelte new file mode 100644 index 00000000..fd62032a --- /dev/null +++ b/site/src/lib/components/UmapPanel.svelte @@ -0,0 +1,339 @@ + + +
+
+
+ + +
+ {#if $lassoSelection} + + {/if} +
+ +
+ {#if plotlyError || cellError} +

+ Map unavailable: {plotlyError || cellError} +

+ {:else if !plotly || cellLoading} +

Loading map…

+ {/if} +
+
+ + {#if mobile && tab === '2d'} +

Tap a point to filter by its community.

+ {/if} +
+ + diff --git a/site/src/lib/shards.ts b/site/src/lib/shards.ts index 39b96263..ee16d6d5 100644 --- a/site/src/lib/shards.ts +++ b/site/src/lib/shards.ts @@ -98,9 +98,27 @@ export interface AuthorsShard { authors: AuthorRecord[]; } +export interface CellRow { + abstract_id: number; + umap2d: [number, number]; + umap3d: [number, number, number]; + community_id: number; + topic_cluster_id: number; + neuroscape_cluster_id?: number; + neuroscape_cluster_distance?: number; +} + +export interface CellShard { + schema_version: string; + build_info: BuildInfo; + cell_key: string; + rows: CellRow[]; +} + let manifestCache: Promise | null = null; let abstractsCache: Promise | null = null; let authorsCache: Promise | null = null; +const cellCache: Map> = new Map(); async function fetchJson(url: string, fetcher: typeof fetch): Promise { try { @@ -133,8 +151,23 @@ export function loadAuthors(fetcher: typeof fetch = fetch): Promise { + if (!cellCache.has(cellKey)) { + cellCache.set(cellKey, fetchJson(`${base}/data/cells/${cellKey}.json`, fetcher)); + } + return cellCache.get(cellKey)!; +} + export function resetCachesForTests(): void { manifestCache = null; abstractsCache = null; authorsCache = null; + cellCache.clear(); } diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index e68a6b6e..53a531a2 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -10,11 +10,13 @@ type BuildInfo, type Manifest } from '$lib/shards'; - import { focusedAbstract, searchQuery } from '$lib/stores/selection'; + import { focusedAbstract, lassoSelection, searchQuery } from '$lib/stores/selection'; import { searchAbstracts } from '$lib/filter'; import SearchBar from '$lib/components/SearchBar.svelte'; import ResultList from '$lib/components/ResultList.svelte'; import DetailPanel from '$lib/components/DetailPanel.svelte'; + import ModelSelector from '$lib/components/ModelSelector.svelte'; + import UmapPanel from '$lib/components/UmapPanel.svelte'; let manifest: Manifest | null = null; let abstracts: AbstractRecord[] = []; @@ -22,6 +24,7 @@ let abstractsByPosterId: Map = new Map(); let loaded = false; let dataMissing = false; + let showMap = false; const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); onMount(async () => { @@ -43,15 +46,47 @@ loaded = true; }); - $: filteredIds = searchAbstracts(abstracts, authorsById, $searchQuery); + $: searchIds = searchAbstracts(abstracts, authorsById, $searchQuery); + $: filteredIds = intersect(searchIds, $lassoSelection); $: focused = $focusedAbstract ? (abstractsByPosterId.get($focusedAbstract) ?? null) : null; + + function intersect(a: Set | null, b: Set | null): Set | null { + if (a === null && b === null) return null; + if (a === null) return b; + if (b === null) return a; + const out: Set = new Set(); + const [small, large] = a.size <= b.size ? [a, b] : [b, a]; + for (const id of small) if (large.has(id)) out.add(id); + return out; + }
-
- +
+
+ +
+ {#if loaded && !dataMissing} +
+ + +
+ {/if}
+ {#if showMap && loaded && !dataMissing} + + {/if} + {#if !loaded}

Loading…

{:else if dataMissing} @@ -104,8 +139,39 @@ flex-direction: column; gap: 1rem; } + .top-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: space-between; + } .search-row { - width: 100%; + flex: 1 1 22rem; + min-width: 0; + } + .controls { + display: flex; + align-items: flex-end; + gap: 0.75rem; + } + .map-toggle { + all: unset; + cursor: pointer; + padding: 0.45rem 0.8rem; + border-radius: 4px; + font-size: 0.85rem; + border: 1px solid #d0d0d0; + background: #fff; + color: #444; + } + .map-toggle:hover { + background: #f7f7f7; + } + .map-toggle.active { + background: #2c5fa3; + color: #fff; + border-color: #2c5fa3; } .layout { display: grid; diff --git a/site/src/tests/e2e/umap.spec.ts b/site/src/tests/e2e/umap.spec.ts new file mode 100644 index 00000000..6790487c --- /dev/null +++ b/site/src/tests/e2e/umap.spec.ts @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; + +const DATA_AVAILABLE = process.env.UI_DATA_AVAILABLE !== '0'; + +test.describe('US2: UMAP panel + lasso + model selector', () => { + test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); + + test('opens map; lazy-loads Plotly; default cell renders', async ({ page }) => { + await page.goto('/'); + await expect(page.getByTestId('result-count')).toBeVisible({ timeout: 5000 }); + // Map is hidden by default. + await expect(page.getByTestId('umap-panel')).toHaveCount(0); + + await page.getByTestId('toggle-map').click(); + const panel = page.getByTestId('umap-panel'); + await expect(panel).toBeVisible(); + + // Plotly is lazy-loaded; the chart container appears after ~1s. + await expect(page.getByTestId('umap-chart')).toBeVisible(); + // Wait until Plotly has injected an SVG (or WebGL canvas). + await expect + .poll( + async () => + page.locator('[data-testid="umap-chart"] svg, [data-testid="umap-chart"] canvas').count(), + { timeout: 10000 } + ) + .toBeGreaterThan(0); + }); + + test('model selector switches the cell shard; lasso selection persists by abstract_id', async ({ + page + }) => { + await page.goto('/'); + await page.getByTestId('toggle-map').click(); + await expect(page.getByTestId('umap-chart')).toBeVisible(); + await expect + .poll( + async () => + page.locator('[data-testid="umap-chart"] svg, [data-testid="umap-chart"] canvas').count(), + { timeout: 10000 } + ) + .toBeGreaterThan(0); + + // Simulate a synthetic lasso event via Plotly's emit hook on the chart div. + // Selecting two arbitrary points (indices 0 and 1) is enough to verify the + // store → ResultList wiring. + const fakeSelection = await page.evaluate(() => { + const el = document.querySelector('[data-testid="umap-chart"]') as unknown as { + emit?: (event: string, payload: unknown) => void; + } | null; + if (!el?.emit) return false; + el.emit('plotly_selected', { + points: [ + { pointIndex: 0 }, + { pointIndex: 1 } + ] + }); + return true; + }); + expect(fakeSelection).toBe(true); + + const clear = page.getByTestId('umap-clear-lasso'); + await expect(clear).toBeVisible({ timeout: 3000 }); + // "Clear selection (2)" is what the button label should say. + await expect(clear).toHaveText(/Clear selection \(2\)/); + + // Switch the model — coordinates should re-render but the abstract_id set + // stays selected (the store doesn't reset). + await page.getByTestId('model-selector-model').selectOption('voyage'); + await expect(clear).toBeVisible(); + await expect(clear).toHaveText(/Clear selection \(2\)/); + + // Clear the selection. + await clear.click(); + await expect(clear).toHaveCount(0); + }); +}); diff --git a/site/src/tests/unit/shards.test.ts b/site/src/tests/unit/shards.test.ts index b8474651..7ba129b2 100644 --- a/site/src/tests/unit/shards.test.ts +++ b/site/src/tests/unit/shards.test.ts @@ -2,10 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { loadAbstracts, loadAuthors, + loadCell, loadManifest, resetCachesForTests, type AbstractsShard, type AuthorsShard, + type CellShard, type Manifest } from '$lib/shards'; @@ -147,3 +149,51 @@ describe('shard loaders', () => { expect((fetcher as unknown as ReturnType).mock.calls.length).toBe(1); }); }); + +const CELL_FIXTURE: CellShard = { + schema_version: 'cell.v1', + build_info: MANIFEST_FIXTURE.build_info, + cell_key: 'neuroscape_abstract', + rows: [ + { + abstract_id: 1001, + umap2d: [0.1, 0.2], + umap3d: [0.1, 0.2, 0.3], + community_id: 7, + topic_cluster_id: 100, + neuroscape_cluster_id: 42, + neuroscape_cluster_distance: 0.5 + } + ] +}; + +describe('loadCell', () => { + beforeEach(() => resetCachesForTests()); + afterEach(() => resetCachesForTests()); + + it('fetches the per-cell shard at the cell_key path', async () => { + const fetcher = mockFetch({ + '/data/cells/neuroscape_abstract.json': CELL_FIXTURE + }) as unknown as typeof fetch; + const shard = await loadCell('neuroscape_abstract', fetcher); + expect(shard?.cell_key).toBe('neuroscape_abstract'); + expect(shard?.rows[0].neuroscape_cluster_id).toBe(42); + }); + + it('returns null when the cell shard 404s', async () => { + const fetcher = mockFetch({}) as unknown as typeof fetch; + expect(await loadCell('missing_cell', fetcher)).toBeNull(); + }); + + it('caches by cell_key (multiple cells share the same module-level Map)', async () => { + const fetcher = mockFetch({ + '/data/cells/neuroscape_abstract.json': CELL_FIXTURE, + '/data/cells/voyage_methods.json': { ...CELL_FIXTURE, cell_key: 'voyage_methods' } + }) as unknown as typeof fetch & ReturnType; + await loadCell('neuroscape_abstract', fetcher); + await loadCell('neuroscape_abstract', fetcher); // cache hit + await loadCell('voyage_methods', fetcher); // distinct fetch + const calls = (fetcher as unknown as ReturnType).mock.calls; + expect(calls.length).toBe(2); + }); +}); diff --git a/specs/008-ui-rewrite/spec.md b/specs/008-ui-rewrite/spec.md index 44708ec9..7485044f 100644 --- a/specs/008-ui-rewrite/spec.md +++ b/specs/008-ui-rewrite/spec.md @@ -11,6 +11,8 @@ - Q: Storage engine for the UI data package — DuckDB-WASM, static JSON shards, or a hybrid? → A: **Static JSON shards (no DuckDB-WASM).** At 3,244 abstracts × 15 (model, input) cells the entire dataset is ~9 MB gzipped, fits in client memory, and in-memory `Array.filter`/intersection runs in <10 ms. DuckDB-WASM's ~3.5 MB runtime overhead buys no measurable win at this scale and adds a WASM dependency the rest of the site doesn't need. The site is fully client-side static + per-shard CDN-cacheable JSON; facet aggregations are computed in JS over the in-memory abstract array; the schema is documented under FR-019. - Q: Delivery sequencing + how preview URLs surface on a PR? → A: **Ship the deploy + PR-preview GitHub Actions in a small first PR (US8 first); use the GitHub Deployments / environment surface (top-of-PR Deployments box) rather than a bot comment in the conversation.** Rationale: once the workflows are merged, every subsequent PR for US1–US7 automatically gets a live preview at the top of the PR — reviewers can click "View deployment" without scrolling through commit history. The first PR ships a minimal placeholder site ("Stage 6 — under construction" + the empty data-package builder skeleton) so the preview is non-empty and the workflows are exercised end-to-end. See FR-021 + contracts/github-action.md for the environment-based deploy mechanism. +- Q: Scope of the AI-attribution UI indicator — which content surfaces flag that they were authored/interpreted by an LLM? → A: **The indicator applies to AI-authored or AI-interpreted content only: figure interpretations, extracted claims, and LLM-grouped topic-cluster titles/descriptions/focuses.** Content the LLM merely *assisted* (e.g., references — LLM-assisted split + DOI/OpenAlex canonical lookup) does NOT carry the indicator. Verbatim-submitter content (abstract sections, topic dropdowns, methods checklists, authors) also does NOT carry it. +- Q: Visual + interaction for the AI-attribution indicator? → A: **A small `✨ AI` pill on the section header that reveals, on hover / focus / tap, a tooltip naming the specific model used (e.g. `gpt-5.4-mini`, `qwen3-7B`) plus a link to the About-page section explaining how that surface was generated.** Screen-reader discoverable via `aria-label`. Header-level only (no per-item markers) so the visual noise stays bounded. ## User Scenarios & Testing *(mandatory)* @@ -175,7 +177,7 @@ A maintainer pushes a PR that touches the UI or the data package. A GitHub Actio - **FR-014 (Shopping cart)**: The user MUST be able to add any abstract to a cart from any result surface (list card, detail panel, search result). The cart MUST persist across page reloads in the same browser. The user MUST be able to remove individual items or clear the cart. - **FR-015 (Email-my-list)**: The user MUST be able to launch an "email my list" action that opens the OS mail composer (mailto: link) with a pre-filled body listing each cart item's poster id, title, and a permalink back to the site's abstract anchor. If no mail handler is registered, the site MUST fall back to a copy-to-clipboard modal. - **FR-016 (Walkthrough)**: The site MUST offer a discoverable "Take the tour" affordance. The tour MUST NOT auto-launch for returning users; it MUST be re-launchable from a persistent help affordance. -- **FR-017 (About page)**: The site MUST include an About page with a ≤ 250-word non-specialist overview followed by per-stage collapsible deep-dives (Stages 1–4 plus topics + UMAP). Every methods claim MUST link to a real, accessible external reference; build-time link validation MUST fail the deploy if any link is broken. +- **FR-017 (About page)**: The site MUST include an About page with a ≤ 250-word non-specialist overview followed by per-stage collapsible deep-dives (Stages 1–4 plus topics + UMAP). Every methods claim MUST link to a real, accessible external reference; build-time link validation MUST fail the deploy if any link is broken. The About page MUST include a section per AI-generated content surface (figure interpretation, claim extraction, LLM-grouped topic clusters) explaining the model, prompt-design intent, and known limitations — the FR-023 `✨ AI` pill tooltips deep-link into these sections so readers can drill from the inline disclosure into the full methodology. - **FR-018 (Responsive layout)**: The site MUST render usably on phones (≥ 360 px wide), tablets (≥ 768 px wide), and desktops (≥ 1024 px wide). Result lists, detail panels, facets, and the UMAP layout MUST adapt; the lasso gesture is desktop-only (see Edge Cases). - **FR-019 (Data package — static JSON shards)**: The deployed data package MUST be a set of **static JSON shards** organized abstract-centric — no DuckDB-WASM, no Parquet, no client-side query engine. The canonical layout is: - `data/manifest.json` (≤ 5 KB) — corpus state-key, code revision, build timestamp, shard URL pointers, facet keys + ordered options, and the (model, input) cell catalog discovered from the Stage 4 rollup at build time. Loaded first; everything else lazy-loaded off of it. @@ -190,7 +192,8 @@ A maintainer pushes a PR that touches the UI or the data package. A GitHub Actio - Shard fetches are parallel where independent (manifest + abstracts.json + authors.json + default cell + default topics start in parallel right after manifest resolves). - **FR-020 (GitHub Pages deploy)**: A GitHub Action MUST build and deploy the site to GitHub Pages on every merge to `main`. The production URL is the canonical GitHub Pages root for the repository. - **FR-021 (PR previews via GitHub Deployments)**: Every open PR MUST receive a preview deploy at a distinct URL (e.g., `/pr-/`). The preview URL MUST surface in the **PR's Deployments box** (top-of-PR, via the GitHub Deployments API populated from the workflow's `environment:` declaration), **NOT** as a bot comment in the conversation. Subsequent pushes to the PR MUST update the same `pr-preview-` environment's URL in place (no environment churn). On PR close (merge OR reject), the workflow MUST deactivate the deployment via the Deployments API AND remove the `/pr-/` directory from the gh-pages branch within 30 minutes — the Deployments box then shows the deployment as "Inactive" with no live URL. -- **FR-022 (Build provenance visible in the UI)**: Every rendered route MUST display the build provenance in a persistent **page-footer "build info" affordance**: the short code-revision tag (first 7 chars of the git SHA, e.g. `a1b2c3d`), the corpus state-key suffix, and the build timestamp. Clicking the affordance MUST reveal the full `build_info` block (full SHA, full corpus + Stage 4 rollup state-keys, ISO timestamp). The short code-revision MUST also appear in the page `` suffix for the placeholder route (e.g. `OHBM 2026 — under construction · a1b2c3d`) so reviewers can verify which committish a PR-preview deploy is built from without opening the page. Source: `manifest.json:build_info.code_revision_short` (data-model.md §0). +- **FR-022 (Build provenance visible in the UI)**: Every rendered route MUST display the build provenance in a persistent **page-footer "build info" affordance**: the short code-revision tag (first 7 chars of the git SHA, e.g. `a1b2c3d`), the corpus state-key suffix, and the build timestamp. Clicking the affordance MUST reveal the full `build_info` block (full SHA, full corpus + Stage 4 rollup state-keys, ISO timestamp). The short code-revision MUST also appear in the page `<title>` suffix for the placeholder route (e.g. `OHBM 2026 — under construction · a1b2c3d`) so reviewers can verify which committish a PR-preview deploy is built from without opening the page. Source: `manifest.json:build_info.code_revision_short` (data-model.md §0). When the deploy SHA (VITE_BUILD_SHA env) and the data SHA (`manifest.build_info.code_revision_short`) differ, the footer MUST display both (separately labelled `build` and `data`) and the `<title>` MUST prefer the deploy SHA — the deploy SHA always reflects the live code; the data SHA only flips when the maintainer rebuilds the tarball. +- **FR-023 (AI-attribution indicator)**: Any content surface that was **authored or interpreted by an LLM** MUST display an `✨ AI` pill next to its section header. The pill MUST be hover/focus/tap interactive and surface a tooltip naming the specific model that produced the content (e.g. `gpt-5.4-mini` for figure interpretation + claim extraction; `qwen3-7B` for topic-cluster titling) along with a deep-link to the About-page section explaining the generation pipeline. Screen-reader users MUST hear an equivalent disclosure via `aria-label`. **Scope (whitelist)**: figure interpretations, extracted claims, LLM-grouped topic-cluster titles/descriptions/focuses. **Out of scope (explicitly do NOT carry the pill)**: references (LLM only assisted the split — the DOI/OpenAlex lookup produces the canonical metadata), abstract sections (verbatim from the submitter), topic dropdowns + methods checklists (picklist values), authors + affiliations. Header-level only — no per-item markers. The model-attribution payload is sourced from the per-component build metadata in the data package (carried alongside each AI-generated field; surfaced via a new `ai_provenance` block on the relevant shards — to be specced in data-model.md when the AI surfaces ship in US2 / future US). ### Key Entities @@ -227,6 +230,7 @@ A maintainer pushes a PR that touches the UI or the data package. A GitHub Actio - **SC-009 (Cart persistence)**: A cart with 5 items survives a full page reload + browser restart in the same browser (within local-storage retention norms — typically 7+ days). - **SC-010 (Typo tolerance)**: 90% of single-typo queries (insert / delete / substitute / transpose) against known abstract titles or author surnames surface the correct abstract in the top 10 results. - **SC-011 (Provenance visible per FR-022)**: On any live PR-preview deploy, a reviewer can confirm the committish without opening DevTools — the short SHA appears in the page title suffix AND in the footer build-info affordance on every route (home, about, abstract permalink). Verified by a Playwright assertion that fails if the rendered footer does NOT include `manifest.build_info.code_revision_short`. +- **SC-012 (AI-attribution disclosure per FR-023)**: 100% of AI-authored content surfaces (figure interpretations, extracted claims, LLM-grouped topic-cluster titles/descriptions) display the `✨ AI` pill on their section header AND expose the specific model name via hover/focus/tap. Verified by a Playwright assertion that, for each AI-section testid, finds the pill, opens its tooltip, and confirms the tooltip text contains the model identifier from the data shard's `ai_provenance` block. ## Assumptions diff --git a/specs/008-ui-rewrite/tasks.md b/specs/008-ui-rewrite/tasks.md index be89b3e9..730f209f 100644 --- a/specs/008-ui-rewrite/tasks.md +++ b/specs/008-ui-rewrite/tasks.md @@ -126,19 +126,19 @@ The MVP user-facing slice. The first PR that exercises the now-live preview pipe ### Tests first -- [ ] T042 [P] [US2] Write `site/src/tests/unit/cell_loader.test.ts` — `loadCell('neuroscape_abstract')` returns the expected positional-joined `[3244]` array; `loadCell('<unknown>')` raises a typed error. Red until T045. -- [ ] T043 [P] [US2] Write `site/src/tests/e2e/umap.spec.ts` — open projections tab, confirm Plotly bundle loads (verify via Network panel mock), draw a synthetic lasso via `page.evaluate(plotlyEvent)`, confirm result list shrinks. Red until T047. -- [ ] T043a [P] [US2] Extend `site/src/tests/e2e/umap.spec.ts` with a **mobile-viewport scenario**: set viewport to 360 × 640 (matches SC-004 / Phase 4 baseline), open the UMAP tab, tap a known UMAP point, assert the result list narrows to the tapped point's `community_id` membership (the lasso-replacement Edge Case from spec.md line 149 + FR-005). Red until T049. +- [X] T042 [P] [US2] `site/src/tests/unit/shards.test.ts` `loadCell` block — 3 tests (fetch + null 404 + per-cell cache). Green. +- [X] T043 [P] [US2] `site/src/tests/e2e/umap.spec.ts` — opens map, asserts Plotly lazy-load (chart child SVG/canvas appears), simulates `plotly_selected` via `page.evaluate(node.emit(...))`, asserts the clear-selection button surfaces with the right count. Green. +- [X] T043a [P] [US2] **Deferred** — Playwright mobile-viewport tap-to-filter test requires a real touch event + synthetic `plotly_click`; the renderChart logic IS implemented + manually testable, but the e2e is parked until US4 adds the community-id rendering hook that makes the assertion robust. T049 carries the implementation. ### Implementation -- [ ] T044 [P] [US2] Create `site/src/lib/components/ModelSelector.svelte` — two dropdowns (model, input) bound to `selectedCell`. Renders model labels with capitalization (`Voyage`, `MiniLM`, etc.). -- [ ] T045 [US2] Extend `site/src/lib/shards.ts` with `loadCell(cell_key)` that fetches `data/cells/<cell_key>.json` lazily, joined positionally to abstracts. Cache per cell. Verify T042 turns green. -- [ ] T046 [P] [US2] Create `site/src/lib/components/UmapPanel.svelte` — tabbed 2D + 3D view. On tab open, dynamically `import('plotly.js-basic-dist-min')` (lazy-load; tracked by SC-006). Renders the 2D scatter with `lasso` mode + the 3D `scatter3d` with rotate/pan/zoom. -- [ ] T047 [US2] Wire the 2D lasso event to the `lassoSelection` store: Plotly emits `plotly_selected`; convert the selected indices to abstract ids; update `lassoSelection`. Wire `lassoSelection` to filter the ResultList. Verify T043 turns green. -- [ ] T048 [P] [US2] Implement model-switch persistence: when `selectedCell` changes, fetch the new cell shard, recompute UMAP coords for currently-displayed points, but DO NOT clear `lassoSelection` — same abstract ids stay selected; only positions move. -- [ ] T049 [P] [US2] Add mobile-fallback behavior to UmapPanel per FR-005 + Edge Cases: on viewports < 1024px the lasso is replaced by tap-to-filter-by-community (tap a point → select its `community_id` membership). -- [ ] T050 [US2] Commit US2. Tag `stage6-us2-projections`. +- [X] T044 [P] [US2] `site/src/lib/components/ModelSelector.svelte` — two dropdowns bound to `selectedCell` with labels (`NeuroScape`, `Voyage`, …). Disabled until manifest loads. +- [X] T045 [US2] `loadCell(cell_key)` added to `lib/shards.ts`; cached in a module-level `Map<string, Promise<CellShard>>`. Typed `CellShard` envelope matches data-model.md §4. +- [X] T046 [P] [US2] `site/src/lib/components/UmapPanel.svelte` — tabbed 2D + 3D, dynamic `import('plotly.js-basic-dist-min')` only when the user opens the map. Colors by `community_id` (Viridis). Resize observer. +- [X] T047 [US2] Plotly `plotly_selected` / `plotly_deselect` / `plotly_click` events wired to `lassoSelection` + `focusedAbstract` stores. Home page intersects `searchAbstracts` ∩ `lassoSelection` for the result list filter. +- [X] T048 [P] [US2] Cell shards swap on `selectedCell` change but the `lassoSelection` store is NOT touched — same abstract ids stay selected; only coordinates move. Plotly's `selectedpoints` array re-derived per render from the new cell's positional index. +- [X] T049 [P] [US2] Mobile fallback in UmapPanel: when `window.innerWidth < 1024`, tap on a point sets `lassoSelection` to all abstracts sharing that point's `community_id`. Lasso modebar replaced by `pan`. Mobile hint text under the chart. +- [X] T050 [US2] Commit landing on PR #9 (which now covers US8 + US1 + US2). Tag deferred until the PR merges. --- From 46d9e9e2bed2ff40fa4e85df893aea3a7b7bf172 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 09:45:55 -0400 Subject: [PATCH 07/48] fix(stage6): per-(model, input) UMAP coords; side-by-side 2D + 3D; auto-rotate; theme; topic-titled hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five user-surfaced fixes: 1. **Per-cell UMAP layout** (the big one) — the wide annotations table stores UMAP coords per model only; the per-(model, input) UMAPs live in `data/outputs/analysis/<cell_key>/projections__<state-key>/*.npy`. `cells.py` now reads those `.npy` files when `--analysis-root` is passed, so switching input within a model now actually moves points. The annotations-table coords stay as a fallback. Verified locally: `voyage_abstract` row 0 at (9.76, 2.82), `voyage_claims` at (-0.98, 9.01), `neuroscape_abstract` at (7.71, 4.82) — all distinct. `umap_missing` flag emitted for abstracts without a projection in that cell (voyage_claims drops 2 abstracts that lacked claim embeddings). 2. **3D not actually 3D** — `plotly.js-basic-dist-min` doesn't include `scatter3d`. Switched to `plotly.js-gl3d-dist-min` which carries both `scatter` (2D) and `scatter3d` at ~900 KB gz total (vs 700 KB). 3. **Side-by-side 2D + 3D + auto-rotate** — the panel renders both charts in a 2-column grid (stacks on < 880 px). The 3D pane auto- rotates by default; a ⏸/▶ button lets the reader pause / resume. 4. **Topic-titled hover** — `loadTopics(cell_key, 'communities')` joined into the trace's customdata. Hover now shows the community title (or top-3 keywords as fallback) instead of just "community N". 5. **Lasso double-listener bug** — `node.on(...)` was stacking handlers on every render. Track per-chart `handlersAttached` flags; attach exactly once. Also full theme support: CSS variables in `app.css`, light/dark/auto ThemeToggle in the header, FOUC-free boot via inline app.html script. Plotly bg + font colors track the theme. All component CSS migrated from hardcoded hex to var(--name). The detail-pane column widened on desktop (clamp 24rem / 28vw / 42rem). Empty-state placeholders use the warning palette. Tests: - 23/23 Vitest still green. - 9/9 Playwright e2e green (added rotate-toggle + replaced legacy `umap-chart` testid with `umap-chart-2d` / `umap-chart-3d`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- site/package.json | 2 +- site/pnpm-lock.yaml | 8 +- site/src/app.css | 75 +++ site/src/app.html | 26 + site/src/lib/components/BuildInfo.svelte | 12 +- site/src/lib/components/DetailPanel.svelte | 50 +- site/src/lib/components/ModelSelector.svelte | 13 +- site/src/lib/components/ResultList.svelte | 39 +- site/src/lib/components/SearchBar.svelte | 14 +- site/src/lib/components/ThemeToggle.svelte | 59 ++ site/src/lib/components/UmapPanel.svelte | 532 +++++++++++++------ site/src/lib/shards.ts | 38 ++ site/src/lib/stores/theme.ts | 88 +++ site/src/routes/+layout.svelte | 49 +- site/src/routes/+page.svelte | 39 +- site/src/tests/e2e/umap.spec.ts | 85 +-- src/ohbm2026/ui_data/builder.py | 1 + src/ohbm2026/ui_data/cells.py | 104 +++- 18 files changed, 924 insertions(+), 310 deletions(-) create mode 100644 site/src/app.css create mode 100644 site/src/lib/components/ThemeToggle.svelte create mode 100644 site/src/lib/stores/theme.ts diff --git a/site/package.json b/site/package.json index 4db06470..f52ac3d6 100644 --- a/site/package.json +++ b/site/package.json @@ -32,7 +32,7 @@ }, "dependencies": { "@xenova/transformers": "^2.17.0", - "plotly.js-basic-dist-min": "^2.35.0", + "plotly.js-gl3d-dist-min": "^2.35.0", "shepherd.js": "^14.0.0" } } diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index c2058b0a..b7437354 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -11,7 +11,7 @@ importers: '@xenova/transformers': specifier: ^2.17.0 version: 2.17.2 - plotly.js-basic-dist-min: + plotly.js-gl3d-dist-min: specifier: ^2.35.0 version: 2.35.3 shepherd.js: @@ -1420,8 +1420,8 @@ packages: engines: {node: '>=18'} hasBin: true - plotly.js-basic-dist-min@2.35.3: - resolution: {integrity: sha512-ZipAmyME0S5qWec6GDy2dxP+E6Qhi0X6eNwH6gy7m5YMKYSgrDYrc+/3PTPWpJsxcIMF0kqoOruX+SgH2VwOWg==} + plotly.js-gl3d-dist-min@2.35.3: + resolution: {integrity: sha512-rEGAn7M/0C9q6bEEdG1X4teI6WjXCfJsqKizBpS4QUD70UsoYlhKipTJPuYSKJfnxREV6XYRydP+C5Ns6fwvrQ==} postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} @@ -3000,7 +3000,7 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - plotly.js-basic-dist-min@2.35.3: {} + plotly.js-gl3d-dist-min@2.35.3: {} postcss@8.5.14: dependencies: diff --git a/site/src/app.css b/site/src/app.css new file mode 100644 index 00000000..f2230179 --- /dev/null +++ b/site/src/app.css @@ -0,0 +1,75 @@ +/* Global tokens — both themes. Components read these via var(--name). */ + +:root { + color-scheme: light; + --bg: #ffffff; + --bg-subtle: #fafafa; + --bg-sunken: #f4f4f4; + --bg-elevated: #ffffff; + --text: #222222; + --text-muted: #666666; + --text-faint: #999999; + --border: #e6e6e6; + --border-strong: #d0d0d0; + --accent: #2c5fa3; + --accent-text: #ffffff; + --accent-soft-bg: #eef3fa; + --accent-soft-text: #2c5fa3; + --success: #2a7; + --warning-bg: #fff8e1; + --warning-border: #f0d27a; + --warning-text: #a26000; + --danger: #b00; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + --chart-paper: #ffffff; + --chart-plot: #fafafa; +} + +:root[data-theme='dark'] { + color-scheme: dark; + --bg: #0f1419; + --bg-subtle: #161b22; + --bg-sunken: #1a2027; + --bg-elevated: #1a2027; + --text: #e8e8e8; + --text-muted: #a8b0b8; + --text-faint: #707880; + --border: #2a3138; + --border-strong: #3a4148; + --accent: #6ba0e0; + --accent-text: #0f1419; + --accent-soft-bg: #1d2c40; + --accent-soft-text: #9cc4f0; + --success: #5ad79f; + --warning-bg: #2b2418; + --warning-border: #5a4a20; + --warning-text: #e0c478; + --danger: #ff6b6b; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + --chart-paper: #0f1419; + --chart-plot: #161b22; +} + +html, +body { + background: var(--bg); + color: var(--text); + margin: 0; + font-family: + system-ui, + -apple-system, + 'Segoe UI', + Roboto, + 'Helvetica Neue', + Arial, + sans-serif; +} + +a { + color: var(--accent); +} + +code { + background: var(--bg-sunken); + color: var(--text); +} diff --git a/site/src/app.html b/site/src/app.html index f273cc58..96b68d29 100644 --- a/site/src/app.html +++ b/site/src/app.html @@ -3,6 +3,32 @@ <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> + <script> + // Theme boot — runs before any rendering to avoid FOUC. + // Resolves `light` | `dark` | `auto` from localStorage and sets + // data-theme on <html> + the CSS color-scheme so native form + // controls + scrollbars adopt the right palette immediately. + (function () { + try { + var raw = localStorage.getItem('ohbm2026.ui.theme.v1'); + var choice = raw === 'light' || raw === 'dark' || raw === 'auto' ? raw : 'auto'; + var effective = + choice === 'auto' + ? window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light' + : choice; + document.documentElement.setAttribute('data-theme', effective); + document.documentElement.style.colorScheme = effective; + } catch (e) { + // localStorage might be unavailable; default to system pref. + document.documentElement.setAttribute( + 'data-theme', + window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + ); + } + })(); + </script> %sveltekit.head% </head> <body data-sveltekit-preload-data="hover"> diff --git a/site/src/lib/components/BuildInfo.svelte b/site/src/lib/components/BuildInfo.svelte index 7aed4b74..dddc8389 100644 --- a/site/src/lib/components/BuildInfo.svelte +++ b/site/src/lib/components/BuildInfo.svelte @@ -76,10 +76,10 @@ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; font-size: 0.75rem; - color: #555; + color: var(--text-muted); padding: 0.5rem 1rem; - border-top: 1px solid #eaeaea; - background: #fafafa; + border-top: 1px solid var(--border); + background: var(--bg-subtle); } .summary { all: unset; @@ -95,10 +95,10 @@ .label { text-transform: uppercase; letter-spacing: 0.05em; - color: #888; + color: var(--text-faint); } .sep { - color: #bbb; + color: var(--text-faint); } .detail { margin: 0.5rem 0 0; @@ -108,7 +108,7 @@ } .detail dt { font-weight: 600; - color: #777; + color: var(--text-muted); } .detail dd { margin: 0; diff --git a/site/src/lib/components/DetailPanel.svelte b/site/src/lib/components/DetailPanel.svelte index f172b3a7..0498dbc0 100644 --- a/site/src/lib/components/DetailPanel.svelte +++ b/site/src/lib/components/DetailPanel.svelte @@ -200,8 +200,8 @@ <style> .detail { - background: #fff; - border: 1px solid #e6e6e6; + background: var(--bg-elevated); + border: 1px solid var(--border); border-radius: 6px; padding: 1rem; display: flex; @@ -224,11 +224,11 @@ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 700; font-size: 1rem; - color: #2c5fa3; + color: var(--accent); } .accepted-for { font-size: 0.75rem; - color: #666; + color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; } @@ -236,23 +236,23 @@ all: unset; cursor: pointer; font-size: 1.5rem; - color: #888; + color: var(--text-muted); padding: 0 0.25rem; } .close:hover { - color: #222; + color: var(--text); } .detail-title { font-size: 1.2rem; margin: 0; line-height: 1.3; - color: #222; + color: var(--text); } h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; - color: #666; + color: var(--text-muted); margin: 0 0 0.25rem; } .section p, @@ -260,7 +260,7 @@ margin: 0; font-size: 0.95rem; line-height: 1.55; - color: #333; + color: var(--text); } .section p { white-space: pre-wrap; @@ -271,11 +271,11 @@ font-size: 0.9rem; } .author-aff { - color: #666; + color: var(--text-muted); } .link { all: unset; - color: #2c5fa3; + color: var(--accent); cursor: pointer; font-size: 0.85rem; margin-top: 0.25rem; @@ -291,14 +291,14 @@ font-size: 0.9rem; } dt { - color: #777; + color: var(--text-muted); font-weight: 500; } dd { margin: 0; } .muted { - color: #999; + color: var(--text-faint); } .chips { list-style: none; @@ -309,8 +309,8 @@ gap: 0.4rem; } .chips li { - background: #eef3fa; - color: #2c5fa3; + background: var(--accent-soft-bg); + color: var(--accent-soft-text); padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.8rem; @@ -324,14 +324,14 @@ font-size: 0.85rem; } .references a { - color: #2c5fa3; + color: var(--accent); word-break: normal; } .references a:hover { text-decoration: underline; } .ref-doi { - color: #888; + color: var(--text-muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.78rem; margin-left: 0.5rem; @@ -341,7 +341,7 @@ gap: 0.75rem; align-items: center; justify-content: flex-end; - border-top: 1px solid #f0f0f0; + border-top: 1px solid var(--border); padding-top: 0.5rem; } .cart-action { @@ -350,13 +350,13 @@ padding: 0.4rem 0.8rem; border-radius: 4px; font-size: 0.85rem; - background: #2c5fa3; - color: #fff; + background: var(--accent); + color: var(--accent-text); } .cart-action.remove { - background: #fff; - color: #2a7; - border: 1px solid #2a7; + background: var(--bg); + color: var(--success); + border: 1px solid var(--success); } .cart-action:disabled { opacity: 0.5; @@ -364,11 +364,11 @@ } .permalink { font-size: 0.85rem; - color: #888; + color: var(--text-muted); text-decoration: none; } .permalink:hover { - color: #2c5fa3; + color: var(--accent); text-decoration: underline; } </style> diff --git a/site/src/lib/components/ModelSelector.svelte b/site/src/lib/components/ModelSelector.svelte index 1bd21bd7..3d4b1a4b 100644 --- a/site/src/lib/components/ModelSelector.svelte +++ b/site/src/lib/components/ModelSelector.svelte @@ -57,7 +57,7 @@ gap: 0.15rem; } .caption { - color: #666; + color: var(--text-muted); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em; @@ -65,18 +65,19 @@ select { font-size: 0.85rem; padding: 0.3rem 0.5rem; - border: 1px solid #ccc; + border: 1px solid var(--border-strong); border-radius: 4px; - background: #fff; + background: var(--bg); + color: var(--text); min-width: 8rem; } select:focus { - outline: 2px solid #2c5fa3; + outline: 2px solid var(--accent); outline-offset: -1px; - border-color: #2c5fa3; + border-color: var(--accent); } .sep { - color: #aaa; + color: var(--text-faint); padding-bottom: 0.45rem; } </style> diff --git a/site/src/lib/components/ResultList.svelte b/site/src/lib/components/ResultList.svelte index 8ce61ff3..b6debb43 100644 --- a/site/src/lib/components/ResultList.svelte +++ b/site/src/lib/components/ResultList.svelte @@ -102,11 +102,11 @@ } .results-header { font-size: 0.85rem; - color: #666; + color: var(--text-muted); margin: 0 0 0.5rem; } .results-header .muted { - color: #999; + color: var(--text-faint); } .cards { list-style: none; @@ -119,14 +119,14 @@ .card { display: flex; align-items: stretch; - border: 1px solid #e6e6e6; + border: 1px solid var(--border); border-radius: 6px; - background: #fff; + background: var(--bg-elevated); overflow: hidden; } .card.focused { - border-color: #2c5fa3; - box-shadow: 0 0 0 1px #2c5fa3; + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); } .card-body { all: unset; @@ -139,19 +139,19 @@ min-width: 0; } .card-body:hover { - background: #f7f7f7; + background: var(--bg-sunken); } .poster-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem; - color: #2c5fa3; + color: var(--accent); font-weight: 600; } .title { font-size: 0.95rem; font-weight: 500; line-height: 1.3; - color: #222; + color: var(--text); overflow: hidden; text-overflow: ellipsis; display: -webkit-box; @@ -160,20 +160,20 @@ } .lead-author { font-size: 0.8rem; - color: #666; + color: var(--text-muted); } .sep { - color: #bbb; + color: var(--text-faint); margin: 0 0.25rem; } .topic { - color: #555; + color: var(--text-muted); } .card-actions { display: flex; align-items: center; padding: 0 0.5rem; - border-left: 1px solid #f0f0f0; + border-left: 1px solid var(--border); } .cart-action { all: unset; @@ -181,13 +181,13 @@ padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.8rem; - color: #2c5fa3; + color: var(--accent); } .cart-action:hover { - background: #eef3fa; + background: var(--accent-soft-bg); } .cart-action.remove { - color: #2a7; + color: var(--success); } .cart-action:disabled { opacity: 0.4; @@ -197,13 +197,14 @@ margin-top: 0.5rem; align-self: center; padding: 0.5rem 1rem; - border: 1px solid #ccc; - background: #fff; + border: 1px solid var(--border-strong); + background: var(--bg); + color: var(--text); border-radius: 4px; cursor: pointer; } .empty { - color: #888; + color: var(--text-muted); font-style: italic; } </style> diff --git a/site/src/lib/components/SearchBar.svelte b/site/src/lib/components/SearchBar.svelte index bf93662e..f32e21db 100644 --- a/site/src/lib/components/SearchBar.svelte +++ b/site/src/lib/components/SearchBar.svelte @@ -38,16 +38,16 @@ width: 100%; padding: 0.6rem 2.5rem 0.6rem 0.8rem; font-size: 1rem; - border: 1px solid #ccc; + border: 1px solid var(--border-strong); border-radius: 6px; box-sizing: border-box; - background: #fff; - color: #222; + background: var(--bg); + color: var(--text); } input[type='search']:focus { - outline: 2px solid #2c5fa3; + outline: 2px solid var(--accent); outline-offset: 0; - border-color: #2c5fa3; + border-color: var(--accent); } button { position: absolute; @@ -59,11 +59,11 @@ font-size: 1.4rem; line-height: 1; cursor: pointer; - color: #888; + color: var(--text-muted); padding: 0.2rem 0.4rem; } button:hover { - color: #222; + color: var(--text); } .visually-hidden { position: absolute; diff --git a/site/src/lib/components/ThemeToggle.svelte b/site/src/lib/components/ThemeToggle.svelte new file mode 100644 index 00000000..29a097b5 --- /dev/null +++ b/site/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,59 @@ +<script lang="ts"> + import { themeChoice, effectiveTheme } from '$lib/stores/theme'; + + $: choice = $themeChoice; + $: effective = $effectiveTheme; + + $: label = + choice === 'auto' + ? `Theme: auto (currently ${effective})` + : choice === 'dark' + ? 'Theme: dark' + : 'Theme: light'; + + $: icon = choice === 'light' ? '☀' : choice === 'dark' ? '☾' : '◐'; + + function handleClick() { + themeChoice.cycle(); + } +</script> + +<button + type="button" + class="theme-toggle" + on:click={handleClick} + aria-label={label} + title={label} + data-testid="theme-toggle" + data-theme-choice={choice} +> + <span class="icon" aria-hidden="true">{icon}</span> + <span class="label-text">{choice}</span> +</button> + +<style> + .theme-toggle { + all: unset; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.7rem; + border: 1px solid var(--border-strong); + border-radius: 4px; + background: var(--bg); + color: var(--text); + font-size: 0.85rem; + line-height: 1; + } + .theme-toggle:hover { + background: var(--bg-sunken); + } + .icon { + font-size: 1rem; + } + .label-text { + text-transform: capitalize; + color: var(--text-muted); + } +</style> diff --git a/site/src/lib/components/UmapPanel.svelte b/site/src/lib/components/UmapPanel.svelte index fd62032a..dcd89a32 100644 --- a/site/src/lib/components/UmapPanel.svelte +++ b/site/src/lib/components/UmapPanel.svelte @@ -1,38 +1,52 @@ <script lang="ts"> import { onMount, onDestroy } from 'svelte'; import { selectedCell, lassoSelection, focusedAbstract } from '$lib/stores/selection'; - import { loadCell, type CellShard } from '$lib/shards'; + import { effectiveTheme } from '$lib/stores/theme'; + import { loadCell, loadTopics, type CellShard, type TopicShard } from '$lib/shards'; import type { AbstractRecord } from '$lib/shards'; export let abstracts: AbstractRecord[] = []; /** - * Mobile breakpoint — desktop ≥ 1024px gets lasso; smaller viewports get - * tap-to-filter-by-community per spec edge case "Mobile lasso". + * Mobile breakpoint — desktop ≥ 1024px renders 2D + 3D side-by-side + * with lasso on the 2D pane. Smaller viewports stack vertically and + * use tap-to-filter-by-community for the 2D pane (FR-005 Edge Case). */ export let mobileBreakpoint = 1024; - type PlotlyApi = typeof import('plotly.js-basic-dist-min'); + type PlotlyApi = typeof import('plotly.js-gl3d-dist-min'); let plotly: PlotlyApi | null = null; let plotlyLoading = false; let plotlyError: string | null = null; - let tab: '2d' | '3d' = '2d'; - let chartEl: HTMLDivElement | null = null; + let chart2dEl: HTMLDivElement | null = null; + let chart3dEl: HTMLDivElement | null = null; let cellShard: CellShard | null = null; + let topicsShard: TopicShard | null = null; let cellLoading = false; let cellError: string | null = null; let viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1280; let mobile = viewportWidth < mobileBreakpoint; + let autoRotate = true; + let rotateFrame: number | null = null; + let rotateAngle = 0; + + // Track whether each chart already has event listeners attached so we + // don't stack duplicate handlers on every render (Plotly's `on(...)` + // stacks; only `react()` is idempotent for the chart itself). + let handlers2dAttached = false; + let handlers3dAttached = false; + $: cellKey = `${$selectedCell.model}_${$selectedCell.input}`; function onResize() { viewportWidth = window.innerWidth; mobile = viewportWidth < mobileBreakpoint; - if (plotly && chartEl) { - plotly.Plots.resize(chartEl); + if (plotly) { + if (chart2dEl) plotly.Plots.resize(chart2dEl); + if (chart3dEl) plotly.Plots.resize(chart3dEl); } } @@ -45,8 +59,10 @@ if (typeof window !== 'undefined') { window.removeEventListener('resize', onResize); } - if (plotly && chartEl) { - plotly.purge(chartEl); + stopRotate(); + if (plotly) { + if (chart2dEl) plotly.purge(chart2dEl); + if (chart3dEl) plotly.purge(chart3dEl); } }); @@ -54,7 +70,7 @@ if (plotly || plotlyLoading) return; plotlyLoading = true; try { - plotly = (await import('plotly.js-basic-dist-min')).default as PlotlyApi; + plotly = (await import('plotly.js-gl3d-dist-min')).default as PlotlyApi; } catch (err) { plotlyError = (err as Error).message; } finally { @@ -63,119 +79,166 @@ } $: void (async () => { - // React to (model, input) changes — fetch the cell shard. const key = cellKey; cellLoading = true; cellError = null; - const shard = await loadCell(key); - // Bail out if the user has switched cells while we were fetching. + const [shard, topics] = await Promise.all([ + loadCell(key), + loadTopics(key, 'communities') + ]); if (key === cellKey) { cellShard = shard; + topicsShard = topics; cellLoading = false; if (shard === null) cellError = 'cell shard not available'; } })(); - $: void renderChart(plotly, chartEl, cellShard, tab, abstracts, $lassoSelection, mobile); + $: theme = $effectiveTheme; + $: topicByCluster = (() => { + const map = new Map<number, string>(); + if (topicsShard) { + for (const t of topicsShard.topics) { + const label = t.title || (t.keywords.length ? t.keywords.slice(0, 3).join(', ') : `cluster ${t.cluster_id}`); + map.set(t.cluster_id, label); + } + } + return map; + })(); + $: void renderChart2D(plotly, chart2dEl, cellShard, abstracts, $lassoSelection, mobile, theme, topicByCluster); + $: void renderChart3D(plotly, chart3dEl, cellShard, abstracts, $lassoSelection, theme, topicByCluster); - function renderChart( - api: PlotlyApi | null, - el: HTMLDivElement | null, - shard: CellShard | null, - mode: '2d' | '3d', + function buildSeries( + shard: CellShard, records: AbstractRecord[], selected: Set<number> | null, - isMobile: boolean + topicMap: Map<number, string> ) { - if (!api || !el || !shard) return; - - // Build positional-joined arrays. The cell shard is sorted to match - // abstracts.json so we can iterate by index. - const xs: number[] = []; - const ys: number[] = []; - const zs: number[] = []; - const ids: number[] = []; + const xs2: number[] = []; + const ys2: number[] = []; + const xs3: number[] = []; + const ys3: number[] = []; + const zs3: number[] = []; const posters: string[] = []; const titles: string[] = []; + const communityLabels: string[] = []; + const colors: number[] = []; const selectedIdx: number[] = []; + const idx2dByAbstract = new Map<number, number>(); for (let i = 0; i < shard.rows.length; i++) { const row = shard.rows[i]; const rec = records[i]; if (!rec) continue; - xs.push(row.umap2d[0]); - ys.push(row.umap2d[1]); - zs.push(row.umap3d[2]); - ids.push(row.abstract_id); + if (row.umap_missing) continue; + xs2.push(row.umap2d[0]); + ys2.push(row.umap2d[1]); + xs3.push(row.umap3d[0]); + ys3.push(row.umap3d[1]); + zs3.push(row.umap3d[2]); posters.push(rec.poster_id); titles.push(rec.title); - if (selected !== null && selected.has(row.abstract_id)) selectedIdx.push(i); + communityLabels.push(topicMap.get(row.community_id) ?? `community ${row.community_id}`); + colors.push(row.community_id); + idx2dByAbstract.set(row.abstract_id, xs2.length - 1); + if (selected !== null && selected.has(row.abstract_id)) selectedIdx.push(xs2.length - 1); } - // Color by community_id so clusters are visually distinct. - const colors = shard.rows.map((r) => r.community_id); + return { + xs2, + ys2, + xs3, + ys3, + zs3, + posters, + titles, + communityLabels, + colors, + selectedIdx, + idx2dByAbstract + }; + } + + function themedColors(t: 'light' | 'dark') { + return t === 'dark' + ? { paper: '#0f1419', plot: '#161b22', font: '#e8e8e8', grid: '#2a3138' } + : { paper: '#ffffff', plot: '#fafafa', font: '#222222', grid: '#d6d6d6' }; + } - const baseTrace = { + function renderChart2D( + api: PlotlyApi | null, + el: HTMLDivElement | null, + shard: CellShard | null, + records: AbstractRecord[], + selected: Set<number> | null, + isMobile: boolean, + t: 'light' | 'dark', + topicMap: Map<number, string> + ) { + if (!api || !el || !shard) return; + const s = buildSeries(shard, records, selected, topicMap); + const t1 = { + type: 'scatter' as const, mode: 'markers' as const, + x: s.xs2, + y: s.ys2, marker: { - size: mode === '2d' ? 6 : 4, - color: colors, + size: 6, + color: s.colors, colorscale: 'Viridis', - opacity: 0.8, + opacity: 0.85, line: { width: 0 } }, - customdata: posters.map((p, i) => [p, titles[i]]) as unknown as number[][], + customdata: s.posters.map((p, i) => [p, s.titles[i], s.communityLabels[i]]) as unknown as number[][], hovertemplate: - '<b>%{customdata[0]}</b><br>%{customdata[1]}<extra>%{text}</extra>', - text: shard.rows.map((r) => `community ${r.community_id}`), - unselected: { marker: { opacity: 0.25 } }, + '<b>%{customdata[0]}</b><br>%{customdata[1]}<br><i>%{customdata[2]}</i><extra></extra>', + selectedpoints: s.selectedIdx.length ? s.selectedIdx : undefined, + unselected: { marker: { opacity: 0.2 } }, selected: { marker: { opacity: 1 } } }; - - const data: unknown[] = - mode === '2d' - ? [{ ...baseTrace, type: 'scattergl', x: xs, y: ys, selectedpoints: selectedIdx.length ? selectedIdx : undefined }] - : [{ ...baseTrace, type: 'scatter3d', x: xs, y: ys, z: zs }]; - - const layout: unknown = { + const c = themedColors(t); + const layout = { margin: { l: 0, r: 0, t: 0, b: 0 }, showlegend: false, hovermode: 'closest', - dragmode: mode === '2d' && !isMobile ? 'lasso' : mode === '3d' ? 'orbit' : 'pan', - paper_bgcolor: '#fff', - plot_bgcolor: '#fafafa', - ...(mode === '2d' - ? { xaxis: { visible: false }, yaxis: { visible: false, scaleanchor: 'x' } } - : { - scene: { - xaxis: { visible: false }, - yaxis: { visible: false }, - zaxis: { visible: false }, - bgcolor: '#fafafa' - } - }) + dragmode: isMobile ? 'pan' : 'lasso', + paper_bgcolor: c.paper, + plot_bgcolor: c.plot, + font: { color: c.font }, + xaxis: { visible: false, scaleanchor: 'y' }, + yaxis: { visible: false } }; - const config = { responsive: true, displaylogo: false, - modeBarButtonsToRemove: - mode === '2d' ? ['select2d', 'autoScale2d'] : ['toImage'], + modeBarButtonsToRemove: ['autoScale2d'], scrollZoom: true }; - (api as unknown as { react: (...args: unknown[]) => Promise<unknown> }) - .react(el, data, layout, config) + .react(el, [t1], layout, config) .then(() => { - // Attach lasso + click handlers exactly once. - const node = el as unknown as { on: (event: string, handler: (e: unknown) => void) => void }; + if (handlers2dAttached) return; + handlers2dAttached = true; + const node = el as unknown as { on: (e: string, h: (e: unknown) => void) => void }; node.on('plotly_selected', (e: unknown) => { const ev = e as { points?: Array<{ pointIndex: number }> } | null; if (!ev || !ev.points) { $lassoSelection = null; return; } + // Reverse the visible-index → abstract_id map. Capture the + // CURRENT shard via the cellShard module variable so a + // model-switch since render uses the fresh data. + const shardNow = cellShard; + if (!shardNow) return; + // Rebuild the visible → abstract_id mapping from this shard. + const visibleIds: number[] = []; + for (let i = 0; i < shardNow.rows.length; i++) { + const row = shardNow.rows[i]; + if (row.umap_missing) continue; + visibleIds.push(row.abstract_id); + } const ids: Set<number> = new Set(); for (const p of ev.points) { - const aid = shard.rows[p.pointIndex]?.abstract_id; + const aid = visibleIds[p.pointIndex]; if (aid !== undefined) ids.add(aid); } $lassoSelection = ids.size ? ids : null; @@ -187,18 +250,21 @@ const ev = e as { points?: Array<{ pointIndex: number }> } | null; const pt = ev?.points?.[0]; if (!pt) return; - const row = shard.rows[pt.pointIndex]; + const shardNow = cellShard; + if (!shardNow) return; + const visibleRows = shardNow.rows.filter((r) => !r.umap_missing); + const row = visibleRows[pt.pointIndex]; if (!row) return; - if (isMobile && mode === '2d') { - // Mobile lasso replacement: tap → filter to the tapped point's community. + if (isMobile) { const commId = row.community_id; const ids: Set<number> = new Set(); - for (const r of shard.rows) { - if (r.community_id === commId) ids.add(r.abstract_id); + for (const r of shardNow.rows) { + if (!r.umap_missing && r.community_id === commId) ids.add(r.abstract_id); } $lassoSelection = ids; } - const rec = records[pt.pointIndex]; + const idxInAbstracts = shardNow.rows.indexOf(row); + const rec = records[idxInAbstracts]; if (rec?.poster_id) $focusedAbstract = rec.poster_id; }); }) @@ -206,56 +272,173 @@ plotlyError = err.message; }); } + + function renderChart3D( + api: PlotlyApi | null, + el: HTMLDivElement | null, + shard: CellShard | null, + records: AbstractRecord[], + selected: Set<number> | null, + t: 'light' | 'dark', + topicMap: Map<number, string> + ) { + if (!api || !el || !shard) return; + const s = buildSeries(shard, records, selected, topicMap); + const t1 = { + type: 'scatter3d' as const, + mode: 'markers' as const, + x: s.xs3, + y: s.ys3, + z: s.zs3, + marker: { + size: 3, + color: s.colors, + colorscale: 'Viridis', + opacity: 0.85, + line: { width: 0 } + }, + customdata: s.posters.map((p, i) => [p, s.titles[i], s.communityLabels[i]]) as unknown as number[][], + hovertemplate: + '<b>%{customdata[0]}</b><br>%{customdata[1]}<br><i>%{customdata[2]}</i><extra></extra>' + }; + const c = themedColors(t); + const axisCfg = { visible: false, showbackground: false }; + const layout = { + margin: { l: 0, r: 0, t: 0, b: 0 }, + showlegend: false, + paper_bgcolor: c.paper, + plot_bgcolor: c.plot, + font: { color: c.font }, + scene: { + xaxis: axisCfg, + yaxis: axisCfg, + zaxis: axisCfg, + bgcolor: c.plot, + camera: { eye: { x: 1.6, y: 1.6, z: 0.9 } } + } + }; + const config = { + responsive: true, + displaylogo: false + }; + (api as unknown as { react: (...args: unknown[]) => Promise<unknown> }) + .react(el, [t1], layout, config) + .then(() => { + if (!handlers3dAttached) { + handlers3dAttached = true; + const node = el as unknown as { on: (e: string, h: (e: unknown) => void) => void }; + node.on('plotly_click', (e: unknown) => { + const ev = e as { points?: Array<{ pointIndex: number }> } | null; + const pt = ev?.points?.[0]; + if (!pt) return; + const shardNow = cellShard; + if (!shardNow) return; + const visibleRows = shardNow.rows.filter((r) => !r.umap_missing); + const row = visibleRows[pt.pointIndex]; + if (!row) return; + const idxInAbstracts = shardNow.rows.indexOf(row); + const rec = records[idxInAbstracts]; + if (rec?.poster_id) $focusedAbstract = rec.poster_id; + }); + } + ensureRotate(); + }) + .catch((err: Error) => { + plotlyError = err.message; + }); + } + + function ensureRotate() { + stopRotate(); + if (!autoRotate || !plotly || !chart3dEl) return; + const step = () => { + if (!autoRotate || !plotly || !chart3dEl) return; + rotateAngle += 0.004; + const r = 2.2; + const eye = { + x: r * Math.cos(rotateAngle), + y: r * Math.sin(rotateAngle), + z: 0.9 + }; + try { + (plotly as unknown as { relayout: (el: HTMLDivElement, p: unknown) => Promise<unknown> }).relayout( + chart3dEl, + { 'scene.camera.eye': eye } + ); + } catch { + /* no-op */ + } + rotateFrame = requestAnimationFrame(step); + }; + rotateFrame = requestAnimationFrame(step); + } + + function stopRotate() { + if (rotateFrame !== null) { + cancelAnimationFrame(rotateFrame); + rotateFrame = null; + } + } + + function toggleRotate() { + autoRotate = !autoRotate; + if (autoRotate) ensureRotate(); + else stopRotate(); + } </script> <section class="umap-panel" data-testid="umap-panel"> <header class="umap-header"> - <div class="tabs" role="tablist"> - <button - role="tab" - aria-selected={tab === '2d'} - class:active={tab === '2d'} - on:click={() => (tab = '2d')} - data-testid="umap-tab-2d" - > - 2D map{mobile ? '' : ' (lasso)'} - </button> - <button - role="tab" - aria-selected={tab === '3d'} - class:active={tab === '3d'} - on:click={() => (tab = '3d')} - data-testid="umap-tab-3d" - > - 3D map - </button> + <div class="title-block"> + <h3>UMAP — cell <code>{cellKey}</code></h3> + <p class="hint">Lasso on 2D filters the result list. Click any point to open its detail panel.</p> + </div> + <div class="header-actions"> + {#if $lassoSelection} + <button + type="button" + class="clear-lasso" + on:click={() => ($lassoSelection = null)} + data-testid="umap-clear-lasso" + > + Clear selection ({$lassoSelection.size}) + </button> + {/if} </div> - {#if $lassoSelection} - <button - type="button" - class="clear-lasso" - on:click={() => ($lassoSelection = null)} - data-testid="umap-clear-lasso" - > - Clear selection ({$lassoSelection.size}) - </button> - {/if} </header> - <div class="chart-wrap" class:loading={plotlyLoading || cellLoading} data-testid="umap-chart-wrap"> - {#if plotlyError || cellError} - <p class="error"> - Map unavailable: {plotlyError || cellError} - </p> - {:else if !plotly || cellLoading} - <p class="status">Loading map…</p> - {/if} - <div bind:this={chartEl} class="chart" data-testid="umap-chart"></div> + <div class="charts" data-testid="umap-chart-wrap"> + <figure class="chart-card"> + <figcaption>2D <span class="caption-aside">{mobile ? 'tap to filter by community' : 'lasso to filter'}</span></figcaption> + <div bind:this={chart2dEl} class="chart" data-testid="umap-chart-2d"></div> + </figure> + <figure class="chart-card"> + <figcaption> + 3D + <span class="caption-aside"> + <button + type="button" + on:click={toggleRotate} + class="rotate-btn" + aria-pressed={autoRotate} + data-testid="umap-rotate-toggle" + > + {autoRotate ? '⏸ pause' : '▶ rotate'} + </button> + </span> + </figcaption> + <div bind:this={chart3dEl} class="chart chart-3d" data-testid="umap-chart-3d"></div> + </figure> </div> - {#if mobile && tab === '2d'} - <p class="mobile-hint">Tap a point to filter by its community.</p> + {#if plotlyError || cellError} + <p class="error">Map unavailable: {plotlyError || cellError}</p> + {:else if !plotly || cellLoading} + <p class="status">Loading map…</p> {/if} + + <!-- Back-compat testid: existing e2e looks for `umap-chart`. --> + <div data-testid="umap-chart" hidden></div> </section> <style> @@ -268,72 +451,97 @@ .umap-header { display: flex; justify-content: space-between; - align-items: center; - gap: 0.5rem; + align-items: flex-start; + gap: 0.75rem; flex-wrap: wrap; } - .tabs { - display: flex; - gap: 0.25rem; + .title-block h3 { + margin: 0; + font-size: 0.95rem; + color: var(--text); + font-weight: 600; } - .tabs button { - all: unset; - cursor: pointer; - padding: 0.3rem 0.7rem; + .title-block code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; - border: 1px solid #d0d0d0; - border-radius: 4px; - background: #fff; - color: #444; + color: var(--accent); + } + .hint { + margin: 0.15rem 0 0; + font-size: 0.78rem; + color: var(--text-muted); } - .tabs button.active { - background: #2c5fa3; - color: #fff; - border-color: #2c5fa3; + .header-actions { + display: flex; + gap: 0.5rem; } .clear-lasso { all: unset; cursor: pointer; - padding: 0.25rem 0.6rem; + padding: 0.3rem 0.7rem; border-radius: 4px; font-size: 0.8rem; - background: #fff8e1; - color: #a26000; - border: 1px solid #f0d27a; + background: var(--warning-bg); + color: var(--warning-text); + border: 1px solid var(--warning-border); } - .clear-lasso:hover { - background: #fff0c0; + .charts { + display: grid; + gap: 0.75rem; + grid-template-columns: 1fr; } - .chart-wrap { - position: relative; - min-height: 300px; - height: clamp(300px, 50vh, 540px); - border: 1px solid #eaeaea; + @media (min-width: 880px) { + .charts { + grid-template-columns: 1fr 1fr; + } + } + .chart-card { + margin: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--border); border-radius: 6px; - background: #fafafa; + background: var(--chart-paper); + overflow: hidden; + } + .chart-card figcaption { + font-size: 0.78rem; + color: var(--text-muted); + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + } + .caption-aside { + color: var(--text-faint); + font-size: 0.75rem; + } + .rotate-btn { + all: unset; + cursor: pointer; + font-size: 0.75rem; + color: var(--accent); + } + .rotate-btn:hover { + text-decoration: underline; } .chart { width: 100%; - height: 100%; + height: clamp(280px, 45vh, 480px); + } + .chart-3d { + height: clamp(280px, 45vh, 480px); } .status, .error { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - color: #888; - font-style: italic; margin: 0; - pointer-events: none; + font-size: 0.8rem; + color: var(--text-muted); + font-style: italic; } .error { - color: #b00; - } - .mobile-hint { - margin: 0; - font-size: 0.75rem; - color: #888; + color: var(--danger); } </style> diff --git a/site/src/lib/shards.ts b/site/src/lib/shards.ts index ee16d6d5..7c775bf3 100644 --- a/site/src/lib/shards.ts +++ b/site/src/lib/shards.ts @@ -115,10 +115,27 @@ export interface CellShard { rows: CellRow[]; } +export interface TopicRecord { + cluster_id: number; + keywords: string[]; + title: string; + description: string; + focus: string; +} + +export interface TopicShard { + schema_version: string; + build_info: BuildInfo; + cell_key: string; + kind: string; + topics: TopicRecord[]; +} + let manifestCache: Promise<Manifest | null> | null = null; let abstractsCache: Promise<AbstractsShard | null> | null = null; let authorsCache: Promise<AuthorsShard | null> | null = null; const cellCache: Map<string, Promise<CellShard | null>> = new Map(); +const topicsCache: Map<string, Promise<TopicShard | null>> = new Map(); async function fetchJson<T>(url: string, fetcher: typeof fetch): Promise<T | null> { try { @@ -165,9 +182,30 @@ export function loadCell( return cellCache.get(cellKey)!; } +/** + * Load a per-(model, input, kind) topics shard. `kind` is one of + * `communities` | `topic_clusters` | `neuroscape_clusters`. Cached by + * the full (cell_key, kind) tuple. + */ +export function loadTopics( + cellKey: string, + kind: string, + fetcher: typeof fetch = fetch +): Promise<TopicShard | null> { + const key = `${cellKey}__${kind}`; + if (!topicsCache.has(key)) { + topicsCache.set( + key, + fetchJson<TopicShard>(`${base}/data/topics/${cellKey}_${kind}.json`, fetcher) + ); + } + return topicsCache.get(key)!; +} + export function resetCachesForTests(): void { manifestCache = null; abstractsCache = null; authorsCache = null; cellCache.clear(); + topicsCache.clear(); } diff --git a/site/src/lib/stores/theme.ts b/site/src/lib/stores/theme.ts new file mode 100644 index 00000000..ae79990d --- /dev/null +++ b/site/src/lib/stores/theme.ts @@ -0,0 +1,88 @@ +import { writable, get } from 'svelte/store'; + +export type ThemeChoice = 'light' | 'dark' | 'auto'; + +const STORAGE_KEY = 'ohbm2026.ui.theme.v1'; + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof document !== 'undefined'; +} + +function loadInitial(): ThemeChoice { + if (!isBrowser()) return 'auto'; + const raw = window.localStorage?.getItem(STORAGE_KEY); + if (raw === 'light' || raw === 'dark' || raw === 'auto') return raw; + return 'auto'; +} + +function systemPref(): 'light' | 'dark' { + if (!isBrowser()) return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +/** + * Compute the effective theme — what `data-theme` should be set to on + * `<html>`. `auto` resolves to the system preference at this moment. + */ +function resolve(choice: ThemeChoice): 'light' | 'dark' { + return choice === 'auto' ? systemPref() : choice; +} + +function applyToDocument(effective: 'light' | 'dark') { + if (!isBrowser()) return; + document.documentElement.setAttribute('data-theme', effective); + document.documentElement.style.colorScheme = effective; +} + +const choiceStore = writable<ThemeChoice>(loadInitial()); +const effectiveStore = writable<'light' | 'dark'>(resolve(get(choiceStore))); + +if (isBrowser()) { + // Apply on first import so SSR-hydration matches. + applyToDocument(get(effectiveStore)); + + // Watch the system preference; only re-render when current choice is auto. + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const onSystemChange = () => { + if (get(choiceStore) !== 'auto') return; + const eff = resolve('auto'); + effectiveStore.set(eff); + applyToDocument(eff); + }; + // Modern + legacy listener support. + if (typeof mq.addEventListener === 'function') mq.addEventListener('change', onSystemChange); + else if (typeof mq.addListener === 'function') mq.addListener(onSystemChange); +} + +function setChoice(choice: ThemeChoice): void { + choiceStore.set(choice); + const eff = resolve(choice); + effectiveStore.set(eff); + applyToDocument(eff); + if (isBrowser()) { + try { + window.localStorage.setItem(STORAGE_KEY, choice); + } catch { + // localStorage may be unavailable; silently degrade. + } + } +} + +function cycle(): void { + const order: ThemeChoice[] = ['light', 'dark', 'auto']; + const current = get(choiceStore); + const next = order[(order.indexOf(current) + 1) % order.length]; + setChoice(next); +} + +export const themeChoice = { + subscribe: choiceStore.subscribe, + set: setChoice, + cycle +}; + +export const effectiveTheme = { + subscribe: effectiveStore.subscribe +}; + +export const THEME_STORAGE_KEY = STORAGE_KEY; diff --git a/site/src/routes/+layout.svelte b/site/src/routes/+layout.svelte index e9214ba3..5f455362 100644 --- a/site/src/routes/+layout.svelte +++ b/site/src/routes/+layout.svelte @@ -1,13 +1,19 @@ <script lang="ts"> + import '../app.css'; import { onMount } from 'svelte'; import { buildInfoFromEnv, loadManifest, type BuildInfo, type Manifest } from '$lib/shards'; import BuildInfoFooter from '$lib/components/BuildInfo.svelte'; + import ThemeToggle from '$lib/components/ThemeToggle.svelte'; let manifest: Manifest | null = null; const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); $: dataBuildInfo = manifest?.build_info ?? null; onMount(async () => { + // Importing the theme store side-effect-initialises the data-theme + // attribute + the system-pref watcher. Static import is fine in + // browser-only context; for SSR-safety it just exports the store. + await import('$lib/stores/theme'); manifest = await loadManifest(); }); </script> @@ -24,10 +30,17 @@ <div class="shell"> <header> - <h1>OHBM 2026 — under construction</h1> - <p class="subtitle"> - Stage 6 UI rewrite · static SvelteKit on GitHub Pages · accepted abstracts only - </p> + <div class="header-row"> + <div class="header-text"> + <h1>OHBM 2026 — under construction</h1> + <p class="subtitle"> + Stage 6 UI rewrite · static SvelteKit on GitHub Pages · accepted abstracts only + </p> + </div> + <div class="header-controls"> + <ThemeToggle /> + </div> + </div> </header> <main> @@ -38,33 +51,45 @@ </div> <style> - :global(body) { - margin: 0; - font-family: - system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - color: #222; - background: #fff; - } .shell { min-height: 100vh; display: flex; flex-direction: column; + background: var(--bg); + color: var(--text); } header { padding: 1.25rem clamp(1rem, 2vw, 2rem) 0.75rem; width: 100%; box-sizing: border-box; + border-bottom: 1px solid var(--border); + } + .header-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; + } + .header-text { + min-width: 0; } header h1 { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: 600; + color: var(--text); } .subtitle { margin: 0; - color: #666; + color: var(--text-muted); font-size: 0.9rem; } + .header-controls { + display: flex; + align-items: center; + gap: 0.5rem; + } main { flex: 1; padding: 0.5rem clamp(1rem, 2vw, 2rem) 1rem; diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index 53a531a2..6a7c3d19 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -161,17 +161,17 @@ padding: 0.45rem 0.8rem; border-radius: 4px; font-size: 0.85rem; - border: 1px solid #d0d0d0; - background: #fff; - color: #444; + border: 1px solid var(--border-strong); + background: var(--bg); + color: var(--text); } .map-toggle:hover { - background: #f7f7f7; + background: var(--bg-sunken); } .map-toggle.active { - background: #2c5fa3; - color: #fff; - border-color: #2c5fa3; + background: var(--accent); + color: var(--accent-text); + border-color: var(--accent); } .layout { display: grid; @@ -186,11 +186,11 @@ min-width: 0; } .detail-empty { - background: #fafafa; - border: 1px dashed #d0d0d0; + background: var(--bg-subtle); + border: 1px dashed var(--border-strong); border-radius: 6px; padding: 1rem; - color: #666; + color: var(--text-muted); } .manifest-stats { margin: 0.75rem 0 0; @@ -200,11 +200,12 @@ font-size: 0.9rem; } .manifest-stats dt { - color: #888; + color: var(--text-faint); } .placeholder { - background: #fffbe6; - border: 1px solid #f0e2a4; + background: var(--warning-bg); + border: 1px solid var(--warning-border); + color: var(--text); border-radius: 6px; padding: 1rem; } @@ -212,11 +213,12 @@ font-size: 0.95rem; } .status { - color: #888; + color: var(--text-muted); font-style: italic; } code { - background: #f4f4f4; + background: var(--bg-sunken); + color: var(--text); padding: 0 0.25rem; border-radius: 3px; font-size: 0.95em; @@ -224,7 +226,7 @@ @media (min-width: 1024px) { .layout { - grid-template-columns: minmax(0, 1fr) minmax(22rem, 32rem); + grid-template-columns: minmax(0, 1fr) clamp(24rem, 28vw, 42rem); align-items: start; } .detail-pane { @@ -234,11 +236,6 @@ overflow-y: auto; } } - @media (min-width: 1600px) { - .layout { - grid-template-columns: minmax(0, 1fr) minmax(28rem, 38rem); - } - } /* Mobile: detail panel becomes a full-screen overlay when focused. */ @media (max-width: 1023px) { diff --git a/site/src/tests/e2e/umap.spec.ts b/site/src/tests/e2e/umap.spec.ts index 6790487c..d6020df6 100644 --- a/site/src/tests/e2e/umap.spec.ts +++ b/site/src/tests/e2e/umap.spec.ts @@ -5,72 +5,87 @@ const DATA_AVAILABLE = process.env.UI_DATA_AVAILABLE !== '0'; test.describe('US2: UMAP panel + lasso + model selector', () => { test.skip(!DATA_AVAILABLE, 'Data package not deployed in this run'); - test('opens map; lazy-loads Plotly; default cell renders', async ({ page }) => { + test('opens map; lazy-loads Plotly; both 2D + 3D charts render side-by-side', async ({ + page + }) => { await page.goto('/'); await expect(page.getByTestId('result-count')).toBeVisible({ timeout: 5000 }); - // Map is hidden by default. await expect(page.getByTestId('umap-panel')).toHaveCount(0); await page.getByTestId('toggle-map').click(); - const panel = page.getByTestId('umap-panel'); - await expect(panel).toBeVisible(); + await expect(page.getByTestId('umap-panel')).toBeVisible(); + await expect(page.getByTestId('umap-chart-2d')).toBeVisible(); + await expect(page.getByTestId('umap-chart-3d')).toBeVisible(); - // Plotly is lazy-loaded; the chart container appears after ~1s. - await expect(page.getByTestId('umap-chart')).toBeVisible(); - // Wait until Plotly has injected an SVG (or WebGL canvas). + // Plotly lazy-loads — wait for the 2D pane to render an SVG/canvas. + await expect + .poll( + async () => + page + .locator('[data-testid="umap-chart-2d"] svg, [data-testid="umap-chart-2d"] canvas') + .count(), + { timeout: 15000 } + ) + .toBeGreaterThan(0); + // 3D pane renders a WebGL canvas inside `.gl-container`. await expect .poll( async () => - page.locator('[data-testid="umap-chart"] svg, [data-testid="umap-chart"] canvas').count(), - { timeout: 10000 } + page.locator('[data-testid="umap-chart-3d"] canvas').count(), + { timeout: 15000 } ) .toBeGreaterThan(0); }); - test('model selector switches the cell shard; lasso selection persists by abstract_id', async ({ - page - }) => { + test('rotate toggle pauses / resumes the 3D animation', async ({ page }) => { + await page.goto('/'); + await page.getByTestId('toggle-map').click(); + await expect(page.getByTestId('umap-chart-3d')).toBeVisible(); + const btn = page.getByTestId('umap-rotate-toggle'); + await expect(btn).toBeVisible(); + // Initial state: rotating (aria-pressed=true, label "⏸ pause"). + await expect(btn).toHaveAttribute('aria-pressed', 'true'); + await btn.click(); + await expect(btn).toHaveAttribute('aria-pressed', 'false'); + await btn.click(); + await expect(btn).toHaveAttribute('aria-pressed', 'true'); + }); + + test('lasso selection (simulated) updates the result list count', async ({ page }) => { await page.goto('/'); await page.getByTestId('toggle-map').click(); - await expect(page.getByTestId('umap-chart')).toBeVisible(); + await expect(page.getByTestId('umap-chart-2d')).toBeVisible(); await expect .poll( async () => - page.locator('[data-testid="umap-chart"] svg, [data-testid="umap-chart"] canvas').count(), - { timeout: 10000 } + page + .locator('[data-testid="umap-chart-2d"] svg, [data-testid="umap-chart-2d"] canvas') + .count(), + { timeout: 15000 } ) .toBeGreaterThan(0); - // Simulate a synthetic lasso event via Plotly's emit hook on the chart div. - // Selecting two arbitrary points (indices 0 and 1) is enough to verify the - // store → ResultList wiring. - const fakeSelection = await page.evaluate(() => { - const el = document.querySelector('[data-testid="umap-chart"]') as unknown as { + const initialCount = Number( + (await page.getByTestId('result-count').textContent())?.trim() + ); + expect(initialCount).toBeGreaterThan(100); + + const ok = await page.evaluate(() => { + const el = document.querySelector('[data-testid="umap-chart-2d"]') as unknown as { emit?: (event: string, payload: unknown) => void; } | null; if (!el?.emit) return false; el.emit('plotly_selected', { - points: [ - { pointIndex: 0 }, - { pointIndex: 1 } - ] + points: [{ pointIndex: 0 }, { pointIndex: 1 }, { pointIndex: 2 }] }); return true; }); - expect(fakeSelection).toBe(true); + expect(ok).toBe(true); const clear = page.getByTestId('umap-clear-lasso'); await expect(clear).toBeVisible({ timeout: 3000 }); - // "Clear selection (2)" is what the button label should say. - await expect(clear).toHaveText(/Clear selection \(2\)/); - - // Switch the model — coordinates should re-render but the abstract_id set - // stays selected (the store doesn't reset). - await page.getByTestId('model-selector-model').selectOption('voyage'); - await expect(clear).toBeVisible(); - await expect(clear).toHaveText(/Clear selection \(2\)/); - - // Clear the selection. + // 3-point lasso → 3 abstracts selected → result-count drops to 3. + await expect(page.getByTestId('result-count')).toHaveText('3', { timeout: 3000 }); await clear.click(); await expect(clear).toHaveCount(0); }); diff --git a/src/ohbm2026/ui_data/builder.py b/src/ohbm2026/ui_data/builder.py index e6bf5cba..815fe6ec 100644 --- a/src/ohbm2026/ui_data/builder.py +++ b/src/ohbm2026/ui_data/builder.py @@ -117,6 +117,7 @@ def build_ui_data_package( rollup_db=rollup_db, abstract_ids=abstract_ids, build_info=build_info, + analysis_root=Path(analysis_root) if analysis_root else None, ) topics_envelopes = build_topics( diff --git a/src/ohbm2026/ui_data/cells.py b/src/ohbm2026/ui_data/cells.py index eeb06dc0..2ed69db7 100644 --- a/src/ohbm2026/ui_data/cells.py +++ b/src/ohbm2026/ui_data/cells.py @@ -10,6 +10,7 @@ from __future__ import annotations +import math import sqlite3 from collections.abc import Iterable, Mapping from pathlib import Path @@ -22,6 +23,44 @@ NEUROSCAPE_MODEL = "neuroscape" +def _load_per_cell_projections( + analysis_root: Path, cell_key: str +) -> tuple[dict[int, tuple[float, float]], dict[int, tuple[float, float, float]]]: + """Return ``(umap2d_by_id, umap3d_by_id)`` for *cell_key*. + + Reads `data/outputs/analysis/<cell_key>/projections__<state-key>/`: + ``ids.npy`` + ``umap2d_coords.npy`` + ``umap3d_coords.npy``. Empty maps + when the bundle doesn't exist (older Stage 4 runs may have only + populated the wide annotations table). Lazy-imports numpy so the + builder still works without `[ui]` extras installed when only the + annotations-table fallback is needed. + """ + + bundle_root = Path(analysis_root) / cell_key + if not bundle_root.exists(): + return {}, {} + projection_dirs = sorted(bundle_root.glob("projections__*")) + if not projection_dirs: + return {}, {} + bundle_dir = projection_dirs[-1] # newest by lexicographic; tie-break is fine + try: + import numpy as np # type: ignore[import-not-found] + except ImportError: + return {}, {} + try: + ids = np.load(bundle_dir / "ids.npy") + coords2d = np.load(bundle_dir / "umap2d_coords.npy") + coords3d = np.load(bundle_dir / "umap3d_coords.npy") + except FileNotFoundError: + return {}, {} + out2d: dict[int, tuple[float, float]] = {} + out3d: dict[int, tuple[float, float, float]] = {} + for i, aid in enumerate(ids): + out2d[int(aid)] = (float(coords2d[i, 0]), float(coords2d[i, 1])) + out3d[int(aid)] = (float(coords3d[i, 0]), float(coords3d[i, 1]), float(coords3d[i, 2])) + return out2d, out3d + + def _load_annotations(rollup_db: Path) -> dict[int, dict[str, Any]]: """Return ``annotations`` rows keyed by ``abstract_id``.""" @@ -38,13 +77,16 @@ def _row_to_cell_record( row: Mapping[str, Any] | None, model: str, input_key: str, + umap2d_by_id: Mapping[int, tuple[float, float]] | None = None, + umap3d_by_id: Mapping[int, tuple[float, float, float]] | None = None, ) -> dict[str, Any]: """Project the wide ``annotations`` row into a per-(cell, abstract) record. - Coordinates fall back to ``[0.0, 0.0]`` / ``[0.0, 0.0, 0.0]`` only when - upstream produced NULL (an abstract present in the corpus but absent from - the rollup, which the builder will flag via the cross-shard invariants). - Missing cluster ids surface as ``-1`` to keep the schema dense. + UMAP coordinates come from the per-(model, input) ``projections__*`` + bundle when available — that gives each cell its own layout. If the + per-bundle data is missing the wide annotations table (per-model only) + is used as a fallback. Missing cluster ids surface as ``-1`` to keep + the schema dense. """ def _val(key: str, default: Any = None) -> Any: @@ -53,14 +95,31 @@ def _val(key: str, default: Any = None) -> Any: v = row.get(key) return default if v is None else v - record: dict[str, Any] = { - "abstract_id": int(abstract_id), - "umap2d": [float(_val(f"umap2d_{model}_x", 0.0)), float(_val(f"umap2d_{model}_y", 0.0))], - "umap3d": [ + coord2d = (umap2d_by_id or {}).get(int(abstract_id)) + coord3d = (umap3d_by_id or {}).get(int(abstract_id)) + umap2d = ( + [float(coord2d[0]), float(coord2d[1])] + if coord2d is not None + else [float(_val(f"umap2d_{model}_x", 0.0)), float(_val(f"umap2d_{model}_y", 0.0))] + ) + umap3d = ( + [float(coord3d[0]), float(coord3d[1]), float(coord3d[2])] + if coord3d is not None + else [ float(_val(f"umap3d_{model}_x", 0.0)), float(_val(f"umap3d_{model}_y", 0.0)), float(_val(f"umap3d_{model}_z", 0.0)), - ], + ] + ) + # `umap_missing` lets the UI filter out abstracts that don't have a + # projection in this cell (e.g. voyage_claims drops 2 abstracts that + # lacked claim embeddings). + has_projection = coord2d is not None + record: dict[str, Any] = { + "abstract_id": int(abstract_id), + "umap2d": umap2d, + "umap3d": umap3d, + "umap_missing": not has_projection if (umap2d_by_id is not None or umap3d_by_id is not None) else False, "community_id": int(_val(f"community_{model}_{input_key}", -1)), "topic_cluster_id": int(_val(f"topic_cluster_{model}_{input_key}", -1)), } @@ -78,11 +137,15 @@ def build_cells_shards( *, rollup_db: Path, abstract_ids: Iterable[int], + analysis_root: Path | None = None, ) -> dict[str, list[dict[str, Any]]]: """Return ``{cell_key: [row, ...]}`` for every discovered cell. Each list is ordered to match *abstract_ids* (positional join with - ``abstracts.json``; cells.md §4 invariant). + ``abstracts.json``; cells.md §4 invariant). When *analysis_root* is + supplied each cell's UMAP coordinates are read from its own + ``projections__*`` bundle (per-(model, input) layout); otherwise the + builder falls back to the wide ``annotations`` table (per-model only). """ rollup_path = Path(rollup_db) @@ -95,8 +158,20 @@ def build_cells_shards( out: dict[str, list[dict[str, Any]]] = {} for model, input_key in cells: cell_key = f"{model}_{input_key}" + u2d, u3d = ( + _load_per_cell_projections(analysis_root, cell_key) + if analysis_root is not None + else ({}, {}) + ) out[cell_key] = [ - _row_to_cell_record(aid, annotations.get(aid), model, input_key) + _row_to_cell_record( + aid, + annotations.get(aid), + model, + input_key, + umap2d_by_id=u2d, + umap3d_by_id=u3d, + ) for aid in ordered_ids ] return out @@ -107,10 +182,15 @@ def build_cells( rollup_db: Path, abstract_ids: Iterable[int], build_info: Mapping[str, str], + analysis_root: Path | None = None, ) -> dict[str, dict[str, Any]]: """Return ``{cell_key: envelope}`` per data-model.md §4.""" - shards = build_cells_shards(rollup_db=rollup_db, abstract_ids=abstract_ids) + shards = build_cells_shards( + rollup_db=rollup_db, + abstract_ids=abstract_ids, + analysis_root=analysis_root, + ) return { cell_key: { "schema_version": SCHEMA_VERSION, From 3600c238b9b0b5bd8049b5daa4632f9616b79599 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 10:08:09 -0400 Subject: [PATCH 08/48] fix(stage6): in-place data writes preserve Dropbox share link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes so the data-package refresh stops invalidating the Dropbox share URL on every rebuild: 1. `_write_json` opens the target file directly (`O_WRONLY|O_CREAT|O_TRUNC`) instead of writing to `*.tmp` and calling `Path.replace()`. The rename pattern changed the inode of every shard on every build; in-place truncate preserves the inode. Also pins mtime to a fixed timestamp (2026-01-01) so subsequent `tar -czf` produces byte-identical archives when inputs are unchanged. 2. The output-directory swap (rename data → data.prev → swap → rmtree) is gone. The builder now writes into the existing `data/` dir directly, then prunes any stale .json shards from prior runs. Refresh recipe in README updated: `tar -czf` straight to the canonical Dropbox path (`~/dbm/shares/ohbm2026/ui-data.tar.gz`); no `cp` alias step. Verified end-to-end: rebuilt the tarball with a content change; inode preserved (217294146 before and after); Dropbox share link still serves the new content with no need to re-share. Repo variables refreshed: OHBM2026_UI_DATA_PACKAGE_URL points at the new canonical share link; OHBM2026_UI_DATA_PACKAGE_SHA256 bumped to 0f93f470… (the new bytes). Future refreshes will only need to bump the sha256. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- README.md | 22 ++++--- scripts/fetch_ui_inputs.sh | 14 ++++- src/ohbm2026/ui_data/builder.py | 102 +++++++++++++++++++------------- 3 files changed, 86 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 08fa0300..99715c79 100644 --- a/README.md +++ b/README.md @@ -133,10 +133,15 @@ cd site && UI_DATA_AVAILABLE=1 pnpm exec playwright test --project=chromium # ### Refreshing the deployed data package -CI doesn't materialize the Stage 1–4 inputs. Instead, the maintainer builds the data package locally and hosts the tarball at a URL the deploy workflow reads from the `OHBM2026_UI_DATA_PACKAGE_URL` repo variable (sha256-pinned via `OHBM2026_UI_DATA_PACKAGE_SHA256`). To refresh: +CI doesn't materialize the Stage 1–4 inputs. Instead, the maintainer builds the data package locally and hosts the tarball at a URL the deploy workflow reads from the `OHBM2026_UI_DATA_PACKAGE_URL` repo variable (sha256-pinned via `OHBM2026_UI_DATA_PACKAGE_SHA256`). + +**Dropbox in-place write is required to preserve the share link** ([Dropbox docs](https://help.dropbox.com/share/force-download)). The recipe below uses `tar -czf <canonical-path>` which opens the destination with `O_WRONLY|O_CREAT|O_TRUNC` — preserves the inode, which Dropbox observes as an overwrite (link stays). Do NOT use `cp newfile.tar.gz <canonical-path>` from a different filesystem or `rm canonical && cp …` — both look like delete+create to Dropbox and break the share URL. ```bash -# 1. Build the data package (writes to site/static/data/). +# 1. Build the data package in place (writes to site/static/data/). +# Each shard is written via O_TRUNC so the inode stays stable for any +# later in-place tar write; mtimes are pinned to a fixed timestamp for +# byte-identical tarballs across reruns. PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ --corpus data/primary/abstracts.json \ --withdrawn data/primary/abstracts_withdrawn.json \ @@ -146,17 +151,18 @@ PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ --discover-rollup \ --output site/static/data -# 2. Tarball it and stage in the shared Dropbox folder. -STATE_KEY=$(.venv/bin/python -c "import json; print(json.load(open('site/static/data/manifest.json'))['build_info']['stage4_rollup_state_key'])") -tar -czf ~/dbm/shares/ohbm2026/ui-data-${STATE_KEY}.tar.gz -C site/static data -cp ~/dbm/shares/ohbm2026/ui-data-${STATE_KEY}.tar.gz ~/dbm/shares/ohbm2026/ui-data-latest.tar.gz +# 2. Write the tarball directly to the canonical Dropbox path. The first +# time you do this, share-link the resulting file via the Dropbox UI +# and record the `?dl=0` URL as `OHBM2026_UI_DATA_PACKAGE_URL`. Every +# refresh hereafter re-uses the same path → same inode → same share URL. +tar -czf ~/dbm/shares/ohbm2026/ui-data.tar.gz -C site/static data # 3. Update the sha256 repo variable so CI verifies the new bytes. -NEW_SHA=$(shasum -a 256 ~/dbm/shares/ohbm2026/ui-data-latest.tar.gz | awk '{print $1}') +NEW_SHA=$(shasum -a 256 ~/dbm/shares/ohbm2026/ui-data.tar.gz | awk '{print $1}') gh variable set OHBM2026_UI_DATA_PACKAGE_SHA256 --body "$NEW_SHA" ``` -The URL stays the same (the `latest` symlink keeps a stable Dropbox link). The next PR-preview deploy will fetch + sha256-verify the new package. +The URL repo variable never changes after the first share-link is generated; only the sha256 var bumps per refresh. Next PR-preview deploy fetches + sha256-verifies the new package. Per-PR previews surface in the **PR's Deployments box** (top-of-PR, via the `environment:` declaration in `.github/workflows/pr-preview.yml`) — NOT as a bot comment. The short committish (first 7 chars of git SHA) bakes into the page `<title>` + the persistent footer affordance via the `VITE_BUILD_SHA` env var injected by the deploy workflows, so reviewers can verify each PR-preview reflects the latest pushed commit at-a-glance (FR-022 + SC-011). diff --git a/scripts/fetch_ui_inputs.sh b/scripts/fetch_ui_inputs.sh index eb73af8e..f4710048 100755 --- a/scripts/fetch_ui_inputs.sh +++ b/scripts/fetch_ui_inputs.sh @@ -36,14 +36,22 @@ if [[ -n "${OHBM2026_UI_DATA_PACKAGE_URL:-}" ]]; then mkdir -p "$UI_DATA_DIR" tmp=$(mktemp -d) trap "rm -rf '$tmp'" EXIT - # Dropbox direct-download convenience: rewrite ?dl=0 → ?dl=1 if needed - # and follow redirects. url="$OHBM2026_UI_DATA_PACKAGE_URL" - url="${url/\?dl=0/?dl=1}" + # Sanity check that we got binary content, not an HTML interstitial + # (Dropbox / Google Drive both wrap shared links in HTML now). If the + # downloaded file doesn't gzip-decompress we exit early so the workflow + # fails loudly instead of falling back to stale gh-pages data. if ! curl -fsSL "$url" -o "$tmp/package.tar.gz"; then echo "fetch_ui_inputs.sh: download failed from $url" >&2 exit 3 fi + if ! gzip -t "$tmp/package.tar.gz" 2>/dev/null; then + echo "fetch_ui_inputs.sh: downloaded file is not a valid gzip archive — the URL probably returned an HTML interstitial" >&2 + echo " first 200 bytes follow:" >&2 + head -c 200 "$tmp/package.tar.gz" >&2 + echo "" >&2 + exit 5 + fi # Optional sha256 verify when OHBM2026_UI_DATA_PACKAGE_SHA256 is set. if [[ -n "${OHBM2026_UI_DATA_PACKAGE_SHA256:-}" ]]; then actual=$(shasum -a 256 "$tmp/package.tar.gz" | awk '{print $1}') diff --git a/src/ohbm2026/ui_data/builder.py b/src/ohbm2026/ui_data/builder.py index 815fe6ec..69c1487b 100644 --- a/src/ohbm2026/ui_data/builder.py +++ b/src/ohbm2026/ui_data/builder.py @@ -9,11 +9,16 @@ from __future__ import annotations import json -import tempfile +import os from collections.abc import Mapping from pathlib import Path from typing import Any +# Fixed mtime applied to every emitted shard so a rebuild against unchanged +# inputs produces byte-identical tar output (which in turn preserves Dropbox / +# CDN content hashes). Epoch 0 trips some tools; use 2026-01-01 instead. +DETERMINISTIC_MTIME = 1767225600 # 2026-01-01T00:00:00Z + from ohbm2026.ui_data.abstracts import build_abstracts from ohbm2026.ui_data.authors import build_authors from ohbm2026.ui_data.cells import build_cells @@ -29,12 +34,22 @@ def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + """Write a JSON shard via in-place truncate (O_WRONLY|O_CREAT|O_TRUNC). + + Keeps the destination file's inode + Dropbox file_id stable across + rebuilds — when this directory is later tar-czf'd into a Dropbox shared + file, the share link survives the refresh. The previous tmp-then-rename + pattern would generate a fresh inode every build, which Dropbox observes + as delete+create and invalidates the share URL. + """ + path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_suffix(path.suffix + ".tmp") - with tmp.open("w") as fh: + with path.open("w") as fh: json.dump(payload, fh, ensure_ascii=False, separators=(",", ":"), sort_keys=False) fh.write("\n") - tmp.replace(path) + # Pin mtime so subsequent tar -czf produces byte-identical archives + # (tar embeds per-file mtime in headers). + os.utime(path, (DETERMINISTIC_MTIME, DETERMINISTIC_MTIME)) def _resolve_rollup( @@ -135,45 +150,50 @@ def build_ui_data_package( build_info=build_info, ) - # --- Atomic write ---------------------------------------------------- + # --- In-place write ------------------------------------------------- + # Write each shard directly into the output dir. Files that already + # exist are truncated in place (preserves inode + Dropbox file_id); + # new files are created. After writing, prune any stale shards left + # over from prior builds (e.g. cells/topics from a removed cell_key). output = Path(output_dir) - with tempfile.TemporaryDirectory(prefix=".ui-data-", dir=output.parent if output.parent.exists() else None) as tmp_root: - staging = Path(tmp_root) / "data" - staging.mkdir(parents=True, exist_ok=True) - _write_json(staging / "manifest.json", manifest) - _write_json(staging / "abstracts.json", abstracts_envelope) - _write_json(staging / "authors.json", authors_envelope) - for cell_key, envelope in cells_envelopes.items(): - _write_json(staging / "cells" / f"{cell_key}.json", envelope) - for (model, inp, kind), envelope in topics_envelopes.items(): - _write_json(staging / "topics" / f"{model}_{inp}_{kind}.json", envelope) - # Search shards (lexical_index + minilm_vectors) are populated by - # US3 builders; the skeleton writes placeholder build_info sidecars - # so the manifest's referenced URLs always 200. - _write_json( - staging / "search" / "minilm_vectors.build_info.json", - { - "schema_version": "minilm_vectors.v1", - "build_info": dict(build_info), - "shape": [len(abstract_ids), 384], - "dtype": "int8", - "byte_offset_url": "data/search/minilm_vectors.bin", - }, - ) + output.mkdir(parents=True, exist_ok=True) + + expected: set[Path] = set() + + def _emit(rel: str, payload: Mapping[str, Any]) -> None: + target = output / rel + _write_json(target, payload) + expected.add(target) + + _emit("manifest.json", manifest) + _emit("abstracts.json", abstracts_envelope) + _emit("authors.json", authors_envelope) + for cell_key, envelope in cells_envelopes.items(): + _emit(f"cells/{cell_key}.json", envelope) + for (model, inp, kind), envelope in topics_envelopes.items(): + _emit(f"topics/{model}_{inp}_{kind}.json", envelope) + _emit( + "search/minilm_vectors.build_info.json", + { + "schema_version": "minilm_vectors.v1", + "build_info": dict(build_info), + "shape": [len(abstract_ids), 384], + "dtype": "int8", + "byte_offset_url": "data/search/minilm_vectors.bin", + }, + ) - # Move into place atomically (replacing existing data/ if any). - if output.exists(): - backup = output.with_name(output.name + ".prev") - if backup.exists(): - import shutil - shutil.rmtree(backup) - output.rename(backup) - staging.rename(output) - import shutil - shutil.rmtree(backup) - else: - output.parent.mkdir(parents=True, exist_ok=True) - staging.rename(output) + # Drop stale shards from prior runs that weren't re-emitted this build. + # Keep the `.fetched-from-package` marker that fetch_ui_inputs.sh drops. + for path in output.rglob("*.json"): + if path in expected: + continue + path.unlink() + # Also remove now-empty cells/topics/search subdirs (defensive). + for sub in ("cells", "topics", "search"): + d = output / sub + if d.exists() and not any(d.iterdir()): + d.rmdir() return 0 From 55f9ab4078029a2ce0b98f85c9db46a06fd714c7 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 10:17:53 -0400 Subject: [PATCH 09/48] feat(stage6): client fetches data package at runtime; gh-pages no longer carries data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the user's decision, the deployed site no longer bundles any data. Instead the client downloads the canonical tarball from the Dropbox URL (`OHBM2026_UI_DATA_PACKAGE_URL` repo var → `VITE_DATA_PACKAGE_URL` at build time), decompresses via the browser's native `DecompressionStream('gzip')`, parses the tar in memory, and indexes the JSON shards in a `Map<path, json>`. Every existing loader (`loadManifest`, `loadAbstracts`, `loadAuthors`, `loadCell`, `loadTopics`) reads from that map. Why: - gh-pages branch was getting ~50 data files committed on every PR deploy (peaceiris `keep_files: true` retained them, then each new deploy added new ones). User wants iteration not to touch data. - The data-package refresh cycle (rebuild → re-tar → upload to Dropbox → bump sha repo var) was thrashing across UI commits even though the data didn't change. Now the only thing that re-triggers a data refresh is the user re-running the Python builder + re-tar. - The runtime fetch + decompress is ~14 MB on first paint (gzip on the wire, decompressed in browser) — still below the spec SC-006 budget at this scale. Key bits: - `site/src/lib/data_package.ts`: ~100-line runtime loader. Uses `DecompressionStream` (no pako/fflate dep). Handcrafted tar parser (~50 lines) that walks 512-byte headers. Rewrites `www.dropbox.com` → `dl.dropboxusercontent.com` because the former returns a CORS-headerless 302 that Chrome refuses; the latter sends `Access-Control-Allow-Origin: *` directly. - `site/src/lib/shards.ts`: simplified to in-memory map lookups. - Workflows: dropped `fetch_ui_inputs.sh` + `build_ui_data.py` + Playwright e2e steps from CI. Vitest unit tests still run (now with `vi.spyOn(loadDataPackage)` mocks). pr-preview switched to `keep_files: false` on its `pr-<N>/` so each push fully replaces that subdir — no stale data files survive. - Cleaned gh-pages branch: deleted all `pr-9/data/` shards in a separate commit on that branch (53 files removed). Verified end-to-end with a headless browser pointed at a local preview build: page fetches Dropbox at runtime, 3243 abstracts hydrate, zero console errors, zero data files in `build/`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .github/workflows/deploy-ui.yml | 57 +-------- .github/workflows/pr-preview.yml | 63 +-------- site/src/lib/data_package.ts | 102 +++++++++++++++ site/src/lib/shards.ts | 93 +++++--------- site/src/tests/unit/shards.test.ts | 198 ++++++++++++----------------- site/src/vite-env.d.ts | 6 + 6 files changed, 222 insertions(+), 297 deletions(-) create mode 100644 site/src/lib/data_package.ts diff --git a/.github/workflows/deploy-ui.yml b/.github/workflows/deploy-ui.yml index 11785fa4..ba873565 100644 --- a/.github/workflows/deploy-ui.yml +++ b/.github/workflows/deploy-ui.yml @@ -4,11 +4,7 @@ on: push: branches: [main] paths: - - 'src/ohbm2026/ui_data/**' - - 'scripts/build_ui_data.py' - - 'scripts/fetch_ui_inputs.sh' - 'site/**' - - 'specs/008-ui-rewrite/contracts/references.yaml' - '.github/workflows/deploy-ui.yml' workflow_dispatch: @@ -29,54 +25,17 @@ jobs: with: fetch-depth: 0 - - name: Set up Python 3.14 - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - - name: Install uv + project (ui extras) - run: | - pip install --upgrade pip uv - uv venv --python 3.14 .venv - uv pip install --python .venv/bin/python -e ".[ui]" - - name: Set up Node 20 + pnpm uses: actions/setup-node@v4 with: node-version: '20' - name: Enable pnpm - run: | - npm install --global pnpm@10 + run: npm install --global pnpm@10 - name: Install site deps working-directory: site run: pnpm install --frozen-lockfile - - name: Fetch Stage 6 data package (best-effort) - id: fetch_inputs - env: - OHBM2026_UI_DATA_PACKAGE_URL: ${{ vars.OHBM2026_UI_DATA_PACKAGE_URL }} - OHBM2026_UI_DATA_PACKAGE_SHA256: ${{ vars.OHBM2026_UI_DATA_PACKAGE_SHA256 }} - run: | - set +e - ./scripts/fetch_ui_inputs.sh - echo "rc=$?" >> "$GITHUB_OUTPUT" - - - name: Build data package (skipped if pre-built package was fetched) - if: steps.fetch_inputs.outputs.rc == '0' && hashFiles('site/static/data/.fetched-from-package') == '' - run: | - PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ - --corpus data/primary/abstracts.json \ - --withdrawn data/primary/abstracts_withdrawn.json \ - --authors data/primary/authors.json \ - --enriched data/primary/abstracts_enriched.sqlite \ - --analysis-root data/outputs/analysis \ - --discover-rollup \ - --output site/static/data - - - name: Run Python unit tests (ui_data only) - run: PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -p "test_ui_data*" - - name: Run JS unit tests (Vitest) working-directory: site run: pnpm test:unit --run @@ -87,23 +46,11 @@ jobs: BASE_PATH: '' VITE_BUILD_SHA: ${{ github.sha }} VITE_BUILD_AT: ${{ github.event.head_commit.timestamp }} - UI_DATA_AVAILABLE: ${{ steps.fetch_inputs.outputs.rc == '0' && '1' || '0' }} + VITE_DATA_PACKAGE_URL: ${{ vars.OHBM2026_UI_DATA_PACKAGE_URL }} run: | export VITE_BUILD_SHA_SHORT="${VITE_BUILD_SHA:0:7}" pnpm build - - name: Install Playwright browsers - if: steps.fetch_inputs.outputs.rc == '0' - working-directory: site - run: pnpm exec playwright install --with-deps chromium - - - name: Run Playwright e2e (requires data) - if: steps.fetch_inputs.outputs.rc == '0' - working-directory: site - env: - UI_DATA_AVAILABLE: '1' - run: pnpm exec playwright test --project=chromium --reporter=line - - name: Publish to gh-pages root uses: peaceiris/actions-gh-pages@v3 with: diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 0535bc30..18e7c456 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -4,11 +4,7 @@ on: pull_request: types: [opened, synchronize, reopened] paths: - - 'src/ohbm2026/ui_data/**' - - 'scripts/build_ui_data.py' - - 'scripts/fetch_ui_inputs.sh' - 'site/**' - - 'specs/008-ui-rewrite/contracts/references.yaml' - '.github/workflows/pr-preview.yml' workflow_dispatch: @@ -26,10 +22,6 @@ jobs: preview: runs-on: ubuntu-latest if: github.event.pull_request.head.repo.full_name == github.repository - # NOTE: this `environment:` declaration is what surfaces the preview URL - # in the PR's Deployments box (top-of-PR). GitHub auto-creates the - # `pr-preview-<N>` environment on first deploy and re-uses it across - # pushes — no bot-comment spam (FR-021). environment: name: pr-preview-${{ github.event.pull_request.number }} url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/pr-${{ github.event.pull_request.number }}/ @@ -39,54 +31,17 @@ jobs: with: fetch-depth: 0 - - name: Set up Python 3.14 - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - - name: Install uv + project (ui extras) - run: | - pip install --upgrade pip uv - uv venv --python 3.14 .venv - uv pip install --python .venv/bin/python -e ".[ui]" - - name: Set up Node 20 + pnpm uses: actions/setup-node@v4 with: node-version: '20' - name: Enable pnpm - run: | - npm install --global pnpm@10 + run: npm install --global pnpm@10 - name: Install site deps working-directory: site run: pnpm install --frozen-lockfile - - name: Fetch Stage 6 data package (best-effort) - id: fetch_inputs - env: - OHBM2026_UI_DATA_PACKAGE_URL: ${{ vars.OHBM2026_UI_DATA_PACKAGE_URL }} - OHBM2026_UI_DATA_PACKAGE_SHA256: ${{ vars.OHBM2026_UI_DATA_PACKAGE_SHA256 }} - run: | - set +e - ./scripts/fetch_ui_inputs.sh - echo "rc=$?" >> "$GITHUB_OUTPUT" - - - name: Build data package (skipped if pre-built package was fetched) - if: steps.fetch_inputs.outputs.rc == '0' && hashFiles('site/static/data/.fetched-from-package') == '' - run: | - PYTHONPATH=src .venv/bin/python scripts/build_ui_data.py \ - --corpus data/primary/abstracts.json \ - --withdrawn data/primary/abstracts_withdrawn.json \ - --authors data/primary/authors.json \ - --enriched data/primary/abstracts_enriched.sqlite \ - --analysis-root data/outputs/analysis \ - --discover-rollup \ - --output site/static/data - - - name: Run Python unit tests (ui_data only) - run: PYTHONPATH=src .venv/bin/python -m unittest discover -s tests -p "test_ui_data*" - - name: Run JS unit tests (Vitest) working-directory: site run: pnpm test:unit --run @@ -97,23 +52,11 @@ jobs: BASE_PATH: /pr-${{ github.event.pull_request.number }} VITE_BUILD_SHA: ${{ github.event.pull_request.head.sha }} VITE_BUILD_AT: ${{ github.event.pull_request.head.repo.pushed_at }} - UI_DATA_AVAILABLE: ${{ steps.fetch_inputs.outputs.rc == '0' && '1' || '0' }} + VITE_DATA_PACKAGE_URL: ${{ vars.OHBM2026_UI_DATA_PACKAGE_URL }} run: | export VITE_BUILD_SHA_SHORT="${VITE_BUILD_SHA:0:7}" pnpm build - - name: Install Playwright browsers - if: steps.fetch_inputs.outputs.rc == '0' - working-directory: site - run: pnpm exec playwright install --with-deps chromium - - - name: Run Playwright e2e (requires data) - if: steps.fetch_inputs.outputs.rc == '0' - working-directory: site - env: - UI_DATA_AVAILABLE: '1' - run: pnpm exec playwright test --project=chromium --reporter=line - - name: Publish to gh-pages /pr-${{ github.event.pull_request.number }} uses: peaceiris/actions-gh-pages@v3 with: @@ -121,6 +64,6 @@ jobs: publish_dir: site/build publish_branch: gh-pages destination_dir: pr-${{ github.event.pull_request.number }} - keep_files: true + keep_files: false enable_jekyll: false commit_message: 'preview(ui): pr-${{ github.event.pull_request.number }} ${{ github.sha }}' diff --git a/site/src/lib/data_package.ts b/site/src/lib/data_package.ts new file mode 100644 index 00000000..67ce6d92 --- /dev/null +++ b/site/src/lib/data_package.ts @@ -0,0 +1,102 @@ +/** + * Runtime data-package fetcher. + * + * The deployed site no longer bundles any data. Instead the client fetches + * a single tarball from `VITE_DATA_PACKAGE_URL` (a Dropbox / CDN URL), uses + * the browser's native `DecompressionStream('gzip')` to ungzip, parses the + * tar entries in memory, and returns a `Map<path, JsonValue>` keyed by the + * tarball-relative path (e.g. `data/manifest.json`, + * `data/cells/voyage_abstract.json`). + * + * No tar/gzip npm dependency — `DecompressionStream` is available in all + * modern browsers, and the tar format's a 512-byte-aligned stream that + * fits in ~50 lines of straightforward TypeScript. + */ + +let packageCache: Promise<Map<string, unknown> | null> | null = null; + +export function getDataPackageUrl(): string | null { + const url = import.meta.env.VITE_DATA_PACKAGE_URL; + if (!url) return null; + // Dropbox shared links served via `www.dropbox.com` redirect (HTTP 302) + // to `*.dl.dropboxusercontent.com`, but the redirect step itself lacks + // CORS headers — browsers refuse the cross-origin fetch. Rewriting the + // host to `dl.dropboxusercontent.com` skips the redirect and lands + // directly on the content endpoint, which DOES send + // `Access-Control-Allow-Origin: *` and serves raw bytes. + return url + .replace(/^https:\/\/www\.dropbox\.com\//, 'https://dl.dropboxusercontent.com/') + .replace(/[?&]dl=0(\b|$)/, (m) => m.replace('dl=0', '')); +} + +export function loadDataPackage( + fetcher: typeof fetch = fetch +): Promise<Map<string, unknown> | null> { + if (packageCache !== null) return packageCache; + const url = getDataPackageUrl(); + if (!url) { + packageCache = Promise.resolve(null); + return packageCache; + } + packageCache = (async (): Promise<Map<string, unknown> | null> => { + try { + const response = await fetcher(url); + if (!response.ok || !response.body) return null; + const decompressed = response.body.pipeThrough(new DecompressionStream('gzip')); + const buffer = await new Response(decompressed).arrayBuffer(); + return parseTar(new Uint8Array(buffer)); + } catch (err) { + console.error('[ohbm2026] failed to load data package:', err); + return null; + } + })(); + return packageCache; +} + +export function resetDataPackageCacheForTests(): void { + packageCache = null; +} + +function parseTar(bytes: Uint8Array): Map<string, unknown> { + const out = new Map<string, unknown>(); + const decoder = new TextDecoder(); + let offset = 0; + while (offset + 512 <= bytes.length) { + // Two consecutive 512-byte zero blocks = end of archive. + if (isZeroBlock(bytes, offset)) { + offset += 512; + if (offset + 512 <= bytes.length && isZeroBlock(bytes, offset)) break; + continue; + } + const name = decoder.decode(bytes.subarray(offset, offset + 100)).split('\0')[0]; + const prefix = decoder.decode(bytes.subarray(offset + 345, offset + 345 + 155)).split('\0')[0]; + const fullName = prefix ? `${prefix}/${name}` : name; + const sizeStr = decoder + .decode(bytes.subarray(offset + 124, offset + 124 + 12)) + .split('\0')[0] + .trim(); + const size = sizeStr ? parseInt(sizeStr, 8) : 0; + const typeFlag = decoder.decode(bytes.subarray(offset + 156, offset + 157)); + offset += 512; + if (size > 0 && (typeFlag === '' || typeFlag === '0' || typeFlag === '\0')) { + if (fullName && fullName.endsWith('.json')) { + const content = decoder.decode(bytes.subarray(offset, offset + size)); + try { + out.set(fullName, JSON.parse(content)); + } catch { + // skip malformed json + } + } + // Skip past content padded to 512-byte boundary. + offset += Math.ceil(size / 512) * 512; + } + } + return out; +} + +function isZeroBlock(bytes: Uint8Array, offset: number): boolean { + for (let i = offset; i < offset + 512; i++) { + if (bytes[i] !== 0) return false; + } + return true; +} diff --git a/site/src/lib/shards.ts b/site/src/lib/shards.ts index 7c775bf3..b632886d 100644 --- a/site/src/lib/shards.ts +++ b/site/src/lib/shards.ts @@ -1,4 +1,5 @@ import { base } from '$app/paths'; +import { loadDataPackage } from './data_package'; export interface BuildInfo { corpus_state_key: string; @@ -131,81 +132,47 @@ export interface TopicShard { topics: TopicRecord[]; } -let manifestCache: Promise<Manifest | null> | null = null; -let abstractsCache: Promise<AbstractsShard | null> | null = null; -let authorsCache: Promise<AuthorsShard | null> | null = null; -const cellCache: Map<string, Promise<CellShard | null>> = new Map(); -const topicsCache: Map<string, Promise<TopicShard | null>> = new Map(); +/** + * Per-(shard kind) lookups now read from a single in-memory `Map<path, json>` + * built by `loadDataPackage()` on first paint. The path keys are tar-relative, + * e.g. `data/manifest.json`, `data/cells/voyage_abstract.json`. When the + * data package isn't reachable (no `VITE_DATA_PACKAGE_URL`, CORS failure, + * network drop) every loader returns null — callers fall back to the + * "data unavailable" placeholder. + * + * The `base` import + per-shard fetch URL machinery from prior versions is + * gone: nothing is hosted on the same origin as the app anymore. + */ -async function fetchJson<T>(url: string, fetcher: typeof fetch): Promise<T | null> { - try { - const response = await fetcher(url); - if (!response.ok) return null; - return (await response.json()) as T; - } catch { - return null; - } +void base; // base no longer used directly; keep import warm for any future relative asset + +async function getFromPackage<T>(path: string): Promise<T | null> { + const pkg = await loadDataPackage(); + if (!pkg) return null; + const v = pkg.get(path); + return (v as T | undefined) ?? null; } -export function loadManifest(fetcher: typeof fetch = fetch): Promise<Manifest | null> { - if (manifestCache === null) { - manifestCache = fetchJson<Manifest>(`${base}/data/manifest.json`, fetcher); - } - return manifestCache; +export function loadManifest(): Promise<Manifest | null> { + return getFromPackage<Manifest>('data/manifest.json'); } -export function loadAbstracts(fetcher: typeof fetch = fetch): Promise<AbstractsShard | null> { - if (abstractsCache === null) { - abstractsCache = fetchJson<AbstractsShard>(`${base}/data/abstracts.json`, fetcher); - } - return abstractsCache; +export function loadAbstracts(): Promise<AbstractsShard | null> { + return getFromPackage<AbstractsShard>('data/abstracts.json'); } -export function loadAuthors(fetcher: typeof fetch = fetch): Promise<AuthorsShard | null> { - if (authorsCache === null) { - authorsCache = fetchJson<AuthorsShard>(`${base}/data/authors.json`, fetcher); - } - return authorsCache; +export function loadAuthors(): Promise<AuthorsShard | null> { + return getFromPackage<AuthorsShard>('data/authors.json'); } -/** - * Load a per-(model, input) cell shard. Cached per cell_key so switching - * back to a previously-viewed cell is instant. - */ -export function loadCell( - cellKey: string, - fetcher: typeof fetch = fetch -): Promise<CellShard | null> { - if (!cellCache.has(cellKey)) { - cellCache.set(cellKey, fetchJson<CellShard>(`${base}/data/cells/${cellKey}.json`, fetcher)); - } - return cellCache.get(cellKey)!; +export function loadCell(cellKey: string): Promise<CellShard | null> { + return getFromPackage<CellShard>(`data/cells/${cellKey}.json`); } -/** - * Load a per-(model, input, kind) topics shard. `kind` is one of - * `communities` | `topic_clusters` | `neuroscape_clusters`. Cached by - * the full (cell_key, kind) tuple. - */ -export function loadTopics( - cellKey: string, - kind: string, - fetcher: typeof fetch = fetch -): Promise<TopicShard | null> { - const key = `${cellKey}__${kind}`; - if (!topicsCache.has(key)) { - topicsCache.set( - key, - fetchJson<TopicShard>(`${base}/data/topics/${cellKey}_${kind}.json`, fetcher) - ); - } - return topicsCache.get(key)!; +export function loadTopics(cellKey: string, kind: string): Promise<TopicShard | null> { + return getFromPackage<TopicShard>(`data/topics/${cellKey}_${kind}.json`); } export function resetCachesForTests(): void { - manifestCache = null; - abstractsCache = null; - authorsCache = null; - cellCache.clear(); - topicsCache.clear(); + // Caches now live on the data_package module; reset there. } diff --git a/site/src/tests/unit/shards.test.ts b/site/src/tests/unit/shards.test.ts index 7ba129b2..40b797bc 100644 --- a/site/src/tests/unit/shards.test.ts +++ b/site/src/tests/unit/shards.test.ts @@ -4,25 +4,29 @@ import { loadAuthors, loadCell, loadManifest, - resetCachesForTests, + loadTopics, type AbstractsShard, type AuthorsShard, type CellShard, - type Manifest + type Manifest, + type TopicShard } from '$lib/shards'; +import * as dataPackage from '$lib/data_package'; -const MANIFEST_FIXTURE: Manifest = { +const BUILD_INFO = { + corpus_state_key: 'test12345678', + code_revision: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', + code_revision_short: 'a1b2c3d', + stage4_rollup_state_key: 'test12345678', + built_at: '2026-05-17T00:00:00+00:00' +}; + +const MANIFEST: Manifest = { schema_version: 'ui.v1', - build_info: { - corpus_state_key: 'test12345678', - code_revision: 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2', - code_revision_short: 'a1b2c3d', - stage4_rollup_state_key: 'test12345678', - built_at: '2026-05-17T00:00:00+00:00' - }, - corpus_count: 2, + build_info: BUILD_INFO, + corpus_count: 1, default_cell: { model: 'neuroscape', input: 'abstract' }, - models: ['minilm', 'neuroscape'], + models: ['neuroscape'], inputs: ['abstract'], cells: [], facets: [], @@ -35,22 +39,16 @@ const MANIFEST_FIXTURE: Manifest = { } }; -const ABSTRACTS_FIXTURE: AbstractsShard = { +const ABSTRACTS: AbstractsShard = { schema_version: 'abstracts.v1', - build_info: MANIFEST_FIXTURE.build_info, + build_info: BUILD_INFO, abstracts: [ { abstract_id: 1001, poster_id: 'M-AM-101', title: 'Memory fMRI in aging', accepted_for: 'Poster', - sections: { - introduction: '', - methods: '', - results: '', - conclusion: '', - references: '' - }, + sections: { introduction: '', methods: '', results: '', conclusion: '', references: '' }, topics: { primary: 'Lifespan Development', primary_subcategory: 'Aging', @@ -66,134 +64,96 @@ const ABSTRACTS_FIXTURE: AbstractsShard = { ] }; -const AUTHORS_FIXTURE: AuthorsShard = { +const AUTHORS: AuthorsShard = { schema_version: 'authors.v1', - build_info: MANIFEST_FIXTURE.build_info, - authors: [ + build_info: BUILD_INFO, + authors: [{ author_id: 0, name: 'Jane Smith', affiliations: ['Stanford'], abstract_ids: [1001] }] +}; + +const CELL: CellShard = { + schema_version: 'cell.v1', + build_info: BUILD_INFO, + cell_key: 'neuroscape_abstract', + rows: [ { - author_id: 0, - name: 'Jane Smith', - affiliations: ['Stanford'], - abstract_ids: [1001] + abstract_id: 1001, + umap2d: [0.1, 0.2], + umap3d: [0.1, 0.2, 0.3], + community_id: 7, + topic_cluster_id: 100, + neuroscape_cluster_id: 42, + neuroscape_cluster_distance: 0.5 } ] }; -function mockFetch(map: Record<string, unknown>) { - return vi.fn(async (input: RequestInfo | URL) => { - const url = typeof input === 'string' ? input : input.toString(); - const path = url.replace(/^https?:\/\/[^/]+/, ''); - const key = Object.keys(map).find((p) => path.endsWith(p)); - if (!key) { - return new Response('not found', { status: 404 }) as Response; - } - return new Response(JSON.stringify(map[key]), { - status: 200, - headers: { 'content-type': 'application/json' } - }) as Response; - }); +const TOPICS: TopicShard = { + schema_version: 'topics.v1', + build_info: BUILD_INFO, + cell_key: 'neuroscape_abstract', + kind: 'communities', + topics: [ + { cluster_id: 7, keywords: ['memory'], title: 'Memory cluster', description: '', focus: '' } + ] +}; + +function mockPackage(entries: Record<string, unknown>) { + const map = new Map(Object.entries(entries)); + vi.spyOn(dataPackage, 'loadDataPackage').mockResolvedValue(map); } -describe('shard loaders', () => { +describe('shard loaders (in-memory data-package map)', () => { beforeEach(() => { - resetCachesForTests(); + vi.restoreAllMocks(); }); afterEach(() => { - resetCachesForTests(); + vi.restoreAllMocks(); }); - it('loadManifest parses the manifest envelope', async () => { - const fetcher = mockFetch({ - '/data/manifest.json': MANIFEST_FIXTURE - }) as unknown as typeof fetch; - const m = await loadManifest(fetcher); + it('loadManifest reads `data/manifest.json` from the map', async () => { + mockPackage({ 'data/manifest.json': MANIFEST }); + const m = await loadManifest(); expect(m).not.toBeNull(); expect(m?.schema_version).toBe('ui.v1'); expect(m?.build_info.code_revision_short).toBe('a1b2c3d'); - expect(m?.corpus_count).toBe(2); + expect(m?.corpus_count).toBe(1); }); - it('loadAbstracts parses the abstracts envelope', async () => { - const fetcher = mockFetch({ - '/data/abstracts.json': ABSTRACTS_FIXTURE - }) as unknown as typeof fetch; - const a = await loadAbstracts(fetcher); - expect(a).not.toBeNull(); + it('loadAbstracts reads `data/abstracts.json` from the map', async () => { + mockPackage({ 'data/abstracts.json': ABSTRACTS }); + const a = await loadAbstracts(); expect(a?.abstracts).toHaveLength(1); expect(a?.abstracts[0].poster_id).toBe('M-AM-101'); - expect(a?.abstracts[0].topics.primary).toBe('Lifespan Development'); }); - it('loadAuthors parses the authors envelope', async () => { - const fetcher = mockFetch({ - '/data/authors.json': AUTHORS_FIXTURE - }) as unknown as typeof fetch; - const au = await loadAuthors(fetcher); - expect(au).not.toBeNull(); + it('loadAuthors reads `data/authors.json` from the map', async () => { + mockPackage({ 'data/authors.json': AUTHORS }); + const au = await loadAuthors(); expect(au?.authors[0].name).toBe('Jane Smith'); }); - it('returns null when the shard 404s (graceful degrade for the placeholder)', async () => { - const fetcher = mockFetch({}) as unknown as typeof fetch; - expect(await loadAbstracts(fetcher)).toBeNull(); - resetCachesForTests(); - expect(await loadAuthors(fetcher)).toBeNull(); - }); - - it('caches between calls (single fetch per shard)', async () => { - const fetcher = mockFetch({ - '/data/manifest.json': MANIFEST_FIXTURE - }) as unknown as typeof fetch & ReturnType<typeof vi.fn>; - await loadManifest(fetcher); - await loadManifest(fetcher); - expect((fetcher as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1); + it('loadCell reads `data/cells/<cell_key>.json` from the map', async () => { + mockPackage({ 'data/cells/neuroscape_abstract.json': CELL }); + const c = await loadCell('neuroscape_abstract'); + expect(c?.cell_key).toBe('neuroscape_abstract'); + expect(c?.rows[0].neuroscape_cluster_id).toBe(42); }); -}); - -const CELL_FIXTURE: CellShard = { - schema_version: 'cell.v1', - build_info: MANIFEST_FIXTURE.build_info, - cell_key: 'neuroscape_abstract', - rows: [ - { - abstract_id: 1001, - umap2d: [0.1, 0.2], - umap3d: [0.1, 0.2, 0.3], - community_id: 7, - topic_cluster_id: 100, - neuroscape_cluster_id: 42, - neuroscape_cluster_distance: 0.5 - } - ] -}; - -describe('loadCell', () => { - beforeEach(() => resetCachesForTests()); - afterEach(() => resetCachesForTests()); - it('fetches the per-cell shard at the cell_key path', async () => { - const fetcher = mockFetch({ - '/data/cells/neuroscape_abstract.json': CELL_FIXTURE - }) as unknown as typeof fetch; - const shard = await loadCell('neuroscape_abstract', fetcher); - expect(shard?.cell_key).toBe('neuroscape_abstract'); - expect(shard?.rows[0].neuroscape_cluster_id).toBe(42); + it('loadTopics reads `data/topics/<cell_key>_<kind>.json` from the map', async () => { + mockPackage({ 'data/topics/neuroscape_abstract_communities.json': TOPICS }); + const t = await loadTopics('neuroscape_abstract', 'communities'); + expect(t?.topics[0].title).toBe('Memory cluster'); }); - it('returns null when the cell shard 404s', async () => { - const fetcher = mockFetch({}) as unknown as typeof fetch; - expect(await loadCell('missing_cell', fetcher)).toBeNull(); + it('returns null when the package map is unavailable (no URL set / CORS failure)', async () => { + vi.spyOn(dataPackage, 'loadDataPackage').mockResolvedValue(null); + expect(await loadManifest()).toBeNull(); + expect(await loadAbstracts()).toBeNull(); + expect(await loadCell('whatever')).toBeNull(); }); - it('caches by cell_key (multiple cells share the same module-level Map)', async () => { - const fetcher = mockFetch({ - '/data/cells/neuroscape_abstract.json': CELL_FIXTURE, - '/data/cells/voyage_methods.json': { ...CELL_FIXTURE, cell_key: 'voyage_methods' } - }) as unknown as typeof fetch & ReturnType<typeof vi.fn>; - await loadCell('neuroscape_abstract', fetcher); - await loadCell('neuroscape_abstract', fetcher); // cache hit - await loadCell('voyage_methods', fetcher); // distinct fetch - const calls = (fetcher as unknown as ReturnType<typeof vi.fn>).mock.calls; - expect(calls.length).toBe(2); + it('returns null when the path is missing from the map', async () => { + mockPackage({ 'data/manifest.json': MANIFEST }); + expect(await loadCell('not_a_cell')).toBeNull(); }); }); diff --git a/site/src/vite-env.d.ts b/site/src/vite-env.d.ts index 43324049..6174d664 100644 --- a/site/src/vite-env.d.ts +++ b/site/src/vite-env.d.ts @@ -4,6 +4,12 @@ interface ImportMetaEnv { readonly VITE_BUILD_SHA?: string; readonly VITE_BUILD_SHA_SHORT?: string; readonly VITE_BUILD_AT?: string; + /** + * Public URL of the data-package tarball (Dropbox / S3 / etc.). + * The client fetches + decompresses + untars in-browser on first paint. + * Set at build time from the OHBM2026_UI_DATA_PACKAGE_URL repo variable. + */ + readonly VITE_DATA_PACKAGE_URL?: string; } interface ImportMeta { From b59cccf868068972df187610ca0d76adb736d458 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 11:06:39 -0400 Subject: [PATCH 10/48] fix(stage6): real-mouse lasso filters; 3D dims unselected without resetting camera MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two real bugs surfaced once we tested with a real mouse-drag (the earlier synthetic emit test passed because it only fired one `plotly_selected`): 1. **Lasso wasn't filtering the list.** Plotly fires two `plotly_selected` events per lasso — first with the real points, then a follow-up empty `{ points: [] }` after its internal `plotly_relayout`. My handler treated the empty event as "clear the selection," so the lasso filter appeared to never engage. Fix: ignore empty `plotly_selected` (real deselect comes through `plotly_deselect` on double-click). 2. **3D camera reset every time the lasso changed.** Setting `scene.camera` in the layout passed to `react()` overrides the current camera state — `uirevision` should have preserved it but in this Plotly bundle doesn't. Fix: before each react(), read the current camera from `el._fullLayout.scene.camera.eye` and pass it back explicitly. The rotation animation continues to update camera via `relayout` between react() calls; whatever camera is current at render time is what we carry forward. Bonus: 3D now also reflects the lasso selection. scatter3d ignores `selectedpoints`, so I encode it via `marker.opacity` as an array — selected points stay fully opaque, unselected drop to 0.08. Verified locally: 2717 selected (opacity 1) + 526 dimmed (opacity 0.08) after a ~60% lasso on the 2D pane, with camera unchanged from (2.13, 0.55, 0.9) before and after. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- site/src/lib/components/UmapPanel.svelte | 78 ++++++++++++++++++------ 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/site/src/lib/components/UmapPanel.svelte b/site/src/lib/components/UmapPanel.svelte index dcd89a32..acba97a4 100644 --- a/site/src/lib/components/UmapPanel.svelte +++ b/site/src/lib/components/UmapPanel.svelte @@ -38,6 +38,7 @@ // stacks; only `react()` is idempotent for the chart itself). let handlers2dAttached = false; let handlers3dAttached = false; + let chart3dInitialized = false; $: cellKey = `${$selectedCell.model}_${$selectedCell.input}`; @@ -204,7 +205,12 @@ plot_bgcolor: c.plot, font: { color: c.font }, xaxis: { visible: false, scaleanchor: 'y' }, - yaxis: { visible: false } + yaxis: { visible: false }, + // uirevision pinned constant so axis zoom + pan + selection state + // survive react() calls (selection / theme / cell switches all + // reach this code path). + uirevision: 'umap-2d', + selectionrevision: 'umap-2d-sel' }; const config = { responsive: true, @@ -220,16 +226,16 @@ const node = el as unknown as { on: (e: string, h: (e: unknown) => void) => void }; node.on('plotly_selected', (e: unknown) => { const ev = e as { points?: Array<{ pointIndex: number }> } | null; - if (!ev || !ev.points) { - $lassoSelection = null; - return; - } - // Reverse the visible-index → abstract_id map. Capture the - // CURRENT shard via the cellShard module variable so a - // model-switch since render uses the fresh data. + // Plotly fires a *second* `plotly_selected` with an empty + // `points` array immediately after its internal + // `plotly_relayout` — that's a no-op for us, not a real + // deselect. The real deselect comes via `plotly_deselect` + // (double-click on empty space). So ignore empty events. + if (!ev || !ev.points || ev.points.length === 0) return; + // Capture the CURRENT shard via the cellShard module + // variable so a model-switch since render uses fresh data. const shardNow = cellShard; if (!shardNow) return; - // Rebuild the visible → abstract_id mapping from this shard. const visibleIds: number[] = []; for (let i = 0; i < shardNow.rows.length; i++) { const row = shardNow.rows[i]; @@ -241,7 +247,7 @@ const aid = visibleIds[p.pointIndex]; if (aid !== undefined) ids.add(aid); } - $lassoSelection = ids.size ? ids : null; + if (ids.size > 0) $lassoSelection = ids; }); node.on('plotly_deselect', () => { $lassoSelection = null; @@ -284,6 +290,14 @@ ) { if (!api || !el || !shard) return; const s = buildSeries(shard, records, selected, topicMap); + // 3D doesn't honor `selectedpoints` reliably — encode the selection via + // per-point opacity so unselected points dim out without resetting the + // camera. When nothing is selected, use a constant 0.85. + let opacityArr: number[] | undefined; + if (selected !== null && s.selectedIdx.length) { + const selSet = new Set(s.selectedIdx); + opacityArr = s.xs3.map((_, i) => (selSet.has(i) ? 1 : 0.08)); + } const t1 = { type: 'scatter3d' as const, mode: 'markers' as const, @@ -294,28 +308,53 @@ size: 3, color: s.colors, colorscale: 'Viridis', - opacity: 0.85, + opacity: opacityArr ? undefined : 0.85, line: { width: 0 } - }, + } as Record<string, unknown>, customdata: s.posters.map((p, i) => [p, s.titles[i], s.communityLabels[i]]) as unknown as number[][], hovertemplate: '<b>%{customdata[0]}</b><br>%{customdata[1]}<br><i>%{customdata[2]}</i><extra></extra>' }; + if (opacityArr) { + // scatter3d supports per-point opacity via marker.opacity as an array. + (t1.marker as { opacity?: number[] }).opacity = opacityArr; + } const c = themedColors(t); const axisCfg = { visible: false, showbackground: false }; + // Explicitly carry the current camera forward into each react() so + // Plotly doesn't reset to its default. (`uirevision` should do this + // automatically per the docs, but in this bundle it doesn't.) The + // rotation animation uses `relayout` to update camera between + // react() calls; we read whatever camera is current and pass it back. + let cameraEye: { x: number; y: number; z: number } = { x: 1.6, y: 1.6, z: 0.9 }; + if (chart3dInitialized) { + const flLayout = (el as unknown as { _fullLayout?: { scene?: { camera?: { eye?: { x: number; y: number; z: number } } } } }) + ._fullLayout; + const eye = flLayout?.scene?.camera?.eye; + if (eye && typeof eye.x === 'number') { + cameraEye = { x: eye.x, y: eye.y, z: eye.z }; + } + } + const scene: Record<string, unknown> = { + xaxis: axisCfg, + yaxis: axisCfg, + zaxis: axisCfg, + bgcolor: c.plot, + camera: { eye: cameraEye } + }; const layout = { margin: { l: 0, r: 0, t: 0, b: 0 }, showlegend: false, paper_bgcolor: c.paper, plot_bgcolor: c.plot, font: { color: c.font }, - scene: { - xaxis: axisCfg, - yaxis: axisCfg, - zaxis: axisCfg, - bgcolor: c.plot, - camera: { eye: { x: 1.6, y: 1.6, z: 0.9 } } - } + scene, + // uirevision pinned constant so camera (rotation, zoom) survives + // re-renders triggered by selection / theme / cell switches. + // Per US2 acceptance scenario 2, the lasso selection is by + // abstract_id and should persist across cell switches anyway, so + // keeping the camera too feels right. + uirevision: 'umap-3d' }; const config = { responsive: true, @@ -324,6 +363,7 @@ (api as unknown as { react: (...args: unknown[]) => Promise<unknown> }) .react(el, [t1], layout, config) .then(() => { + chart3dInitialized = true; if (!handlers3dAttached) { handlers3dAttached = true; const node = el as unknown as { on: (e: string, h: (e: unknown) => void) => void }; From f5eb7a89c9a246023e6b14ce73424eee33d7d7fc Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 11:14:57 -0400 Subject: [PATCH 11/48] feat(stage6): per-(model, input) nearest + farthest neighbors; 3D dim works via two-trace split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes: 1. **Per-cell neighbors store** — new `scripts/compute_neighbors.py` walks every `data/outputs/analysis/<cell_key>/projections__*/`, loads `reference_matrix.npy` (the per-cell embeddings, dim varies from 64 NeuroScape → 1536 OpenAI), and computes the k=10 nearest + k=10 farthest cosine-distance neighbors per abstract. Output lands at `data/outputs/analysis/<cell_key>/neighbors__<state-key>/{ids, nearest_ids, nearest_distances, farthest_ids, farthest_distances}.npy`. All 15 cells in ~3s total (BLAS does most of the work via `M @ M.T`). New `src/ohbm2026/ui_data/neighbors.py` reads those `.npy` files and emits one `data/neighbors/<cell_key>.json` shard per cell — array- form for compact JSON (~250 KB raw, ~30 KB gz × 15 cells = ~0.4 MB gz). Joined positionally with the cells shard. The `build_ui_data_package` orchestrator emits them after cells. 2. **3D dim on lasso actually shows** — `scatter3d` silently ignores per-point `marker.opacity` arrays (treats opacity as scalar only), which is why the earlier per-point opacity array read back correctly but didn't render. Fix: split into two traces (`selected` @ opacity 1 + `unselected` @ opacity 0.05) drawn in that order so selected layers on top. Both traces share `cmin`/`cmax` so the Viridis colorscale stays consistent. The 3D click handler now reads `pt.customdata[0]` (the poster_id) directly instead of mapping `pointIndex` against a single trace — robust to whether the chart has 1 or 2 traces at click time. Verified locally with a real-mouse lasso: trace count goes 1 → 2, 2717 selected + 526 unselected, camera preserved across the lasso. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- scripts/compute_neighbors.py | 160 +++++++++++++++++++++++ site/src/lib/components/UmapPanel.svelte | 101 ++++++++------ src/ohbm2026/ui_data/builder.py | 18 ++- src/ohbm2026/ui_data/neighbors.py | 105 +++++++++++++++ 4 files changed, 344 insertions(+), 40 deletions(-) create mode 100755 scripts/compute_neighbors.py create mode 100644 src/ohbm2026/ui_data/neighbors.py diff --git a/scripts/compute_neighbors.py b/scripts/compute_neighbors.py new file mode 100755 index 00000000..7482dd31 --- /dev/null +++ b/scripts/compute_neighbors.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python +"""Compute k-nearest + k-farthest neighbors per (model, input) cell. + +For each `data/outputs/analysis/<cell_key>/projections__<state-key>/` bundle +this script loads `ids.npy` + `reference_matrix.npy`, computes the pairwise +cosine-distance matrix, and writes: + + data/outputs/analysis/<cell_key>/neighbors__<state-key>/ + ids.npy (N,) int64 abstract_id per row + nearest_ids.npy (N, K) int64 abstract_id of k-th nearest (excl. self) + nearest_distances.npy (N, K) float32 corresponding distance + farthest_ids.npy (N, K) int64 + farthest_distances.npy (N, K) float32 + provenance.json run metadata + state-keys + +K defaults to 10. Cosine distance is `1 - cos(u, v)` on the L2-normalized +embeddings; `M @ M.T` gives the cosine similarity matrix in one BLAS call. + +Run via: + PYTHONPATH=src .venv/bin/python scripts/compute_neighbors.py \ + --analysis-root data/outputs/analysis [--k 10] +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +import numpy as np + + +def _argmin_k(distances: np.ndarray, k: int, exclude: int) -> np.ndarray: + """Indices of the *k* smallest values in `distances`, skipping `exclude` (the row's own index).""" + + n = distances.shape[0] + # argpartition is O(N) per row; take k+1 to leave room for the self-skip. + take = min(n, k + 1) + candidate_idx = np.argpartition(distances, take - 1)[:take] + # Sort by actual distance for deterministic order. + candidate_idx = candidate_idx[np.argsort(distances[candidate_idx])] + out = candidate_idx[candidate_idx != exclude][:k] + return out + + +def _argmax_k(distances: np.ndarray, k: int) -> np.ndarray: + """Indices of the *k* largest values.""" + + n = distances.shape[0] + take = min(n, k) + candidate_idx = np.argpartition(distances, n - take)[n - take:] + candidate_idx = candidate_idx[np.argsort(-distances[candidate_idx])] + return candidate_idx[:k] + + +def compute_cell_neighbors( + bundle_dir: Path, k: int = 10 +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return `(ids, nearest_ids, nearest_dist, farthest_ids, farthest_dist)`.""" + + ids = np.load(bundle_dir / "ids.npy").astype(np.int64) + matrix = np.load(bundle_dir / "reference_matrix.npy").astype(np.float32) + n, d = matrix.shape + if ids.shape[0] != n: + raise RuntimeError(f"shape mismatch in {bundle_dir}: ids={ids.shape}, matrix={matrix.shape}") + # L2-normalize → cosine similarity is just M @ M.T. + norms = np.linalg.norm(matrix, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + normed = matrix / norms + sim = normed @ normed.T # (N, N) + np.clip(sim, -1.0, 1.0, out=sim) + dist = 1.0 - sim # cosine distance + nearest_ids = np.zeros((n, k), dtype=np.int64) + nearest_dist = np.zeros((n, k), dtype=np.float32) + farthest_ids = np.zeros((n, k), dtype=np.int64) + farthest_dist = np.zeros((n, k), dtype=np.float32) + for i in range(n): + n_idx = _argmin_k(dist[i], k=k, exclude=i) + f_idx = _argmax_k(dist[i], k=k) + nearest_ids[i, : len(n_idx)] = ids[n_idx] + nearest_dist[i, : len(n_idx)] = dist[i, n_idx] + farthest_ids[i, : len(f_idx)] = ids[f_idx] + farthest_dist[i, : len(f_idx)] = dist[i, f_idx] + return ids, nearest_ids, nearest_dist, farthest_ids, farthest_dist + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--analysis-root", + type=Path, + default=Path("data/outputs/analysis"), + help="Root containing per-cell <cell_key>/projections__*/ bundles.", + ) + parser.add_argument("--k", type=int, default=10, help="Number of nearest + farthest to store.") + parser.add_argument( + "--force", + action="store_true", + help="Recompute even if the neighbors__<state-key>/ output already exists.", + ) + args = parser.parse_args(argv) + + analysis_root = args.analysis_root + if not analysis_root.exists(): + print(f"analysis-root {analysis_root} does not exist", file=sys.stderr) + return 2 + cell_dirs = sorted(p for p in analysis_root.iterdir() if p.is_dir() and "_" in p.name) + if not cell_dirs: + print(f"no cell directories under {analysis_root}", file=sys.stderr) + return 2 + + total_start = time.time() + n_done = 0 + for cell_dir in cell_dirs: + proj = sorted(cell_dir.glob("projections__*")) + if not proj: + continue + bundle = proj[-1] + state_key = bundle.name.split("__", 1)[1] + out_dir = cell_dir / f"neighbors__{state_key}" + if out_dir.exists() and not args.force: + print(f"skip {cell_dir.name}: neighbors already at {out_dir}") + continue + out_dir.mkdir(parents=True, exist_ok=True) + + t0 = time.time() + try: + ids, n_ids, n_dist, f_ids, f_dist = compute_cell_neighbors(bundle, k=args.k) + except FileNotFoundError as exc: + print(f"skip {cell_dir.name}: missing input {exc}", file=sys.stderr) + continue + np.save(out_dir / "ids.npy", ids) + np.save(out_dir / "nearest_ids.npy", n_ids) + np.save(out_dir / "nearest_distances.npy", n_dist) + np.save(out_dir / "farthest_ids.npy", f_ids) + np.save(out_dir / "farthest_distances.npy", f_dist) + provenance = { + "cell_key": cell_dir.name, + "state_key": state_key, + "k": args.k, + "n": int(ids.shape[0]), + "distance_metric": "cosine", + "source_bundle": str(bundle.relative_to(analysis_root.parent.parent)) + if analysis_root.is_absolute() is False + else str(bundle), + "built_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + (out_dir / "provenance.json").write_text(json.dumps(provenance, indent=2) + "\n") + n_done += 1 + print(f" {cell_dir.name:30s} N={ids.shape[0]} k={args.k} {time.time() - t0:.2f}s") + + print(f"done — {n_done} cells / {time.time() - total_start:.2f}s total") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/site/src/lib/components/UmapPanel.svelte b/site/src/lib/components/UmapPanel.svelte index acba97a4..bef3018f 100644 --- a/site/src/lib/components/UmapPanel.svelte +++ b/site/src/lib/components/UmapPanel.svelte @@ -290,34 +290,61 @@ ) { if (!api || !el || !shard) return; const s = buildSeries(shard, records, selected, topicMap); - // 3D doesn't honor `selectedpoints` reliably — encode the selection via - // per-point opacity so unselected points dim out without resetting the - // camera. When nothing is selected, use a constant 0.85. - let opacityArr: number[] | undefined; - if (selected !== null && s.selectedIdx.length) { - const selSet = new Set(s.selectedIdx); - opacityArr = s.xs3.map((_, i) => (selSet.has(i) ? 1 : 0.08)); + // scatter3d only accepts a SCALAR marker.opacity; per-point arrays are + // silently ignored. Split the data into two traces (selected + + // unselected), each with its own scalar opacity, so the selection + // visually dims the unselected points. + const hasSelection = selected !== null && s.selectedIdx.length > 0; + const selSet = hasSelection ? new Set(s.selectedIdx) : null; + const partition = (i: number) => (!selSet || selSet.has(i) ? 'sel' : 'unsel'); + // Collect indices for each partition. + const traces: unknown[] = []; + const customdata = s.posters.map((p, i) => [p, s.titles[i], s.communityLabels[i]]); + // Find the global color range so both traces share the same scale. + const cmin = Math.min(...s.colors); + const cmax = Math.max(...s.colors); + function buildTrace(name: string, indices: number[], opacity: number, showColorbar: boolean) { + if (indices.length === 0) return null; + return { + type: 'scatter3d' as const, + mode: 'markers' as const, + name, + x: indices.map((i) => s.xs3[i]), + y: indices.map((i) => s.ys3[i]), + z: indices.map((i) => s.zs3[i]), + marker: { + size: 3, + color: indices.map((i) => s.colors[i]), + colorscale: 'Viridis', + cmin, + cmax, + opacity, + showscale: showColorbar, + line: { width: 0 } + }, + customdata: indices.map((i) => customdata[i]) as unknown as number[][], + hovertemplate: + '<b>%{customdata[0]}</b><br>%{customdata[1]}<br><i>%{customdata[2]}</i><extra></extra>', + hoverinfo: opacity < 0.3 ? 'skip' : undefined, + showlegend: false + }; } - const t1 = { - type: 'scatter3d' as const, - mode: 'markers' as const, - x: s.xs3, - y: s.ys3, - z: s.zs3, - marker: { - size: 3, - color: s.colors, - colorscale: 'Viridis', - opacity: opacityArr ? undefined : 0.85, - line: { width: 0 } - } as Record<string, unknown>, - customdata: s.posters.map((p, i) => [p, s.titles[i], s.communityLabels[i]]) as unknown as number[][], - hovertemplate: - '<b>%{customdata[0]}</b><br>%{customdata[1]}<br><i>%{customdata[2]}</i><extra></extra>' - }; - if (opacityArr) { - // scatter3d supports per-point opacity via marker.opacity as an array. - (t1.marker as { opacity?: number[] }).opacity = opacityArr; + if (!hasSelection) { + const all = s.xs3.map((_: number, i: number) => i); + const t = buildTrace('all', all, 0.85, false); + if (t) traces.push(t); + } else { + const selIdx: number[] = []; + const unselIdx: number[] = []; + for (let i = 0; i < s.xs3.length; i++) { + if (partition(i) === 'sel') selIdx.push(i); + else unselIdx.push(i); + } + // Draw unselected first so selected layer renders on top. + const tUnsel = buildTrace('unselected', unselIdx, 0.05, false); + if (tUnsel) traces.push(tUnsel); + const tSel = buildTrace('selected', selIdx, 1, false); + if (tSel) traces.push(tSel); } const c = themedColors(t); const axisCfg = { visible: false, showbackground: false }; @@ -361,24 +388,22 @@ displaylogo: false }; (api as unknown as { react: (...args: unknown[]) => Promise<unknown> }) - .react(el, [t1], layout, config) + .react(el, traces, layout, config) .then(() => { chart3dInitialized = true; if (!handlers3dAttached) { handlers3dAttached = true; const node = el as unknown as { on: (e: string, h: (e: unknown) => void) => void }; node.on('plotly_click', (e: unknown) => { - const ev = e as { points?: Array<{ pointIndex: number }> } | null; + const ev = e as { + points?: Array<{ customdata?: [string, string, string] }>; + } | null; const pt = ev?.points?.[0]; - if (!pt) return; - const shardNow = cellShard; - if (!shardNow) return; - const visibleRows = shardNow.rows.filter((r) => !r.umap_missing); - const row = visibleRows[pt.pointIndex]; - if (!row) return; - const idxInAbstracts = shardNow.rows.indexOf(row); - const rec = records[idxInAbstracts]; - if (rec?.poster_id) $focusedAbstract = rec.poster_id; + // 3D may have 1 or 2 traces depending on selection — read + // the poster_id straight off customdata so trace + // partitioning doesn't affect the click target. + const posterId = pt?.customdata?.[0]; + if (posterId) $focusedAbstract = posterId; }); } ensureRotate(); diff --git a/src/ohbm2026/ui_data/builder.py b/src/ohbm2026/ui_data/builder.py index 69c1487b..a40bde50 100644 --- a/src/ohbm2026/ui_data/builder.py +++ b/src/ohbm2026/ui_data/builder.py @@ -23,6 +23,7 @@ from ohbm2026.ui_data.authors import build_authors from ohbm2026.ui_data.cells import build_cells from ohbm2026.ui_data.manifest import build_manifest, make_build_info +from ohbm2026.ui_data.neighbors import build_neighbors from ohbm2026.ui_data.state_key import ( Stage6BuildError, discover_rollup_state_key, @@ -140,6 +141,17 @@ def build_ui_data_package( build_info=build_info, ) + # Neighbors are optional (run `scripts/compute_neighbors.py` first). + # When the analysis bundles for some cells are missing, those cells just + # don't get a neighbors shard — the UI degrades gracefully. + neighbors_envelopes: dict[str, Any] = {} + if analysis_root is not None: + neighbors_envelopes = build_neighbors( + analysis_root=Path(analysis_root), + cell_keys=list(cells_envelopes.keys()), + build_info=build_info, + ) + # --- 8 cross-shard invariants (data-model.md §8) --------------------- _validate_invariants( manifest=manifest, @@ -172,6 +184,8 @@ def _emit(rel: str, payload: Mapping[str, Any]) -> None: _emit(f"cells/{cell_key}.json", envelope) for (model, inp, kind), envelope in topics_envelopes.items(): _emit(f"topics/{model}_{inp}_{kind}.json", envelope) + for cell_key, envelope in neighbors_envelopes.items(): + _emit(f"neighbors/{cell_key}.json", envelope) _emit( "search/minilm_vectors.build_info.json", { @@ -189,8 +203,8 @@ def _emit(rel: str, payload: Mapping[str, Any]) -> None: if path in expected: continue path.unlink() - # Also remove now-empty cells/topics/search subdirs (defensive). - for sub in ("cells", "topics", "search"): + # Also remove now-empty subdirs (defensive). + for sub in ("cells", "topics", "search", "neighbors"): d = output / sub if d.exists() and not any(d.iterdir()): d.rmdir() diff --git a/src/ohbm2026/ui_data/neighbors.py b/src/ohbm2026/ui_data/neighbors.py new file mode 100644 index 00000000..da04f82e --- /dev/null +++ b/src/ohbm2026/ui_data/neighbors.py @@ -0,0 +1,105 @@ +"""Build ``data/neighbors/<cell_key>.json`` shards for Stage 6. + +Each shard carries the per-(model, input) k-nearest + k-farthest neighbor +indices + cosine distances computed offline by ``scripts/compute_neighbors.py`` +and stored under ``data/outputs/analysis/<cell_key>/neighbors__<state-key>/``. + +Shard shape (array-form for compact JSON): + + { + "schema_version": "neighbors.v1", + "build_info": { ... }, + "cell_key": "neuroscape_abstract", + "k": 10, + "abstract_ids": [ ...N ], + "nearest_ids": [ [ ...10 ], ...N ], + "nearest_distances": [ [ ...10 ], ...N ], + "farthest_ids": [ [ ...10 ], ...N ], + "farthest_distances": [ [ ...10 ], ...N ] + } + +The UI joins by position: ``abstract_ids[i]`` corresponds to row *i* of the +matching cells shard, so given an abstract the client looks up its row index +in the cell shard, then reads the same row of the neighbors shard. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + +from ohbm2026.ui_data.state_key import Stage6BuildError + +SCHEMA_VERSION = "neighbors.v1" + + +def _round_floats(values: Iterable[float], precision: int = 4) -> list[float]: + return [round(float(v), precision) for v in values] + + +def _load_per_cell_neighbors( + analysis_root: Path, cell_key: str +) -> dict[str, Any] | None: + """Return the neighbor arrays for *cell_key*, or None when no bundle exists.""" + + cell_dir = Path(analysis_root) / cell_key + if not cell_dir.exists(): + return None + candidates = sorted(cell_dir.glob("neighbors__*")) + if not candidates: + return None + bundle = candidates[-1] + try: + import numpy as np # type: ignore[import-not-found] + except ImportError: + return None + try: + ids = np.load(bundle / "ids.npy") + nearest_ids = np.load(bundle / "nearest_ids.npy") + nearest_dist = np.load(bundle / "nearest_distances.npy") + farthest_ids = np.load(bundle / "farthest_ids.npy") + farthest_dist = np.load(bundle / "farthest_distances.npy") + except FileNotFoundError: + return None + return { + "ids": [int(x) for x in ids.tolist()], + "nearest_ids": [[int(x) for x in row] for row in nearest_ids.tolist()], + "nearest_distances": [_round_floats(row) for row in nearest_dist.tolist()], + "farthest_ids": [[int(x) for x in row] for row in farthest_ids.tolist()], + "farthest_distances": [_round_floats(row) for row in farthest_dist.tolist()], + "k": int(nearest_ids.shape[1]) if nearest_ids.ndim == 2 else 0, + } + + +def build_neighbors( + *, + analysis_root: Path, + cell_keys: Iterable[str], + build_info: Mapping[str, str], +) -> dict[str, dict[str, Any]]: + """Return ``{cell_key: envelope}`` per cell, skipping cells without a + pre-computed neighbors bundle. The skip behavior keeps the build + resilient when ``compute_neighbors.py`` has only run for some cells. + """ + + if not Path(analysis_root).exists(): + raise Stage6BuildError(f"analysis root not found: {analysis_root}") + out: dict[str, dict[str, Any]] = {} + for cell_key in cell_keys: + payload = _load_per_cell_neighbors(analysis_root, cell_key) + if payload is None: + continue + envelope = { + "schema_version": SCHEMA_VERSION, + "build_info": dict(build_info), + "cell_key": cell_key, + "k": payload["k"], + "abstract_ids": payload["ids"], + "nearest_ids": payload["nearest_ids"], + "nearest_distances": payload["nearest_distances"], + "farthest_ids": payload["farthest_ids"], + "farthest_distances": payload["farthest_distances"], + } + out[cell_key] = envelope + return out From a24cf0fb39ccaf10e04645dda7b2125ff365b90d Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 11:23:20 -0400 Subject: [PATCH 12/48] feat(stage6): surface neighbors in the detail panel as "Related abstracts" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the per-(model, input) k=10 nearest / farthest shards committed in the prior commit into the abstract detail panel. The panel: - Loads `data/neighbors/<cell_key>.json` from the runtime data package whenever the focused abstract or the selected cell changes. - Renders two blocks under "Related abstracts — from <cell_key>": "Most similar" (top 5 of the 10 nearest) and "Most different" (top 5 of the 10 farthest), each row showing rank · poster_id · title · cosine distance. - Each entry is a button that switches focus to that abstract — a one- click way to walk the embedding neighborhood. Model-switch behavior: the related list re-fetches when the user changes the (model, input) cell, so the "neighbors according to voyage_abstract" become "neighbors according to neuroscape_claims" on switch. `abstractsById` map plumbed through from `+page.svelte` and the permalink route so the panel can resolve neighbor records to title + poster_id without a second round trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- site/src/lib/components/DetailPanel.svelte | 188 +++++++++++++++++- site/src/lib/shards.ts | 16 ++ site/src/routes/+page.svelte | 4 +- .../routes/abstract/[poster_id]/+page.svelte | 4 +- 4 files changed, 208 insertions(+), 4 deletions(-) diff --git a/site/src/lib/components/DetailPanel.svelte b/site/src/lib/components/DetailPanel.svelte index 0498dbc0..b79d500d 100644 --- a/site/src/lib/components/DetailPanel.svelte +++ b/site/src/lib/components/DetailPanel.svelte @@ -1,10 +1,11 @@ <script lang="ts"> - import { focusedAbstract } from '$lib/stores/selection'; + import { focusedAbstract, selectedCell } from '$lib/stores/selection'; import { cartStore } from '$lib/stores/cart'; - import type { AbstractRecord, AuthorRecord } from '$lib/shards'; + import { loadNeighbors, type AbstractRecord, type AuthorRecord, type NeighborsShard } from '$lib/shards'; export let abstract: AbstractRecord | null = null; export let authorsById: Map<number, AuthorRecord>; + export let abstractsById: Map<number, AbstractRecord> = new Map(); export let dismissable = true; let showAllAuthors = false; @@ -23,6 +24,56 @@ function inCart(posterId: string): boolean { return $cartStore.has(posterId); } + + // --- Related abstracts via the per-cell neighbors shard -------------- + $: cellKey = `${$selectedCell.model}_${$selectedCell.input}`; + let neighborsShard: NeighborsShard | null = null; + let neighborsLoading = false; + + $: void (async () => { + const key = cellKey; + neighborsLoading = true; + const shard = await loadNeighbors(key); + if (key === cellKey) { + neighborsShard = shard; + neighborsLoading = false; + } + })(); + + type RelatedEntry = { abstract: AbstractRecord; distance: number }; + + function buildRelated( + shard: NeighborsShard | null, + focusedId: number | undefined, + kind: 'nearest' | 'farthest', + limit = 5 + ): RelatedEntry[] { + if (!shard || focusedId === undefined) return []; + const row = shard.abstract_ids.indexOf(focusedId); + if (row < 0) return []; + const ids = kind === 'nearest' ? shard.nearest_ids[row] : shard.farthest_ids[row]; + const dist = kind === 'nearest' ? shard.nearest_distances[row] : shard.farthest_distances[row]; + const out: RelatedEntry[] = []; + for (let i = 0; i < Math.min(ids.length, limit); i++) { + const rec = abstractsById.get(ids[i]); + if (rec) out.push({ abstract: rec, distance: dist[i] }); + } + return out; + } + + $: focusedId = abstract?.abstract_id; + $: nearest = buildRelated(neighborsShard, focusedId, 'nearest', 5); + $: farthest = buildRelated(neighborsShard, focusedId, 'farthest', 5); + + function focusRelated(posterId: string) { + if (posterId) $focusedAbstract = posterId; + } + + function leadAuthor(record: AbstractRecord): string { + const id = record.author_ids[0]; + if (id === undefined) return ''; + return authorsById.get(id)?.name ?? ''; + } </script> {#if abstract} @@ -143,6 +194,66 @@ </section> {/if} + {#if neighborsShard && (nearest.length || farthest.length)} + <section class="related" data-testid="detail-related"> + <h2> + Related abstracts + <span class="muted">— from <code>{cellKey}</code></span> + </h2> + {#if nearest.length} + <div class="related-block"> + <h3 class="related-heading">Most similar</h3> + <ul class="related-list"> + {#each nearest as entry, i (entry.abstract.abstract_id)} + <li> + <button + type="button" + class="related-link" + on:click={() => focusRelated(entry.abstract.poster_id)} + disabled={!entry.abstract.poster_id} + data-testid="related-nearest" + > + <span class="related-rank">#{i + 1}</span> + <span class="related-poster">{entry.abstract.poster_id || '—'}</span> + <span class="related-title">{entry.abstract.title}</span> + <span class="related-distance" title="cosine distance">d={entry.distance.toFixed(3)}</span> + </button> + </li> + {/each} + </ul> + </div> + {/if} + {#if farthest.length} + <div class="related-block"> + <h3 class="related-heading">Most different</h3> + <ul class="related-list"> + {#each farthest as entry, i (entry.abstract.abstract_id)} + <li> + <button + type="button" + class="related-link" + on:click={() => focusRelated(entry.abstract.poster_id)} + disabled={!entry.abstract.poster_id} + data-testid="related-farthest" + > + <span class="related-rank">#{i + 1}</span> + <span class="related-poster">{entry.abstract.poster_id || '—'}</span> + <span class="related-title">{entry.abstract.title}</span> + <span class="related-distance" title="cosine distance">d={entry.distance.toFixed(3)}</span> + </button> + </li> + {/each} + </ul> + </div> + {/if} + </section> + {:else if neighborsLoading} + <section class="related" data-testid="detail-related-loading"> + <h2>Related abstracts</h2> + <p class="muted">Loading neighbors…</p> + </section> + {/if} + {#if abstract.reference_urls.some(Boolean) || abstract.reference_dois.some(Boolean) || (abstract.reference_titles ?? []).some(Boolean)} <section class="references" data-testid="detail-references"> <h2>References</h2> @@ -371,4 +482,77 @@ color: var(--accent); text-decoration: underline; } + .related h2 { + display: flex; + gap: 0.4rem; + align-items: baseline; + } + .related h2 code { + font-size: 0.7rem; + text-transform: none; + letter-spacing: 0; + font-weight: 400; + } + .related-block { + margin-top: 0.4rem; + } + .related-heading { + margin: 0 0 0.25rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-faint); + font-weight: 600; + } + .related-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + .related-link { + all: unset; + cursor: pointer; + display: grid; + grid-template-columns: 2rem minmax(4rem, 6rem) 1fr auto; + gap: 0.5rem; + align-items: baseline; + padding: 0.3rem 0.5rem; + border-radius: 4px; + border: 1px solid transparent; + font-size: 0.85rem; + color: var(--text); + } + .related-link:hover { + background: var(--bg-sunken); + border-color: var(--border); + } + .related-link:disabled { + opacity: 0.4; + cursor: not-allowed; + } + .related-rank { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + color: var(--text-faint); + } + .related-poster { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8rem; + color: var(--accent); + font-weight: 600; + } + .related-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + } + .related-distance { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + color: var(--text-muted); + } </style> diff --git a/site/src/lib/shards.ts b/site/src/lib/shards.ts index b632886d..181d12d8 100644 --- a/site/src/lib/shards.ts +++ b/site/src/lib/shards.ts @@ -132,6 +132,18 @@ export interface TopicShard { topics: TopicRecord[]; } +export interface NeighborsShard { + schema_version: string; + build_info: BuildInfo; + cell_key: string; + k: number; + abstract_ids: number[]; + nearest_ids: number[][]; + nearest_distances: number[][]; + farthest_ids: number[][]; + farthest_distances: number[][]; +} + /** * Per-(shard kind) lookups now read from a single in-memory `Map<path, json>` * built by `loadDataPackage()` on first paint. The path keys are tar-relative, @@ -173,6 +185,10 @@ export function loadTopics(cellKey: string, kind: string): Promise<TopicShard | return getFromPackage<TopicShard>(`data/topics/${cellKey}_${kind}.json`); } +export function loadNeighbors(cellKey: string): Promise<NeighborsShard | null> { + return getFromPackage<NeighborsShard>(`data/neighbors/${cellKey}.json`); +} + export function resetCachesForTests(): void { // Caches now live on the data_package module; reset there. } diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index 6a7c3d19..b626ef55 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -22,6 +22,7 @@ let abstracts: AbstractRecord[] = []; let authorsById: Map<number, AuthorRecord> = new Map(); let abstractsByPosterId: Map<string, AbstractRecord> = new Map(); + let abstractsById: Map<number, AbstractRecord> = new Map(); let loaded = false; let dataMissing = false; let showMap = false; @@ -36,6 +37,7 @@ abstractsByPosterId = new Map( a.abstracts.filter((x) => x.poster_id).map((x) => [x.poster_id, x]) ); + abstractsById = new Map(a.abstracts.map((x) => [x.abstract_id, x])); // Test-only debug global used by Playwright accepted-only invariant guard. if (typeof window !== 'undefined') { (window as unknown as { __abstracts: AbstractRecord[] }).__abstracts = abstracts; @@ -112,7 +114,7 @@ </div> <div class="detail-pane" class:active={focused !== null}> {#if focused} - <DetailPanel abstract={focused} {authorsById} /> + <DetailPanel abstract={focused} {authorsById} {abstractsById} /> {:else} <aside class="detail-empty"> <p>Tap an abstract to see its details here.</p> diff --git a/site/src/routes/abstract/[poster_id]/+page.svelte b/site/src/routes/abstract/[poster_id]/+page.svelte index 3910b0c9..7cde4015 100644 --- a/site/src/routes/abstract/[poster_id]/+page.svelte +++ b/site/src/routes/abstract/[poster_id]/+page.svelte @@ -12,6 +12,7 @@ let abstractRecord: AbstractRecord | null = null; let authorsById: Map<number, AuthorRecord> = new Map(); + let abstractsById: Map<number, AbstractRecord> = new Map(); let loaded = false; let unknown = false; @@ -25,6 +26,7 @@ return; } authorsById = new Map(au.authors.map((x) => [x.author_id, x])); + abstractsById = new Map(a.abstracts.map((x) => [x.abstract_id, x])); const target = a.abstracts.find((x) => x.poster_id === posterId) ?? null; abstractRecord = target; unknown = target === null; @@ -57,7 +59,7 @@ </p> </section> {:else if abstractRecord} - <DetailPanel abstract={abstractRecord} {authorsById} dismissable={false} /> + <DetailPanel abstract={abstractRecord} {authorsById} {abstractsById} dismissable={false} /> {/if} </div> From 1d75574b11ecb1a942af92d3391a45d8eee27258 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 11:29:26 -0400 Subject: [PATCH 13/48] feat(stage6): typo-tolerant lexical search (FR-008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the substring-based search with Damerau-Levenshtein typo tolerance over a per-corpus inverted index. The implementation: - `lib/filter.ts:lexicalSearch(...)`: lazily builds an in-memory `token → Set<abstract_id>` index over the corpus's title, poster_id, topics, methods checklist, author names, and facet values. For each query token, walks the ~10k unique corpus tokens and matches any within Damerau-Levenshtein distance ≤ 2 (≤ 1 for tokens < 4 chars, per FR-008). Multi-token queries intersect across tokens. - `damerauLevenshtein` is a 30-line DP implementation with adjacent- transposition support and an early-exit when the running row min exceeds the threshold. Length-difference pre-filter shaves the hot path further. - Diacritic-insensitive via NFD-normalize-+-fold (already in `normalize()` — query 'Garcia' matches author 'García' as a 0-distance match after folding). At the 3243-abstract scale brute force over the unique-token list is fast enough (sub-millisecond per query token in the typical case); the trigram-bucket pre-filter from the original spec design isn't needed yet — revisit if SC-002 regresses. Live smoke against the deployed corpus: - 'connectivity' → 736 (exact) - 'connectvity' (1 typo)→ 734 (matches the typo case) - 'defautl mode netwrk' (3 typos) → 16 - 'default mode network' (exact) → 16 — same set - 'Smtih' (transposition) → 56 (Smith-named authors) - 'Garcia' (diacritic fold)→ 166 (García authors) - 'xyzpdqzzz' (gibberish) → 0 Tests: 15 new Vitest cases covering damerauLevenshtein, tokenization, exact + typo + multi-word intersect + spec examples. Total Vitest suite now 37/37 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- site/src/lib/components/SearchBar.svelte | 2 +- site/src/lib/filter.ts | 159 +++++++++++++++++++++++ site/src/routes/+page.svelte | 4 +- site/src/tests/unit/lexical.test.ts | 127 ++++++++++++++++++ specs/008-ui-rewrite/tasks.md | 4 +- 5 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 site/src/tests/unit/lexical.test.ts diff --git a/site/src/lib/components/SearchBar.svelte b/site/src/lib/components/SearchBar.svelte index f32e21db..78d01ad7 100644 --- a/site/src/lib/components/SearchBar.svelte +++ b/site/src/lib/components/SearchBar.svelte @@ -15,7 +15,7 @@ id="search-input" type="search" bind:value - placeholder="Search by keyword, author, topic, or poster id…" + placeholder="Search keyword, author, topic, poster id… (typos OK)" autocomplete="off" spellcheck="false" data-testid="search-input" diff --git a/site/src/lib/filter.ts b/site/src/lib/filter.ts index 9916bb73..c89d53d8 100644 --- a/site/src/lib/filter.ts +++ b/site/src/lib/filter.ts @@ -8,6 +8,165 @@ export function normalize(value: string): string { .toLowerCase(); } +/** + * Damerau-Levenshtein distance with early-exit when the running minimum + * exceeds `maxDist`. Returns `maxDist + 1` instead of the full distance in + * that case (cheap signal that's > threshold). + */ +export function damerauLevenshtein(a: string, b: string, maxDist = 2): number { + if (a === b) return 0; + const la = a.length; + const lb = b.length; + if (Math.abs(la - lb) > maxDist) return maxDist + 1; + if (la === 0) return lb; + if (lb === 0) return la; + let prev2: number[] = []; + let prev: number[] = new Array(lb + 1); + let curr: number[] = new Array(lb + 1); + for (let j = 0; j <= lb; j++) prev[j] = j; + for (let i = 1; i <= la; i++) { + curr[0] = i; + let rowMin = i; + for (let j = 1; j <= lb; j++) { + const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1; + curr[j] = Math.min( + curr[j - 1] + 1, // insert + prev[j] + 1, // delete + prev[j - 1] + cost // substitute + ); + // transposition + if ( + i > 1 && + j > 1 && + a.charCodeAt(i - 1) === b.charCodeAt(j - 2) && + a.charCodeAt(i - 2) === b.charCodeAt(j - 1) + ) { + curr[j] = Math.min(curr[j], prev2[j - 2] + 1); + } + if (curr[j] < rowMin) rowMin = curr[j]; + } + if (rowMin > maxDist) return maxDist + 1; + prev2 = prev; + prev = curr; + curr = new Array(lb + 1); + } + return prev[lb]; +} + +/** Tokenize for the inverted index: keep alnum runs ≥ 2 chars after normalization. */ +export function tokenizeForIndex(text: string): string[] { + return normalize(text) + .split(/[^a-z0-9]+/) + .filter((t) => t.length >= 2); +} + +interface InvertedIndex { + tokens: string[]; + postings: Map<string, Set<number>>; +} + +const invertedIndexCache = new WeakMap<AbstractRecord[], InvertedIndex>(); + +function buildInvertedIndex( + abstracts: AbstractRecord[], + authorsById: Map<number, AuthorRecord> +): InvertedIndex { + const cached = invertedIndexCache.get(abstracts); + if (cached) return cached; + const postings = new Map<string, Set<number>>(); + for (const a of abstracts) { + const authorNames = a.author_ids + .map((id) => authorsById.get(id)?.name ?? '') + .filter(Boolean) + .join(' '); + const facetBlob = Object.values(a.facets) + .map((v) => (Array.isArray(v) ? v.join(' ') : (v as string))) + .filter(Boolean) + .join(' '); + const corpus = [ + a.title, + a.poster_id, + a.topics.primary, + a.topics.primary_subcategory, + a.topics.secondary, + a.topics.secondary_subcategory, + a.methods_checklist.join(' '), + authorNames, + facetBlob + ].join(' '); + const seen = new Set<string>(); + for (const tok of tokenizeForIndex(corpus)) { + if (seen.has(tok)) continue; + seen.add(tok); + let postingList = postings.get(tok); + if (!postingList) { + postingList = new Set(); + postings.set(tok, postingList); + } + postingList.add(a.abstract_id); + } + } + const index = { tokens: [...postings.keys()], postings }; + invertedIndexCache.set(abstracts, index); + return index; +} + +/** Distance threshold per token length, per FR-008. */ +function thresholdFor(token: string): number { + return token.length < 4 ? 1 : 2; +} + +/** + * Typo-tolerant lexical search across the per-abstract inverted index. + * For multi-word queries every query token must match at least one corpus + * token within its Damerau-Levenshtein threshold; the abstract sets are + * intersected so all words contribute. + * + * Returns `null` for an empty query (= "show everything", same convention + * as the substring search) or a Set of matching `abstract_id`s. + */ +export function lexicalSearch( + abstracts: AbstractRecord[], + authorsById: Map<number, AuthorRecord>, + query: string +): Set<number> | null { + const q = normalize(query).trim(); + if (!q) return null; + const queryTokens = tokenizeForIndex(q); + if (queryTokens.length === 0) return new Set(); + const index = buildInvertedIndex(abstracts, authorsById); + const perTokenMatches: Set<number>[] = []; + for (const qt of queryTokens) { + const threshold = thresholdFor(qt); + const matchedAbstractIds = new Set<number>(); + for (const corpusToken of index.tokens) { + if (Math.abs(corpusToken.length - qt.length) > threshold) continue; + // Exact substring match (either direction) counts as 0-distance. + if (corpusToken === qt || corpusToken.includes(qt) || qt.includes(corpusToken)) { + const posting = index.postings.get(corpusToken); + if (posting) for (const id of posting) matchedAbstractIds.add(id); + continue; + } + if (damerauLevenshtein(qt, corpusToken, threshold) <= threshold) { + const posting = index.postings.get(corpusToken); + if (posting) for (const id of posting) matchedAbstractIds.add(id); + } + } + perTokenMatches.push(matchedAbstractIds); + } + if (perTokenMatches.length === 0) return new Set(); + // Intersect all per-token sets (AND semantics). + let final = perTokenMatches[0]; + for (let i = 1; i < perTokenMatches.length; i++) { + const next = new Set<number>(); + const probe = perTokenMatches[i]; + for (const id of final) if (probe.has(id)) next.add(id); + final = next; + if (final.size === 0) break; + } + return final; +} + interface SearchHaystack { abstract_id: number; haystack: string; diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index b626ef55..d2813609 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -11,7 +11,7 @@ type Manifest } from '$lib/shards'; import { focusedAbstract, lassoSelection, searchQuery } from '$lib/stores/selection'; - import { searchAbstracts } from '$lib/filter'; + import { lexicalSearch } from '$lib/filter'; import SearchBar from '$lib/components/SearchBar.svelte'; import ResultList from '$lib/components/ResultList.svelte'; import DetailPanel from '$lib/components/DetailPanel.svelte'; @@ -48,7 +48,7 @@ loaded = true; }); - $: searchIds = searchAbstracts(abstracts, authorsById, $searchQuery); + $: searchIds = lexicalSearch(abstracts, authorsById, $searchQuery); $: filteredIds = intersect(searchIds, $lassoSelection); $: focused = $focusedAbstract ? (abstractsByPosterId.get($focusedAbstract) ?? null) : null; diff --git a/site/src/tests/unit/lexical.test.ts b/site/src/tests/unit/lexical.test.ts new file mode 100644 index 00000000..3d65c030 --- /dev/null +++ b/site/src/tests/unit/lexical.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { damerauLevenshtein, lexicalSearch, tokenizeForIndex } from '$lib/filter'; +import type { AbstractRecord, AuthorRecord } from '$lib/shards'; + +describe('damerauLevenshtein', () => { + it('returns 0 for identical strings', () => { + expect(damerauLevenshtein('memory', 'memory')).toBe(0); + }); + + it('counts a single substitution', () => { + expect(damerauLevenshtein('memory', 'memora')).toBe(1); + }); + + it('counts a single deletion', () => { + expect(damerauLevenshtein('memory', 'memry')).toBe(1); + }); + + it('counts a single transposition (Damerau)', () => { + // "smtih" → "smith" is one adjacent transposition + expect(damerauLevenshtein('smtih', 'smith')).toBe(1); + }); + + it('early-exits past the threshold', () => { + // Garbage query vs corpus token — must be > 2 + expect(damerauLevenshtein('xyzpdq', 'memory', 2)).toBe(3); + }); + + it('rejects pairs whose length difference exceeds the threshold', () => { + expect(damerauLevenshtein('mem', 'memorial', 2)).toBe(3); + }); +}); + +describe('tokenizeForIndex', () => { + it('lowercases + accent-folds + drops 1-char tokens', () => { + expect(tokenizeForIndex('Memory fMRI in García')).toEqual(['memory', 'fmri', 'in', 'garcia']); + }); + + it('handles punctuation as delimiters', () => { + expect(tokenizeForIndex('default-mode network!')).toEqual(['default', 'mode', 'network']); + }); +}); + +const author: AuthorRecord = { + author_id: 0, + name: 'José García', + affiliations: ['UAM'], + abstract_ids: [1001] +}; + +const abstracts: AbstractRecord[] = [ + { + abstract_id: 1001, + poster_id: 'M-AM-101', + title: 'Memory fMRI in aging', + accepted_for: 'Poster', + sections: { introduction: '', methods: '', results: '', conclusion: '', references: '' }, + topics: { + primary: 'Lifespan Development', + primary_subcategory: 'Aging', + secondary: '', + secondary_subcategory: '' + }, + methods_checklist: ['Functional MRI'], + facets: { keywords: ['Aging', 'MRI'], methods: ['Functional MRI'] }, + author_ids: [0], + reference_dois: [], + reference_urls: [] + }, + { + abstract_id: 1003, + poster_id: 'M-AM-103', + title: 'Default mode network in fMRI', + accepted_for: 'Oral', + sections: { introduction: '', methods: '', results: '', conclusion: '', references: '' }, + topics: { + primary: 'Cognition', + primary_subcategory: 'Memory', + secondary: '', + secondary_subcategory: '' + }, + methods_checklist: ['Functional MRI'], + facets: { keywords: ['DMN', 'resting-state'] }, + author_ids: [], + reference_dois: [], + reference_urls: [] + } +]; + +const authorsById = new Map([[0, author]]); + +describe('lexicalSearch (FR-008 typo tolerance)', () => { + it('returns null for empty query', () => { + expect(lexicalSearch(abstracts, authorsById, '')).toBeNull(); + }); + + it('matches an exact token', () => { + expect(lexicalSearch(abstracts, authorsById, 'aging')).toEqual(new Set([1001])); + }); + + it('matches a single-substitution typo on a long word', () => { + // "aginq" → "aging" (1 substitution; threshold = 2 for length 5) + expect(lexicalSearch(abstracts, authorsById, 'aginq')).toEqual(new Set([1001])); + }); + + it('matches the FR-008 example: 2-typo query "defautl mode netwrk" → "default mode network"', () => { + // All three query tokens must hit the abstract (AND across tokens). + expect(lexicalSearch(abstracts, authorsById, 'defautl mode netwrk')).toEqual(new Set([1003])); + }); + + it('matches the FR-010 example: surname "Smtih" within the typo budget would match "Smith"', () => { + // We only have a "García" author here so verify diacritic-folded match. + expect(lexicalSearch(abstracts, authorsById, 'Garcia')).toEqual(new Set([1001])); + }); + + it('intersects across multiple query tokens', () => { + // "memory mri" should hit only abstracts that have BOTH tokens; both + // abstracts mention MRI / functional MRI but only 1001 has "memory" in + // the title (1003 has it under primary_subcategory). Either way both + // should match for "memory mri". + const ids = lexicalSearch(abstracts, authorsById, 'memory mri'); + expect(ids?.size).toBeGreaterThan(0); + }); + + it('returns an empty set for queries that match nothing', () => { + expect(lexicalSearch(abstracts, authorsById, 'xyzpdqzzz')).toEqual(new Set()); + }); +}); diff --git a/specs/008-ui-rewrite/tasks.md b/specs/008-ui-rewrite/tasks.md index 730f209f..35a7cf18 100644 --- a/specs/008-ui-rewrite/tasks.md +++ b/specs/008-ui-rewrite/tasks.md @@ -151,13 +151,13 @@ The MVP user-facing slice. The first PR that exercises the now-live preview pipe ### Tests first - [ ] T051 [P] [US3] Write `tests/test_ui_data_lexical_index.py::test_inverted_index_shape` — the Python builder produces a JSON conforming to data-model.md §6: `{tokens: [...], trigram_index: {...}}`; trigram_index inverts tokens[].trigrams. Red until T054. -- [ ] T052 [P] [US3] Write `site/src/tests/unit/lexical.test.ts` — given a tiny fixture index of 5 tokens, `lexicalSearch("memry")` returns "memory" (1 edit); `lexicalSearch("xyzpdq")` returns []; `lexicalSearch("the")` is dropped as a stopword. Red until T055. +- [X] T052 [P] [US3] `site/src/tests/unit/lexical.test.ts` — 15 tests covering `damerauLevenshtein` (identity / substitution / deletion / transposition / early-exit / length filter), `tokenizeForIndex`, and `lexicalSearch` (exact + typo + multi-word intersect + FR-008 / FR-010 examples + empty + gibberish). Green. - [ ] T053 [P] [US3] Write `site/src/tests/unit/semantic.test.ts` — given a fixture int8 vector buffer of 10 abstracts × 4 dims, `semanticSearch(queryVector)` returns ids ranked by cosine descending. Red until T058. ### Implementation - [ ] T054 [P] [US3] Create `src/ohbm2026/ui_data/lexical_index.py` with `build_lexical_index(abstracts, authors) -> dict`. Tokenizes title + sections + keywords + methods + author names (NFC-normalize, lowercase, accent-fold, stopword-drop). For each token, emits trigrams + the postings list of abstract_ids. Builds the inverse `trigram_index`. Serializes to `search/lexical_index.json` ≤ 500 KB gz. Verify T051 turns green. -- [ ] T055 [P] [US3] Create `site/src/lib/search/lexical.ts` with `lexicalSearch(query)` — trigrams the query; uses trigram_index to fetch candidate token_ids; filters candidates with Damerau-Levenshtein distance ≤ 2 (≤ 1 for words < 4 chars); returns ranked abstract_ids from posting lists. Verify T052 turns green. +- [X] T055 [P] [US3] **Approach swap**: instead of a trigram-bucket pre-filter on a pre-built JSON shard, the lexical search lives entirely client-side in `site/src/lib/filter.ts`. `lexicalSearch(abstracts, authorsById, query)` lazily builds an in-memory inverted index (`token → Set<abstract_id>`) over the corpus title + topics + methods + author names + facet values, then for each query token walks the unique-token list and matches anything within Damerau-Levenshtein distance ≤ 2 (≤ 1 for tokens < 4 chars). Multi-token queries AND-intersect across query tokens. At 3243-abstract scale the brute-force lookup is fast enough (~10 ms typical query) that the trigram-pre-filter optimization isn't needed yet; revisit if SC-002 regresses. Verified live: 'connectvity' typo → 734 matches (vs 736 exact); 'defautl mode netwrk' (3-typo) → 16 matches (identical count to exact 'default mode network'). - [ ] T056 [P] [US3] Create `src/ohbm2026/ui_data/vectors.py` with `build_minilm_vectors(minilm_bundle_path) -> bytes` — reads the Stage 3 MiniLM bundle's `vectors.npy`, int8-quantizes to `[3244, 384]`, validates cosine-recovery error ≤ 0.5% on a held-out subset, writes raw little-endian binary. - [ ] T057 [P] [US3] Create `site/src/lib/workers/semantic.worker.ts` — a Web Worker that, on receipt of a query string, loads transformers.js + the `Xenova/all-MiniLM-L6-v2` ONNX model, embeds the query, fetches `minilm_vectors.bin` (lazy on first query), and runs cosine similarity. Returns top-k ranked abstract ids. - [ ] T058 [P] [US3] Create `site/src/lib/search/semantic.ts` — thin facade that spawns the worker, posts queries, returns ranked results. Verify T053 turns green. From 2114924b42655d4cc1f6b14f5368bddd87f99915 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 11:42:14 -0400 Subject: [PATCH 14/48] feat(stage6): semantic search via MiniLM ONNX in a Web Worker (FR-007, FR-009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always-on semantic search merged with the lexical typo-tolerant results. No toggle — the worker boots in the background on page load and joins the existing lexical pipeline as soon as it's ready. Data pipeline: - `src/ohbm2026/ui_data/vectors.py`: composes the per-abstract semantic vector as the mean of {introduction, methods, results, conclusion} MiniLM-L6 embeddings, L2-renormalized. Quantized to int8 with a single global scale (max-abs → 127). Cosine-recovery MAE measured on a held-out 100-pair sample = 0.00057 (well under the 0.5% threshold in data-model.md §7). - The orchestrator emits `data/search/minilm_vectors.bin` (1.2 MB raw for 3243 × 384 int8) + a sidecar `minilm_vectors.build_info.json` carrying scale, shape, components used, missing_abstract_ids, and the cosine-recovery MAE. - The runtime tar parser in `data_package.ts` was extended to surface `.bin` entries as `Uint8Array`s alongside the JSON shards (with an explicit copy so the underlying 50 MB decompressed package can be GC'd once parsed). Browser: - `lib/workers/semantic.worker.ts`: loads `Xenova/all-MiniLM-L6-v2` via transformers.js from the Hugging Face CDN (~23 MB one-time, browser-cached thereafter), embeds query text with mean-pooling + L2-normalize, then dot-products against every int8 corpus vector for cosine similarity. Returns top-K positional indices + scores. - `lib/search/semantic.ts`: main-thread facade. Lazy-initializes the worker; transfers the corpus `ArrayBuffer` to the worker (zero-copy); reports init status via a Svelte store; serializes queries with a monotonic id so out-of-order completions can't overwrite a newer result. - `routes/+page.svelte`: `onMount` kicks off `warmSemantic()` in the background (non-blocking). On every `$searchQuery` change, runs both `lexicalSearch` (synchronous) and `semanticSearch` (async, behind the worker). Results union-merged into `searchIds`; if the worker isn't ready or errors, the union degrades gracefully to lexical-only. Bundle impact: transformers.js + ONNX runtime add a 1.6 MB chunk that Vite code-splits via the `new Worker(new URL(...))` pattern; that chunk loads only when `warmSemantic()` fires (which is `onMount`, so de facto first paint, but off the critical render path). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- scripts/build_ui_data.py | 11 +- site/src/lib/data_package.ts | 8 ++ site/src/lib/search/semantic.ts | 109 +++++++++++++++++++ site/src/lib/shards.ts | 29 +++++ site/src/lib/workers/semantic.worker.ts | 78 ++++++++++++++ site/src/routes/+page.svelte | 59 +++++++++- src/ohbm2026/ui_data/builder.py | 57 ++++++++-- src/ohbm2026/ui_data/vectors.py | 138 ++++++++++++++++++++++++ 8 files changed, 476 insertions(+), 13 deletions(-) create mode 100644 site/src/lib/search/semantic.ts create mode 100644 site/src/lib/workers/semantic.worker.ts create mode 100644 src/ohbm2026/ui_data/vectors.py diff --git a/scripts/build_ui_data.py b/scripts/build_ui_data.py index c6fb3020..0efdd032 100755 --- a/scripts/build_ui_data.py +++ b/scripts/build_ui_data.py @@ -35,7 +35,15 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="Discover the active rollup state-key under --analysis-root.", ) - parser.add_argument("--minilm-bundle", dest="minilm_bundle", type=Path, default=None) + parser.add_argument( + "--minilm-root", + dest="minilm_root", + type=Path, + default=Path("data/outputs/embeddings/minilm"), + help="Root of MiniLM component bundles (introduction__*/, methods__*/, …) for the int8 vector buffer.", + ) + parser.add_argument("--minilm-bundle", dest="minilm_bundle", type=Path, default=None, + help="Deprecated single-bundle alias; use --minilm-root.") parser.add_argument("--references-yaml", dest="references_yaml", type=Path, default=None) parser.add_argument("--output", required=True, type=Path) return parser.parse_args(argv) @@ -56,6 +64,7 @@ def main(argv: list[str] | None = None) -> int: rollup=args.rollup, discover_rollup=args.discover_rollup, output_dir=args.output, + minilm_root=args.minilm_root, ) except Stage6BuildError as exc: print(f"build_ui_data.py: {exc}", file=sys.stderr) diff --git a/site/src/lib/data_package.ts b/site/src/lib/data_package.ts index 67ce6d92..f9e6bfdd 100644 --- a/site/src/lib/data_package.ts +++ b/site/src/lib/data_package.ts @@ -86,6 +86,14 @@ function parseTar(bytes: Uint8Array): Map<string, unknown> { } catch { // skip malformed json } + } else if (fullName && fullName.endsWith('.bin')) { + // Copy out the raw bytes so the underlying ArrayBuffer can be + // GC'd once the parser returns; sharing the original buffer + // would pin the whole 50 MB decompressed package in memory. + const slice = bytes.subarray(offset, offset + size); + const copy = new Uint8Array(size); + copy.set(slice); + out.set(fullName, copy); } // Skip past content padded to 512-byte boundary. offset += Math.ceil(size / 512) * 512; diff --git a/site/src/lib/search/semantic.ts b/site/src/lib/search/semantic.ts new file mode 100644 index 00000000..b558693d --- /dev/null +++ b/site/src/lib/search/semantic.ts @@ -0,0 +1,109 @@ +import { loadMinilmVectors } from '$lib/shards'; +import { writable, type Readable } from 'svelte/store'; + +/** + * Main-thread facade for the semantic-search worker. Lazy-initializes the + * worker + transferring the int8 vector buffer + waiting for the + * `Xenova/all-MiniLM-L6-v2` model to finish loading (one-time per browser + * session; the model + its tokenizer are cached by the browser thereafter). + */ + +type SemanticStatus = + | { state: 'idle' } + | { state: 'loading-vectors' } + | { state: 'loading-model' } + | { state: 'ready' } + | { state: 'error'; message: string }; + +const _status = writable<SemanticStatus>({ state: 'idle' }); +export const semanticStatus: Readable<SemanticStatus> = { subscribe: _status.subscribe }; + +let worker: Worker | null = null; +let initPromise: Promise<Worker> | null = null; +let queryCounter = 0; + +export interface SemanticHit { + index: number; + score: number; +} + +async function initWorker(): Promise<Worker> { + if (worker) return worker; + if (initPromise) return initPromise; + initPromise = (async () => { + _status.set({ state: 'loading-vectors' }); + const vectors = await loadMinilmVectors(); + if (!vectors) { + _status.set({ state: 'error', message: 'minilm vectors not available' }); + throw new Error('minilm vectors not available'); + } + const w = new Worker(new URL('../workers/semantic.worker.ts', import.meta.url), { + type: 'module' + }); + const ready = new Promise<void>((resolve, reject) => { + const handler = (e: MessageEvent) => { + const m = e.data; + if (m?.type === 'progress' && m.stage === 'model') { + _status.set({ state: 'loading-model' }); + } else if (m?.type === 'ready') { + w.removeEventListener('message', handler); + resolve(); + } else if (m?.type === 'error') { + w.removeEventListener('message', handler); + reject(new Error(m.message)); + } + }; + w.addEventListener('message', handler); + }); + const buffer = vectors.bytes.buffer.slice( + vectors.bytes.byteOffset, + vectors.bytes.byteOffset + vectors.bytes.byteLength + ); + w.postMessage( + { + type: 'init', + vectors: buffer, + dim: vectors.sidecar.shape[1], + scale: vectors.sidecar.scale + }, + [buffer] + ); + await ready; + worker = w; + _status.set({ state: 'ready' }); + return w; + })().catch((err) => { + _status.set({ state: 'error', message: (err as Error).message }); + initPromise = null; + throw err; + }); + return initPromise; +} + +/** Ensure the worker is initialized; resolves once ready. */ +export async function warmSemantic(): Promise<void> { + await initWorker(); +} + +export async function semanticSearch(query: string, topK = 50): Promise<SemanticHit[]> { + const w = await initWorker(); + const id = `q-${++queryCounter}`; + return new Promise((resolve, reject) => { + const handler = (e: MessageEvent) => { + const m = e.data; + if (m?.type === 'results' && m.id === id) { + w.removeEventListener('message', handler); + const hits: SemanticHit[] = m.indices.map((idx: number, k: number) => ({ + index: idx, + score: m.scores[k] + })); + resolve(hits); + } else if (m?.type === 'error') { + w.removeEventListener('message', handler); + reject(new Error(m.message)); + } + }; + w.addEventListener('message', handler); + w.postMessage({ type: 'query', query, topK, id }); + }); +} diff --git a/site/src/lib/shards.ts b/site/src/lib/shards.ts index 181d12d8..44d79f39 100644 --- a/site/src/lib/shards.ts +++ b/site/src/lib/shards.ts @@ -189,6 +189,35 @@ export function loadNeighbors(cellKey: string): Promise<NeighborsShard | null> { return getFromPackage<NeighborsShard>(`data/neighbors/${cellKey}.json`); } +export interface MinilmVectorsSidecar { + schema_version: string; + build_info: BuildInfo; + shape: [number, number]; + dtype: string; + scale: number; + max_abs_original: number; + components: string[]; + component_state_keys: string[]; + missing_abstract_ids: number[]; + cosine_recovery_mae: number; + byte_offset_url: string; + note?: string; +} + +export async function loadMinilmVectors(): Promise<{ + sidecar: MinilmVectorsSidecar; + bytes: Uint8Array; +} | null> { + const sidecar = await getFromPackage<MinilmVectorsSidecar>( + 'data/search/minilm_vectors.build_info.json' + ); + if (!sidecar) return null; + const pkg = await loadDataPackage(); + const bytes = pkg?.get('data/search/minilm_vectors.bin') as Uint8Array | undefined; + if (!bytes) return null; + return { sidecar, bytes }; +} + export function resetCachesForTests(): void { // Caches now live on the data_package module; reset there. } diff --git a/site/src/lib/workers/semantic.worker.ts b/site/src/lib/workers/semantic.worker.ts new file mode 100644 index 00000000..67e962bc --- /dev/null +++ b/site/src/lib/workers/semantic.worker.ts @@ -0,0 +1,78 @@ +/// <reference lib="webworker" /> + +/** + * Semantic-search worker. + * + * Boots transformers.js inside a Web Worker (off the main thread), pulls the + * `Xenova/all-MiniLM-L6-v2` ONNX model from the Hugging Face CDN (~23 MB, + * one-time download cached by the browser), and answers query messages by: + * 1. Mean-pooled + L2-normalized embedding of the query text → 384-d float32 + * 2. Cosine similarity against every row of the int8-quantized corpus + * matrix (`[N, 384]`) transferred from the main thread on init. + * 3. Top-K indices (positional → maps to `abstracts.json:abstracts[i]`). + * + * Wire protocol: + * main → worker: { type: 'init', vectors: Uint8Array, dim, scale } + * { type: 'query', query: string, topK: number, id: string } + * worker → main: { type: 'ready' } | { type: 'error', message } + * { type: 'results', id, indices: number[], scores: number[] } + * { type: 'progress', stage: 'model', detail: string } + */ + +import { pipeline, env, type FeatureExtractionPipeline } from '@xenova/transformers'; + +// Disable local-model lookups; we always pull from the Hugging Face CDN. +env.allowLocalModels = false; +env.useBrowserCache = true; + +let extractor: FeatureExtractionPipeline | null = null; +let corpus: Int8Array | null = null; +let dim = 384; + +type InitMsg = { type: 'init'; vectors: ArrayBuffer; dim: number; scale: number }; +type QueryMsg = { type: 'query'; query: string; topK: number; id: string }; +type InMsg = InitMsg | QueryMsg; + +const post = (msg: unknown) => (self as unknown as Worker).postMessage(msg); + +self.addEventListener('message', async (e: MessageEvent<InMsg>) => { + const msg = e.data; + try { + if (msg.type === 'init') { + corpus = new Int8Array(msg.vectors); + dim = msg.dim; + post({ type: 'progress', stage: 'model', detail: 'loading' }); + extractor = (await pipeline( + 'feature-extraction', + 'Xenova/all-MiniLM-L6-v2' + )) as FeatureExtractionPipeline; + post({ type: 'ready' }); + return; + } + if (msg.type === 'query') { + if (!extractor || !corpus) { + post({ type: 'error', message: 'worker not initialized' }); + return; + } + const output = await extractor(msg.query, { pooling: 'mean', normalize: true }); + const query = output.data as Float32Array; + const n = Math.floor(corpus.length / dim); + const scores = new Float32Array(n); + for (let i = 0; i < n; i++) { + let s = 0; + const off = i * dim; + for (let j = 0; j < dim; j++) s += query[j] * corpus[off + j]; + scores[i] = s; + } + const topK = Math.min(msg.topK || 50, n); + // Heap-pick via partial sort: collect indices, sort by descending score, slice. + const indices = Array.from({ length: n }, (_, i) => i); + indices.sort((a, b) => scores[b] - scores[a]); + const top = indices.slice(0, topK); + const topScores = top.map((i) => scores[i]); + post({ type: 'results', id: msg.id, indices: top, scores: topScores }); + } + } catch (err) { + post({ type: 'error', message: (err as Error).message }); + } +}); diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index d2813609..2bf49894 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -26,6 +26,8 @@ let loaded = false; let dataMissing = false; let showMap = false; + let semanticIds: Set<number> | null = null; + let semanticQuerySerial = 0; const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); onMount(async () => { @@ -46,10 +48,65 @@ dataMissing = true; } loaded = true; + // Warm the semantic worker in the background so the model is ready + // when the user types. No explicit toggle — semantic search is + // always on, merged with lexical via union below. + if (!dataMissing) { + void (async () => { + try { + const mod = await import('$lib/search/semantic'); + await mod.warmSemantic(); + } catch (err) { + console.warn('semantic search unavailable:', err); + } + })(); + } }); - $: searchIds = lexicalSearch(abstracts, authorsById, $searchQuery); + // Re-run semantic search on query change, with serial-number guard so + // out-of-order completions don't overwrite a newer result. + $: void (async (q: string) => { + const trimmed = q.trim(); + if (!trimmed) { + semanticIds = null; + return; + } + const my = ++semanticQuerySerial; + try { + const mod = await import('$lib/search/semantic'); + const hits = await mod.semanticSearch(trimmed, 50); + if (my !== semanticQuerySerial) return; // newer query in flight + // Translate worker indices (positional in abstracts.json) → abstract_ids + const ids = new Set<number>(); + for (const h of hits) { + const rec = abstracts[h.index]; + if (rec) ids.add(rec.abstract_id); + } + semanticIds = ids; + } catch { + if (my === semanticQuerySerial) semanticIds = null; + } + })($searchQuery); + + $: lexicalIds = lexicalSearch(abstracts, authorsById, $searchQuery); + $: searchIds = mergeSearch(lexicalIds, semanticIds, $searchQuery); $: filteredIds = intersect(searchIds, $lassoSelection); + + function mergeSearch( + lex: Set<number> | null, + sem: Set<number> | null, + query: string + ): Set<number> | null { + // No query → no filter (let the lasso decide). + if (!query.trim()) return null; + if (lex === null && sem === null) return null; + if (lex === null) return sem; + if (sem === null) return lex; + // Union: surfacing every hit from either source preserves recall. + const union = new Set<number>(lex); + for (const id of sem) union.add(id); + return union; + } $: focused = $focusedAbstract ? (abstractsByPosterId.get($focusedAbstract) ?? null) : null; function intersect(a: Set<number> | null, b: Set<number> | null): Set<number> | null { diff --git a/src/ohbm2026/ui_data/builder.py b/src/ohbm2026/ui_data/builder.py index a40bde50..6bfad1fd 100644 --- a/src/ohbm2026/ui_data/builder.py +++ b/src/ohbm2026/ui_data/builder.py @@ -29,6 +29,7 @@ discover_rollup_state_key, ) from ohbm2026.ui_data.topics import build_topics +from ohbm2026.ui_data.vectors import build_minilm_vectors __all__ = ["build_ui_data_package", "Stage6BuildError"] @@ -81,6 +82,7 @@ def build_ui_data_package( discover_rollup: bool, output_dir: Path, build_info: Mapping[str, str] | None = None, + minilm_root: Path | None = None, ) -> int: """Run the full Stage 6 build. @@ -186,20 +188,53 @@ def _emit(rel: str, payload: Mapping[str, Any]) -> None: _emit(f"topics/{model}_{inp}_{kind}.json", envelope) for cell_key, envelope in neighbors_envelopes.items(): _emit(f"neighbors/{cell_key}.json", envelope) - _emit( - "search/minilm_vectors.build_info.json", - { - "schema_version": "minilm_vectors.v1", - "build_info": dict(build_info), - "shape": [len(abstract_ids), 384], - "dtype": "int8", - "byte_offset_url": "data/search/minilm_vectors.bin", - }, - ) + # MiniLM semantic-search vectors — quantized int8 buffer + sidecar. + # When the MiniLM bundle isn't available the builder emits just a sidecar + # placeholder so the manifest's pointed-at URL still 200s. + if minilm_root is not None and Path(minilm_root).exists(): + try: + bin_bytes, sidecar = build_minilm_vectors( + embeddings_root=Path(minilm_root), + abstract_ids=abstract_ids, + build_info=build_info, + ) + bin_path = output / "search" / "minilm_vectors.bin" + bin_path.parent.mkdir(parents=True, exist_ok=True) + with bin_path.open("wb") as fh: + fh.write(bin_bytes) + # Pin mtime same as JSON shards for tarball reproducibility. + os.utime(bin_path, (DETERMINISTIC_MTIME, DETERMINISTIC_MTIME)) + expected.add(bin_path) + _emit("search/minilm_vectors.build_info.json", sidecar) + except FileNotFoundError as exc: + print(f"WARNING: MiniLM vectors skipped: {exc}") + _emit( + "search/minilm_vectors.build_info.json", + { + "schema_version": "minilm_vectors.v1", + "build_info": dict(build_info), + "shape": [len(abstract_ids), 384], + "dtype": "int8", + "byte_offset_url": "data/search/minilm_vectors.bin", + "note": "vectors not built (MiniLM bundle missing)", + }, + ) + else: + _emit( + "search/minilm_vectors.build_info.json", + { + "schema_version": "minilm_vectors.v1", + "build_info": dict(build_info), + "shape": [len(abstract_ids), 384], + "dtype": "int8", + "byte_offset_url": "data/search/minilm_vectors.bin", + "note": "vectors not built (no --minilm-root)", + }, + ) # Drop stale shards from prior runs that weren't re-emitted this build. # Keep the `.fetched-from-package` marker that fetch_ui_inputs.sh drops. - for path in output.rglob("*.json"): + for path in list(output.rglob("*.json")) + list(output.rglob("*.bin")): if path in expected: continue path.unlink() diff --git a/src/ohbm2026/ui_data/vectors.py b/src/ohbm2026/ui_data/vectors.py new file mode 100644 index 00000000..dc0853c0 --- /dev/null +++ b/src/ohbm2026/ui_data/vectors.py @@ -0,0 +1,138 @@ +"""Quantize MiniLM-L6 embeddings to int8 for the in-browser semantic search. + +The browser-side worker (`site/src/lib/workers/semantic.worker.ts`) loads +`Xenova/all-MiniLM-L6-v2` via transformers.js to embed the query text in +the browser, then ranks the corpus by cosine similarity against this +pre-computed int8 vector buffer. + +We compose the per-component bundles (introduction, methods, results, +conclusion) into a single per-abstract vector by mean-pooling and +L2-renormalizing — `title` is too narrow on its own; the four sections +together give a representative summary. Each vector is then int8-quantized +with a SINGLE global scale (max-abs → 127). Cosine-similarity ordering is +preserved by the int8 quantization regardless of scale. + +Output: a raw little-endian Int8 buffer of shape `[N, 384]` row-major, +plus a sidecar JSON with the build_info + scale + abstract_ids order so +the browser can dequantize + positionally-join with `abstracts.json`. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Any + + +COMPONENT_NAMES: tuple[str, ...] = ("introduction", "methods", "results", "conclusion") +DIMENSION = 384 + + +def _load_component(component_root: Path) -> tuple["object", "object"]: + """Return ``(ids, vectors)`` for a MiniLM component bundle. + + Lazy-imports numpy so `vectors.py` is import-safe without the [ui] extra. + """ + + import numpy as np # noqa: I001 + + ids = np.load(component_root / "ids.npy") + vectors = np.load(component_root / "vectors.npy").astype(np.float32) + if vectors.shape[1] != DIMENSION: + raise RuntimeError( + f"{component_root}: expected {DIMENSION}-dim vectors, got {vectors.shape[1]}" + ) + return ids, vectors + + +def build_minilm_vectors( + *, + embeddings_root: Path, + abstract_ids: Iterable[int], + build_info: Mapping[str, str], + components: tuple[str, ...] = COMPONENT_NAMES, +) -> tuple[bytes, dict[str, Any]]: + """Return ``(int8_buffer, sidecar_dict)`` for the canonical per-abstract semantic vector. + + *abstract_ids* is the ordering required by ``abstracts.json`` — the output + buffer is positionally joined to that ordering so the browser can index + row *i* directly. Abstracts whose embedding is missing get a zero vector + (which yields cosine 0 — they're never top-K matches). + """ + + import numpy as np # noqa: I001 + + ordered_ids = list(abstract_ids) + + # Find one component dir per name (most recent state-key suffix). + component_dirs: list[Path] = [] + for name in components: + candidates = sorted(Path(embeddings_root).glob(f"{name}__*")) + if not candidates: + raise FileNotFoundError( + f"No MiniLM bundle for component {name!r} under {embeddings_root}" + ) + component_dirs.append(candidates[-1]) + + # Load each component → build a (abstract_id → vector) map, then mean. + per_abstract: dict[int, list[np.ndarray]] = {} + used_state_keys: list[str] = [] + for comp_dir in component_dirs: + ids, vectors = _load_component(comp_dir) + used_state_keys.append(comp_dir.name.split("__", 1)[1]) + for i, aid in enumerate(ids): + per_abstract.setdefault(int(aid), []).append(vectors[i]) + + # Compose per-abstract vector = mean of components (re-normalized). + composed = np.zeros((len(ordered_ids), DIMENSION), dtype=np.float32) + missing: list[int] = [] + for row, aid in enumerate(ordered_ids): + parts = per_abstract.get(int(aid)) + if not parts: + missing.append(int(aid)) + continue + mean = np.mean(np.stack(parts, axis=0), axis=0) + norm = np.linalg.norm(mean) + if norm > 0: + mean = mean / norm + composed[row] = mean + + # Pick global scale: max abs across the matrix → 127. + max_abs = float(np.max(np.abs(composed))) if composed.size else 1.0 + if max_abs == 0: + max_abs = 1.0 + scale_to_int = 127.0 / max_abs + quant = np.clip(np.round(composed * scale_to_int), -127, 127).astype(np.int8) + + # Cosine-recovery probe on a held-out subset (data-model.md §7). + rng = np.random.default_rng(seed=0) + n_check = min(100, composed.shape[0]) + sample_idx = rng.choice(composed.shape[0], size=n_check, replace=False) if n_check else np.array([], dtype=int) + recovery_error = 0.0 + if n_check >= 2: + f32 = composed[sample_idx] + i8_as_f32 = quant[sample_idx].astype(np.float32) / scale_to_int + # Normalize both + for matrix in (f32, i8_as_f32): + norms = np.linalg.norm(matrix, axis=1, keepdims=True) + norms[norms == 0] = 1 + matrix /= norms + cos_f32 = f32 @ f32.T + cos_i8 = i8_as_f32 @ i8_as_f32.T + recovery_error = float(np.mean(np.abs(cos_f32 - cos_i8))) + + sidecar = { + "schema_version": "minilm_vectors.v1", + "build_info": dict(build_info), + "shape": [composed.shape[0], DIMENSION], + "dtype": "int8", + "scale": scale_to_int, + "max_abs_original": max_abs, + "components": list(components), + "component_state_keys": used_state_keys, + "missing_abstract_ids": missing, + "cosine_recovery_mae": recovery_error, + "byte_offset_url": "data/search/minilm_vectors.bin", + } + return quant.tobytes(), sidecar From df0299d8f8e793ad47e49d7829b33d9cea3e250f Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 11:56:56 -0400 Subject: [PATCH 15/48] =?UTF-8?q?feat(stage6):=20US4=20=E2=80=94=20interac?= =?UTF-8?q?tive=20facet=20sidebar=20with=2013=20facets=20(FR-013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-(model, input) cell-independent filter sidebar on the home page: - `lib/facets.ts`: 13 facet keys discovered from each abstract's `facets` block + the implicit `accepted_for` / `primary_topic` / `secondary_topic`. `recomputeFacets(abstracts, filters, preFilterIds)` returns `Map<facet_key, [{ value, count }]>` honoring FR-013: counts for facet F use the intersection of (search ∩ lasso ∩ OTHER facets) excluding F itself, so selecting "Methods=fMRI" doesn't zero out the other Methods options. `filterByFacets` intersects all per-facet sets across abstracts and returns the matching ids. - `components/FacetSidebar.svelte`: collapsible-section list with a checkbox + count per option. Most-used facets (topics, methods, study type, etc.) expanded by default; verbose ones (keywords, processing packages, brain regions/networks, accepted-for) collapsed. Per-facet section caps at top-30 options + "+ N more" hint to keep the DOM bounded for facets like keywords. - `+page.svelte` wiring: `facetIds = filterByFacets(...)` is now in the intersect chain alongside `searchIds` + `lassoSelection`, so facet + lasso + search are all multiplicative filters on the result list. Counts displayed in the sidebar use a `pre-filter` derived from (search ∩ lasso) — they show "what's reachable from here" rather than corpus-absolute totals. Layout: desktop ≥ 1024px is now 3-col (facets · results · detail); the facet column is always visible + sticky. Below 1024px the facet pane hides by default + a `🔍 Filters` toggle button reveals it inline (full-width). Verified locally: 3243 abstracts → click Methods=Functional MRI → 1615 → Clear → 3243. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- site/src/lib/components/FacetSidebar.svelte | 204 ++++++++++++++++++++ site/src/lib/facets.ts | 177 +++++++++++++++++ site/src/routes/+page.svelte | 61 +++++- 3 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 site/src/lib/components/FacetSidebar.svelte create mode 100644 site/src/lib/facets.ts diff --git a/site/src/lib/components/FacetSidebar.svelte b/site/src/lib/components/FacetSidebar.svelte new file mode 100644 index 00000000..f567aa40 --- /dev/null +++ b/site/src/lib/components/FacetSidebar.svelte @@ -0,0 +1,204 @@ +<script lang="ts"> + import { activeFilters } from '$lib/stores/selection'; + import { + FACET_KEYS_ORDERED, + FACET_LABELS, + clearAllFilters, + toggleFilter, + type FacetCounts, + type FacetKey + } from '$lib/facets'; + + export let counts: FacetCounts; + export let collapsedByDefault: FacetKey[] = [ + 'keywords', + 'processing_packages', + 'brain_regions', + 'brain_networks', + 'recording_technology', + 'accepted_for' + ]; + + let expanded: Record<string, boolean> = {}; + $: for (const key of FACET_KEYS_ORDERED) { + if (!(key in expanded)) expanded[key] = !collapsedByDefault.includes(key); + } + + function toggle(key: FacetKey, option: string) { + $activeFilters = toggleFilter($activeFilters, key, option); + } + + function clear() { + $activeFilters = clearAllFilters(); + } + + function isActive(key: FacetKey, option: string): boolean { + return $activeFilters.get(key)?.has(option) ?? false; + } + + $: activeCount = [...$activeFilters.values()].reduce((sum, s) => sum + s.size, 0); +</script> + +<aside class="facets" data-testid="facet-sidebar"> + <header> + <h2>Filters</h2> + {#if activeCount > 0} + <button type="button" class="clear" on:click={clear} data-testid="facets-clear"> + Clear ({activeCount}) + </button> + {/if} + </header> + + {#each FACET_KEYS_ORDERED as key (key)} + {@const options = counts.get(key) ?? []} + {@const isOpen = expanded[key]} + {#if options.length} + <section class="facet" data-testid={`facet-${key}`}> + <button + type="button" + class="facet-header" + on:click={() => (expanded[key] = !isOpen)} + aria-expanded={isOpen} + > + <span class="caret">{isOpen ? '▾' : '▸'}</span> + <span class="facet-label">{FACET_LABELS[key]}</span> + <span class="facet-count">{options.length}</span> + </button> + {#if isOpen} + <ul class="options"> + {#each options.slice(0, 30) as opt (opt.value)} + <li> + <label + class="opt" + class:active={isActive(key, opt.value)} + data-testid={`facet-option-${key}`} + > + <input + type="checkbox" + checked={isActive(key, opt.value)} + on:change={() => toggle(key, opt.value)} + /> + <span class="opt-label">{opt.value}</span> + <span class="opt-count">{opt.count}</span> + </label> + </li> + {/each} + {#if options.length > 30} + <li class="more-hint">+ {options.length - 30} more</li> + {/if} + </ul> + {/if} + </section> + {/if} + {/each} +</aside> + +<style> + .facets { + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + font-size: 0.85rem; + color: var(--text); + } + header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); + } + h2 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + } + .clear { + all: unset; + cursor: pointer; + font-size: 0.75rem; + color: var(--accent); + padding: 0.2rem 0.5rem; + border-radius: 3px; + border: 1px solid var(--border); + } + .clear:hover { + background: var(--bg-sunken); + } + .facet { + display: flex; + flex-direction: column; + } + .facet-header { + all: unset; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0; + font-weight: 500; + color: var(--text); + } + .facet-header:hover { + color: var(--accent); + } + .caret { + font-size: 0.65rem; + color: var(--text-muted); + width: 0.7rem; + } + .facet-label { + flex: 1; + font-size: 0.85rem; + } + .facet-count { + font-size: 0.7rem; + color: var(--text-faint); + } + .options { + list-style: none; + padding: 0 0 0.25rem 1.1rem; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; + } + .opt { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.15rem 0.35rem; + border-radius: 3px; + cursor: pointer; + font-size: 0.8rem; + } + .opt:hover { + background: var(--bg-sunken); + } + .opt.active { + background: var(--accent-soft-bg); + color: var(--accent-soft-text); + } + .opt input[type='checkbox'] { + margin: 0; + } + .opt-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .opt-count { + font-size: 0.72rem; + color: var(--text-faint); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + } + .more-hint { + color: var(--text-faint); + font-size: 0.72rem; + padding-left: 0.4rem; + font-style: italic; + } +</style> diff --git a/site/src/lib/facets.ts b/site/src/lib/facets.ts new file mode 100644 index 00000000..647caa55 --- /dev/null +++ b/site/src/lib/facets.ts @@ -0,0 +1,177 @@ +import type { AbstractRecord } from '$lib/shards'; + +/** + * Facet recomputation per US4 / FR-013. + * + * For each facet (Topics + Methods + Study type + Population + Field strength + * + Processing packages + Species + Recording technology + Brain regions + + * Brain networks + Keywords + Accepted-for), build `{option → count}` over + * the abstracts that pass the current intersection of (search ∩ lasso ∩ + * other-facet-filters). The "other-facet-filters" detail is the FR-013 nuance: + * within a given facet we want to show what's reachable IF the user also + * selected another option from that facet — so the count for facet F uses the + * intersection EXCLUDING F's current selections (otherwise once you select + * Species=Human you'd see counts of 0 for every other Species). + */ + +export type ActiveFilters = Map<string, Set<string>>; + +const FACETS_FROM_BLOCK = [ + 'keywords', + 'methods', + 'study_type', + 'population', + 'field_strength', + 'processing_packages', + 'species', + 'recording_technology', + 'brain_regions', + 'brain_networks' +] as const; + +export type FacetKey = + | 'accepted_for' + | 'primary_topic' + | 'secondary_topic' + | (typeof FACETS_FROM_BLOCK)[number]; + +export const FACET_KEYS_ORDERED: FacetKey[] = [ + 'primary_topic', + 'secondary_topic', + 'methods', + 'study_type', + 'population', + 'field_strength', + 'processing_packages', + 'species', + 'recording_technology', + 'brain_regions', + 'brain_networks', + 'keywords', + 'accepted_for' +]; + +export const FACET_LABELS: Record<FacetKey, string> = { + accepted_for: 'Accepted for', + primary_topic: 'Primary topic', + secondary_topic: 'Secondary topic', + keywords: 'Keywords', + methods: 'Methods', + study_type: 'Study type', + population: 'Population', + field_strength: 'Field strength', + processing_packages: 'Processing packages', + species: 'Species', + recording_technology: 'Recording technology', + brain_regions: 'Brain regions', + brain_networks: 'Brain networks' +}; + +function valuesFor(record: AbstractRecord, key: FacetKey): string[] { + if (key === 'accepted_for') return record.accepted_for ? [record.accepted_for] : []; + if (key === 'primary_topic') return record.topics.primary ? [record.topics.primary] : []; + if (key === 'secondary_topic') return record.topics.secondary ? [record.topics.secondary] : []; + const v = record.facets[key]; + if (Array.isArray(v)) return v.map((x) => String(x)).filter(Boolean); + if (typeof v === 'string' && v) return [v]; + return []; +} + +/** Does *record* pass the active filters in `filters`, optionally ignoring facet *exceptKey*? */ +function passesFilters( + record: AbstractRecord, + filters: ActiveFilters, + exceptKey: FacetKey | null = null +): boolean { + for (const [key, options] of filters) { + if (!options.size) continue; + if (key === exceptKey) continue; + const recordValues = valuesFor(record, key as FacetKey); + let hit = false; + for (const v of recordValues) { + if (options.has(v)) { + hit = true; + break; + } + } + if (!hit) return false; + } + return true; +} + +export function filterByFacets( + abstracts: AbstractRecord[], + filters: ActiveFilters +): Set<number> | null { + let active = false; + for (const set of filters.values()) { + if (set.size) { + active = true; + break; + } + } + if (!active) return null; + const out = new Set<number>(); + for (const a of abstracts) { + if (passesFilters(a, filters)) out.add(a.abstract_id); + } + return out; +} + +export interface FacetOption { + value: string; + count: number; +} + +export type FacetCounts = Map<FacetKey, FacetOption[]>; + +/** + * Compute the per-option counts for every facet, restricted to the abstracts + * that pass `(searchIds ∩ lassoIds ∩ facetsExceptSelf)`. The per-facet + * exception lets the sidebar show "what would happen if I added another + * option from this facet" — selecting Methods=fMRI doesn't zero out every + * other Method count. + */ +export function recomputeFacets( + abstracts: AbstractRecord[], + filters: ActiveFilters, + preFilteredIds: Set<number> | null +): FacetCounts { + const out: FacetCounts = new Map(); + for (const key of FACET_KEYS_ORDERED) { + const counts = new Map<string, number>(); + for (const record of abstracts) { + if (preFilteredIds && !preFilteredIds.has(record.abstract_id)) continue; + if (!passesFilters(record, filters, key)) continue; + for (const v of valuesFor(record, key)) { + counts.set(v, (counts.get(v) ?? 0) + 1); + } + } + const sorted: FacetOption[] = [...counts.entries()] + .map(([value, count]) => ({ value, count })) + .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)); + out.set(key, sorted); + } + return out; +} + +/** Helper for the sidebar: toggle a single (facet, option) selection. */ +export function toggleFilter( + filters: ActiveFilters, + key: FacetKey, + option: string +): ActiveFilters { + const next = new Map<string, Set<string>>( + [...filters].map(([k, v]) => [k, new Set(v)]) + ); + const set = next.get(key) ?? new Set(); + if (set.has(option)) set.delete(option); + else set.add(option); + if (set.size) next.set(key, set); + else next.delete(key); + return next; +} + +export function clearAllFilters(): ActiveFilters { + return new Map(); +} diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index 2bf49894..e53a62e7 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -10,13 +10,15 @@ type BuildInfo, type Manifest } from '$lib/shards'; - import { focusedAbstract, lassoSelection, searchQuery } from '$lib/stores/selection'; + import { activeFilters, focusedAbstract, lassoSelection, searchQuery } from '$lib/stores/selection'; import { lexicalSearch } from '$lib/filter'; + import { filterByFacets, recomputeFacets } from '$lib/facets'; import SearchBar from '$lib/components/SearchBar.svelte'; import ResultList from '$lib/components/ResultList.svelte'; import DetailPanel from '$lib/components/DetailPanel.svelte'; import ModelSelector from '$lib/components/ModelSelector.svelte'; import UmapPanel from '$lib/components/UmapPanel.svelte'; + import FacetSidebar from '$lib/components/FacetSidebar.svelte'; let manifest: Manifest | null = null; let abstracts: AbstractRecord[] = []; @@ -28,6 +30,7 @@ let showMap = false; let semanticIds: Set<number> | null = null; let semanticQuerySerial = 0; + let showFacets = false; // mobile drawer state; desktop always-shown const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); onMount(async () => { @@ -90,7 +93,13 @@ $: lexicalIds = lexicalSearch(abstracts, authorsById, $searchQuery); $: searchIds = mergeSearch(lexicalIds, semanticIds, $searchQuery); - $: filteredIds = intersect(searchIds, $lassoSelection); + $: facetIds = filterByFacets(abstracts, $activeFilters); + // Facet counts honor FR-013: each facet's option counts come from the + // intersection of search + lasso + OTHER active facets (not the facet itself + // — that's handled inside recomputeFacets via the `exceptKey` param). + $: preFilterForFacetCounts = intersect(searchIds, $lassoSelection); + $: facetCounts = recomputeFacets(abstracts, $activeFilters, preFilterForFacetCounts); + $: filteredIds = intersect(intersect(searchIds, $lassoSelection), facetIds); function mergeSearch( lex: Set<number> | null, @@ -128,6 +137,16 @@ {#if loaded && !dataMissing} <div class="controls"> <ModelSelector {manifest} /> + <button + type="button" + class="filters-toggle mobile-only" + class:active={showFacets} + on:click={() => (showFacets = !showFacets)} + aria-pressed={showFacets} + data-testid="toggle-facets" + > + 🔍 Filters + </button> <button type="button" class="map-toggle" @@ -166,6 +185,9 @@ </section> {:else} <div class="layout"> + <div class="facet-pane" class:open={showFacets} data-testid="facet-pane"> + <FacetSidebar counts={facetCounts} /> + </div> <div class="list-pane"> <ResultList {abstracts} {authorsById} {filteredIds} /> </div> @@ -238,12 +260,34 @@ gap: 1rem; width: 100%; } + .facet-pane { + min-width: 0; + display: none; /* shown via class on smaller viewports; @media for desktop */ + } + .facet-pane.open { + display: block; + } .list-pane { min-width: 0; } .detail-pane { min-width: 0; } + .filters-toggle { + all: unset; + cursor: pointer; + padding: 0.45rem 0.8rem; + border-radius: 4px; + font-size: 0.85rem; + border: 1px solid var(--border-strong); + background: var(--bg); + color: var(--text); + } + .filters-toggle.active { + background: var(--accent); + color: var(--accent-text); + border-color: var(--accent); + } .detail-empty { background: var(--bg-subtle); border: 1px dashed var(--border-strong); @@ -285,15 +329,26 @@ @media (min-width: 1024px) { .layout { - grid-template-columns: minmax(0, 1fr) clamp(24rem, 28vw, 42rem); + grid-template-columns: clamp(14rem, 18vw, 20rem) minmax(0, 1fr) clamp(22rem, 26vw, 38rem); align-items: start; } + .facet-pane { + display: block !important; /* always visible on desktop */ + position: sticky; + top: 1rem; + max-height: calc(100vh - 2rem); + overflow-y: auto; + padding-right: 0.5rem; + } .detail-pane { position: sticky; top: 1rem; max-height: calc(100vh - 2rem); overflow-y: auto; } + .filters-toggle.mobile-only { + display: none; + } } /* Mobile: detail panel becomes a full-screen overlay when focused. */ From 39e299f41b90ddb36f7b30b776b2a91a41bf9ffb Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh <satra@mit.edu> Date: Sun, 17 May 2026 12:06:16 -0400 Subject: [PATCH 16/48] feat(stage6): semantic-search toggle, score badge on cards, rebrand to "OHBM 2026 Atlas" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coupled additions: 1. **Semantic on/off toggle** — `lib/stores/searchMode.ts` exposes a `semanticEnabled` store backed by `localStorage` (default ON). The home page's "✨ Semantic" header button flips it; when OFF the semantic worker isn't queried at all and `semanticScores` stays null. The toggle is persistent across reloads. 2. **Per-card score badge** — instead of just tracking which abstracts the semantic worker returned, `+page.svelte` now keeps a `Map<abstract_id, cosine_similarity>` so each result card can show the cosine distance (`✨ d=0.123`) when an entry came back from semantic. Sort order also flips: when `semanticScores` is non-empty the result list sorts by score descending (most-similar first), then corpus-order for cards without a semantic score. 3. **Rebrand** — title, header h1, and document `<title>` all switched from "OHBM 2026 — under construction" to "OHBM 2026 Atlas". Subtitle updated to a user-facing one-liner. e2e expectation regex updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- site/src/lib/components/ResultList.svelte | 49 +++++++++++++- site/src/lib/stores/searchMode.ts | 39 +++++++++++ site/src/routes/+layout.svelte | 10 +-- site/src/routes/+page.svelte | 79 +++++++++++------------ site/src/tests/e2e/browse.spec.ts | 2 +- 5 files changed, 131 insertions(+), 48 deletions(-) create mode 100644 site/src/lib/stores/searchMode.ts diff --git a/site/src/lib/components/ResultList.svelte b/site/src/lib/components/ResultList.svelte index b6debb43..ce2a2e43 100644 --- a/site/src/lib/components/ResultList.svelte +++ b/site/src/lib/components/ResultList.svelte @@ -10,12 +10,31 @@ * `null` means "show all" (no filter). */ export let filteredIds: Set<number> | null = null; + /** When semantic search is active, a map `abstract_id → cosine similarity` so + * we can show a per-card score badge and sort by score. */ + export let semanticScores: Map<number, number> | null = null; /** Truncate to this many cards to keep DOM size bounded; "Show more" appends. */ export let initialWindow = 60; let revealed = initialWindow; - $: visible = abstracts.filter((a) => filteredIds === null || filteredIds.has(a.abstract_id)); + $: visible = (() => { + const matched = abstracts.filter((a) => filteredIds === null || filteredIds.has(a.abstract_id)); + // When semantic scores are present, sort by score descending so the + // most semantically similar surface first. Stable sort: cards with no + // semantic score keep their corpus order. + if (semanticScores && semanticScores.size > 0) { + return matched.slice().sort((a, b) => { + const sa = semanticScores!.get(a.abstract_id); + const sb = semanticScores!.get(b.abstract_id); + if (sa === undefined && sb === undefined) return 0; + if (sa === undefined) return 1; + if (sb === undefined) return -1; + return sb - sa; + }); + } + return matched; + })(); $: pageItems = visible.slice(0, revealed); function loadMore() { @@ -54,7 +73,18 @@ data-testid="result-card" data-poster-id={record.poster_id} > - <div class="poster-id">{record.poster_id || `id ${record.abstract_id}`}</div> + <div class="card-top"> + <span class="poster-id">{record.poster_id || `id ${record.abstract_id}`}</span> + {#if semanticScores && semanticScores.has(record.abstract_id)} + <span + class="semantic-badge" + title={`cosine similarity ${semanticScores.get(record.abstract_id)?.toFixed(3)} (distance ${(1 - (semanticScores.get(record.abstract_id) ?? 0)).toFixed(3)})`} + data-testid="semantic-score" + > + ✨ d={(1 - (semanticScores.get(record.abstract_id) ?? 0)).toFixed(3)} + </span> + {/if} + </div> <div class="title">{record.title}</div> <div class="lead-author"> {leadAuthor(record)} @@ -141,12 +171,27 @@ .card-body:hover { background: var(--bg-sunken); } + .card-top { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.5rem; + } .poster-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem; color: var(--accent); font-weight: 600; } + .semantic-badge { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + color: var(--text-muted); + background: var(--accent-soft-bg); + padding: 0.1rem 0.4rem; + border-radius: 999px; + flex-shrink: 0; + } .title { font-size: 0.95rem; font-weight: 500; diff --git a/site/src/lib/stores/searchMode.ts b/site/src/lib/stores/searchMode.ts new file mode 100644 index 00000000..f48927bf --- /dev/null +++ b/site/src/lib/stores/searchMode.ts @@ -0,0 +1,39 @@ +import { writable, get } from 'svelte/store'; + +const STORAGE_KEY = 'ohbm2026.ui.searchMode.v1'; + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function loadInitial(): boolean { + if (!isBrowser()) return true; + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw === '0') return false; + return true; // default on +} + +const _enabled = writable<boolean>(loadInitial()); + +function setEnabled(value: boolean): void { + _enabled.set(value); + if (isBrowser()) { + try { + window.localStorage.setItem(STORAGE_KEY, value ? '1' : '0'); + } catch { + // silent degrade + } + } +} + +function toggle(): void { + setEnabled(!get(_enabled)); +} + +export const semanticEnabled = { + subscribe: _enabled.subscribe, + set: setEnabled, + toggle +}; + +export const SEMANTIC_STORAGE_KEY = STORAGE_KEY; diff --git a/site/src/routes/+layout.svelte b/site/src/routes/+layout.svelte index 5f455362..70cf63fc 100644 --- a/site/src/routes/+layout.svelte +++ b/site/src/routes/+layout.svelte @@ -20,11 +20,11 @@ <svelte:head> {#if envBuildInfo} - <title>OHBM 2026 — under construction · {envBuildInfo.code_revision_short} + OHBM 2026 Atlas · {envBuildInfo.code_revision_short} {:else if dataBuildInfo} - OHBM 2026 — under construction · {dataBuildInfo.code_revision_short} + OHBM 2026 Atlas · {dataBuildInfo.code_revision_short} {:else} - OHBM 2026 — under construction + OHBM 2026 Atlas {/if} @@ -32,9 +32,9 @@
-

OHBM 2026 — under construction

+

OHBM 2026 Atlas

- Stage 6 UI rewrite · static SvelteKit on GitHub Pages · accepted abstracts only + Browse, search, and explore the 2026 accepted abstracts

diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index e53a62e7..ad2504ba 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -19,6 +19,7 @@ import ModelSelector from '$lib/components/ModelSelector.svelte'; import UmapPanel from '$lib/components/UmapPanel.svelte'; import FacetSidebar from '$lib/components/FacetSidebar.svelte'; + import { semanticEnabled } from '$lib/stores/searchMode'; let manifest: Manifest | null = null; let abstracts: AbstractRecord[] = []; @@ -28,7 +29,7 @@ let loaded = false; let dataMissing = false; let showMap = false; - let semanticIds: Set | null = null; + let semanticScores: Map | null = null; let semanticQuerySerial = 0; let showFacets = false; // mobile drawer state; desktop always-shown const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); @@ -67,36 +68,38 @@ }); // Re-run semantic search on query change, with serial-number guard so - // out-of-order completions don't overwrite a newer result. - $: void (async (q: string) => { + // out-of-order completions don't overwrite a newer result. Skipped when + // the user has disabled semantic via the toggle. + $: void (async (q: string, on: boolean) => { const trimmed = q.trim(); - if (!trimmed) { - semanticIds = null; + if (!on || !trimmed) { + semanticScores = null; return; } const my = ++semanticQuerySerial; try { const mod = await import('$lib/search/semantic'); const hits = await mod.semanticSearch(trimmed, 50); - if (my !== semanticQuerySerial) return; // newer query in flight - // Translate worker indices (positional in abstracts.json) → abstract_ids - const ids = new Set(); + if (my !== semanticQuerySerial) return; + // Translate worker indices (positional in abstracts.json) → abstract_id + // AND preserve the per-hit cosine similarity so the card can show it. + const scores = new Map(); for (const h of hits) { const rec = abstracts[h.index]; - if (rec) ids.add(rec.abstract_id); + if (rec) scores.set(rec.abstract_id, h.score); } - semanticIds = ids; + semanticScores = scores; } catch { - if (my === semanticQuerySerial) semanticIds = null; + if (my === semanticQuerySerial) semanticScores = null; } - })($searchQuery); + })($searchQuery, $semanticEnabled); $: lexicalIds = lexicalSearch(abstracts, authorsById, $searchQuery); - $: searchIds = mergeSearch(lexicalIds, semanticIds, $searchQuery); + $: semanticIdsForMerge = semanticScores + ? new Set(semanticScores.keys()) + : null; + $: searchIds = mergeSearch(lexicalIds, semanticIdsForMerge, $searchQuery); $: facetIds = filterByFacets(abstracts, $activeFilters); - // Facet counts honor FR-013: each facet's option counts come from the - // intersection of search + lasso + OTHER active facets (not the facet itself - // — that's handled inside recomputeFacets via the `exceptKey` param). $: preFilterForFacetCounts = intersect(searchIds, $lassoSelection); $: facetCounts = recomputeFacets(abstracts, $activeFilters, preFilterForFacetCounts); $: filteredIds = intersect(intersect(searchIds, $lassoSelection), facetIds); @@ -106,12 +109,10 @@ sem: Set | null, query: string ): Set | null { - // No query → no filter (let the lasso decide). if (!query.trim()) return null; if (lex === null && sem === null) return null; if (lex === null) return sem; if (sem === null) return lex; - // Union: surfacing every hit from either source preserves recall. const union = new Set(lex); for (const id of sem) union.add(id); return union; @@ -139,7 +140,20 @@ +
- +
{#if focused} @@ -236,7 +250,7 @@ align-items: flex-end; gap: 0.75rem; } - .map-toggle { + .control-toggle { all: unset; cursor: pointer; padding: 0.45rem 0.8rem; @@ -246,10 +260,10 @@ background: var(--bg); color: var(--text); } - .map-toggle:hover { + .control-toggle:hover { background: var(--bg-sunken); } - .map-toggle.active { + .control-toggle.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); @@ -273,21 +287,6 @@ .detail-pane { min-width: 0; } - .filters-toggle { - all: unset; - cursor: pointer; - padding: 0.45rem 0.8rem; - border-radius: 4px; - font-size: 0.85rem; - border: 1px solid var(--border-strong); - background: var(--bg); - color: var(--text); - } - .filters-toggle.active { - background: var(--accent); - color: var(--accent-text); - border-color: var(--accent); - } .detail-empty { background: var(--bg-subtle); border: 1px dashed var(--border-strong); @@ -346,7 +345,7 @@ max-height: calc(100vh - 2rem); overflow-y: auto; } - .filters-toggle.mobile-only { + .mobile-only { display: none; } } diff --git a/site/src/tests/e2e/browse.spec.ts b/site/src/tests/e2e/browse.spec.ts index aa0da866..b923d646 100644 --- a/site/src/tests/e2e/browse.spec.ts +++ b/site/src/tests/e2e/browse.spec.ts @@ -79,6 +79,6 @@ test.describe('US1: build provenance (FR-022 / SC-011)', () => { test('placeholder route also surfaces the build short SHA in the title', async ({ page }) => { await page.goto('/'); const title = await page.title(); - expect(title).toMatch(/OHBM 2026 — under construction · [0-9a-f]{7}/); + expect(title).toMatch(/OHBM 2026 Atlas · [0-9a-f]{7}/); }); }); From a378adb0e222a26b197ef435d7d9ffd77cd6a322 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 12:09:05 -0400 Subject: [PATCH 17/48] fix(stage6): semantic-score badge shows real cosine similarity now (was off-by-quantization-scale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker was summing `query[j] * corpus[off+j]` without dequantizing the int8 corpus values. Result magnitudes ran to ±300 (vs the expected ±1) so the displayed cosine distance was nonsense like `d=-308.537`. Fix: pass `scale` through to the worker on init, store `invScale = 1/scale`, and multiply each row's dot-product by `invScale` once (one multiply per row, not per element — stays fast for 3243 × 384 = 1.2M mac ops). The query is already L2-normalized by transformers.js; the dequantized corpus rows have L2 norm ≈ 1.0; so the resulting score is a true cosine similarity in [-1, 1] and `d = 1 - score` lands in roughly [0, 2] as the FR-008 spec assumed. Ranking order is unaffected by this fix (the wrong-scaled scores still sort in the same order); only the displayed badge value changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/workers/semantic.worker.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/site/src/lib/workers/semantic.worker.ts b/site/src/lib/workers/semantic.worker.ts index 67e962bc..38415b3a 100644 --- a/site/src/lib/workers/semantic.worker.ts +++ b/site/src/lib/workers/semantic.worker.ts @@ -28,6 +28,7 @@ env.useBrowserCache = true; let extractor: FeatureExtractionPipeline | null = null; let corpus: Int8Array | null = null; let dim = 384; +let invScale = 1; // 1 / quantization-scale → multiplied into each dot-product result type InitMsg = { type: 'init'; vectors: ArrayBuffer; dim: number; scale: number }; type QueryMsg = { type: 'query'; query: string; topK: number; id: string }; @@ -41,6 +42,7 @@ self.addEventListener('message', async (e: MessageEvent) => { if (msg.type === 'init') { corpus = new Int8Array(msg.vectors); dim = msg.dim; + invScale = msg.scale > 0 ? 1 / msg.scale : 1; post({ type: 'progress', stage: 'model', detail: 'loading' }); extractor = (await pipeline( 'feature-extraction', @@ -62,7 +64,12 @@ self.addEventListener('message', async (e: MessageEvent) => { let s = 0; const off = i * dim; for (let j = 0; j < dim; j++) s += query[j] * corpus[off + j]; - scores[i] = s; + // Dequantize: the corpus dot-product is scaled by the quantization + // factor; divide once per row instead of per-element to stay fast. + // Query is already L2-normalized; dequantized corpus rows have + // max-abs ≈ original_max_abs ≈ 0.25 (norm ≈ 1.0 across 384 dims), + // so the resulting score is the cosine similarity in [-1, 1]. + scores[i] = s * invScale; } const topK = Math.min(msg.topK || 50, n); // Heap-pick via partial sort: collect indices, sort by descending score, slice. From c5bbec4cc1814d5bde111693ee8ab97b0764e9ab Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 12:13:22 -0400 Subject: [PATCH 18/48] fix(stage6): tighten typo search to exact + Damerau-Levenshtein only; clamp semantic score to [-1, 1] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes: 1. **Lexical-search relaxation rolled back.** The earlier impl took substring inclusion in either direction as a 0-distance match: any query whose `query.includes(corpus_token)` was true added that token's postings, which let a 3-char corpus token like "mri" match any longer query that contained those 3 chars. That was a permissiveness beyond what FR-008 asks for. Match now strictly requires `corpus_token == query_word` OR `damerauLevenshtein(query, corpus_token, threshold) ≤ threshold` (threshold = 1 for words < 4 chars, 2 otherwise). Spec'd cases still hit the same numbers: `connectvity` → 734, `defautl mode netwrk` → 16, `Smtih` → 56, `Garcia` → 166, `xyzpdqzzz` → 0. 2. **Semantic similarity clamped to [-1, 1].** Int8 quantization can perturb the dequantized corpus row's L2 norm slightly above 1, so the float-query × dequantized-corpus dot product can land at 1.0003. That makes `distance = 1 - sim` go negative. Clamping the raw cosine to [-1, 1] keeps the displayed distance in [0, 2] as the spec assumed. Ranking order is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/filter.ts | 13 ++++++------- site/src/lib/workers/semantic.worker.ts | 13 +++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/site/src/lib/filter.ts b/site/src/lib/filter.ts index c89d53d8..3abe1d2a 100644 --- a/site/src/lib/filter.ts +++ b/site/src/lib/filter.ts @@ -141,13 +141,12 @@ export function lexicalSearch( const matchedAbstractIds = new Set(); for (const corpusToken of index.tokens) { if (Math.abs(corpusToken.length - qt.length) > threshold) continue; - // Exact substring match (either direction) counts as 0-distance. - if (corpusToken === qt || corpusToken.includes(qt) || qt.includes(corpusToken)) { - const posting = index.postings.get(corpusToken); - if (posting) for (const id of posting) matchedAbstractIds.add(id); - continue; - } - if (damerauLevenshtein(qt, corpusToken, threshold) <= threshold) { + // Match by EXACT equality or Damerau-Levenshtein ≤ threshold only. + // The earlier impl also took bidirectional substring matches as + // 0-distance — that was too permissive (a 3-char corpus token like + // "mri" matched any longer query containing those chars), so it's + // gone now. Behaviour now strictly follows FR-008. + if (corpusToken === qt || damerauLevenshtein(qt, corpusToken, threshold) <= threshold) { const posting = index.postings.get(corpusToken); if (posting) for (const id of posting) matchedAbstractIds.add(id); } diff --git a/site/src/lib/workers/semantic.worker.ts b/site/src/lib/workers/semantic.worker.ts index 38415b3a..94d7c497 100644 --- a/site/src/lib/workers/semantic.worker.ts +++ b/site/src/lib/workers/semantic.worker.ts @@ -64,12 +64,13 @@ self.addEventListener('message', async (e: MessageEvent) => { let s = 0; const off = i * dim; for (let j = 0; j < dim; j++) s += query[j] * corpus[off + j]; - // Dequantize: the corpus dot-product is scaled by the quantization - // factor; divide once per row instead of per-element to stay fast. - // Query is already L2-normalized; dequantized corpus rows have - // max-abs ≈ original_max_abs ≈ 0.25 (norm ≈ 1.0 across 384 dims), - // so the resulting score is the cosine similarity in [-1, 1]. - scores[i] = s * invScale; + // Dequantize once per row (faster than per-element). Then clamp + // to [-1, 1] — int8 quantization can perturb the dequantized + // corpus norm slightly above 1, which can push cosine sim above + // 1 (and thus negative cosine distance). Clamp keeps the + // downstream `d = 1 - sim` in [0, 2]. + const raw = s * invScale; + scores[i] = raw > 1 ? 1 : raw < -1 ? -1 : raw; } const topK = Math.min(msg.topK || 50, n); // Heap-pick via partial sort: collect indices, sort by descending score, slice. From 331464f2c60105a2cce85f7b1f8e069c8258018f Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 12:24:46 -0400 Subject: [PATCH 19/48] feat(ui-search): rank exact-token matches above fuzzy hits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lexical search now returns `{ ids, exactness }` — the per-abstract count of EXACTLY matched query tokens. `ResultList` sorts by exactness first (descending), then by semantic score, so a rare term like "pydra" surfaces the abstract that literally contains it above the proximal Damerau-Levenshtein neighbours (hydra, pydry, …). Also indexes section bodies (introduction / methods / results / conclusion) in the inverted index — earlier the haystack was metadata-only, so an abstract that only mentioned a tool inside its Methods section was unreachable via lexical search. Updated thresholds: <4 chars → exact only, 4–6 → DL ≤ 1, ≥7 → DL ≤ 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/components/ResultList.svelte | 38 ++++++---- site/src/lib/filter.ts | 87 ++++++++++++++++------- site/src/routes/+page.svelte | 12 +++- site/src/tests/unit/lexical.test.ts | 52 ++++++++++---- 4 files changed, 135 insertions(+), 54 deletions(-) diff --git a/site/src/lib/components/ResultList.svelte b/site/src/lib/components/ResultList.svelte index ce2a2e43..b4fcea88 100644 --- a/site/src/lib/components/ResultList.svelte +++ b/site/src/lib/components/ResultList.svelte @@ -13,6 +13,11 @@ /** When semantic search is active, a map `abstract_id → cosine similarity` so * we can show a per-card score badge and sort by score. */ export let semanticScores: Map | null = null; + /** When lexical search is active, a map `abstract_id → number of EXACT + * query-token matches`. Used as the primary sort key so an abstract that + * literally contains the user's query (e.g. "pydra") surfaces above + * fuzzy/proximal matches (hydra, pydry, etc.). */ + export let lexicalExactness: Map | null = null; /** Truncate to this many cards to keep DOM size bounded; "Show more" appends. */ export let initialWindow = 60; @@ -20,20 +25,25 @@ $: visible = (() => { const matched = abstracts.filter((a) => filteredIds === null || filteredIds.has(a.abstract_id)); - // When semantic scores are present, sort by score descending so the - // most semantically similar surface first. Stable sort: cards with no - // semantic score keep their corpus order. - if (semanticScores && semanticScores.size > 0) { - return matched.slice().sort((a, b) => { - const sa = semanticScores!.get(a.abstract_id); - const sb = semanticScores!.get(b.abstract_id); - if (sa === undefined && sb === undefined) return 0; - if (sa === undefined) return 1; - if (sb === undefined) return -1; - return sb - sa; - }); - } - return matched; + const hasExactness = lexicalExactness !== null && lexicalExactness.size > 0; + const hasSemantic = semanticScores !== null && semanticScores.size > 0; + if (!hasExactness && !hasSemantic) return matched; + // Sort key: primary = lexical exactness (higher first), secondary = + // semantic score (higher first). Exactness wins because the user's + // expectation when typing a rare technical term ("pydra") is that the + // actual abstract containing the word lands at the top, not buried + // among fuzzy proximal matches. + return matched.slice().sort((a, b) => { + const ea = (hasExactness ? lexicalExactness!.get(a.abstract_id) : undefined) ?? 0; + const eb = (hasExactness ? lexicalExactness!.get(b.abstract_id) : undefined) ?? 0; + if (ea !== eb) return eb - ea; + const sa = hasSemantic ? semanticScores!.get(a.abstract_id) : undefined; + const sb = hasSemantic ? semanticScores!.get(b.abstract_id) : undefined; + if (sa === undefined && sb === undefined) return 0; + if (sa === undefined) return 1; + if (sb === undefined) return -1; + return sb - sa; + }); })(); $: pageItems = visible.slice(0, revealed); diff --git a/site/src/lib/filter.ts b/site/src/lib/filter.ts index 3abe1d2a..2fcf317c 100644 --- a/site/src/lib/filter.ts +++ b/site/src/lib/filter.ts @@ -83,6 +83,10 @@ function buildInvertedIndex( .map((v) => (Array.isArray(v) ? v.join(' ') : (v as string))) .filter(Boolean) .join(' '); + // IMPORTANT: section bodies are part of the haystack now. The earlier + // build only indexed metadata (title, topics, methods checklist, + // authors, facets), which meant a query like "pydra" missed the one + // abstract that mentions the tool in its Methods section. const corpus = [ a.title, a.poster_id, @@ -91,6 +95,10 @@ function buildInvertedIndex( a.topics.secondary, a.topics.secondary_subcategory, a.methods_checklist.join(' '), + a.sections.introduction, + a.sections.methods, + a.sections.results, + a.sections.conclusion, authorNames, facetBlob ].join(' '); @@ -111,9 +119,20 @@ function buildInvertedIndex( return index; } -/** Distance threshold per token length, per FR-008. */ +/** + * Distance threshold per token length. Tighter than FR-008's "≤2 for ≥4" + * because at 3K abstracts × ~50K unique corpus tokens, a 5-char query like + * "pydra" admits dozens of proximal matches (hydra, pyra, pydry, …) most + * of which aren't what the user wants. Stricter scheme: + * < 4 chars → exact only (DL = 0); too noisy otherwise + * 4–6 chars → DL ≤ 1 ; catches single-typo / transposition (Smtih→Smith) + * ≥ 7 chars → DL ≤ 2 ; matches the FR-008 spec for longer words + */ function thresholdFor(token: string): number { - return token.length < 4 ? 1 : 2; + const n = token.length; + if (n < 4) return 0; + if (n < 7) return 1; + return 2; } /** @@ -122,48 +141,68 @@ function thresholdFor(token: string): number { * token within its Damerau-Levenshtein threshold; the abstract sets are * intersected so all words contribute. * - * Returns `null` for an empty query (= "show everything", same convention - * as the substring search) or a Set of matching `abstract_id`s. + * Returns `null` for an empty query, or a `{ ids, exactness }` pair where + * `exactness` is a per-abstract count of how many query tokens matched + * EXACTLY (not just within DL distance). The UI uses `exactness` to rank + * exact-match abstracts first. */ +export interface LexicalResult { + ids: Set; + exactness: Map; // abstract_id → number of EXACT query-token matches +} + export function lexicalSearch( abstracts: AbstractRecord[], authorsById: Map, query: string -): Set | null { +): LexicalResult | null { const q = normalize(query).trim(); if (!q) return null; const queryTokens = tokenizeForIndex(q); - if (queryTokens.length === 0) return new Set(); + if (queryTokens.length === 0) return { ids: new Set(), exactness: new Map() }; const index = buildInvertedIndex(abstracts, authorsById); - const perTokenMatches: Set[] = []; + + // Per-token match sets, paired with per-abstract exact-hit flags. + type PerTokenMatch = { all: Set; exact: Set }; + const perTokenMatches: PerTokenMatch[] = []; for (const qt of queryTokens) { const threshold = thresholdFor(qt); - const matchedAbstractIds = new Set(); + const all = new Set(); + const exact = new Set(); for (const corpusToken of index.tokens) { if (Math.abs(corpusToken.length - qt.length) > threshold) continue; - // Match by EXACT equality or Damerau-Levenshtein ≤ threshold only. - // The earlier impl also took bidirectional substring matches as - // 0-distance — that was too permissive (a 3-char corpus token like - // "mri" matched any longer query containing those chars), so it's - // gone now. Behaviour now strictly follows FR-008. - if (corpusToken === qt || damerauLevenshtein(qt, corpusToken, threshold) <= threshold) { + const isExact = corpusToken === qt; + if (isExact || damerauLevenshtein(qt, corpusToken, threshold) <= threshold) { const posting = index.postings.get(corpusToken); - if (posting) for (const id of posting) matchedAbstractIds.add(id); + if (!posting) continue; + for (const id of posting) { + all.add(id); + if (isExact) exact.add(id); + } } } - perTokenMatches.push(matchedAbstractIds); + perTokenMatches.push({ all, exact }); } - if (perTokenMatches.length === 0) return new Set(); - // Intersect all per-token sets (AND semantics). - let final = perTokenMatches[0]; + if (perTokenMatches.length === 0) return { ids: new Set(), exactness: new Map() }; + + // AND-intersect across per-token sets. + let finalIds = perTokenMatches[0].all; for (let i = 1; i < perTokenMatches.length; i++) { const next = new Set(); - const probe = perTokenMatches[i]; - for (const id of final) if (probe.has(id)) next.add(id); - final = next; - if (final.size === 0) break; + const probe = perTokenMatches[i].all; + for (const id of finalIds) if (probe.has(id)) next.add(id); + finalIds = next; + if (finalIds.size === 0) break; + } + + // For each surviving abstract, count how many query tokens matched EXACTLY. + const exactness = new Map(); + for (const id of finalIds) { + let n = 0; + for (const m of perTokenMatches) if (m.exact.has(id)) n++; + exactness.set(id, n); } - return final; + return { ids: finalIds, exactness }; } interface SearchHaystack { diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index ad2504ba..3efcb564 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -94,7 +94,9 @@ } })($searchQuery, $semanticEnabled); - $: lexicalIds = lexicalSearch(abstracts, authorsById, $searchQuery); + $: lexicalResult = lexicalSearch(abstracts, authorsById, $searchQuery); + $: lexicalIds = lexicalResult?.ids ?? null; + $: lexicalExactness = lexicalResult?.exactness ?? null; $: semanticIdsForMerge = semanticScores ? new Set(semanticScores.keys()) : null; @@ -203,7 +205,13 @@
- +
{#if focused} diff --git a/site/src/tests/unit/lexical.test.ts b/site/src/tests/unit/lexical.test.ts index 3d65c030..c8fdaca0 100644 --- a/site/src/tests/unit/lexical.test.ts +++ b/site/src/tests/unit/lexical.test.ts @@ -94,34 +94,58 @@ describe('lexicalSearch (FR-008 typo tolerance)', () => { }); it('matches an exact token', () => { - expect(lexicalSearch(abstracts, authorsById, 'aging')).toEqual(new Set([1001])); + const result = lexicalSearch(abstracts, authorsById, 'aging'); + expect(result?.ids).toEqual(new Set([1001])); + // "aging" is an exact match in abstract 1001 → exactness count = 1. + expect(result?.exactness.get(1001)).toBe(1); }); it('matches a single-substitution typo on a long word', () => { - // "aginq" → "aging" (1 substitution; threshold = 2 for length 5) - expect(lexicalSearch(abstracts, authorsById, 'aginq')).toEqual(new Set([1001])); + // "aginq" → "aging" (1 substitution; threshold = 1 for length 5 under + // the tightened scheme). + const result = lexicalSearch(abstracts, authorsById, 'aginq'); + expect(result?.ids).toEqual(new Set([1001])); + // Fuzzy hit, NOT exact → exactness should be 0. + expect(result?.exactness.get(1001) ?? 0).toBe(0); }); it('matches the FR-008 example: 2-typo query "defautl mode netwrk" → "default mode network"', () => { // All three query tokens must hit the abstract (AND across tokens). - expect(lexicalSearch(abstracts, authorsById, 'defautl mode netwrk')).toEqual(new Set([1003])); + // Lengths: defautl=7 (thr 2), mode=4 (thr 1), netwrk=6 (thr 1). + // "defautl"→"default" = 1 transposition (DL 1 ≤ 2) ✓ + // "mode" exact ✓; "netwrk"→"network" = 1 deletion (DL 1 ≤ 1) ✓ + const result = lexicalSearch(abstracts, authorsById, 'defautl mode netwrk'); + expect(result?.ids).toEqual(new Set([1003])); + // "mode" hits exactly; the other two are typo-corrected → exactness = 1. + expect(result?.exactness.get(1003)).toBe(1); }); - it('matches the FR-010 example: surname "Smtih" within the typo budget would match "Smith"', () => { - // We only have a "García" author here so verify diacritic-folded match. - expect(lexicalSearch(abstracts, authorsById, 'Garcia')).toEqual(new Set([1001])); + it('matches the FR-010 example: diacritic-folded surname', () => { + // "Garcia" tokenizes to ["garcia"]; corpus has the NFD-folded "garcia" + // from "García". Length 6 → threshold 1; exact hit after folding. + const result = lexicalSearch(abstracts, authorsById, 'Garcia'); + expect(result?.ids).toEqual(new Set([1001])); }); it('intersects across multiple query tokens', () => { - // "memory mri" should hit only abstracts that have BOTH tokens; both - // abstracts mention MRI / functional MRI but only 1001 has "memory" in - // the title (1003 has it under primary_subcategory). Either way both - // should match for "memory mri". - const ids = lexicalSearch(abstracts, authorsById, 'memory mri'); - expect(ids?.size).toBeGreaterThan(0); + const result = lexicalSearch(abstracts, authorsById, 'memory mri'); + expect((result?.ids.size ?? 0)).toBeGreaterThan(0); }); it('returns an empty set for queries that match nothing', () => { - expect(lexicalSearch(abstracts, authorsById, 'xyzpdqzzz')).toEqual(new Set()); + const result = lexicalSearch(abstracts, authorsById, 'xyzpdqzzz'); + expect(result?.ids).toEqual(new Set()); + expect(result?.exactness.size).toBe(0); + }); + + it('ranks the exact-match abstract above fuzzy proximal matches', () => { + // Regression guard for the "pydra" issue: an exact-token hit must + // produce a strictly higher exactness count than any fuzzy hit so the + // UI can sort it to the top. + const result = lexicalSearch(abstracts, authorsById, 'aging'); + expect(result).not.toBeNull(); + // Only the abstract with the literal word should have exactness ≥ 1. + const counts = [...result!.exactness.values()]; + expect(Math.max(0, ...counts)).toBeGreaterThanOrEqual(1); }); }); From 3f59cad7a11655bcc50bbfc6ddd3b2f1db4af94e Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 12:50:16 -0400 Subject: [PATCH 20/48] feat(ui-facets): unify primary+secondary topics into one facet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the separate `primary_topic` / `secondary_topic` filters with a single `Topic` facet whose options are the deduped union of an abstract's primary + secondary topic. A selected option matches if EITHER position holds it. Same treatment for the new `Subcategory` facet (union of primary_subcategory + secondary_subcategory). Why: users browsing by topic don't care whether the submitter put a term in the primary or secondary slot — having two parallel lists forced them to tick the same name twice, and abstracts that placed a shared term only in the secondary slot were silently invisible when the primary list was used. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/facets.ts | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/site/src/lib/facets.ts b/site/src/lib/facets.ts index 647caa55..42afd4b3 100644 --- a/site/src/lib/facets.ts +++ b/site/src/lib/facets.ts @@ -31,13 +31,13 @@ const FACETS_FROM_BLOCK = [ export type FacetKey = | 'accepted_for' - | 'primary_topic' - | 'secondary_topic' + | 'topic' + | 'subcategory' | (typeof FACETS_FROM_BLOCK)[number]; export const FACET_KEYS_ORDERED: FacetKey[] = [ - 'primary_topic', - 'secondary_topic', + 'topic', + 'subcategory', 'methods', 'study_type', 'population', @@ -53,8 +53,8 @@ export const FACET_KEYS_ORDERED: FacetKey[] = [ export const FACET_LABELS: Record = { accepted_for: 'Accepted for', - primary_topic: 'Primary topic', - secondary_topic: 'Secondary topic', + topic: 'Topic', + subcategory: 'Subcategory', keywords: 'Keywords', methods: 'Methods', study_type: 'Study type', @@ -67,10 +67,26 @@ export const FACET_LABELS: Record = { brain_networks: 'Brain networks' }; +function dedupe(values: string[]): string[] { + const out: string[] = []; + const seen = new Set(); + for (const v of values) { + if (!v || seen.has(v)) continue; + seen.add(v); + out.push(v); + } + return out; +} + function valuesFor(record: AbstractRecord, key: FacetKey): string[] { if (key === 'accepted_for') return record.accepted_for ? [record.accepted_for] : []; - if (key === 'primary_topic') return record.topics.primary ? [record.topics.primary] : []; - if (key === 'secondary_topic') return record.topics.secondary ? [record.topics.secondary] : []; + // The Topic facet is the UNION of primary + secondary topic values per + // abstract (deduped). A selected Topic option matches if EITHER position + // equals it — the previous split into primary_topic / secondary_topic + // made users pick the same term twice for the same conceptual filter. + if (key === 'topic') return dedupe([record.topics.primary, record.topics.secondary]); + if (key === 'subcategory') + return dedupe([record.topics.primary_subcategory, record.topics.secondary_subcategory]); const v = record.facets[key]; if (Array.isArray(v)) return v.map((x) => String(x)).filter(Boolean); if (typeof v === 'string' && v) return [v]; From 826df6501a11387a68f23d0756b048d251e8c758 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 12:57:13 -0400 Subject: [PATCH 21/48] feat(ui-facets): add cluster filter + top-5 expand + UMAP color caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * New `Cluster (current map)` facet at the top of the sidebar. Options are the per-(model, input) cell's community labels (drawn from the same `topics/communities` shard that names UMAP clusters). Selecting a cluster filters the result list to the abstracts coloured under it. This gives users a list-driven way to enter a cluster without having to lasso it on the 2D map. * Sidebar now collapses each facet to its top 5 options by default; a `Show all N ▾` button reveals the rest inside a 14 rem scroll container. The earlier `top-30 + "+ N more"` hint was too long for facets like Keywords (hundreds of options) and the dead hint line felt broken. * UMAP caption now states that points are coloured by cluster (the community detected per cell). This answers the recurring "what is the colour encoding?" question without users having to inspect a data sample. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/components/FacetSidebar.svelte | 53 +++++++++++++++---- site/src/lib/components/UmapPanel.svelte | 5 +- site/src/lib/facets.ts | 36 ++++++++++--- site/src/routes/+page.svelte | 57 +++++++++++++++++++-- 4 files changed, 127 insertions(+), 24 deletions(-) diff --git a/site/src/lib/components/FacetSidebar.svelte b/site/src/lib/components/FacetSidebar.svelte index f567aa40..9454859f 100644 --- a/site/src/lib/components/FacetSidebar.svelte +++ b/site/src/lib/components/FacetSidebar.svelte @@ -19,7 +19,13 @@ 'accepted_for' ]; + /** Default visible option count per facet; clicking "Show all N" reveals + * the rest inside a scroll container. Keeps the sidebar short on first + * impression — most users only care about the top few. */ + const COLLAPSED_OPTION_COUNT = 5; + let expanded: Record = {}; + let optionsExpanded: Record = {}; $: for (const key of FACET_KEYS_ORDERED) { if (!(key in expanded)) expanded[key] = !collapsedByDefault.includes(key); } @@ -36,6 +42,10 @@ return $activeFilters.get(key)?.has(option) ?? false; } + function toggleOptions(key: string) { + optionsExpanded = { ...optionsExpanded, [key]: !optionsExpanded[key] }; + } + $: activeCount = [...$activeFilters.values()].reduce((sum, s) => sum + s.size, 0); @@ -52,6 +62,9 @@ {#each FACET_KEYS_ORDERED as key (key)} {@const options = counts.get(key) ?? []} {@const isOpen = expanded[key]} + {@const showAll = optionsExpanded[key]} + {@const hasOverflow = options.length > COLLAPSED_OPTION_COUNT} + {@const visibleOptions = showAll ? options : options.slice(0, COLLAPSED_OPTION_COUNT)} {#if options.length}
{#if isOpen} -
    - {#each options.slice(0, 30) as opt (opt.value)} +
      + {#each visibleOptions as opt (opt.value)}
    • {/each} - {#if options.length > 30} -
    • + {options.length - 30} more
    • - {/if}
    + {#if hasOverflow} + + {/if} {/if}
{/if} @@ -165,6 +187,21 @@ flex-direction: column; gap: 0.1rem; } + .options.scroll { + max-height: 14rem; + overflow-y: auto; + padding-right: 0.4rem; + } + .show-toggle { + all: unset; + cursor: pointer; + font-size: 0.72rem; + color: var(--accent); + padding: 0.15rem 0 0.35rem 1.5rem; + } + .show-toggle:hover { + text-decoration: underline; + } .opt { display: flex; align-items: center; @@ -195,10 +232,4 @@ color: var(--text-faint); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } - .more-hint { - color: var(--text-faint); - font-size: 0.72rem; - padding-left: 0.4rem; - font-style: italic; - } diff --git a/site/src/lib/components/UmapPanel.svelte b/site/src/lib/components/UmapPanel.svelte index bef3018f..46d9ed9b 100644 --- a/site/src/lib/components/UmapPanel.svelte +++ b/site/src/lib/components/UmapPanel.svelte @@ -456,7 +456,10 @@

UMAP — cell {cellKey}

-

Lasso on 2D filters the result list. Click any point to open its detail panel.

+

+ Points are coloured by cluster (community detected for this cell). + Lasso on 2D filters the result list; click any point to open its detail panel. +

{#if $lassoSelection} diff --git a/site/src/lib/facets.ts b/site/src/lib/facets.ts index 42afd4b3..e752bede 100644 --- a/site/src/lib/facets.ts +++ b/site/src/lib/facets.ts @@ -31,11 +31,13 @@ const FACETS_FROM_BLOCK = [ export type FacetKey = | 'accepted_for' + | 'cluster' | 'topic' | 'subcategory' | (typeof FACETS_FROM_BLOCK)[number]; export const FACET_KEYS_ORDERED: FacetKey[] = [ + 'cluster', 'topic', 'subcategory', 'methods', @@ -53,6 +55,7 @@ export const FACET_KEYS_ORDERED: FacetKey[] = [ export const FACET_LABELS: Record = { accepted_for: 'Accepted for', + cluster: 'Cluster (current map)', topic: 'Topic', subcategory: 'Subcategory', keywords: 'Keywords', @@ -67,6 +70,18 @@ export const FACET_LABELS: Record = { brain_networks: 'Brain networks' }; +/** + * Per-(model, input) cell-specific context for facets that derive their + * options from the active UMAP layout rather than the abstract record alone. + * Currently just the cluster facet — `clusterLabelByAbstractId` maps each + * abstract to the community label that the UMAP color-codes it under. + */ +export interface FacetCellContext { + clusterLabelByAbstractId: Map; +} + +const EMPTY_CTX: FacetCellContext = { clusterLabelByAbstractId: new Map() }; + function dedupe(values: string[]): string[] { const out: string[] = []; const seen = new Set(); @@ -78,8 +93,12 @@ function dedupe(values: string[]): string[] { return out; } -function valuesFor(record: AbstractRecord, key: FacetKey): string[] { +function valuesFor(record: AbstractRecord, key: FacetKey, ctx: FacetCellContext): string[] { if (key === 'accepted_for') return record.accepted_for ? [record.accepted_for] : []; + if (key === 'cluster') { + const label = ctx.clusterLabelByAbstractId.get(record.abstract_id); + return label ? [label] : []; + } // The Topic facet is the UNION of primary + secondary topic values per // abstract (deduped). A selected Topic option matches if EITHER position // equals it — the previous split into primary_topic / secondary_topic @@ -97,12 +116,13 @@ function valuesFor(record: AbstractRecord, key: FacetKey): string[] { function passesFilters( record: AbstractRecord, filters: ActiveFilters, + ctx: FacetCellContext, exceptKey: FacetKey | null = null ): boolean { for (const [key, options] of filters) { if (!options.size) continue; if (key === exceptKey) continue; - const recordValues = valuesFor(record, key as FacetKey); + const recordValues = valuesFor(record, key as FacetKey, ctx); let hit = false; for (const v of recordValues) { if (options.has(v)) { @@ -117,7 +137,8 @@ function passesFilters( export function filterByFacets( abstracts: AbstractRecord[], - filters: ActiveFilters + filters: ActiveFilters, + ctx: FacetCellContext = EMPTY_CTX ): Set | null { let active = false; for (const set of filters.values()) { @@ -129,7 +150,7 @@ export function filterByFacets( if (!active) return null; const out = new Set(); for (const a of abstracts) { - if (passesFilters(a, filters)) out.add(a.abstract_id); + if (passesFilters(a, filters, ctx)) out.add(a.abstract_id); } return out; } @@ -151,15 +172,16 @@ export type FacetCounts = Map; export function recomputeFacets( abstracts: AbstractRecord[], filters: ActiveFilters, - preFilteredIds: Set | null + preFilteredIds: Set | null, + ctx: FacetCellContext = EMPTY_CTX ): FacetCounts { const out: FacetCounts = new Map(); for (const key of FACET_KEYS_ORDERED) { const counts = new Map(); for (const record of abstracts) { if (preFilteredIds && !preFilteredIds.has(record.abstract_id)) continue; - if (!passesFilters(record, filters, key)) continue; - for (const v of valuesFor(record, key)) { + if (!passesFilters(record, filters, ctx, key)) continue; + for (const v of valuesFor(record, key, ctx)) { counts.set(v, (counts.get(v) ?? 0) + 1); } } diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index 3efcb564..de9c020f 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -4,15 +4,19 @@ buildInfoFromEnv, loadAbstracts, loadAuthors, + loadCell, loadManifest, + loadTopics, type AbstractRecord, type AuthorRecord, type BuildInfo, - type Manifest + type CellShard, + type Manifest, + type TopicShard } from '$lib/shards'; - import { activeFilters, focusedAbstract, lassoSelection, searchQuery } from '$lib/stores/selection'; + import { activeFilters, focusedAbstract, lassoSelection, searchQuery, selectedCell } from '$lib/stores/selection'; import { lexicalSearch } from '$lib/filter'; - import { filterByFacets, recomputeFacets } from '$lib/facets'; + import { filterByFacets, recomputeFacets, type FacetCellContext } from '$lib/facets'; import SearchBar from '$lib/components/SearchBar.svelte'; import ResultList from '$lib/components/ResultList.svelte'; import DetailPanel from '$lib/components/DetailPanel.svelte'; @@ -32,6 +36,8 @@ let semanticScores: Map | null = null; let semanticQuerySerial = 0; let showFacets = false; // mobile drawer state; desktop always-shown + let cellShard: CellShard | null = null; + let cellTopics: TopicShard | null = null; const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); onMount(async () => { @@ -101,11 +107,52 @@ ? new Set(semanticScores.keys()) : null; $: searchIds = mergeSearch(lexicalIds, semanticIdsForMerge, $searchQuery); - $: facetIds = filterByFacets(abstracts, $activeFilters); + + // Load the current (model, input) cell + its community topics so the + // Cluster facet can offer per-cell options. The same data feeds the + // UMAP panel; loadCell/loadTopics are cheap (Map-get from the in-memory + // data package) so duplicating the load here is fine. + $: cellKey = `${$selectedCell.model}_${$selectedCell.input}`; + $: void (async () => { + const key = cellKey; + const [c, t] = await Promise.all([loadCell(key), loadTopics(key, 'communities')]); + // Guard against late-arriving results after the user switched cells. + if (key === cellKey) { + cellShard = c; + cellTopics = t; + } + })(); + $: facetCtx = buildFacetCtx(cellShard, cellTopics); + $: facetIds = filterByFacets(abstracts, $activeFilters, facetCtx); $: preFilterForFacetCounts = intersect(searchIds, $lassoSelection); - $: facetCounts = recomputeFacets(abstracts, $activeFilters, preFilterForFacetCounts); + $: facetCounts = recomputeFacets(abstracts, $activeFilters, preFilterForFacetCounts, facetCtx); $: filteredIds = intersect(intersect(searchIds, $lassoSelection), facetIds); + function buildFacetCtx( + shard: CellShard | null, + topics: TopicShard | null + ): FacetCellContext { + const labelByCluster = new Map(); + if (topics) { + for (const t of topics.topics) { + const label = t.title + ? t.title + : t.keywords.length + ? t.keywords.slice(0, 3).join(', ') + : `cluster ${t.cluster_id}`; + labelByCluster.set(t.cluster_id, label); + } + } + const clusterLabelByAbstractId = new Map(); + if (shard) { + for (const row of shard.rows) { + const label = labelByCluster.get(row.community_id); + if (label) clusterLabelByAbstractId.set(row.abstract_id, label); + } + } + return { clusterLabelByAbstractId }; + } + function mergeSearch( lex: Set | null, sem: Set | null, From 242c05e2f949ede38ede2d8b86192b1e7384d6f5 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 12:59:01 -0400 Subject: [PATCH 22/48] fix(umap-3d): preserve user zoom when resuming auto-rotate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orbit step hard-coded radius (r=2.2) and height (z=0.9), so every animation frame snapped the camera back to that default — meaning the moment the user un-paused after zooming, the view jumped to the factory position. Re-seed `r`, `z`, and `rotateAngle` from the current camera eye each time rotation (re)starts, so pause → zoom/orbit → unpause continues the orbit from the user's chosen distance and pitch. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/components/UmapPanel.svelte | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/site/src/lib/components/UmapPanel.svelte b/site/src/lib/components/UmapPanel.svelte index 46d9ed9b..a7023d48 100644 --- a/site/src/lib/components/UmapPanel.svelte +++ b/site/src/lib/components/UmapPanel.svelte @@ -416,14 +416,32 @@ function ensureRotate() { stopRotate(); if (!autoRotate || !plotly || !chart3dEl) return; + // Seed the orbit from the user's CURRENT camera so that pausing, + // zooming/orbiting, then unpausing continues from where they left off + // instead of snapping back to the hard-coded default (r=2.2, z=0.9). + let r = 2.2; + let z = 0.9; + const flLayout = ( + chart3dEl as unknown as { + _fullLayout?: { scene?: { camera?: { eye?: { x: number; y: number; z: number } } } }; + } + )._fullLayout; + const eye0 = flLayout?.scene?.camera?.eye; + if (eye0 && typeof eye0.x === 'number') { + const r0 = Math.hypot(eye0.x, eye0.y); + if (r0 > 1e-6) { + r = r0; + z = eye0.z; + rotateAngle = Math.atan2(eye0.y, eye0.x); + } + } const step = () => { if (!autoRotate || !plotly || !chart3dEl) return; rotateAngle += 0.004; - const r = 2.2; const eye = { x: r * Math.cos(rotateAngle), y: r * Math.sin(rotateAngle), - z: 0.9 + z }; try { (plotly as unknown as { relayout: (el: HTMLDivElement, p: unknown) => Promise }).relayout( From 23750b6a1ffcc9af4b687978eeec340a36828822 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 13:06:22 -0400 Subject: [PATCH 23/48] feat(ui): facets dim UMAP, scrollable options, wrapping labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Facet selections (e.g. picking a Cluster or Topic) now also dim the UMAP — the panel takes the full `filteredIds` set (search ∩ lasso ∩ facets) and renders everything outside it at low opacity. Lasso writes still go to `$lassoSelection`; UMAP receives the composite selection via a new `selection` prop. * Facet option lists drop the "Show all N" button. Lists with more than 5 options become directly scrollable (12 rem max-height); short lists render bare. * Option labels wrap (word-break: break-word) so multi-word topic names are fully visible instead of ellipsised. A `title` attribute also surfaces the full string on hover for the still-truncated edge cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/components/FacetSidebar.svelte | 58 ++++++--------------- site/src/lib/components/UmapPanel.svelte | 12 ++++- site/src/routes/+page.svelte | 2 +- 3 files changed, 27 insertions(+), 45 deletions(-) diff --git a/site/src/lib/components/FacetSidebar.svelte b/site/src/lib/components/FacetSidebar.svelte index 9454859f..8423cb2d 100644 --- a/site/src/lib/components/FacetSidebar.svelte +++ b/site/src/lib/components/FacetSidebar.svelte @@ -19,13 +19,12 @@ 'accepted_for' ]; - /** Default visible option count per facet; clicking "Show all N" reveals - * the rest inside a scroll container. Keeps the sidebar short on first - * impression — most users only care about the top few. */ - const COLLAPSED_OPTION_COUNT = 5; + /** When a facet has more than this many options, the list becomes + * vertically scrollable (capped via `.options.scroll`). Below the + * threshold there's nothing to scroll, so we render the bare list. */ + const SCROLL_THRESHOLD = 5; let expanded: Record = {}; - let optionsExpanded: Record = {}; $: for (const key of FACET_KEYS_ORDERED) { if (!(key in expanded)) expanded[key] = !collapsedByDefault.includes(key); } @@ -42,10 +41,6 @@ return $activeFilters.get(key)?.has(option) ?? false; } - function toggleOptions(key: string) { - optionsExpanded = { ...optionsExpanded, [key]: !optionsExpanded[key] }; - } - $: activeCount = [...$activeFilters.values()].reduce((sum, s) => sum + s.size, 0); @@ -62,9 +57,7 @@ {#each FACET_KEYS_ORDERED as key (key)} {@const options = counts.get(key) ?? []} {@const isOpen = expanded[key]} - {@const showAll = optionsExpanded[key]} - {@const hasOverflow = options.length > COLLAPSED_OPTION_COUNT} - {@const visibleOptions = showAll ? options : options.slice(0, COLLAPSED_OPTION_COUNT)} + {@const useScroll = options.length > SCROLL_THRESHOLD} {#if options.length}
{#if isOpen} -
    - {#each visibleOptions as opt (opt.value)} +
      + {#each options as opt (opt.value)}
    • {/each}
    - {#if hasOverflow} - - {/if} {/if}
{/if} @@ -188,29 +169,22 @@ gap: 0.1rem; } .options.scroll { - max-height: 14rem; + max-height: 12rem; overflow-y: auto; padding-right: 0.4rem; } - .show-toggle { - all: unset; - cursor: pointer; - font-size: 0.72rem; - color: var(--accent); - padding: 0.15rem 0 0.35rem 1.5rem; - } - .show-toggle:hover { - text-decoration: underline; - } .opt { display: flex; - align-items: center; + align-items: flex-start; gap: 0.4rem; padding: 0.15rem 0.35rem; border-radius: 3px; cursor: pointer; font-size: 0.8rem; } + .opt input[type='checkbox'] { + margin-top: 0.18rem; /* line up with the wrapped label's first line */ + } .opt:hover { background: var(--bg-sunken); } @@ -223,9 +197,9 @@ } .opt-label { flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + min-width: 0; + line-height: 1.25; + word-break: break-word; /* topic names like "Functional Connectivity (Resting-State)" wrap cleanly */ } .opt-count { font-size: 0.72rem; diff --git a/site/src/lib/components/UmapPanel.svelte b/site/src/lib/components/UmapPanel.svelte index a7023d48..1b55f17b 100644 --- a/site/src/lib/components/UmapPanel.svelte +++ b/site/src/lib/components/UmapPanel.svelte @@ -6,6 +6,14 @@ import type { AbstractRecord } from '$lib/shards'; export let abstracts: AbstractRecord[] = []; + /** + * Set of abstract_ids the rest of the app considers "currently selected" + * — i.e. the intersection of search ∩ lasso ∩ facets. The UMAP dims + * everything outside this set so picking a cluster or facet visually + * narrows the map alongside the result list. `null` = no narrowing + * (every point at full opacity). + */ + export let selection: Set | null = null; /** * Mobile breakpoint — desktop ≥ 1024px renders 2D + 3D side-by-side * with lasso on the 2D pane. Smaller viewports stack vertically and @@ -106,8 +114,8 @@ } return map; })(); - $: void renderChart2D(plotly, chart2dEl, cellShard, abstracts, $lassoSelection, mobile, theme, topicByCluster); - $: void renderChart3D(plotly, chart3dEl, cellShard, abstracts, $lassoSelection, theme, topicByCluster); + $: void renderChart2D(plotly, chart2dEl, cellShard, abstracts, selection, mobile, theme, topicByCluster); + $: void renderChart3D(plotly, chart3dEl, cellShard, abstracts, selection, theme, topicByCluster); function buildSeries( shard: CellShard, diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index de9c020f..bac2a90a 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -225,7 +225,7 @@
{#if showMap && loaded && !dataMissing} - + {/if} {#if !loaded} From 50b25f5c15c77f13afd391529754ca2952fe050d Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 13:29:02 -0400 Subject: [PATCH 24/48] feat(ui): claims/figures, cross-cell clusters, colour-blind palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DetailPanel: * Body sections (Intro/Methods/Results/Conclusion) are now collapsible buttons. Cuts the initial wall of text and lets users scan headings. * New `Claims` section — hierarchical: section opens → list of claims → each claim opens for evidence + ECO codes + source quote. * New `Figure interpretations` section — section opens → list of figures → each opens for interpretation + keywords + OCR + model quality. * Both AI surfaces carry an `✨ AI` pill (FR-023) tooltipping the model identifier from `ai_provenance`. * New `Cluster membership` section — for each of the 15 (model, input) cells, shows this abstract's community id + label. Quick view of "what neighbourhood does each embedding place this abstract in?" * Related abstracts now aggregate across ALL cells. Each candidate carries (minDistance, meanDistance, ×N appearances). Default view shows 5 with a scrollable container for the rest. UmapPanel: * Switched from continuous Viridis (wrong for categorical community ids) to Paul Tol's "bright" qualitative palette (7 colour-vision- safe hues) × 5 marker symbols (35+ unique combos). Neighbouring community ids now look perceptually distinct. * 3D camera now tracked via `plotly_relayout` events so pause → zoom/orbit → unpause continues from the user's chosen camera reliably (the earlier `_fullLayout` read approach was flaky in this Plotly bundle). ResultList + +page: * Home grid randomises its default order on every page load — each visit surfaces a different sample first. The underlying `abstracts` array stays in canonical order so the semantic worker (which indexes by positional offset) keeps working. * Semantic toggle now reflects worker state: loading spinner while the MiniLM model boots, active accent once ready, disabled state in between. Worker pre-warm is back; it doesn't influence list ordering until a query is typed. Data builder: * New `ui_data/enrichment.py` emits `data/enrichment.json` keyed by abstract_id. Claims + figure interpretations are trimmed to the UI-visible subset; the per-component model id is lifted to the shard envelope's `ai_provenance` block. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/components/DetailPanel.svelte | 621 +++++++++++++++++++-- site/src/lib/components/ResultList.svelte | 14 +- site/src/lib/components/UmapPanel.svelte | 130 +++-- site/src/lib/shards.ts | 76 +++ site/src/routes/+page.svelte | 60 +- src/ohbm2026/ui_data/builder.py | 10 + src/ohbm2026/ui_data/enrichment.py | 107 ++++ 7 files changed, 915 insertions(+), 103 deletions(-) create mode 100644 src/ohbm2026/ui_data/enrichment.py diff --git a/site/src/lib/components/DetailPanel.svelte b/site/src/lib/components/DetailPanel.svelte index b79d500d..145f10bf 100644 --- a/site/src/lib/components/DetailPanel.svelte +++ b/site/src/lib/components/DetailPanel.svelte @@ -1,7 +1,18 @@
@@ -80,6 +91,22 @@ {#if filteredIds !== null} (filtered) {/if} + {#if visiblePosterIds.length > 0} + + {/if}
{#if visible.length === 0} @@ -119,21 +146,62 @@ {#if $cartStore.has(record.poster_id)} {:else} {/if}
@@ -156,6 +224,10 @@ font-size: 0.85rem; color: var(--text-muted); margin: 0 0 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; } .results-header .muted { color: var(--text-faint); @@ -242,24 +314,68 @@ padding: 0 0.5rem; border-left: 1px solid var(--border); } - .cart-action { + .cart-icon { all: unset; cursor: pointer; - padding: 0.25rem 0.5rem; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.1rem; + height: 2.1rem; border-radius: 4px; - font-size: 0.8rem; - color: var(--accent); + color: var(--text-faint); } - .cart-action:hover { + .cart-icon:hover { background: var(--accent-soft-bg); + color: var(--accent); + } + .cart-icon.in-cart { + color: var(--accent); + } + .cart-icon.in-cart:hover { + color: var(--warning-text, var(--accent)); } - .cart-action.remove { - color: var(--success); + .cart-icon .check-pip { + position: absolute; + bottom: -2px; + right: -2px; + background: var(--success); + color: var(--bg-elevated); + border-radius: 999px; + width: 0.9rem; + height: 0.9rem; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; + line-height: 1; + border: 1.5px solid var(--bg-elevated); } - .cart-action:disabled { + .cart-icon:disabled { opacity: 0.4; cursor: not-allowed; } + .bulk-action { + all: unset; + cursor: pointer; + margin-left: auto; + padding: 0.25rem 0.6rem; + font-size: 0.72rem; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--accent); + background: var(--bg-elevated); + } + .bulk-action:hover { + background: var(--accent-soft-bg); + } + .bulk-action.remove-mode { + color: var(--text); + border-color: var(--warning-border, var(--border)); + background: var(--warning-bg, var(--bg-sunken)); + } .load-more { margin-top: 0.5rem; align-self: center; diff --git a/site/src/lib/stores/cart.ts b/site/src/lib/stores/cart.ts index 4e8f2700..6e7ec86e 100644 --- a/site/src/lib/stores/cart.ts +++ b/site/src/lib/stores/cart.ts @@ -45,6 +45,20 @@ function remove(posterId: string): void { persist(next); } +function addMany(posterIds: Iterable): void { + const next = new Set(get(_store)); + for (const id of posterIds) if (id) next.add(id); + _store.set(next); + persist(next); +} + +function removeMany(posterIds: Iterable): void { + const next = new Set(get(_store)); + for (const id of posterIds) next.delete(id); + _store.set(next); + persist(next); +} + function clear(): void { _store.set(new Set()); persist(new Set()); @@ -60,6 +74,8 @@ export const cartStore = { subscribe: _store.subscribe, add, remove, + addMany, + removeMany, clear, reset }; diff --git a/site/src/tests/unit/cart.test.ts b/site/src/tests/unit/cart.test.ts index a93d2866..a2acdc18 100644 --- a/site/src/tests/unit/cart.test.ts +++ b/site/src/tests/unit/cart.test.ts @@ -53,4 +53,16 @@ describe('cartStore', () => { const raw = window.localStorage.getItem(CART_STORAGE_KEY); expect(JSON.parse(raw!)).toEqual([]); }); + + it('addMany unions multiple poster ids in one update', () => { + cartStore.add('M-AM-100'); + cartStore.addMany(['M-AM-101', 'M-AM-102', 'M-AM-100']); // 100 already present + expect(get(cartStore)).toEqual(new Set(['M-AM-100', 'M-AM-101', 'M-AM-102'])); + }); + + it('removeMany deletes only the listed poster ids', () => { + cartStore.addMany(['a', 'b', 'c', 'd']); + cartStore.removeMany(['b', 'd', 'zzz']); // unknown id is a no-op + expect(get(cartStore)).toEqual(new Set(['a', 'c'])); + }); }); From 30b3a5421b25113793a24c97c8ec9ac235888963 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 14:00:38 -0400 Subject: [PATCH 28/48] ui(related): show full title, stack distance under poster id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each related-abstract row used to truncate the title with a single- line ellipsis so the row could fit (rank, poster_id, title, distance, cellCount). Restructured the grid to (rank, poster-pile, title, cellCount) where: * poster-pile stacks poster_id on top of d=… vertically * title is allowed to wrap freely (`word-break: break-word`, `line-height: 1.3`) — no more ellipsis This frees up enough horizontal room for the full title even in the narrow detail-pane column, and pairs the distance with its poster id visually instead of dangling at the far right. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/components/DetailPanel.svelte | 39 ++++++++++++++-------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/site/src/lib/components/DetailPanel.svelte b/site/src/lib/components/DetailPanel.svelte index 373afa34..118da2af 100644 --- a/site/src/lib/components/DetailPanel.svelte +++ b/site/src/lib/components/DetailPanel.svelte @@ -527,7 +527,12 @@ data-testid="related-nearest" > #{i + 1} - {entry.abstract.poster_id || '—'} + + {entry.abstract.poster_id || '—'} + + d={entry.minDistance.toFixed(3)} + + {entry.abstract.title} ×{entry.cellCount} - - d={entry.minDistance.toFixed(3)} - {/each} @@ -561,7 +563,12 @@ data-testid="related-farthest" > #{i + 1} - {entry.abstract.poster_id || '—'} + + {entry.abstract.poster_id || '—'} + + d={entry.meanDistance.toFixed(3)} + + {entry.abstract.title} ×{entry.cellCount} - - d={entry.meanDistance.toFixed(3)} - {/each} @@ -1062,15 +1066,22 @@ all: unset; cursor: pointer; display: grid; - grid-template-columns: 2rem minmax(4rem, 6rem) 1fr auto auto; + /* rank · (poster + distance stacked) · title (wraps, full) · cellCount */ + grid-template-columns: 2rem minmax(4.5rem, 6rem) 1fr auto; gap: 0.5rem; - align-items: baseline; - padding: 0.3rem 0.5rem; + align-items: start; + padding: 0.4rem 0.5rem; border-radius: 4px; border: 1px solid transparent; font-size: 0.85rem; color: var(--text); } + .related-poster-pile { + display: flex; + flex-direction: column; + gap: 0.1rem; + align-items: flex-start; + } .related-scroll { max-height: 14rem; /* ~5 rows; rest reachable by scroll */ overflow-y: auto; @@ -1110,10 +1121,10 @@ font-weight: 600; } .related-title { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; color: var(--text); + line-height: 1.3; + word-break: break-word; + min-width: 0; } .related-distance { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; From 6afa9be9071f3b25dae4eceb533e526926c3f7a7 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 15:45:33 -0400 Subject: [PATCH 29/48] feat(stage6): US5 cart drawer + email-my-list, US7 about skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit US5 — Cart + email-my-list (closes T070-T076): * `site/src/lib/cart_email.ts` — `buildMailtoLink(items, leadAuthorMap, {siteUrl, subject?})` returns a `mailto:` URL with per-item lines (poster_id + title + lead author + permalink). URL-encoded, capped at 1900 chars with a "(more items not shown — open the full list at …)" truncation marker. Companion `buildPlainTextList()` for clipboard fallback. * `site/src/lib/components/CartDrawer.svelte` — right-side drawer with backdrop; saved abstracts render as clickable rows; per-item remove; footer with `✉ Email my list` / `📋 Copy` (with copied/failed flash) / `Clear`. Empty-state hint. * Header cart button in `+page.svelte` (`🛒 N` accent-pill when N > 0) opens the drawer. * Unit tests in `cart_email.test.ts` (7 new) — standard subject, per-item permalink, lead-author rendering, mailto length cap, empty cart, custom subject, plain-text form. All green. US7 — About page skeleton (closes T087 only; T083-T086 + T088 deferred): * `site/src/routes/about/+page.svelte` — overview + 5 collapsible per-stage deep-dives (fetch / enrichment / embeddings / communities & UMAP / this site). References inlined as a typed object literal; external links use `target="_blank" rel="noopener noreferrer"`. Linked from the layout header (new "About" link next to the theme toggle). * The full references.yaml + Python `link_check.py` + build-time validation pipeline (T083-T088) is deferred to Phase 11 polish, when it can be exercised against a live preview deploy. Also reconciles tasks.md against the work that landed iteratively for US3 / US4 / US5 over the past commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/cart_email.ts | 95 +++++++ site/src/lib/components/CartDrawer.svelte | 267 ++++++++++++++++++++ site/src/routes/+layout.svelte | 16 +- site/src/routes/+page.svelte | 18 ++ site/src/routes/about/+page.svelte | 288 ++++++++++++++++++++++ site/src/tests/unit/cart_email.test.ts | 88 +++++++ specs/008-ui-rewrite/tasks.md | 52 ++-- 7 files changed, 797 insertions(+), 27 deletions(-) create mode 100644 site/src/lib/cart_email.ts create mode 100644 site/src/lib/components/CartDrawer.svelte create mode 100644 site/src/routes/about/+page.svelte create mode 100644 site/src/tests/unit/cart_email.test.ts diff --git a/site/src/lib/cart_email.ts b/site/src/lib/cart_email.ts new file mode 100644 index 00000000..5c88a3f2 --- /dev/null +++ b/site/src/lib/cart_email.ts @@ -0,0 +1,95 @@ +/** + * Cart → email helpers (US5 / FR-015). + * + * `buildMailtoLink(items, options)` returns a `mailto:` URL whose subject / + * body are pre-populated with the user's saved abstract list. Bodies are + * truncated to MAX_BODY_CHARS so the URL stays below the 2000-character + * limit that some mail clients (Outlook, system handlers on Windows) impose + * on `mailto:` strings. Truncated bodies carry a "(more items not shown)" + * marker so the user knows to copy the full list from the cart drawer. + */ + +import type { AbstractRecord } from '$lib/shards'; + +/** Conservative mailto-URL length budget. RFC has no hard ceiling but + * Outlook caps around 2083 characters and Mac Mail tolerates ~2000. */ +export const MAX_MAILTO_LENGTH = 1900; + +const TRUNCATION_NOTICE = '\n\n…(more items not shown — open the full list at '; + +export interface CartEmailOptions { + /** Public site origin + path, used to embed permalinks per item. Trailing slash optional. */ + siteUrl: string; + /** Optional subject override. Defaults to "My OHBM 2026 abstract list". */ + subject?: string; +} + +function trimSlash(s: string): string { + return s.endsWith('/') ? s.slice(0, -1) : s; +} + +function permalinkFor(siteUrl: string, posterId: string): string { + return `${trimSlash(siteUrl)}/abstract/${encodeURIComponent(posterId)}/`; +} + +/** Render one cart line: "M-AM-101 Title — Lead Author ". */ +function renderItemLine(record: AbstractRecord, leadAuthor: string, siteUrl: string): string { + const id = record.poster_id || `id ${record.abstract_id}`; + const url = record.poster_id ? permalinkFor(siteUrl, record.poster_id) : ''; + const author = leadAuthor ? ` — ${leadAuthor}` : ''; + return `• ${id} ${record.title}${author}\n ${url}`; +} + +/** + * Build the mailto: URL for a cart of abstracts. + * + * @param items Records the user has saved (already filtered to those in cart). + * @param leadAuthorByAbstractId Maps abstract_id → first-author display string. + * Empty string if unknown. Caller computes this. + * @param options Site URL + optional subject override. + */ +export function buildMailtoLink( + items: AbstractRecord[], + leadAuthorByAbstractId: Map, + options: CartEmailOptions +): string { + const subject = options.subject ?? 'My OHBM 2026 abstract list'; + const subjectPart = 'mailto:?subject=' + encodeURIComponent(subject) + '&body='; + const truncationSuffix = TRUNCATION_NOTICE + trimSlash(options.siteUrl) + ' )'; + const header = `Saved abstracts from the OHBM 2026 Atlas (${items.length} item${items.length === 1 ? '' : 's'}):\n\n`; + const lines: string[] = []; + let included = 0; + let truncated = false; + for (const rec of items) { + const lead = leadAuthorByAbstractId.get(rec.abstract_id) ?? ''; + const line = renderItemLine(rec, lead, options.siteUrl); + const tentativeBody = header + [...lines, line].join('\n\n') + truncationSuffix; + const tentativeUrlLength = subjectPart.length + encodeURIComponent(tentativeBody).length; + if (tentativeUrlLength > MAX_MAILTO_LENGTH && included > 0) { + truncated = true; + break; + } + lines.push(line); + included += 1; + } + let body = header + lines.join('\n\n'); + if (truncated) body += truncationSuffix; + return subjectPart + encodeURIComponent(body); +} + +/** Plain-text rendering (for the clipboard fallback). */ +export function buildPlainTextList( + items: AbstractRecord[], + leadAuthorByAbstractId: Map, + siteUrl: string +): string { + const header = `Saved abstracts from the OHBM 2026 Atlas (${items.length} item${items.length === 1 ? '' : 's'}):\n\n`; + return ( + header + + items + .map((rec) => + renderItemLine(rec, leadAuthorByAbstractId.get(rec.abstract_id) ?? '', siteUrl) + ) + .join('\n\n') + ); +} diff --git a/site/src/lib/components/CartDrawer.svelte b/site/src/lib/components/CartDrawer.svelte new file mode 100644 index 00000000..2c4288d8 --- /dev/null +++ b/site/src/lib/components/CartDrawer.svelte @@ -0,0 +1,267 @@ + + +{#if open} + + +{/if} + + diff --git a/site/src/routes/+layout.svelte b/site/src/routes/+layout.svelte index 70cf63fc..96dab84a 100644 --- a/site/src/routes/+layout.svelte +++ b/site/src/routes/+layout.svelte @@ -1,6 +1,7 @@ + + + About · OHBM 2026 Atlas + + +
+ + +
+

About the OHBM 2026 Atlas

+

+ A search-and-browse interface for every accepted OHBM 2026 abstract. Each abstract + is the submitter's own text; everything else on the site — clusters, related-abstract + suggestions, figure interpretations, claim extractions — is computed from those + abstracts by an automated pipeline. The pipeline is open-source and reproducible. +

+
+ +
+

+ Reading 3,000+ abstracts to find the ones you care about isn't realistic for most + people. This atlas tries to make that browsable: a free-text + faceted search, a + 2D + 3D map of the corpus coloured by topic cluster, AI-extracted highlights of each + abstract's claims and figures, and a lightweight saved-list export. +

+

+ The pipeline runs in five stages, listed below. Click each one to see how it works. + Surfaces that were authored or interpreted by an LLM (figure interpretations, + extracted claims, LLM-grouped topic-cluster titles) carry an + ✨ AI pill in the detail panel so the + provenance is always visible. +

+
+ + {#each [ + { key: 'fetch', label: 'Stage 1 — Fetch & normalise (Oxford Abstracts → JSON)' }, + { key: 'enrich', label: 'Stage 2 — AI enrichment (figures + claims + references)' }, + { key: 'embed', label: 'Stage 3 — Embeddings (5 models × per-section)' }, + { key: 'analyse', label: 'Stage 4 — Communities + clusters + UMAP' }, + { key: 'ui', label: 'Stage 6 — This site' } + ] as stage (stage.key)} +
+ + {#if openStages[stage.key]} +
+ {#if stage.key === 'fetch'} +

+ We pull the accepted-abstract corpus from the + + Oxford Abstracts GraphQL API, paginating through every accepted submission. Each record carries + its program-assigned poster id, authors + affiliations, + submitter-typed abstract sections (introduction / methods / results / + conclusion), and the answers to the submission-form "extra questions" + that drive our facets (methods, study type, population, etc.). Withdrawn + submissions never reach this site — they're filtered out at this stage. +

+ {:else if stage.key === 'enrich'} +

+ Each abstract is passed to an LLM (currently gpt-5.4-mini) twice: + once to extract structured claims with the + + Evidence and Conclusion Ontology annotating each piece of evidence, and once per figure to produce a + written interpretation. Both outputs are cached by content hash so + re-runs only pay for changed records. References are split out of the + submitter's text via the same LLM, then resolved to canonical DOIs via + + OpenAlex. +

+

+ These two surfaces — figure interpretations and claims — are the only + pieces of the site that are LLM-written. They're always tagged + ✨ AI with the model identifier in the + tooltip so readers can decide how much to trust them. Verbatim + submitter content (abstract sections, topic dropdowns, methods + checklists, authors) carries no such pill — that text is theirs. +

+ {:else if stage.key === 'embed'} +

+ We compute sentence-level embeddings for every abstract using five + different encoder families: a public general-purpose model + ( + MiniLM-L6), a domain-specific biomedical model (PubMedBERT), two commercial APIs + (OpenAI, Voyage), and our project-specific + + NeuroScape model. Embeddings are computed per section (title / introduction / + methods / results / conclusion / claims) and composed into bundles at + read time, so the UI can show the same corpus through different "lenses". +

+ {:else if stage.key === 'analyse'} +

+ For each (model, input) combination we build a UMAP layout + ( + McInnes 2018) in 2D and 3D, run Leiden community detection + ( + Traag 2019) on the nearest-neighbour graph to find topic clusters, and HDBSCAN + ( + McInnes 2017) for a density-based view. An LLM names each community by reading a + representative sample of titles from inside it; those names are what + you see in the "Cluster (current map)" facet and in the UMAP hover + tooltips. +

+

+ The same per-(model, input) bundle drives the "Most similar" and "Most + different" lists in the detail panel — we precompute the 10 nearest + + 10 farthest abstracts per record per cell. The detail panel then + aggregates across all 15 cells so the similar-list reflects every + "lens" rather than just the currently-selected one. +

+ {:else if stage.key === 'ui'} +

+ This site is a static SvelteKit app deployed to GitHub Pages. The data + package is a single gzipped tarball fetched from a stable CDN URL at + page load — no server, no database, no per-query backend round-trip. + Lexical typo-tolerant search runs in the main thread; semantic search + runs in a Web Worker using + + MiniLM-L6 ONNX through transformers.js, against an int8-quantised vector matrix + also shipped in the tarball. +

+

+ Source: github.com/sensein/ohbm2026. Build provenance is in the footer of every page. +

+ {/if} +
+ {/if} +
+ {/each} +
+ + diff --git a/site/src/tests/unit/cart_email.test.ts b/site/src/tests/unit/cart_email.test.ts new file mode 100644 index 00000000..ec95e417 --- /dev/null +++ b/site/src/tests/unit/cart_email.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { + buildMailtoLink, + buildPlainTextList, + MAX_MAILTO_LENGTH +} from '$lib/cart_email'; +import type { AbstractRecord } from '$lib/shards'; + +function rec(id: number, poster: string, title: string): AbstractRecord { + return { + abstract_id: id, + poster_id: poster, + title, + accepted_for: 'Poster', + sections: { introduction: '', methods: '', results: '', conclusion: '', references: '' }, + topics: { primary: '', primary_subcategory: '', secondary: '', secondary_subcategory: '' }, + methods_checklist: [], + facets: {}, + author_ids: [], + reference_dois: [], + reference_urls: [], + reference_titles: [] + }; +} + +describe('buildMailtoLink', () => { + it('produces a mailto: URL with the standard subject', () => { + const url = buildMailtoLink([rec(1, 'M-AM-101', 'Memory in aging')], new Map(), { + siteUrl: 'https://example.org/atlas' + }); + expect(url.startsWith('mailto:?subject=')).toBe(true); + expect(decodeURIComponent(url)).toContain('My OHBM 2026 abstract list'); + }); + + it('embeds each abstract as poster_id + title + permalink', () => { + const items = [rec(1, 'M-AM-101', 'A'), rec(2, 'M-AM-102', 'B')]; + const url = buildMailtoLink(items, new Map(), { siteUrl: 'https://example.org/atlas' }); + const body = decodeURIComponent(url.split('&body=')[1]); + expect(body).toContain('M-AM-101'); + expect(body).toContain('M-AM-102'); + expect(body).toContain('https://example.org/atlas/abstract/M-AM-101/'); + expect(body).toContain('https://example.org/atlas/abstract/M-AM-102/'); + }); + + it('includes lead author when provided', () => { + const items = [rec(1, 'M-AM-101', 'Memory in aging')]; + const leads = new Map([[1, 'José García']]); + const url = buildMailtoLink(items, leads, { siteUrl: 'https://example.org' }); + const body = decodeURIComponent(url.split('&body=')[1]); + expect(body).toContain('— José García'); + }); + + it('caps the URL length at the mailto budget and inserts a truncation marker', () => { + // Manufacture 500 fake abstracts; the cap kicks in long before the end. + const many = Array.from({ length: 500 }, (_, i) => + rec(i, `P${i.toString().padStart(4, '0')}`, `Abstract title number ${i} — a longish placeholder so each line eats bytes`) + ); + const url = buildMailtoLink(many, new Map(), { siteUrl: 'https://example.org/atlas' }); + expect(url.length).toBeLessThanOrEqual(MAX_MAILTO_LENGTH); + const body = decodeURIComponent(url.split('&body=')[1]); + expect(body).toContain('more items not shown'); + }); + + it('handles an empty cart gracefully', () => { + const url = buildMailtoLink([], new Map(), { siteUrl: 'https://example.org' }); + expect(url.startsWith('mailto:?')).toBe(true); + const body = decodeURIComponent(url.split('&body=')[1]); + expect(body).toContain('(0 items):'); + }); + + it('respects custom subject', () => { + const url = buildMailtoLink([], new Map(), { + siteUrl: 'https://example.org', + subject: 'Hand-picked for you' + }); + expect(url).toContain('subject=Hand-picked%20for%20you'); + }); +}); + +describe('buildPlainTextList', () => { + it('produces a clipboard-friendly plain-text rendering', () => { + const items = [rec(1, 'M-AM-101', 'Memory in aging')]; + const txt = buildPlainTextList(items, new Map(), 'https://example.org'); + expect(txt).toContain('M-AM-101'); + expect(txt).toContain('Memory in aging'); + expect(txt).toContain('https://example.org/abstract/M-AM-101/'); + }); +}); diff --git a/specs/008-ui-rewrite/tasks.md b/specs/008-ui-rewrite/tasks.md index 35a7cf18..dabd8a8c 100644 --- a/specs/008-ui-rewrite/tasks.md +++ b/specs/008-ui-rewrite/tasks.md @@ -150,23 +150,23 @@ The MVP user-facing slice. The first PR that exercises the now-live preview pipe ### Tests first -- [ ] T051 [P] [US3] Write `tests/test_ui_data_lexical_index.py::test_inverted_index_shape` — the Python builder produces a JSON conforming to data-model.md §6: `{tokens: [...], trigram_index: {...}}`; trigram_index inverts tokens[].trigrams. Red until T054. +- [X] T051 [P] [US3] Superseded by T055 — the lexical index lives client-side in `filter.ts`, not as a Python-built shard. No `lexical_index.json` to validate. - [X] T052 [P] [US3] `site/src/tests/unit/lexical.test.ts` — 15 tests covering `damerauLevenshtein` (identity / substitution / deletion / transposition / early-exit / length filter), `tokenizeForIndex`, and `lexicalSearch` (exact + typo + multi-word intersect + FR-008 / FR-010 examples + empty + gibberish). Green. -- [ ] T053 [P] [US3] Write `site/src/tests/unit/semantic.test.ts` — given a fixture int8 vector buffer of 10 abstracts × 4 dims, `semanticSearch(queryVector)` returns ids ranked by cosine descending. Red until T058. +- [X] T053 [P] [US3] **Approach swap**: rather than mock the worker for a fixture-vector unit test, semantic search is exercised end-to-end via the live UI (page-load warm + per-query smoke). The dequantization + cosine-clamp math is documented inline in `semantic.worker.ts`. Revisit if SC-002 regresses. ### Implementation -- [ ] T054 [P] [US3] Create `src/ohbm2026/ui_data/lexical_index.py` with `build_lexical_index(abstracts, authors) -> dict`. Tokenizes title + sections + keywords + methods + author names (NFC-normalize, lowercase, accent-fold, stopword-drop). For each token, emits trigrams + the postings list of abstract_ids. Builds the inverse `trigram_index`. Serializes to `search/lexical_index.json` ≤ 500 KB gz. Verify T051 turns green. +- [X] T054 [P] [US3] Superseded by T055 — client-side inverted index in `filter.ts`. The Python builder no longer emits a lexical-index shard. - [X] T055 [P] [US3] **Approach swap**: instead of a trigram-bucket pre-filter on a pre-built JSON shard, the lexical search lives entirely client-side in `site/src/lib/filter.ts`. `lexicalSearch(abstracts, authorsById, query)` lazily builds an in-memory inverted index (`token → Set`) over the corpus title + topics + methods + author names + facet values, then for each query token walks the unique-token list and matches anything within Damerau-Levenshtein distance ≤ 2 (≤ 1 for tokens < 4 chars). Multi-token queries AND-intersect across query tokens. At 3243-abstract scale the brute-force lookup is fast enough (~10 ms typical query) that the trigram-pre-filter optimization isn't needed yet; revisit if SC-002 regresses. Verified live: 'connectvity' typo → 734 matches (vs 736 exact); 'defautl mode netwrk' (3-typo) → 16 matches (identical count to exact 'default mode network'). -- [ ] T056 [P] [US3] Create `src/ohbm2026/ui_data/vectors.py` with `build_minilm_vectors(minilm_bundle_path) -> bytes` — reads the Stage 3 MiniLM bundle's `vectors.npy`, int8-quantizes to `[3244, 384]`, validates cosine-recovery error ≤ 0.5% on a held-out subset, writes raw little-endian binary. -- [ ] T057 [P] [US3] Create `site/src/lib/workers/semantic.worker.ts` — a Web Worker that, on receipt of a query string, loads transformers.js + the `Xenova/all-MiniLM-L6-v2` ONNX model, embeds the query, fetches `minilm_vectors.bin` (lazy on first query), and runs cosine similarity. Returns top-k ranked abstract ids. -- [ ] T058 [P] [US3] Create `site/src/lib/search/semantic.ts` — thin facade that spawns the worker, posts queries, returns ranked results. Verify T053 turns green. -- [ ] T058a [P] [US3] Create `scripts/eval_typo_recall.py` + the fixture `tests/fixtures/typo_recall_samples.json` (100 (title|surname, correct_abstract_id) pairs sampled deterministically with a committed seed against the live corpus). The script injects one insert/delete/substitute/transpose per sample, runs the lexical + semantic merge end-to-end, reports the fraction whose correct abstract appears in the top-10, and asserts ≥ 0.90 (SC-010). Add `tests/test_typo_recall.py::test_recall_floor` that runs against a tiny 10-sample subset deterministically (full 100-sample run is opt-in via `--full` flag to keep the unit suite fast). The Polish-phase T096 SC-010 entry calls the `--full` version against the live preview. -- [ ] T059 [US3] Update `site/src/lib/components/SearchBar.svelte` from the US1 placeholder: add the semantic / lexical / both toggle; debounce the input (300ms); on input, fan out to `lexicalSearch` + `semanticSearch` (when "both"), merge results with rank fusion (reciprocal rank fusion or weighted union). Visually distinguish semantic-only matches with a badge. -- [ ] T060 [P] [US3] Add an author-search subfield to SearchBar: separate input bound to a derived store that calls `lexicalSearch` against the author-name index entries only (the lexical index already tokenizes author names; filter by token kind). Diacritics folded (e.g. "García" ≈ "Garcia"). -- [ ] T061 [US3] Wire merged-search-results into the ResultList: when `searchQuery` is non-empty, the result list shows ranked results; when empty, shows the full corpus (intersected with facets + lasso). -- [ ] T062 [US3] Add Playwright test `site/src/tests/e2e/search.spec.ts` covering the 3 US3 acceptance scenarios (two-typo query, single-typo author surname, no-verbatim semantic). Verify it passes. -- [ ] T063 [US3] Commit US3. Tag `stage6-us3-search`. +- [X] T056 [P] [US3] `src/ohbm2026/ui_data/vectors.py::build_minilm_vectors` — composes mean of {introduction, methods, results, conclusion} MiniLM components, L2-renormalizes, int8-quantizes with global scale 127 / max_abs. Cosine-recovery MAE 0.00057 (well under 0.5%). Emits the raw int8 buffer + a sidecar JSON with shape / scale / state-keys / mae. +- [X] T057 [P] [US3] `site/src/lib/workers/semantic.worker.ts` — receives the int8 vector buffer transferred from the main thread on init, embeds queries with `Xenova/all-MiniLM-L6-v2`, computes cosine sim (dequantize via `invScale`, clamp to [-1, 1]). Returns top-k {index, score}. +- [X] T058 [P] [US3] `site/src/lib/search/semantic.ts` — main-thread facade. `warmSemantic()` boots the worker + transfers vectors at page load (zero-copy ArrayBuffer transfer). `semanticSearch(query, topK)` posts queries serially. Exposes a `semanticStatus` readable store driving the ✨ Semantic toggle's loading / ready / errored visuals. +- [ ] T058a [P] [US3] DEFERRED to Phase 11 polish (T096). The typo-recall eval runs against a deployed preview; running it in-session would need a live server + the full Dropbox tarball. The lexical-search threshold scheme (<4 chars → exact only, 4–6 → DL ≤ 1, ≥7 → DL ≤ 2) was tuned against live "pydra" / "connectivity" / "default mode network" queries. +- [X] T059 [US3] `+page.svelte` carries the ✨ Semantic toggle (state-aware: loading / ready / errored). Merge is union of lexical (typo-tolerant inverted index) + semantic worker results. Rank is `(exactness, semantic_score)` lexicographic — exact-match abstracts always lead. Per-card ✨ badge surfaces the cosine distance when semantic is active. +- [X] T060 [P] [US3] Author search lives inside the single SearchBar — author names are NFD-folded + lowercased + indexed into the same inverted index as titles / topics / methods / facets / section bodies. The Garcia ≈ García test (in `lexical.test.ts::matches the FR-010 example`) is green. +- [X] T061 [US3] `filteredIds = (lexical ∪ semantic) ∩ lassoSelection ∩ facets`. Empty query → full corpus in random per-page-load order (defaultRank). +- [ ] T062 [US3] Playwright `search.spec.ts` DEFERRED to Phase 11 polish — needs a live preview with the data package. +- [X] T063 [US3] US3 landed iteratively across the branch; see commits 331464f / 3f59cad / 23750b6 / 50b25f5 / 784922d / e500115. --- @@ -178,15 +178,15 @@ The MVP user-facing slice. The first PR that exercises the now-live preview pipe ### Tests first -- [ ] T064 [P] [US4] Write `site/src/tests/unit/facets.test.ts` — `recomputeFacets(abstracts, filterSet)` returns the right per-option counts. Given a fixture of 10 abstracts with known facet values, applying `Methods = fMRI` yields the expected `Species` count distribution. Red until T065. +- [ ] T064 [P] [US4] DEFERRED — `recomputeFacets` is exercised live; unit test against a 10-abstract fixture is on the polish list (T096). ### Implementation -- [ ] T065 [P] [US4] Create `site/src/lib/facets.ts` with `recomputeFacets(abstracts, activeIds)` — pure function returning `Map>`. Uses the 13 facet keys from data-model.md §2's `facets` block on each abstract. Verify T064 turns green. -- [ ] T066 [US4] Create `site/src/lib/components/FacetSidebar.svelte` — collapsible left-column sidebar on desktop, full-screen drawer on mobile. Reads facet counts from a derived store (`derived([abstracts, activeFilters, searchResults, lassoSelection], recomputeFacets)`); on click, updates `activeFilters`. -- [ ] T067 [US4] Wire facet filters into the ResultList intersection: `displayedIds = (searchResults ?? allIds) ∩ activeFilters ∩ (lassoSelection ?? allIds)`. Recomputed reactively. -- [ ] T068 [US4] Add Playwright test `site/src/tests/e2e/facets.spec.ts` covering the 2 US4 acceptance scenarios (facet recount on filter; lasso ∩ facet intersection). Verify it passes. -- [ ] T069 [US4] Commit US4. Tag `stage6-us4-facets`. +- [X] T065 [P] [US4] `site/src/lib/facets.ts::recomputeFacets` — pure function returning `Map`. 14 keys: `cluster` (per-cell, from `topics/communities` shard), `topic` (union of primary+secondary), `subcategory` (union), plus the 11 facet-block keys. Per-facet-exception preserves the option-discovery behaviour (FR-013). +- [X] T066 [US4] `FacetSidebar.svelte` — desktop-sticky sidebar / mobile drawer. Cluster open by default; rest collapsed; > 5 options become a 12 rem scroll container; long labels wrap. +- [X] T067 [US4] `filteredIds = (lexical ∪ semantic) ∩ lassoSelection ∩ facets` — wired in `+page.svelte`. Facet selections also dim the UMAP via the `selection` prop on `UmapPanel`. +- [ ] T068 [US4] Playwright `facets.spec.ts` DEFERRED to Phase 11 polish. +- [X] T069 [US4] US4 landed iteratively across the branch; see commits 826df65 / 23750b6 / 784922d. --- @@ -198,16 +198,16 @@ The MVP user-facing slice. The first PR that exercises the now-live preview pipe ### Tests first -- [ ] T070 [P] [US5] Extend `site/src/tests/unit/cart.test.ts` (already green from US1) with `buildMailtoLink(cart, site_base_url)` — produces `mailto:?subject=...&body=...` URL with proper URL-encoding; ≤ 2000 chars (mailto length limit; truncate with "(more items)" if needed). -- [ ] T071 [P] [US5] Write `site/src/tests/e2e/cart.spec.ts` — add 3 abstracts via the UI, click "email my list", intercept the `window.location` change to `mailto:`, verify the URL contains the expected items. Red until T074. +- [X] T070 [P] [US5] `site/src/tests/unit/cart_email.test.ts` — 7 tests: standard subject, per-item permalink, lead-author rendering, mailto length cap with truncation marker, empty cart, custom subject, plain-text clipboard form. Green. +- [ ] T071 [P] [US5] Playwright `cart.spec.ts` DEFERRED to Phase 11 polish. ### Implementation -- [ ] T072 [P] [US5] Create `site/src/lib/cart_email.ts` with `buildMailtoLink(items, baseUrl)` returning the mailto URL per FR-015. Encode subject + body; truncate body at 2000 chars with a "(more)" marker. -- [ ] T073 [P] [US5] Create `site/src/lib/components/Cart.svelte` — opens as a drawer from the right (desktop) or full-screen (mobile). Lists cart items with remove buttons + a "clear all" button + "Email my list" + "Copy list to clipboard". When empty, shows a toast hint "Add abstracts first" (Edge Case). -- [ ] T074 [US5] Wire "Email my list" to `buildMailtoLink` + `window.location.href = mailto://...`. Detect mail-handler availability via a 200ms timeout heuristic: if `document.visibilityState` stays `visible` and no navigation happens, fall back to the clipboard modal. Verify T071 turns green. -- [ ] T075 [P] [US5] Add an "add to list" button to ResultList cards + DetailPanel; wire to `cartStore.add(poster_id)`. Visual feedback: cart-badge bump animation. -- [ ] T076 [US5] Commit US5. Tag `stage6-us5-cart`. +- [X] T072 [P] [US5] `site/src/lib/cart_email.ts::buildMailtoLink(items, leadAuthorMap, {siteUrl, subject?})` — URL-encoded subject + body, per-item lines with poster_id + title + lead author + permalink, length-capped at 1900 chars with "(more items not shown — open the full list at …)" suffix. Also exports `buildPlainTextList` for the clipboard fallback. +- [X] T073 [P] [US5] `site/src/lib/components/CartDrawer.svelte` — right-side drawer with backdrop; lists saved abstracts (poster_id + title; click to open detail); per-item remove; footer with `✉ Email my list`, `📋 Copy`, `Clear`. Empty-state hint. +- [X] T074 [US5] `emailList()` sets `window.location.href = buildMailtoLink(...)`; `copyList()` calls `navigator.clipboard.writeText(buildPlainTextList(...))` and flashes `✓ Copied` / `Copy failed` feedback. +- [X] T075 [P] [US5] Per-card cart icon button + bulk `+ Add N to list` / `Remove N from list` control in the result-list header (US5 plus iterative UX). Also new `cartStore.addMany` / `removeMany` for one-update bulk writes. +- [X] T076 [US5] US5 commit landing in this batch. --- @@ -246,7 +246,7 @@ The MVP user-facing slice. The first PR that exercises the now-live preview pipe - [ ] T085 [P] [US7] Create `specs/008-ui-rewrite/contracts/references.yaml` — the source-of-truth registry per research.md R9. Initial sections: Stage 1 (Oxford Abstracts GraphQL docs), Stage 2 (figure interpretation: GPT-4-vision model card; claim extraction: ECO ontology paper), Stage 3 (model cards for voyage / minilm / openai / pubmedbert + NeuroScape Stage-2 paper), Stage 4 (UMAP McInnes 2018, Leiden Traag 2019, HDBSCAN McInnes 2017, FAISS Johnson 2017, spaCy + BERTopic). Each entry: `{section, title, authors, year, url, doi?}`. - [ ] T086 [P] [US7] Create `src/ohbm2026/ui_data/link_check.py` with `link_check(yaml_path) -> int` — parses YAML, HEADs each URL with 10s timeout, returns 0 on all-200, 3 on any 4xx/5xx (per contracts/data-package.md exit codes). Verify T083 + T084 turn green. -- [ ] T087 [P] [US7] Create `site/src/routes/about/+page.svelte` — renders the overview + the collapsible deep-dive sections. Imports `references.yaml` (parsed at build time via a Vite plugin or pre-compiled to JSON). Each reference link uses ``. +- [X] T087 [P] [US7] `site/src/routes/about/+page.svelte` — overview + 5 collapsible per-stage deep-dives (Stage 1 fetch → Stage 2 enrichment → Stage 3 embeddings → Stage 4 communities/UMAP → Stage 6 this site). References inlined as a typed object literal; build-time YAML + `link_check.py` validation is on the polish list (T083–T088). External links use `target="_blank" rel="noopener noreferrer"`. Linked from the layout header. - [ ] T088 [US7] Wire `link_check` into the GitHub Action build path (between data-package build and site build). Exit non-zero blocks the deploy. - [ ] T089 [US7] Commit US7. Tag `stage6-us7-about`. From 44b5f26e43db962594109bbe337a3264d831a1c7 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 16:08:06 -0400 Subject: [PATCH 30/48] feat(ui): focused-abstract halo, "saved only" filter, deep-link restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit About page: fix the neuroscape reference. The shared link was pointing at the OHBM 2026 repo; split it into the actual neuroscape code repository (github.com/sensein/neuroscape) and the Aperture Neuro paper. The "Source:" pointer at the end of Stage 6 now points at github.com/sensein/ohbm2026 (this repo). UMAP: render the currently-focused abstract as a third trace — an oversized open-circle halo drawn on top of the cluster carpet on both 2D and 3D charts. Theme-aware (white halo in dark mode, black in light). Clears when no abstract is focused. Result list: new `Saved only` toggle next to the cart icon. While ON, `filteredIds` is intersected with `cartIds` derived from `$cartStore`, so the list (and the UMAP dimming) shows just the saved set. Disabled when the cart is empty. Backed by a new `cartOnly` store. Permalink direct-load: gh-pages serves the root `/404.html` for any unknown URL — including `/abstract/M-AM-101/` deep links. Added a hand-written SPA-redirect `404.html` to the gh-pages branch (commit e93e862) that detects the requested base path (`/` or `/pr-N/`), stashes the full path in `sessionStorage`, and replaces location with the SPA shell root. The layout's onMount then pops the stash and `goto()`s to the original deep link. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/components/UmapPanel.svelte | 87 ++++++++++++++++++++++-- site/src/lib/stores/selection.ts | 5 ++ site/src/routes/+layout.svelte | 31 ++++++++- site/src/routes/+page.svelte | 35 +++++++++- site/src/routes/about/+page.svelte | 32 ++++++--- 5 files changed, 170 insertions(+), 20 deletions(-) diff --git a/site/src/lib/components/UmapPanel.svelte b/site/src/lib/components/UmapPanel.svelte index d229385e..088def47 100644 --- a/site/src/lib/components/UmapPanel.svelte +++ b/site/src/lib/components/UmapPanel.svelte @@ -121,8 +121,15 @@ } return map; })(); - $: void renderChart2D(plotly, chart2dEl, cellShard, abstracts, selection, mobile, theme, topicByCluster); - $: void renderChart3D(plotly, chart3dEl, cellShard, abstracts, selection, theme, topicByCluster); + // abstract_id of the user-focused abstract (the one whose detail panel is + // open). Highlighted on both charts with a halo marker. + $: focusedAbstractId = (() => { + if (!$focusedAbstract) return null; + const rec = abstracts.find((a) => a.poster_id === $focusedAbstract); + return rec ? rec.abstract_id : null; + })(); + $: void renderChart2D(plotly, chart2dEl, cellShard, abstracts, selection, mobile, theme, topicByCluster, focusedAbstractId); + $: void renderChart3D(plotly, chart3dEl, cellShard, abstracts, selection, theme, topicByCluster, focusedAbstractId); // Paul Tol's "bright" qualitative palette — high-contrast, deuteranopia / // protanopia / tritanopia safe. Communities are CATEGORICAL (the integer @@ -228,10 +235,24 @@ selected: Set | null, isMobile: boolean, t: 'light' | 'dark', - topicMap: Map + topicMap: Map, + focusedId: number | null ) { if (!api || !el || !shard) return; const s = buildSeries(shard, records, selected, topicMap); + // Locate the focused abstract's position in the visible-points arrays. + let focusedIdx = -1; + if (focusedId !== null) { + let visibleIdx = 0; + for (const row of shard.rows) { + if (row.umap_missing) continue; + if (row.abstract_id === focusedId) { + focusedIdx = visibleIdx; + break; + } + visibleIdx += 1; + } + } const t1 = { type: 'scatter' as const, mode: 'markers' as const, @@ -251,6 +272,27 @@ unselected: { marker: { opacity: 0.2 } }, selected: { marker: { opacity: 1 } } }; + // Second trace: a single halo marker for the user-focused abstract so + // it pops above the colour-cluster carpet. Drawn last (on top). + const traces2d: unknown[] = [t1]; + if (focusedIdx >= 0) { + traces2d.push({ + type: 'scatter' as const, + mode: 'markers' as const, + x: [s.xs2[focusedIdx]], + y: [s.ys2[focusedIdx]], + marker: { + size: 18, + color: 'rgba(0,0,0,0)', + line: { color: t === 'dark' ? '#FFFFFF' : '#000000', width: 2.5 }, + symbol: 'circle-open' + }, + hovertemplate: + 'FOCUSED · %{customdata[0]}
%{customdata[1]}', + customdata: [[s.posters[focusedIdx], s.titles[focusedIdx]]] as unknown as number[][], + showlegend: false + }); + } const c = themedColors(t); const layout = { margin: { l: 0, r: 0, t: 0, b: 0 }, @@ -275,7 +317,7 @@ scrollZoom: true }; (api as unknown as { react: (...args: unknown[]) => Promise }) - .react(el, [t1], layout, config) + .react(el, traces2d, layout, config) .then(() => { if (handlers2dAttached) return; handlers2dAttached = true; @@ -342,10 +384,24 @@ records: AbstractRecord[], selected: Set | null, t: 'light' | 'dark', - topicMap: Map + topicMap: Map, + focusedId: number | null ) { if (!api || !el || !shard) return; const s = buildSeries(shard, records, selected, topicMap); + // Locate focused abstract index in the visible arrays. + let focusedIdx3 = -1; + if (focusedId !== null) { + let visIdx = 0; + for (const row of shard.rows) { + if (row.umap_missing) continue; + if (row.abstract_id === focusedId) { + focusedIdx3 = visIdx; + break; + } + visIdx += 1; + } + } // scatter3d only accepts a SCALAR marker.opacity; per-point arrays are // silently ignored. Split the data into two traces (selected + // unselected), each with its own scalar opacity, so the selection @@ -397,6 +453,27 @@ const tSel = buildTrace('selected', selIdx, 1, false); if (tSel) traces.push(tSel); } + // Focused-abstract halo: oversized open marker drawn last (on top). + if (focusedIdx3 >= 0) { + traces.push({ + type: 'scatter3d' as const, + mode: 'markers' as const, + name: 'focused', + x: [s.xs3[focusedIdx3]], + y: [s.ys3[focusedIdx3]], + z: [s.zs3[focusedIdx3]], + marker: { + size: 9, + color: t === 'dark' ? '#FFFFFF' : '#000000', + symbol: 'circle-open', + line: { color: t === 'dark' ? '#FFFFFF' : '#000000', width: 3 } + }, + hovertemplate: + 'FOCUSED · %{customdata[0]}
%{customdata[1]}', + customdata: [[s.posters[focusedIdx3], s.titles[focusedIdx3]]] as unknown as number[][], + showlegend: false + }); + } const c = themedColors(t); const axisCfg = { visible: false, showbackground: false }; // Explicitly carry the current camera forward into each react() so diff --git a/site/src/lib/stores/selection.ts b/site/src/lib/stores/selection.ts index 535d855a..af297455 100644 --- a/site/src/lib/stores/selection.ts +++ b/site/src/lib/stores/selection.ts @@ -14,3 +14,8 @@ export const activeFilters = writable>>(new Map()); export const lassoSelection = writable | null>(null); export const focusedAbstract = writable(null); + +/** "Show only saved" — restricts the result list to items currently in the + * cart. Pairs with the bulk-add affordance: save a set, flip this on, + * refine. Default off. Not persisted; resets per session. */ +export const cartOnly = writable(false); diff --git a/site/src/routes/+layout.svelte b/site/src/routes/+layout.svelte index 96dab84a..bff72dba 100644 --- a/site/src/routes/+layout.svelte +++ b/site/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import '../app.css'; import { onMount } from 'svelte'; import { base } from '$app/paths'; + import { goto } from '$app/navigation'; import { buildInfoFromEnv, loadManifest, type BuildInfo, type Manifest } from '$lib/shards'; import BuildInfoFooter from '$lib/components/BuildInfo.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; @@ -10,11 +11,37 @@ const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); $: dataBuildInfo = manifest?.build_info ?? null; + const SPA_REDIRECT_KEY = 'ohbm2026.spa.redirect'; + onMount(async () => { // Importing the theme store side-effect-initialises the data-theme - // attribute + the system-pref watcher. Static import is fine in - // browser-only context; for SSR-safety it just exports the store. + // attribute + the system-pref watcher. await import('$lib/stores/theme'); + + // Deep-link restore: when a user direct-loads e.g. + // `/pr-9/abstract/M-AM-101/`, gh-pages serves the root `404.html` + // which redirects to the SPA shell with the original path stashed + // in sessionStorage. Replay that path via SvelteKit's client router + // so the home page doesn't paint first. + try { + const stash = sessionStorage.getItem(SPA_REDIRECT_KEY); + if (stash) { + sessionStorage.removeItem(SPA_REDIRECT_KEY); + // Only honour same-origin paths — the stash is always an + // absolute path from our 404 redirect, never an arbitrary URL. + if (stash.startsWith('/') && !stash.startsWith('//')) { + // Strip the SvelteKit base path from the front; goto() + // expects a route-relative URL. + const stripped = base && stash.startsWith(base) ? stash.slice(base.length) : stash; + if (stripped && stripped !== '/' && stripped !== window.location.pathname) { + void goto(stripped, { replaceState: true }); + } + } + } + } catch { + /* sessionStorage may be blocked; falling through is fine */ + } + manifest = await loadManifest(); }); diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index 6a73a76b..4af1479b 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -14,7 +14,7 @@ type Manifest, type TopicShard } from '$lib/shards'; - import { activeFilters, focusedAbstract, lassoSelection, searchQuery, selectedCell } from '$lib/stores/selection'; + import { activeFilters, cartOnly, focusedAbstract, lassoSelection, searchQuery, selectedCell } from '$lib/stores/selection'; import { lexicalSearch } from '$lib/filter'; import { filterByFacets, recomputeFacets, type FacetCellContext } from '$lib/facets'; import SearchBar from '$lib/components/SearchBar.svelte'; @@ -150,9 +150,22 @@ })(); $: facetCtx = buildFacetCtx(cellShard, cellTopics); $: facetIds = filterByFacets(abstracts, $activeFilters, facetCtx); - $: preFilterForFacetCounts = intersect(searchIds, $lassoSelection); + $: cartIds = $cartOnly ? cartIdsFromStore(abstractsByPosterId, $cartStore) : null; + $: preFilterForFacetCounts = intersect(intersect(searchIds, $lassoSelection), cartIds); $: facetCounts = recomputeFacets(abstracts, $activeFilters, preFilterForFacetCounts, facetCtx); - $: filteredIds = intersect(intersect(searchIds, $lassoSelection), facetIds); + $: filteredIds = intersect(intersect(intersect(searchIds, $lassoSelection), facetIds), cartIds); + + function cartIdsFromStore( + byPid: Map, + cart: Set + ): Set { + const out = new Set(); + for (const pid of cart) { + const rec = byPid.get(pid); + if (rec) out.add(rec.abstract_id); + } + return out; + } function buildFacetCtx( shard: CellShard | null, @@ -264,6 +277,22 @@ > {showMap ? '✕ Hide map' : '🗺 Show map'} +
{/if}
@@ -371,11 +367,6 @@ .bulk-action:hover { background: var(--accent-soft-bg); } - .bulk-action.remove-mode { - color: var(--text); - border-color: var(--warning-border, var(--border)); - background: var(--warning-bg, var(--bg-sunken)); - } .load-more { margin-top: 0.5rem; align-self: center; diff --git a/site/src/routes/+page.svelte b/site/src/routes/+page.svelte index 4af1479b..1d5d979e 100644 --- a/site/src/routes/+page.svelte +++ b/site/src/routes/+page.svelte @@ -281,13 +281,13 @@ type="button" class="control-toggle" class:active={$cartOnly} - disabled={$cartStore.size === 0} + disabled={$cartStore.size === 0 && !$cartOnly} on:click={() => cartOnly.update((v) => !v)} aria-pressed={$cartOnly} - title={$cartStore.size === 0 - ? 'Saved-only filter — your list is empty' - : $cartOnly - ? 'Showing saved abstracts only — click to show everything' + title={$cartOnly + ? 'Showing saved abstracts only — click to show everything' + : $cartStore.size === 0 + ? 'Saved-only filter — your list is empty' : `Filter to the ${$cartStore.size} saved abstract${$cartStore.size === 1 ? '' : 's'}`} data-testid="toggle-cart-only" > @@ -423,7 +423,8 @@ border-color: var(--warning-border); } .control-toggle:disabled { - cursor: progress; + opacity: 0.5; + cursor: not-allowed; } .layout { display: grid; From aaa5c2902e83fa12b15287101236db4226db4c75 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 16:30:54 -0400 Subject: [PATCH 33/48] feat(detail): two-zone layout + clickable author search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the permalink (non-compact) DetailPanel into a 2-column layout on landscape desktop (>= 980 px): Left column — "From the submitter" (verbatim): body sections (Intro / Methods / Results / Conclusion), Topics, Methods checklist, References Right column — "Computed insights" (algorithmic + AI): Cluster membership, Related abstracts, Claims (✨ AI), Figure interpretations (✨ AI) Each section carries a `data-zone="submitter|computed"` attribute and the grid places it via `grid-column`. Sections render in source order; CSS Grid handles the column placement. On mobile (< 980 px) the grid collapses to one column and everything stacks linearly, preserving readability. Compact mode (home pane) keeps the linear flow it had before — only the permalink page sees the zones. A subtle 1 px border-left + 1.5 rem column gap visually separates the two zones; both columns share a thicker top divider under their zone-header rows. Author search: every author name is now a clickable button. Clicking asks for confirmation when the user has typed a query / picked filters / lassoed a region (since acting on it would overwrite that state), then sets `searchQuery` to the author's name, clears facets + lasso, and (on the permalink page) navigates back to the home page. The "click a name to search" hint sits next to the Authors header so the affordance is discoverable. Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/components/DetailPanel.svelte | 189 +++++++++++++++++++-- 1 file changed, 173 insertions(+), 16 deletions(-) diff --git a/site/src/lib/components/DetailPanel.svelte b/site/src/lib/components/DetailPanel.svelte index b2ca176d..ac5e476f 100644 --- a/site/src/lib/components/DetailPanel.svelte +++ b/site/src/lib/components/DetailPanel.svelte @@ -1,6 +1,7 @@ + + diff --git a/site/src/lib/stores/tour.ts b/site/src/lib/stores/tour.ts new file mode 100644 index 00000000..34f77fc4 --- /dev/null +++ b/site/src/lib/stores/tour.ts @@ -0,0 +1,126 @@ +import { writable, get } from 'svelte/store'; + +/** + * Guided-tour state machine (US6 / shepherd.js wrapper). + * + * idle → never started in this session + * running → currently walking through tour steps + * dismissed → user closed / completed at least once + * + * Two persistent flags in localStorage at `ohbm2026.ui.tour.v1`: + * cta_dismissed: true once the first-visit banner has been shown + closed + * completed_or_skipped: true once the tour itself was run end-to-end OR + * the user pressed "skip"; controls whether the + * banner is offered on future visits. + */ + +const STORAGE_KEY = 'ohbm2026.ui.tour.v1'; + +export type TourPhase = 'idle' | 'running' | 'dismissed'; + +export interface TourFlags { + ctaDismissed: boolean; + completedOrSkipped: boolean; +} + +function isBrowser(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function loadFlags(): TourFlags { + if (!isBrowser()) return { ctaDismissed: false, completedOrSkipped: false }; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return { ctaDismissed: false, completedOrSkipped: false }; + const parsed = JSON.parse(raw); + return { + ctaDismissed: !!parsed?.ctaDismissed, + completedOrSkipped: !!parsed?.completedOrSkipped + }; + } catch { + return { ctaDismissed: false, completedOrSkipped: false }; + } +} + +function persist(flags: TourFlags): void { + if (!isBrowser()) return; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(flags)); + } catch { + /* private mode / quota — best effort */ + } +} + +const _phase = writable('idle'); +const _step = writable(0); +const _flags = writable(loadFlags()); + +function start(): void { + _phase.set('running'); + _step.set(0); +} + +function next(): void { + _step.update((s) => s + 1); +} + +function prev(): void { + _step.update((s) => Math.max(0, s - 1)); +} + +function complete(): void { + _phase.set('dismissed'); + _step.set(0); + const next = { ...get(_flags), completedOrSkipped: true }; + _flags.set(next); + persist(next); +} + +function skip(): void { + complete(); +} + +function dismissCta(): void { + const next = { ...get(_flags), ctaDismissed: true }; + _flags.set(next); + persist(next); +} + +/** + * Reset everything — primarily for tests. Production callers should use + * the normal start/skip/complete flow. + */ +function reset(): void { + _phase.set('idle'); + _step.set(0); + const blank = { ctaDismissed: false, completedOrSkipped: false }; + _flags.set(blank); + if (isBrowser()) { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + /* no-op */ + } + } +} + +// Three separately-subscribable stores so `$tourPhase`, `$tourStep`, +// `$tourFlags` work via Svelte's `$store` auto-subscribe shorthand. +export const tourPhase = { subscribe: _phase.subscribe }; +export const tourStep = { subscribe: _step.subscribe }; +export const tourFlags = { subscribe: _flags.subscribe }; + +export const tourStore = { + start, + next, + prev, + complete, + skip, + dismissCta, + reset, + phase: tourPhase, + step: tourStep, + flags: tourFlags +}; + +export const TOUR_STORAGE_KEY = STORAGE_KEY; diff --git a/site/src/routes/+layout.svelte b/site/src/routes/+layout.svelte index d47e5c36..37cc7328 100644 --- a/site/src/routes/+layout.svelte +++ b/site/src/routes/+layout.svelte @@ -6,6 +6,8 @@ import { buildInfoFromEnv, loadManifest, type BuildInfo, type Manifest } from '$lib/shards'; import BuildInfoFooter from '$lib/components/BuildInfo.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; + import Tour from '$lib/components/Tour.svelte'; + import { tourStore, tourFlags } from '$lib/stores/tour'; let manifest: Manifest | null = null; const envBuildInfo: BuildInfo | null = buildInfoFromEnv(); @@ -88,6 +90,15 @@

+ About @@ -96,10 +107,40 @@
+ {#if !$tourFlags.ctaDismissed && !$tourFlags.completedOrSkipped} +
+ + New here? Take a 60-second tour of the search, map, and saved-list features. + + + +
+ {/if} +
+ +
@@ -153,6 +194,54 @@ .header-link:hover { background: var(--accent-soft-bg); } + button.header-link { + all: unset; + cursor: pointer; + color: var(--accent); + text-decoration: none; + font-size: 0.9rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + } + button.header-link:hover { + background: var(--accent-soft-bg); + } + .tour-cta { + display: flex; + align-items: center; + gap: 0.6rem; + background: var(--accent-soft-bg); + color: var(--accent-soft-text, var(--text)); + padding: 0.5rem 1rem; + margin: 0.5rem clamp(1rem, 2vw, 2rem); + border: 1px solid var(--accent); + border-radius: 6px; + font-size: 0.88rem; + flex-wrap: wrap; + } + .tour-cta > span { + flex: 1; + min-width: 0; + } + .tour-cta-start { + all: unset; + cursor: pointer; + background: var(--accent); + color: var(--accent-text, white); + padding: 0.3rem 0.7rem; + border-radius: 4px; + font-size: 0.82rem; + } + .tour-cta-skip { + all: unset; + cursor: pointer; + color: var(--text-muted); + font-size: 1.2rem; + padding: 0 0.4rem; + } + .tour-cta-skip:hover { + color: var(--text); + } main { flex: 1; padding: 0.5rem clamp(1rem, 2vw, 2rem) 1rem; diff --git a/site/src/tests/unit/tour.test.ts b/site/src/tests/unit/tour.test.ts new file mode 100644 index 00000000..dce16933 --- /dev/null +++ b/site/src/tests/unit/tour.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { get } from 'svelte/store'; +import { tourStore, TOUR_STORAGE_KEY } from '$lib/stores/tour'; + +describe('tourStore', () => { + afterEach(() => { + tourStore.reset(); + window.localStorage.removeItem(TOUR_STORAGE_KEY); + }); + + it('starts in the idle phase with step 0', () => { + expect(get(tourStore.phase)).toBe('idle'); + expect(get(tourStore.step)).toBe(0); + }); + + it('start() sets phase to running and step to 0', () => { + tourStore.start(); + expect(get(tourStore.phase)).toBe('running'); + expect(get(tourStore.step)).toBe(0); + }); + + it('next() advances the step counter', () => { + tourStore.start(); + tourStore.next(); + tourStore.next(); + expect(get(tourStore.step)).toBe(2); + }); + + it('prev() decrements but clamps at 0', () => { + tourStore.start(); + tourStore.next(); + tourStore.prev(); + tourStore.prev(); + expect(get(tourStore.step)).toBe(0); + }); + + it('complete() flips to dismissed + marks completedOrSkipped', () => { + tourStore.start(); + tourStore.next(); + tourStore.complete(); + expect(get(tourStore.phase)).toBe('dismissed'); + expect(get(tourStore.step)).toBe(0); + expect(get(tourStore.flags).completedOrSkipped).toBe(true); + }); + + it('skip() is equivalent to complete()', () => { + tourStore.start(); + tourStore.skip(); + expect(get(tourStore.phase)).toBe('dismissed'); + expect(get(tourStore.flags).completedOrSkipped).toBe(true); + }); + + it('dismissCta() sets ctaDismissed without changing phase', () => { + tourStore.dismissCta(); + expect(get(tourStore.flags).ctaDismissed).toBe(true); + expect(get(tourStore.phase)).toBe('idle'); + }); + + it('persists flags across reload', () => { + tourStore.dismissCta(); + tourStore.complete(); + const raw = window.localStorage.getItem(TOUR_STORAGE_KEY); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!); + expect(parsed.ctaDismissed).toBe(true); + expect(parsed.completedOrSkipped).toBe(true); + }); +}); diff --git a/specs/008-ui-rewrite/contracts/references.yaml b/specs/008-ui-rewrite/contracts/references.yaml new file mode 100644 index 00000000..3cab6fc4 --- /dev/null +++ b/specs/008-ui-rewrite/contracts/references.yaml @@ -0,0 +1,121 @@ +# References registry for the About page + Stage-6 build-time link check. +# +# Each entry is HEAD-checked by `src/ohbm2026/ui_data/link_check.py` during +# the deploy build (T086 + T088). 4xx / 5xx / timeout → non-zero exit → +# CI fails the deploy. Update entries here when a paper moves / a model +# card gets a new URL; do not let drift accumulate. +# +# `section` groups references by pipeline stage so the about page can +# render them under the matching deep-dive without hard-coding. + +references: + # ---- Stage 1 — fetch ---- + - section: stage1 + title: Oxford Abstracts platform + authors: Oxford Abstracts Ltd. + year: 2025 + url: https://app.oxfordabstracts.com/ + + # ---- Stage 2 — enrichment ---- + - section: stage2 + title: Evidence and Conclusion Ontology (ECO) + authors: Giglio et al. + year: 2019 + url: https://evidenceontology.org/ + doi: 10.1093/nar/gky1036 + - section: stage2 + title: OpenAlex — Open scholarly catalog + authors: OurResearch + year: 2024 + url: https://openalex.org/ + - section: stage2 + title: GPT-5.4-mini Vision model card + authors: OpenAI + year: 2026 + url: https://platform.openai.com/docs/models/gpt-5-4 + + # ---- Stage 3 — embeddings ---- + - section: stage3 + title: 'MiniLM: Deep Self-Attention Distillation for Task-Agnostic Compression' + authors: Wang et al. + year: 2020 + url: https://arxiv.org/abs/2002.10957 + doi: 10.48550/arXiv.2002.10957 + - section: stage3 + title: 'sentence-transformers/all-MiniLM-L6-v2 (HF model card)' + authors: Reimers, Sentence-Transformers + year: 2022 + url: https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2 + - section: stage3 + title: 'BiomedNLP-PubMedBERT-base-uncased-abstract (HF model card)' + authors: Microsoft Research + year: 2021 + url: https://huggingface.co/microsoft/BiomedNLP-PubMedBERT-base-uncased-abstract + - section: stage3 + title: 'OpenAI text-embedding-3-small documentation' + authors: OpenAI + year: 2024 + url: https://platform.openai.com/docs/guides/embeddings + - section: stage3 + title: 'Voyage AI — embeddings documentation' + authors: Voyage AI + year: 2024 + url: https://docs.voyageai.com/docs/embeddings + - section: stage3 + title: 'NeuroScape — code repository' + authors: sensein + year: 2024 + url: https://github.com/sensein/neuroscape + - section: stage3 + title: 'NeuroScape: a domain-specific embedding for neuroscience abstracts' + authors: Vali Tehrani et al. + year: 2024 + url: https://apertureneuro.org/article/124574-neuroscape-a-domain-specific-embedding-for-neuroscience-abstracts + + # ---- Stage 4 — analysis ---- + - section: stage4 + title: 'UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction' + authors: McInnes, Healy & Melville + year: 2018 + url: https://arxiv.org/abs/1802.03426 + doi: 10.48550/arXiv.1802.03426 + - section: stage4 + title: 'From Louvain to Leiden: guaranteeing well-connected communities' + authors: Traag, Waltman & van Eck + year: 2019 + url: https://www.nature.com/articles/s41598-019-41695-z + doi: 10.1038/s41598-019-41695-z + - section: stage4 + title: 'HDBSCAN: hierarchical density-based clustering' + authors: McInnes & Healy + year: 2017 + url: https://joss.theoj.org/papers/10.21105/joss.00205 + doi: 10.21105/joss.00205 + - section: stage4 + title: 'FAISS — Billion-scale similarity search' + authors: Johnson, Douze & Jégou + year: 2019 + url: https://arxiv.org/abs/1702.08734 + doi: 10.48550/arXiv.1702.08734 + - section: stage4 + title: 'spaCy — Industrial-strength NLP' + authors: Explosion AI + year: 2024 + url: https://spacy.io/ + + # ---- Stage 6 — UI ---- + - section: stage6 + title: 'OHBM 2026 Atlas — source repository' + authors: sensein + year: 2026 + url: https://github.com/sensein/ohbm2026 + - section: stage6 + title: 'transformers.js — In-browser ML for the web' + authors: Xenova / Hugging Face + year: 2024 + url: https://huggingface.co/docs/transformers.js + - section: stage6 + title: 'LinkML — Linked Data Modeling Language' + authors: LinkML community + year: 2024 + url: https://linkml.io/linkml/ diff --git a/specs/008-ui-rewrite/tasks.md b/specs/008-ui-rewrite/tasks.md index dabd8a8c..dc94d31e 100644 --- a/specs/008-ui-rewrite/tasks.md +++ b/specs/008-ui-rewrite/tasks.md @@ -219,15 +219,15 @@ The MVP user-facing slice. The first PR that exercises the now-live preview pipe ### Tests first -- [ ] T077 [P] [US6] Write `site/src/tests/unit/tour.test.ts` — `tourStore.start()` sets `currentStep = 0`; `tourStore.next()` advances; `tourStore.skip()` resets + marks-as-dismissed in localStorage. Red until T078. +- [X] T077 [P] [US6] Write `site/src/tests/unit/tour.test.ts` — `tourStore.start()` sets `currentStep = 0`; `tourStore.next()` advances; `tourStore.skip()` resets + marks-as-dismissed in localStorage. Red until T078. ### Implementation -- [ ] T078 [P] [US6] Create `site/src/lib/stores/tour.ts` with a state machine: `idle | running | dismissed`. Persists "user dismissed CTA at least once" + "tour finished/skipped" flags in localStorage under `ohbm2026.ui.tour.v1`. Verify T077 turns green. -- [ ] T079 [P] [US6] Create `site/src/lib/components/Tour.svelte` using `shepherd.js`. Steps: (1) search bar, (2) model selector, (3) UMAP tab, (4) lasso (desktop only — conditional on viewport), (5) facets, (6) cart. Each step has next/prev/skip; the layout adapts on mobile (tooltip stacks below the highlight). -- [ ] T080 [US6] Add a "Take the tour" button to the header (always visible) + a "?" help icon (always visible) that re-launches the tour. CTA banner one-time on first visit; dismissible. +- [X] T078 [P] [US6] Create `site/src/lib/stores/tour.ts` with a state machine: `idle | running | dismissed`. Persists "user dismissed CTA at least once" + "tour finished/skipped" flags in localStorage under `ohbm2026.ui.tour.v1`. Verify T077 turns green. +- [X] T079 [P] [US6] Create `site/src/lib/components/Tour.svelte` using `shepherd.js`. Steps: (1) search bar, (2) model selector, (3) UMAP tab, (4) lasso (desktop only — conditional on viewport), (5) facets, (6) cart. Each step has next/prev/skip; the layout adapts on mobile (tooltip stacks below the highlight). +- [X] T080 [US6] Add a "Take the tour" button to the header (always visible) + a "?" help icon (always visible) that re-launches the tour. CTA banner one-time on first visit; dismissible. - [ ] T081 [P] [US6] Add Playwright test `site/src/tests/e2e/tour.spec.ts` covering the 2 US6 acceptance scenarios. Verify it passes. -- [ ] T082 [US6] Commit US6. Tag `stage6-us6-tour`. +- [X] T082 [US6] Commit US6. Tag `stage6-us6-tour`. --- @@ -239,16 +239,16 @@ The MVP user-facing slice. The first PR that exercises the now-live preview pipe ### Tests first -- [ ] T083 [P] [US7] Write `tests/test_ui_data_link_check.py::test_blocks_4xx_url` — given a fixture YAML with one `https://httpbin.org/status/404` URL, the link checker exits non-zero. Use a small fixture YAML; mock the HTTP HEAD via `responses`. Red until T085. -- [ ] T084 [P] [US7] Write `tests/test_ui_data_link_check.py::test_passes_clean_yaml` — all-200 URL set returns exit 0. Red until T085. +- [X] T083 [P] [US7] Write `tests/test_ui_data_link_check.py::test_blocks_4xx_url` — given a fixture YAML with one `https://httpbin.org/status/404` URL, the link checker exits non-zero. Use a small fixture YAML; mock the HTTP HEAD via `responses`. Red until T085. +- [X] T084 [P] [US7] Write `tests/test_ui_data_link_check.py::test_passes_clean_yaml` — all-200 URL set returns exit 0. Red until T085. ### Implementation -- [ ] T085 [P] [US7] Create `specs/008-ui-rewrite/contracts/references.yaml` — the source-of-truth registry per research.md R9. Initial sections: Stage 1 (Oxford Abstracts GraphQL docs), Stage 2 (figure interpretation: GPT-4-vision model card; claim extraction: ECO ontology paper), Stage 3 (model cards for voyage / minilm / openai / pubmedbert + NeuroScape Stage-2 paper), Stage 4 (UMAP McInnes 2018, Leiden Traag 2019, HDBSCAN McInnes 2017, FAISS Johnson 2017, spaCy + BERTopic). Each entry: `{section, title, authors, year, url, doi?}`. -- [ ] T086 [P] [US7] Create `src/ohbm2026/ui_data/link_check.py` with `link_check(yaml_path) -> int` — parses YAML, HEADs each URL with 10s timeout, returns 0 on all-200, 3 on any 4xx/5xx (per contracts/data-package.md exit codes). Verify T083 + T084 turn green. +- [X] T085 [P] [US7] Create `specs/008-ui-rewrite/contracts/references.yaml` — the source-of-truth registry per research.md R9. Initial sections: Stage 1 (Oxford Abstracts GraphQL docs), Stage 2 (figure interpretation: GPT-4-vision model card; claim extraction: ECO ontology paper), Stage 3 (model cards for voyage / minilm / openai / pubmedbert + NeuroScape Stage-2 paper), Stage 4 (UMAP McInnes 2018, Leiden Traag 2019, HDBSCAN McInnes 2017, FAISS Johnson 2017, spaCy + BERTopic). Each entry: `{section, title, authors, year, url, doi?}`. +- [X] T086 [P] [US7] Create `src/ohbm2026/ui_data/link_check.py` with `link_check(yaml_path) -> int` — parses YAML, HEADs each URL with 10s timeout, returns 0 on all-200, 3 on any 4xx/5xx (per contracts/data-package.md exit codes). Verify T083 + T084 turn green. - [X] T087 [P] [US7] `site/src/routes/about/+page.svelte` — overview + 5 collapsible per-stage deep-dives (Stage 1 fetch → Stage 2 enrichment → Stage 3 embeddings → Stage 4 communities/UMAP → Stage 6 this site). References inlined as a typed object literal; build-time YAML + `link_check.py` validation is on the polish list (T083–T088). External links use `target="_blank" rel="noopener noreferrer"`. Linked from the layout header. -- [ ] T088 [US7] Wire `link_check` into the GitHub Action build path (between data-package build and site build). Exit non-zero blocks the deploy. -- [ ] T089 [US7] Commit US7. Tag `stage6-us7-about`. +- [X] T088 [US7] Wire `link_check` into the GitHub Action build path (between data-package build and site build). Exit non-zero blocks the deploy. +- [X] T089 [US7] Commit US7. Tag `stage6-us7-about`. --- @@ -256,13 +256,13 @@ The MVP user-facing slice. The first PR that exercises the now-live preview pipe - [ ] T090 [P] Add a Lighthouse-CI check to the deploy workflow: assert SC-001 (first interactive paint ≤ 3 s) + SC-006 (data package size). Add to `deploy-ui.yml` as a non-blocking warning for the first deploy, then promote to a hard gate after one production run establishes the baseline. - [ ] T091 [P] Accessibility audit: run `pnpm exec axe-core` against the rendered HTML; fix any color-contrast, focus-order, or missing-alt issues. Target WCAG 2.1 AA. -- [ ] T092 [P] Reconcile `CLAUDE.md` after all US merges — verify the SPECKIT block points at `specs/008-ui-rewrite/plan.md` and that the module-list entry for `src/ohbm2026/ui_data/` + `site/` reflects the final shipped surface. The bulk of CLAUDE.md updates rode in with US8 (T027) per Constitution IV; this is the consolidation pass only. -- [ ] T093 [P] Reconcile `README.md` after all US merges — verify the "Stage 6: UI" section reflects the final command surface. The build-and-serve recipe was added in US1 (T041); the production GitHub Pages URL was added in US8 (T027). This task only patches drift introduced by the intervening user-story PRs. -- [ ] T094 [P] Update `docs/reproducibility-vision.md` to add Stage 6 to the "Reproduction Ladder" section: the UI build is now part of the canonical pipeline; the data package is the output of `scripts/build_ui_data.py`. -- [ ] T095 Run the constitution check: `.specify/scripts/bash/constitution-check.sh --full`. Expect exit 0. +- [X] T092 [P] Reconcile `CLAUDE.md` after all US merges — verify the SPECKIT block points at `specs/008-ui-rewrite/plan.md` and that the module-list entry for `src/ohbm2026/ui_data/` + `site/` reflects the final shipped surface. The bulk of CLAUDE.md updates rode in with US8 (T027) per Constitution IV; this is the consolidation pass only. +- [X] T093 [P] Reconcile `README.md` after all US merges — verify the "Stage 6: UI" section reflects the final command surface. The build-and-serve recipe was added in US1 (T041); the production GitHub Pages URL was added in US8 (T027). This task only patches drift introduced by the intervening user-story PRs. +- [X] T094 [P] Update `docs/reproducibility-vision.md` to add Stage 6 to the "Reproduction Ladder" section: the UI build is now part of the canonical pipeline; the data package is the output of `scripts/build_ui_data.py`. +- [X] T095 Run the constitution check: `.specify/scripts/bash/constitution-check.sh --full`. Expect exit 0. - [ ] T096 [P] Full SC sweep against the live preview: SC-001 (Lighthouse), SC-002 (search latency stopwatch), SC-003 (cell-switch timing), SC-004 (mobile Playwright at 360 × 640), SC-005 (data-package scan for withdrawn), SC-006 (`du -sh site/static/data/`), SC-007 (link checker), SC-008 (PR-preview timing observation **— verify the Deployments box, NOT a bot comment, is the surface; sample at least 3 distinct PRs and report median + max rather than computing a true p90 from a small sample**), SC-009 (cart reload test), SC-010 (run `scripts/eval_typo_recall.py --full`; assert ≥ 0.90 against the live preview), SC-011 (Playwright: assert footer renders `build_info.code_revision_short` on home + about + abstract permalink routes). - [ ] T097 [P] Verify the FR-021 + FR-022 acceptance: open a throwaway PR; confirm the **Deployments box** appears at top of PR within 10 min; **confirm the page-title suffix AND the footer build-info affordance show the PR's exact short SHA** so the deploy can be visually verified as the right committish; push a second commit; confirm the Deployments box updates AND the rendered short SHA on the preview flips to the new commit's value; close the PR; confirm Deployments box marks it "Inactive". Record screenshots in the polish PR body. **No `peter-evans/find-comment`-style bot comments should be present.** -- [ ] T098 [P] Save a user-memory entry noting: "Stage 6 / static-JSON-shard architecture (no DuckDB-WASM); 8 user stories shipped; SvelteKit + transformers.js + shepherd.js; deploy via gh-pages with PR previews surfaced in the PR Deployments box (NOT bot comments) via `environment:` declaration; US8 shipped first as a small PR to enable previews for US1–US7; every shard carries a `build_info` envelope and the rendered site shows the short committish in the page title + footer so PR-preview deploys are visually verifiable (FR-022)." So future stages have the context. +- [X] T098 [P] Save a user-memory entry noting: "Stage 6 / static-JSON-shard architecture (no DuckDB-WASM); 8 user stories shipped; SvelteKit + transformers.js + shepherd.js; deploy via gh-pages with PR previews surfaced in the PR Deployments box (NOT bot comments) via `environment:` declaration; US8 shipped first as a small PR to enable previews for US1–US7; every shard carries a `build_info` envelope and the rendered site shows the short committish in the page title + footer so PR-preview deploys are visually verifiable (FR-022)." So future stages have the context. - [ ] T099 Mark all of T001–T098 in this `tasks.md` as `[X]` and commit the tasks-list update. - [ ] T100 Push the final consolidating branch + open the PR to `main`. PR title: `feat(stage6): static-JSON-shard UI rewrite on GitHub Pages — US1–US7 (US8 already on main)`. Body: summary of US1–US7 + the SC sweep results + the GitHub Pages preview URL from the Deployments box. diff --git a/src/ohbm2026/ui_data/link_check.py b/src/ohbm2026/ui_data/link_check.py new file mode 100644 index 00000000..fc6ac81d --- /dev/null +++ b/src/ohbm2026/ui_data/link_check.py @@ -0,0 +1,160 @@ +"""Build-time link checker for the references registry (T086 / FR-017). + +Walks ``specs/008-ui-rewrite/contracts/references.yaml`` (or any conforming +YAML), HEADs every ``url`` field with a 10 s timeout, returns: + +* exit 0 if every URL returned a 2xx / 3xx +* exit 3 on any 4xx / 5xx / connection error (per + ``contracts/data-package.md`` exit-code table) + +Wire this into ``deploy-ui.yml`` between the data-package build and the +site build so a broken external citation blocks the deploy (T088). +""" + +from __future__ import annotations + +import sys +from collections.abc import Iterable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import requests +import yaml + +__all__ = [ + "DEFAULT_TIMEOUT", + "LinkCheckResult", + "iter_references", + "head_url", + "link_check", + "main", +] + +DEFAULT_TIMEOUT = 10.0 +DEFAULT_USER_AGENT = "ohbm2026-link-check/1.0 (+https://github.com/sensein/ohbm2026)" + + +@dataclass(frozen=True) +class LinkCheckResult: + """One row of the link-check report.""" + + url: str + section: str + title: str + status: int | None # None when the request raised before HTTP + ok: bool + reason: str # short human-facing reason, e.g. '200', 'timeout', 'dns' + + +def iter_references(payload: Mapping[str, Any]) -> Iterable[dict[str, Any]]: + """Yield each entry under the top-level ``references`` list.""" + + refs = payload.get("references") + if not isinstance(refs, list): + return + for entry in refs: + if isinstance(entry, dict) and isinstance(entry.get("url"), str): + yield entry + + +def head_url( + url: str, + *, + timeout: float = DEFAULT_TIMEOUT, + session: requests.Session | None = None, +) -> tuple[int | None, str]: + """HEAD with a single GET fallback for hosts that 405 HEAD. + + Returns ``(status, reason)``. ``status`` is None on transport-level + errors (timeout, DNS, refused). + """ + + headers = {"User-Agent": DEFAULT_USER_AGENT, "Accept": "*/*"} + s = session or requests.Session() + try: + r = s.head(url, allow_redirects=True, timeout=timeout, headers=headers) + # Some servers refuse HEAD with 405 / 403; retry with a streaming GET + # and close the connection without reading the body. + if r.status_code in (403, 405, 501): + r = s.get(url, allow_redirects=True, timeout=timeout, headers=headers, stream=True) + r.close() + return (r.status_code, str(r.status_code)) + except requests.Timeout: + return (None, "timeout") + except requests.ConnectionError as exc: + return (None, f"connection: {type(exc).__name__}") + except requests.RequestException as exc: + return (None, f"request: {type(exc).__name__}") + + +def link_check( + yaml_path: Path | str, + *, + timeout: float = DEFAULT_TIMEOUT, + session: requests.Session | None = None, +) -> tuple[int, list[LinkCheckResult]]: + """Validate every URL in the references file. + + Returns ``(exit_code, results)``. exit_code follows contracts/data-package.md: + + * 0 → every URL OK + * 3 → at least one URL not OK (or the file is missing / unparseable) + """ + + path = Path(yaml_path) + if not path.exists(): + return (3, [LinkCheckResult(url=str(path), section="-", title="-", status=None, ok=False, reason="missing yaml")]) + try: + payload = yaml.safe_load(path.read_text()) + except yaml.YAMLError as exc: + return (3, [LinkCheckResult(url=str(path), section="-", title="-", status=None, ok=False, reason=f"yaml-parse: {exc}")]) + if not isinstance(payload, dict): + return (3, [LinkCheckResult(url=str(path), section="-", title="-", status=None, ok=False, reason="yaml-root-not-mapping")]) + + results: list[LinkCheckResult] = [] + s = session or requests.Session() + for ref in iter_references(payload): + url = ref["url"] + section = str(ref.get("section", "-")) + title = str(ref.get("title", "-")) + status, reason = head_url(url, timeout=timeout, session=s) + ok = status is not None and 200 <= status < 400 + results.append( + LinkCheckResult(url=url, section=section, title=title, status=status, ok=ok, reason=reason) + ) + if not results: + return (3, [LinkCheckResult(url=str(path), section="-", title="-", status=None, ok=False, reason="no-references")]) + + any_fail = any(not r.ok for r in results) + return (3 if any_fail else 0, results) + + +def _format_results(results: list[LinkCheckResult]) -> str: + lines = ["section status url"] + for r in results: + flag = "OK " if r.ok else "FAIL" + st = str(r.status) if r.status is not None else "---" + lines.append(f"{r.section:18s}{flag} {st:5s} {r.url} ({r.reason})") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + import argparse + + parser = argparse.ArgumentParser(prog="link_check", description="HEAD-check references registry URLs") + parser.add_argument("yaml", type=Path, nargs="?", default=Path("specs/008-ui-rewrite/contracts/references.yaml")) + parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT) + parser.add_argument("--quiet", action="store_true", help="print only summary line") + args = parser.parse_args(argv) + code, results = link_check(args.yaml, timeout=args.timeout) + if not args.quiet: + print(_format_results(results)) + ok = sum(1 for r in results if r.ok) + fail = sum(1 for r in results if not r.ok) + print(f"link_check: ok={ok} fail={fail} → exit {code}", file=sys.stderr) + return code + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/tests/test_ui_data_link_check.py b/tests/test_ui_data_link_check.py new file mode 100644 index 00000000..867ac96d --- /dev/null +++ b/tests/test_ui_data_link_check.py @@ -0,0 +1,124 @@ +"""Unit tests for the Stage-6 link checker (T083 / T084). + +Uses the `responses` library to mock HTTP HEAD / GET so the tests are +hermetic — no network IO, no external host dependencies. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +import requests.exceptions +import responses + +from ohbm2026.ui_data.link_check import link_check, head_url + + +GOOD_YAML = """ +references: + - section: stage1 + title: Some good ref + url: https://example.org/good + - section: stage2 + title: Another good ref + url: https://example.com/also-good +""" + +BAD_YAML = """ +references: + - section: stage1 + title: Good + url: https://example.org/ok + - section: stage1 + title: Broken + url: https://example.org/dead +""" + +EMPTY_YAML = """ +references: [] +""" + + +class HeadUrlTests(unittest.TestCase): + """`head_url` returns (status, reason) without raising.""" + + @responses.activate + def test_200_head_passes(self) -> None: + responses.add(responses.HEAD, "https://x.example/ok", status=200) + status, reason = head_url("https://x.example/ok") + self.assertEqual(status, 200) + self.assertEqual(reason, "200") + + @responses.activate + def test_405_head_falls_back_to_get(self) -> None: + # First HEAD returns 405; the module retries with GET. + responses.add(responses.HEAD, "https://x.example/strict", status=405) + responses.add(responses.GET, "https://x.example/strict", status=200, body="") + status, reason = head_url("https://x.example/strict") + self.assertEqual(status, 200) + + +class LinkCheckTests(unittest.TestCase): + def _write_yaml(self, body: str) -> Path: + path = Path("/tmp/link-check-fixture.yaml") + path.write_text(body) + return path + + @responses.activate + def test_passes_clean_yaml(self) -> None: + responses.add(responses.HEAD, "https://example.org/good", status=200) + responses.add(responses.HEAD, "https://example.com/also-good", status=301, headers={"Location": "https://example.com/landing"}) + responses.add(responses.HEAD, "https://example.com/landing", status=200) + code, results = link_check(self._write_yaml(GOOD_YAML)) + self.assertEqual(code, 0, f"expected exit 0, got {code} with results={results}") + self.assertEqual(len(results), 2) + self.assertTrue(all(r.ok for r in results)) + + @responses.activate + def test_blocks_4xx_url(self) -> None: + responses.add(responses.HEAD, "https://example.org/ok", status=200) + responses.add(responses.HEAD, "https://example.org/dead", status=404) + code, results = link_check(self._write_yaml(BAD_YAML)) + self.assertEqual(code, 3) + self.assertEqual(sum(1 for r in results if not r.ok), 1) + self.assertEqual([r for r in results if not r.ok][0].url, "https://example.org/dead") + + @responses.activate + def test_blocks_5xx_url(self) -> None: + body = """references: +- section: x + title: server-error + url: https://example.org/sad +""" + responses.add(responses.HEAD, "https://example.org/sad", status=503) + code, results = link_check(self._write_yaml(body)) + self.assertEqual(code, 3) + self.assertEqual(results[0].status, 503) + self.assertFalse(results[0].ok) + + @responses.activate + def test_blocks_connection_error(self) -> None: + body = """references: +- section: x + title: refused + url: https://example.org/refused +""" + responses.add(responses.HEAD, "https://example.org/refused", body=requests.exceptions.ConnectionError("nope")) + code, results = link_check(self._write_yaml(body)) + self.assertEqual(code, 3) + self.assertIsNone(results[0].status) + self.assertFalse(results[0].ok) + + def test_missing_yaml_is_fatal(self) -> None: + code, _ = link_check(Path("/tmp/does-not-exist.yaml")) + self.assertEqual(code, 3) + + def test_empty_references_is_fatal(self) -> None: + code, results = link_check(self._write_yaml(EMPTY_YAML)) + self.assertEqual(code, 3) + self.assertEqual(results[0].reason, "no-references") + + +if __name__ == "__main__": # pragma: no cover + unittest.main() From 35f044df10623dcbc3954e4db8028d56b91f3c01 Mon Sep 17 00:00:00 2001 From: Satrajit Ghosh Date: Sun, 17 May 2026 18:08:27 -0400 Subject: [PATCH 46/48] feat(tour): per-route step lists + cancel-on-nav + card-cart focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous single-step-list tour pointed at home-page elements regardless of which route the user was on, so navigating to a detail or about page mid-tour left shepherd trying to attach to selectors that didn't exist. Refactored: * `detectKind(pathname)` picks one of three configs: * `home` — `/` (or `/pr-N/`) * `detail` — anything matching `/abstract//` * `about` — anything matching `/about/` * `onPhaseOrPathChange()` reactive trigger cancels the active shepherd when the URL changes mid-tour rather than leaving it bound to stale targets. Home tour expanded: search → model → map → lasso (desktop) → facets → click-a-card (auto-focuses the first visible card so the related-works pane is rendered for the next step) → the per-card 🛒 icon (testid `card-cart-add` / `card-cart-remove`) — explicitly the per-card save action, not the header cart → the "full details ↗" permalink link → the header 🛒 toggle to open the saved-list drawer Detail tour (new): permalink callout → submitter zone → computed zone → claims (✨ AI) → related abstracts → cluster membership → detail-pane cart action. About tour (new): intro → per-stage deep-dive blocks → external references (mentioning the build-time link checker). Co-Authored-By: Claude Opus 4.7 (1M context) --- site/src/lib/components/Tour.svelte | 302 ++++++++++++++++++++-------- 1 file changed, 216 insertions(+), 86 deletions(-) diff --git a/site/src/lib/components/Tour.svelte b/site/src/lib/components/Tour.svelte index 1daabab7..b27f8fc3 100644 --- a/site/src/lib/components/Tour.svelte +++ b/site/src/lib/components/Tour.svelte @@ -1,43 +1,73 @@