- 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>
543 lines
17 KiB
Python
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"]
|