Files
nap-cli/tests/test_mcp_search_latency.py
T
HumbleTurtleandClaude Opus 5.5 0938b3026e Keep the local index fresh from the change feed and improve ranking
- Track a feed cursor in the local index: reindex takes the head cursor
  before listing and commits it with the snapshot; reads refresh from
  /changes (bounded, throttled) and apply creates, updates, archives,
  restores and hard deletes atomically with the cursor. Existing
  cursorless indexes bootstrap tracking on first read. Failures answer
  locally with explicit freshness warnings; 400 triggers a full
  reindex, 404 falls back to timestamp sync.
- Rank by a weighted score: exact identity (including aliases and
  paths), term coverage including body, intent, bm25, authority, a
  status penalty for superseded/completed records and mild recency.
- Return query-centred FTS5 snippets (never for E2E nodes).
- FTS schema v2: porter stemming, prefix index, title column, rowid
  deletes, two-character terms, domain words no longer stopwords;
  v1 indexes upgrade in place.
- Hyphenated alias/path literals match as adjacent-token phrases and
  rank as exact identity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:09:12 -03:00

543 lines
17 KiB
Python

import importlib.util
import pathlib
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import pytest
def load_module():
script_path = (
pathlib.Path(__file__).resolve().parents[1]
/ "resources"
/ "scripts"
/ "napseer_mcp_server.py"
)
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location(
"napseer_mcp_server_search_latency_test", script_path
)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def test_natural_language_discovery_uses_one_backend_search_request(tmp_path):
mod = load_module()
calls = []
mod.INDEX_PATH = tmp_path / "missing-index.sqlite"
mod.resolve_project_id = lambda _args: "project-1"
mod.project_encryption_state = lambda _project_id: "standard"
def request(method, path, payload=None, **_kwargs):
calls.append((method, path, payload))
return {
"items": [],
"next_cursor": None,
"query_analysis": {"mode": "terms"},
"search_strategy": "identity_fts",
"search_coverage": "identity_only",
"search_projection": "postgres_identity_v1",
}
mod.request_json = request
result = mod.discover_memory(
{
"q": "SIARA signer facturacion certificate API XML signature",
"limit": 20,
"view": "summary",
}
)
assert result["ok"] is True
assert len(calls) == 1
assert calls[0][0] == "GET"
assert "/nodes?" in calls[0][1]
assert result["search_strategy"] == "identity_fts"
assert result["search_coverage"] == "identity_only"
assert result["search_projection"] == "postgres_identity_v1"
def test_sparse_index_search_latency_does_not_scale_with_query_variants(tmp_path):
mod = load_module()
calls = []
mod.INDEX_PATH = tmp_path / "missing-index.sqlite"
mod.resolve_project_id = lambda _args: "project-1"
mod.project_encryption_state = lambda _project_id: "standard"
def request(_method, _path, _payload=None, **_kwargs):
calls.append(time.monotonic())
time.sleep(0.03)
return {"items": [], "next_cursor": None}
mod.request_json = request
started = time.monotonic()
mod.search_memory(
{
"q": "one two three four five six seven eight",
"limit": 20,
"view": "summary",
}
)
elapsed = time.monotonic() - started
assert len(calls) == 1
assert elapsed < 0.15
def test_complete_local_search_skips_remote_encryption_preflight_and_search(tmp_path):
mod = load_module()
mod.INDEX_PATH = tmp_path / "index.sqlite"
mod.INDEX_PATH.touch()
mod.resolve_project_id = lambda _args: "project-1"
mod.project_encryption_state = lambda _project_id: "unknown"
items = [
{
"id": f"node-{index}",
"project_id": "project-1",
"full_path": f"/notes/result-{index}",
"name": f"result-{index}",
"type": "note",
"tags": [],
"updated_at": "2026-08-22T00:00:00Z",
}
for index in range(5)
]
mod.local_index_diagnostics = lambda _project_id: {
"exists": True,
"graph_complete": True,
}
mod.search_local_index = lambda _args: {
"items": items,
"query": {"terms": ["result"]},
"search_strategies": [],
"index": {"exists": True, "graph_complete": True, "used": True},
}
def unexpected_request(*_args, **_kwargs):
raise AssertionError("complete local search must not make an HTTP request")
mod.request_json = unexpected_request
result = mod.search_memory({"q": "result", "limit": 5, "view": "summary"})
assert result["sources"] == ["local"]
assert result["search_strategy"] == "local_fts"
assert result["search_coverage"] == "identity_and_local_body"
assert result["search_projection"] == "sqlite_fts5_current"
assert len(result["items"]) == 5
assert all(item["node_id"].startswith("node-") for item in result["items"])
def indexed_node(path, *, node_type="note", tags=None, status="active", content=""):
name = path.rsplit("/", 1)[-1]
return {
"id": f"node-{path.strip('/').replace('/', '-')}",
"project_id": "project-1",
"full_path": path,
"folder_path": path.rsplit("/", 1)[0] or None,
"name": name,
"title": name,
"type": node_type,
"tags": tags or [],
"aliases": [],
"links": [],
"metadata": {"status": status},
"content_text": content,
"encryption_state": "legacy_plaintext",
"updated_at": "2026-08-22T00:00:00Z",
}
def feed_provider(calls):
"""Synthetic server: empty change feed and no newer nodes."""
def request(method, path, payload=None, **_kwargs):
calls.append((method, path))
if "/changes?" in path:
return {"items": [], "next_cursor": "c0", "has_more": False}
return {"items": [], "next_cursor": None}
return request
def configure_local_index(mod, tmp_path):
mod.AUTH_DIR = tmp_path
mod.INDEX_PATH = tmp_path / "index.sqlite"
mod.INDEX_LOCK_PATH = tmp_path / "index.lock"
mod.resolve_project_id = lambda _args: "project-1"
def test_single_pass_ranking_prefers_exact_canonical_identity(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
mod.index_node(
indexed_node(
"/rules/memory-source-policy",
node_type="rule",
tags=["memory", "policy"],
content="Canonical memory source rule.",
)
)
for index in range(12):
mod.index_node(
indexed_node(
f"/work/current/incidental-{index}",
content="memory source policy " * (index + 2),
)
)
result = mod.search_local_index({"q": "memory source policy", "limit": 5})
assert result["items"][0]["full_path"] == "/rules/memory-source-policy"
assert "exact_identity" in result["items"][0]["match_reason"]
assert "full_path" in result["items"][0]["matched_fields"]
assert result["search_strategies"] == [
{
"strategy": "single_pass_weighted_candidates",
"matched": 13,
"returned": 5,
"query_count": 1,
"candidate_limit": 30,
}
]
def test_complete_local_compact_browse_uses_keyset_cursor_without_http(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
for index in range(7):
item = indexed_node(
f"/plans/item-{index}",
node_type="plan",
tags=["performance"],
status="active",
)
item["updated_at"] = f"2026-08-2{index}T00:00:00Z"
mod.index_node(item)
mod.set_local_graph_index_complete("project-1", True)
calls = []
mod.TOKEN = "disposable-test-token" # synthetic login; requests go to feed_provider only
mod.request_json = feed_provider(calls)
# The first read starts change-feed tracking for this cursorless index.
mod.discover_memory({"folder_path": "/plans", "limit": 1, "view": "paths"})
assert [path.split("?")[0].rsplit("/", 1)[-1] for _method, path in calls] == ["changes", "nodes"]
calls.clear()
first = mod.discover_memory(
{
"folder_path": "/plans",
"tag": "performance",
"limit": 3,
"view": "paths",
}
)
second = mod.discover_memory(
{
"folder_path": "/plans",
"tag": "performance",
"limit": 3,
"view": "paths",
"cursor": first["next_cursor"],
}
)
assert first["source"] == "local_index"
assert first["search_strategy"] == "local_snapshot_browse"
assert first["search_projection"] == "sqlite_compact_identity_v1"
assert first["freshness"]["graph_complete"] is True
assert first["next_cursor"].startswith(mod.LOCAL_BROWSE_CURSOR_PREFIX)
assert [item["updated_at"] for item in first["items"]] == sorted(
(item["updated_at"] for item in first["items"]), reverse=True
)
assert not ({item["node_id"] for item in first["items"]} & {item["node_id"] for item in second["items"]})
assert all(item["node_id"] for item in first["items"] + second["items"])
# Steady state inside the refresh throttle window: no HTTP at all.
assert calls == []
assert first["freshness"]["feed_sync"] == "throttled"
def test_local_compact_browse_cursor_is_bound_to_filters(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
for index in range(3):
mod.index_node(indexed_node(f"/plans/item-{index}", node_type="plan"))
mod.set_local_graph_index_complete("project-1", True)
first = mod.discover_memory({"folder_path": "/plans", "limit": 1, "view": "summary"})
with pytest.raises(RuntimeError, match="cursor filters do not match"):
mod.discover_memory(
{
"folder_path": "/decisions",
"limit": 1,
"view": "summary",
"cursor": first["next_cursor"],
}
)
def test_local_compact_browse_title_order_is_global_across_pages(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
for title in ["Zulu", "alpha", "Mike", "bravo"]:
item = indexed_node(f"/notes/{title}")
item["title"] = title
mod.index_node(item)
mod.set_local_graph_index_complete("project-1", True)
first = mod.discover_memory({"limit": 2, "view": "titles", "sort": "title_asc"})
second = mod.discover_memory(
{"limit": 2, "view": "titles", "sort": "title_asc", "cursor": first["next_cursor"]}
)
assert [item["title"] for item in first["items"] + second["items"]] == [
"alpha",
"bravo",
"Mike",
"Zulu",
]
def test_compact_browse_falls_back_once_when_local_contract_is_incomplete(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
mod.index_node(indexed_node("/plans/local", node_type="plan"))
calls = []
def request(method, path, payload=None, **_kwargs):
calls.append((method, path, payload))
return {"items": [], "next_cursor": None}
mod.request_json = request
result = mod.discover_memory({"folder_path": "/plans", "limit": 5, "view": "paths"})
assert result["ok"] is True
assert len(calls) == 1
assert calls[0][0] == "GET"
assert "/nodes?" in calls[0][1]
with pytest.raises(RuntimeError, match="complete active-node index"):
mod.discover_memory(
{"folder_path": "/plans", "limit": 5, "view": "paths", "mode": "local"}
)
def test_local_search_reads_overlap_without_writer_file_lock(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
for index in range(4):
mod.index_node(indexed_node(f"/notes/result-{index}", content="shared needle"))
mod.set_local_graph_index_complete("project-1", True)
def unexpected_writer_lock(*_args, **_kwargs):
raise AssertionError("read-only local search must not acquire the writer file lock")
mod.index_file_lock = unexpected_writer_lock
original_rank = mod.rank_search_items
rendezvous = threading.Barrier(2)
def overlapping_rank(items, analysis, limit=None):
rendezvous.wait(timeout=1)
return original_rank(items, analysis, limit=limit)
mod.rank_search_items = overlapping_rank
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
executor.submit(mod.search_local_index, {"q": "shared needle", "limit": 2})
for _ in range(2)
]
results = [future.result(timeout=2) for future in futures]
assert all(len(result["items"]) == 2 for result in results)
def test_index_schema_initializes_once_and_revalidates_replacement(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
original_initialize = mod.initialize_index_schema
calls = []
def counted_initialize(conn):
calls.append(str(mod.INDEX_PATH))
return original_initialize(conn)
mod.initialize_index_schema = counted_initialize
with mod.index_connect():
pass
with mod.index_connect():
pass
assert len(calls) == 1
replacement = tmp_path / "replacement.sqlite"
replacement.touch()
replacement.replace(mod.INDEX_PATH)
with mod.index_connect():
pass
assert len(calls) == 2
def test_discover_contract_advertises_local_routing_and_pagination():
mod = load_module()
tool = next(item for item in mod.tools() if item["name"] == "nap_discover")
properties = tool["inputSchema"]["properties"]
assert "cursor" in properties
assert properties["mode"]["enum"] == ["hybrid", "server", "local"]
def test_query_planner_normalizes_plurals_and_reports_intent():
mod = load_module()
analysis = mod.analyze_search_query(
"where did we decide how search queries and policies should work"
)
assert "query" in analysis["terms"]
assert "querie" not in analysis["terms"]
assert analysis["inferred_intent"] == "decision"
assert len(mod.fts_query_variants(analysis["raw"])[1]) == 1
def test_local_search_pushes_structured_filters_into_candidate_query(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
nodes = [
indexed_node(
"/decisions/search-governance",
node_type="decision",
tags=["search"],
content="search governance",
),
indexed_node(
"/decisions/old-search-governance",
node_type="decision",
tags=["search"],
status="superseded",
content="search governance",
),
indexed_node(
"/plans/search-governance",
node_type="plan",
tags=["search"],
content="search governance",
),
indexed_node(
"/decisions/other-search",
node_type="decision",
tags=["other"],
content="search governance",
),
]
for node in nodes:
mod.index_node(node)
result = mod.search_local_index(
{
"q": "search governance",
"folder_path": "/decisions",
"tag": "search",
"type": "decision",
"status": "active",
"limit": 10,
}
)
assert [item["full_path"] for item in result["items"]] == [
"/decisions/search-governance"
]
assert result["index"]["query_count"] == 1
assert set(result["index"]["filters_applied"]) >= {
"folder_path",
"tag",
"type",
"status",
}
def test_explicit_intent_boosts_without_filtering(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
mod.index_node(
indexed_node(
"/decisions/gateway-relay-authentication",
node_type="decision",
content="gateway relay authentication",
)
)
mod.index_node(
indexed_node(
"/work/current/gateway-relay-authentication",
content="gateway relay authentication",
)
)
result = mod.search_local_index(
{"q": "gateway relay authentication", "intent": "decision", "limit": 10}
)
assert result["items"][0]["full_path"].startswith("/decisions/")
assert len(result["items"]) == 2
def test_complete_filtered_local_search_stays_off_network(tmp_path):
mod = load_module()
configure_local_index(mod, tmp_path)
mod.index_node(
indexed_node(
"/documentation/facturacion-electronica",
tags=["billing"],
content="facturación electrónica",
)
)
mod.index_node(
indexed_node(
"/work/current/facturacion-migration",
tags=["migration"],
content="facturación electrónica",
)
)
mod.set_local_graph_index_complete("project-1", True)
calls = []
mod.TOKEN = "disposable-test-token" # synthetic login; requests go to feed_provider only
mod.request_json = feed_provider(calls)
mod.search_memory({"q": "facturación", "tag": "billing", "limit": 1, "view": "summary"})
assert len(calls) == 2 # one-time change-feed bootstrap: head + catch-up
calls.clear()
result = mod.search_memory(
{
"q": "facturación",
"tag": "billing",
"limit": 1,
"view": "summary",
}
)
assert result["sources"] == ["local"]
assert [item["full_path"] for item in result["items"]] == [
"/documentation/facturacion-electronica"
]
assert calls == [] # a complete, tracked, recently refreshed index stays off the network
assert not result.get("warnings")
def test_discovery_schema_documents_dynamic_sort_and_intent():
mod = load_module()
tool = next(item for item in mod.tools() if item["name"] == "nap_discover")
properties = tool["inputSchema"]["properties"]
assert properties["intent"]["enum"] == [
"any",
"decision",
"rule",
"plan",
"task",
"implementation",
"documentation",
]
assert "default" not in properties["sort"]
assert "relevance" in properties["sort"]["description"]