vortexa 0.3.2__tar.gz → 0.3.4__tar.gz
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.
- {vortexa-0.3.2 → vortexa-0.3.4}/PKG-INFO +1 -1
- {vortexa-0.3.2 → vortexa-0.3.4}/pyproject.toml +1 -1
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/graph.py +19 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/indexer.py +282 -1
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/types.py +9 -0
- vortexa-0.3.4/src/vortexa/interfaces/cli.py +670 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa.egg-info/PKG-INFO +1 -1
- vortexa-0.3.2/src/vortexa/interfaces/cli.py +0 -307
- {vortexa-0.3.2 → vortexa-0.3.4}/LICENSE +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/README.md +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/setup.cfg +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/__init__.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/__init__.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/chunking.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/context_engine.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/embedding.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/inference.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/language.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/lf4_v4_model.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/v4_embedder.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/core/vortex_score.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/interfaces/__init__.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/interfaces/mcp_server.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/interfaces/watcher.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/search/__init__.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/search/ranking.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/search/search.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/search/tokens.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/storage/__init__.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/storage/bm25.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/storage/vector_store.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa/storage/walker.py +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa.egg-info/SOURCES.txt +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa.egg-info/dependency_links.txt +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa.egg-info/entry_points.txt +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa.egg-info/requires.txt +0 -0
- {vortexa-0.3.2 → vortexa-0.3.4}/src/vortexa.egg-info/top_level.txt +0 -0
|
@@ -92,6 +92,24 @@ class RepoGraph:
|
|
|
92
92
|
return [e.dst if direction == "out" else e.src for e in edges if e.kind == kind]
|
|
93
93
|
return [e.dst if direction == "out" else e.src for e in edges]
|
|
94
94
|
|
|
95
|
+
def edges_from(self, node_id: str, kind: Optional[str] = None) -> List[GraphEdge]:
|
|
96
|
+
return [e for e in self._out.get(node_id, []) if kind is None or e.kind == kind]
|
|
97
|
+
|
|
98
|
+
def edges_to(self, node_id: str, kind: Optional[str] = None) -> List[GraphEdge]:
|
|
99
|
+
return [e for e in self._in.get(node_id, []) if kind is None or e.kind == kind]
|
|
100
|
+
|
|
101
|
+
def find_file_node(self, file_path: str) -> Optional[GraphNode]:
|
|
102
|
+
for node in self.nodes.values():
|
|
103
|
+
if node.kind == "file" and node.path == file_path:
|
|
104
|
+
return node
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
def find_nodes_in_file(self, file_path: str) -> List[GraphNode]:
|
|
108
|
+
return [self.nodes[nid] for nid in self._file_symbols.get(file_path, set()) if nid in self.nodes]
|
|
109
|
+
|
|
110
|
+
def find_nodes_by_name(self, name: str) -> List[GraphNode]:
|
|
111
|
+
return [self.nodes[nid] for nid in self._name_index.get(name, set()) if nid in self.nodes]
|
|
112
|
+
|
|
95
113
|
def expand(self, seed_ids: List[str], max_hops: int = 2, max_size: int = 100) -> List[Tuple[str, int]]:
|
|
96
114
|
"""BFS from seed nodes, return (node_id, hop_count) pairs."""
|
|
97
115
|
visited: Set[str] = set()
|
|
@@ -238,6 +256,7 @@ class RepoGraphBuilder:
|
|
|
238
256
|
candidates = self.graph.resolve_name(name)
|
|
239
257
|
for cid in candidates[:3]: # limit candidates
|
|
240
258
|
self.graph.add_edge(file_id, cid, "REFERENCES", weight=0.3)
|
|
259
|
+
self.graph.add_edge(file_id, cid, "CALLS", weight=0.3)
|
|
241
260
|
|
|
242
261
|
def build(self, files: Dict[str, str]) -> RepoGraph:
|
|
243
262
|
"""Build the graph from a {path: content} dict."""
|
|
@@ -20,14 +20,17 @@ from typing import cast
|
|
|
20
20
|
|
|
21
21
|
import lmdb
|
|
22
22
|
import numpy as np
|
|
23
|
+
|
|
23
24
|
from vortexa.core.chunking import chunk_source
|
|
24
25
|
from vortexa.core.embedding import Embedder
|
|
26
|
+
from vortexa.core.graph import RepoGraph
|
|
25
27
|
from vortexa.core.language import detect_language, get_extensions
|
|
26
28
|
from vortexa.core.types import (
|
|
27
29
|
Chunk,
|
|
28
30
|
ChunkConfig,
|
|
29
31
|
Encoder,
|
|
30
32
|
IndexStats,
|
|
33
|
+
SearchMode,
|
|
31
34
|
SearchResult,
|
|
32
35
|
)
|
|
33
36
|
from vortexa.search.search import search as _search
|
|
@@ -110,7 +113,7 @@ class CodebaseIndexer:
|
|
|
110
113
|
self._model = model
|
|
111
114
|
else:
|
|
112
115
|
from vortexa.core.v4_embedder import VortexEmbedderV4
|
|
113
|
-
self._embedder = VortexEmbedderV4(model_id)
|
|
116
|
+
self._embedder = VortexEmbedderV4(model_id or "VTXAI/vtx-embed-7M")
|
|
114
117
|
self._model = self._embedder
|
|
115
118
|
|
|
116
119
|
# In-memory state
|
|
@@ -120,6 +123,7 @@ class CodebaseIndexer:
|
|
|
120
123
|
self.chunk_memo: dict[str, str] = {} # chunk_id -> chunk_hash (for memoization)
|
|
121
124
|
self._vector_store: VectorStore | None = None
|
|
122
125
|
self._bm25_index: BM25Index | None = None
|
|
126
|
+
self._repo_graph: RepoGraph | None = None
|
|
123
127
|
|
|
124
128
|
# Stats
|
|
125
129
|
self._memo_hits = 0
|
|
@@ -303,6 +307,283 @@ class CodebaseIndexer:
|
|
|
303
307
|
alpha=alpha,
|
|
304
308
|
)
|
|
305
309
|
|
|
310
|
+
# ── Context resolution ──────────────────────────────────────────────
|
|
311
|
+
|
|
312
|
+
def _build_repo_graph(self) -> RepoGraph:
|
|
313
|
+
"""Build a repo graph from indexed Python files."""
|
|
314
|
+
from vortexa.core.graph import RepoGraphBuilder
|
|
315
|
+
builder = RepoGraphBuilder()
|
|
316
|
+
files: dict[str, str] = {}
|
|
317
|
+
for rel in self.file_hashes:
|
|
318
|
+
file_path = self.root / rel
|
|
319
|
+
if file_path.suffix == ".py":
|
|
320
|
+
try:
|
|
321
|
+
files[rel] = file_path.read_text(encoding="utf-8", errors="replace")
|
|
322
|
+
except OSError:
|
|
323
|
+
pass
|
|
324
|
+
return builder.build(files)
|
|
325
|
+
|
|
326
|
+
def _find_test_files(self, primary_files: set[str]) -> list[str]:
|
|
327
|
+
test_files: list[str] = []
|
|
328
|
+
for file_path in primary_files:
|
|
329
|
+
path = Path(file_path)
|
|
330
|
+
stem = path.stem
|
|
331
|
+
suffix = path.suffix
|
|
332
|
+
parent = path.parent
|
|
333
|
+
candidates = [
|
|
334
|
+
parent / f"test_{stem}{suffix}",
|
|
335
|
+
parent / f"{stem}_test{suffix}",
|
|
336
|
+
]
|
|
337
|
+
for candidate in candidates:
|
|
338
|
+
candidate_str = str(candidate)
|
|
339
|
+
if candidate_str != file_path and candidate_str not in test_files:
|
|
340
|
+
if candidate_str in self.file_hashes:
|
|
341
|
+
test_files.append(candidate_str)
|
|
342
|
+
return test_files
|
|
343
|
+
|
|
344
|
+
def _find_imports_importers(
|
|
345
|
+
self, primary_files: set[str], graph: RepoGraph
|
|
346
|
+
) -> tuple[list[str], list[str]]:
|
|
347
|
+
imports: list[str] = []
|
|
348
|
+
imported_by: list[str] = []
|
|
349
|
+
for file_path in primary_files:
|
|
350
|
+
file_node = graph.find_file_node(file_path)
|
|
351
|
+
if file_node is None:
|
|
352
|
+
continue
|
|
353
|
+
for edge in graph.edges_from(file_node.id, kind="IMPORTS"):
|
|
354
|
+
target = graph.nodes.get(edge.dst)
|
|
355
|
+
if target and target.path and target.path not in primary_files:
|
|
356
|
+
if target.path not in imports:
|
|
357
|
+
imports.append(target.path)
|
|
358
|
+
for edge in graph.edges_from(file_node.id, kind="IMPORTS_FROM"):
|
|
359
|
+
target = graph.nodes.get(edge.dst)
|
|
360
|
+
if target and target.path and target.path not in primary_files:
|
|
361
|
+
if target.path not in imports:
|
|
362
|
+
imports.append(target.path)
|
|
363
|
+
for edge in graph.edges_to(file_node.id, kind="IMPORTS"):
|
|
364
|
+
source = graph.nodes.get(edge.src)
|
|
365
|
+
if source and source.path and source.path not in primary_files:
|
|
366
|
+
if source.path not in imported_by:
|
|
367
|
+
imported_by.append(source.path)
|
|
368
|
+
for edge in graph.edges_to(file_node.id, kind="IMPORTS_FROM"):
|
|
369
|
+
source = graph.nodes.get(edge.src)
|
|
370
|
+
if source and source.path and source.path not in primary_files:
|
|
371
|
+
if source.path not in imported_by:
|
|
372
|
+
imported_by.append(source.path)
|
|
373
|
+
return imports, imported_by
|
|
374
|
+
|
|
375
|
+
def _find_symbols(self, primary_files: set[str], graph: RepoGraph) -> list[dict]:
|
|
376
|
+
symbols: list[dict] = []
|
|
377
|
+
seen: set[str] = set()
|
|
378
|
+
skip_kinds = {"file"}
|
|
379
|
+
for file_path in primary_files:
|
|
380
|
+
for node_id in graph._file_symbols.get(file_path, set()):
|
|
381
|
+
if node_id in seen:
|
|
382
|
+
continue
|
|
383
|
+
seen.add(node_id)
|
|
384
|
+
node = graph.nodes.get(node_id)
|
|
385
|
+
if not node or node.kind in skip_kinds:
|
|
386
|
+
continue
|
|
387
|
+
symbols.append({
|
|
388
|
+
"name": node.name,
|
|
389
|
+
"kind": node.kind,
|
|
390
|
+
"file": node.path,
|
|
391
|
+
"line": node.line,
|
|
392
|
+
})
|
|
393
|
+
return symbols[:15]
|
|
394
|
+
|
|
395
|
+
def _find_callers_callees(
|
|
396
|
+
self, primary_files: set[str], graph: RepoGraph
|
|
397
|
+
) -> tuple[list[dict], list[dict]]:
|
|
398
|
+
callers: list[dict] = []
|
|
399
|
+
callees: list[dict] = []
|
|
400
|
+
for file_path in primary_files:
|
|
401
|
+
for node_id in graph._file_symbols.get(file_path, set()):
|
|
402
|
+
node = graph.nodes.get(node_id)
|
|
403
|
+
if not node:
|
|
404
|
+
continue
|
|
405
|
+
for edge in graph.edges_from(node_id, kind="CALLS"):
|
|
406
|
+
target = graph.nodes.get(edge.dst)
|
|
407
|
+
if target and target.path != file_path:
|
|
408
|
+
callees.append({
|
|
409
|
+
"name": target.name,
|
|
410
|
+
"file": target.path,
|
|
411
|
+
"line": target.line,
|
|
412
|
+
})
|
|
413
|
+
break
|
|
414
|
+
for edge in graph.edges_to(node_id, kind="CALLS"):
|
|
415
|
+
source = graph.nodes.get(edge.src)
|
|
416
|
+
if source and source.path != file_path:
|
|
417
|
+
callers.append({
|
|
418
|
+
"name": source.name,
|
|
419
|
+
"file": source.path,
|
|
420
|
+
"line": source.line,
|
|
421
|
+
})
|
|
422
|
+
break
|
|
423
|
+
return callers[:10], callees[:10]
|
|
424
|
+
|
|
425
|
+
def _find_dependency_chain(
|
|
426
|
+
self, primary_files: set[str], graph: RepoGraph, depth: int = 1
|
|
427
|
+
) -> list[str]:
|
|
428
|
+
chain: list[str] = []
|
|
429
|
+
visited: set[str] = set(primary_files)
|
|
430
|
+
for file_path in primary_files:
|
|
431
|
+
file_node = graph.find_file_node(file_path)
|
|
432
|
+
if file_node is None:
|
|
433
|
+
continue
|
|
434
|
+
expanded = graph.expand([file_node.id], max_hops=depth, max_size=50)
|
|
435
|
+
for nid, hop in expanded:
|
|
436
|
+
if hop == 0:
|
|
437
|
+
continue
|
|
438
|
+
node = graph.nodes.get(nid)
|
|
439
|
+
if node and node.path and node.path not in visited:
|
|
440
|
+
visited.add(node.path)
|
|
441
|
+
chain.append(node.path)
|
|
442
|
+
return chain[:10]
|
|
443
|
+
|
|
444
|
+
def _expand_context(
|
|
445
|
+
self, query: str, primary: list[SearchResult], graph: RepoGraph
|
|
446
|
+
) -> dict:
|
|
447
|
+
primary_files = {r.chunk.file_path for r in primary}
|
|
448
|
+
test_files = self._find_test_files(primary_files)
|
|
449
|
+
imports, imported_by = self._find_imports_importers(primary_files, graph)
|
|
450
|
+
symbols = self._find_symbols(primary_files, graph)
|
|
451
|
+
callers, callees = self._find_callers_callees(primary_files, graph)
|
|
452
|
+
dependency_chain = self._find_dependency_chain(primary_files, graph, depth=1)
|
|
453
|
+
|
|
454
|
+
scores = [r.score for r in primary if r.score > 0]
|
|
455
|
+
confidence = sum(scores) / len(scores) if scores else 0.0
|
|
456
|
+
|
|
457
|
+
related_files = list(
|
|
458
|
+
primary_files | set(imports) | set(imported_by) | set(test_files)
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
"query": query,
|
|
463
|
+
"confidence": round(confidence, 3),
|
|
464
|
+
"primary_chunks": primary,
|
|
465
|
+
"related_files": related_files,
|
|
466
|
+
"test_files": test_files,
|
|
467
|
+
"imports": imports,
|
|
468
|
+
"imported_by": imported_by,
|
|
469
|
+
"symbols": symbols,
|
|
470
|
+
"callers": callers,
|
|
471
|
+
"callees": callees,
|
|
472
|
+
"dependency_chain": dependency_chain,
|
|
473
|
+
"total_tokens": sum(len(r.chunk.content) for r in primary) // 4,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
def _compress_pack(self, pack: dict) -> dict:
|
|
477
|
+
if not pack.get("primary_chunks"):
|
|
478
|
+
return pack
|
|
479
|
+
pack = dict(pack)
|
|
480
|
+
pack["primary_chunks"] = pack["primary_chunks"][:10]
|
|
481
|
+
pack["related_files"] = pack["related_files"][:5]
|
|
482
|
+
pack["test_files"] = pack["test_files"][:3]
|
|
483
|
+
pack["imports"] = pack["imports"][:2]
|
|
484
|
+
pack["imported_by"] = pack["imported_by"][:1]
|
|
485
|
+
pack["symbols"] = pack["symbols"][:5]
|
|
486
|
+
pack["callers"] = pack["callers"][:2]
|
|
487
|
+
pack["callees"] = pack["callees"][:2]
|
|
488
|
+
pack["dependency_chain"] = pack["dependency_chain"][:3]
|
|
489
|
+
pack["total_tokens"] = sum(len(r.chunk.content) for r in pack["primary_chunks"]) // 4
|
|
490
|
+
return pack
|
|
491
|
+
|
|
492
|
+
def _empty_pack(self, query: str) -> dict:
|
|
493
|
+
return {
|
|
494
|
+
"query": query,
|
|
495
|
+
"confidence": 0.0,
|
|
496
|
+
"primary_chunks": [],
|
|
497
|
+
"related_files": [],
|
|
498
|
+
"test_files": [],
|
|
499
|
+
"imports": [],
|
|
500
|
+
"imported_by": [],
|
|
501
|
+
"symbols": [],
|
|
502
|
+
"callers": [],
|
|
503
|
+
"callees": [],
|
|
504
|
+
"dependency_chain": [],
|
|
505
|
+
"total_tokens": 0,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
def resolve(self, query: str, top_k: int = 5) -> dict:
|
|
509
|
+
"""Full context resolution: search + expand + compress."""
|
|
510
|
+
if not self.chunks:
|
|
511
|
+
return self._empty_pack(query)
|
|
512
|
+
primary = self.search(query, top_k=top_k)
|
|
513
|
+
if not primary:
|
|
514
|
+
return self._empty_pack(query)
|
|
515
|
+
graph = self._build_repo_graph()
|
|
516
|
+
pack = self._expand_context(query, primary, graph)
|
|
517
|
+
return self._compress_pack(pack)
|
|
518
|
+
|
|
519
|
+
def explain(self, code_location: str) -> dict:
|
|
520
|
+
"""Explain a code location: find symbol, expand context, return pack."""
|
|
521
|
+
query = code_location
|
|
522
|
+
if ":" in code_location:
|
|
523
|
+
parts = code_location.rsplit(":", 1)
|
|
524
|
+
if parts[0].endswith(
|
|
525
|
+
(".py", ".js", ".ts", ".rs", ".go", ".java", ".jsx", ".tsx")
|
|
526
|
+
):
|
|
527
|
+
file_path = parts[0]
|
|
528
|
+
try:
|
|
529
|
+
line = int(parts[1])
|
|
530
|
+
except ValueError:
|
|
531
|
+
line = 0
|
|
532
|
+
chunks_here = [c for c in self.chunks if c.file_path == file_path]
|
|
533
|
+
if line > 0:
|
|
534
|
+
chunks_here = [
|
|
535
|
+
c for c in chunks_here if c.start_line <= line <= c.end_line
|
|
536
|
+
]
|
|
537
|
+
if chunks_here:
|
|
538
|
+
primary = [
|
|
539
|
+
SearchResult(
|
|
540
|
+
chunk=chunks_here[0], score=1.0, source=SearchMode.SEMANTIC
|
|
541
|
+
)
|
|
542
|
+
]
|
|
543
|
+
graph = self._build_repo_graph()
|
|
544
|
+
pack = self._expand_context(query, primary, graph)
|
|
545
|
+
return self._compress_pack(pack)
|
|
546
|
+
return self.resolve(code_location, top_k=3)
|
|
547
|
+
|
|
548
|
+
def format_context(self, pack: dict) -> str:
|
|
549
|
+
"""Format a context pack as a human-readable string for agents."""
|
|
550
|
+
lines = [f"[{pack['confidence']:.2f}] {pack['query']}"]
|
|
551
|
+
if pack.get("primary_chunks"):
|
|
552
|
+
lines.append(f" ({len(pack['primary_chunks'])} files)")
|
|
553
|
+
lines.append("")
|
|
554
|
+
for i, result in enumerate(pack.get("primary_chunks", []), 1):
|
|
555
|
+
chunk = result.chunk
|
|
556
|
+
span = (
|
|
557
|
+
f"{chunk.start_line}-{chunk.end_line}"
|
|
558
|
+
if chunk.end_line != chunk.start_line
|
|
559
|
+
else str(chunk.start_line)
|
|
560
|
+
)
|
|
561
|
+
lines.append(f" {i}. {chunk.file_path}:{span} [{result.score:.2f}]")
|
|
562
|
+
snippet = chunk.content.strip()[:300]
|
|
563
|
+
for line in snippet.split("\n"):
|
|
564
|
+
lines.append(f" {line}")
|
|
565
|
+
if pack.get("symbols"):
|
|
566
|
+
names = [s["name"] for s in pack["symbols"][:6]]
|
|
567
|
+
if len(pack["symbols"]) > 6:
|
|
568
|
+
names.append("...")
|
|
569
|
+
lines.append(f" sym: {' '.join(names)}")
|
|
570
|
+
hints = []
|
|
571
|
+
if pack.get("test_files"):
|
|
572
|
+
hints.append(
|
|
573
|
+
f"tests: {', '.join(f.split('/')[-1] for f in pack['test_files'][:2])}"
|
|
574
|
+
)
|
|
575
|
+
if pack.get("imports"):
|
|
576
|
+
hints.append(
|
|
577
|
+
f"deps: {', '.join(f.split('/')[-1] for f in pack['imports'][:2])}"
|
|
578
|
+
)
|
|
579
|
+
if pack.get("callers"):
|
|
580
|
+
hints.append(
|
|
581
|
+
f"callers: {', '.join(c['name'] for c in pack['callers'][:2])}"
|
|
582
|
+
)
|
|
583
|
+
if hints:
|
|
584
|
+
lines.append(f" {' | '.join(hints)}")
|
|
585
|
+
return "\n".join(lines)
|
|
586
|
+
|
|
306
587
|
def find_related(self, chunk_idx: int, top_k: int = 5) -> list[SearchResult]:
|
|
307
588
|
"""Find chunks semantically similar to a given chunk (cocoindex find_related)."""
|
|
308
589
|
if chunk_idx < 0 or chunk_idx >= len(self.chunks):
|
|
@@ -96,3 +96,12 @@ class IndexStats:
|
|
|
96
96
|
memo_hits: int = 0 # Chunks skipped due to memoization
|
|
97
97
|
memo_misses: int = 0 # Chunks re-embedded
|
|
98
98
|
index_time_ms: float = 0 # Elapsed wall-clock time for the index run
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class GraphContext:
|
|
103
|
+
"""Compact structural context for one search-result file."""
|
|
104
|
+
|
|
105
|
+
key_symbol: str = ""
|
|
106
|
+
incoming: tuple[str, ...] = ()
|
|
107
|
+
outgoing: tuple[str, ...] = ()
|