Spaces:
Sleeping
Sleeping
feat(compare): group contrast visibility L1+L2 (v2.1.0)
Browse filesRaise ANALYSIS Amplitude, auto +Only on SAE, soft group Y gaps, and optional dim-sort by between-group |Δmean| so GROUP_* domains are readable in the matrix.
Co-authored-by: Cursor <cursoragent@cursor.com>
- .agents/skills/dev-protocol/lessons-learned.md +22 -0
- CHANGELOG.md +13 -0
- manifest.json +1 -1
- package.json +1 -1
- roadmap/compare-group-contrast-viz.md +14 -13
- src/main.js +58 -3
- src/style.css +27 -0
- src/ui/ComparePanel.js +86 -13
- src/ui/Navbar.js +1 -1
- src/ui/saeFilterBridge.js +38 -0
- src/ui/spatialSliderDefaults.js +10 -2
- src/visualizer/Instancer.js +87 -8
- src/visualizer/LayoutEngine.js +15 -2
- src/visualizer/dimContrastSort.js +116 -0
- src/visualizer/groupStackLayout.js +60 -0
- tests/LayoutEngine.test.js +14 -0
- tests/dimContrastSort.test.js +55 -0
- tests/groupStackLayout.test.js +54 -0
- tests/saeFilterBridge.test.js +29 -0
- tests/spatialSliderDefaults.test.js +29 -8
.agents/skills/dev-protocol/lessons-learned.md
CHANGED
|
@@ -224,6 +224,28 @@ User-editable hex anchors replace the former fixed dual ramp (no product mid-sto
|
|
| 224 |
- Encode already had bucketing + inference_mode + autocast; MPS autocast attempted with FP32 fallback.
|
| 225 |
- **Encode I/O bottleneck**: GPU matmul ~20ms; dense `.tolist()` of `[N, 8192]` JSON was the freeze. Fix: `encode_vectors_sparse` → `{format:topk_sparse, indices[N,K], values[N,K]}` + router `ORJSONResponse`; `RemoteProvider.saeEncode` densifies via `densifyTopKActivations`. `load_model()` is singleton (`model is not None` short-circuit) — do not `torch.load` per request.
|
| 226 |
- **Train UI hang**: UI could sit on `Starting SAE training… · working Ns` while backend was already `success`. Causes: (1) poll started only after `POST /train` returned — large embeddings JSON could stall the POST; (2) `refreshSaeStatusUi` nulled status on fetch error → fallback label "Starting…"; (3) async `setInterval` overlap. Fix: poll immediately on busy; keep last status on error; fetch timeouts; ignore pre-POST `idle`; clear poll in `_stopSaeTrainBusy`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
---
|
| 228 |
|
| 229 |
## 5. Protocolo de Mantenimiento de Lecciones Aprendidas
|
|
|
|
| 224 |
- Encode already had bucketing + inference_mode + autocast; MPS autocast attempted with FP32 fallback.
|
| 225 |
- **Encode I/O bottleneck**: GPU matmul ~20ms; dense `.tolist()` of `[N, 8192]` JSON was the freeze. Fix: `encode_vectors_sparse` → `{format:topk_sparse, indices[N,K], values[N,K]}` + router `ORJSONResponse`; `RemoteProvider.saeEncode` densifies via `densifyTopKActivations`. `load_model()` is singleton (`model is not None` short-circuit) — do not `torch.load` per request.
|
| 226 |
- **Train UI hang**: UI could sit on `Starting SAE training… · working Ns` while backend was already `success`. Causes: (1) poll started only after `POST /train` returned — large embeddings JSON could stall the POST; (2) `refreshSaeStatusUi` nulled status on fetch error → fallback label "Starting…"; (3) async `setInterval` overlap. Fix: poll immediately on busy; keep last status on error; fetch timeouts; ignore pre-POST `idle`; clear poll in `_stopSaeTrainBusy`.
|
| 227 |
+
|
| 228 |
+
### 4.11. COMPARE group contrast visibility (v2.1.0)
|
| 229 |
+
- **SAE + global z-score**: Top‑K densified matrix (~5% nonzero) → exact zeros normalize to \(t \approx -0.24\) (false negative “dust”). Mitigation in v1: auto **+ Only** on SAE encode success; restore previous filter on SAE OFF (`saeFilterBridge`). Do not change global z-score contract without an explicit D10.
|
| 230 |
+
- **Amplitude floor**: `COMPARE|ANALYSIS|POINTS` at Amp `1.0` collapses relief even when math has signal — default Amp **16** (RIBBONS twin too).
|
| 231 |
+
- **Dim sort**: optional client permutation by max pairwise `|mean_Gi − mean_Gj|`; session-only, OFF default; only when ≥2 `groupId`. Does not mutate backend payload.
|
| 232 |
+
- **Cosine ▲/▼ vs groups**: disable while groups active — global cosine sort breaks GROUP_* contiguity and soft Y gaps.
|
| 233 |
+
- Soft ANALYSIS Y gap: +1 empty slot between consecutive different `groupId` blocks (`groupStackLayout`).
|
| 234 |
+
|
| 235 |
+
#### Handoff for predecessor / sister apps (architect brief)
|
| 236 |
+
Portable findings from VectorLab 3D `v2.1.0` — apply if the older app shares ANALYSIS matrix paint + groups + SAE Top‑K:
|
| 237 |
+
|
| 238 |
+
| Finding | Detail | Recommended fix |
|
| 239 |
+
| :--- | :--- | :--- |
|
| 240 |
+
| Sparse SAE + **global** z-score+tanh | Exact zeros → \(t\approx-0.24\) (painted as negative “dust”); nonzeros saturate near +1 | Prefer **+ Only** when SAE ON; optionally restore prior filter on SAE OFF. Avoid changing global norm unless product accepts colormap shift |
|
| 241 |
+
| Flat relief | Amplitude default at slider floor (~1) hides real peaks (RAW peak \|a\|~0.22 → Y tiny) | Raise ANALYSIS Amplitude default (VL3D used **16**; ARITH often **40**) |
|
| 242 |
+
| Unsorted X | Dim index order hides domain bands even when between-group signal exists | Client **dim permutation** by `max_{i,j} \|mean_Gi − mean_Gj\|`; toggle OFF = raw order |
|
| 243 |
+
| Cosine list sort vs groups | Global cosine reorder breaks group contiguity on Y | Disable cosine ▲/▼ while ≥2 groups, or regroup after sort |
|
| 244 |
+
| Expectation vs math | RAW G1↔G2 centroid cosine ~0.55 (not antipodal); SAE can improve (~0.22) but paint still hides it | Fix paint/order first; wider SAE train is L3, not required for readability |
|
| 245 |
+
| Layout axes (ANALYSIS) | X=dim, Y=thread stack + val×amp, Z=0 | Soft Y gap between `GROUP_*` blocks (+1× spacing) aids domain reading |
|
| 246 |
+
|
| 247 |
+
**Modules shipped (reference):** `saeFilterBridge.js`, `groupStackLayout.js`, `dimContrastSort.js`; wiring in Compare panel + Instancer layout. Diagnosis: `scripts/diagnose_group_separation.py`. Roadmap: `roadmap/compare-group-contrast-viz.md`.
|
| 248 |
+
|
| 249 |
---
|
| 250 |
|
| 251 |
## 5. Protocolo de Mantenimiento de Lecciones Aprendidas
|
CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
| 2 |
|
| 3 |
All notable changes to VectorLab 3D will be documented in this file.
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
## [2.0.0] - 2026-08-02
|
| 6 |
|
| 7 |
### Added
|
|
|
|
| 2 |
|
| 3 |
All notable changes to VectorLab 3D will be documented in this file.
|
| 4 |
|
| 5 |
+
## [2.1.0] - 2026-08-02
|
| 6 |
+
|
| 7 |
+
### Added
|
| 8 |
+
- **COMPARE group contrast visibility** (`feat/compare-group-contrast-viz`):
|
| 9 |
+
- Amplitude default **16** for `COMPARE|ANALYSIS|POINTS` and RIBBONS twin (was floor `1.0`).
|
| 10 |
+
- SAE ON auto-sets Visualization filter to **+ Only**; SAE OFF restores the previous filter.
|
| 11 |
+
- Soft Y gap (+1× Dist Y) between contiguous `GROUP_*` blocks in ANALYSIS.
|
| 12 |
+
- Toggle **Sort dims by group contrast** (session-only, OFF default; visible when ≥2 groups) — permutes X by max pairwise `|Δmean|`.
|
| 13 |
+
- Cosine ▲/▼ disabled while groups are active (preserves block layout).
|
| 14 |
+
|
| 15 |
+
### Changed
|
| 16 |
+
- Roadmap decisions D1–D12 closed (pack 1: L1+L2; L3 deferred; SemVer MINOR).
|
| 17 |
+
|
| 18 |
## [2.0.0] - 2026-08-02
|
| 19 |
|
| 20 |
### Added
|
manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"project": "vectorlab-3d",
|
| 3 |
-
"version": "2.
|
| 4 |
"bootstrap_run": false,
|
| 5 |
"state_schema": {
|
| 6 |
"model_name": "all-mpnet-base-v2",
|
|
|
|
| 1 |
{
|
| 2 |
"project": "vectorlab-3d",
|
| 3 |
+
"version": "2.1.0",
|
| 4 |
"bootstrap_run": false,
|
| 5 |
"state_schema": {
|
| 6 |
"model_name": "all-mpnet-base-v2",
|
package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"name": "vectorlab-3d",
|
| 3 |
-
"version": "2.
|
| 4 |
"type": "module",
|
| 5 |
"scripts": {
|
| 6 |
"dev": "vite",
|
|
|
|
| 1 |
{
|
| 2 |
"name": "vectorlab-3d",
|
| 3 |
+
"version": "2.1.0",
|
| 4 |
"type": "module",
|
| 5 |
"scripts": {
|
| 6 |
"dev": "vite",
|
roadmap/compare-group-contrast-viz.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
# Roadmap — COMPARE group contrast visibility (RAW vs SAE)
|
| 2 |
|
| 3 |
-
**Status:**
|
|
|
|
| 4 |
**Date:** 2026-08-02
|
| 5 |
**Product:** VectorLab 3D (`lsv2`)
|
| 6 |
**Prompt companion:** [`PROMPT-compare-group-contrast-viz.md`](./PROMPT-compare-group-contrast-viz.md)
|
|
@@ -119,18 +120,18 @@ Do **not** invent answers. Ask the human, record answers in this doc §3.1, then
|
|
| 119 |
|
| 120 |
| ID | Answer | Date |
|
| 121 |
| :--- | :--- | :--- |
|
| 122 |
-
| D1 |
|
| 123 |
-
| D2 |
|
| 124 |
-
| D3 |
|
| 125 |
-
| D4 |
|
| 126 |
-
| D5 |
|
| 127 |
-
| D6 |
|
| 128 |
-
| D7 |
|
| 129 |
-
| D8 |
|
| 130 |
-
| D9 |
|
| 131 |
-
| D10 |
|
| 132 |
-
| D11 |
|
| 133 |
-
| D12 |
|
| 134 |
|
| 135 |
---
|
| 136 |
|
|
|
|
| 1 |
# Roadmap — COMPARE group contrast visibility (RAW vs SAE)
|
| 2 |
|
| 3 |
+
**Status:** Implemented (awaiting APPROVAL GATE / merge) — D* pack 1; L1+L2; v2.1.0
|
| 4 |
+
|
| 5 |
**Date:** 2026-08-02
|
| 6 |
**Product:** VectorLab 3D (`lsv2`)
|
| 7 |
**Prompt companion:** [`PROMPT-compare-group-contrast-viz.md`](./PROMPT-compare-group-contrast-viz.md)
|
|
|
|
| 120 |
|
| 121 |
| ID | Answer | Date |
|
| 122 |
| :--- | :--- | :--- |
|
| 123 |
+
| D1 | **L1+L2** (pack 1 B) — L3 out | 2026-08-02 |
|
| 124 |
+
| D2 | Amplitude **16** for `COMPARE\|ANALYSIS\|POINTS` **and** RIBBONS twin | 2026-08-02 |
|
| 125 |
+
| D3 | **A** auto-set filter to `+ Only` (`positive`) on SAE encode success | 2026-08-02 |
|
| 126 |
+
| D4 | **A** restore previous filter when SAE OFF | 2026-08-02 |
|
| 127 |
+
| D5 | **B** soft Y gap: **+1×** `threadVectorDistance` between group blocks | 2026-08-02 |
|
| 128 |
+
| D6 | **A** `\|mean_G1 − mean_G2\|` (for >2 groups: max pairwise \|Δmean\|) | 2026-08-02 |
|
| 129 |
+
| D7 | **A** dim-sort toggle only when ≥2 distinct `groupId` | 2026-08-02 |
|
| 130 |
+
| D8 | **A** session only, **OFF** by default (no localStorage) | 2026-08-02 |
|
| 131 |
+
| D9 | **B** disable cosine ▲/▼ while groups active (reason in title) | 2026-08-02 |
|
| 132 |
+
| D10 | **A** defer — keep global z-score; L1 `+ Only` mitigates dust | 2026-08-02 |
|
| 133 |
+
| D11 | **A** out of v1 | 2026-08-02 |
|
| 134 |
+
| D12 | **MINOR `2.1.0`** | 2026-08-02 |
|
| 135 |
|
| 136 |
---
|
| 137 |
|
src/main.js
CHANGED
|
@@ -27,9 +27,18 @@ import { resolveSpatialDefaults } from './ui/spatialSliderDefaults.js';
|
|
| 27 |
import {
|
| 28 |
visualizationControlsMarkup,
|
| 29 |
wireVisualizationControls,
|
|
|
|
| 30 |
readVisualizationPanelCollapsed,
|
| 31 |
} from './ui/VisualizationControls.js';
|
| 32 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
import {
|
| 34 |
loadSaeSettings,
|
| 35 |
saveSaeSettings,
|
|
@@ -120,6 +129,10 @@ class VectorLabApp {
|
|
| 120 |
this._saeTrainPollInFlight = false;
|
| 121 |
this._saeTrainSeenRunning = false;
|
| 122 |
this._saeTrainPostDone = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
const saeHooks = {
|
| 125 |
onSaeToggle: (enabled) => this.handleSaeToggle(enabled),
|
|
@@ -129,6 +142,10 @@ class VectorLabApp {
|
|
| 129 |
this.saeSettings = s;
|
| 130 |
saveSaeSettings(s);
|
| 131 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
};
|
| 133 |
|
| 134 |
this.sidebar = new Sidebar(
|
|
@@ -301,7 +318,8 @@ class VectorLabApp {
|
|
| 301 |
state.renderMode,
|
| 302 |
this.sliderConfig,
|
| 303 |
this.viewMode,
|
| 304 |
-
this.vizConfig
|
|
|
|
| 305 |
);
|
| 306 |
this.setCompareOverlayLabels(labels);
|
| 307 |
this.comparePanel.updateGroupLegend(data.items);
|
|
@@ -733,6 +751,40 @@ class VectorLabApp {
|
|
| 733 |
}
|
| 734 |
}
|
| 735 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
clearSaeFraming() {
|
| 737 |
this.instancer.setDimSpanScale(1);
|
| 738 |
if (this._preSaeThreadWidth != null) {
|
|
@@ -746,6 +798,7 @@ class VectorLabApp {
|
|
| 746 |
|
| 747 |
restoreRawWorkspace({ reframe = false } = {}) {
|
| 748 |
this.clearSaeFraming();
|
|
|
|
| 749 |
if (this.rawCompareData) {
|
| 750 |
state.setCompareData(cloneCompareRaw(this.rawCompareData));
|
| 751 |
this.comparePanel.updateCompareResults(state.compareData);
|
|
@@ -800,6 +853,7 @@ class VectorLabApp {
|
|
| 800 |
}
|
| 801 |
state.setCompareData(saeData);
|
| 802 |
this.comparePanel.updateCompareResults(saeData);
|
|
|
|
| 803 |
|
| 804 |
if (reframe) {
|
| 805 |
await this.frameAfterSaeEncode(saeData, this.rawCompareData);
|
|
@@ -861,7 +915,8 @@ class VectorLabApp {
|
|
| 861 |
state.renderMode,
|
| 862 |
this.sliderConfig,
|
| 863 |
this.viewMode,
|
| 864 |
-
this.vizConfig
|
|
|
|
| 865 |
);
|
| 866 |
this.setCompareOverlayLabels(labels);
|
| 867 |
this.comparePanel.updateCompareResults(withMeta);
|
|
|
|
| 27 |
import {
|
| 28 |
visualizationControlsMarkup,
|
| 29 |
wireVisualizationControls,
|
| 30 |
+
syncVisualizationControlsFromConfig,
|
| 31 |
readVisualizationPanelCollapsed,
|
| 32 |
} from './ui/VisualizationControls.js';
|
| 33 |
+
import {
|
| 34 |
+
loadVisualizationSettings,
|
| 35 |
+
saveVisualizationSettings,
|
| 36 |
+
} from './ui/visualizationControlsDefaults.js';
|
| 37 |
+
import {
|
| 38 |
+
snapshotFilterForSae,
|
| 39 |
+
filterModeForSaeOn,
|
| 40 |
+
restoreFilterAfterSae,
|
| 41 |
+
} from './ui/saeFilterBridge.js';
|
| 42 |
import {
|
| 43 |
loadSaeSettings,
|
| 44 |
saveSaeSettings,
|
|
|
|
| 129 |
this._saeTrainPollInFlight = false;
|
| 130 |
this._saeTrainSeenRunning = false;
|
| 131 |
this._saeTrainPostDone = false;
|
| 132 |
+
/** @type {{ previousMode: string }|null} */
|
| 133 |
+
this._saeFilterSnapshot = null;
|
| 134 |
+
/** Session-only dim contrast sort (Compare groups). */
|
| 135 |
+
this.dimSortByContrast = false;
|
| 136 |
|
| 137 |
const saeHooks = {
|
| 138 |
onSaeToggle: (enabled) => this.handleSaeToggle(enabled),
|
|
|
|
| 142 |
this.saeSettings = s;
|
| 143 |
saveSaeSettings(s);
|
| 144 |
},
|
| 145 |
+
onDimSortChange: (enabled) => {
|
| 146 |
+
this.dimSortByContrast = !!enabled;
|
| 147 |
+
this.refreshRender();
|
| 148 |
+
},
|
| 149 |
};
|
| 150 |
|
| 151 |
this.sidebar = new Sidebar(
|
|
|
|
| 318 |
state.renderMode,
|
| 319 |
this.sliderConfig,
|
| 320 |
this.viewMode,
|
| 321 |
+
this.vizConfig,
|
| 322 |
+
{ dimSortByContrast: this.dimSortByContrast }
|
| 323 |
);
|
| 324 |
this.setCompareOverlayLabels(labels);
|
| 325 |
this.comparePanel.updateGroupLegend(data.items);
|
|
|
|
| 751 |
}
|
| 752 |
}
|
| 753 |
|
| 754 |
+
/**
|
| 755 |
+
* Apply Visualization filter mode and sync right-dock radios + localStorage.
|
| 756 |
+
* @param {string} mode
|
| 757 |
+
*/
|
| 758 |
+
applyVizFilterMode(mode) {
|
| 759 |
+
if (!this.vizConfig) return;
|
| 760 |
+
this.vizConfig.vizFilterMode = mode;
|
| 761 |
+
saveVisualizationSettings(this.vizConfig);
|
| 762 |
+
if (this.vizEl) {
|
| 763 |
+
syncVisualizationControlsFromConfig(this.vizEl, this.vizConfig);
|
| 764 |
+
}
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
/**
|
| 768 |
+
* D3: on SAE encode success, force + Only (snapshot once per SAE ON session).
|
| 769 |
+
*/
|
| 770 |
+
applySaeOnFilterOverride() {
|
| 771 |
+
if (!this._saeFilterSnapshot) {
|
| 772 |
+
this._saeFilterSnapshot = snapshotFilterForSae(this.vizConfig?.vizFilterMode);
|
| 773 |
+
}
|
| 774 |
+
this.applyVizFilterMode(filterModeForSaeOn());
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
/**
|
| 778 |
+
* D4: restore pre-SAE filter when leaving SAE.
|
| 779 |
+
*/
|
| 780 |
+
restoreSaeFilterOverride() {
|
| 781 |
+
const restored = restoreFilterAfterSae(this._saeFilterSnapshot);
|
| 782 |
+
this._saeFilterSnapshot = null;
|
| 783 |
+
if (restored != null) {
|
| 784 |
+
this.applyVizFilterMode(restored);
|
| 785 |
+
}
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
clearSaeFraming() {
|
| 789 |
this.instancer.setDimSpanScale(1);
|
| 790 |
if (this._preSaeThreadWidth != null) {
|
|
|
|
| 798 |
|
| 799 |
restoreRawWorkspace({ reframe = false } = {}) {
|
| 800 |
this.clearSaeFraming();
|
| 801 |
+
this.restoreSaeFilterOverride();
|
| 802 |
if (this.rawCompareData) {
|
| 803 |
state.setCompareData(cloneCompareRaw(this.rawCompareData));
|
| 804 |
this.comparePanel.updateCompareResults(state.compareData);
|
|
|
|
| 853 |
}
|
| 854 |
state.setCompareData(saeData);
|
| 855 |
this.comparePanel.updateCompareResults(saeData);
|
| 856 |
+
this.applySaeOnFilterOverride();
|
| 857 |
|
| 858 |
if (reframe) {
|
| 859 |
await this.frameAfterSaeEncode(saeData, this.rawCompareData);
|
|
|
|
| 915 |
state.renderMode,
|
| 916 |
this.sliderConfig,
|
| 917 |
this.viewMode,
|
| 918 |
+
this.vizConfig,
|
| 919 |
+
{ dimSortByContrast: this.dimSortByContrast }
|
| 920 |
);
|
| 921 |
this.setCompareOverlayLabels(labels);
|
| 922 |
this.comparePanel.updateCompareResults(withMeta);
|
src/style.css
CHANGED
|
@@ -210,6 +210,33 @@ canvas {
|
|
| 210 |
text-transform: none;
|
| 211 |
}
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
/* COMPARE: cosine-vs-anchor list — scroll only inside the list (§4.1 panel invariant) */
|
| 214 |
#compare-panel .compare-results {
|
| 215 |
flex: 1 1 auto;
|
|
|
|
| 210 |
text-transform: none;
|
| 211 |
}
|
| 212 |
|
| 213 |
+
.compare-dim-sort {
|
| 214 |
+
display: flex;
|
| 215 |
+
align-items: center;
|
| 216 |
+
gap: 8px;
|
| 217 |
+
margin: 0 0 10px;
|
| 218 |
+
padding: 6px 2px;
|
| 219 |
+
font-size: 0.78rem;
|
| 220 |
+
font-weight: 600;
|
| 221 |
+
letter-spacing: 0.04em;
|
| 222 |
+
color: rgba(230, 235, 245, 0.88);
|
| 223 |
+
cursor: pointer;
|
| 224 |
+
user-select: none;
|
| 225 |
+
flex-shrink: 0;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.compare-dim-sort[hidden] {
|
| 229 |
+
display: none !important;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.compare-dim-sort input {
|
| 233 |
+
accent-color: var(--accent-gold, #e8b84a);
|
| 234 |
+
width: 14px;
|
| 235 |
+
height: 14px;
|
| 236 |
+
margin: 0;
|
| 237 |
+
cursor: pointer;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
/* COMPARE: cosine-vs-anchor list — scroll only inside the list (§4.1 panel invariant) */
|
| 241 |
#compare-panel .compare-results {
|
| 242 |
flex: 1 1 auto;
|
src/ui/ComparePanel.js
CHANGED
|
@@ -96,6 +96,7 @@ export class ComparePanel {
|
|
| 96 |
* onSaeTrain?: (settings: object) => void|Promise<void>,
|
| 97 |
* getSaeSettings?: () => object,
|
| 98 |
* setSaeSettings?: (s: object) => void,
|
|
|
|
| 99 |
* }} [saeHooks]
|
| 100 |
*/
|
| 101 |
constructor(containerElement, onCalculateCallback, onReorderCallback = null, saeHooks = {}) {
|
|
@@ -107,6 +108,10 @@ export class ComparePanel {
|
|
| 107 |
/** @type {Array|null} */
|
| 108 |
this.items = null;
|
| 109 |
this.reorderLocked = false;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
this.element = document.createElement('div');
|
| 112 |
this.element.id = 'compare-panel';
|
|
@@ -139,6 +144,10 @@ export class ComparePanel {
|
|
| 139 |
<span class="metric-item">Loaded Tokens: <strong id="token-count-val">0</strong></span>
|
| 140 |
</div>
|
| 141 |
<div id="compare-groups-legend" class="compare-groups-legend" hidden></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
<div class="compare-cosine-header">
|
| 144 |
<h3 id="compare-cosine-subtitle" class="compare-cosine-subtitle">COSINE SIMILARITY vs —</h3>
|
|
@@ -159,6 +168,8 @@ export class ComparePanel {
|
|
| 159 |
this.textarea = this.element.querySelector('#compare-tokens');
|
| 160 |
this.tokenCountVal = this.element.querySelector('#token-count-val');
|
| 161 |
this.groupsLegend = this.element.querySelector('#compare-groups-legend');
|
|
|
|
|
|
|
| 162 |
this.cosineSubtitle = this.element.querySelector('#compare-cosine-subtitle');
|
| 163 |
this.cosineList = this.element.querySelector('#compare-cosine-list');
|
| 164 |
this.btnSortDesc = this.element.querySelector('#btn-sort-desc');
|
|
@@ -228,11 +239,20 @@ export class ComparePanel {
|
|
| 228 |
btn.addEventListener('click', (e) => {
|
| 229 |
e.preventDefault();
|
| 230 |
e.stopPropagation();
|
| 231 |
-
if (this.reorderLocked) return;
|
| 232 |
const direction = btn.getAttribute('data-sort');
|
| 233 |
this.handleSort(direction);
|
| 234 |
});
|
| 235 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
}
|
| 237 |
|
| 238 |
applyReorderResult(result) {
|
|
@@ -254,15 +274,13 @@ export class ComparePanel {
|
|
| 254 |
}
|
| 255 |
|
| 256 |
handleSort(direction) {
|
| 257 |
-
if (!this.items || this.reorderLocked) return;
|
| 258 |
this.applyReorderResult(sortCompareItemsByCosine(this.items, direction));
|
| 259 |
}
|
| 260 |
|
| 261 |
setReorderLocked(locked) {
|
| 262 |
this.reorderLocked = locked;
|
| 263 |
-
|
| 264 |
-
if (this.btnSortDesc) this.btnSortDesc.disabled = locked || !hasItems;
|
| 265 |
-
if (this.btnSortAsc) this.btnSortAsc.disabled = locked || !hasItems;
|
| 266 |
|
| 267 |
this.cosineList.querySelectorAll('.btn-reorder').forEach((btn) => {
|
| 268 |
if (locked) {
|
|
@@ -276,6 +294,60 @@ export class ComparePanel {
|
|
| 276 |
});
|
| 277 |
}
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
setLoading(loading) {
|
| 280 |
this.saeUi.setPrimaryLoading(loading);
|
| 281 |
}
|
|
@@ -304,15 +376,16 @@ export class ComparePanel {
|
|
| 304 |
if (!counts.size) {
|
| 305 |
this.groupsLegend.setAttribute('hidden', '');
|
| 306 |
this.groupsLegend.innerHTML = '';
|
| 307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
}
|
| 309 |
-
this.
|
| 310 |
-
this.groupsLegend.innerHTML = [...counts.values()]
|
| 311 |
-
.map(
|
| 312 |
-
(g) =>
|
| 313 |
-
`<span class="compare-group-chip" title="${g.count} tokens">${g.label}<em>${g.count}</em></span>`
|
| 314 |
-
)
|
| 315 |
-
.join('');
|
| 316 |
}
|
| 317 |
|
| 318 |
/**
|
|
|
|
| 96 |
* onSaeTrain?: (settings: object) => void|Promise<void>,
|
| 97 |
* getSaeSettings?: () => object,
|
| 98 |
* setSaeSettings?: (s: object) => void,
|
| 99 |
+
* onDimSortChange?: (enabled: boolean) => void,
|
| 100 |
* }} [saeHooks]
|
| 101 |
*/
|
| 102 |
constructor(containerElement, onCalculateCallback, onReorderCallback = null, saeHooks = {}) {
|
|
|
|
| 108 |
/** @type {Array|null} */
|
| 109 |
this.items = null;
|
| 110 |
this.reorderLocked = false;
|
| 111 |
+
/** Session-only dim contrast sort (D8 OFF default). */
|
| 112 |
+
this.dimSortByContrast = false;
|
| 113 |
+
/** Cosine ▲/▼ blocked while ≥2 groups (D9). */
|
| 114 |
+
this.cosineSortBlockedByGroups = false;
|
| 115 |
|
| 116 |
this.element = document.createElement('div');
|
| 117 |
this.element.id = 'compare-panel';
|
|
|
|
| 144 |
<span class="metric-item">Loaded Tokens: <strong id="token-count-val">0</strong></span>
|
| 145 |
</div>
|
| 146 |
<div id="compare-groups-legend" class="compare-groups-legend" hidden></div>
|
| 147 |
+
<label id="compare-dim-sort-row" class="compare-dim-sort" hidden>
|
| 148 |
+
<input type="checkbox" id="compare-dim-sort-toggle">
|
| 149 |
+
<span>Sort dims by group contrast</span>
|
| 150 |
+
</label>
|
| 151 |
|
| 152 |
<div class="compare-cosine-header">
|
| 153 |
<h3 id="compare-cosine-subtitle" class="compare-cosine-subtitle">COSINE SIMILARITY vs —</h3>
|
|
|
|
| 168 |
this.textarea = this.element.querySelector('#compare-tokens');
|
| 169 |
this.tokenCountVal = this.element.querySelector('#token-count-val');
|
| 170 |
this.groupsLegend = this.element.querySelector('#compare-groups-legend');
|
| 171 |
+
this.dimSortRow = this.element.querySelector('#compare-dim-sort-row');
|
| 172 |
+
this.dimSortToggle = this.element.querySelector('#compare-dim-sort-toggle');
|
| 173 |
this.cosineSubtitle = this.element.querySelector('#compare-cosine-subtitle');
|
| 174 |
this.cosineList = this.element.querySelector('#compare-cosine-list');
|
| 175 |
this.btnSortDesc = this.element.querySelector('#btn-sort-desc');
|
|
|
|
| 239 |
btn.addEventListener('click', (e) => {
|
| 240 |
e.preventDefault();
|
| 241 |
e.stopPropagation();
|
| 242 |
+
if (this.reorderLocked || this.cosineSortBlockedByGroups) return;
|
| 243 |
const direction = btn.getAttribute('data-sort');
|
| 244 |
this.handleSort(direction);
|
| 245 |
});
|
| 246 |
});
|
| 247 |
+
|
| 248 |
+
if (this.dimSortToggle) {
|
| 249 |
+
this.dimSortToggle.addEventListener('change', () => {
|
| 250 |
+
this.dimSortByContrast = !!this.dimSortToggle.checked;
|
| 251 |
+
if (typeof this.saeHooks.onDimSortChange === 'function') {
|
| 252 |
+
this.saeHooks.onDimSortChange(this.dimSortByContrast);
|
| 253 |
+
}
|
| 254 |
+
});
|
| 255 |
+
}
|
| 256 |
}
|
| 257 |
|
| 258 |
applyReorderResult(result) {
|
|
|
|
| 274 |
}
|
| 275 |
|
| 276 |
handleSort(direction) {
|
| 277 |
+
if (!this.items || this.reorderLocked || this.cosineSortBlockedByGroups) return;
|
| 278 |
this.applyReorderResult(sortCompareItemsByCosine(this.items, direction));
|
| 279 |
}
|
| 280 |
|
| 281 |
setReorderLocked(locked) {
|
| 282 |
this.reorderLocked = locked;
|
| 283 |
+
this.syncCosineSortButtons();
|
|
|
|
|
|
|
| 284 |
|
| 285 |
this.cosineList.querySelectorAll('.btn-reorder').forEach((btn) => {
|
| 286 |
if (locked) {
|
|
|
|
| 294 |
});
|
| 295 |
}
|
| 296 |
|
| 297 |
+
/**
|
| 298 |
+
* Enable/disable cosine ▲/▼; when groups active, force off with reason title (D9).
|
| 299 |
+
*/
|
| 300 |
+
syncCosineSortButtons() {
|
| 301 |
+
const hasItems = !!(this.items && this.items.length > 1);
|
| 302 |
+
const blocked = this.cosineSortBlockedByGroups;
|
| 303 |
+
const disabled = this.reorderLocked || !hasItems || blocked;
|
| 304 |
+
const title = blocked
|
| 305 |
+
? 'Disabled while groups are active (preserves group blocks)'
|
| 306 |
+
: null;
|
| 307 |
+
if (this.btnSortDesc) {
|
| 308 |
+
this.btnSortDesc.disabled = disabled;
|
| 309 |
+
this.btnSortDesc.title = title || 'Highest → lowest';
|
| 310 |
+
}
|
| 311 |
+
if (this.btnSortAsc) {
|
| 312 |
+
this.btnSortAsc.disabled = disabled;
|
| 313 |
+
this.btnSortAsc.title = title || 'Lowest → highest';
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
/**
|
| 318 |
+
* Show dim-sort toggle only when ≥2 groups; sync cosine block (D7/D9).
|
| 319 |
+
* @param {Array<{ groupId?: string, groupLabel?: string }>|null|undefined} items
|
| 320 |
+
*/
|
| 321 |
+
syncGroupLayoutControls(items) {
|
| 322 |
+
/** @type {Set<string>} */
|
| 323 |
+
const ids = new Set();
|
| 324 |
+
for (const it of items || []) {
|
| 325 |
+
if (it?.groupId) ids.add(it.groupId);
|
| 326 |
+
}
|
| 327 |
+
const multiGroup = ids.size >= 2;
|
| 328 |
+
this.cosineSortBlockedByGroups = multiGroup;
|
| 329 |
+
this.syncCosineSortButtons();
|
| 330 |
+
|
| 331 |
+
if (this.dimSortRow) {
|
| 332 |
+
if (multiGroup) {
|
| 333 |
+
this.dimSortRow.removeAttribute('hidden');
|
| 334 |
+
} else {
|
| 335 |
+
this.dimSortRow.setAttribute('hidden', '');
|
| 336 |
+
if (this.dimSortByContrast) {
|
| 337 |
+
this.dimSortByContrast = false;
|
| 338 |
+
if (this.dimSortToggle) this.dimSortToggle.checked = false;
|
| 339 |
+
if (typeof this.saeHooks.onDimSortChange === 'function') {
|
| 340 |
+
this.saeHooks.onDimSortChange(false);
|
| 341 |
+
}
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
if (this.dimSortToggle) {
|
| 346 |
+
this.dimSortToggle.checked = this.dimSortByContrast;
|
| 347 |
+
this.dimSortToggle.disabled = !multiGroup;
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
setLoading(loading) {
|
| 352 |
this.saeUi.setPrimaryLoading(loading);
|
| 353 |
}
|
|
|
|
| 376 |
if (!counts.size) {
|
| 377 |
this.groupsLegend.setAttribute('hidden', '');
|
| 378 |
this.groupsLegend.innerHTML = '';
|
| 379 |
+
} else {
|
| 380 |
+
this.groupsLegend.removeAttribute('hidden');
|
| 381 |
+
this.groupsLegend.innerHTML = [...counts.values()]
|
| 382 |
+
.map(
|
| 383 |
+
(g) =>
|
| 384 |
+
`<span class="compare-group-chip" title="${g.count} tokens">${g.label}<em>${g.count}</em></span>`
|
| 385 |
+
)
|
| 386 |
+
.join('');
|
| 387 |
}
|
| 388 |
+
this.syncGroupLayoutControls(items);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
}
|
| 390 |
|
| 391 |
/**
|
src/ui/Navbar.js
CHANGED
|
@@ -16,7 +16,7 @@ export class Navbar {
|
|
| 16 |
<div class="logo-icon">🌐</div>
|
| 17 |
<div class="title-group">
|
| 18 |
<h1>VECTORLAB <span class="accent-3d">3D</span></h1>
|
| 19 |
-
<span class="version-tag">v2.
|
| 20 |
</div>
|
| 21 |
</div>
|
| 22 |
|
|
|
|
| 16 |
<div class="logo-icon">🌐</div>
|
| 17 |
<div class="title-group">
|
| 18 |
<h1>VECTORLAB <span class="accent-3d">3D</span></h1>
|
| 19 |
+
<span class="version-tag">v2.1.0</span>
|
| 20 |
</div>
|
| 21 |
</div>
|
| 22 |
|
src/ui/saeFilterBridge.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* SAE ↔ Visualization sign-filter bridge (D3/D4).
|
| 3 |
+
* On SAE ON: force + Only. On SAE OFF: restore the pre-SAE filter.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { normalizeFilterMode } from './visualizationControlsDefaults.js';
|
| 7 |
+
|
| 8 |
+
/** @typedef {import('./visualizationControlsDefaults.js').VizFilterMode} VizFilterMode */
|
| 9 |
+
/** @typedef {{ previousMode: VizFilterMode }} SaeFilterSnapshot */
|
| 10 |
+
|
| 11 |
+
/** Filter applied automatically when SAE encode succeeds. */
|
| 12 |
+
export const SAE_ON_FILTER_MODE = /** @type {VizFilterMode} */ ('positive');
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Snapshot current filter before overriding for SAE.
|
| 16 |
+
* @param {unknown} currentMode
|
| 17 |
+
* @returns {SaeFilterSnapshot}
|
| 18 |
+
*/
|
| 19 |
+
export function snapshotFilterForSae(currentMode) {
|
| 20 |
+
return { previousMode: normalizeFilterMode(currentMode) };
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* @returns {VizFilterMode}
|
| 25 |
+
*/
|
| 26 |
+
export function filterModeForSaeOn() {
|
| 27 |
+
return SAE_ON_FILTER_MODE;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/**
|
| 31 |
+
* Restore filter after SAE OFF. Null snapshot → null (caller no-ops).
|
| 32 |
+
* @param {SaeFilterSnapshot|null|undefined} snapshot
|
| 33 |
+
* @returns {VizFilterMode|null}
|
| 34 |
+
*/
|
| 35 |
+
export function restoreFilterAfterSae(snapshot) {
|
| 36 |
+
if (!snapshot || typeof snapshot !== 'object') return null;
|
| 37 |
+
return normalizeFilterMode(snapshot.previousMode);
|
| 38 |
+
}
|
src/ui/spatialSliderDefaults.js
CHANGED
|
@@ -45,11 +45,19 @@ export const SPATIAL_DEFAULT_OVERRIDES = {
|
|
| 45 |
threadWidth: 0.1,
|
| 46 |
threadThickness: 0.01,
|
| 47 |
},
|
| 48 |
-
//
|
| 49 |
'COMPARE|ANALYSIS|POINTS': {
|
| 50 |
threadSpacing: 1.45,
|
| 51 |
threadVectorDistance: 1.0,
|
| 52 |
-
threadAmplitudeY:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
threadWidth: 0.2,
|
| 54 |
threadThickness: 0.01,
|
| 55 |
},
|
|
|
|
| 45 |
threadWidth: 0.1,
|
| 46 |
threadThickness: 0.01,
|
| 47 |
},
|
| 48 |
+
// COMPARE + ANALYSIS + POINTS — Amplitude 16 so activation peaks are readable (was floor 1.0)
|
| 49 |
'COMPARE|ANALYSIS|POINTS': {
|
| 50 |
threadSpacing: 1.45,
|
| 51 |
threadVectorDistance: 1.0,
|
| 52 |
+
threadAmplitudeY: 16.0,
|
| 53 |
+
threadWidth: 0.2,
|
| 54 |
+
threadThickness: 0.01,
|
| 55 |
+
},
|
| 56 |
+
// COMPARE + ANALYSIS + RIBBONS — same Amplitude twin as POINTS
|
| 57 |
+
'COMPARE|ANALYSIS|RIBBONS': {
|
| 58 |
+
threadSpacing: 1.45,
|
| 59 |
+
threadVectorDistance: 1.0,
|
| 60 |
+
threadAmplitudeY: 16.0,
|
| 61 |
threadWidth: 0.2,
|
| 62 |
threadThickness: 0.01,
|
| 63 |
},
|
src/visualizer/Instancer.js
CHANGED
|
@@ -3,6 +3,12 @@ import { MeshFactory } from './MeshFactory.js';
|
|
| 3 |
import { LayoutEngine } from './LayoutEngine.js';
|
| 4 |
import { arithmeticSequenceIndex, arithmeticThreadLabel } from '../ui/threadLabelFormat.js';
|
| 5 |
import { normalizeRenderMode } from '../core/State.js';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
/**
|
| 8 |
* Instancer Manager for rendering vector points, ribbons, and highlights in the Three.js scene.
|
|
@@ -222,9 +228,17 @@ export class Instancer {
|
|
| 222 |
* @param {Object} [spatialConfig] - Real-time spatial slider configuration
|
| 223 |
* @param {string} [viewMode="NAVIGATION"] - "NAVIGATION" | "ANALYSIS"
|
| 224 |
* @param {Object} [vizConfig] - Global sign filter + color anchors
|
|
|
|
| 225 |
* @returns {Array<{ id: string, text: string, type: string, origin3D: THREE.Vector3 }>} Label metadata
|
| 226 |
*/
|
| 227 |
-
renderCompareData(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
this.clear();
|
| 229 |
renderMode = normalizeRenderMode(renderMode);
|
| 230 |
this.renderMode = renderMode;
|
|
@@ -242,7 +256,8 @@ export class Instancer {
|
|
| 242 |
|
| 243 |
if (!compareResponse || !compareResponse.items || compareResponse.items.length === 0) return [];
|
| 244 |
|
| 245 |
-
const
|
|
|
|
| 246 |
const thicknessFactor = (spatialConfig && spatialConfig.threadThickness !== undefined)
|
| 247 |
? spatialConfig.threadThickness
|
| 248 |
: 0.3;
|
|
@@ -255,6 +270,10 @@ export class Instancer {
|
|
| 255 |
? spatialConfig.threadAmplitudeY
|
| 256 |
: 16.0;
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
const pointsData = [];
|
| 259 |
const threadLabelItems = [];
|
| 260 |
const threads = [];
|
|
@@ -262,21 +281,32 @@ export class Instancer {
|
|
| 262 |
const surfaceRows = [];
|
| 263 |
let pointOffset = 0;
|
| 264 |
|
| 265 |
-
|
| 266 |
-
const
|
| 267 |
-
if (!
|
| 268 |
|
|
|
|
| 269 |
const threadId = item.id || `tok_${idx}`;
|
| 270 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
const activations = [];
|
| 272 |
|
| 273 |
vec3D.forEach((p, dimIdx) => {
|
| 274 |
const val = vec[dimIdx];
|
|
|
|
| 275 |
pointsData.push({
|
| 276 |
position: p,
|
| 277 |
activation: val,
|
| 278 |
size: 10.0 * thicknessFactor,
|
| 279 |
-
meta: { type: "compare", token: item.text, dim:
|
| 280 |
});
|
| 281 |
activations.push(val);
|
| 282 |
});
|
|
@@ -307,6 +337,8 @@ export class Instancer {
|
|
| 307 |
pointOffset,
|
| 308 |
dimCount: vec.length,
|
| 309 |
sequenceIndex: idx,
|
|
|
|
|
|
|
| 310 |
_layoutPoints: vec3D,
|
| 311 |
groupId: item.groupId,
|
| 312 |
groupLabel: item.groupLabel,
|
|
@@ -350,6 +382,7 @@ export class Instancer {
|
|
| 350 |
totalThreads,
|
| 351 |
spacingY,
|
| 352 |
amplitudeY,
|
|
|
|
| 353 |
pointsMesh,
|
| 354 |
baselineMesh,
|
| 355 |
threads,
|
|
@@ -364,13 +397,31 @@ export class Instancer {
|
|
| 364 |
* @returns {THREE.Vector3|null} origin for floating labels
|
| 365 |
*/
|
| 366 |
_applyCompareThreadLayout(thread, sequenceIndex, runtime, { updateBounds = false } = {}) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
const vec3D = this.layoutEngine.mapVectorTo3DPoints(
|
| 368 |
thread.embedding,
|
| 369 |
sequenceIndex,
|
| 370 |
runtime.viewMode,
|
| 371 |
runtime.totalThreads,
|
| 372 |
runtime.spacingY,
|
| 373 |
-
runtime.amplitudeY
|
|
|
|
| 374 |
);
|
| 375 |
if (!vec3D.length) return null;
|
| 376 |
|
|
@@ -494,6 +545,34 @@ export class Instancer {
|
|
| 494 |
}
|
| 495 |
});
|
| 496 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 497 |
this._syncCompareBaseline(origins);
|
| 498 |
|
| 499 |
if (onFrame) onFrame(labels);
|
|
|
|
| 3 |
import { LayoutEngine } from './LayoutEngine.js';
|
| 4 |
import { arithmeticSequenceIndex, arithmeticThreadLabel } from '../ui/threadLabelFormat.js';
|
| 5 |
import { normalizeRenderMode } from '../core/State.js';
|
| 6 |
+
import { computeGroupAwareYSlots } from './groupStackLayout.js';
|
| 7 |
+
import {
|
| 8 |
+
applyDimPermutation,
|
| 9 |
+
computeDimContrastPermutation,
|
| 10 |
+
hasEnoughGroupsForDimSort,
|
| 11 |
+
} from './dimContrastSort.js';
|
| 12 |
|
| 13 |
/**
|
| 14 |
* Instancer Manager for rendering vector points, ribbons, and highlights in the Three.js scene.
|
|
|
|
| 228 |
* @param {Object} [spatialConfig] - Real-time spatial slider configuration
|
| 229 |
* @param {string} [viewMode="NAVIGATION"] - "NAVIGATION" | "ANALYSIS"
|
| 230 |
* @param {Object} [vizConfig] - Global sign filter + color anchors
|
| 231 |
+
* @param {{ dimSortByContrast?: boolean }} [options]
|
| 232 |
* @returns {Array<{ id: string, text: string, type: string, origin3D: THREE.Vector3 }>} Label metadata
|
| 233 |
*/
|
| 234 |
+
renderCompareData(
|
| 235 |
+
compareResponse,
|
| 236 |
+
renderMode = "POINTS",
|
| 237 |
+
spatialConfig = null,
|
| 238 |
+
viewMode = "NAVIGATION",
|
| 239 |
+
vizConfig = null,
|
| 240 |
+
options = {}
|
| 241 |
+
) {
|
| 242 |
this.clear();
|
| 243 |
renderMode = normalizeRenderMode(renderMode);
|
| 244 |
this.renderMode = renderMode;
|
|
|
|
| 256 |
|
| 257 |
if (!compareResponse || !compareResponse.items || compareResponse.items.length === 0) return [];
|
| 258 |
|
| 259 |
+
const items = compareResponse.items;
|
| 260 |
+
const totalThreads = items.length;
|
| 261 |
const thicknessFactor = (spatialConfig && spatialConfig.threadThickness !== undefined)
|
| 262 |
? spatialConfig.threadThickness
|
| 263 |
: 0.3;
|
|
|
|
| 270 |
? spatialConfig.threadAmplitudeY
|
| 271 |
: 16.0;
|
| 272 |
|
| 273 |
+
const { slots: ySlots, span: ySlotSpan } = computeGroupAwareYSlots(items, { gapSlots: 1 });
|
| 274 |
+
const dimSortOn = options.dimSortByContrast === true && hasEnoughGroupsForDimSort(items);
|
| 275 |
+
const dimPerm = dimSortOn ? computeDimContrastPermutation(items) : null;
|
| 276 |
+
|
| 277 |
const pointsData = [];
|
| 278 |
const threadLabelItems = [];
|
| 279 |
const threads = [];
|
|
|
|
| 281 |
const surfaceRows = [];
|
| 282 |
let pointOffset = 0;
|
| 283 |
|
| 284 |
+
items.forEach((item, idx) => {
|
| 285 |
+
const rawVec = item.embedding;
|
| 286 |
+
if (!rawVec || !rawVec.length) return;
|
| 287 |
|
| 288 |
+
const vec = dimPerm ? applyDimPermutation(rawVec, dimPerm) : rawVec;
|
| 289 |
const threadId = item.id || `tok_${idx}`;
|
| 290 |
+
const layoutOpts = { ySlot: ySlots[idx] ?? idx, ySlotSpan };
|
| 291 |
+
const vec3D = this.layoutEngine.mapVectorTo3DPoints(
|
| 292 |
+
vec,
|
| 293 |
+
idx,
|
| 294 |
+
viewMode,
|
| 295 |
+
totalThreads,
|
| 296 |
+
spacingY,
|
| 297 |
+
amplitudeY,
|
| 298 |
+
layoutOpts
|
| 299 |
+
);
|
| 300 |
const activations = [];
|
| 301 |
|
| 302 |
vec3D.forEach((p, dimIdx) => {
|
| 303 |
const val = vec[dimIdx];
|
| 304 |
+
const sourceDim = dimPerm ? dimPerm[dimIdx] : dimIdx;
|
| 305 |
pointsData.push({
|
| 306 |
position: p,
|
| 307 |
activation: val,
|
| 308 |
size: 10.0 * thicknessFactor,
|
| 309 |
+
meta: { type: "compare", token: item.text, dim: sourceDim, val }
|
| 310 |
});
|
| 311 |
activations.push(val);
|
| 312 |
});
|
|
|
|
| 337 |
pointOffset,
|
| 338 |
dimCount: vec.length,
|
| 339 |
sequenceIndex: idx,
|
| 340 |
+
ySlot: layoutOpts.ySlot,
|
| 341 |
+
ySlotSpan,
|
| 342 |
_layoutPoints: vec3D,
|
| 343 |
groupId: item.groupId,
|
| 344 |
groupLabel: item.groupLabel,
|
|
|
|
| 382 |
totalThreads,
|
| 383 |
spacingY,
|
| 384 |
amplitudeY,
|
| 385 |
+
ySlotSpan,
|
| 386 |
pointsMesh,
|
| 387 |
baselineMesh,
|
| 388 |
threads,
|
|
|
|
| 397 |
* @returns {THREE.Vector3|null} origin for floating labels
|
| 398 |
*/
|
| 399 |
_applyCompareThreadLayout(thread, sequenceIndex, runtime, { updateBounds = false } = {}) {
|
| 400 |
+
// Settled layout uses stored group-gap slots; fractional tween uses sequenceIndex as Y.
|
| 401 |
+
const settled = Number.isInteger(sequenceIndex)
|
| 402 |
+
&& sequenceIndex === thread.sequenceIndex
|
| 403 |
+
&& thread.ySlot !== undefined;
|
| 404 |
+
const layoutOpts = settled
|
| 405 |
+
? {
|
| 406 |
+
ySlot: thread.ySlot,
|
| 407 |
+
ySlotSpan: thread.ySlotSpan !== undefined
|
| 408 |
+
? thread.ySlotSpan
|
| 409 |
+
: (runtime.ySlotSpan !== undefined
|
| 410 |
+
? runtime.ySlotSpan
|
| 411 |
+
: Math.max(0, runtime.totalThreads - 1)),
|
| 412 |
+
}
|
| 413 |
+
: {
|
| 414 |
+
ySlot: sequenceIndex,
|
| 415 |
+
ySlotSpan: Math.max(0, runtime.totalThreads - 1),
|
| 416 |
+
};
|
| 417 |
const vec3D = this.layoutEngine.mapVectorTo3DPoints(
|
| 418 |
thread.embedding,
|
| 419 |
sequenceIndex,
|
| 420 |
runtime.viewMode,
|
| 421 |
runtime.totalThreads,
|
| 422 |
runtime.spacingY,
|
| 423 |
+
runtime.amplitudeY,
|
| 424 |
+
layoutOpts
|
| 425 |
);
|
| 426 |
if (!vec3D.length) return null;
|
| 427 |
|
|
|
|
| 545 |
}
|
| 546 |
});
|
| 547 |
|
| 548 |
+
if (t >= 1) {
|
| 549 |
+
const ordered = [...runtime.threads].sort((a, b) => a.sequenceIndex - b.sequenceIndex);
|
| 550 |
+
const { slots, span } = computeGroupAwareYSlots(ordered, { gapSlots: 1 });
|
| 551 |
+
runtime.ySlotSpan = span;
|
| 552 |
+
ordered.forEach((th, i) => {
|
| 553 |
+
th.ySlot = slots[i];
|
| 554 |
+
th.ySlotSpan = span;
|
| 555 |
+
});
|
| 556 |
+
origins.length = 0;
|
| 557 |
+
labels.length = 0;
|
| 558 |
+
runtime.threads.forEach((thread) => {
|
| 559 |
+
const origin = this._applyCompareThreadLayout(thread, thread.sequenceIndex, runtime, {
|
| 560 |
+
updateBounds: true,
|
| 561 |
+
});
|
| 562 |
+
if (origin) {
|
| 563 |
+
origins.push(origin);
|
| 564 |
+
labels.push({
|
| 565 |
+
id: thread.id,
|
| 566 |
+
text: thread.text,
|
| 567 |
+
type: "compare",
|
| 568 |
+
origin3D: origin,
|
| 569 |
+
groupId: thread.groupId,
|
| 570 |
+
groupLabel: thread.groupLabel,
|
| 571 |
+
});
|
| 572 |
+
}
|
| 573 |
+
});
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
this._syncCompareBaseline(origins);
|
| 577 |
|
| 578 |
if (onFrame) onFrame(labels);
|
src/visualizer/LayoutEngine.js
CHANGED
|
@@ -95,8 +95,17 @@ export class LayoutEngine {
|
|
| 95 |
* @param {number} [totalThreads=5] - Total number of threads for centering in ANALYSIS mode
|
| 96 |
* @param {number} [spacingY=46.0] - Vertical Y separation between threads
|
| 97 |
* @param {number} [amplitudeY=16.0] - Height scale factor for activation points (+1 / -1)
|
|
|
|
| 98 |
*/
|
| 99 |
-
mapVectorTo3DPoints(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
if (!vector || !vector.length) return [];
|
| 101 |
const count = vector.length;
|
| 102 |
const offsetCenterX = (count * this.scaleX) / 2.0;
|
|
@@ -104,7 +113,11 @@ export class LayoutEngine {
|
|
| 104 |
if (viewMode === 'ANALYSIS') {
|
| 105 |
// Stack threads vertically along Y axis with customizable separation (spacingY)
|
| 106 |
const verticalSpacing = spacingY ?? 46.0;
|
| 107 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
const offsetY = centeredYSlot * verticalSpacing;
|
| 109 |
const ampY = amplitudeY ?? 16.0; // Scaled activation amplitude for point Y peaks (+1 / -1)
|
| 110 |
|
|
|
|
| 95 |
* @param {number} [totalThreads=5] - Total number of threads for centering in ANALYSIS mode
|
| 96 |
* @param {number} [spacingY=46.0] - Vertical Y separation between threads
|
| 97 |
* @param {number} [amplitudeY=16.0] - Height scale factor for activation points (+1 / -1)
|
| 98 |
+
* @param {{ ySlot?: number, ySlotSpan?: number }} [layoutOpts] - Optional ANALYSIS slot override (group gaps)
|
| 99 |
*/
|
| 100 |
+
mapVectorTo3DPoints(
|
| 101 |
+
vector,
|
| 102 |
+
sequenceIndex = 0,
|
| 103 |
+
viewMode = 'NAVIGATION',
|
| 104 |
+
totalThreads = 5,
|
| 105 |
+
spacingY = 46.0,
|
| 106 |
+
amplitudeY = 16.0,
|
| 107 |
+
layoutOpts = {}
|
| 108 |
+
) {
|
| 109 |
if (!vector || !vector.length) return [];
|
| 110 |
const count = vector.length;
|
| 111 |
const offsetCenterX = (count * this.scaleX) / 2.0;
|
|
|
|
| 113 |
if (viewMode === 'ANALYSIS') {
|
| 114 |
// Stack threads vertically along Y axis with customizable separation (spacingY)
|
| 115 |
const verticalSpacing = spacingY ?? 46.0;
|
| 116 |
+
const ySlot = layoutOpts.ySlot !== undefined ? layoutOpts.ySlot : sequenceIndex;
|
| 117 |
+
const ySlotSpan = layoutOpts.ySlotSpan !== undefined
|
| 118 |
+
? layoutOpts.ySlotSpan
|
| 119 |
+
: Math.max(0, totalThreads - 1);
|
| 120 |
+
const centeredYSlot = ySlotSpan / 2.0 - ySlot;
|
| 121 |
const offsetY = centeredYSlot * verticalSpacing;
|
| 122 |
const ampY = amplitudeY ?? 16.0; // Scaled activation amplitude for point Y peaks (+1 / -1)
|
| 123 |
|
src/visualizer/dimContrastSort.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Dimension permutation by between-group mean contrast (L2 / D6).
|
| 3 |
+
* Score per dim = max pairwise |mean_Gi − mean_Gj| across distinct groupIds.
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { countDistinctGroups, listDistinctGroupIds } from './groupStackLayout.js';
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* @param {Array<{ groupId?: string, embedding?: number[] }|null|undefined>|null|undefined} items
|
| 10 |
+
* @returns {boolean}
|
| 11 |
+
*/
|
| 12 |
+
export function hasEnoughGroupsForDimSort(items) {
|
| 13 |
+
return countDistinctGroups(items) >= 2;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Identity permutation [0, 1, …, dim-1].
|
| 18 |
+
* @param {number} dim
|
| 19 |
+
* @returns {number[]}
|
| 20 |
+
*/
|
| 21 |
+
export function identityPermutation(dim) {
|
| 22 |
+
const n = Math.max(0, Math.floor(Number(dim) || 0));
|
| 23 |
+
return Array.from({ length: n }, (_, i) => i);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Per-dimension contrast scores (max pairwise |Δmean|). Length = embedding dim.
|
| 28 |
+
* Returns [] if <2 groups or empty/mismatched embeddings.
|
| 29 |
+
*
|
| 30 |
+
* @param {Array<{ groupId?: string, embedding?: number[] }|null|undefined>|null|undefined} items
|
| 31 |
+
* @returns {number[]}
|
| 32 |
+
*/
|
| 33 |
+
export function computeDimContrastScores(items) {
|
| 34 |
+
const list = (items || []).filter((it) => it?.groupId && Array.isArray(it.embedding) && it.embedding.length);
|
| 35 |
+
const groupIds = listDistinctGroupIds(list);
|
| 36 |
+
if (groupIds.length < 2) return [];
|
| 37 |
+
|
| 38 |
+
const dim = list[0].embedding.length;
|
| 39 |
+
if (!list.every((it) => it.embedding.length === dim)) return [];
|
| 40 |
+
|
| 41 |
+
/** @type {Map<string, { sum: Float64Array, count: number }>} */
|
| 42 |
+
const acc = new Map();
|
| 43 |
+
for (const gid of groupIds) {
|
| 44 |
+
acc.set(gid, { sum: new Float64Array(dim), count: 0 });
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
for (const it of list) {
|
| 48 |
+
const bucket = acc.get(it.groupId);
|
| 49 |
+
if (!bucket) continue;
|
| 50 |
+
const emb = it.embedding;
|
| 51 |
+
for (let d = 0; d < dim; d++) {
|
| 52 |
+
bucket.sum[d] += emb[d];
|
| 53 |
+
}
|
| 54 |
+
bucket.count += 1;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/** @type {Float64Array[]} */
|
| 58 |
+
const means = [];
|
| 59 |
+
for (const gid of groupIds) {
|
| 60 |
+
const bucket = acc.get(gid);
|
| 61 |
+
if (!bucket || bucket.count < 1) continue;
|
| 62 |
+
const mean = new Float64Array(dim);
|
| 63 |
+
for (let d = 0; d < dim; d++) {
|
| 64 |
+
mean[d] = bucket.sum[d] / bucket.count;
|
| 65 |
+
}
|
| 66 |
+
means.push(mean);
|
| 67 |
+
}
|
| 68 |
+
if (means.length < 2) return [];
|
| 69 |
+
|
| 70 |
+
/** @type {number[]} */
|
| 71 |
+
const scores = new Array(dim);
|
| 72 |
+
for (let d = 0; d < dim; d++) {
|
| 73 |
+
let peak = 0;
|
| 74 |
+
for (let i = 0; i < means.length; i++) {
|
| 75 |
+
for (let j = i + 1; j < means.length; j++) {
|
| 76 |
+
const delta = Math.abs(means[i][d] - means[j][d]);
|
| 77 |
+
if (delta > peak) peak = delta;
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
scores[d] = peak;
|
| 81 |
+
}
|
| 82 |
+
return scores;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Permutation of dim indices sorted by contrast descending (stable on ties by index).
|
| 87 |
+
* Identity when contrast cannot be computed.
|
| 88 |
+
*
|
| 89 |
+
* @param {Array<{ groupId?: string, embedding?: number[] }|null|undefined>|null|undefined} items
|
| 90 |
+
* @returns {number[]}
|
| 91 |
+
*/
|
| 92 |
+
export function computeDimContrastPermutation(items) {
|
| 93 |
+
const scores = computeDimContrastScores(items);
|
| 94 |
+
if (!scores.length) {
|
| 95 |
+
const dim = items?.find((it) => Array.isArray(it?.embedding))?.embedding?.length ?? 0;
|
| 96 |
+
return identityPermutation(dim);
|
| 97 |
+
}
|
| 98 |
+
return scores
|
| 99 |
+
.map((score, index) => ({ score, index }))
|
| 100 |
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
| 101 |
+
.map((row) => row.index);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/**
|
| 105 |
+
* Reorder vector components by permutation (displayIndex → sourceDim).
|
| 106 |
+
* @param {number[]} vector
|
| 107 |
+
* @param {number[]|null|undefined} permutation
|
| 108 |
+
* @returns {number[]}
|
| 109 |
+
*/
|
| 110 |
+
export function applyDimPermutation(vector, permutation) {
|
| 111 |
+
if (!Array.isArray(vector)) return [];
|
| 112 |
+
if (!Array.isArray(permutation) || permutation.length !== vector.length) {
|
| 113 |
+
return vector.slice();
|
| 114 |
+
}
|
| 115 |
+
return permutation.map((src) => vector[src]);
|
| 116 |
+
}
|
src/visualizer/groupStackLayout.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ANALYSIS Y-stack slots with optional soft gap between GROUP_* blocks (D5).
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Distinct non-empty groupId values in item order.
|
| 7 |
+
* @param {Array<{ groupId?: string }|null|undefined>|null|undefined} items
|
| 8 |
+
* @returns {string[]}
|
| 9 |
+
*/
|
| 10 |
+
export function listDistinctGroupIds(items) {
|
| 11 |
+
/** @type {string[]} */
|
| 12 |
+
const out = [];
|
| 13 |
+
const seen = new Set();
|
| 14 |
+
for (const it of items || []) {
|
| 15 |
+
const id = it?.groupId;
|
| 16 |
+
if (!id || seen.has(id)) continue;
|
| 17 |
+
seen.add(id);
|
| 18 |
+
out.push(id);
|
| 19 |
+
}
|
| 20 |
+
return out;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/**
|
| 24 |
+
* @param {Array<{ groupId?: string }|null|undefined>|null|undefined} items
|
| 25 |
+
* @returns {number}
|
| 26 |
+
*/
|
| 27 |
+
export function countDistinctGroups(items) {
|
| 28 |
+
return listDistinctGroupIds(items).length;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Y slot indices for ANALYSIS stacking. When consecutive items have different
|
| 33 |
+
* non-empty groupIds, insert `gapSlots` empty slots between them (+1× spacing).
|
| 34 |
+
*
|
| 35 |
+
* @param {Array<{ groupId?: string }|null|undefined>|null|undefined} items
|
| 36 |
+
* @param {{ gapSlots?: number }} [opts]
|
| 37 |
+
* @returns {{ slots: number[], span: number }}
|
| 38 |
+
*/
|
| 39 |
+
export function computeGroupAwareYSlots(items, opts = {}) {
|
| 40 |
+
const list = Array.isArray(items) ? items : [];
|
| 41 |
+
const gapSlots = Number.isFinite(opts.gapSlots) ? Math.max(0, Math.floor(opts.gapSlots)) : 1;
|
| 42 |
+
|
| 43 |
+
/** @type {number[]} */
|
| 44 |
+
const slots = [];
|
| 45 |
+
let slot = 0;
|
| 46 |
+
let prevGroup = /** @type {string|null} */ (null);
|
| 47 |
+
|
| 48 |
+
for (let i = 0; i < list.length; i++) {
|
| 49 |
+
const cur = list[i]?.groupId || null;
|
| 50 |
+
if (i > 0 && prevGroup && cur && prevGroup !== cur) {
|
| 51 |
+
slot += gapSlots;
|
| 52 |
+
}
|
| 53 |
+
slots.push(slot);
|
| 54 |
+
prevGroup = cur;
|
| 55 |
+
slot += 1;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
const maxSlot = slots.length ? slots[slots.length - 1] : 0;
|
| 59 |
+
return { slots, span: maxSlot };
|
| 60 |
+
}
|
tests/LayoutEngine.test.js
CHANGED
|
@@ -44,4 +44,18 @@ describe('LayoutEngine Math Tests', () => {
|
|
| 44 |
// Slot 0 (centeredYSlot = 2.0) with spacing 80 should have double the vertical Y offset of spacing 40
|
| 45 |
expect(pointsExpandedY[0].y).toBeCloseTo(pointsDefaultY[0].y * 2, 5);
|
| 46 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
});
|
|
|
|
| 44 |
// Slot 0 (centeredYSlot = 2.0) with spacing 80 should have double the vertical Y offset of spacing 40
|
| 45 |
expect(pointsExpandedY[0].y).toBeCloseTo(pointsDefaultY[0].y * 2, 5);
|
| 46 |
});
|
| 47 |
+
|
| 48 |
+
it('honors optional ySlot / ySlotSpan for group soft gaps', () => {
|
| 49 |
+
const engine = new LayoutEngine({ scaleX: 1.0 });
|
| 50 |
+
const vector = [0.0];
|
| 51 |
+
const noGap = engine.mapVectorTo3DPoints(vector, 2, 'ANALYSIS', 4, 10.0, 16.0);
|
| 52 |
+
const withGap = engine.mapVectorTo3DPoints(vector, 2, 'ANALYSIS', 4, 10.0, 16.0, {
|
| 53 |
+
ySlot: 3,
|
| 54 |
+
ySlotSpan: 4,
|
| 55 |
+
});
|
| 56 |
+
// slot 3 of span 4 → centered = 2-3 = -1 → y = -10
|
| 57 |
+
// slot 2 of span 3 → centered = 1.5-2 = -0.5 → y = -5
|
| 58 |
+
expect(noGap[0].y).toBeCloseTo(-5, 5);
|
| 59 |
+
expect(withGap[0].y).toBeCloseTo(-10, 5);
|
| 60 |
+
});
|
| 61 |
});
|
tests/dimContrastSort.test.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest';
|
| 2 |
+
import {
|
| 3 |
+
hasEnoughGroupsForDimSort,
|
| 4 |
+
computeDimContrastScores,
|
| 5 |
+
computeDimContrastPermutation,
|
| 6 |
+
applyDimPermutation,
|
| 7 |
+
identityPermutation,
|
| 8 |
+
} from '../src/visualizer/dimContrastSort.js';
|
| 9 |
+
|
| 10 |
+
describe('dimContrastSort', () => {
|
| 11 |
+
it('requires ≥2 groups', () => {
|
| 12 |
+
expect(hasEnoughGroupsForDimSort([{ groupId: 'G1', embedding: [1, 0] }])).toBe(false);
|
| 13 |
+
expect(hasEnoughGroupsForDimSort([
|
| 14 |
+
{ groupId: 'G1', embedding: [1, 0] },
|
| 15 |
+
{ groupId: 'G2', embedding: [0, 1] },
|
| 16 |
+
])).toBe(true);
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
it('scores dims by max pairwise |Δmean|', () => {
|
| 20 |
+
// dim0: G1=1, G2=0 → contrast 1
|
| 21 |
+
// dim1: G1=0, G2=0 → contrast 0
|
| 22 |
+
// dim2: G1=0.5, G2=−0.5 → contrast 1
|
| 23 |
+
const items = [
|
| 24 |
+
{ groupId: 'G1', embedding: [1, 0, 0.5] },
|
| 25 |
+
{ groupId: 'G1', embedding: [1, 0, 0.5] },
|
| 26 |
+
{ groupId: 'G2', embedding: [0, 0, -0.5] },
|
| 27 |
+
{ groupId: 'G2', embedding: [0, 0, -0.5] },
|
| 28 |
+
];
|
| 29 |
+
const scores = computeDimContrastScores(items);
|
| 30 |
+
expect(scores).toHaveLength(3);
|
| 31 |
+
expect(scores[0]).toBeCloseTo(1);
|
| 32 |
+
expect(scores[1]).toBeCloseTo(0);
|
| 33 |
+
expect(scores[2]).toBeCloseTo(1);
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
it('permutes high-contrast dims first (stable on ties)', () => {
|
| 37 |
+
const items = [
|
| 38 |
+
{ groupId: 'G1', embedding: [1, 0, 2] },
|
| 39 |
+
{ groupId: 'G2', embedding: [0, 0, 0] },
|
| 40 |
+
];
|
| 41 |
+
// contrasts: dim0=1, dim1=0, dim2=2 → order [2, 0, 1]
|
| 42 |
+
expect(computeDimContrastPermutation(items)).toEqual([2, 0, 1]);
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it('applyDimPermutation reorders components', () => {
|
| 46 |
+
expect(applyDimPermutation([10, 20, 30], [2, 0, 1])).toEqual([30, 10, 20]);
|
| 47 |
+
expect(applyDimPermutation([1, 2], null)).toEqual([1, 2]);
|
| 48 |
+
expect(identityPermutation(3)).toEqual([0, 1, 2]);
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
it('falls back to identity when <2 groups', () => {
|
| 52 |
+
const items = [{ groupId: 'G1', embedding: [1, 2, 3] }];
|
| 53 |
+
expect(computeDimContrastPermutation(items)).toEqual([0, 1, 2]);
|
| 54 |
+
});
|
| 55 |
+
});
|
tests/groupStackLayout.test.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest';
|
| 2 |
+
import {
|
| 3 |
+
countDistinctGroups,
|
| 4 |
+
computeGroupAwareYSlots,
|
| 5 |
+
listDistinctGroupIds,
|
| 6 |
+
} from '../src/visualizer/groupStackLayout.js';
|
| 7 |
+
|
| 8 |
+
describe('groupStackLayout', () => {
|
| 9 |
+
it('counts distinct groupIds', () => {
|
| 10 |
+
expect(countDistinctGroups([])).toBe(0);
|
| 11 |
+
expect(countDistinctGroups([{ groupId: 'G1' }, { groupId: 'G1' }])).toBe(1);
|
| 12 |
+
expect(countDistinctGroups([
|
| 13 |
+
{ groupId: 'G1' },
|
| 14 |
+
{ groupId: 'G2' },
|
| 15 |
+
{ groupId: 'G1' },
|
| 16 |
+
])).toBe(2);
|
| 17 |
+
expect(listDistinctGroupIds([
|
| 18 |
+
{ groupId: 'GROUP_1' },
|
| 19 |
+
{ groupId: 'GROUP_2' },
|
| 20 |
+
])).toEqual(['GROUP_1', 'GROUP_2']);
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
it('uses contiguous slots when no group change', () => {
|
| 24 |
+
const items = [{ groupId: 'G1' }, { groupId: 'G1' }, { groupId: 'G1' }];
|
| 25 |
+
expect(computeGroupAwareYSlots(items)).toEqual({ slots: [0, 1, 2], span: 2 });
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
it('inserts soft gap (+1 slot) between different groups', () => {
|
| 29 |
+
const items = [
|
| 30 |
+
{ groupId: 'G1' },
|
| 31 |
+
{ groupId: 'G1' },
|
| 32 |
+
{ groupId: 'G2' },
|
| 33 |
+
{ groupId: 'G2' },
|
| 34 |
+
];
|
| 35 |
+
// slots 0,1 | gap | 3,4 → span 4
|
| 36 |
+
expect(computeGroupAwareYSlots(items, { gapSlots: 1 })).toEqual({
|
| 37 |
+
slots: [0, 1, 3, 4],
|
| 38 |
+
span: 4,
|
| 39 |
+
});
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
it('skips gap when items lack groupId', () => {
|
| 43 |
+
const items = [{}, {}, {}];
|
| 44 |
+
expect(computeGroupAwareYSlots(items)).toEqual({ slots: [0, 1, 2], span: 2 });
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it('honors gapSlots=0 (no extra separation)', () => {
|
| 48 |
+
const items = [{ groupId: 'A' }, { groupId: 'B' }];
|
| 49 |
+
expect(computeGroupAwareYSlots(items, { gapSlots: 0 })).toEqual({
|
| 50 |
+
slots: [0, 1],
|
| 51 |
+
span: 1,
|
| 52 |
+
});
|
| 53 |
+
});
|
| 54 |
+
});
|
tests/saeFilterBridge.test.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest';
|
| 2 |
+
import {
|
| 3 |
+
SAE_ON_FILTER_MODE,
|
| 4 |
+
snapshotFilterForSae,
|
| 5 |
+
filterModeForSaeOn,
|
| 6 |
+
restoreFilterAfterSae,
|
| 7 |
+
} from '../src/ui/saeFilterBridge.js';
|
| 8 |
+
|
| 9 |
+
describe('saeFilterBridge', () => {
|
| 10 |
+
it('SAE ON forces + Only (positive)', () => {
|
| 11 |
+
expect(filterModeForSaeOn()).toBe('positive');
|
| 12 |
+
expect(SAE_ON_FILTER_MODE).toBe('positive');
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
it('snapshots current filter and restores it after SAE OFF', () => {
|
| 16 |
+
const snap = snapshotFilterForSae('all');
|
| 17 |
+
expect(snap.previousMode).toBe('all');
|
| 18 |
+
expect(restoreFilterAfterSae(snap)).toBe('all');
|
| 19 |
+
|
| 20 |
+
const snapNeg = snapshotFilterForSae('negative');
|
| 21 |
+
expect(restoreFilterAfterSae(snapNeg)).toBe('negative');
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
it('normalizes invalid snapshot modes to all', () => {
|
| 25 |
+
expect(snapshotFilterForSae('bogus').previousMode).toBe('all');
|
| 26 |
+
expect(restoreFilterAfterSae(null)).toBeNull();
|
| 27 |
+
expect(restoreFilterAfterSae(undefined)).toBeNull();
|
| 28 |
+
});
|
| 29 |
+
});
|
tests/spatialSliderDefaults.test.js
CHANGED
|
@@ -6,23 +6,33 @@ import {
|
|
| 6 |
} from '../src/ui/spatialSliderDefaults.js';
|
| 7 |
|
| 8 |
describe('resolveSpatialDefaults', () => {
|
|
|
|
| 9 |
const savedKeys = [];
|
| 10 |
|
| 11 |
afterEach(() => {
|
| 12 |
-
for (const key of savedKeys.splice(0)) {
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
});
|
| 16 |
|
| 17 |
function setOverride(key, partial) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
SPATIAL_DEFAULT_OVERRIDES[key] = partial;
|
| 19 |
-
savedKeys.push(key);
|
| 20 |
}
|
| 21 |
|
| 22 |
it('returns global mid defaults when no overrides exist', () => {
|
| 23 |
const d = resolveSpatialDefaults({
|
| 24 |
-
workspaceMode: '
|
| 25 |
-
viewMode: '
|
| 26 |
renderMode: 'RIBBONS',
|
| 27 |
});
|
| 28 |
expect(d.threadSpacing).toBe(GLOBAL_SPATIAL_DEFAULTS.threadSpacing);
|
|
@@ -50,7 +60,7 @@ describe('resolveSpatialDefaults', () => {
|
|
| 50 |
});
|
| 51 |
|
| 52 |
it('does not leak overrides from a different context', () => {
|
| 53 |
-
setOverride('COMPARE|ANALYSIS|
|
| 54 |
|
| 55 |
const d = resolveSpatialDefaults({
|
| 56 |
workspaceMode: 'ARITHMETIC',
|
|
@@ -87,7 +97,7 @@ describe('resolveSpatialDefaults', () => {
|
|
| 87 |
expect(d.threadThickness).toBe(0.01);
|
| 88 |
});
|
| 89 |
|
| 90 |
-
it('applies
|
| 91 |
const d = resolveSpatialDefaults({
|
| 92 |
workspaceMode: 'COMPARE',
|
| 93 |
viewMode: 'ANALYSIS',
|
|
@@ -95,11 +105,22 @@ describe('resolveSpatialDefaults', () => {
|
|
| 95 |
});
|
| 96 |
expect(d.threadSpacing).toBe(1.45);
|
| 97 |
expect(d.threadVectorDistance).toBe(1.0);
|
| 98 |
-
expect(d.threadAmplitudeY).toBe(
|
| 99 |
expect(d.threadWidth).toBe(0.2);
|
| 100 |
expect(d.threadThickness).toBe(0.01);
|
| 101 |
});
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
it('applies captured COMPARE|NAVIGATION|RIBBONS preset', () => {
|
| 104 |
const d = resolveSpatialDefaults({
|
| 105 |
workspaceMode: 'COMPARE',
|
|
|
|
| 6 |
} from '../src/ui/spatialSliderDefaults.js';
|
| 7 |
|
| 8 |
describe('resolveSpatialDefaults', () => {
|
| 9 |
+
/** @type {Array<{ key: string, previous: object|undefined }>} */
|
| 10 |
const savedKeys = [];
|
| 11 |
|
| 12 |
afterEach(() => {
|
| 13 |
+
for (const { key, previous } of savedKeys.splice(0)) {
|
| 14 |
+
if (previous === undefined) {
|
| 15 |
+
delete SPATIAL_DEFAULT_OVERRIDES[key];
|
| 16 |
+
} else {
|
| 17 |
+
SPATIAL_DEFAULT_OVERRIDES[key] = previous;
|
| 18 |
+
}
|
| 19 |
}
|
| 20 |
});
|
| 21 |
|
| 22 |
function setOverride(key, partial) {
|
| 23 |
+
savedKeys.push({
|
| 24 |
+
key,
|
| 25 |
+
previous: SPATIAL_DEFAULT_OVERRIDES[key]
|
| 26 |
+
? { ...SPATIAL_DEFAULT_OVERRIDES[key] }
|
| 27 |
+
: undefined,
|
| 28 |
+
});
|
| 29 |
SPATIAL_DEFAULT_OVERRIDES[key] = partial;
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
it('returns global mid defaults when no overrides exist', () => {
|
| 33 |
const d = resolveSpatialDefaults({
|
| 34 |
+
workspaceMode: 'ARITHMETIC',
|
| 35 |
+
viewMode: 'NAVIGATION',
|
| 36 |
renderMode: 'RIBBONS',
|
| 37 |
});
|
| 38 |
expect(d.threadSpacing).toBe(GLOBAL_SPATIAL_DEFAULTS.threadSpacing);
|
|
|
|
| 60 |
});
|
| 61 |
|
| 62 |
it('does not leak overrides from a different context', () => {
|
| 63 |
+
setOverride('COMPARE|ANALYSIS|POINTS', { threadWidth: 0.25 });
|
| 64 |
|
| 65 |
const d = resolveSpatialDefaults({
|
| 66 |
workspaceMode: 'ARITHMETIC',
|
|
|
|
| 97 |
expect(d.threadThickness).toBe(0.01);
|
| 98 |
});
|
| 99 |
|
| 100 |
+
it('applies COMPARE|ANALYSIS|POINTS preset with Amplitude 16', () => {
|
| 101 |
const d = resolveSpatialDefaults({
|
| 102 |
workspaceMode: 'COMPARE',
|
| 103 |
viewMode: 'ANALYSIS',
|
|
|
|
| 105 |
});
|
| 106 |
expect(d.threadSpacing).toBe(1.45);
|
| 107 |
expect(d.threadVectorDistance).toBe(1.0);
|
| 108 |
+
expect(d.threadAmplitudeY).toBe(16.0);
|
| 109 |
expect(d.threadWidth).toBe(0.2);
|
| 110 |
expect(d.threadThickness).toBe(0.01);
|
| 111 |
});
|
| 112 |
|
| 113 |
+
it('applies COMPARE|ANALYSIS|RIBBONS twin with Amplitude 16', () => {
|
| 114 |
+
const d = resolveSpatialDefaults({
|
| 115 |
+
workspaceMode: 'COMPARE',
|
| 116 |
+
viewMode: 'ANALYSIS',
|
| 117 |
+
renderMode: 'RIBBONS',
|
| 118 |
+
});
|
| 119 |
+
expect(d.threadAmplitudeY).toBe(16.0);
|
| 120 |
+
expect(d.threadSpacing).toBe(1.45);
|
| 121 |
+
expect(d.threadVectorDistance).toBe(1.0);
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
it('applies captured COMPARE|NAVIGATION|RIBBONS preset', () => {
|
| 125 |
const d = resolveSpatialDefaults({
|
| 126 |
workspaceMode: 'COMPARE',
|