Skip to content

Commit e89c0b6

Browse files
jpheinclaude
andcommitted
fix: document pre-filter tradeoffs, improve test assertions and coverage
Addresses Copilot review feedback on MemPalace#660. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1356207 commit e89c0b6

2 files changed

Lines changed: 75 additions & 7 deletions

File tree

mempalace/layers.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,13 @@ class Layer1:
8282

8383
MAX_DRAWERS = 15 # at most 15 moments in wake-up
8484
MAX_CHARS = 3200 # hard cap on total L1 text (~800 tokens)
85-
MAX_SCAN = 2000 # don't scan more than this for L1 generation
85+
# MAX_SCAN caps how many drawers we read for L1 generation. This prevents
86+
# O(n) full-table scans on large palaces (100K+ drawers) which are too slow
87+
# for wake-up. The tradeoff: the top-15 selection is approximate for very
88+
# large datasets — drawers beyond MAX_SCAN are never considered. This is
89+
# acceptable because L1 is a best-effort summary, and L3 deep search covers
90+
# the full corpus when precision matters.
91+
MAX_SCAN = 2000
8692

8793
def __init__(self, palace_path: str = None, wing: str = None):
8894
cfg = MempalaceConfig()
@@ -97,7 +103,12 @@ def _fetch_drawers(self, col) -> tuple:
97103
"""
98104
_BATCH = 500
99105

100-
# Fast path: only fetch drawers with importance >= 3
106+
# Fast path: only fetch drawers with importance >= 3.
107+
# This is an optimization that catches the common case — importance is
108+
# the primary signal in generate()'s scoring. generate() also considers
109+
# emotional_weight and weight, but those are rarely set without a
110+
# corresponding importance value. The fallback full-scan below ensures
111+
# nothing is missed when the fast path returns too few results.
101112
importance_filter = {"importance": {"$gte": 3}}
102113
if self.wing:
103114
where = {"$and": [{"wing": self.wing}, importance_filter]}

tests/test_layers.py

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,12 @@ def test_layer0_default_path():
7373
def _mock_chromadb_for_layer(docs, metas, monkeypatch=None):
7474
"""Return a mock collection whose get() returns docs/metas.
7575
76-
Layer1._fetch_drawers() has two phases: a fast-path (importance pre-filter)
77-
and a fallback full-scan. For small test datasets (< 500 items), each phase
78-
makes exactly one col.get() call before breaking (len < _BATCH). We
79-
provide two identical responses: one consumed by the fast path and one by
80-
the fallback.
76+
Layer1._fetch_drawers() has two phases: a fast-path (importance >= 3
77+
pre-filter) and a fallback full-scan. For small test datasets (< 500
78+
items), each phase makes exactly one col.get() call before breaking
79+
(len < _BATCH). We provide two identical responses: the first is consumed
80+
by the fast path, and the second is only consumed when the fast path
81+
doesn't return enough results (< MAX_DRAWERS) and the fallback executes.
8182
"""
8283
mock_col = MagicMock()
8384
mock_col.get.side_effect = [
@@ -153,6 +154,7 @@ def test_layer1_with_wing_filter():
153154
where = call_kwargs.get("where", {})
154155
assert "$and" in where
155156
assert {"wing": "project_x"} in where["$and"]
157+
assert {"importance": {"$gte": 3}} in where["$and"]
156158

157159

158160
def test_layer1_truncates_long_snippets():
@@ -210,6 +212,61 @@ def test_layer1_importance_from_various_keys():
210212
assert "ESSENTIAL STORY" in result
211213

212214

215+
def test_layer1_pagination_stops_at_max_scan():
216+
"""_fetch_drawers() paginates in _BATCH (500) chunks and stops at MAX_SCAN."""
217+
_BATCH = 500
218+
# Build a full batch of 500 docs (first page) and a partial second page
219+
batch_docs = [f"doc{i}" for i in range(_BATCH)]
220+
batch_metas = [{"room": "r", "importance": 5} for _ in range(_BATCH)]
221+
partial_docs = [f"doc{i}" for i in range(100)]
222+
partial_metas = [{"room": "r", "importance": 5} for _ in range(100)]
223+
224+
mock_col = MagicMock()
225+
# Fast path: page 1 (full batch) -> page 2 (partial, triggers break)
226+
mock_col.get.side_effect = [
227+
{"documents": batch_docs, "metadatas": batch_metas},
228+
{"documents": partial_docs, "metadatas": partial_metas},
229+
]
230+
231+
with (
232+
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
233+
patch("mempalace.layers._get_collection", return_value=mock_col),
234+
):
235+
mock_cfg.return_value.palace_path = "/fake"
236+
layer = Layer1(palace_path="/fake")
237+
result = layer.generate()
238+
239+
# Fast path got 600 results (>= MAX_DRAWERS=15), so no fallback needed.
240+
# Two get() calls: first batch of 500, second batch of 100 (< _BATCH -> break).
241+
assert mock_col.get.call_count == 2
242+
assert "ESSENTIAL STORY" in result
243+
244+
245+
def test_layer1_pagination_caps_at_max_scan():
246+
"""_fetch_drawers() stops reading once MAX_SCAN is reached, even mid-pagination."""
247+
_BATCH = 500
248+
batch_docs = [f"doc{i}" for i in range(_BATCH)]
249+
batch_metas = [{"room": "r", "importance": 5} for _ in range(_BATCH)]
250+
251+
mock_col = MagicMock()
252+
# Return full batches every time — the loop should stop after hitting MAX_SCAN
253+
mock_col.get.return_value = {"documents": batch_docs, "metadatas": batch_metas}
254+
255+
with (
256+
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
257+
patch("mempalace.layers._get_collection", return_value=mock_col),
258+
):
259+
mock_cfg.return_value.palace_path = "/fake"
260+
layer = Layer1(palace_path="/fake")
261+
layer.MAX_SCAN = 1200 # Should stop after 3 pages (500+500+500 >= 1200)
262+
result = layer.generate()
263+
264+
# Fast path: 3 pages to reach >= MAX_SCAN, then stops.
265+
# No fallback since 1500 >= MAX_DRAWERS.
266+
assert mock_col.get.call_count == 3
267+
assert "ESSENTIAL STORY" in result
268+
269+
213270
def test_layer1_batch_exception_breaks():
214271
"""If the fast-path raises, fallback scan still returns results gracefully."""
215272
mock_col = MagicMock()

0 commit comments

Comments
 (0)