descry-codegraph 0.1.0__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.
descry/embeddings.py ADDED
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env python3
2
+ """Optional semantic search with embeddings for descry.
3
+
4
+ Uses sentence-transformers for embedding generation and numpy for similarity.
5
+ Falls back to TF-IDF search if dependencies are not available.
6
+
7
+ Usage:
8
+ # Check if available
9
+ if embeddings_available():
10
+ searcher = SemanticSearcher(graph_path)
11
+ results = searcher.search("authentication logic", limit=10)
12
+ """
13
+
14
+ import contextlib
15
+ import hashlib
16
+ import json
17
+ import logging
18
+ import os
19
+ import threading
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Optional
23
+
24
+
25
+ try:
26
+ import fcntl as _fcntl # Unix only
27
+ except ImportError:
28
+ _fcntl = None
29
+
30
+
31
+ @contextlib.contextmanager
32
+ def _file_lock(lock_path: Path, timeout: float = 600.0):
33
+ """Cross-process advisory lock.
34
+
35
+ Uses fcntl.flock on Unix; falls back to atomic-create sentinel on
36
+ platforms without fcntl (Windows).
37
+ """
38
+ if _fcntl is not None:
39
+ with open(lock_path, "w") as f:
40
+ _fcntl.flock(f, _fcntl.LOCK_EX)
41
+ try:
42
+ yield
43
+ finally:
44
+ _fcntl.flock(f, _fcntl.LOCK_UN)
45
+ try:
46
+ lock_path.unlink()
47
+ except OSError:
48
+ pass
49
+ return
50
+
51
+ # Windows fallback: exclusive-create a sentinel file.
52
+ start = time.monotonic()
53
+ while True:
54
+ try:
55
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
56
+ os.close(fd)
57
+ break
58
+ except FileExistsError:
59
+ if time.monotonic() - start > timeout:
60
+ raise TimeoutError(
61
+ f"Timed out waiting for embeddings lock at {lock_path}"
62
+ )
63
+ time.sleep(0.1)
64
+ try:
65
+ yield
66
+ finally:
67
+ try:
68
+ lock_path.unlink()
69
+ except OSError:
70
+ pass
71
+
72
+
73
+ logger = logging.getLogger(__name__)
74
+
75
+ # Try to import embedding dependencies
76
+ try:
77
+ import numpy as np
78
+ from sentence_transformers import SentenceTransformer
79
+
80
+ EMBEDDINGS_AVAILABLE = True
81
+ except ImportError:
82
+ EMBEDDINGS_AVAILABLE = False
83
+ np = None
84
+ SentenceTransformer = None
85
+
86
+
87
+ def embeddings_available() -> bool:
88
+ """Check if embedding dependencies are available."""
89
+ return EMBEDDINGS_AVAILABLE
90
+
91
+
92
+ def _load_sentence_transformer(
93
+ model_name: str,
94
+ revision: str | None = None,
95
+ trust_remote_code: bool | None = None,
96
+ ):
97
+ """Construct a SentenceTransformer with explicit trust/revision semantics.
98
+
99
+ The default model (Jina code embeddings) requires remote-code loading; its
100
+ revision is pinned for supply-chain integrity. User-supplied models (from
101
+ `.descry.toml` [embeddings] model) default to trust_remote_code=False.
102
+
103
+ Args:
104
+ model_name: Model repo id or local path.
105
+ revision: Pinned git sha for HF downloads; defaults to the pinned
106
+ DEFAULT_MODEL_REVISION when model_name matches MODEL_NAME.
107
+ trust_remote_code: Whether to allow model-provided Python code.
108
+ Defaults to True only for the bundled default model; False for
109
+ all user-supplied models.
110
+ """
111
+ if trust_remote_code is None:
112
+ trust_remote_code = model_name == SemanticSearcher.MODEL_NAME
113
+ if revision is None and model_name == SemanticSearcher.MODEL_NAME:
114
+ revision = SemanticSearcher.DEFAULT_MODEL_REVISION
115
+ kwargs: dict = {"trust_remote_code": trust_remote_code}
116
+ if revision:
117
+ kwargs["revision"] = revision
118
+ return SentenceTransformer(model_name, **kwargs)
119
+
120
+
121
+ class SemanticSearcher:
122
+ """Semantic search using sentence embeddings.
123
+
124
+ Generates embeddings for node names and docstrings, then uses
125
+ cosine similarity for semantic search.
126
+ """
127
+
128
+ # Code-optimized embedding model (896-dim, 494M params)
129
+ # Significantly better code search quality than general-purpose models.
130
+ # This model requires `trust_remote_code=True`; revision is pinned for
131
+ # supply-chain integrity (A.3 Option B).
132
+ MODEL_NAME = "jinaai/jina-code-embeddings-0.5b"
133
+ # Pinned HF revision (git sha) for the default model. Update intentionally
134
+ # when upgrading; cache auto-invalidates via model-name hash in cache key.
135
+ DEFAULT_MODEL_REVISION = "4db235132dafbe56a8b9c5f59b59795ecf58a4a7"
136
+
137
+ def __init__(
138
+ self,
139
+ graph_path: str,
140
+ cache_dir: Optional[str] = None,
141
+ force_rebuild: bool = False,
142
+ model_name: str | None = None,
143
+ ):
144
+ """Initialize the semantic searcher.
145
+
146
+ Args:
147
+ graph_path: Path to codebase_graph.json
148
+ cache_dir: Optional directory for caching embeddings
149
+ force_rebuild: Force regeneration of embeddings even if cache exists
150
+ """
151
+ self.model_name = model_name or self.MODEL_NAME
152
+
153
+ if not EMBEDDINGS_AVAILABLE:
154
+ raise ImportError(
155
+ "Embeddings require sentence-transformers and numpy. "
156
+ "Install with: pip install descry-codegraph[embeddings]"
157
+ )
158
+
159
+ # Resolve to absolute path to avoid nested directory issues when CWD is inside cache
160
+ self.graph_path = Path(graph_path).resolve()
161
+ if cache_dir:
162
+ self.cache_dir = Path(cache_dir).resolve()
163
+ elif self.graph_path.parent.name == ".descry_cache":
164
+ # Graph is already in cache dir, use it directly
165
+ self.cache_dir = self.graph_path.parent
166
+ else:
167
+ self.cache_dir = self.graph_path.parent / ".descry_cache"
168
+
169
+ # Load graph (B.6: schema-checked)
170
+ from descry._graph import load_graph_with_schema
171
+
172
+ self.data = load_graph_with_schema(self.graph_path)
173
+ self.nodes = self.data["nodes"]
174
+
175
+ # Load or create embeddings
176
+ self.model = None
177
+ self.embeddings = None
178
+ self.node_texts = None
179
+ self._load_or_create_embeddings(force_rebuild=force_rebuild)
180
+
181
+ def _cache_key(self) -> str:
182
+ """Compute content-addressed cache key.
183
+
184
+ Composition: int(mtime) + sha256[:16] of graph bytes + sha256[:8] of
185
+ model name. Model-hash component ensures A.3 model swap auto-invalidates
186
+ old caches.
187
+ """
188
+ graph_mtime = int(self.graph_path.stat().st_mtime)
189
+ graph_hash = hashlib.sha256(self.graph_path.read_bytes()).hexdigest()[:16]
190
+ model_hash = hashlib.sha256(self.model_name.encode("utf-8")).hexdigest()[:8]
191
+ return f"{graph_mtime}_{graph_hash}_{model_hash}"
192
+
193
+ def _cache_paths(self) -> tuple[Path, Path]:
194
+ """Return (npz_path, json_sidecar_path) for the current cache key."""
195
+ key = self._cache_key()
196
+ return (
197
+ self.cache_dir / f"embeddings_{key}.npz",
198
+ self.cache_dir / f"embeddings_{key}.json",
199
+ )
200
+
201
+ def _cleanup_old_embeddings(self, keep: set[Path]):
202
+ """Remove embedding cache files not in the keep set."""
203
+ try:
204
+ old_files = [
205
+ f for f in self.cache_dir.glob("embeddings_*.npz") if f not in keep
206
+ ] + [f for f in self.cache_dir.glob("embeddings_*.json") if f not in keep]
207
+ for old_file in old_files:
208
+ logger.info(f"Removing stale embedding cache: {old_file.name}")
209
+ old_file.unlink()
210
+ if old_files:
211
+ logger.info(f"Cleaned up {len(old_files)} old embedding file(s)")
212
+ except Exception as e:
213
+ logger.warning(f"Failed to cleanup old embeddings: {e}")
214
+
215
+ def _atomic_save(self, npz_path: Path, json_path: Path) -> None:
216
+ """Write .npz + JSON sidecar atomically (tmp then rename).
217
+
218
+ Note: np.savez appends '.npz' to paths that don't end in '.npz'.
219
+ We write to '<final>.tmp.npz' (which numpy preserves because the
220
+ suffix is already .npz) and then rename to the final path.
221
+ """
222
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
223
+ npz_tmp = npz_path.with_suffix(".tmp.npz")
224
+ json_tmp = json_path.with_suffix(".tmp.json")
225
+ cleanup = [npz_tmp, json_tmp]
226
+ try:
227
+ np.savez(npz_tmp, embeddings=self.embeddings)
228
+ with open(json_tmp, "w") as f:
229
+ json.dump({"texts": self.node_texts}, f)
230
+ os.replace(npz_tmp, npz_path)
231
+ os.replace(json_tmp, json_path)
232
+ finally:
233
+ for p in cleanup:
234
+ if p.exists():
235
+ try:
236
+ p.unlink()
237
+ except OSError:
238
+ pass
239
+
240
+ def _load_or_create_embeddings(self, force_rebuild: bool = False):
241
+ """Load embeddings from cache or create new ones.
242
+
243
+ Serialized via a cache-dir lockfile so concurrent processes (e.g.
244
+ descry-mcp + descry-web both starting on a cold cache) don't each
245
+ load the model and write to the same files.
246
+ """
247
+ npz_path, json_path = self._cache_paths()
248
+
249
+ # Fast path: cache is hot, no lock needed.
250
+ if (
251
+ not force_rebuild
252
+ and npz_path.exists()
253
+ and json_path.exists()
254
+ and self._try_load_cache(npz_path, json_path)
255
+ ):
256
+ return
257
+
258
+ # Cold path: acquire a cross-process lock, recheck, then generate.
259
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
260
+ lock_path = self.cache_dir / "embeddings.lock"
261
+ with _file_lock(lock_path):
262
+ if (
263
+ not force_rebuild
264
+ and npz_path.exists()
265
+ and json_path.exists()
266
+ and self._try_load_cache(npz_path, json_path)
267
+ ):
268
+ return
269
+
270
+ self._generate_embeddings()
271
+ self._atomic_save(npz_path, json_path)
272
+ logger.info(f"Cached {len(self.node_texts)} embeddings to {npz_path}")
273
+ self._cleanup_old_embeddings(keep={npz_path, json_path})
274
+
275
+ def _try_load_cache(self, npz_path: Path, json_path: Path) -> bool:
276
+ """Attempt to load embeddings from cache. Returns True on success."""
277
+ try:
278
+ data = np.load(npz_path, allow_pickle=False)
279
+ embeddings = data["embeddings"]
280
+ with open(json_path) as f:
281
+ sidecar = json.load(f)
282
+ texts = sidecar["texts"]
283
+ # Consistency check: an interrupted _atomic_save (or two racing
284
+ # writers) could leave a mismatched pair on disk. Refuse to load
285
+ # it — the cache will regenerate and overwrite with a consistent
286
+ # pair. Cheaper than silently serving wrong embeddings.
287
+ if len(texts) != embeddings.shape[0]:
288
+ logger.warning(
289
+ "Embedding cache mismatch (%d texts vs %d embeddings); regenerating",
290
+ len(texts),
291
+ embeddings.shape[0],
292
+ )
293
+ return False
294
+ self.embeddings = embeddings
295
+ self.node_texts = texts
296
+ logger.info(f"Loaded {len(self.node_texts)} embeddings from cache")
297
+ self._cleanup_old_embeddings(keep={npz_path, json_path})
298
+ return True
299
+ except Exception as e:
300
+ logger.warning(f"Cache load failed: {e}, regenerating...")
301
+ return False
302
+
303
+ def _generate_embeddings(self):
304
+ """Generate weighted composite embeddings for all nodes.
305
+
306
+ Uses separate embeddings for name, signature, and docstring with
307
+ weighted combination. This improves semantic search by:
308
+ - Prioritizing docstring content for meaning-based queries
309
+ - Still matching on symbol names and signatures
310
+ - Including caller context for disambiguation
311
+
312
+ Weights: name=0.2, signature=0.3, docstring=0.5
313
+ """
314
+ logger.info("Loading embedding model...")
315
+ self.model = _load_sentence_transformer(self.model_name)
316
+
317
+ # Collect texts for each component
318
+ names = []
319
+ signatures = []
320
+ docstrings = []
321
+ self.node_texts = [] # Combined text for cache identification
322
+
323
+ for node in self.nodes:
324
+ meta = node.get("metadata", {})
325
+
326
+ name = meta.get("name", "") or node["id"].split("::")[-1]
327
+ sig = meta.get("signature", "")
328
+ doc = meta.get("docstring", "")[:500]
329
+
330
+ names.append(name)
331
+ signatures.append(sig if sig else name) # Fall back to name if no signature
332
+ docstrings.append(doc if doc else name) # Fall back to name if no docstring
333
+
334
+ # Combined text for cache identification
335
+ combined = f"{name} {sig} {doc}".strip()
336
+ self.node_texts.append(combined if combined else node["id"])
337
+
338
+ logger.info(
339
+ f"Generating weighted embeddings for {len(self.node_texts)} nodes..."
340
+ )
341
+
342
+ # Generate embeddings for each component
343
+ # batch_size keeps memory usage reasonable
344
+ logger.info(" Encoding names...")
345
+ name_embeddings = self.model.encode(
346
+ names, show_progress_bar=False, convert_to_numpy=True
347
+ )
348
+
349
+ logger.info(" Encoding signatures...")
350
+ sig_embeddings = self.model.encode(
351
+ signatures, show_progress_bar=False, convert_to_numpy=True
352
+ )
353
+
354
+ logger.info(" Encoding docstrings...")
355
+ doc_embeddings = self.model.encode(
356
+ docstrings, show_progress_bar=False, convert_to_numpy=True
357
+ )
358
+
359
+ # Weighted combination: name=0.2, signature=0.3, docstring=0.5
360
+ # Docstring gets highest weight for semantic/meaning-based queries
361
+ logger.info(" Computing weighted composites...")
362
+ self.embeddings = (
363
+ 0.2 * name_embeddings + 0.3 * sig_embeddings + 0.5 * doc_embeddings
364
+ )
365
+
366
+ # Normalize the combined embeddings for consistent cosine similarity
367
+ norms = np.linalg.norm(self.embeddings, axis=1, keepdims=True)
368
+ norms = np.where(norms == 0, 1, norms) # Avoid division by zero
369
+ self.embeddings = self.embeddings / norms
370
+
371
+ logger.info("Weighted embeddings generated successfully")
372
+
373
+ def search(self, query: str, limit: int = 10, min_score: float = 0.3) -> list:
374
+ """Search for nodes semantically similar to the query.
375
+
376
+ Args:
377
+ query: Natural language search query
378
+ limit: Maximum number of results
379
+ min_score: Minimum cosine similarity score (0-1)
380
+
381
+ Returns:
382
+ List of (node, score) tuples sorted by relevance
383
+
384
+ Scoring includes:
385
+ - Cosine similarity (primary)
386
+ - In-degree boost (symbols with more callers ranked higher)
387
+ - Type preference (Functions/Classes over Constants)
388
+ """
389
+ import math
390
+
391
+ if self.model is None:
392
+ self.model = _load_sentence_transformer(self.model_name)
393
+
394
+ # Encode query
395
+ query_embedding = self.model.encode([query], convert_to_numpy=True)[0]
396
+
397
+ # Compute cosine similarities
398
+ similarities = np.dot(self.embeddings, query_embedding) / (
399
+ np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(query_embedding)
400
+ )
401
+
402
+ # Get candidates above threshold
403
+ top_indices = np.argsort(similarities)[::-1]
404
+ candidates = []
405
+ for idx in top_indices:
406
+ base_score = float(similarities[idx])
407
+ if base_score < min_score:
408
+ break
409
+ # Collect more candidates than needed for re-ranking
410
+ if len(candidates) >= limit * 3:
411
+ break
412
+ candidates.append((idx, base_score))
413
+
414
+ # Re-rank with in-degree boost and type preference
415
+ results = []
416
+ for idx, base_score in candidates:
417
+ node = self.nodes[idx]
418
+ meta = node.get("metadata", {})
419
+
420
+ # In-degree boost: log scaling to prevent domination by very popular symbols
421
+ in_degree = meta.get("in_degree", 0)
422
+ in_degree_boost = 0.0
423
+ if in_degree > 0:
424
+ # Boost ranges from 0 to ~0.15 for nodes with 1-100+ callers
425
+ in_degree_boost = min(0.15, math.log(1 + in_degree) * 0.03)
426
+
427
+ # Type preference: small tie-breaker
428
+ node_type = node.get("type", "")
429
+ type_boost = {
430
+ "Function": 0.02,
431
+ "Method": 0.02,
432
+ "Class": 0.015,
433
+ "Constant": 0.005,
434
+ "File": 0.0,
435
+ }.get(node_type, 0.01)
436
+
437
+ final_score = base_score + in_degree_boost + type_boost
438
+ results.append((node, final_score))
439
+
440
+ # Sort by final score and return top results
441
+ results.sort(key=lambda x: x[1], reverse=True)
442
+ return results[:limit]
443
+
444
+ def _find_similar(self, node_id: str, limit: int = 5) -> list:
445
+ """Find nodes semantically similar to a given node.
446
+
447
+ Args:
448
+ node_id: The node ID to find similar nodes for
449
+ limit: Maximum number of results
450
+
451
+ Returns:
452
+ List of (node, score) tuples sorted by similarity
453
+ """
454
+ # Find the node index
455
+ node_idx = None
456
+ for i, node in enumerate(self.nodes):
457
+ if node["id"] == node_id:
458
+ node_idx = i
459
+ break
460
+
461
+ if node_idx is None:
462
+ return []
463
+
464
+ # Use that node's embedding as query
465
+ node_embedding = self.embeddings[node_idx]
466
+
467
+ # Compute similarities
468
+ similarities = np.dot(self.embeddings, node_embedding) / (
469
+ np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(node_embedding)
470
+ )
471
+
472
+ # Get top results (excluding the query node itself)
473
+ top_indices = np.argsort(similarities)[::-1]
474
+ results = []
475
+ for idx in top_indices:
476
+ if idx == node_idx:
477
+ continue
478
+ score = float(similarities[idx])
479
+ results.append((self.nodes[idx], score))
480
+ if len(results) >= limit:
481
+ break
482
+
483
+ return results
484
+
485
+
486
+ # Convenience function for one-off searches (with thread safety)
487
+ _cached_searcher = None
488
+ _searcher_lock = threading.Lock()
489
+
490
+
491
+ def _semantic_search(
492
+ query: str,
493
+ graph_path: str = ".descry_cache/codebase_graph.json",
494
+ limit: int = 10,
495
+ ) -> list:
496
+ """Convenience function for semantic search (thread-safe).
497
+
498
+ Args:
499
+ query: Natural language search query
500
+ graph_path: Path to graph file
501
+ limit: Maximum results
502
+
503
+ Returns:
504
+ List of (node, score) tuples
505
+ """
506
+ global _cached_searcher
507
+
508
+ if not EMBEDDINGS_AVAILABLE:
509
+ logger.warning("Embeddings not available, falling back to keyword search")
510
+ return []
511
+
512
+ with _searcher_lock:
513
+ if _cached_searcher is None or str(_cached_searcher.graph_path) != graph_path:
514
+ _cached_searcher = SemanticSearcher(graph_path)
515
+ searcher = _cached_searcher
516
+
517
+ return searcher.search(query, limit=limit)
518
+
519
+
520
+ def get_embeddings_status(
521
+ graph_path: str = ".descry_cache/codebase_graph.json",
522
+ ) -> dict:
523
+ """Get embeddings status for diagnostics.
524
+
525
+ Reports whether a cache exists for the current graph and counts node
526
+ texts via the JSON sidecar (no np.load of user-controlled .npz).
527
+ """
528
+ graph_path = Path(graph_path).resolve()
529
+ cache_dir = graph_path.parent
530
+
531
+ status = {
532
+ "available": EMBEDDINGS_AVAILABLE,
533
+ "cached": False,
534
+ "node_count": 0,
535
+ "stale": False,
536
+ "cache_path": None,
537
+ }
538
+
539
+ if not EMBEDDINGS_AVAILABLE or not graph_path.exists():
540
+ return status
541
+
542
+ # Any embedding file (npz + matching json sidecar) counts; report the
543
+ # most recent pair. No np.load — just read the JSON sidecar for count.
544
+ npz_files = sorted(
545
+ cache_dir.glob("embeddings_*.npz"),
546
+ key=lambda p: p.stat().st_mtime,
547
+ reverse=True,
548
+ )
549
+ for npz_file in npz_files:
550
+ sidecar = npz_file.with_suffix(".json")
551
+ if sidecar.exists():
552
+ status["cached"] = True
553
+ status["cache_path"] = str(npz_file)
554
+ try:
555
+ with open(sidecar) as f:
556
+ texts = json.load(f).get("texts", [])
557
+ status["node_count"] = len(texts)
558
+ except Exception:
559
+ pass
560
+ return status
561
+
562
+ # Fallback: orphan npz (no sidecar) is stale
563
+ if npz_files:
564
+ status["stale"] = True
565
+ status["cache_path"] = str(npz_files[0])
566
+
567
+ return status
568
+
569
+
570
+ if __name__ == "__main__":
571
+ # Test the embeddings module
572
+ import sys
573
+
574
+ if not EMBEDDINGS_AVAILABLE:
575
+ print("Embeddings not available. Install with:")
576
+ print(" pip install descry-codegraph[embeddings]")
577
+ sys.exit(1)
578
+
579
+ if len(sys.argv) < 2:
580
+ print("Usage: python embeddings.py <query>")
581
+ sys.exit(1)
582
+
583
+ query = " ".join(sys.argv[1:])
584
+ results = _semantic_search(query)
585
+
586
+ print(f"\nSemantic search results for: '{query}'")
587
+ print("-" * 50)
588
+ for node, score in results:
589
+ meta = node.get("metadata", {})
590
+ print(f"[{score:.3f}] [{node['type'][:3]}] {meta.get('name', node['id'])}")
591
+ if meta.get("docstring"):
592
+ doc = meta["docstring"].split("\n")[0][:60]
593
+ print(f" {doc}...")