hbauzan Cursor commited on
Commit
35c1912
·
1 Parent(s): c7eb0af

feat(compare): group contrast visibility L1+L2 (v2.1.0)

Browse files

Raise 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 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.0.0",
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.0.0",
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:** Planned (diagnosis complete; product decisions open)
 
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 | _pending_ | |
123
- | D2 | _pending_ | |
124
- | D3 | _pending_ | |
125
- | D4 | _pending_ | |
126
- | D5 | _pending_ | |
127
- | D6 | _pending_ | |
128
- | D7 | _pending_ | |
129
- | D8 | _pending_ | |
130
- | D9 | _pending_ | |
131
- | D10 | _pending_ | |
132
- | D11 | _pending_ | |
133
- | D12 | _pending_ | |
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 { loadVisualizationSettings } from './ui/visualizationControlsDefaults.js';
 
 
 
 
 
 
 
 
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
- const hasItems = !!(this.items && this.items.length > 1);
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
- return;
 
 
 
 
 
 
 
308
  }
309
- this.groupsLegend.removeAttribute('hidden');
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.0.0</span>
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
- // Captured COMPARE + ANALYSIS + POINTS (Spatial Controls + CAM POSE framing)
49
  'COMPARE|ANALYSIS|POINTS': {
50
  threadSpacing: 1.45,
51
  threadVectorDistance: 1.0,
52
- threadAmplitudeY: 1.0,
 
 
 
 
 
 
 
 
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(compareResponse, renderMode = "POINTS", spatialConfig = null, viewMode = "NAVIGATION", vizConfig = null) {
 
 
 
 
 
 
 
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 totalThreads = compareResponse.items.length;
 
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
- compareResponse.items.forEach((item, idx) => {
266
- const vec = item.embedding;
267
- if (!vec || !vec.length) return;
268
 
 
269
  const threadId = item.id || `tok_${idx}`;
270
- const vec3D = this.layoutEngine.mapVectorTo3DPoints(vec, idx, viewMode, totalThreads, spacingY, amplitudeY);
 
 
 
 
 
 
 
 
 
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: dimIdx, val }
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(vector, sequenceIndex = 0, viewMode = 'NAVIGATION', totalThreads = 5, spacingY = 46.0, amplitudeY = 16.0) {
 
 
 
 
 
 
 
 
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 centeredYSlot = (totalThreads - 1) / 2.0 - sequenceIndex;
 
 
 
 
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
- delete SPATIAL_DEFAULT_OVERRIDES[key];
 
 
 
 
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: 'COMPARE',
25
- viewMode: 'ANALYSIS',
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|RIBBONS', { threadWidth: 0.25 });
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 captured COMPARE|ANALYSIS|POINTS preset', () => {
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(1.0);
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',