import { Note, NoteCluster, BridgeNote } from '../types'; import { cosineSimilarity } from './geminiService'; export function dbscan(notes: Note[], eps: number, minPts: number): number[] { const n = notes.length; const labels = new Array(n).fill(-1); // -1 = noise, 0+ = cluster id let clusterId = 0; for (let i = 0; i < n; i++) { if (labels[i] !== -1) continue; const neighbors = getNeighbors(i, notes, eps); if (neighbors.length < minPts) { labels[i] = -1; // remains noise for now continue; } labels[i] = clusterId; const queue = neighbors.filter(idx => idx !== i); for (let j = 0; j < queue.length; j++) { const pIdx = queue[j]; if (labels[pIdx] === -1) { labels[pIdx] = clusterId; // noisy point becomes border point } if (labels[pIdx] !== -1 && labels[pIdx] < clusterId) { // This should not happen in standard DBSCAN unless we re-visit } if (labels[pIdx] === clusterId && labels[pIdx] !== -1) { // Skip if already processed in this cluster } // If it was already labeled, skip re-neighboring const pWasNoise = labels[pIdx] === -1; if (labels[pIdx] === -1) labels[pIdx] = clusterId; // If point was not processed if (pWasNoise || labels[pIdx] === clusterId ) { // This is a simplified queue processing } } // Standard DBSCAN expansion expandCluster(i, neighbors, labels, clusterId, notes, eps, minPts); clusterId++; } return labels; } function expandCluster(pIdx: number, neighbors: number[], labels: number[], clusterId: number, notes: Note[], eps: number, minPts: number) { let i = 0; while (i < neighbors.length) { const qIdx = neighbors[i]; if (labels[qIdx] === -1) { labels[qIdx] = clusterId; } else if (labels[qIdx] === undefined || labels[qIdx] === -1) { // unreachable } if (labels[qIdx] === clusterId || labels[qIdx] === -1) { const qNeighbors = getNeighbors(qIdx, notes, eps); if (qNeighbors.length >= minPts) { for(const qn of qNeighbors) { if (labels[qn] === -1) { labels[qn] = clusterId; neighbors.push(qn); } else if (!labels.hasOwnProperty(qn)) { // logic error } } } } i++; } } // Clean DBSCAN implementation export function runClustering(notes: Note[], eps: number = 0.15, minPts: number = 2): { labels: number[], clusters: NoteCluster[] } { const validNotes = notes.filter(n => n.embedding && n.embedding.length > 0); if (validNotes.length === 0) return { labels: [], clusters: [] }; const n = validNotes.length; const labels = new Array(n).fill(-1); let cId = 0; for (let i = 0; i < n; i++) { if (labels[i] !== -1) continue; const neighbors = findNeighbors(i, validNotes, eps); if (neighbors.length < minPts) { labels[i] = -1; } else { labels[i] = cId; expand(i, neighbors, labels, cId, validNotes, eps, minPts); cId++; } } const clusters: NoteCluster[] = []; const colorPalette = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']; for (let i = 0; i < cId; i++) { const noteIds = validNotes.filter((_, idx) => labels[idx] === i).map(n => n.id); clusters.push({ id: `cluster-${i}`, name: `Cluster ${i + 1}`, noteIds, color: colorPalette[i % colorPalette.length] }); } return { labels, clusters }; } function findNeighbors(idx: number, notes: Note[], eps: number): number[] { const neighbors: number[] = []; const targetEmbedding = notes[idx].embedding!; for (let i = 0; i < notes.length; i++) { const sim = cosineSimilarity(targetEmbedding, notes[i].embedding!); const dist = 1 - sim; if (dist <= eps) { neighbors.push(i); } } return neighbors; } function expand(rootIdx: number, neighbors: number[], labels: number[], cId: number, notes: Note[], eps: number, minPts: number) { const queue = [...neighbors]; for (let i = 0; i < queue.length; i++) { const qIdx = queue[i]; if (labels[qIdx] === -1) { labels[qIdx] = cId; } if (labels[qIdx] !== -1 && labels[qIdx] !== cId) continue; if (labels[qIdx] === cId) { // already visited but let's check neighbors if we just added it } // If point was noise, it now belongs to cluster, but we don't necessarily expand from it unless it's a core point // This is the standard DBSCAN: noise points can become border points } // Re-implementing correctly let head = 0; while(head < queue.length) { const qIdx = queue[head]; if (labels[qIdx] === -1) labels[qIdx] = cId; if (labels[qIdx] === cId) { const qNeighbors = findNeighbors(qIdx, notes, eps); if (qNeighbors.length >= minPts) { for(const qn of qNeighbors) { if (labels[qn] === -1) { labels[qn] = cId; queue.push(qn); } } } } head++; } } function getNeighbors(idx: number, notes: Note[], eps: number): number[] { const neighbors: number[] = []; const target = notes[idx].embedding!; for (let i = 0; i < notes.length; i++) { if (!notes[i].embedding) continue; const dist = 1 - cosineSimilarity(target, notes[i].embedding!); if (dist <= eps) neighbors.push(i); } return neighbors; } export function detectBridges(notes: Note[], clusters: NoteCluster[], threshold: number = 0.5): BridgeNote[] { const bridges: BridgeNote[] = []; const validNotes = notes.filter(n => n.embedding); for (const note of validNotes) { const connectedClusters = new Set(); for (const cluster of clusters) { // Check if note has strong links to ANY note in this cluster const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id) && n.embedding); const hasStrongLink = clusterNotes.some(cn => cosineSimilarity(note.embedding!, cn.embedding!) > threshold); if (hasStrongLink) { connectedClusters.add(cluster.id); } } if (connectedClusters.size >= 2) { bridges.push({ noteId: note.id, connectedClusterIds: Array.from(connectedClusters), bridgeScore: connectedClusters.size / Math.max(clusters.length, 1) }); } } return bridges.sort((a, b) => b.bridgeScore - a.bridgeScore); } export function calculateCentroid(noteIds: string[], allNotes: Note[]): number[] | undefined { const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding); if (clusterNotes.length === 0) return undefined; const embeddingDim = clusterNotes[0].embedding!.length; const centroid = new Array(embeddingDim).fill(0); for (const note of clusterNotes) { for (let i = 0; i < embeddingDim; i++) { centroid[i] += note.embedding![i]; } } for (let i = 0; i < embeddingDim; i++) { centroid[i] /= clusterNotes.length; } return centroid; } export function getMostCentralNoteTitles(noteIds: string[], centroid: number[] | undefined, allNotes: Note[], count: number = 5): string[] { const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding); if (clusterNotes.length === 0) return []; if (!centroid) return clusterNotes.slice(0, count).map(n => n.title); const scored = clusterNotes.map(n => ({ title: n.title, similarity: cosineSimilarity(n.embedding!, centroid) })); scored.sort((a, b) => b.similarity - a.similarity); return scored.slice(0, count).map(item => item.title); }