diff --git a/package.json b/package.json index c50b91f..4417ecc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-note-categorization", - "version": "0.1.7", + "version": "0.1.8", "scripts": { "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && npm run copyAssets && webpack --env joplin-plugin-config=createArchive", "prepare": "npm run dist", diff --git a/src/manifest.json b/src/manifest.json index 466aace..34a0a7e 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "id": "com.harsh16gupta.notecategorization", "app_min_version": "3.5", - "version": "0.1.7", + "version": "0.1.8", "name": "Note Categorization Plugin", "description": "AI-based note categorisation: clusters notes semantically, suggests tags and notebook structures, and detects stale notes.", "author": "Harsh Gupta", diff --git a/src/pipeline/clustering/autoK.ts b/src/pipeline/clustering/autoK.ts index 1b8da02..026baa7 100644 --- a/src/pipeline/clustering/autoK.ts +++ b/src/pipeline/clustering/autoK.ts @@ -1,7 +1,5 @@ import { DistanceFn, silhouetteScore } from './metrics'; import { kmeans } from './kmeans'; -// NOTE: kmedoids is not used in the default pipeline (too slow), but kept here for manual benchmarking -import { kmedoids } from './kmedoids'; import { log } from '../../utils/logger'; /** Absolute minimum K to try (silhouette needs at least 2 clusters). */ @@ -107,23 +105,18 @@ export function computeKRange(n: number): [number, number] { * produces more useful note categories. * * @param vectors Input data points (N x D), already UMAP-reduced if applicable - * @param algorithm Which algorithm to use: 'kmeans' or 'kmedoids' (note: kmedoids is not used in the default pipeline) + * @param algorithm Which algorithm to use: 'kmeans' * @param distFn Distance function (cosine or euclidean) * @param seed Seed for reproducible initialization * @returns The optimal K, its assignments, and its silhouette score */ -export function findOptimalK( - vectors: number[][], - algorithm: 'kmeans' | 'kmedoids', - distFn: DistanceFn, - seed: number, -): AutoKResult { +export function findOptimalK(vectors: number[][], algorithm: 'kmeans', distFn: DistanceFn, seed: number): AutoKResult { const n = vectors.length; const [minK, maxK] = computeKRange(n); log(`Auto-K: sweeping K=${minK}..${maxK} for ${algorithm} (N=${n})`); - const clusterFn = algorithm === 'kmeans' ? kmeans : kmedoids; + const clusterFn = kmeans; // Collect all valid (k, score, assignments) candidates const candidates: { k: number; score: number; assignments: number[] }[] = []; diff --git a/src/pipeline/clustering/benchmark.ts b/src/pipeline/clustering/benchmark.ts index bbf368b..8b9c5c1 100644 --- a/src/pipeline/clustering/benchmark.ts +++ b/src/pipeline/clustering/benchmark.ts @@ -1,8 +1,6 @@ import { CategorizationConfig, BenchmarkResult, ClusteringStrategy } from '../../types/cluster'; import { DistanceFn, getDistanceFn, silhouetteScore, euclideanDistance } from './metrics'; import { kmeans } from './kmeans'; -// NOTE: kmedoids is not used in the default pipeline (too slow), but kept here for manual benchmarking -import { kmedoids } from './kmedoids'; import { hdbscan } from './hdbscan'; import { findOptimalK } from './autoK'; import { UmapProjector } from '../UmapProjector'; @@ -26,8 +24,6 @@ export function runStrategy( switch (strategy.algorithm) { case 'kmeans': return kmeans(vectors, strategy.K ?? DEFAULT_K, distFn, seed); - case 'kmedoids': // NOTE: not used in default pipeline strategies (too slow) - return kmedoids(vectors, strategy.K ?? DEFAULT_K, distFn, seed); case 'hdbscan': return hdbscan(vectors, strategy.minClusterSize ?? DEFAULT_MIN_CLUSTER_SIZE, strategy.minSamples, distFn); default: @@ -150,7 +146,7 @@ export function benchmark( let assignments: number[]; let score: number; - if (strategy.K === 'auto' && (strategy.algorithm === 'kmeans' || strategy.algorithm === 'kmedoids')) { + if (strategy.K === 'auto' && strategy.algorithm === 'kmeans') { // Auto-K: sweep K range and pick the best const autoResult = findOptimalK(clusteringVectors, strategy.algorithm, clusterDistFn, config.seed); assignments = autoResult.assignments; diff --git a/src/pipeline/clustering/kmedoids.ts b/src/pipeline/clustering/kmedoids.ts deleted file mode 100644 index 8b4a673..0000000 --- a/src/pipeline/clustering/kmedoids.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * NOTE: kmedoids is NOT currently used in the default pipeline strategies. - * It was removed due to prohibitively high runtime (~38s for ~500 notes) - * compared to kmeans and hdbscan. The implementation is retained here for - * potential future use or manual benchmarking. - */ - -import { DistanceFn } from './metrics'; -import { mulberry32 } from '../../utils/prng'; - -const MAX_ITERATIONS = 100; - -/** - * Finds the index of the point that is farthest from any existing medoid. - * Used for greedy medoid initialization (BUILD phase of PAM). - */ -function findFarthestPoint(vectors: number[][], medoidIndices: number[], distFn: DistanceFn): number { - const medoidSet = new Set(medoidIndices); - let bestIdx = 0; - let bestMinDist = -1; - - for (let i = 0; i < vectors.length; i++) { - if (medoidSet.has(i)) continue; - - let minDist = Infinity; - for (const m of medoidIndices) { - const d = distFn(vectors[i], vectors[m]); - if (d < minDist) minDist = d; - } - - if (minDist > bestMinDist) { - bestMinDist = minDist; - bestIdx = i; - } - } - - return bestIdx; -} - -/** - * Assigns each point to the nearest medoid. - */ -function assignToMedoids(vectors: number[][], medoidIndices: number[], distFn: DistanceFn): number[] { - return vectors.map((vec) => { - let bestCluster = 0; - let bestDist = Infinity; - for (let c = 0; c < medoidIndices.length; c++) { - const d = distFn(vec, vectors[medoidIndices[c]]); - if (d < bestDist) { - bestDist = d; - bestCluster = c; - } - } - return bestCluster; - }); -} - -/** - * Computes the total cost (sum of distances from each point to its medoid). - */ -function totalCost(vectors: number[][], assignments: number[], medoidIndices: number[], distFn: DistanceFn): number { - let cost = 0; - for (let i = 0; i < vectors.length; i++) { - cost += distFn(vectors[i], vectors[medoidIndices[assignments[i]]]); - } - return cost; -} - -/** - * K-Medoids clustering using a simplified PAM (Partitioning Around Medoids). - * - * Unlike K-Means, medoids are always actual data points rather than - * computed means. This makes K-Medoids more robust to outliers and - * works naturally with any distance metric (not just Euclidean). - * - * @param vectors Input data points (N x D) - * @param K Number of clusters - * @param distFn Distance function - * @param seed Seed for reproducible initialization - * @param maxIter Maximum iterations (default: 100) - * @returns Cluster assignments (length N, values 0..K-1) - */ -export function kmedoids( - vectors: number[][], - K: number, - distFn: DistanceFn, - seed: number, - maxIter: number = MAX_ITERATIONS, -): number[] { - const n = vectors.length; - if (n === 0) throw new Error('Cannot cluster empty input'); - if (K <= 0) throw new Error('K must be positive'); - if (K >= n) return vectors.map((_, i) => i); - - const rng = mulberry32(seed); - - // BUILD phase: initialize medoids greedily - // First medoid is random, subsequent ones maximize distance from existing medoids - const medoidIndices: number[] = [Math.floor(rng() * n)]; - for (let c = 1; c < K; c++) { - medoidIndices.push(findFarthestPoint(vectors, medoidIndices, distFn)); - } - - let assignments = assignToMedoids(vectors, medoidIndices, distFn); - let currentCost = totalCost(vectors, assignments, medoidIndices, distFn); - - // SWAP phase: try swapping each medoid with each non-medoid - for (let iter = 0; iter < maxIter; iter++) { - let improved = false; - - for (let m = 0; m < K; m++) { - for (let i = 0; i < n; i++) { - if (medoidIndices.includes(i)) continue; - - // Try swapping medoid m with point i - const oldMedoid = medoidIndices[m]; - medoidIndices[m] = i; - - const newAssignments = assignToMedoids(vectors, medoidIndices, distFn); - const newCost = totalCost(vectors, newAssignments, medoidIndices, distFn); - - if (newCost < currentCost) { - // Keep the swap - assignments = newAssignments; - currentCost = newCost; - improved = true; - } else { - // Revert the swap - medoidIndices[m] = oldMedoid; - } - } - } - - if (!improved) break; - } - - return assignments; -} diff --git a/src/types/cluster.ts b/src/types/cluster.ts index 3e828e8..9fb75f2 100644 --- a/src/types/cluster.ts +++ b/src/types/cluster.ts @@ -1,11 +1,10 @@ -// NOTE: 'kmedoids' is kept in the type for compatibility but is not used in the default pipeline strategies (too slow) -export type ClusteringAlgorithm = 'kmeans' | 'kmedoids' | 'hdbscan'; +export type ClusteringAlgorithm = 'kmeans' | 'hdbscan'; export interface ClusteringStrategy { /** Human-readable label for this run, e.g. 'kmeans-5' */ name: string; algorithm: ClusteringAlgorithm; - /** Number of clusters (kmeans / kmedoids). Use 'auto' for automatic selection via silhouette sweep. Note: kmedoids is not active in the default pipeline. */ + /** Number of clusters (kmeans). Use 'auto' for automatic selection via silhouette sweep. */ K?: number | 'auto'; /** Minimum points to form a cluster (hdbscan, default: 3) */ minClusterSize?: number; diff --git a/src/webview/pages/SettingsPage.tsx b/src/webview/pages/SettingsPage.tsx index cbb3be4..a09d21e 100644 --- a/src/webview/pages/SettingsPage.tsx +++ b/src/webview/pages/SettingsPage.tsx @@ -27,7 +27,7 @@ export const SettingsPage: React.FC = () => { • Embedding Model: all-MiniLM-L6-v2 (384-dim)
- • Clustering Strategies: Auto K-Means, Auto K-Medoids, HDBSCAN + • Clustering Strategies: Auto K-Means, HDBSCAN
diff --git a/test/pipeline/clustering/autoK.test.ts b/test/pipeline/clustering/autoK.test.ts index 3374aa5..6d06c32 100644 --- a/test/pipeline/clustering/autoK.test.ts +++ b/test/pipeline/clustering/autoK.test.ts @@ -150,11 +150,9 @@ describe('autoK findOptimalK', () => { expect(res1).toEqual(res2); }); - it('works with both kmeans and kmedoids', () => { + it('finds optimal K using kmeans', () => { const resKmeans = findOptimalK(THREE_CLUSTERS, 'kmeans', euclideanDistance, 42); - const resKmedoids = findOptimalK(THREE_CLUSTERS, 'kmedoids', euclideanDistance, 42); expect(resKmeans.bestK).toBe(3); - expect(resKmedoids.bestK).toBe(3); }); it('falls back to K=1 when no valid clustering is possible', () => { diff --git a/test/pipeline/clustering/kmedoids.test.ts b/test/pipeline/clustering/kmedoids.test.ts deleted file mode 100644 index 3228d01..0000000 --- a/test/pipeline/clustering/kmedoids.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { kmedoids } from '../../../src/pipeline/clustering/kmedoids'; -import { euclideanDistance, cosineDistance } from '../../../src/pipeline/clustering/metrics'; - -const SEPARATED_CLUSTERS = [ - [0.1, 0.2], - [0.2, 0.1], - [0.0, 0.0], - [100, 0.1], - [99.9, 0.2], - [100.1, 0.0], - [0.1, 100], - [0.2, 99.9], - [0.0, 100.1], -]; - -describe('kmedoids', () => { - it('is deterministic: same seed produces same result', () => { - const a = kmedoids(SEPARATED_CLUSTERS, 3, euclideanDistance, 42); - const b = kmedoids(SEPARATED_CLUSTERS, 3, euclideanDistance, 42); - expect(a).toEqual(b); - }); - - it('correctly assigns well-separated clusters', () => { - const assignments = kmedoids(SEPARATED_CLUSTERS, 3, euclideanDistance, 42); - expect(assignments[0]).toBe(assignments[1]); - expect(assignments[0]).toBe(assignments[2]); - expect(assignments[3]).toBe(assignments[4]); - expect(assignments[3]).toBe(assignments[5]); - expect(assignments[6]).toBe(assignments[7]); - expect(assignments[6]).toBe(assignments[8]); - expect(assignments[0]).not.toBe(assignments[3]); - expect(assignments[0]).not.toBe(assignments[6]); - expect(assignments[3]).not.toBe(assignments[6]); - }); - - it('K >= N gives each point its own cluster', () => { - const data = [ - [1, 0], - [0, 1], - [1, 1], - ]; - expect(kmedoids(data, 5, euclideanDistance, 42)).toEqual([0, 1, 2]); - }); - - it('throws on empty input', () => { - expect(() => kmedoids([], 2, euclideanDistance, 42)).toThrow('Cannot cluster empty input'); - }); - - it('throws on K <= 0', () => { - expect(() => kmedoids([[1, 0]], 0, euclideanDistance, 42)).toThrow('K must be positive'); - }); - - it('works with cosineDistance', () => { - // Unit vectors in clearly different directions - const unitVecs = [ - [1, 0], - [0.99, 0.01], // near x-axis - [0, 1], - [0.01, 0.99], // near y-axis - [-1, 0], - [-0.99, -0.01], // near -x-axis - ]; - const assignments = kmedoids(unitVecs, 3, cosineDistance, 42); - expect(assignments[0]).toBe(assignments[1]); - expect(assignments[2]).toBe(assignments[3]); - expect(assignments[4]).toBe(assignments[5]); - }); - - it('returns assignments with correct length and valid range', () => { - const K = 3; - const assignments = kmedoids(SEPARATED_CLUSTERS, K, euclideanDistance, 42); - expect(assignments).toHaveLength(SEPARATED_CLUSTERS.length); - for (const a of assignments) { - expect(a).toBeGreaterThanOrEqual(0); - expect(a).toBeLessThan(K); - } - }); -});