opencode-rag-plugin 1.19.5 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/chunker/image.d.ts +1 -1
  2. package/dist/chunker/image.js +35 -22
  3. package/dist/cli/commands/backend-detect.d.ts +61 -0
  4. package/dist/cli/commands/backend-detect.js +119 -0
  5. package/dist/cli/commands/describe-image.js +2 -1
  6. package/dist/cli/commands/index-command.js +11 -0
  7. package/dist/cli/commands/init-helpers.d.ts +4 -1
  8. package/dist/cli/commands/init-helpers.js +10 -5
  9. package/dist/cli/commands/init.js +30 -3
  10. package/dist/cli/commands/setup.js +7 -1
  11. package/dist/cli/types.d.ts +2 -0
  12. package/dist/core/config.d.ts +27 -1
  13. package/dist/core/config.js +10 -2
  14. package/dist/core/interfaces.d.ts +32 -4
  15. package/dist/core/manifest.js +1 -1
  16. package/dist/describer/describer.d.ts +20 -0
  17. package/dist/describer/describer.js +117 -14
  18. package/dist/describer/shared.d.ts +28 -0
  19. package/dist/describer/shared.js +60 -0
  20. package/dist/embedder/factory.js +1 -1
  21. package/dist/embedder/ollama.d.ts +3 -1
  22. package/dist/embedder/ollama.js +12 -2
  23. package/dist/indexer/pipeline.js +187 -99
  24. package/dist/mcp/handlers.d.ts +2 -0
  25. package/dist/mcp/handlers.js +1 -1
  26. package/dist/mcp/server.js +2 -1
  27. package/dist/opencode/system-guidance.js +4 -4
  28. package/dist/opencode/tools.js +4 -1
  29. package/dist/vectorstore/lancedb.d.ts +79 -7
  30. package/dist/vectorstore/lancedb.js +200 -72
  31. package/dist/vectorstore/memory.d.ts +8 -3
  32. package/dist/vectorstore/memory.js +22 -3
  33. package/dist/watcher.d.ts +8 -0
  34. package/dist/watcher.js +222 -96
  35. package/dist/web/api.js +24 -15
  36. package/dist/web/pca.d.ts +5 -2
  37. package/dist/web/pca.js +75 -20
  38. package/dist/web/ui/assets/ScatterPlot3D-BFWO5sAH.js +4116 -0
  39. package/dist/web/ui/assets/index-BLzCza1W.css +1 -0
  40. package/dist/web/ui/assets/index-BdPHzjQh.js +4 -0
  41. package/dist/web/ui/index.html +2 -2
  42. package/package.json +4 -1
  43. package/dist/web/ui/assets/index-BDPYdtA1.js +0 -3
  44. package/dist/web/ui/assets/index-CKdp79Tw.css +0 -1
package/dist/watcher.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import chokidar from "chokidar";
7
7
  import path from "node:path";
8
- import { writeFileSync, unlinkSync, existsSync } from "node:fs";
8
+ import { writeFileSync, unlinkSync, existsSync, readFileSync, openSync, closeSync } from "node:fs";
9
9
  import { appendDebugLog } from "./core/fileLogger.js";
10
10
  import { isCorruptionError } from "./vectorstore/lancedb.js";
11
11
  import { createWatchPassScheduler, createWatchIgnore, runIndexPass, } from "./indexer.js";
@@ -18,6 +18,81 @@ function writeWatcherStatus(storePath, status) {
18
18
  // silently ignore write errors
19
19
  }
20
20
  }
21
+ // ── Cross-process watcher claim lock ────────────────────────────────────────
22
+ // `index.lock` only serializes individual index passes — it does NOT stop N
23
+ // processes (N OpenCode sessions, or a session + `opencode-rag index --watch`)
24
+ // from each spawning their own chokidar watcher for the same workspace. Every
25
+ // extra watcher fires its own fire-and-forget initial pass (all but one skip
26
+ // the pass lock and then retry every 30s) and burns file-watcher resources.
27
+ // The claim lock below guarantees at most ONE active watcher per workspace:
28
+ // the first process to claim it runs the watcher; the others stay dormant and
29
+ // periodically re-check so they take over if the owning process exits.
30
+ const WATCHER_LOCK_FILE = "watcher.lock";
31
+ /** How often a dormant indexer re-checks whether the watcher lock is free. */
32
+ const WATCHER_RECHECK_MS = 60_000;
33
+ function isPidAlive(pid) {
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ function readWatcherLock(storePath) {
43
+ try {
44
+ const parsed = JSON.parse(readFileSync(path.join(storePath, WATCHER_LOCK_FILE), "utf-8"));
45
+ return typeof parsed.pid === "number" ? parsed : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * Atomically claim the watcher lock for a workspace. Returns true only if
53
+ * this process is now the active watcher. A stale lock (dead PID) or an
54
+ * unreadable/corrupt lock file is cleared and re-claimed.
55
+ */
56
+ export function tryAcquireWatcherLock(storePath) {
57
+ const lockPath = path.join(storePath, WATCHER_LOCK_FILE);
58
+ for (let attempt = 0; attempt < 2; attempt++) {
59
+ const existing = readWatcherLock(storePath);
60
+ if (!existing || !isPidAlive(existing.pid)) {
61
+ // Stale, corrupt, or missing lock — clear it and claim atomically
62
+ // (O_EXCL create so two racing processes cannot both win).
63
+ try {
64
+ unlinkSync(lockPath);
65
+ }
66
+ catch { /* may not exist */ }
67
+ try {
68
+ const fd = openSync(lockPath, "wx");
69
+ try {
70
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), "utf-8");
71
+ }
72
+ finally {
73
+ closeSync(fd);
74
+ }
75
+ return true;
76
+ }
77
+ catch {
78
+ // Someone else claimed it between our unlink and create — retry once.
79
+ continue;
80
+ }
81
+ }
82
+ return false;
83
+ }
84
+ return false;
85
+ }
86
+ /** Release the watcher lock — only if this process owns it. */
87
+ export function releaseWatcherLock(storePath) {
88
+ const lock = readWatcherLock(storePath);
89
+ if (lock?.pid === process.pid) {
90
+ try {
91
+ unlinkSync(path.join(storePath, WATCHER_LOCK_FILE));
92
+ }
93
+ catch { /* ignore */ }
94
+ }
95
+ }
21
96
  /**
22
97
  * Create a background file watcher that automatically re-indexes the
23
98
  * workspace when files change. Uses chokidar for file system events and
@@ -29,110 +104,120 @@ function writeWatcherStatus(storePath, status) {
29
104
  */
30
105
  export function createBackgroundIndexer(options) {
31
106
  const { cwd, storePath, config, store, embedder, logFilePath, logLevel, keywordIndex, descriptionProvider, dimension } = options;
32
- writeWatcherStatus(storePath, { running: false, lastRunAt: undefined });
33
- const ac = new AbortController();
34
- const updateStatus = (partial) => {
35
- writeWatcherStatus(storePath, { running: false, lastRunAt: undefined, ...partial });
36
- };
37
- const runPass = async (filterPaths) => {
38
- updateStatus({ running: true, lastRunAt: Date.now() });
39
- try {
40
- const stats = await runIndexPass({
41
- cwd,
42
- storePath,
43
- config,
44
- store,
45
- embedder,
46
- keywordIndex,
47
- descriptionProvider,
48
- dimension,
49
- filterPaths,
50
- abortSignal: ac.signal,
51
- logger: {
52
- info: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
53
- warn: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
54
- debug: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message: `DEBUG: ${message}`, severity: "debug" }, logLevel),
55
- },
56
- });
57
- // A lock-skipped pass did NO work — retry shortly so the workspace
58
- // does not stay unindexed until the next file event.
59
- if (stats.skipped) {
107
+ const autoIndexCfg = config.openCode.autoIndex ?? { enabled: false, debounceMs: 5000, intervalMs: 300000 };
108
+ let active = false;
109
+ let recheckTimer;
110
+ let stopActive;
111
+ /** Start the chokidar watcher, debounced scheduler, and initial pass. */
112
+ const startActive = () => {
113
+ if (active)
114
+ return;
115
+ active = true;
116
+ writeWatcherStatus(storePath, { running: false, lastRunAt: undefined });
117
+ const ac = new AbortController();
118
+ const updateStatus = (partial) => {
119
+ writeWatcherStatus(storePath, { running: false, lastRunAt: undefined, ...partial });
120
+ };
121
+ const runPass = async (filterPaths) => {
122
+ updateStatus({ running: true, lastRunAt: Date.now() });
123
+ try {
124
+ const stats = await runIndexPass({
125
+ cwd,
126
+ storePath,
127
+ config,
128
+ store,
129
+ embedder,
130
+ keywordIndex,
131
+ descriptionProvider,
132
+ dimension,
133
+ filterPaths,
134
+ abortSignal: ac.signal,
135
+ logger: {
136
+ info: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
137
+ warn: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message }, logLevel),
138
+ debug: (message) => appendDebugLog(logFilePath, { scope: "autoIndex", message: `DEBUG: ${message}`, severity: "debug" }, logLevel),
139
+ },
140
+ });
141
+ // A lock-skipped pass did NO work — retry shortly so the workspace
142
+ // does not stay unindexed until the next file event.
143
+ if (stats.skipped) {
144
+ appendDebugLog(logFilePath, {
145
+ scope: "autoIndex",
146
+ message: "Index pass skipped (another pass holds the lock) — retrying in 30s",
147
+ }, logLevel);
148
+ if (!ac.signal.aborted) {
149
+ setTimeout(() => {
150
+ if (!ac.signal.aborted)
151
+ scheduler.notifyChange(filterPaths);
152
+ }, 30_000).unref();
153
+ }
154
+ }
155
+ updateStatus({ running: false, lastRunAt: Date.now() });
156
+ }
157
+ catch (err) {
60
158
  appendDebugLog(logFilePath, {
61
159
  scope: "autoIndex",
62
- message: "Index pass skipped (another pass holds the lock) — retrying in 30s",
160
+ message: "Watch reindex pass failed",
161
+ error: err,
63
162
  }, logLevel);
64
- if (!ac.signal.aborted) {
65
- setTimeout(() => {
66
- if (!ac.signal.aborted)
67
- scheduler.notifyChange(filterPaths);
68
- }, 30_000).unref();
163
+ if (isCorruptionError(err)) {
164
+ appendDebugLog(logFilePath, {
165
+ scope: "autoIndex",
166
+ message: "Corruption detected — run 'opencode-rag index --force' to rebuild manually",
167
+ }, logLevel);
69
168
  }
169
+ updateStatus({ running: false, lastRunAt: Date.now() });
70
170
  }
71
- updateStatus({ running: false, lastRunAt: Date.now() });
72
- }
73
- catch (err) {
171
+ };
172
+ // Fire-and-forget initial index pass
173
+ runPass().catch((err) => {
74
174
  appendDebugLog(logFilePath, {
75
175
  scope: "autoIndex",
76
- message: "Watch reindex pass failed",
176
+ message: "Initial index pass failed",
77
177
  error: err,
78
178
  }, logLevel);
79
- if (isCorruptionError(err)) {
80
- appendDebugLog(logFilePath, {
81
- scope: "autoIndex",
82
- message: "Corruption detected — run 'opencode-rag index --force' to rebuild manually",
83
- }, logLevel);
84
- }
85
- updateStatus({ running: false, lastRunAt: Date.now() });
86
- }
87
- };
88
- // Fire-and-forget initial index pass
89
- runPass().catch((err) => {
90
- appendDebugLog(logFilePath, {
91
- scope: "autoIndex",
92
- message: "Initial index pass failed",
93
- error: err,
94
- }, logLevel);
95
- });
96
- const autoIndexCfg = config.openCode.autoIndex ?? { enabled: false, debounceMs: 5000, intervalMs: 300000 };
97
- const scheduler = createWatchPassScheduler(runPass, (error) => {
98
- const message = error.message || String(error);
99
- appendDebugLog(logFilePath, {
100
- scope: "autoIndex",
101
- message: `Watch reindex failed: ${message}`,
102
- error,
103
- }, logLevel);
104
- }, autoIndexCfg.debounceMs);
105
- const watcher = chokidar.watch(cwd, {
106
- ignored: createWatchIgnore(cwd, config, storePath),
107
- ignoreInitial: true,
108
- persistent: true,
109
- });
110
- const handleChange = (filePath) => scheduler.notifyChange(filePath ? [filePath] : undefined);
111
- watcher.on("add", handleChange);
112
- watcher.on("change", handleChange);
113
- watcher.on("unlink", handleChange);
114
- watcher.on("unlinkDir", handleChange);
115
- watcher.on("addDir", handleChange);
116
- watcher.on("error", (error) => {
117
- appendDebugLog(logFilePath, {
118
- scope: "autoIndex",
119
- message: `Watcher error: ${error.message}`,
120
- error,
121
- }, logLevel);
122
- });
123
- // Periodic timer: only needed for git backend (chokidar gets real FS events).
124
- // Note: with git mode BOTH backends run — the scheduler coalesces redundant
125
- // passes into one, so this only adds a safety net for missed events.
126
- const watcherBackend = autoIndexCfg.watcher ?? "chokidar";
127
- const periodicTimer = watcherBackend === "git"
128
- ? setInterval(() => {
129
- scheduler.notifyChange();
130
- }, autoIndexCfg.intervalMs)
131
- : undefined;
132
- // Never keep the process alive just for the periodic scan
133
- periodicTimer?.unref();
134
- return {
135
- async close() {
179
+ });
180
+ const scheduler = createWatchPassScheduler(runPass, (error) => {
181
+ const message = error.message || String(error);
182
+ appendDebugLog(logFilePath, {
183
+ scope: "autoIndex",
184
+ message: `Watch reindex failed: ${message}`,
185
+ error,
186
+ }, logLevel);
187
+ }, autoIndexCfg.debounceMs);
188
+ const watcher = chokidar.watch(cwd, {
189
+ ignored: createWatchIgnore(cwd, config, storePath),
190
+ ignoreInitial: true,
191
+ persistent: true,
192
+ });
193
+ const handleChange = (filePath) => scheduler.notifyChange(filePath ? [filePath] : undefined);
194
+ watcher.on("add", handleChange);
195
+ watcher.on("change", handleChange);
196
+ watcher.on("unlink", handleChange);
197
+ watcher.on("unlinkDir", handleChange);
198
+ watcher.on("addDir", handleChange);
199
+ watcher.on("error", (error) => {
200
+ appendDebugLog(logFilePath, {
201
+ scope: "autoIndex",
202
+ message: `Watcher error: ${error.message}`,
203
+ error,
204
+ }, logLevel);
205
+ });
206
+ // Periodic timer: only needed for git backend (chokidar gets real FS events).
207
+ // Note: with git mode BOTH backends run — the scheduler coalesces redundant
208
+ // passes into one, so this only adds a safety net for missed events.
209
+ const watcherBackend = autoIndexCfg.watcher ?? "chokidar";
210
+ const periodicTimer = watcherBackend === "git"
211
+ ? setInterval(() => {
212
+ scheduler.notifyChange();
213
+ }, autoIndexCfg.intervalMs)
214
+ : undefined;
215
+ // Never keep the process alive just for the periodic scan
216
+ periodicTimer?.unref();
217
+ stopActive = async () => {
218
+ if (!active)
219
+ return;
220
+ active = false;
136
221
  if (periodicTimer)
137
222
  clearInterval(periodicTimer);
138
223
  ac.abort();
@@ -154,10 +239,51 @@ export function createBackgroundIndexer(options) {
154
239
  }
155
240
  catch { /* ignore */ }
156
241
  }
242
+ releaseWatcherLock(storePath);
157
243
  appendDebugLog(logFilePath, {
158
244
  scope: "autoIndex",
159
245
  message: "Background indexer shut down",
160
246
  });
247
+ };
248
+ };
249
+ if (tryAcquireWatcherLock(storePath)) {
250
+ startActive();
251
+ }
252
+ else {
253
+ // Another process already runs the watcher for this workspace (a second
254
+ // OpenCode session, `opencode-rag index --watch`, …). Stay dormant and
255
+ // re-check periodically so this process takes over once the owner exits.
256
+ const owner = readWatcherLock(storePath);
257
+ appendDebugLog(logFilePath, {
258
+ scope: "autoIndex",
259
+ message: owner?.pid
260
+ ? `Watcher already running for this workspace (PID ${owner.pid}) — skipping duplicate watcher`
261
+ : "Watcher already running for this workspace — skipping duplicate watcher",
262
+ }, logLevel);
263
+ recheckTimer = setInterval(() => {
264
+ if (!active && tryAcquireWatcherLock(storePath)) {
265
+ appendDebugLog(logFilePath, {
266
+ scope: "autoIndex",
267
+ message: "Previous watcher released this workspace — taking over",
268
+ }, logLevel);
269
+ if (recheckTimer) {
270
+ clearInterval(recheckTimer);
271
+ recheckTimer = undefined;
272
+ }
273
+ startActive();
274
+ }
275
+ }, WATCHER_RECHECK_MS);
276
+ recheckTimer.unref();
277
+ }
278
+ return {
279
+ async close() {
280
+ if (recheckTimer) {
281
+ clearInterval(recheckTimer);
282
+ recheckTimer = undefined;
283
+ }
284
+ if (stopActive) {
285
+ await stopActive();
286
+ }
161
287
  },
162
288
  };
163
289
  }
package/dist/web/api.js CHANGED
@@ -602,17 +602,18 @@ function redactKeys(obj) {
602
602
  }
603
603
  }
604
604
  /**
605
- * Project chunk embeddings to 2D via PCA for the Embedding Space Explorer.
606
- * Capped at 5000 chunks and memoized per (storePath, maxChunks) so the
605
+ * Project chunk embeddings to 2D/3D via PCA for the Embedding Space Explorer.
606
+ * Capped at 5000 chunks and memoized per (maxChunks, dims) so the
607
607
  * O(n·dim²) computation does not run on every visit.
608
608
  */
609
609
  let projectionCache = null;
610
610
  async function handleEmbeddingProjection(store, params) {
611
611
  const rawMaxChunks = parseInt(params.get("maxChunks") ?? "5000", 10);
612
612
  const maxChunks = Number.isFinite(rawMaxChunks) ? Math.min(5000, Math.max(1, rawMaxChunks)) : 5000;
613
+ const dims = parseInt(params.get("dims") ?? "2", 10) === 3 ? 3 : 2;
613
614
  try {
614
615
  // Invalidated after a reindex pass completes (see handleReindex)
615
- const cacheKey = `${maxChunks}`;
616
+ const cacheKey = `${maxChunks}:${dims}`;
616
617
  if (projectionCache && projectionCache.key === cacheKey) {
617
618
  return { status: 200, body: projectionCache.body };
618
619
  }
@@ -622,23 +623,31 @@ async function handleEmbeddingProjection(store, params) {
622
623
  return { status: 200, body: projectionCache.body };
623
624
  }
624
625
  if (chunks.length === 1) {
625
- const body = { points: [{ id: chunks[0].id, x: 0.5, y: 0.5, filePath: chunks[0].filePath, startLine: chunks[0].startLine, endLine: chunks[0].endLine, language: chunks[0].language, description: chunks[0].description }], totalChunks: 1, displayedChunks: 1 };
626
+ const point = { id: chunks[0].id, x: 0.5, y: 0.5, filePath: chunks[0].filePath, startLine: chunks[0].startLine, endLine: chunks[0].endLine, language: chunks[0].language, description: chunks[0].description };
627
+ if (dims === 3)
628
+ point.z = 0.5;
629
+ const body = { points: [point], totalChunks: 1, displayedChunks: 1 };
626
630
  projectionCache = { key: cacheKey, body };
627
631
  return { status: 200, body };
628
632
  }
629
633
  const { computePCA } = await import("./pca.js");
630
634
  const vectors = chunks.map(c => c.embedding);
631
- const projected = computePCA(vectors);
632
- const points = chunks.map((c, i) => ({
633
- id: c.id,
634
- x: projected[i].x,
635
- y: projected[i].y,
636
- filePath: c.filePath,
637
- startLine: c.startLine,
638
- endLine: c.endLine,
639
- language: c.language,
640
- description: c.description,
641
- }));
635
+ const projected = computePCA(vectors, dims);
636
+ const points = chunks.map((c, i) => {
637
+ const point = {
638
+ id: c.id,
639
+ x: projected[i].x,
640
+ y: projected[i].y,
641
+ filePath: c.filePath,
642
+ startLine: c.startLine,
643
+ endLine: c.endLine,
644
+ language: c.language,
645
+ description: c.description,
646
+ };
647
+ if (dims === 3)
648
+ point.z = projected[i].z;
649
+ return point;
650
+ });
642
651
  const body = { points, totalChunks: chunks.length, displayedChunks: points.length };
643
652
  projectionCache = { key: cacheKey, body };
644
653
  return { status: 200, body };
package/dist/web/pca.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Self-contained, zero-dependency PCA implementation for 2D embedding projection.
2
+ * Self-contained, zero-dependency PCA implementation for embedding projection.
3
+ * Supports projecting to 2 or 3 dimensions (top-K eigenvectors via power
4
+ * iteration + deflation).
3
5
  */
4
- export declare function computePCA(vectors: number[][]): {
6
+ export declare function computePCA(vectors: number[][], dims?: 2 | 3): {
5
7
  x: number;
6
8
  y: number;
9
+ z?: number;
7
10
  }[];
package/dist/web/pca.js CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Self-contained, zero-dependency PCA implementation for 2D embedding projection.
2
+ * Self-contained, zero-dependency PCA implementation for embedding projection.
3
+ * Supports projecting to 2 or 3 dimensions (top-K eigenvectors via power
4
+ * iteration + deflation).
3
5
  */
4
- export function computePCA(vectors) {
6
+ export function computePCA(vectors, dims = 2) {
5
7
  const n = vectors.length;
6
8
  if (n === 0)
7
9
  return [];
8
10
  const dim = vectors[0].length;
9
11
  if (n === 1)
10
- return [{ x: 0.5, y: 0.5 }];
12
+ return dims === 3 ? [{ x: 0.5, y: 0.5, z: 0.5 }] : [{ x: 0.5, y: 0.5 }];
11
13
  // 1. Compute column means
12
14
  const means = new Array(dim).fill(0);
13
15
  for (let i = 0; i < n; i++) {
@@ -19,7 +21,9 @@ export function computePCA(vectors) {
19
21
  means[j] /= n;
20
22
  // 2. Center data
21
23
  const centered = vectors.map(v => v.map((val, j) => val - means[j]));
22
- // 3. Compute covariance matrix (dim x dim), upper triangle
24
+ // 3. Compute covariance matrix (dim x dim); fill the upper triangle then
25
+ // mirror it so the matrix is symmetric (power iteration needs a symmetric
26
+ // operator to find the true principal axes).
23
27
  const cov = Array.from({ length: dim }, () => new Array(dim).fill(0));
24
28
  for (let i = 0; i < n; i++) {
25
29
  for (let j = 0; j < dim; j++) {
@@ -31,23 +35,42 @@ export function computePCA(vectors) {
31
35
  for (let j = 0; j < dim; j++) {
32
36
  for (let k = j; k < dim; k++) {
33
37
  cov[j][k] /= n - 1;
38
+ cov[k][j] = cov[j][k];
34
39
  }
35
40
  }
36
- // 4. Power iteration to find top-2 eigenvectors
37
- const pc1 = powerIteration(cov, dim, 50);
38
- // Deflate: subtract PC1's contribution to find PC2
39
- const deflated = cov.map((row, i) => {
40
- const pc1DotRow = pc1.reduce((sum, v, idx) => sum + v * cov[i][idx], 0);
41
- const pc1NormSq = pc1.reduce((sum, v) => sum + v * v, 0);
42
- return row.map((val, j) => val - (pc1DotRow / pc1NormSq) * pc1[j]);
41
+ // 4. Find the top-K eigenvectors: power iteration, then deflate the
42
+ // covariance by each discovered eigenvector before finding the next.
43
+ // Once the remaining matrix is numerically ~zero (degenerate / low-rank
44
+ // input), the rest of the PCs are zero vectors — this keeps PC2/PC3 from
45
+ // picking up deflation noise and avoids NaN from a 0/0 deflation.
46
+ const pcs = [];
47
+ let deflated = cov;
48
+ const threshold = maxAbs(cov) * 1e-12;
49
+ for (let pc = 0; pc < dims; pc++) {
50
+ if (maxAbs(deflated) <= threshold) {
51
+ pcs.push(new Array(dim).fill(0));
52
+ continue;
53
+ }
54
+ const eigen = powerIteration(deflated, dim, 50);
55
+ pcs.push(eigen);
56
+ deflated = deflate(deflated, eigen);
57
+ }
58
+ // 5. Project centered data onto the PCs
59
+ const projected = centered.map(v => {
60
+ const point = { x: 0, y: 0, z: 0 };
61
+ for (let pc = 0; pc < dims; pc++) {
62
+ const s = v.reduce((sum, val, j) => sum + val * pcs[pc][j], 0);
63
+ if (pc === 0)
64
+ point.x = s;
65
+ else if (pc === 1)
66
+ point.y = s;
67
+ else
68
+ point.z = s;
69
+ }
70
+ return point;
43
71
  });
44
- const pc2 = powerIteration(deflated, dim, 50);
45
- // 5. Project centered data onto PCs
46
- const projected = centered.map(v => ({
47
- x: v.reduce((sum, val, j) => sum + val * pc1[j], 0),
48
- y: v.reduce((sum, val, j) => sum + val * pc2[j], 0),
49
- }));
50
- // 6. Normalize to [0, 1]
72
+ // 6. Normalize to [0, 1]. 2D keeps per-axis normalization (unchanged);
73
+ // 3D uses the max extent across all axes so the cube stays proportional.
51
74
  const xs = projected.map(p => p.x);
52
75
  const ys = projected.map(p => p.y);
53
76
  const minX = Math.min(...xs);
@@ -56,11 +79,43 @@ export function computePCA(vectors) {
56
79
  const maxY = Math.max(...ys);
57
80
  const rangeX = maxX - minX || 1;
58
81
  const rangeY = maxY - minY || 1;
82
+ if (dims === 2) {
83
+ return projected.map(p => ({
84
+ x: (p.x - minX) / rangeX,
85
+ y: (p.y - minY) / rangeY,
86
+ }));
87
+ }
88
+ const zs = projected.map(p => p.z);
89
+ const minZ = Math.min(...zs);
90
+ const maxZ = Math.max(...zs);
91
+ const maxRange = Math.max(rangeX, rangeY, maxZ - minZ || 1);
59
92
  return projected.map(p => ({
60
- x: (p.x - minX) / rangeX,
61
- y: (p.y - minY) / rangeY,
93
+ x: (p.x - minX) / maxRange,
94
+ y: (p.y - minY) / maxRange,
95
+ z: (p.z - minZ) / maxRange,
62
96
  }));
63
97
  }
98
+ /** Subtract the outer-product contribution of a principal component from a symmetric matrix. */
99
+ function deflate(matrix, pc) {
100
+ const pcNormSq = pc.reduce((sum, v) => sum + v * v, 0);
101
+ return matrix.map((row, i) => {
102
+ const pcDotRow = pc.reduce((sum, v, idx) => sum + v * matrix[i][idx], 0);
103
+ const scale = pcNormSq > 1e-12 ? pcDotRow / pcNormSq : 0;
104
+ return row.map((val, j) => val - scale * pc[j]);
105
+ });
106
+ }
107
+ /** Largest absolute entry of a matrix. */
108
+ function maxAbs(matrix) {
109
+ let m = 0;
110
+ for (const row of matrix) {
111
+ for (const val of row) {
112
+ const abs = Math.abs(val);
113
+ if (abs > m)
114
+ m = abs;
115
+ }
116
+ }
117
+ return m;
118
+ }
64
119
  /** Power iteration to find the dominant eigenvector of a symmetric matrix. */
65
120
  function powerIteration(matrix, dim, maxIter) {
66
121
  let v = new Array(dim).fill(0).map(() => Math.random() * 2 - 1);