1536 lines
65 KiB
Python
1536 lines
65 KiB
Python
"""
|
|
test_searcher.py -- Tests for both search() (CLI) and search_memories() (API).
|
|
|
|
Uses the real ChromaDB fixtures from conftest.py for integration tests,
|
|
plus mock-based tests for error paths.
|
|
"""
|
|
|
|
import sqlite3
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from _chroma_palace_helper import make_minimal_chroma_sqlite
|
|
|
|
from mempalace.backends import BackendMismatchError
|
|
from mempalace.searcher import (
|
|
SearchError,
|
|
_result_drawer_id,
|
|
build_where_filter,
|
|
get_collection,
|
|
search,
|
|
search_memories,
|
|
)
|
|
|
|
|
|
# ── build_where_filter (unit) ──────────────────────────────────────────
|
|
|
|
|
|
class TestBuildWhereFilter:
|
|
"""build_where_filter composes a ChromaDB where clause from optional
|
|
wing / room / source_file constraints (#1815). ChromaDB needs a ``$and``
|
|
only when ≥2 clauses are present; a single clause is returned bare and
|
|
zero clauses yield an empty filter."""
|
|
|
|
def test_no_filters_returns_empty(self):
|
|
assert build_where_filter() == {}
|
|
|
|
def test_wing_only(self):
|
|
assert build_where_filter(wing="backend") == {"wing": "backend"}
|
|
|
|
def test_room_only(self):
|
|
assert build_where_filter(room="auth") == {"room": "auth"}
|
|
|
|
def test_wing_and_room(self):
|
|
assert build_where_filter(wing="backend", room="auth") == {
|
|
"$and": [{"wing": "backend"}, {"room": "auth"}]
|
|
}
|
|
|
|
def test_source_file_only(self):
|
|
assert build_where_filter(source_file="auth.py") == {"source_file": "auth.py"}
|
|
|
|
def test_wing_and_source_file(self):
|
|
assert build_where_filter(wing="backend", source_file="auth.py") == {
|
|
"$and": [{"wing": "backend"}, {"source_file": "auth.py"}]
|
|
}
|
|
|
|
def test_room_and_source_file(self):
|
|
assert build_where_filter(room="auth", source_file="auth.py") == {
|
|
"$and": [{"room": "auth"}, {"source_file": "auth.py"}]
|
|
}
|
|
|
|
def test_wing_room_and_source_file(self):
|
|
assert build_where_filter(wing="backend", room="auth", source_file="auth.py") == {
|
|
"$and": [{"wing": "backend"}, {"room": "auth"}, {"source_file": "auth.py"}]
|
|
}
|
|
|
|
|
|
# ── search_memories (API) ──────────────────────────────────────────────
|
|
|
|
|
|
class TestSearchMemories:
|
|
def test_basic_search(self, palace_path, seeded_collection):
|
|
result = search_memories("JWT authentication", palace_path)
|
|
assert "results" in result
|
|
assert len(result["results"]) > 0
|
|
assert result["query"] == "JWT authentication"
|
|
|
|
def test_wing_filter(self, palace_path, seeded_collection):
|
|
result = search_memories("planning", palace_path, wing="notes")
|
|
assert all(r["wing"] == "notes" for r in result["results"])
|
|
|
|
def test_room_filter(self, palace_path, seeded_collection):
|
|
result = search_memories("database", palace_path, room="backend")
|
|
assert all(r["room"] == "backend" for r in result["results"])
|
|
|
|
def test_wing_and_room_filter(self, palace_path, seeded_collection):
|
|
result = search_memories("code", palace_path, wing="project", room="frontend")
|
|
assert all(r["wing"] == "project" and r["room"] == "frontend" for r in result["results"])
|
|
|
|
def test_source_file_filter(self, palace_path, seeded_collection):
|
|
result = search_memories("authentication module", palace_path, source_file="auth.py")
|
|
assert result["results"], "exact source_file match should return its drawer"
|
|
assert all(r["source_file"] == "auth.py" for r in result["results"])
|
|
|
|
def test_source_file_with_wing_filter(self, palace_path, seeded_collection):
|
|
result = search_memories("database", palace_path, wing="project", source_file="db.py")
|
|
assert result["results"]
|
|
assert all(
|
|
r["source_file"] == "db.py" and r["wing"] == "project" for r in result["results"]
|
|
)
|
|
|
|
def test_nonmatching_source_file_returns_empty_not_error(self, palace_path, seeded_collection):
|
|
result = search_memories("authentication", palace_path, source_file="nope.md")
|
|
assert "error" not in result
|
|
assert result["results"] == []
|
|
|
|
def test_filters_envelope_includes_source_file(self, palace_path, seeded_collection):
|
|
result = search_memories("authentication", palace_path, source_file="auth.py")
|
|
assert result["filters"]["source_file"] == "auth.py"
|
|
|
|
def test_result_exposes_full_source_path(self, palace_path, seeded_collection):
|
|
# The displayed source_file is a basename; source_path carries the full
|
|
# stored value so a caller can round-trip it back into a source_file filter.
|
|
result = search_memories("authentication module", palace_path)
|
|
hit = result["results"][0]
|
|
assert hit["source_file"] == "auth.py"
|
|
assert hit["source_path"] == "auth.py"
|
|
|
|
def test_source_file_filter_matches_full_path_not_basename(self, palace_path):
|
|
from mempalace.palace import get_collection
|
|
|
|
col = get_collection(palace_path, create=True)
|
|
col.upsert(
|
|
ids=["fp1"],
|
|
documents=["The deploy script restarts the gunicorn workers nightly."],
|
|
metadatas=[{"wing": "ops", "room": "deploy", "source_file": "/srv/app/deploy.sh"}],
|
|
)
|
|
# The full stored path matches and round-trips via source_path.
|
|
hit = search_memories(
|
|
"deploy gunicorn workers", palace_path, source_file="/srv/app/deploy.sh"
|
|
)
|
|
assert [h["source_path"] for h in hit["results"]] == ["/srv/app/deploy.sh"]
|
|
assert [h["source_file"] for h in hit["results"]] == ["deploy.sh"]
|
|
# The basename does NOT match — exact full-path semantics only (issue v1).
|
|
miss = search_memories("deploy gunicorn workers", palace_path, source_file="deploy.sh")
|
|
assert miss["results"] == []
|
|
|
|
def test_source_file_filter_honored_in_bm25_fallback(self, palace_path, seeded_collection):
|
|
# vector_disabled routes through _bm25_only_via_sqlite (#1222); the
|
|
# source_file filter must hold there too, not silently no-op.
|
|
result = search_memories(
|
|
"authentication module",
|
|
palace_path,
|
|
source_file="auth.py",
|
|
vector_disabled=True,
|
|
collection_name="mempalace_drawers",
|
|
)
|
|
assert "error" not in result
|
|
assert result["results"], "BM25 fallback should still find the auth drawer"
|
|
assert all(r["source_file"] == "auth.py" for r in result["results"])
|
|
|
|
def test_n_results_limit(self, palace_path, seeded_collection):
|
|
result = search_memories("code", palace_path, n_results=2)
|
|
assert len(result["results"]) <= 2
|
|
|
|
def test_no_palace_returns_error(self, tmp_path):
|
|
result = search_memories("anything", str(tmp_path / "missing"))
|
|
assert "error" in result
|
|
|
|
def test_result_fields(self, palace_path, seeded_collection):
|
|
result = search_memories("authentication", palace_path)
|
|
hit = result["results"][0]
|
|
assert "text" in hit
|
|
assert "wing" in hit
|
|
assert "room" in hit
|
|
assert "source_file" in hit
|
|
assert "similarity" in hit
|
|
assert isinstance(hit["similarity"], float)
|
|
assert "created_at" in hit
|
|
|
|
def test_created_at_contains_filed_at(self, palace_path, seeded_collection):
|
|
"""created_at surfaces the filed_at metadata from the drawer."""
|
|
result = search_memories("JWT authentication", palace_path)
|
|
hit = result["results"][0]
|
|
assert hit["created_at"] == "2026-01-01T00:00:00"
|
|
|
|
def test_created_at_fallback_when_filed_at_missing(self):
|
|
"""created_at defaults to 'unknown' when filed_at is absent."""
|
|
mock_col = MagicMock()
|
|
mock_col.query.return_value = {
|
|
"ids": [["drawer_no_date"]],
|
|
"documents": [["Some text without a date"]],
|
|
"metadatas": [[{"wing": "project", "room": "backend", "source_file": "x.py"}]],
|
|
"distances": [[0.1]],
|
|
}
|
|
|
|
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
|
result = search_memories("test", "/fake/path")
|
|
hit = result["results"][0]
|
|
assert hit["created_at"] == "unknown"
|
|
|
|
def test_search_memories_query_error(self):
|
|
"""search_memories returns error dict when query raises."""
|
|
mock_col = MagicMock()
|
|
mock_col.query.side_effect = RuntimeError("query failed")
|
|
|
|
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
|
result = search_memories("test", "/fake/path")
|
|
assert "error" in result
|
|
assert "query failed" in result["error"]
|
|
# Callers (and CI assertions) index ``results`` unconditionally —
|
|
# error envelopes must not omit the key (Windows KeyError flake).
|
|
assert result["results"] == []
|
|
|
|
def test_search_memories_vector_path_uses_explicit_collection_name(self):
|
|
mock_col = MagicMock()
|
|
mock_col.query.return_value = {
|
|
"documents": [[]],
|
|
"metadatas": [[]],
|
|
"distances": [[]],
|
|
"ids": [[]],
|
|
}
|
|
|
|
with patch("mempalace.searcher.get_collection", return_value=mock_col) as get_collection:
|
|
search_memories("test", "/fake/path", collection_name="custom_drawers")
|
|
|
|
get_collection.assert_called_once_with(
|
|
"/fake/path",
|
|
collection_name="custom_drawers",
|
|
create=False,
|
|
)
|
|
|
|
def test_search_memories_filters_in_result(self, palace_path, seeded_collection):
|
|
result = search_memories("test", palace_path, wing="project", room="backend")
|
|
assert result["filters"]["wing"] == "project"
|
|
assert result["filters"]["room"] == "backend"
|
|
|
|
def test_search_memories_handles_none_metadata(self):
|
|
"""API path: `None` entries in the drawer results' metadatas list must
|
|
fall back to the sentinel strings (wing/room 'unknown', source '?')
|
|
rather than raising `AttributeError: 'NoneType' object has no
|
|
attribute 'get'` while the rest of the result set renders."""
|
|
mock_col = MagicMock()
|
|
mock_col.query.return_value = {
|
|
"documents": [["first doc", "second doc"]],
|
|
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}, None]],
|
|
"distances": [[0.1, 0.2]],
|
|
"ids": [["d1", "d2"]],
|
|
}
|
|
|
|
def mock_get_collection(path, collection_name=None, create=False):
|
|
# First call: drawers. Second call: closets — raise so hybrid
|
|
# degrades to pure drawer search (the catch block covers it).
|
|
if not hasattr(mock_get_collection, "_called"):
|
|
mock_get_collection._called = True
|
|
return mock_col
|
|
raise RuntimeError("no closets")
|
|
|
|
with patch("mempalace.searcher.get_collection", side_effect=mock_get_collection):
|
|
result = search_memories("anything", "/fake/path")
|
|
assert "results" in result
|
|
assert len(result["results"]) == 2
|
|
# The None-metadata hit renders with sentinel values, not a crash.
|
|
none_hit = result["results"][1]
|
|
assert none_hit["text"] == "second doc"
|
|
assert none_hit["wing"] == "unknown"
|
|
assert none_hit["room"] == "unknown"
|
|
|
|
def test_effective_distance_clamped_to_valid_cosine_range(self):
|
|
"""A strong closet boost (up to 0.40) applied to a low-distance drawer
|
|
can drive ``dist - boost`` negative. That violates the cosine-distance
|
|
invariant ``[0, 2]``: the API returns ``similarity > 1.0`` and the
|
|
internal ``_sort_key`` sinks below ordinary positive distances,
|
|
inverting the ranking so the best hybrid matches sort last.
|
|
|
|
With the clamp, ``effective_distance`` stays in ``[0, 2]``,
|
|
``similarity`` stays in ``[0, 1]``, and the sort order is stable.
|
|
"""
|
|
# Drawer a.md gets a tiny base distance (0.08) — nearly exact match.
|
|
# Drawer b.md gets a larger base distance (0.35).
|
|
drawers_col = MagicMock()
|
|
drawers_col.query.return_value = {
|
|
"documents": [["doc-a", "doc-b"]],
|
|
"metadatas": [
|
|
[
|
|
{"source_file": "a.md", "wing": "w", "room": "r", "chunk_index": 0},
|
|
{"source_file": "b.md", "wing": "w", "room": "r", "chunk_index": 0},
|
|
]
|
|
],
|
|
"distances": [[0.08, 0.35]],
|
|
"ids": [["d-a", "d-b"]],
|
|
}
|
|
# A strong closet at rank 0 points at a.md → boost = 0.40,
|
|
# which exceeds a.md's base distance and would go negative without
|
|
# the clamp. No closet for b.md.
|
|
closets_col = MagicMock()
|
|
closets_col.query.return_value = {
|
|
"documents": [["closet-preview-a"]],
|
|
"metadatas": [[{"source_file": "a.md"}]],
|
|
"distances": [[0.2]], # within CLOSET_DISTANCE_CAP (1.5)
|
|
"ids": [["c-a"]],
|
|
}
|
|
|
|
with (
|
|
patch("mempalace.searcher.get_collection", return_value=drawers_col),
|
|
patch("mempalace.searcher.get_closets_collection", return_value=closets_col),
|
|
):
|
|
result = search_memories("query", "/fake/path", n_results=5)
|
|
|
|
hits = result["results"]
|
|
assert hits, "should return results"
|
|
|
|
# Invariants on every hit.
|
|
for h in hits:
|
|
assert 0.0 <= h["similarity"] <= 1.0, (
|
|
f"similarity out of range: {h['similarity']} for {h['source_file']}"
|
|
)
|
|
assert 0.0 <= h["effective_distance"] <= 2.0, (
|
|
f"effective_distance out of range: {h['effective_distance']} for {h['source_file']}"
|
|
)
|
|
|
|
# With the clamp, the closet-boosted a.md still ranks ahead of b.md —
|
|
# the boost still wins, but it no longer flips the ranking.
|
|
assert hits[0]["source_file"] == "a.md"
|
|
assert hits[0]["matched_via"] == "drawer+closet"
|
|
|
|
|
|
# ── BM25 internals: None / empty document safety ─────────────────────
|
|
|
|
|
|
class TestBM25NoneSafety:
|
|
"""Regression tests for the AttributeError observed in production when
|
|
Chroma returned ``None`` documents inside a hybrid-rerank pass.
|
|
|
|
Trace from the daemon log (2026-04-24 21:07:05):
|
|
File "mempalace/searcher.py", line 81, in _bm25_scores
|
|
tokenized = [_tokenize(d) for d in documents]
|
|
File "mempalace/searcher.py", line 52, in _tokenize
|
|
return _TOKEN_RE.findall(text.lower())
|
|
AttributeError: 'NoneType' object has no attribute 'lower'
|
|
"""
|
|
|
|
def test_tokenize_handles_none(self):
|
|
from mempalace.searcher import _tokenize
|
|
|
|
assert _tokenize(None) == []
|
|
|
|
def test_tokenize_handles_empty_string(self):
|
|
from mempalace.searcher import _tokenize
|
|
|
|
assert _tokenize("") == []
|
|
|
|
def test_bm25_scores_does_not_crash_on_none_documents(self):
|
|
"""A ``None`` mixed into the corpus must yield score 0.0 for that doc
|
|
and finite scores for the rest, not raise AttributeError."""
|
|
from mempalace.searcher import _bm25_scores
|
|
|
|
scores = _bm25_scores(
|
|
"postgres migration", ["postgres migration done", None, "kafka rebalance"]
|
|
)
|
|
assert len(scores) == 3
|
|
assert scores[1] == 0.0
|
|
assert scores[0] > 0.0
|
|
|
|
|
|
# ── search() (CLI print function) ─────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_palace_path(tmp_path):
|
|
"""tmp_path with chroma.sqlite3 touched so searcher.search's
|
|
filesystem-first state checks (#1498) pass through to the mocked
|
|
backend instead of raising on State A / State B."""
|
|
p = tmp_path / "palace"
|
|
p.mkdir()
|
|
make_minimal_chroma_sqlite(p)
|
|
return str(p)
|
|
|
|
|
|
class TestSearchCLI:
|
|
def test_search_prints_results(self, palace_path, seeded_collection, capsys):
|
|
search("JWT authentication", palace_path)
|
|
captured = capsys.readouterr()
|
|
assert "JWT" in captured.out or "authentication" in captured.out
|
|
|
|
def test_search_with_wing_filter(self, palace_path, seeded_collection, capsys):
|
|
search("planning", palace_path, wing="notes")
|
|
captured = capsys.readouterr()
|
|
assert "Results for" in captured.out
|
|
|
|
def test_search_with_room_filter(self, palace_path, seeded_collection, capsys):
|
|
search("database", palace_path, room="backend")
|
|
captured = capsys.readouterr()
|
|
assert "Room:" in captured.out
|
|
|
|
def test_search_with_wing_and_room(self, palace_path, seeded_collection, capsys):
|
|
search("code", palace_path, wing="project", room="frontend")
|
|
captured = capsys.readouterr()
|
|
assert "Wing:" in captured.out
|
|
assert "Room:" in captured.out
|
|
|
|
def test_search_no_palace_raises(self, tmp_path):
|
|
with pytest.raises(SearchError, match="No palace found"):
|
|
search("anything", str(tmp_path / "missing"))
|
|
|
|
def test_search_no_results(self, palace_path, collection, capsys):
|
|
"""Empty collection returns no results message."""
|
|
# collection is empty (no seeded data)
|
|
result = search("xyzzy_nonexistent_query", palace_path, n_results=1)
|
|
captured = capsys.readouterr()
|
|
# Either prints "No results" or returns None
|
|
assert result is None or "No results" in captured.out
|
|
|
|
def test_search_query_error_raises(self, fake_palace_path):
|
|
"""search raises SearchError when query fails."""
|
|
mock_col = MagicMock()
|
|
mock_col.query.side_effect = RuntimeError("boom")
|
|
|
|
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
|
with pytest.raises(SearchError, match="Search error"):
|
|
search("test", fake_palace_path)
|
|
|
|
def test_search_n_results(self, palace_path, seeded_collection, capsys):
|
|
search("code", palace_path, n_results=1)
|
|
captured = capsys.readouterr()
|
|
# Should have output with at least one result block
|
|
assert "[1]" in captured.out
|
|
|
|
def test_search_applies_bm25_hybrid_rerank(self, fake_palace_path, capsys):
|
|
"""CLI search must call the same hybrid rerank that the MCP path uses.
|
|
|
|
Regression for a bug where the CLI only consulted ChromaDB cosine
|
|
distance: a drawer whose body contained every query term still
|
|
scored zero similarity if its embedding happened to be far from
|
|
the query (e.g. the drawer was a shell-output fragment that
|
|
embeds as "file tree noise"). Hybrid rerank fixes this by
|
|
combining BM25 with cosine — lexical matches rise above pure
|
|
vector noise.
|
|
|
|
Simulates: three candidates, all with distance >= 1.0 (cosine = 0);
|
|
candidate 2 contains every query term. After the fix, candidate 2
|
|
should rank first and display a non-zero bm25 score.
|
|
"""
|
|
mock_col = MagicMock()
|
|
mock_col.metadata = {"hnsw:space": "cosine"}
|
|
mock_col.query.return_value = {
|
|
"documents": [
|
|
[
|
|
"unrelated directory listing -rw-rw-r-- file.txt",
|
|
"foo bar baz is a multi-word phrase",
|
|
"another unrelated chunk about colors",
|
|
]
|
|
],
|
|
"metadatas": [
|
|
[
|
|
{"source_file": "a.md", "wing": "w", "room": "r"},
|
|
{"source_file": "b.md", "wing": "w", "room": "r"},
|
|
{"source_file": "c.md", "wing": "w", "room": "r"},
|
|
]
|
|
],
|
|
"distances": [[1.5, 1.5, 1.5]],
|
|
}
|
|
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
|
search("foo bar baz", fake_palace_path)
|
|
captured = capsys.readouterr()
|
|
first_block, _, _ = captured.out.partition("[2]")
|
|
# Lexical match must rank first
|
|
assert "b.md" in first_block, (
|
|
f"expected lexical match 'b.md' at rank 1, got:\n{captured.out}"
|
|
)
|
|
# Non-zero bm25 reported
|
|
assert "bm25=" in first_block
|
|
assert "bm25=0.0" not in first_block
|
|
# Metric-labeled vector similarity still reported for transparency.
|
|
# Label is now "<metric>_sim=" (honest about the backend's metric)
|
|
# rather than a hard-coded "cosine=".
|
|
assert "cosine_sim=" in first_block
|
|
|
|
def test_search_warns_when_palace_uses_wrong_distance_metric(self, fake_palace_path, capsys):
|
|
"""Legacy palaces created without `hnsw:space=cosine` silently
|
|
use L2, which breaks similarity interpretation. CLI must warn
|
|
the user and point them at `mempalace repair` rather than
|
|
pretending the `Match` scores are meaningful."""
|
|
mock_col = MagicMock()
|
|
mock_col.metadata = {} # legacy: no hnsw:space set
|
|
mock_col.query.return_value = {
|
|
"documents": [["some drawer content"]],
|
|
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}]],
|
|
"distances": [[1.2]],
|
|
}
|
|
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
|
search("anything", fake_palace_path)
|
|
captured = capsys.readouterr()
|
|
assert "mempalace repair" in captured.err
|
|
assert "cosine" in captured.err.lower()
|
|
|
|
def test_search_does_not_warn_when_palace_is_correctly_configured(
|
|
self, fake_palace_path, capsys
|
|
):
|
|
mock_col = MagicMock()
|
|
mock_col.metadata = {"hnsw:space": "cosine"}
|
|
mock_col.query.return_value = {
|
|
"documents": [["some drawer content"]],
|
|
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}]],
|
|
"distances": [[0.3]],
|
|
}
|
|
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
|
search("anything", fake_palace_path)
|
|
captured = capsys.readouterr()
|
|
assert "mempalace repair" not in captured.err
|
|
|
|
def test_search_handles_none_metadata_without_crash(self, fake_palace_path, capsys):
|
|
"""ChromaDB can return `None` entries in the metadatas list when a
|
|
drawer has no metadata. The CLI print path must not crash on them
|
|
mid-render — it used to raise `AttributeError: 'NoneType' object has
|
|
no attribute 'get'` after printing earlier results."""
|
|
mock_col = MagicMock()
|
|
mock_col.query.return_value = {
|
|
"documents": [["first doc", "second doc"]],
|
|
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}, None]],
|
|
"distances": [[0.1, 0.2]],
|
|
}
|
|
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
|
search("anything", fake_palace_path)
|
|
captured = capsys.readouterr()
|
|
assert "[1]" in captured.out
|
|
assert "[2]" in captured.out
|
|
# Second result renders with fallback '?' values instead of crashing
|
|
assert "second doc" in captured.out
|
|
|
|
def test_search_handles_none_document_without_crash(self, fake_palace_path, capsys):
|
|
mock_col = MagicMock()
|
|
mock_col.metadata = {"hnsw:space": "cosine"}
|
|
mock_col.query.return_value = {
|
|
"documents": [["first doc", None]],
|
|
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}, None]],
|
|
"distances": [[0.1, 0.2]],
|
|
}
|
|
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
|
search("anything", fake_palace_path)
|
|
captured = capsys.readouterr()
|
|
assert "[1]" in captured.out
|
|
assert "[2]" in captured.out
|
|
|
|
def test_search_routes_to_bm25_when_hnsw_diverged(self, fake_palace_path, capsys):
|
|
"""Regression: `mempalace search` on a diverged HNSW segment must not
|
|
segfault ChromaDB's Rust bindings.
|
|
|
|
The MCP path gates this via ``_vector_disabled`` (#1222); the CLI
|
|
path was missing the gate, so any query into a diverged palace
|
|
exited 139 (SIGBUS) at ``chromadb/api/rust.py:_query`` with zero
|
|
diagnostic output. This test verifies the CLI now probes
|
|
``hnsw_capacity_status`` and routes to the BM25-only sqlite
|
|
fallback instead of calling ``col.query()`` against a segment
|
|
that would crash it.
|
|
"""
|
|
bm25_result = {
|
|
"query": "anything",
|
|
"filters": {},
|
|
"total_before_filter": 1,
|
|
"results": [
|
|
{
|
|
"text": "diary entry that matches the query",
|
|
"wing": "wing_test",
|
|
"room": "diary",
|
|
"source_file": "test.jsonl",
|
|
"bm25_score": 1.5,
|
|
"distance": None,
|
|
}
|
|
],
|
|
"fallback": "bm25_only_via_sqlite",
|
|
"fallback_reason": "vector_search_disabled",
|
|
}
|
|
with (
|
|
patch("mempalace.searcher.resolve_backend_name", return_value="chroma"),
|
|
patch(
|
|
"mempalace.backends.chroma.hnsw_capacity_status",
|
|
return_value={"diverged": True, "message": "test divergence"},
|
|
),
|
|
patch("mempalace.searcher._bm25_only_via_sqlite", return_value=bm25_result),
|
|
patch("mempalace.searcher.get_collection") as mock_get_collection,
|
|
):
|
|
search("anything", fake_palace_path)
|
|
captured = capsys.readouterr()
|
|
# Routed to BM25 before opening Chroma at all. Client construction and
|
|
# identity enforcement can touch the same damaged native index, so a
|
|
# query-only guard is insufficient.
|
|
mock_get_collection.assert_not_called()
|
|
# User got actionable output, not a silent crash.
|
|
assert "mempalace repair" in captured.out
|
|
assert "diary entry that matches" in captured.out
|
|
|
|
def test_search_proceeds_to_vector_when_hnsw_healthy(self, fake_palace_path, capsys):
|
|
"""Paired guard: when HNSW is healthy, the divergence probe must NOT
|
|
short-circuit to BM25 — vector search proceeds normally.
|
|
|
|
Prevents a regression where the gate accidentally always fires.
|
|
"""
|
|
mock_col = MagicMock()
|
|
mock_col.metadata = {"hnsw:space": "cosine"}
|
|
mock_col.query.return_value = {
|
|
"documents": [["a matching doc"]],
|
|
"metadatas": [[{"source_file": "a.md", "wing": "w", "room": "r"}]],
|
|
"distances": [[0.1]],
|
|
}
|
|
with (
|
|
patch("mempalace.searcher.resolve_backend_name", return_value="chroma"),
|
|
patch(
|
|
"mempalace.backends.chroma.hnsw_capacity_status",
|
|
return_value={"diverged": False, "status": "ok"},
|
|
),
|
|
patch("mempalace.searcher._bm25_only_via_sqlite") as mock_bm25,
|
|
patch("mempalace.searcher.get_collection", return_value=mock_col),
|
|
):
|
|
search("anything", fake_palace_path)
|
|
captured = capsys.readouterr()
|
|
# Vector path ran.
|
|
mock_col.query.assert_called_once()
|
|
# BM25 fallback was NOT invoked.
|
|
mock_bm25.assert_not_called()
|
|
assert "a matching doc" in captured.out
|
|
|
|
def test_search_forwards_stop_words_to_bm25_fallback_when_hnsw_diverged(self, fake_palace_path):
|
|
"""The diverged-index detour still ranks by BM25, so the stop-word
|
|
filter has to travel with it.
|
|
|
|
``_vector_disabled_search`` already forwards ``stop_words`` on the
|
|
MCP side. Without the same wiring here, a diverged palace would rank
|
|
CLI results by different rules than a healthy one — and silently,
|
|
since the fallback prints results either way.
|
|
"""
|
|
seen = {}
|
|
|
|
def _spy_bm25(**kwargs):
|
|
seen.update(kwargs)
|
|
return {"query": "the cat", "filters": {}, "total_before_filter": 0, "results": []}
|
|
|
|
with (
|
|
patch("mempalace.searcher.resolve_backend_name", return_value="chroma"),
|
|
patch(
|
|
"mempalace.backends.chroma.hnsw_capacity_status",
|
|
return_value={"diverged": True, "message": "test divergence"},
|
|
),
|
|
patch("mempalace.searcher._resolve_stop_words", return_value=frozenset({"the"})),
|
|
patch("mempalace.searcher._bm25_only_via_sqlite", side_effect=_spy_bm25),
|
|
):
|
|
search("the cat", fake_palace_path)
|
|
|
|
assert seen["stop_words"] == frozenset({"the"})
|
|
|
|
def test_search_forwards_date_window_to_bm25_fallback_when_hnsw_diverged(
|
|
self, fake_palace_path
|
|
):
|
|
"""A `--since`/`--before` window must survive the diverged-index detour.
|
|
|
|
The fence returns BM25-only results before the vector path runs, and
|
|
that fallback reads drawers straight from sqlite. Unless the window
|
|
travels with it, the CLI answers a wider question than the caller
|
|
asked — silently, with no notice that the filter was dropped. A
|
|
degraded index may cost ranking quality; it must never cost the
|
|
filter.
|
|
"""
|
|
seen = {}
|
|
|
|
def _spy_bm25(**kwargs):
|
|
seen.update(kwargs)
|
|
return {"query": "anything", "filters": {}, "total_before_filter": 0, "results": []}
|
|
|
|
with (
|
|
patch("mempalace.searcher.resolve_backend_name", return_value="chroma"),
|
|
patch(
|
|
"mempalace.backends.chroma.hnsw_capacity_status",
|
|
return_value={"diverged": True, "message": "test divergence"},
|
|
),
|
|
patch("mempalace.searcher._bm25_only_via_sqlite", side_effect=_spy_bm25),
|
|
):
|
|
search("anything", fake_palace_path, since="2026-01-01", before="2026-02-01")
|
|
|
|
assert seen["since_dt"] == datetime(2026, 1, 1)
|
|
assert seen["before_dt"] == datetime(2026, 2, 1)
|
|
|
|
def test_search_rejects_inverted_window_before_probing_a_diverged_index(self, fake_palace_path):
|
|
"""An inverted window is a caller error, so it must raise the same way
|
|
whether the index is healthy or diverged. If the fence ran first it
|
|
would swallow the mistake and answer with unfiltered BM25 results."""
|
|
with (
|
|
patch("mempalace.searcher.resolve_backend_name", return_value="chroma"),
|
|
patch(
|
|
"mempalace.backends.chroma.hnsw_capacity_status",
|
|
return_value={"diverged": True, "message": "test divergence"},
|
|
),
|
|
patch("mempalace.searcher._bm25_only_via_sqlite") as mock_bm25,
|
|
):
|
|
with pytest.raises(SearchError, match="must be earlier than"):
|
|
search("anything", fake_palace_path, since="2026-02-01", before="2026-01-01")
|
|
mock_bm25.assert_not_called()
|
|
|
|
def test_search_does_not_run_chroma_probe_for_other_backends(self, fake_palace_path, capsys):
|
|
"""The HNSW guard is Chroma-specific and must not fence other backends."""
|
|
mock_col = MagicMock()
|
|
mock_col.query.return_value = {
|
|
"documents": [["backend-native result"]],
|
|
"metadatas": [[{"source_file": "native.md", "wing": "w", "room": "r"}]],
|
|
"distances": [[0.1]],
|
|
}
|
|
with (
|
|
patch("mempalace.searcher.resolve_backend_name", return_value="sqlite_exact"),
|
|
patch("mempalace.backends.chroma.hnsw_capacity_status") as mock_probe,
|
|
patch("mempalace.searcher.get_collection", return_value=mock_col),
|
|
):
|
|
search("anything", fake_palace_path)
|
|
|
|
mock_probe.assert_not_called()
|
|
mock_col.query.assert_called_once()
|
|
assert "backend-native result" in capsys.readouterr().out
|
|
|
|
@pytest.mark.parametrize(
|
|
"resolution_error",
|
|
[BackendMismatchError("mixed backend artifacts"), KeyError("unknown_backend")],
|
|
)
|
|
def test_search_delegates_backend_resolution_errors_to_open_diagnostic(
|
|
self, fake_palace_path, resolution_error
|
|
):
|
|
"""The early HNSW fence must not replace normal CLI diagnostics."""
|
|
with (
|
|
patch("mempalace.searcher.resolve_backend_name", side_effect=resolution_error),
|
|
patch("mempalace.searcher._hnsw_capacity_diverged") as mock_probe,
|
|
patch("mempalace.searcher._open_collection_or_explain", return_value=None) as mock_open,
|
|
):
|
|
with pytest.raises(SearchError):
|
|
search("anything", fake_palace_path)
|
|
|
|
mock_probe.assert_not_called()
|
|
mock_open.assert_called_once_with(fake_palace_path, opener=get_collection)
|
|
|
|
|
|
# ── _tokenize stop-word filter ─────────────────────────────────────────
|
|
|
|
|
|
def test_tokenize_default_keeps_all_tokens():
|
|
"""Without stop_words, behaviour matches the pre-i18n tokenizer."""
|
|
from mempalace.searcher import _tokenize
|
|
|
|
assert _tokenize("The cat sat on the mat") == ["the", "cat", "sat", "on", "the", "mat"]
|
|
|
|
|
|
def test_tokenize_filters_stop_words():
|
|
"""When stop_words is given, matching tokens are dropped."""
|
|
from mempalace.searcher import _tokenize
|
|
|
|
tokens = _tokenize("The cat sat on the mat", stop_words=frozenset({"the", "on"}))
|
|
assert "the" not in tokens
|
|
assert "on" not in tokens
|
|
assert tokens == ["cat", "sat", "mat"]
|
|
|
|
|
|
def test_tokenize_stop_words_empty_is_no_op():
|
|
"""Empty frozenset is the same as default — full backwards compat."""
|
|
from mempalace.searcher import _tokenize
|
|
|
|
assert _tokenize("hello world", stop_words=frozenset()) == _tokenize("hello world")
|
|
|
|
|
|
def test_bm25_scores_filters_stop_words_from_query_and_docs():
|
|
"""BM25 with a stop-words set uses the filtered vocabulary on both sides."""
|
|
from mempalace.searcher import _bm25_scores
|
|
|
|
query = "the quick fox"
|
|
docs = ["the quick fox", "the lazy dog"]
|
|
stop_words = frozenset({"the"})
|
|
|
|
filtered = _bm25_scores(query, docs, stop_words=stop_words)
|
|
unfiltered = _bm25_scores(query, docs)
|
|
|
|
# Both should rank the first doc higher than the second (fox + quick match).
|
|
assert filtered[0] > filtered[1]
|
|
assert unfiltered[0] > unfiltered[1]
|
|
# Filtered scoring differs because IDF no longer counts "the" across docs.
|
|
assert filtered != unfiltered
|
|
|
|
|
|
def test_bm25_scores_all_stopwords_query_returns_zeros():
|
|
"""If every query term is a stop word, BM25 short-circuits to all-zero."""
|
|
from mempalace.searcher import _bm25_scores
|
|
|
|
scores = _bm25_scores(
|
|
"the and of", ["a doc", "another"], stop_words=frozenset({"the", "and", "of"})
|
|
)
|
|
assert scores == [0.0, 0.0]
|
|
|
|
|
|
def test_bm25_scores_all_stopword_docs_returns_zero_vector():
|
|
"""Every doc emptied by stop-word filter yields zero scores without divide errors."""
|
|
from mempalace.searcher import _bm25_scores
|
|
|
|
scores = _bm25_scores("fox", ["the the the", "the the"], stop_words=frozenset({"the", "fox"}))
|
|
assert scores == [0.0, 0.0]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_stopword_cache_and_env(monkeypatch):
|
|
"""Reset the stop-word resolution surface before each test.
|
|
|
|
Two things would otherwise leak across tests in this module:
|
|
|
|
* ``_stopwords_for_canonical`` is ``@lru_cache``'d, so the first test to
|
|
load a locale pins it for the rest of the run. Clearing here turns
|
|
every locale lookup into a fresh load.
|
|
* ``_resolve_stop_words(None)`` reads ``MEMPALACE_LANG`` /
|
|
``MEMPAL_LANG`` env vars before consulting ``MempalaceConfig``. A
|
|
developer running tests with one of those exported in their shell
|
|
would silently bypass the ``MempalaceConfig`` mocks below and see
|
|
different results than CI. Strip them here.
|
|
"""
|
|
from mempalace import searcher
|
|
|
|
searcher._stopwords_for_canonical.cache_clear()
|
|
monkeypatch.delenv("MEMPALACE_LANG", raising=False)
|
|
monkeypatch.delenv("MEMPAL_LANG", raising=False)
|
|
yield
|
|
|
|
|
|
def test_resolve_stop_words_falls_back_silently_when_config_raises(monkeypatch):
|
|
"""If MempalaceConfig() blows up, return an empty set so search keeps working."""
|
|
from mempalace import searcher
|
|
|
|
def boom(*args, **kwargs):
|
|
raise OSError("config.json unreadable")
|
|
|
|
monkeypatch.setattr(searcher, "MempalaceConfig", boom)
|
|
assert searcher._resolve_stop_words(None) == frozenset()
|
|
|
|
|
|
def test_resolve_stop_words_none_with_no_explicit_lang_returns_empty(monkeypatch):
|
|
"""Unconfigured palaces must not suddenly filter stop words."""
|
|
from mempalace import searcher
|
|
|
|
class FakeCfg:
|
|
lang_explicit = None
|
|
|
|
monkeypatch.setattr(searcher, "MempalaceConfig", FakeCfg)
|
|
assert searcher._resolve_stop_words(None) == frozenset()
|
|
|
|
|
|
def test_resolve_stop_words_none_with_explicit_lang_applies_filter(monkeypatch):
|
|
"""When the user opts in via lang_explicit, the locale's stop words load."""
|
|
from mempalace import searcher
|
|
|
|
class FakeCfg:
|
|
lang_explicit = "ja"
|
|
|
|
monkeypatch.setattr(searcher, "MempalaceConfig", FakeCfg)
|
|
sw = searcher._resolve_stop_words(None)
|
|
assert "した" in sw
|
|
|
|
|
|
def test_resolve_stop_words_uses_env_var_before_config(monkeypatch):
|
|
"""The env-var fast path must avoid constructing MempalaceConfig at all
|
|
on the hot search path when the user has set MEMPALACE_LANG."""
|
|
from mempalace import searcher
|
|
|
|
monkeypatch.setenv("MEMPALACE_LANG", "ja")
|
|
|
|
sentinel_calls = []
|
|
|
|
class TripwireCfg:
|
|
def __init__(self):
|
|
sentinel_calls.append("config-loaded")
|
|
|
|
lang_explicit = None
|
|
|
|
monkeypatch.setattr(searcher, "MempalaceConfig", TripwireCfg)
|
|
sw = searcher._resolve_stop_words(None)
|
|
assert "した" in sw
|
|
assert sentinel_calls == [], "MempalaceConfig was constructed despite MEMPALACE_LANG being set"
|
|
|
|
|
|
def test_resolve_stop_words_canonicalizes_cache_key():
|
|
"""Case variants of the same locale must hit the same lru_cache slot.
|
|
|
|
Without canonicalization, ``"en"`` and ``"EN"`` would each consume a
|
|
cache entry pointing at the same set; ``maxsize=16`` could be exhausted
|
|
by a tenant rotating through capitalizations.
|
|
"""
|
|
from mempalace import searcher
|
|
|
|
a = searcher._resolve_stop_words("en")
|
|
b = searcher._resolve_stop_words("EN")
|
|
c = searcher._resolve_stop_words("En")
|
|
assert a is b is c
|
|
|
|
|
|
def test_resolve_stop_words_caches_per_lang():
|
|
"""Repeat lookups for the same lang hit the lru_cache and return the same object."""
|
|
from mempalace import searcher
|
|
|
|
a = searcher._resolve_stop_words("ja")
|
|
b = searcher._resolve_stop_words("ja")
|
|
assert a is b
|
|
|
|
|
|
def test_resolve_stop_words_none_reflects_config_change_between_calls(monkeypatch):
|
|
"""The None-arg path must re-read config on every call; a stale cache key
|
|
would pin the first result for the lifetime of the process (igorls, #977)."""
|
|
from mempalace import searcher
|
|
|
|
class FakeCfgUnset:
|
|
lang_explicit = None
|
|
|
|
monkeypatch.setattr(searcher, "MempalaceConfig", FakeCfgUnset)
|
|
first = searcher._resolve_stop_words(None)
|
|
assert first == frozenset()
|
|
|
|
class FakeCfgJa:
|
|
lang_explicit = "ja"
|
|
|
|
monkeypatch.setattr(searcher, "MempalaceConfig", FakeCfgJa)
|
|
second = searcher._resolve_stop_words(None)
|
|
assert "した" in second, (
|
|
f"cache pinned stale empty set for None after config change; got {second!r}"
|
|
)
|
|
|
|
|
|
# ── stop_words propagation through BM25-only / union-merge paths (post-#1306) ──
|
|
#
|
|
# #1306 added a second BM25 scoring site inside `_bm25_only_via_sqlite` that
|
|
# the original PR didn't cover (it landed on develop after this branch was
|
|
# opened). These tests pin the propagation chain so the BM25 fallback and the
|
|
# `candidate_strategy="union"` merge tokenize at the same locale as
|
|
# `_hybrid_rank`.
|
|
|
|
|
|
def test_bm25_only_via_sqlite_forwards_stop_words_to_bm25_scores(monkeypatch, tmp_path):
|
|
"""`_bm25_only_via_sqlite` must pass `stop_words` into `_bm25_scores`.
|
|
|
|
Without this, vector-disabled (#1222) palaces silently lose stop-word
|
|
filtering on the BM25 fallback path.
|
|
"""
|
|
from mempalace import searcher
|
|
|
|
captured = {}
|
|
real_bm25 = searcher._bm25_scores
|
|
|
|
def _spy(query, docs, **kwargs):
|
|
captured["stop_words"] = kwargs.get("stop_words")
|
|
return real_bm25(query, docs, **kwargs)
|
|
|
|
monkeypatch.setattr(searcher, "_bm25_scores", _spy)
|
|
|
|
# Build a minimal chroma.sqlite3 with one matching drawer so the scoring
|
|
# path is reached. Schema mirrors what `_bm25_only_via_sqlite` reads:
|
|
# the embeddings/segments/collections JOIN added by #1306 to scope
|
|
# candidate selection by collection name, plus embeddings.embedding_id,
|
|
# which the metadata SELECT reads as the stored drawer ID so results
|
|
# round-trip through mempalace_get_drawer.
|
|
db = tmp_path / "chroma.sqlite3"
|
|
conn = sqlite3.connect(db)
|
|
conn.executescript(
|
|
"""
|
|
CREATE VIRTUAL TABLE embedding_fulltext_search USING fts5(string_value, tokenize='trigram');
|
|
CREATE TABLE embedding_metadata (id INTEGER, key TEXT, string_value TEXT, int_value INTEGER);
|
|
CREATE TABLE collections (id TEXT PRIMARY KEY, name TEXT);
|
|
CREATE TABLE segments (id TEXT PRIMARY KEY, collection TEXT);
|
|
CREATE TABLE embeddings (id INTEGER PRIMARY KEY, segment_id TEXT, embedding_id TEXT, created_at TEXT);
|
|
INSERT INTO collections VALUES ('c1', 'mempalace_drawers');
|
|
INSERT INTO segments VALUES ('s1', 'c1');
|
|
INSERT INTO embeddings VALUES (1, 's1', 'drawer-cat-1', '2026-05-03');
|
|
INSERT INTO embedding_fulltext_search (rowid, string_value) VALUES (1, 'the cat sat');
|
|
INSERT INTO embedding_metadata VALUES (1, 'chroma:document', 'the cat sat', NULL);
|
|
INSERT INTO embedding_metadata VALUES (1, 'wing', 'general', NULL);
|
|
INSERT INTO embedding_metadata VALUES (1, 'room', 'inbox', NULL);
|
|
INSERT INTO embedding_metadata VALUES (1, 'source_file', '/x/cat.md', NULL);
|
|
INSERT INTO embedding_metadata VALUES (1, 'filed_at', '2026-05-03', NULL);
|
|
"""
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
searcher._bm25_only_via_sqlite(
|
|
"the cat",
|
|
str(tmp_path),
|
|
n_results=5,
|
|
collection_name="mempalace_drawers",
|
|
stop_words=frozenset({"the"}),
|
|
)
|
|
|
|
assert captured["stop_words"] == frozenset({"the"})
|
|
|
|
|
|
def test_finalize_candidate_hits_forwards_stop_words_to_hybrid_rank(monkeypatch):
|
|
"""`_finalize_candidate_hits` must forward `stop_words` into the final
|
|
`_hybrid_rank` re-rank — the BM25 site on the vector/union path. (The
|
|
union candidate gather runs through the backend's own ``lexical_search``,
|
|
which does its own tokenization and takes no mempalace stop words.)"""
|
|
from mempalace import searcher
|
|
|
|
captured = {}
|
|
|
|
def _hybrid_spy(results, query, **kwargs):
|
|
captured["stop_words"] = kwargs.get("stop_words")
|
|
return results
|
|
|
|
monkeypatch.setattr(searcher, "_hybrid_rank", _hybrid_spy)
|
|
|
|
searcher._finalize_candidate_hits(
|
|
candidate_strategy="vector",
|
|
hits=[],
|
|
drawers_col=None,
|
|
query="q",
|
|
wing=None,
|
|
room=None,
|
|
n_results=5,
|
|
max_distance=0.0,
|
|
stop_words=frozenset({"a", "an"}),
|
|
)
|
|
|
|
assert captured["stop_words"] == frozenset({"a", "an"})
|
|
|
|
|
|
def test_search_memories_vector_disabled_uses_resolved_stop_words(monkeypatch, tmp_path):
|
|
"""`vector_disabled=True` must route `_resolve_stop_words(lang)` into the
|
|
BM25 fallback, not skip stop-word resolution as it did pre-fix."""
|
|
from mempalace import searcher
|
|
|
|
captured = {}
|
|
|
|
def _stub(*args, **kwargs):
|
|
captured["stop_words"] = kwargs.get("stop_words")
|
|
return {"results": [], "fallback": "bm25_only_via_sqlite"}
|
|
|
|
monkeypatch.setattr(searcher, "_bm25_only_via_sqlite", _stub)
|
|
monkeypatch.setattr(searcher, "_resolve_stop_words", lambda lang: frozenset({"de", "es"}))
|
|
|
|
searcher.search_memories(
|
|
query="q",
|
|
palace_path=str(tmp_path),
|
|
vector_disabled=True,
|
|
)
|
|
|
|
assert captured["stop_words"] == frozenset({"de", "es"})
|
|
|
|
|
|
def test_search_cli_threads_resolved_stop_words_to_hybrid_rank(monkeypatch, tmp_path):
|
|
"""The `mempalace search ...` CLI handler must resolve stop_words and
|
|
pass them to `_hybrid_rank`, matching the MCP `search_memories` path so
|
|
`MEMPALACE_LANG` filtering works for CLI users too."""
|
|
from mempalace import searcher
|
|
|
|
# Satisfy the State-A/B filesystem-first checks added in #1498 so
|
|
# `search()` reaches the `_hybrid_rank` call this test exercises.
|
|
make_minimal_chroma_sqlite(tmp_path)
|
|
|
|
captured = {}
|
|
|
|
def _fake_get_collection(palace_path, **kwargs):
|
|
class _Col:
|
|
def query(self, **kwargs):
|
|
return {
|
|
"documents": [["the cat sat"]],
|
|
"metadatas": [[{"wing": "general"}]],
|
|
"distances": [[0.5]],
|
|
}
|
|
|
|
return _Col()
|
|
|
|
def _hybrid_spy(results, query, **kwargs):
|
|
captured["stop_words"] = kwargs.get("stop_words")
|
|
return results
|
|
|
|
monkeypatch.setattr(searcher, "get_collection", _fake_get_collection)
|
|
monkeypatch.setattr(searcher, "_warn_if_legacy_metric", lambda col: None)
|
|
monkeypatch.setattr(searcher, "_resolve_stop_words", lambda lang: frozenset({"the"}))
|
|
monkeypatch.setattr(searcher, "_hybrid_rank", _hybrid_spy)
|
|
|
|
searcher.search(query="cat", palace_path=str(tmp_path))
|
|
|
|
assert captured["stop_words"] == frozenset({"the"})
|
|
|
|
|
|
# ── _result_drawer_id logical-id resolution (#2185) ────────────────────
|
|
|
|
|
|
class TestResultDrawerId:
|
|
"""The drawer_id on a search hit must round-trip through
|
|
``mempalace_get_drawer``. Chunk rows carry their logical-group id under
|
|
``parent_drawer_id`` (``tool_add_drawer``) or ``parent_entry_id``
|
|
(``tool_diary_write``); both must resolve to the logical id (#2185).
|
|
"""
|
|
|
|
def test_plain_drawer_returns_stored_id(self):
|
|
assert _result_drawer_id({"wing": "w"}, "drawer_abc") == "drawer_abc"
|
|
|
|
def test_parent_drawer_id_wins_over_stored_chunk_id(self):
|
|
meta = {"parent_drawer_id": "drawer_abc", "chunk_index": 2}
|
|
assert _result_drawer_id(meta, "drawer_abc_chunk_000002") == "drawer_abc"
|
|
|
|
def test_parent_entry_id_resolves_for_diary_chunks(self):
|
|
"""Regression for #2185: before the fix a chunked diary hit reported
|
|
the physical chunk id, so fetching it returned one chunk of the entry
|
|
instead of the whole entry."""
|
|
meta = {"parent_entry_id": "diary_wing_lily_20260808_1", "chunk_index": 3}
|
|
stored = "diary_wing_lily_20260808_1_chunk_000003"
|
|
assert _result_drawer_id(meta, stored) == "diary_wing_lily_20260808_1"
|
|
|
|
def test_missing_metadata_falls_back_to_stored_id(self):
|
|
assert _result_drawer_id(None, "drawer_abc") == "drawer_abc"
|
|
|
|
|
|
# ── since/before date window (#463) ────────────────────────────────────
|
|
|
|
|
|
class TestSearchMemoriesDateFilter:
|
|
"""search_memories accepts since/before ISO bounds filtered on filed_at.
|
|
|
|
Window semantics mirror list_drawers (#1128): since inclusive, before
|
|
exclusive, wall-clock naive comparison, undated drawers excluded while
|
|
a bound is active. Seeded filed_at values: aaa=01-01, bbb=01-02,
|
|
ccc=01-03, ddd=01-04 (see conftest seeded_collection).
|
|
"""
|
|
|
|
BROAD = "authentication database frontend sprint planning"
|
|
|
|
def test_since_narrows_to_newer_drawers(self, palace_path, seeded_collection):
|
|
result = search_memories(self.BROAD, palace_path, n_results=10, since="2026-01-03")
|
|
assert result["results"], "expected in-window hits"
|
|
assert all(r["created_at"] >= "2026-01-03" for r in result["results"])
|
|
|
|
def test_before_narrows_to_older_drawers(self, palace_path, seeded_collection):
|
|
result = search_memories(self.BROAD, palace_path, n_results=10, before="2026-01-02")
|
|
assert result["results"]
|
|
assert all(r["created_at"] < "2026-01-02" for r in result["results"])
|
|
|
|
def test_window_both_bounds(self, palace_path, seeded_collection):
|
|
result = search_memories(
|
|
self.BROAD, palace_path, n_results=10, since="2026-01-02", before="2026-01-04"
|
|
)
|
|
got = sorted(r["created_at"][:10] for r in result["results"])
|
|
assert got == ["2026-01-02", "2026-01-03"]
|
|
|
|
def test_since_boundary_inclusive(self, palace_path, seeded_collection):
|
|
result = search_memories(self.BROAD, palace_path, n_results=10, since="2026-01-04")
|
|
assert [r["created_at"][:10] for r in result["results"]] == ["2026-01-04"]
|
|
|
|
def test_before_boundary_exclusive(self, palace_path, seeded_collection):
|
|
result = search_memories(self.BROAD, palace_path, n_results=10, before="2026-01-04")
|
|
assert "2026-01-04" not in [r["created_at"][:10] for r in result["results"]]
|
|
assert len(result["results"]) == 3
|
|
|
|
def test_invalid_since_returns_error(self, palace_path, seeded_collection):
|
|
result = search_memories(self.BROAD, palace_path, since="next tuesday")
|
|
assert "error" in result
|
|
assert "since" in result["error"]
|
|
|
|
def test_inverted_window_returns_error(self, palace_path, seeded_collection):
|
|
result = search_memories(self.BROAD, palace_path, since="2026-01-04", before="2026-01-01")
|
|
assert "error" in result
|
|
assert "must be earlier than" in result["error"]
|
|
|
|
def test_undated_drawer_excluded_while_bound_active(self, palace_path, seeded_collection):
|
|
seeded_collection.upsert(
|
|
ids=["undated1"],
|
|
documents=["Undated planning note about authentication frontend database."],
|
|
metadatas=[{"wing": "notes", "room": "planning", "source_file": "undated.md"}],
|
|
)
|
|
result = search_memories(self.BROAD, palace_path, n_results=10, since="2026-01-01")
|
|
assert "undated.md" not in [r["source_file"] for r in result["results"]]
|
|
# ...but with no bound it is searchable as before.
|
|
result = search_memories(self.BROAD, palace_path, n_results=10)
|
|
assert "undated.md" in [r["source_file"] for r in result["results"]]
|
|
|
|
def test_aware_filed_at_matches_wall_clock(self, palace_path, seeded_collection):
|
|
# diary_ingest stamps aware UTC; the window compares wall-clock fields.
|
|
seeded_collection.upsert(
|
|
ids=["aware1"],
|
|
documents=["Aware planning entry about database sprint authentication."],
|
|
metadatas=[
|
|
{
|
|
"wing": "notes",
|
|
"room": "planning",
|
|
"source_file": "aware.md",
|
|
"filed_at": "2026-01-05T12:00:00+00:00",
|
|
}
|
|
],
|
|
)
|
|
result = search_memories(self.BROAD, palace_path, n_results=10, since="2026-01-05")
|
|
assert [r["source_file"] for r in result["results"]] == ["aware.md"]
|
|
|
|
def test_filters_envelope_echoes_window(self, palace_path, seeded_collection):
|
|
result = search_memories(self.BROAD, palace_path, since="2026-01-02", before="2026-01-03")
|
|
assert result["filters"]["since"] == "2026-01-02"
|
|
assert result["filters"]["before"] == "2026-01-03"
|
|
|
|
def test_no_window_keeps_filters_none(self, palace_path, seeded_collection):
|
|
result = search_memories("authentication", palace_path)
|
|
assert result["filters"]["since"] is None
|
|
assert result["filters"]["before"] is None
|
|
|
|
def test_window_composes_with_wing_filter(self, palace_path, seeded_collection):
|
|
result = search_memories(
|
|
self.BROAD, palace_path, n_results=10, wing="project", since="2026-01-02"
|
|
)
|
|
got = {(r["wing"], r["created_at"][:10]) for r in result["results"]}
|
|
assert got == {("project", "2026-01-02"), ("project", "2026-01-03")}
|
|
|
|
def test_max_distance_zero_results_stay_empty_not_error(self, palace_path, seeded_collection):
|
|
result = search_memories("zebra quantum blockchain", palace_path, since="2027-01-01")
|
|
assert "error" not in result
|
|
assert result["results"] == []
|
|
|
|
def test_date_window_widens_candidate_pool(self, palace_path):
|
|
# 12 near-duplicate drawers embed closest to the query; the one
|
|
# in-window drawer is textually farther, so the historical 3x pool
|
|
# (n_results=2 -> 6) would never contain it. The widened window
|
|
# pool must recover it: recall is the design requirement.
|
|
from mempalace.palace import get_collection
|
|
|
|
col = get_collection(palace_path, create=True)
|
|
ids, docs, metas = [], [], []
|
|
for i in range(12):
|
|
ids.append(f"near{i}")
|
|
docs.append(f"Weekly budget meeting notes revision {i} about spending review.")
|
|
metas.append(
|
|
{
|
|
"wing": "fin",
|
|
"room": "budget",
|
|
"source_file": f"near{i}.md",
|
|
"filed_at": "2026-02-01T00:00:00",
|
|
}
|
|
)
|
|
ids.append("target")
|
|
docs.append("Quarterly offsite retrospective and travel logistics summary.")
|
|
metas.append(
|
|
{
|
|
"wing": "fin",
|
|
"room": "budget",
|
|
"source_file": "target.md",
|
|
"filed_at": "2026-03-01T00:00:00",
|
|
}
|
|
)
|
|
col.upsert(ids=ids, documents=docs, metadatas=metas)
|
|
result = search_memories(
|
|
"budget meeting spending review",
|
|
palace_path,
|
|
n_results=2,
|
|
since="2026-02-15",
|
|
)
|
|
assert [r["source_file"] for r in result["results"]] == ["target.md"]
|
|
|
|
def test_bm25_fallback_respects_window(self, palace_path, seeded_collection):
|
|
result = search_memories(
|
|
"authentication database frontend sprint planning tokens",
|
|
palace_path,
|
|
n_results=10,
|
|
vector_disabled=True,
|
|
since="2026-01-02",
|
|
before="2026-01-04",
|
|
)
|
|
assert result.get("fallback") == "bm25_only_via_sqlite"
|
|
assert result["results"], "expected in-window bm25 hits"
|
|
assert sorted(r["created_at"][:10] for r in result["results"]) == [
|
|
"2026-01-02",
|
|
"2026-01-03",
|
|
]
|
|
assert result["filters"]["since"] == "2026-01-02"
|
|
|
|
def test_bm25_fallback_includes_bare_date_filed_at_on_since_boundary(
|
|
self, palace_path, seeded_collection
|
|
):
|
|
# A bare-date filed_at equal to the since day is in-window
|
|
# (since is inclusive, parsed as midnight). The SQL prefilter must
|
|
# not drop it before the authoritative Python check: lexicographic
|
|
# "2026-01-02" < "2026-01-02T00:00:00", so a full-isoformat lower
|
|
# bound would exclude it at the SQL layer where Python can't
|
|
# recover it (review finding on the #463 change).
|
|
seeded_collection.upsert(
|
|
ids=["bare1", "space1", "zulu1"],
|
|
documents=[
|
|
"Bare-date drawer about the database sprint planning.",
|
|
"Space-separated drawer about the database sprint planning.",
|
|
"Zulu drawer about the database sprint planning.",
|
|
],
|
|
metadatas=[
|
|
{
|
|
"wing": "notes",
|
|
"room": "planning",
|
|
"source_file": "bare.md",
|
|
"filed_at": "2026-01-02",
|
|
},
|
|
{
|
|
"wing": "notes",
|
|
"room": "planning",
|
|
"source_file": "space.md",
|
|
# sqlite CURRENT_TIMESTAMP style: space separator sorts
|
|
# before "T" and a full-isoformat SQL bound would drop
|
|
# the whole boundary day.
|
|
"filed_at": "2026-01-02 09:30:00",
|
|
},
|
|
{
|
|
"wing": "notes",
|
|
"room": "planning",
|
|
"source_file": "zulu.md",
|
|
# "Z" sorts after a fractional bound; the day-granular
|
|
# upper prefilter must keep it for the Python check.
|
|
"filed_at": "2026-01-02T10:00:00Z",
|
|
},
|
|
],
|
|
)
|
|
result = search_memories(
|
|
"database sprint planning",
|
|
palace_path,
|
|
n_results=10,
|
|
vector_disabled=True,
|
|
since="2026-01-02",
|
|
before="2026-01-02T10:00:00.500000",
|
|
)
|
|
assert result.get("fallback") == "bm25_only_via_sqlite"
|
|
got = [r["source_file"] for r in result["results"]]
|
|
assert "bare.md" in got
|
|
assert "space.md" in got
|
|
assert "zulu.md" in got
|
|
|
|
def test_bm25_fallback_invalid_since_errors(self, palace_path, seeded_collection):
|
|
result = search_memories(
|
|
"authentication", palace_path, vector_disabled=True, since="garbage"
|
|
)
|
|
assert "error" in result
|
|
assert "since" in result["error"]
|
|
|
|
def test_pool_truncated_flag_set_when_widened_pool_full(self, palace_path):
|
|
# n_results=1 -> widened pool = 15; seed 16 in-window drawers so the
|
|
# backend returns a full pool and the honesty flag must fire.
|
|
from mempalace.palace import get_collection
|
|
|
|
col = get_collection(palace_path, create=True)
|
|
ids, docs, metas = [], [], []
|
|
for i in range(16):
|
|
ids.append(f"flag{i}")
|
|
docs.append(f"standup summary entry number {i} about deploy status.")
|
|
metas.append(
|
|
{
|
|
"wing": "ops",
|
|
"room": "standup",
|
|
"source_file": f"flag{i}.md",
|
|
"filed_at": "2026-05-01T00:00:00",
|
|
}
|
|
)
|
|
col.upsert(ids=ids, documents=docs, metadatas=metas)
|
|
result = search_memories(
|
|
"standup deploy status", palace_path, n_results=1, since="2026-04-01"
|
|
)
|
|
assert result.get("date_filter_pool_truncated") is True
|
|
assert result["total_before_filter"] >= 15
|
|
|
|
def test_pool_truncated_flag_absent_on_small_corpus(self, palace_path, seeded_collection):
|
|
result = search_memories(
|
|
"authentication database", palace_path, n_results=5, since="2026-01-01"
|
|
)
|
|
assert "date_filter_pool_truncated" not in result
|
|
|
|
def test_bm25_fallback_survives_calendar_ceiling_before(self, palace_path, seeded_collection):
|
|
# before="9999-12-31" is a plausible open-ended sentinel; the
|
|
# day-granular SQL prefilter must not overflow past datetime.max
|
|
# on the resilience path (it degrades to no SQL narrowing and the
|
|
# Python filter decides).
|
|
result = search_memories(
|
|
"authentication database frontend sprint planning",
|
|
palace_path,
|
|
n_results=10,
|
|
vector_disabled=True,
|
|
since="2026-01-01",
|
|
before="9999-12-31",
|
|
)
|
|
assert "error" not in result
|
|
assert result.get("fallback") == "bm25_only_via_sqlite"
|
|
assert len(result["results"]) == 4
|
|
|
|
def test_bm25_fallback_pool_truncated_flag(self, palace_path, seeded_collection):
|
|
# Direct call with a tiny max_candidates: the FTS page comes back
|
|
# full under an active window -> the same honesty flag as the
|
|
# vector path; without a window the key stays absent.
|
|
from datetime import datetime
|
|
|
|
from mempalace.searcher import _bm25_only_via_sqlite
|
|
|
|
truncated = _bm25_only_via_sqlite(
|
|
"authentication database frontend sprint",
|
|
palace_path,
|
|
n_results=2,
|
|
max_candidates=2,
|
|
since_dt=datetime(2026, 1, 1),
|
|
before_dt=None,
|
|
)
|
|
assert truncated.get("date_filter_pool_truncated") is True
|
|
unwindowed = _bm25_only_via_sqlite(
|
|
"authentication database frontend sprint",
|
|
palace_path,
|
|
n_results=2,
|
|
max_candidates=2,
|
|
)
|
|
assert "date_filter_pool_truncated" not in unwindowed
|
|
|
|
def test_union_strategy_respects_window(self, palace_path, seeded_collection):
|
|
# The out-of-window drawer has the strongest lexical signal for the
|
|
# query; union mode must not smuggle it past the window.
|
|
seeded_collection.upsert(
|
|
ids=["lex1"],
|
|
documents=["passkeys passkeys passkeys rollout checklist."],
|
|
metadatas=[
|
|
{
|
|
"wing": "notes",
|
|
"room": "planning",
|
|
"source_file": "lex.md",
|
|
"filed_at": "2026-01-01T00:00:00",
|
|
}
|
|
],
|
|
)
|
|
result = search_memories(
|
|
"passkeys rollout",
|
|
palace_path,
|
|
n_results=5,
|
|
candidate_strategy="union",
|
|
since="2026-01-02",
|
|
)
|
|
assert "error" not in result
|
|
assert "lex.md" not in [r["source_file"] for r in result["results"]]
|
|
|
|
def test_chunked_entry_is_windowed_whole_and_reported_logically(
|
|
self, palace_path, seeded_collection
|
|
):
|
|
"""A chunked entry meets the window as one unit and keeps its logical id.
|
|
|
|
Chunk rows are stored one per physical chunk and each carries the
|
|
group's ``filed_at``, so the window decides the whole entry; the hit
|
|
itself resolves to ``parent_entry_id`` (#2185). Neither side of that
|
|
pair is exercised by the other's tests: a physical chunk id leaking
|
|
into a date-filtered result would not round-trip through
|
|
``mempalace_get_drawer``, and a chunk row read as undated would drop
|
|
a dated entry out of an active window.
|
|
"""
|
|
seeded_collection.upsert(
|
|
ids=[
|
|
"diary_notes_ana_20260105_1_chunk_000000",
|
|
"diary_notes_ana_20260105_1_chunk_000001",
|
|
],
|
|
documents=[
|
|
"Passkey rollout retro, part one: what the migration plan got right.",
|
|
"Passkey rollout retro, part two: what the fallback plan missed.",
|
|
],
|
|
metadatas=[
|
|
{
|
|
"wing": "notes",
|
|
"room": "diary",
|
|
"chunk_index": index,
|
|
"parent_entry_id": "diary_notes_ana_20260105_1",
|
|
"filed_at": "2026-01-05T00:00:00",
|
|
}
|
|
for index in range(2)
|
|
],
|
|
)
|
|
|
|
inside = search_memories(
|
|
"passkey rollout retro", palace_path, n_results=10, since="2026-01-05"
|
|
)
|
|
returned = [r["drawer_id"] for r in inside["results"]]
|
|
assert "diary_notes_ana_20260105_1" in returned
|
|
assert not any("_chunk_" in drawer_id for drawer_id in returned)
|
|
|
|
outside = search_memories(
|
|
"passkey rollout retro", palace_path, n_results=10, before="2026-01-05"
|
|
)
|
|
excluded = [r["drawer_id"] for r in outside["results"]]
|
|
assert "diary_notes_ana_20260105_1" not in excluded
|
|
assert not any("_chunk_" in drawer_id for drawer_id in excluded)
|
|
|
|
|
|
class TestCliSearchDateFilter:
|
|
"""The printing CLI path accepts the same since/before window."""
|
|
|
|
def test_cli_search_since_filters_output(self, palace_path, seeded_collection, capsys):
|
|
search(
|
|
"authentication database frontend sprint planning",
|
|
palace_path,
|
|
n_results=10,
|
|
since="2026-01-04",
|
|
)
|
|
out = capsys.readouterr().out
|
|
assert "sprint.md" in out
|
|
assert "auth.py" not in out
|
|
assert "db.py" not in out
|
|
|
|
def test_cli_search_reranks_full_window_pool_before_trim(self, fake_palace_path, capsys):
|
|
# Regression for the review finding: under an active window the CLI
|
|
# must hybrid-re-rank ALL in-window survivors and trim to n_results
|
|
# AFTER the re-rank. A BM25-strong drawer sitting deep in the
|
|
# vector ordering (position 25 of 30) must still surface in the
|
|
# printed top-2; trimming before the re-rank would cut it at
|
|
# position n_results and it could never appear.
|
|
mock_col = MagicMock()
|
|
mock_col.metadata = {"hnsw:space": "cosine"}
|
|
docs, metas, dists = [], [], []
|
|
for i in range(30):
|
|
text = "unrelated filler paragraph number {}".format(i)
|
|
if i == 25:
|
|
text = "quixotic zephyr baseline report" # exact query tokens
|
|
docs.append(text)
|
|
metas.append(
|
|
{
|
|
"wing": "w",
|
|
"room": "r",
|
|
"source_file": "doc{}.md".format(i),
|
|
"filed_at": "2026-01-10T00:00:00",
|
|
}
|
|
)
|
|
dists.append(0.30 + i * 0.01) # strictly increasing vector distance
|
|
mock_col.query.return_value = {
|
|
"documents": [docs],
|
|
"metadatas": [metas],
|
|
"distances": [dists],
|
|
}
|
|
with patch("mempalace.searcher.get_collection", return_value=mock_col):
|
|
search(
|
|
"quixotic zephyr baseline",
|
|
fake_palace_path,
|
|
n_results=2,
|
|
since="2026-01-01",
|
|
)
|
|
out = capsys.readouterr().out
|
|
assert "doc25.md" in out
|
|
# The widened pool was requested from the backend, not just n_results.
|
|
assert mock_col.query.call_args.kwargs["n_results"] > 2
|
|
|
|
def test_cli_search_invalid_since_raises_search_error(
|
|
self, palace_path, seeded_collection, capsys
|
|
):
|
|
import pytest
|
|
|
|
with pytest.raises(SearchError, match="since"):
|
|
search("anything", palace_path, since="garbage")
|
|
|
|
def test_cli_search_inverted_window_raises(self, palace_path, seeded_collection):
|
|
import pytest
|
|
|
|
with pytest.raises(SearchError, match="must be earlier than"):
|
|
search("anything", palace_path, since="2026-01-04", before="2026-01-01")
|