Skip to content

Commit d9e40bf

Browse files
committed
perf(ranking): memoize prompt-independent _analyze_corpus (skips O(N^2) import-graph rebuild)
_analyze_corpus is prompt-independent but re-ran on every query, and its import-graph build is O(N^2) (per file, _resolve_import linear-scans all files per import). On a long-lived ranker (the MCP server) this dominated end-to-end latency on large trees (~84s warm on ~1100 files). Memoize the result keyed by each file's path + content hash, so repeated queries on an unchanged tree reuse it (byte-identical stats) and an edit invalidates it. Measured: _analyze_corpus warm 0.93s -> 0.0001s. (Cold/first build still pays the O(N^2); the algorithmic fix is a separate change.)
1 parent 29071c3 commit d9e40bf

2 files changed

Lines changed: 68 additions & 0 deletions

File tree

‎tenets/core/ranking/ranker.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,14 @@ def _analyze_corpus(
698698
Returns:
699699
Dictionary of corpus statistics
700700
"""
701+
# Memoize this prompt-independent analysis. A long-lived ranker (the MCP
702+
# server) re-runs _analyze_corpus on every query, and its import-graph build
703+
# is O(N^2); reuse the result whenever the file set + contents are unchanged.
704+
_sig = self._corpus_sig(files)
705+
_memo = getattr(self, "_corpus_memo", None)
706+
if _memo is not None and _memo[0] == _sig:
707+
return _memo[1]
708+
701709
self.logger.debug(f"Analyzing corpus of {len(files)} files")
702710

703711
stats = {
@@ -808,8 +816,26 @@ def _analyze_corpus(
808816
f"{stats['total_imports']} imports"
809817
)
810818

819+
self._corpus_memo = (_sig, stats)
811820
return stats
812821

822+
def _corpus_sig(self, files: List[FileAnalysis]) -> str:
823+
"""Signature of the (prompt-independent) corpus, for memoizing _analyze_corpus.
824+
825+
Keyed on each file's path + content hash (already computed by the analyzer),
826+
so an edited file invalidates the memo. Falls back to size when hash is absent.
827+
"""
828+
import hashlib
829+
830+
h = hashlib.md5()
831+
for f in sorted(files, key=lambda x: getattr(x, "path", "") or ""):
832+
h.update((getattr(f, "path", "") or "").encode("utf-8", "replace"))
833+
token = getattr(f, "hash", None)
834+
if not token:
835+
token = getattr(f, "size", None)
836+
h.update(str(token).encode("utf-8", "replace"))
837+
return h.hexdigest()
838+
813839
def _get_corpus_index(self):
814840
"""Lazily construct the persistent corpus index (None if disabled).
815841
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""_analyze_corpus is prompt-independent and O(N^2) (import graph) — memoize it so a
2+
long-lived ranker (the MCP server) doesn't recompute it on every query."""
3+
from unittest.mock import MagicMock, patch
4+
5+
from tenets.config import TenetsConfig
6+
from tenets.core.ranking.ranker import RelevanceRanker
7+
from tenets.models.analysis import FileAnalysis
8+
9+
10+
def _fa(path, content, h):
11+
return FileAnalysis(
12+
path=path, content=content, language="python", size=len(content), imports=[], hash=h
13+
)
14+
15+
16+
def _ranker():
17+
cfg = TenetsConfig()
18+
cfg.cache.index_enabled = False # isolate the memo from the disk corpus index
19+
return RelevanceRanker(cfg)
20+
21+
22+
def test_analyze_corpus_is_memoized_for_unchanged_files():
23+
r = _ranker()
24+
files = [_fa("a.py", "import os\nclass FileHandler: pass", "h1"),
25+
_fa("b.py", "def write_file(): pass", "h2")]
26+
pc = MagicMock()
27+
with patch.object(r, "_build_dependency_tree", wraps=r._build_dependency_tree) as spy:
28+
s1 = r._analyze_corpus(files, pc)
29+
s2 = r._analyze_corpus(files, pc)
30+
assert s2 is s1 # memoized: same stats object returned
31+
assert spy.call_count == 1 # heavy (O(N^2)) work ran exactly once
32+
33+
34+
def test_memo_invalidates_when_a_file_changes():
35+
r = _ranker()
36+
files = [_fa("a.py", "alpha", "h1"), _fa("b.py", "bravo", "h2")]
37+
pc = MagicMock()
38+
with patch.object(r, "_build_dependency_tree", wraps=r._build_dependency_tree) as spy:
39+
r._analyze_corpus(files, pc)
40+
changed = [_fa("a.py", "alpha changed", "h1-NEW"), files[1]]
41+
r._analyze_corpus(changed, pc)
42+
assert spy.call_count == 2 # content hash changed -> recompute, no stale memo

0 commit comments

Comments
 (0)