spatial-memory-mcp 1.9.1__py3-none-any.whl

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 (55) hide show
  1. spatial_memory/__init__.py +97 -0
  2. spatial_memory/__main__.py +271 -0
  3. spatial_memory/adapters/__init__.py +7 -0
  4. spatial_memory/adapters/lancedb_repository.py +880 -0
  5. spatial_memory/config.py +769 -0
  6. spatial_memory/core/__init__.py +118 -0
  7. spatial_memory/core/cache.py +317 -0
  8. spatial_memory/core/circuit_breaker.py +297 -0
  9. spatial_memory/core/connection_pool.py +220 -0
  10. spatial_memory/core/consolidation_strategies.py +401 -0
  11. spatial_memory/core/database.py +3072 -0
  12. spatial_memory/core/db_idempotency.py +242 -0
  13. spatial_memory/core/db_indexes.py +576 -0
  14. spatial_memory/core/db_migrations.py +588 -0
  15. spatial_memory/core/db_search.py +512 -0
  16. spatial_memory/core/db_versioning.py +178 -0
  17. spatial_memory/core/embeddings.py +558 -0
  18. spatial_memory/core/errors.py +317 -0
  19. spatial_memory/core/file_security.py +701 -0
  20. spatial_memory/core/filesystem.py +178 -0
  21. spatial_memory/core/health.py +289 -0
  22. spatial_memory/core/helpers.py +79 -0
  23. spatial_memory/core/import_security.py +433 -0
  24. spatial_memory/core/lifecycle_ops.py +1067 -0
  25. spatial_memory/core/logging.py +194 -0
  26. spatial_memory/core/metrics.py +192 -0
  27. spatial_memory/core/models.py +660 -0
  28. spatial_memory/core/rate_limiter.py +326 -0
  29. spatial_memory/core/response_types.py +500 -0
  30. spatial_memory/core/security.py +588 -0
  31. spatial_memory/core/spatial_ops.py +430 -0
  32. spatial_memory/core/tracing.py +300 -0
  33. spatial_memory/core/utils.py +110 -0
  34. spatial_memory/core/validation.py +406 -0
  35. spatial_memory/factory.py +444 -0
  36. spatial_memory/migrations/__init__.py +40 -0
  37. spatial_memory/ports/__init__.py +11 -0
  38. spatial_memory/ports/repositories.py +630 -0
  39. spatial_memory/py.typed +0 -0
  40. spatial_memory/server.py +1214 -0
  41. spatial_memory/services/__init__.py +70 -0
  42. spatial_memory/services/decay_manager.py +411 -0
  43. spatial_memory/services/export_import.py +1031 -0
  44. spatial_memory/services/lifecycle.py +1139 -0
  45. spatial_memory/services/memory.py +412 -0
  46. spatial_memory/services/spatial.py +1152 -0
  47. spatial_memory/services/utility.py +429 -0
  48. spatial_memory/tools/__init__.py +5 -0
  49. spatial_memory/tools/definitions.py +695 -0
  50. spatial_memory/verify.py +140 -0
  51. spatial_memory_mcp-1.9.1.dist-info/METADATA +509 -0
  52. spatial_memory_mcp-1.9.1.dist-info/RECORD +55 -0
  53. spatial_memory_mcp-1.9.1.dist-info/WHEEL +4 -0
  54. spatial_memory_mcp-1.9.1.dist-info/entry_points.txt +2 -0
  55. spatial_memory_mcp-1.9.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,430 @@
1
+ """Core spatial algorithms for memory navigation and exploration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Sequence
7
+ from dataclasses import dataclass, field
8
+ from typing import TypeVar
9
+
10
+ import numpy as np
11
+ from numpy.typing import NDArray
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ T = TypeVar("T")
16
+ Vector = NDArray[np.float32]
17
+
18
+
19
+ # =============================================================================
20
+ # Vector Operations
21
+ # =============================================================================
22
+
23
+
24
+ def normalize(v: Vector) -> Vector:
25
+ """
26
+ Normalize a vector to unit length.
27
+
28
+ Args:
29
+ v: Input vector to normalize.
30
+
31
+ Returns:
32
+ Unit vector in same direction as v, or zero vector if input norm is negligible.
33
+ """
34
+ norm = np.linalg.norm(v)
35
+ if norm < 1e-10:
36
+ return np.zeros_like(v).astype(np.float32)
37
+ normalized: Vector = v / norm
38
+ return normalized
39
+
40
+
41
+ def normalize_batch(vectors: Vector, copy: bool = True) -> Vector:
42
+ """
43
+ Normalize multiple vectors efficiently.
44
+
45
+ Args:
46
+ vectors: 2D array of shape (n_vectors, n_dimensions).
47
+ copy: If True, creates a copy before modifying. If False, modifies in place.
48
+
49
+ Returns:
50
+ Array of unit vectors with same shape as input.
51
+ """
52
+ if copy:
53
+ vectors = vectors.copy()
54
+ norms = np.linalg.norm(vectors, axis=1, keepdims=True)
55
+ norms = np.maximum(norms, 1e-10)
56
+ vectors /= norms
57
+ return vectors
58
+
59
+
60
+ # =============================================================================
61
+ # SLERP (Spherical Linear Interpolation)
62
+ # =============================================================================
63
+
64
+
65
+ def slerp(v0: Vector, v1: Vector, t: float) -> Vector:
66
+ """
67
+ Spherical linear interpolation between two unit vectors.
68
+
69
+ SLERP produces a constant-speed path along the great circle connecting two
70
+ points on the unit sphere. This is more geometrically correct than linear
71
+ interpolation for normalized embedding vectors.
72
+
73
+ Handles edge cases:
74
+ - Parallel vectors (omega ~ 0): Falls back to linear interpolation
75
+ - Antipodal vectors (omega ~ pi): Chooses arbitrary perpendicular path
76
+
77
+ Args:
78
+ v0: Starting unit vector.
79
+ v1: Ending unit vector.
80
+ t: Interpolation parameter in [0, 1].
81
+
82
+ Returns:
83
+ Interpolated unit vector at parameter t.
84
+
85
+ Example:
86
+ >>> v0 = np.array([1.0, 0.0, 0.0], dtype=np.float32)
87
+ >>> v1 = np.array([0.0, 1.0, 0.0], dtype=np.float32)
88
+ >>> mid = slerp(v0, v1, 0.5)
89
+ >>> np.linalg.norm(mid) # Always unit length
90
+ 1.0
91
+ """
92
+ # Work in float64 for numerical stability
93
+ v0 = normalize(v0.astype(np.float64))
94
+ v1 = normalize(v1.astype(np.float64))
95
+
96
+ # Compute dot product, clamp to [-1, 1] for numerical stability
97
+ dot = np.clip(np.dot(v0, v1), -1.0, 1.0)
98
+
99
+ # Handle nearly parallel vectors (dot ~ 1.0)
100
+ # Linear interpolation is a good approximation when angle is very small
101
+ if dot > 0.9995:
102
+ result = v0 + t * (v1 - v0)
103
+ return normalize(result.astype(np.float32))
104
+
105
+ # Handle nearly antipodal vectors (dot ~ -1.0)
106
+ # Choose an arbitrary perpendicular path
107
+ if dot < -0.9995:
108
+ perp = _find_perpendicular(v0)
109
+ half_angle = np.pi * t
110
+ result = v0 * np.cos(half_angle) + perp * np.sin(half_angle)
111
+ antipodal_result: Vector = result.astype(np.float32)
112
+ return antipodal_result
113
+
114
+ # Standard SLERP formula
115
+ omega = np.arccos(dot)
116
+ sin_omega = np.sin(omega)
117
+ s0 = np.sin((1.0 - t) * omega) / sin_omega
118
+ s1 = np.sin(t * omega) / sin_omega
119
+
120
+ slerp_result: Vector = (s0 * v0 + s1 * v1).astype(np.float32)
121
+ return slerp_result
122
+
123
+
124
+ def _find_perpendicular(v: Vector) -> Vector:
125
+ """
126
+ Find a unit vector perpendicular to v.
127
+
128
+ Uses the approach of creating a vector from the standard basis that differs
129
+ most from v, then applying Gram-Schmidt orthogonalization.
130
+
131
+ Args:
132
+ v: Input unit vector.
133
+
134
+ Returns:
135
+ A unit vector orthogonal to v.
136
+ """
137
+ # Find the component with smallest absolute value
138
+ min_idx = np.argmin(np.abs(v))
139
+
140
+ # Create a basis vector that differs most from v
141
+ basis = np.zeros_like(v)
142
+ basis[min_idx] = 1.0
143
+
144
+ # Gram-Schmidt: subtract projection of basis onto v
145
+ perp = basis - np.dot(v, basis) * v
146
+ return normalize(perp)
147
+
148
+
149
+ def slerp_path(
150
+ v0: Vector,
151
+ v1: Vector,
152
+ steps: int,
153
+ include_endpoints: bool = True,
154
+ ) -> list[Vector]:
155
+ """
156
+ Generate N interpolation steps between two vectors using SLERP.
157
+
158
+ Creates a path of evenly-spaced points along the great circle connecting
159
+ two embedding vectors. Useful for exploring the semantic space between
160
+ two memories.
161
+
162
+ Args:
163
+ v0: Starting vector.
164
+ v1: Ending vector.
165
+ steps: Number of vectors to generate.
166
+ include_endpoints: If True, path starts at v0 and ends at v1.
167
+ If False, generates intermediate points only.
168
+
169
+ Returns:
170
+ List of interpolated unit vectors.
171
+
172
+ Raises:
173
+ ValueError: If steps < 1.
174
+
175
+ Example:
176
+ >>> v0 = np.array([1.0, 0.0, 0.0], dtype=np.float32)
177
+ >>> v1 = np.array([0.0, 1.0, 0.0], dtype=np.float32)
178
+ >>> path = slerp_path(v0, v1, steps=5)
179
+ >>> len(path)
180
+ 5
181
+ """
182
+ if steps < 1:
183
+ raise ValueError("steps must be at least 1")
184
+
185
+ vectors: list[Vector] = []
186
+
187
+ if include_endpoints:
188
+ for i in range(steps):
189
+ t = i / (steps - 1) if steps > 1 else 0.0
190
+ vectors.append(slerp(v0, v1, t))
191
+ else:
192
+ for i in range(steps):
193
+ t = (i + 1) / (steps + 1)
194
+ vectors.append(slerp(v0, v1, t))
195
+
196
+ return vectors
197
+
198
+
199
+ # =============================================================================
200
+ # Temperature-based Selection (for Wander)
201
+ # =============================================================================
202
+
203
+
204
+ def softmax_with_temperature(
205
+ scores: NDArray[np.float64],
206
+ temperature: float = 1.0,
207
+ ) -> NDArray[np.float64]:
208
+ """
209
+ Compute softmax probabilities with temperature scaling.
210
+
211
+ Temperature controls the randomness of the resulting distribution:
212
+ - T -> 0: Deterministic (all probability mass on highest score)
213
+ - T = 1: Standard softmax
214
+ - T -> inf: Uniform random selection
215
+
216
+ Uses numerically stable computation by shifting scores before exponentiation.
217
+
218
+ Args:
219
+ scores: Array of raw scores (higher = better).
220
+ temperature: Temperature parameter (must be >= 0).
221
+
222
+ Returns:
223
+ Probability distribution over scores (sums to 1).
224
+
225
+ Raises:
226
+ ValueError: If temperature is negative.
227
+
228
+ Example:
229
+ >>> scores = np.array([0.9, 0.7, 0.3])
230
+ >>> probs = softmax_with_temperature(scores, temperature=1.0)
231
+ >>> np.sum(probs) # Always sums to 1
232
+ 1.0
233
+ """
234
+ if temperature < 0:
235
+ raise ValueError("Temperature must be non-negative")
236
+
237
+ scores = np.asarray(scores, dtype=np.float64)
238
+
239
+ if len(scores) == 0:
240
+ return np.array([], dtype=np.float64)
241
+
242
+ if len(scores) == 1:
243
+ return np.array([1.0], dtype=np.float64)
244
+
245
+ # Handle temperature = 0 (greedy/deterministic selection)
246
+ if temperature < 1e-10:
247
+ result = np.zeros_like(scores)
248
+ result[np.argmax(scores)] = 1.0
249
+ return result
250
+
251
+ # Scale scores by temperature
252
+ scaled = scores / temperature
253
+
254
+ # Subtract max for numerical stability (prevents overflow in exp)
255
+ scaled_shifted = scaled - np.max(scaled)
256
+ exp_scores = np.exp(scaled_shifted)
257
+
258
+ probabilities: NDArray[np.float64] = exp_scores / np.sum(exp_scores)
259
+ return probabilities
260
+
261
+
262
+ def temperature_select(
263
+ items: Sequence[T],
264
+ scores: NDArray[np.float64],
265
+ temperature: float = 1.0,
266
+ rng: np.random.Generator | None = None,
267
+ ) -> T:
268
+ """
269
+ Select an item using temperature-scaled softmax probabilities.
270
+
271
+ Combines softmax_with_temperature with random selection. Lower temperatures
272
+ favor higher-scored items, while higher temperatures approach uniform random.
273
+
274
+ Args:
275
+ items: Sequence of items to choose from.
276
+ scores: Score for each item (higher = more likely to be selected).
277
+ temperature: Controls randomness (0.1=focused, 2.0=random).
278
+ rng: Optional numpy random generator for reproducibility.
279
+
280
+ Returns:
281
+ Selected item from the sequence.
282
+
283
+ Raises:
284
+ ValueError: If items and scores have different lengths.
285
+
286
+ Example:
287
+ >>> items = ["a", "b", "c"]
288
+ >>> scores = np.array([0.9, 0.7, 0.3])
289
+ >>> # Low temperature: almost always picks "a"
290
+ >>> temperature_select(items, scores, temperature=0.1)
291
+ 'a'
292
+ """
293
+ if len(items) != len(scores):
294
+ raise ValueError("items and scores must have same length")
295
+
296
+ if rng is None:
297
+ rng = np.random.default_rng()
298
+
299
+ probabilities = softmax_with_temperature(scores, temperature)
300
+ idx = rng.choice(len(items), p=probabilities)
301
+ return items[idx]
302
+
303
+
304
+ # =============================================================================
305
+ # HDBSCAN Clustering (for Regions)
306
+ # =============================================================================
307
+
308
+
309
+ @dataclass
310
+ class ClusterInfo:
311
+ """
312
+ Information about a discovered cluster.
313
+
314
+ Represents a semantic region in the memory space discovered by HDBSCAN
315
+ clustering. Contains metadata about the cluster including its size,
316
+ central tendency, and sample members.
317
+
318
+ Attributes:
319
+ cluster_id: Unique identifier for this cluster (-1 indicates noise).
320
+ size: Number of memories in this cluster.
321
+ centroid: Mean vector of all memories in the cluster (normalized).
322
+ centroid_memory_id: ID of the memory closest to the centroid.
323
+ sample_memory_ids: IDs of representative sample memories.
324
+ coherence: Average pairwise similarity within the cluster (0-1).
325
+ keywords: Extracted topic keywords for this cluster.
326
+ """
327
+
328
+ cluster_id: int
329
+ size: int
330
+ centroid: Vector
331
+ centroid_memory_id: str
332
+ sample_memory_ids: list[str]
333
+ coherence: float
334
+ keywords: list[str] = field(default_factory=list)
335
+
336
+
337
+ def configure_hdbscan(
338
+ n_samples: int,
339
+ min_cluster_size: int | None = None,
340
+ min_samples: int | None = None,
341
+ ) -> dict[str, int | str]:
342
+ """
343
+ Configure HDBSCAN parameters based on dataset characteristics.
344
+
345
+ Provides sensible defaults for HDBSCAN clustering on embedding vectors.
346
+ The min_cluster_size is computed adaptively based on dataset size if not
347
+ provided explicitly.
348
+
349
+ Args:
350
+ n_samples: Number of samples in the dataset.
351
+ min_cluster_size: Minimum number of points to form a cluster.
352
+ If None, computed as sqrt(n_samples)/2, clamped to [3, 50].
353
+ min_samples: Minimum samples in neighborhood for core point.
354
+ If None, set to min_cluster_size // 2.
355
+
356
+ Returns:
357
+ Dictionary of HDBSCAN parameters ready to use with hdbscan.HDBSCAN().
358
+
359
+ Example:
360
+ >>> params = configure_hdbscan(1000)
361
+ >>> params["min_cluster_size"]
362
+ 15
363
+ >>> import hdbscan # doctest: +SKIP
364
+ >>> clusterer = hdbscan.HDBSCAN(**params) # doctest: +SKIP
365
+ """
366
+ if min_cluster_size is None:
367
+ # Adaptive min_cluster_size based on dataset size
368
+ min_cluster_size = max(3, int(np.sqrt(n_samples) / 2))
369
+ min_cluster_size = min(min_cluster_size, 50)
370
+
371
+ if min_samples is None:
372
+ min_samples = max(2, min_cluster_size // 2)
373
+
374
+ return {
375
+ "min_cluster_size": min_cluster_size,
376
+ "min_samples": min_samples,
377
+ "metric": "euclidean", # Use with normalized vectors for cosine distance
378
+ "cluster_selection_method": "eom", # Excess of Mass for varied cluster sizes
379
+ "core_dist_n_jobs": -1, # Use all available cores
380
+ }
381
+
382
+
383
+ # =============================================================================
384
+ # UMAP Projection (for Visualize)
385
+ # =============================================================================
386
+
387
+
388
+ def configure_umap(
389
+ n_samples: int,
390
+ n_components: int = 2,
391
+ n_neighbors: int = 15,
392
+ min_dist: float = 0.1,
393
+ random_state: int = 42,
394
+ ) -> dict[str, int | float | str | bool]:
395
+ """
396
+ Configure UMAP parameters for memory visualization.
397
+
398
+ Provides sensible defaults for projecting high-dimensional embedding
399
+ vectors to 2D or 3D for visualization. Parameters are adjusted based
400
+ on the number of samples.
401
+
402
+ Args:
403
+ n_samples: Number of samples to project.
404
+ n_components: Target dimensionality (2 for 2D, 3 for 3D visualization).
405
+ n_neighbors: Size of local neighborhood for manifold approximation.
406
+ Larger values capture more global structure.
407
+ min_dist: Minimum distance between points in embedded space.
408
+ Smaller values create tighter clusters.
409
+ random_state: Random seed for reproducibility.
410
+
411
+ Returns:
412
+ Dictionary of UMAP parameters ready to use with umap.UMAP().
413
+
414
+ Example:
415
+ >>> params = configure_umap(500, n_components=2)
416
+ >>> params["n_neighbors"]
417
+ 15
418
+ >>> import umap # doctest: +SKIP
419
+ >>> reducer = umap.UMAP(**params) # doctest: +SKIP
420
+ """
421
+ return {
422
+ "n_components": n_components,
423
+ # n_neighbors cannot exceed n_samples - 1
424
+ "n_neighbors": min(n_neighbors, n_samples - 1),
425
+ "min_dist": min_dist,
426
+ "metric": "cosine", # Natural metric for embeddings
427
+ "random_state": random_state,
428
+ # Enable low memory mode for large datasets
429
+ "low_memory": n_samples > 5000,
430
+ }