Token maps: map every late-interaction token as its own point, with token-level SAE features - #160
Conversation
…ken SAE Late-interaction embeddings already store per-token vectors; this adds the pipeline to map every token as its own point while keeping the link back to the parent document: - ls-tokenize: rebuilds pylate's document tokenization (marker insertion at position 1 after truncating to document_length-1, punctuation skiplist) with char offsets, validates counts exactly against the stored token vectors, and writes a tok-<embedding> LanceDB table (token_index, parent ls_index, token_pos, token_str, char span). Aborts without writing on any mismatch. - embedding_store: streaming iter_token_vectors (never materializes the full token set), num_tokens loader, token metadata read/write/indexes. - ls-umap --granularity tokens: fit on <= fit_sample uniformly sampled tokens, batch-transform the rest through the fitted reducer (cuML or CPU). - ls-cluster: granularity guards (token umaps cannot cluster on the row-level embedding matrix or a dataset column) and token-frequency default cluster labels. - ls-scope: token scopes keep ls_index == positional token index and carry parent_index/token_pos/token_str; token-level -input.parquet stores char spans instead of duplicating document text; lance scope export skipped at token granularity. - ls-sae --granularity tokens --checkpoint <dir>: streams token vectors through a local (load_from_disk) or hub SAE, one h5 row per token; per-feature stats vectorized (the per-row loop was minutes at 1M rows). - server: /api/jobs/tokenize route, granularity/fit_sample/checkpoint args on the umap/sae job routes, and POST /api/tokens/indexed returning parent document rows + token string/char span for token-index lists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ures When a scope has granularity "tokens" (one scatter point per late-interaction token), the Explore page now: - fetches table pages through POST /api/tokens/indexed (token indices -> parent-document rows + token metadata), keeping the row shape the grid already expects - renders the text column as a windowed snippet around the token's char span with the token highlighted in place (pure substring slices, no innerHTML; helper + 18 vitest cases in lib/tokenSnippet), plus a compact frozen "token" column with the cleaned surface string - resolves hover locally from scopeRows token_str (no server roundtrip) and shows the token prominently in the tooltip - enables the SAE feature surface from the scope's own sae metadata (not just the CDN-labeled model allowlist), building the feature list from the per-dataset features endpoint with generic labels when no label parquet exists; feature filter and FeaturePlot work with token-level indices - hides NN text search and column filters for token scopes (they return document indices; doc->token MaxSim mapping is follow-up) including the URL-driven ?search= path - perf for ~1M-point scopes: Set membership instead of Array.includes per point in drawingPoints, direct scopeRows[ls_index] lookup instead of a linear find per selected row, and featureActivationMap wired to the real page rows (the old dataTableRows prop was hardcoded []) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…b at size 30) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 793418a620
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if sae_id and scope.get("sae", {}).get("granularity", "rows") != "tokens": | ||
| raise ValueError( | ||
| f"umap {umap_id} is token-granularity but sae {sae_id} is not; " | ||
| "re-run ls-sae with --granularity tokens") |
There was a problem hiding this comment.
For token scopes this only checks granularity, so ls-scope ... --sae_id sae-001 still succeeds when that token SAE was produced for a different embedding, or for a stale token run with fewer rows. The UI and /api/tokens/indexed then index the SAE HDF5 by the current token indices, which can silently show activations for the wrong token vectors or 500 on out-of-range indices. Please also validate the SAE metadata's embedding_id and rows against the current embedding/token count before accepting it.
Useful? React with 👍 / 👎.
| // Column filters operate on dataset rows, not tokens, so they're hidden for | ||
| // token scopes too. | ||
| if (columnOptions.length > 0 && !isTokenScope) { |
There was a problem hiding this comment.
Guard URL column filters for token scopes
Hiding column filters from the dropdown is not enough because FilterContext still hydrates ?column=...&value=... and runs the COLUMN filter path, whose result is a list of document row indices. In a token scope those values are then treated as token indices and fetched through /api/tokens/indexed, so a shared or stale column-filter URL selects arbitrary tokens instead of matching parent documents. Add the same isTokenScope guard to URL hydration or the COLUMN switch that the SEARCH path now has.
Useful? React with 👍 / 👎.
… into token-maps Token-mode adaptations to the refactored UI: - FilterDataTableBody takes scope/isTokenScope as props from the connected variant (the body is now presentational; standalone Setup/Preview tables stay row-level) - token hover moved into the new floating hover-card Panel, now with the passage context hydrated via /api/tokens/indexed and the token highlighted in place (design-system accent surface/line) - token cell/highlight styles restyled onto the Amber Console variables Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…context, SAE labels - Scope rows for token scopes now load as parquet bytes parsed client-side with hyparquet (already a dep) via the existing /api/files route, selecting only the columns Explore reads: ~20MB on the wire at 1M tokens vs 227MB of row JSON. BigInt columns coerced to numbers to preserve the ls_index === position invariant. - Table snippet cell keeps the token visible at any cell width: three-segment flex where before-context clips from the left (dir=rtl + <bdi dir=ltr> so the ellipsis lands left without reordering the text) and after-context clips from the right. - Hover card now hydrates the token's passage (parent text + char span via /api/tokens/indexed, debounced) and highlights the token in place; the token string still renders instantly from scopeRows. - PointDetail highlights the token's span inside the full document text. - SAE feature labels: new saeLabels map in lib/SAE.js keyed by the SAE's own model repo (scope-declared SAEs like token SAEs aren't tied to an embedding model entry); ScopeContext loads the latent-taxonomy label parquet when mapped, merges per-dataset stats, and falls back to generic "Feature N" labels if the parquet is missing/unpublished. COLBERT_JINA_64K is mapped (labels go live when the latent-taxonomy site deploys its pending package). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round 2 (pushed): merged latest main + UX fixes from testingMerged Binary scope-rows transport — token scopes now fetch the scope parquet bytes directly (existing Token stays visible in the table snippet — three-segment flex cell: before-context ellipsizes from the left ( Passage context everywhere — the hover card hydrates the parent passage via SAE feature labels — new 🤖 Generated with Claude Code |
GitHub Pages answers range requests with 416 when the client negotiates gzip (byte offsets computed from the uncompressed length don't exist in the compressed representation, and browsers always send Accept-Encoding), so hyparquet's asyncBufferFromUrl reader failed against the deployed label parquet in real browsers — confirmed with curl: a plain ranged GET returns the PAR1 footer, the same request with Accept-Encoding: gzip returns 416. getFeatures now fetches the whole parquet (a few MB, gzip-friendly) and parses from the buffer with parquetReadObjects on named columns — also removing the positional f[0]/f[6] column-order coupling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
SAE labels now working end-to-end from the deployed latent-taxonomy site ( After the COLBERT_JINA_64K package deployed, labels still failed in the browser — root cause was infrastructure, not the wiring: GitHub Pages returns 416 for range requests when the client negotiates gzip (byte offsets from the uncompressed length don't exist in the compressed representation, and browsers always send
Verified headless against the live CDN: feature modal shows the full span labels + per-dataset stats for the 64K token SAE. 🤖 Generated with Claude Code |
Selecting a feature while a cluster filter was active left both params in the URL; the restore effect reads only the first param, so the active-filter chip kept showing the cluster and clearing removed both. Filter writers now share applyFilterToUrlParams (clears the other filter keys before setting) and clear the other filter hooks' state when switching type. Also re-run the filter effect when features finish loading (a feature filter applied before labels arrived silently returned empty), and register the MiniLM Stage-J 49K label parquet in saeLabels. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Filter single-select fix ( Also registered the MiniLM Stage-J 49K label parquet in |
…SAE taxonomy links latentsae's encoders call topk with sorted=False, so the stored top_indices/top_acts arrays are in arbitrary order. The modal sliced the raw array, showing an arbitrary 15 of the top-k and dropping genuinely strong features (a rank-6 activation at array position 31 was invisible). It now sorts by activation (dropping zero-act padding) before slicing; the canvas hover highlight maps through the original array position. Also: the "open in latent-taxonomy" link was hardcoded to the NOMIC model — it now resolves the scope's own SAE via saeLabels/saeAvailable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Follow-up fix: the feature modal's "Top 15" sliced the raw h5 top-k arrays, but latentsae stores them unsorted ( |
…ken-maps Both sides had wired MiniLM labels and fixed the taxonomy deep-link independently; resolved to main's getSaeForModel registry style with the token-scope additions on top (scope-declared SAEs resolve links/labels by the SAE's own model repo via saeLabels). FeatureModal keeps the activation-ranking fix alongside main's saeEntry link. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What
For ColBERT-style late-interaction embeddings (which already store per-token vectors), this adds a token granularity through the whole product: instead of one point per document (mean-pooled), the map shows one point per token, while the table keeps documents readable by showing the parent text with the selected token highlighted in context. Token-level SAEs plug in as first-class feature surfaces.
Pipeline
Full docs in
docs/token-maps.md.Key design points
ls-tokenizereplays pylate's document pipeline (tokenize todocument_length-1, insert the[DocumentMarker]at position 1, drop the 32-symbol punctuation skiplist) withreturn_offsets_mapping, and validates every document's kept-token count against the storednum_tokens. Verified against real models: jina-colbert-v2 and answerai-colbert-small-v1, including truncation, empty strings, and punctuation-only edge cases.--fit_sampleuniformly sampled tokens and batch-transforms the rest (cuML or CPU).ls_index == positional index(it is the global token index), so ScatterGL/selection plumbing is untouched;parent_index/token_pos/token_strcolumns carry the document linkage, andPOST /api/tokens/indexedresolves token indices → parent rows + char spans for the highlight cell.ls-saegains--granularity tokensand--checkpoint <local dir>; the Explore feature modal/filter/coloring now enable from the scope's own SAE metadata (not just the CDN-labeled allowlist).Verified end to end (RTX 5090)
4,000 fineweb-edu chunks → jina-colbert-v2 → 1,009,572 token points with the 64K BatchTopK token SAE (
enjalot/sae-jina-colbert-v2-tokens-64K):Known limitations (documented)
latentsaerelease with BatchTopK +load_from_disk(currently satisfied via the local latent-sae checkout).Tests
uv run pytest tests/ -q→ 298 passed (8 new: tokenize alignment/mismatch-abort/streaming, token umap both fit paths, granularity guards, token SAE,/api/tokens/indexed)cd web && npm run test→ 121 passed (18 new tokenSnippet cases); lint at baseline; production build clean🤖 Generated with Claude Code