Files
Momento/architectural-grid1/src/services/clusteringService.ts
Antigravity f46654f574 feat: editor improvements and architectural grid prototype
Multiple feature additions and improvements across the application:

- NextGen Editor: drag handles, smart paste, block actions
- Structured views: Kanban and table layouts for notes
- Architectural Grid: new brainstorming/agent interface prototype
- Flashcards: SM-2 revision algorithm with AI generation
- MCP server: robustness improvements
- Graph/PDF chat: fix click propagation and copy behavior
- Various UI/UX enhancements and bug fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 19:45:15 +00:00

243 lines
7.6 KiB
TypeScript

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<string>();
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);
}