@memberjunction/ng-clustering 0.0.1 → 5.24.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.
- package/README.md +441 -27
- package/dist/__tests__/clustering-service.test.d.ts +2 -0
- package/dist/__tests__/clustering-service.test.d.ts.map +1 -0
- package/dist/__tests__/clustering-service.test.js +487 -0
- package/dist/__tests__/clustering-service.test.js.map +1 -0
- package/dist/__tests__/clustering-types.test.d.ts +2 -0
- package/dist/__tests__/clustering-types.test.d.ts.map +1 -0
- package/dist/__tests__/clustering-types.test.js +254 -0
- package/dist/__tests__/clustering-types.test.js.map +1 -0
- package/dist/__tests__/index.test.d.ts +2 -0
- package/dist/__tests__/index.test.d.ts.map +1 -0
- package/dist/__tests__/index.test.js +7 -0
- package/dist/__tests__/index.test.js.map +1 -0
- package/dist/__tests__/scatter-component-logic.test.d.ts +2 -0
- package/dist/__tests__/scatter-component-logic.test.d.ts.map +1 -0
- package/dist/__tests__/scatter-component-logic.test.js +494 -0
- package/dist/__tests__/scatter-component-logic.test.js.map +1 -0
- package/dist/lib/cluster-config-panel.component.d.ts +274 -0
- package/dist/lib/cluster-config-panel.component.d.ts.map +1 -0
- package/dist/lib/cluster-config-panel.component.js +732 -0
- package/dist/lib/cluster-config-panel.component.js.map +1 -0
- package/dist/lib/cluster-scatter.component.d.ts +572 -0
- package/dist/lib/cluster-scatter.component.d.ts.map +1 -0
- package/dist/lib/cluster-scatter.component.js +1410 -0
- package/dist/lib/cluster-scatter.component.js.map +1 -0
- package/dist/lib/clustering.module.d.ts +12 -0
- package/dist/lib/clustering.module.d.ts.map +1 -0
- package/dist/lib/clustering.module.js +38 -0
- package/dist/lib/clustering.module.js.map +1 -0
- package/dist/lib/clustering.service.d.ts +43 -0
- package/dist/lib/clustering.service.d.ts.map +1 -0
- package/dist/lib/clustering.service.js +253 -0
- package/dist/lib/clustering.service.js.map +1 -0
- package/dist/lib/clustering.types.d.ts +271 -0
- package/dist/lib/clustering.types.d.ts.map +1 -0
- package/dist/lib/clustering.types.js +74 -0
- package/dist/lib/clustering.types.js.map +1 -0
- package/dist/public-api.d.ts +6 -0
- package/dist/public-api.d.ts.map +1 -0
- package/dist/public-api.js +11 -0
- package/dist/public-api.js.map +1 -0
- package/package.json +41 -6
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { DefaultClusterConfig, CLUSTER_COLORS, } from '../lib/clustering.types';
|
|
3
|
+
// ================================================================
|
|
4
|
+
// Mocks
|
|
5
|
+
// ================================================================
|
|
6
|
+
// Mock Angular's Injectable (the service uses @Injectable)
|
|
7
|
+
vi.mock('@angular/core', () => ({
|
|
8
|
+
Injectable: () => (target) => target,
|
|
9
|
+
}));
|
|
10
|
+
// Shared mock state - use vi.hoisted to make these available in hoisted vi.mock factories
|
|
11
|
+
const { mockLoadVectors, mockKMeansCluster, mockDBSCANCluster, mockUmapFit, umapState } = vi.hoisted(() => ({
|
|
12
|
+
mockLoadVectors: vi.fn(),
|
|
13
|
+
mockKMeansCluster: vi.fn(),
|
|
14
|
+
mockDBSCANCluster: vi.fn(),
|
|
15
|
+
mockUmapFit: vi.fn(),
|
|
16
|
+
umapState: { shouldFail: false },
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('@memberjunction/ai-vectors-memory', () => ({
|
|
19
|
+
SimpleVectorService: class {
|
|
20
|
+
constructor() {
|
|
21
|
+
this.LoadVectors = mockLoadVectors;
|
|
22
|
+
this.KMeansCluster = mockKMeansCluster;
|
|
23
|
+
this.DBSCANCluster = mockDBSCANCluster;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
vi.mock('umap-js', () => ({
|
|
28
|
+
get UMAP() {
|
|
29
|
+
if (umapState.shouldFail) {
|
|
30
|
+
throw new Error('UMAP not available');
|
|
31
|
+
}
|
|
32
|
+
return class {
|
|
33
|
+
constructor() {
|
|
34
|
+
this.fit = mockUmapFit;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
// Import AFTER mocks are set up
|
|
40
|
+
import { ClusteringService } from '../lib/clustering.service';
|
|
41
|
+
// ================================================================
|
|
42
|
+
// Helpers
|
|
43
|
+
// ================================================================
|
|
44
|
+
function makeVectors(count, dim = 10) {
|
|
45
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
46
|
+
Key: `key-${i}`,
|
|
47
|
+
Vector: Array.from({ length: dim }, (_, d) => Math.sin(i * 0.5 + d * 0.3)),
|
|
48
|
+
Label: `Item ${i}`,
|
|
49
|
+
Metadata: { index: i },
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
function makeClusterResult(keys, numClusters) {
|
|
53
|
+
const clusters = new Map();
|
|
54
|
+
for (let c = 0; c < numClusters; c++) {
|
|
55
|
+
clusters.set(c, []);
|
|
56
|
+
}
|
|
57
|
+
keys.forEach((key, i) => {
|
|
58
|
+
clusters.get(i % numClusters).push(key);
|
|
59
|
+
});
|
|
60
|
+
return {
|
|
61
|
+
clusters,
|
|
62
|
+
outliers: [],
|
|
63
|
+
metadata: { metric: 'cosine', silhouetteScore: 0.65 },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function makeDbscanResult(keys, outlierCount) {
|
|
67
|
+
const clusters = new Map();
|
|
68
|
+
const outliers = [];
|
|
69
|
+
clusters.set(0, []);
|
|
70
|
+
clusters.set(1, []);
|
|
71
|
+
keys.forEach((key, i) => {
|
|
72
|
+
if (i < outlierCount) {
|
|
73
|
+
outliers.push(key);
|
|
74
|
+
}
|
|
75
|
+
else if (i % 2 === 0) {
|
|
76
|
+
clusters.get(0).push(key);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
clusters.get(1).push(key);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
clusters,
|
|
84
|
+
outliers,
|
|
85
|
+
metadata: { metric: 'cosine', silhouetteScore: 0.5 },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// ================================================================
|
|
89
|
+
// Tests
|
|
90
|
+
// ================================================================
|
|
91
|
+
describe('ClusteringService', () => {
|
|
92
|
+
let service;
|
|
93
|
+
let config;
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
vi.clearAllMocks();
|
|
96
|
+
umapState.shouldFail = false;
|
|
97
|
+
service = new ClusteringService();
|
|
98
|
+
config = DefaultClusterConfig();
|
|
99
|
+
config.K = 3;
|
|
100
|
+
});
|
|
101
|
+
// ================================================================
|
|
102
|
+
// RunClustering - empty input
|
|
103
|
+
// ================================================================
|
|
104
|
+
describe('RunClustering with empty vectors', () => {
|
|
105
|
+
it('should return an empty result', async () => {
|
|
106
|
+
const result = await service.RunClustering([], config);
|
|
107
|
+
expect(result.Points).toHaveLength(0);
|
|
108
|
+
expect(result.Clusters).toHaveLength(0);
|
|
109
|
+
expect(result.Metrics.RecordCount).toBe(0);
|
|
110
|
+
expect(result.Metrics.ClusterCount).toBe(0);
|
|
111
|
+
expect(result.Metrics.OutlierCount).toBe(0);
|
|
112
|
+
expect(result.Metrics.SilhouetteScore).toBe(0);
|
|
113
|
+
expect(result.Metrics.ComputationTimeMs).toBeGreaterThanOrEqual(0);
|
|
114
|
+
expect(result.Config).toBe(config);
|
|
115
|
+
});
|
|
116
|
+
it('should not call SimpleVectorService for empty input', async () => {
|
|
117
|
+
await service.RunClustering([], config);
|
|
118
|
+
expect(mockLoadVectors).not.toHaveBeenCalled();
|
|
119
|
+
expect(mockKMeansCluster).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
// ================================================================
|
|
123
|
+
// RunClustering - K-Means
|
|
124
|
+
// ================================================================
|
|
125
|
+
describe('RunClustering with K-Means', () => {
|
|
126
|
+
it('should call KMeansCluster with correct parameters', async () => {
|
|
127
|
+
const vectors = makeVectors(6);
|
|
128
|
+
const clusterResult = makeClusterResult(vectors.map(v => v.Key), 3);
|
|
129
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
130
|
+
// UMAP mock
|
|
131
|
+
mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 100, i * 50]));
|
|
132
|
+
await service.RunClustering(vectors, config);
|
|
133
|
+
expect(mockLoadVectors).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(mockKMeansCluster).toHaveBeenCalledWith(3, 100, 'cosine');
|
|
135
|
+
expect(mockDBSCANCluster).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
it('should clamp K to vector count', async () => {
|
|
138
|
+
const vectors = makeVectors(2);
|
|
139
|
+
config.K = 10;
|
|
140
|
+
const clusterResult = makeClusterResult(vectors.map(v => v.Key), 2);
|
|
141
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
142
|
+
// Trivial case: 2 vectors -> deterministic placement
|
|
143
|
+
await service.RunClustering(vectors, config);
|
|
144
|
+
// K should be clamped to vectors.length (2)
|
|
145
|
+
expect(mockKMeansCluster).toHaveBeenCalledWith(2, 100, 'cosine');
|
|
146
|
+
});
|
|
147
|
+
it('should produce correct result structure', async () => {
|
|
148
|
+
const vectors = makeVectors(6);
|
|
149
|
+
const clusterResult = makeClusterResult(vectors.map(v => v.Key), 3);
|
|
150
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
151
|
+
mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 100, i * 50]));
|
|
152
|
+
const result = await service.RunClustering(vectors, config);
|
|
153
|
+
// Points
|
|
154
|
+
expect(result.Points).toHaveLength(6);
|
|
155
|
+
for (const point of result.Points) {
|
|
156
|
+
expect(typeof point.X).toBe('number');
|
|
157
|
+
expect(typeof point.Y).toBe('number');
|
|
158
|
+
expect(point.ClusterId).toBeGreaterThanOrEqual(0);
|
|
159
|
+
expect(point.VectorKey).toBeTruthy();
|
|
160
|
+
expect(point.Label).toBeTruthy();
|
|
161
|
+
expect(point.Metadata).toBeDefined();
|
|
162
|
+
}
|
|
163
|
+
// Clusters
|
|
164
|
+
expect(result.Clusters).toHaveLength(3);
|
|
165
|
+
for (const cluster of result.Clusters) {
|
|
166
|
+
expect(cluster.Id).toBeGreaterThanOrEqual(0);
|
|
167
|
+
expect(cluster.Label).toMatch(/^Cluster \d+$/);
|
|
168
|
+
expect(cluster.Color).toBeTruthy();
|
|
169
|
+
expect(cluster.MemberCount).toBeGreaterThan(0);
|
|
170
|
+
}
|
|
171
|
+
// Sorted by ID
|
|
172
|
+
for (let i = 1; i < result.Clusters.length; i++) {
|
|
173
|
+
expect(result.Clusters[i].Id).toBeGreaterThan(result.Clusters[i - 1].Id);
|
|
174
|
+
}
|
|
175
|
+
// Metrics
|
|
176
|
+
expect(result.Metrics.ClusterCount).toBe(3);
|
|
177
|
+
expect(result.Metrics.RecordCount).toBe(6);
|
|
178
|
+
expect(result.Metrics.SilhouetteScore).toBe(0.65);
|
|
179
|
+
expect(result.Metrics.OutlierCount).toBe(0);
|
|
180
|
+
expect(result.Metrics.ComputationTimeMs).toBeGreaterThanOrEqual(0);
|
|
181
|
+
// Config passthrough
|
|
182
|
+
expect(result.Config).toBe(config);
|
|
183
|
+
});
|
|
184
|
+
it('should assign cluster colors from CLUSTER_COLORS palette', async () => {
|
|
185
|
+
const vectors = makeVectors(6);
|
|
186
|
+
const clusterResult = makeClusterResult(vectors.map(v => v.Key), 3);
|
|
187
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
188
|
+
mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 100, i * 50]));
|
|
189
|
+
const result = await service.RunClustering(vectors, config);
|
|
190
|
+
expect(result.Clusters[0].Color).toBe(CLUSTER_COLORS[0]);
|
|
191
|
+
expect(result.Clusters[1].Color).toBe(CLUSTER_COLORS[1]);
|
|
192
|
+
expect(result.Clusters[2].Color).toBe(CLUSTER_COLORS[2]);
|
|
193
|
+
});
|
|
194
|
+
it('should wrap colors when clusters exceed palette size', async () => {
|
|
195
|
+
const vectors = makeVectors(30);
|
|
196
|
+
const numClusters = 12;
|
|
197
|
+
const clusterResult = makeClusterResult(vectors.map(v => v.Key), numClusters);
|
|
198
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
199
|
+
mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 30, i * 20]));
|
|
200
|
+
config.K = numClusters;
|
|
201
|
+
const result = await service.RunClustering(vectors, config);
|
|
202
|
+
// Cluster 10 wraps to CLUSTER_COLORS[10 % 10] = CLUSTER_COLORS[0]
|
|
203
|
+
expect(result.Clusters[10].Color).toBe(CLUSTER_COLORS[0]);
|
|
204
|
+
expect(result.Clusters[11].Color).toBe(CLUSTER_COLORS[1]);
|
|
205
|
+
});
|
|
206
|
+
it('should use Label from input vector when provided', async () => {
|
|
207
|
+
const vectors = [
|
|
208
|
+
{ Key: 'k1', Vector: [1, 2, 3], Label: 'Custom Label' },
|
|
209
|
+
{ Key: 'k2', Vector: [4, 5, 6] },
|
|
210
|
+
];
|
|
211
|
+
const clusterResult = makeClusterResult(['k1', 'k2'], 1);
|
|
212
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
213
|
+
config.K = 1;
|
|
214
|
+
const result = await service.RunClustering(vectors, config);
|
|
215
|
+
const p1 = result.Points.find(p => p.VectorKey === 'k1');
|
|
216
|
+
const p2 = result.Points.find(p => p.VectorKey === 'k2');
|
|
217
|
+
expect(p1?.Label).toBe('Custom Label');
|
|
218
|
+
expect(p2?.Label).toBe('k2'); // Falls back to Key
|
|
219
|
+
});
|
|
220
|
+
it('should default Metadata to empty object when not provided', async () => {
|
|
221
|
+
const vectors = [
|
|
222
|
+
{ Key: 'k1', Vector: [1, 2, 3] },
|
|
223
|
+
];
|
|
224
|
+
const clusterResult = makeClusterResult(['k1'], 1);
|
|
225
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
226
|
+
config.K = 1;
|
|
227
|
+
const result = await service.RunClustering(vectors, config);
|
|
228
|
+
expect(result.Points[0].Metadata).toEqual({});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
// ================================================================
|
|
232
|
+
// RunClustering - DBSCAN
|
|
233
|
+
// ================================================================
|
|
234
|
+
describe('RunClustering with DBSCAN', () => {
|
|
235
|
+
it('should call DBSCANCluster with correct parameters', async () => {
|
|
236
|
+
config.Algorithm = 'dbscan';
|
|
237
|
+
config.Epsilon = 0.5;
|
|
238
|
+
config.MinPoints = 4;
|
|
239
|
+
const vectors = makeVectors(6);
|
|
240
|
+
const clusterResult = makeDbscanResult(vectors.map(v => v.Key), 1);
|
|
241
|
+
mockDBSCANCluster.mockReturnValue(clusterResult);
|
|
242
|
+
mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 100, i * 50]));
|
|
243
|
+
await service.RunClustering(vectors, config);
|
|
244
|
+
expect(mockDBSCANCluster).toHaveBeenCalledWith(0.5, 4, 'cosine');
|
|
245
|
+
expect(mockKMeansCluster).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
it('should handle outliers correctly', async () => {
|
|
248
|
+
config.Algorithm = 'dbscan';
|
|
249
|
+
const vectors = makeVectors(6);
|
|
250
|
+
const clusterResult = makeDbscanResult(vectors.map(v => v.Key), 2);
|
|
251
|
+
mockDBSCANCluster.mockReturnValue(clusterResult);
|
|
252
|
+
mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 100, i * 50]));
|
|
253
|
+
const result = await service.RunClustering(vectors, config);
|
|
254
|
+
// First 2 vectors are outliers
|
|
255
|
+
const outlierPoints = result.Points.filter(p => p.ClusterId === -1);
|
|
256
|
+
expect(outlierPoints.length).toBe(2);
|
|
257
|
+
expect(result.Metrics.OutlierCount).toBe(2);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
// ================================================================
|
|
261
|
+
// Dimensionality Reduction
|
|
262
|
+
// ================================================================
|
|
263
|
+
describe('dimensionality reduction', () => {
|
|
264
|
+
it('should handle trivial case (1 vector) with deterministic placement', async () => {
|
|
265
|
+
const vectors = makeVectors(1);
|
|
266
|
+
const clusterResult = makeClusterResult(['key-0'], 1);
|
|
267
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
268
|
+
config.K = 1;
|
|
269
|
+
const result = await service.RunClustering(vectors, config);
|
|
270
|
+
// 1 vector -> trivial case: positioned at [250, 350]
|
|
271
|
+
expect(result.Points[0].X).toBe(250);
|
|
272
|
+
expect(result.Points[0].Y).toBe(350);
|
|
273
|
+
});
|
|
274
|
+
it('should handle trivial case (2 vectors) with deterministic placement', async () => {
|
|
275
|
+
const vectors = makeVectors(2);
|
|
276
|
+
const clusterResult = makeClusterResult(['key-0', 'key-1'], 1);
|
|
277
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
278
|
+
config.K = 1;
|
|
279
|
+
const result = await service.RunClustering(vectors, config);
|
|
280
|
+
// 2 vectors -> trivial case: [0*500+250, 350] and [1*500+250, 350]
|
|
281
|
+
expect(result.Points[0].X).toBe(250);
|
|
282
|
+
expect(result.Points[0].Y).toBe(350);
|
|
283
|
+
expect(result.Points[1].X).toBe(750);
|
|
284
|
+
expect(result.Points[1].Y).toBe(350);
|
|
285
|
+
});
|
|
286
|
+
it('should use UMAP when available and normalize to viewbox range', async () => {
|
|
287
|
+
const vectors = makeVectors(5);
|
|
288
|
+
const clusterResult = makeClusterResult(vectors.map(v => v.Key), 2);
|
|
289
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
290
|
+
// Return raw UMAP coordinates - will be normalized
|
|
291
|
+
mockUmapFit.mockReturnValue([
|
|
292
|
+
[0, 0],
|
|
293
|
+
[10, 5],
|
|
294
|
+
[20, 10],
|
|
295
|
+
[30, 15],
|
|
296
|
+
[40, 20],
|
|
297
|
+
]);
|
|
298
|
+
const result = await service.RunClustering(vectors, config);
|
|
299
|
+
// All points should be within normalized range [60, 940] (padding=60, size=1000)
|
|
300
|
+
for (const point of result.Points) {
|
|
301
|
+
expect(point.X).toBeGreaterThanOrEqual(60);
|
|
302
|
+
expect(point.X).toBeLessThanOrEqual(940);
|
|
303
|
+
expect(point.Y).toBeGreaterThanOrEqual(60);
|
|
304
|
+
expect(point.Y).toBeLessThanOrEqual(940);
|
|
305
|
+
expect(Number.isNaN(point.X)).toBe(false);
|
|
306
|
+
expect(Number.isNaN(point.Y)).toBe(false);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
it('should fall back to PCA when UMAP throws', async () => {
|
|
310
|
+
umapState.shouldFail = true;
|
|
311
|
+
const vectors = makeVectors(5);
|
|
312
|
+
const clusterResult = makeClusterResult(vectors.map(v => v.Key), 2);
|
|
313
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
314
|
+
const result = await service.RunClustering(vectors, config);
|
|
315
|
+
// PCA fallback should still produce valid points
|
|
316
|
+
expect(result.Points).toHaveLength(5);
|
|
317
|
+
for (const point of result.Points) {
|
|
318
|
+
expect(Number.isNaN(point.X)).toBe(false);
|
|
319
|
+
expect(Number.isNaN(point.Y)).toBe(false);
|
|
320
|
+
expect(typeof point.X).toBe('number');
|
|
321
|
+
expect(typeof point.Y).toBe('number');
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
it('should produce normalized PCA points within viewbox range', async () => {
|
|
325
|
+
umapState.shouldFail = true;
|
|
326
|
+
const vectors = makeVectors(10, 20);
|
|
327
|
+
const clusterResult = makeClusterResult(vectors.map(v => v.Key), 3);
|
|
328
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
329
|
+
const result = await service.RunClustering(vectors, config);
|
|
330
|
+
for (const point of result.Points) {
|
|
331
|
+
expect(point.X).toBeGreaterThanOrEqual(60);
|
|
332
|
+
expect(point.X).toBeLessThanOrEqual(940);
|
|
333
|
+
expect(point.Y).toBeGreaterThanOrEqual(60);
|
|
334
|
+
expect(point.Y).toBeLessThanOrEqual(940);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
it('should handle identical vectors in PCA fallback gracefully', async () => {
|
|
338
|
+
umapState.shouldFail = true;
|
|
339
|
+
// All vectors are identical
|
|
340
|
+
const vectors = Array.from({ length: 4 }, (_, i) => ({
|
|
341
|
+
Key: `key-${i}`,
|
|
342
|
+
Vector: [1, 1, 1, 1, 1],
|
|
343
|
+
}));
|
|
344
|
+
const clusterResult = makeClusterResult(vectors.map(v => v.Key), 1);
|
|
345
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
346
|
+
config.K = 1;
|
|
347
|
+
const result = await service.RunClustering(vectors, config);
|
|
348
|
+
// All centered to zero, normalization divides by range=0 (uses 1 fallback).
|
|
349
|
+
// All should be at the same location, not NaN.
|
|
350
|
+
for (const point of result.Points) {
|
|
351
|
+
expect(Number.isNaN(point.X)).toBe(false);
|
|
352
|
+
expect(Number.isNaN(point.Y)).toBe(false);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
// ================================================================
|
|
357
|
+
// ReduceDimensions (public helper)
|
|
358
|
+
// ================================================================
|
|
359
|
+
describe('ReduceDimensions', () => {
|
|
360
|
+
it('should return empty array for empty input', async () => {
|
|
361
|
+
const result = await service.ReduceDimensions([]);
|
|
362
|
+
expect(result).toEqual([]);
|
|
363
|
+
});
|
|
364
|
+
it('should return 2D points for valid input using UMAP', async () => {
|
|
365
|
+
mockUmapFit.mockReturnValue([
|
|
366
|
+
[0, 0],
|
|
367
|
+
[5, 5],
|
|
368
|
+
[10, 10],
|
|
369
|
+
]);
|
|
370
|
+
const result = await service.ReduceDimensions([
|
|
371
|
+
[1, 2, 3],
|
|
372
|
+
[4, 5, 6],
|
|
373
|
+
[7, 8, 9],
|
|
374
|
+
]);
|
|
375
|
+
expect(result).toHaveLength(3);
|
|
376
|
+
for (const point of result) {
|
|
377
|
+
expect(typeof point.x).toBe('number');
|
|
378
|
+
expect(typeof point.y).toBe('number');
|
|
379
|
+
expect(Number.isNaN(point.x)).toBe(false);
|
|
380
|
+
expect(Number.isNaN(point.y)).toBe(false);
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
it('should fall back to PCA and still return valid points', async () => {
|
|
384
|
+
umapState.shouldFail = true;
|
|
385
|
+
const result = await service.ReduceDimensions([
|
|
386
|
+
[1, 2, 3, 4, 5],
|
|
387
|
+
[6, 7, 8, 9, 10],
|
|
388
|
+
[11, 12, 13, 14, 15],
|
|
389
|
+
]);
|
|
390
|
+
expect(result).toHaveLength(3);
|
|
391
|
+
for (const point of result) {
|
|
392
|
+
expect(typeof point.x).toBe('number');
|
|
393
|
+
expect(typeof point.y).toBe('number');
|
|
394
|
+
expect(Number.isNaN(point.x)).toBe(false);
|
|
395
|
+
expect(Number.isNaN(point.y)).toBe(false);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
it('should handle 2 vectors (trivial case)', async () => {
|
|
399
|
+
const result = await service.ReduceDimensions([
|
|
400
|
+
[1, 2, 3],
|
|
401
|
+
[4, 5, 6],
|
|
402
|
+
]);
|
|
403
|
+
expect(result).toHaveLength(2);
|
|
404
|
+
// Trivial case: deterministic positions
|
|
405
|
+
expect(result[0].x).toBe(250);
|
|
406
|
+
expect(result[0].y).toBe(350);
|
|
407
|
+
expect(result[1].x).toBe(750);
|
|
408
|
+
expect(result[1].y).toBe(350);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
// ================================================================
|
|
412
|
+
// Result Building (edge cases)
|
|
413
|
+
// ================================================================
|
|
414
|
+
describe('result building edge cases', () => {
|
|
415
|
+
it('should assign ClusterId -1 when vector key not found in cluster map', async () => {
|
|
416
|
+
const vectors = makeVectors(3);
|
|
417
|
+
// Cluster result only includes key-0 and key-1
|
|
418
|
+
const clusters = new Map();
|
|
419
|
+
clusters.set(0, ['key-0', 'key-1']);
|
|
420
|
+
const clusterResult = {
|
|
421
|
+
clusters,
|
|
422
|
+
outliers: [],
|
|
423
|
+
metadata: { metric: 'cosine', silhouetteScore: 0.5 },
|
|
424
|
+
};
|
|
425
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
426
|
+
mockUmapFit.mockReturnValue([[0, 0], [5, 5], [10, 10]]);
|
|
427
|
+
config.K = 1;
|
|
428
|
+
const result = await service.RunClustering(vectors, config);
|
|
429
|
+
const orphanPoint = result.Points.find(p => p.VectorKey === 'key-2');
|
|
430
|
+
expect(orphanPoint?.ClusterId).toBe(-1);
|
|
431
|
+
});
|
|
432
|
+
it('should handle missing metadata on cluster result', async () => {
|
|
433
|
+
const vectors = makeVectors(3);
|
|
434
|
+
const clusters = new Map();
|
|
435
|
+
clusters.set(0, ['key-0', 'key-1', 'key-2']);
|
|
436
|
+
const clusterResult = {
|
|
437
|
+
clusters,
|
|
438
|
+
// no outliers, no metadata
|
|
439
|
+
};
|
|
440
|
+
mockKMeansCluster.mockReturnValue(clusterResult);
|
|
441
|
+
mockUmapFit.mockReturnValue([[0, 0], [5, 5], [10, 10]]);
|
|
442
|
+
config.K = 1;
|
|
443
|
+
const result = await service.RunClustering(vectors, config);
|
|
444
|
+
expect(result.Metrics.SilhouetteScore).toBe(0);
|
|
445
|
+
expect(result.Metrics.OutlierCount).toBe(0);
|
|
446
|
+
});
|
|
447
|
+
it('should use default projection when key not in projected map', async () => {
|
|
448
|
+
// This tests the fallback `projected.get(v.Key) ?? [500, 350]`
|
|
449
|
+
// We achieve this by having UMAP return fewer points than expected
|
|
450
|
+
const vectors = makeVectors(3);
|
|
451
|
+
const clusters = new Map();
|
|
452
|
+
clusters.set(0, ['key-0', 'key-1', 'key-2']);
|
|
453
|
+
mockKMeansCluster.mockReturnValue({ clusters });
|
|
454
|
+
// UMAP returns only 2 points (key "0" and "1" via index mapping)
|
|
455
|
+
// but the service builds keys from the vectorMap, so all 3 keys
|
|
456
|
+
// will be in the projected map. This edge case is hard to trigger
|
|
457
|
+
// through the public API. Instead verify the result is valid.
|
|
458
|
+
mockUmapFit.mockReturnValue([[0, 0], [5, 5], [10, 10]]);
|
|
459
|
+
config.K = 1;
|
|
460
|
+
const result = await service.RunClustering(vectors, config);
|
|
461
|
+
expect(result.Points).toHaveLength(3);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
// ================================================================
|
|
465
|
+
// Distance metric passthrough
|
|
466
|
+
// ================================================================
|
|
467
|
+
describe('distance metric passthrough', () => {
|
|
468
|
+
it('should pass euclidean metric to KMeans', async () => {
|
|
469
|
+
config.DistanceMetric = 'euclidean';
|
|
470
|
+
const vectors = makeVectors(4);
|
|
471
|
+
mockKMeansCluster.mockReturnValue(makeClusterResult(vectors.map(v => v.Key), 2));
|
|
472
|
+
mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 10, i * 5]));
|
|
473
|
+
await service.RunClustering(vectors, config);
|
|
474
|
+
expect(mockKMeansCluster).toHaveBeenCalledWith(expect.any(Number), 100, 'euclidean');
|
|
475
|
+
});
|
|
476
|
+
it('should pass dotproduct metric to DBSCAN', async () => {
|
|
477
|
+
config.Algorithm = 'dbscan';
|
|
478
|
+
config.DistanceMetric = 'dotproduct';
|
|
479
|
+
const vectors = makeVectors(4);
|
|
480
|
+
mockDBSCANCluster.mockReturnValue(makeDbscanResult(vectors.map(v => v.Key), 0));
|
|
481
|
+
mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 10, i * 5]));
|
|
482
|
+
await service.RunClustering(vectors, config);
|
|
483
|
+
expect(mockDBSCANCluster).toHaveBeenCalledWith(0.3, 3, 'dotproduct');
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
//# sourceMappingURL=clustering-service.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clustering-service.test.js","sourceRoot":"","sources":["../../src/__tests__/clustering-service.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, it, expect, vi, beforeEach } from 'vitest';\nimport {\n ClusterConfig,\n ClusterInputVector,\n DefaultClusterConfig,\n CLUSTER_COLORS,\n} from '../lib/clustering.types';\n\n// ================================================================\n// Mocks\n// ================================================================\n\n// Mock Angular's Injectable (the service uses @Injectable)\nvi.mock('@angular/core', () => ({\n Injectable: () => (target: unknown) => target,\n}));\n\n// Shared mock state - use vi.hoisted to make these available in hoisted vi.mock factories\nconst { mockLoadVectors, mockKMeansCluster, mockDBSCANCluster, mockUmapFit, umapState } = vi.hoisted(() => ({\n mockLoadVectors: vi.fn(),\n mockKMeansCluster: vi.fn(),\n mockDBSCANCluster: vi.fn(),\n mockUmapFit: vi.fn(),\n umapState: { shouldFail: false },\n}));\n\nvi.mock('@memberjunction/ai-vectors-memory', () => ({\n SimpleVectorService: class {\n LoadVectors = mockLoadVectors;\n KMeansCluster = mockKMeansCluster;\n DBSCANCluster = mockDBSCANCluster;\n },\n}));\n\nvi.mock('umap-js', () => ({\n get UMAP() {\n if (umapState.shouldFail) {\n throw new Error('UMAP not available');\n }\n return class { fit = mockUmapFit; };\n },\n}));\n\n// Import AFTER mocks are set up\nimport { ClusteringService } from '../lib/clustering.service';\n\n// ================================================================\n// Helpers\n// ================================================================\n\nfunction makeVectors(count: number, dim: number = 10): ClusterInputVector[] {\n return Array.from({ length: count }, (_, i) => ({\n Key: `key-${i}`,\n Vector: Array.from({ length: dim }, (_, d) => Math.sin(i * 0.5 + d * 0.3)),\n Label: `Item ${i}`,\n Metadata: { index: i },\n }));\n}\n\nfunction makeClusterResult(keys: string[], numClusters: number) {\n const clusters = new Map<number, string[]>();\n for (let c = 0; c < numClusters; c++) {\n clusters.set(c, []);\n }\n keys.forEach((key, i) => {\n clusters.get(i % numClusters)!.push(key);\n });\n return {\n clusters,\n outliers: [],\n metadata: { metric: 'cosine' as const, silhouetteScore: 0.65 },\n };\n}\n\nfunction makeDbscanResult(keys: string[], outlierCount: number) {\n const clusters = new Map<number, string[]>();\n const outliers: string[] = [];\n clusters.set(0, []);\n clusters.set(1, []);\n keys.forEach((key, i) => {\n if (i < outlierCount) {\n outliers.push(key);\n } else if (i % 2 === 0) {\n clusters.get(0)!.push(key);\n } else {\n clusters.get(1)!.push(key);\n }\n });\n return {\n clusters,\n outliers,\n metadata: { metric: 'cosine' as const, silhouetteScore: 0.5 },\n };\n}\n\n// ================================================================\n// Tests\n// ================================================================\n\ndescribe('ClusteringService', () => {\n let service: ClusteringService;\n let config: ClusterConfig;\n\n beforeEach(() => {\n vi.clearAllMocks();\n umapState.shouldFail = false;\n service = new ClusteringService();\n config = DefaultClusterConfig();\n config.K = 3;\n });\n\n // ================================================================\n // RunClustering - empty input\n // ================================================================\n\n describe('RunClustering with empty vectors', () => {\n it('should return an empty result', async () => {\n const result = await service.RunClustering([], config);\n expect(result.Points).toHaveLength(0);\n expect(result.Clusters).toHaveLength(0);\n expect(result.Metrics.RecordCount).toBe(0);\n expect(result.Metrics.ClusterCount).toBe(0);\n expect(result.Metrics.OutlierCount).toBe(0);\n expect(result.Metrics.SilhouetteScore).toBe(0);\n expect(result.Metrics.ComputationTimeMs).toBeGreaterThanOrEqual(0);\n expect(result.Config).toBe(config);\n });\n\n it('should not call SimpleVectorService for empty input', async () => {\n await service.RunClustering([], config);\n expect(mockLoadVectors).not.toHaveBeenCalled();\n expect(mockKMeansCluster).not.toHaveBeenCalled();\n });\n });\n\n // ================================================================\n // RunClustering - K-Means\n // ================================================================\n\n describe('RunClustering with K-Means', () => {\n it('should call KMeansCluster with correct parameters', async () => {\n const vectors = makeVectors(6);\n const clusterResult = makeClusterResult(vectors.map(v => v.Key), 3);\n mockKMeansCluster.mockReturnValue(clusterResult);\n\n // UMAP mock\n mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 100, i * 50]));\n\n await service.RunClustering(vectors, config);\n\n expect(mockLoadVectors).toHaveBeenCalledTimes(1);\n expect(mockKMeansCluster).toHaveBeenCalledWith(3, 100, 'cosine');\n expect(mockDBSCANCluster).not.toHaveBeenCalled();\n });\n\n it('should clamp K to vector count', async () => {\n const vectors = makeVectors(2);\n config.K = 10;\n\n const clusterResult = makeClusterResult(vectors.map(v => v.Key), 2);\n mockKMeansCluster.mockReturnValue(clusterResult);\n\n // Trivial case: 2 vectors -> deterministic placement\n await service.RunClustering(vectors, config);\n\n // K should be clamped to vectors.length (2)\n expect(mockKMeansCluster).toHaveBeenCalledWith(2, 100, 'cosine');\n });\n\n it('should produce correct result structure', async () => {\n const vectors = makeVectors(6);\n const clusterResult = makeClusterResult(vectors.map(v => v.Key), 3);\n mockKMeansCluster.mockReturnValue(clusterResult);\n mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 100, i * 50]));\n\n const result = await service.RunClustering(vectors, config);\n\n // Points\n expect(result.Points).toHaveLength(6);\n for (const point of result.Points) {\n expect(typeof point.X).toBe('number');\n expect(typeof point.Y).toBe('number');\n expect(point.ClusterId).toBeGreaterThanOrEqual(0);\n expect(point.VectorKey).toBeTruthy();\n expect(point.Label).toBeTruthy();\n expect(point.Metadata).toBeDefined();\n }\n\n // Clusters\n expect(result.Clusters).toHaveLength(3);\n for (const cluster of result.Clusters) {\n expect(cluster.Id).toBeGreaterThanOrEqual(0);\n expect(cluster.Label).toMatch(/^Cluster \\d+$/);\n expect(cluster.Color).toBeTruthy();\n expect(cluster.MemberCount).toBeGreaterThan(0);\n }\n // Sorted by ID\n for (let i = 1; i < result.Clusters.length; i++) {\n expect(result.Clusters[i].Id).toBeGreaterThan(result.Clusters[i - 1].Id);\n }\n\n // Metrics\n expect(result.Metrics.ClusterCount).toBe(3);\n expect(result.Metrics.RecordCount).toBe(6);\n expect(result.Metrics.SilhouetteScore).toBe(0.65);\n expect(result.Metrics.OutlierCount).toBe(0);\n expect(result.Metrics.ComputationTimeMs).toBeGreaterThanOrEqual(0);\n\n // Config passthrough\n expect(result.Config).toBe(config);\n });\n\n it('should assign cluster colors from CLUSTER_COLORS palette', async () => {\n const vectors = makeVectors(6);\n const clusterResult = makeClusterResult(vectors.map(v => v.Key), 3);\n mockKMeansCluster.mockReturnValue(clusterResult);\n mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 100, i * 50]));\n\n const result = await service.RunClustering(vectors, config);\n\n expect(result.Clusters[0].Color).toBe(CLUSTER_COLORS[0]);\n expect(result.Clusters[1].Color).toBe(CLUSTER_COLORS[1]);\n expect(result.Clusters[2].Color).toBe(CLUSTER_COLORS[2]);\n });\n\n it('should wrap colors when clusters exceed palette size', async () => {\n const vectors = makeVectors(30);\n const numClusters = 12;\n const clusterResult = makeClusterResult(vectors.map(v => v.Key), numClusters);\n mockKMeansCluster.mockReturnValue(clusterResult);\n mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 30, i * 20]));\n config.K = numClusters;\n\n const result = await service.RunClustering(vectors, config);\n\n // Cluster 10 wraps to CLUSTER_COLORS[10 % 10] = CLUSTER_COLORS[0]\n expect(result.Clusters[10].Color).toBe(CLUSTER_COLORS[0]);\n expect(result.Clusters[11].Color).toBe(CLUSTER_COLORS[1]);\n });\n\n it('should use Label from input vector when provided', async () => {\n const vectors: ClusterInputVector[] = [\n { Key: 'k1', Vector: [1, 2, 3], Label: 'Custom Label' },\n { Key: 'k2', Vector: [4, 5, 6] },\n ];\n const clusterResult = makeClusterResult(['k1', 'k2'], 1);\n mockKMeansCluster.mockReturnValue(clusterResult);\n config.K = 1;\n\n const result = await service.RunClustering(vectors, config);\n\n const p1 = result.Points.find(p => p.VectorKey === 'k1');\n const p2 = result.Points.find(p => p.VectorKey === 'k2');\n expect(p1?.Label).toBe('Custom Label');\n expect(p2?.Label).toBe('k2'); // Falls back to Key\n });\n\n it('should default Metadata to empty object when not provided', async () => {\n const vectors: ClusterInputVector[] = [\n { Key: 'k1', Vector: [1, 2, 3] },\n ];\n const clusterResult = makeClusterResult(['k1'], 1);\n mockKMeansCluster.mockReturnValue(clusterResult);\n config.K = 1;\n\n const result = await service.RunClustering(vectors, config);\n expect(result.Points[0].Metadata).toEqual({});\n });\n });\n\n // ================================================================\n // RunClustering - DBSCAN\n // ================================================================\n\n describe('RunClustering with DBSCAN', () => {\n it('should call DBSCANCluster with correct parameters', async () => {\n config.Algorithm = 'dbscan';\n config.Epsilon = 0.5;\n config.MinPoints = 4;\n const vectors = makeVectors(6);\n const clusterResult = makeDbscanResult(vectors.map(v => v.Key), 1);\n mockDBSCANCluster.mockReturnValue(clusterResult);\n mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 100, i * 50]));\n\n await service.RunClustering(vectors, config);\n\n expect(mockDBSCANCluster).toHaveBeenCalledWith(0.5, 4, 'cosine');\n expect(mockKMeansCluster).not.toHaveBeenCalled();\n });\n\n it('should handle outliers correctly', async () => {\n config.Algorithm = 'dbscan';\n const vectors = makeVectors(6);\n const clusterResult = makeDbscanResult(vectors.map(v => v.Key), 2);\n mockDBSCANCluster.mockReturnValue(clusterResult);\n mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 100, i * 50]));\n\n const result = await service.RunClustering(vectors, config);\n\n // First 2 vectors are outliers\n const outlierPoints = result.Points.filter(p => p.ClusterId === -1);\n expect(outlierPoints.length).toBe(2);\n expect(result.Metrics.OutlierCount).toBe(2);\n });\n });\n\n // ================================================================\n // Dimensionality Reduction\n // ================================================================\n\n describe('dimensionality reduction', () => {\n it('should handle trivial case (1 vector) with deterministic placement', async () => {\n const vectors = makeVectors(1);\n const clusterResult = makeClusterResult(['key-0'], 1);\n mockKMeansCluster.mockReturnValue(clusterResult);\n config.K = 1;\n\n const result = await service.RunClustering(vectors, config);\n\n // 1 vector -> trivial case: positioned at [250, 350]\n expect(result.Points[0].X).toBe(250);\n expect(result.Points[0].Y).toBe(350);\n });\n\n it('should handle trivial case (2 vectors) with deterministic placement', async () => {\n const vectors = makeVectors(2);\n const clusterResult = makeClusterResult(['key-0', 'key-1'], 1);\n mockKMeansCluster.mockReturnValue(clusterResult);\n config.K = 1;\n\n const result = await service.RunClustering(vectors, config);\n\n // 2 vectors -> trivial case: [0*500+250, 350] and [1*500+250, 350]\n expect(result.Points[0].X).toBe(250);\n expect(result.Points[0].Y).toBe(350);\n expect(result.Points[1].X).toBe(750);\n expect(result.Points[1].Y).toBe(350);\n });\n\n it('should use UMAP when available and normalize to viewbox range', async () => {\n const vectors = makeVectors(5);\n const clusterResult = makeClusterResult(vectors.map(v => v.Key), 2);\n mockKMeansCluster.mockReturnValue(clusterResult);\n // Return raw UMAP coordinates - will be normalized\n mockUmapFit.mockReturnValue([\n [0, 0],\n [10, 5],\n [20, 10],\n [30, 15],\n [40, 20],\n ]);\n\n const result = await service.RunClustering(vectors, config);\n\n // All points should be within normalized range [60, 940] (padding=60, size=1000)\n for (const point of result.Points) {\n expect(point.X).toBeGreaterThanOrEqual(60);\n expect(point.X).toBeLessThanOrEqual(940);\n expect(point.Y).toBeGreaterThanOrEqual(60);\n expect(point.Y).toBeLessThanOrEqual(940);\n expect(Number.isNaN(point.X)).toBe(false);\n expect(Number.isNaN(point.Y)).toBe(false);\n }\n });\n\n it('should fall back to PCA when UMAP throws', async () => {\n umapState.shouldFail = true;\n const vectors = makeVectors(5);\n const clusterResult = makeClusterResult(vectors.map(v => v.Key), 2);\n mockKMeansCluster.mockReturnValue(clusterResult);\n\n const result = await service.RunClustering(vectors, config);\n\n // PCA fallback should still produce valid points\n expect(result.Points).toHaveLength(5);\n for (const point of result.Points) {\n expect(Number.isNaN(point.X)).toBe(false);\n expect(Number.isNaN(point.Y)).toBe(false);\n expect(typeof point.X).toBe('number');\n expect(typeof point.Y).toBe('number');\n }\n });\n\n it('should produce normalized PCA points within viewbox range', async () => {\n umapState.shouldFail = true;\n const vectors = makeVectors(10, 20);\n const clusterResult = makeClusterResult(vectors.map(v => v.Key), 3);\n mockKMeansCluster.mockReturnValue(clusterResult);\n\n const result = await service.RunClustering(vectors, config);\n\n for (const point of result.Points) {\n expect(point.X).toBeGreaterThanOrEqual(60);\n expect(point.X).toBeLessThanOrEqual(940);\n expect(point.Y).toBeGreaterThanOrEqual(60);\n expect(point.Y).toBeLessThanOrEqual(940);\n }\n });\n\n it('should handle identical vectors in PCA fallback gracefully', async () => {\n umapState.shouldFail = true;\n // All vectors are identical\n const vectors: ClusterInputVector[] = Array.from({ length: 4 }, (_, i) => ({\n Key: `key-${i}`,\n Vector: [1, 1, 1, 1, 1],\n }));\n const clusterResult = makeClusterResult(vectors.map(v => v.Key), 1);\n mockKMeansCluster.mockReturnValue(clusterResult);\n config.K = 1;\n\n const result = await service.RunClustering(vectors, config);\n\n // All centered to zero, normalization divides by range=0 (uses 1 fallback).\n // All should be at the same location, not NaN.\n for (const point of result.Points) {\n expect(Number.isNaN(point.X)).toBe(false);\n expect(Number.isNaN(point.Y)).toBe(false);\n }\n });\n });\n\n // ================================================================\n // ReduceDimensions (public helper)\n // ================================================================\n\n describe('ReduceDimensions', () => {\n it('should return empty array for empty input', async () => {\n const result = await service.ReduceDimensions([]);\n expect(result).toEqual([]);\n });\n\n it('should return 2D points for valid input using UMAP', async () => {\n mockUmapFit.mockReturnValue([\n [0, 0],\n [5, 5],\n [10, 10],\n ]);\n\n const result = await service.ReduceDimensions([\n [1, 2, 3],\n [4, 5, 6],\n [7, 8, 9],\n ]);\n\n expect(result).toHaveLength(3);\n for (const point of result) {\n expect(typeof point.x).toBe('number');\n expect(typeof point.y).toBe('number');\n expect(Number.isNaN(point.x)).toBe(false);\n expect(Number.isNaN(point.y)).toBe(false);\n }\n });\n\n it('should fall back to PCA and still return valid points', async () => {\n umapState.shouldFail = true;\n\n const result = await service.ReduceDimensions([\n [1, 2, 3, 4, 5],\n [6, 7, 8, 9, 10],\n [11, 12, 13, 14, 15],\n ]);\n\n expect(result).toHaveLength(3);\n for (const point of result) {\n expect(typeof point.x).toBe('number');\n expect(typeof point.y).toBe('number');\n expect(Number.isNaN(point.x)).toBe(false);\n expect(Number.isNaN(point.y)).toBe(false);\n }\n });\n\n it('should handle 2 vectors (trivial case)', async () => {\n const result = await service.ReduceDimensions([\n [1, 2, 3],\n [4, 5, 6],\n ]);\n\n expect(result).toHaveLength(2);\n // Trivial case: deterministic positions\n expect(result[0].x).toBe(250);\n expect(result[0].y).toBe(350);\n expect(result[1].x).toBe(750);\n expect(result[1].y).toBe(350);\n });\n });\n\n // ================================================================\n // Result Building (edge cases)\n // ================================================================\n\n describe('result building edge cases', () => {\n it('should assign ClusterId -1 when vector key not found in cluster map', async () => {\n const vectors = makeVectors(3);\n // Cluster result only includes key-0 and key-1\n const clusters = new Map<number, string[]>();\n clusters.set(0, ['key-0', 'key-1']);\n const clusterResult = {\n clusters,\n outliers: [] as string[],\n metadata: { metric: 'cosine' as const, silhouetteScore: 0.5 },\n };\n mockKMeansCluster.mockReturnValue(clusterResult);\n mockUmapFit.mockReturnValue([[0, 0], [5, 5], [10, 10]]);\n config.K = 1;\n\n const result = await service.RunClustering(vectors, config);\n\n const orphanPoint = result.Points.find(p => p.VectorKey === 'key-2');\n expect(orphanPoint?.ClusterId).toBe(-1);\n });\n\n it('should handle missing metadata on cluster result', async () => {\n const vectors = makeVectors(3);\n const clusters = new Map<number, string[]>();\n clusters.set(0, ['key-0', 'key-1', 'key-2']);\n const clusterResult = {\n clusters,\n // no outliers, no metadata\n };\n mockKMeansCluster.mockReturnValue(clusterResult);\n mockUmapFit.mockReturnValue([[0, 0], [5, 5], [10, 10]]);\n config.K = 1;\n\n const result = await service.RunClustering(vectors, config);\n\n expect(result.Metrics.SilhouetteScore).toBe(0);\n expect(result.Metrics.OutlierCount).toBe(0);\n });\n\n it('should use default projection when key not in projected map', async () => {\n // This tests the fallback `projected.get(v.Key) ?? [500, 350]`\n // We achieve this by having UMAP return fewer points than expected\n const vectors = makeVectors(3);\n const clusters = new Map<number, string[]>();\n clusters.set(0, ['key-0', 'key-1', 'key-2']);\n mockKMeansCluster.mockReturnValue({ clusters });\n\n // UMAP returns only 2 points (key \"0\" and \"1\" via index mapping)\n // but the service builds keys from the vectorMap, so all 3 keys\n // will be in the projected map. This edge case is hard to trigger\n // through the public API. Instead verify the result is valid.\n mockUmapFit.mockReturnValue([[0, 0], [5, 5], [10, 10]]);\n config.K = 1;\n\n const result = await service.RunClustering(vectors, config);\n expect(result.Points).toHaveLength(3);\n });\n });\n\n // ================================================================\n // Distance metric passthrough\n // ================================================================\n\n describe('distance metric passthrough', () => {\n it('should pass euclidean metric to KMeans', async () => {\n config.DistanceMetric = 'euclidean';\n const vectors = makeVectors(4);\n mockKMeansCluster.mockReturnValue(makeClusterResult(vectors.map(v => v.Key), 2));\n mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 10, i * 5]));\n\n await service.RunClustering(vectors, config);\n\n expect(mockKMeansCluster).toHaveBeenCalledWith(\n expect.any(Number),\n 100,\n 'euclidean'\n );\n });\n\n it('should pass dotproduct metric to DBSCAN', async () => {\n config.Algorithm = 'dbscan';\n config.DistanceMetric = 'dotproduct';\n const vectors = makeVectors(4);\n mockDBSCANCluster.mockReturnValue(makeDbscanResult(vectors.map(v => v.Key), 0));\n mockUmapFit.mockReturnValue(vectors.map((_, i) => [i * 10, i * 5]));\n\n await service.RunClustering(vectors, config);\n\n expect(mockDBSCANCluster).toHaveBeenCalledWith(0.3, 3, 'dotproduct');\n });\n });\n});\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"clustering-types.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/clustering-types.test.ts"],"names":[],"mappings":""}
|