Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 55 additions & 6 deletions mempalace/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,54 @@ def __init__(self, palace_path: str = None, wing: str = None):
self.palace_path = palace_path or cfg.palace_path
self.wing = wing

def generate(self) -> str:
"""Pull top drawers from ChromaDB and format as compact L1 text."""
def _fetch_drawers(self, col) -> tuple:
"""Fetch drawers for L1 generation.

Tries a pre-filtered query for high-importance drawers first (fast path).
Falls back to a full scan only if too few results come back.
"""
_BATCH = 500

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

docs, metas = [], []
offset = 0
try:
col = _get_collection(self.palace_path, create=False)
while True:
kwargs = {
"include": ["documents", "metadatas"],
"limit": _BATCH,
"offset": offset,
"where": where,
}
batch = col.get(**kwargs)
batch_docs = batch.get("documents", [])
batch_metas = batch.get("metadatas", [])
if not batch_docs:
break
docs.extend(batch_docs)
metas.extend(batch_metas)
offset += len(batch_docs)
if len(batch_docs) < _BATCH or len(docs) >= self.MAX_SCAN:
break
except Exception:
return "## L1 — No palace found. Run: mempalace mine <dir>"
docs, metas = [], []

# Fetch all drawers in batches to avoid SQLite variable limit (~999)
_BATCH = 500
# If enough high-importance drawers, use them
if len(docs) >= self.MAX_DRAWERS:
return docs, metas
Comment on lines +135 to +142

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capping the fast-path results (len(docs) >= MAX_SCAN) and then returning them when len(docs) >= MAX_DRAWERS means generate() is no longer guaranteed to select the true top-15 drawers by importance across the whole palace: col.get() order isn’t tied to importance, and the highest-importance drawers may exist beyond the first MAX_SCAN matches. This also contradicts the PR description’s claim that output is unchanged when enough high-importance drawers exist. If exactness matters, avoid the cap on the candidate set (or use a deterministic multi-threshold strategy like querying for importance>=5, then >=4, etc. until 15 are collected).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented — added multi-line comment explaining the MAX_SCAN tradeoff: prevents O(n) scans on 100K+ palaces, accepts approximate top-15. L3 deep search covers full corpus when precision matters.


# Slow fallback: scan without importance filter
docs, metas = [], []
offset = 0
while True:
Expand All @@ -122,6 +161,16 @@ def generate(self) -> str:
offset += len(batch_docs)
if len(batch_docs) < _BATCH or len(docs) >= self.MAX_SCAN:
break
return docs, metas

def generate(self) -> str:
"""Pull top drawers from ChromaDB and format as compact L1 text."""
try:
col = _get_collection(self.palace_path, create=False)
except Exception:
return "## L1 — No palace found. Run: mempalace mine <dir>"

docs, metas = self._fetch_drawers(col)

if not docs:
return "## L1 — No memories yet."
Expand Down
84 changes: 76 additions & 8 deletions tests/test_layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,19 @@ def test_layer0_default_path():


def _mock_chromadb_for_layer(docs, metas, monkeypatch=None):
"""Return a mock collection whose get() returns docs/metas."""
"""Return a mock collection whose get() returns docs/metas.

Layer1._fetch_drawers() has two phases: a fast-path (importance >= 3
pre-filter) and a fallback full-scan. For small test datasets (< 500
items), each phase makes exactly one col.get() call before breaking
(len < _BATCH). We provide two identical responses: the first is consumed
by the fast path, and the second is only consumed when the fast path
doesn't return enough results (< MAX_DRAWERS) and the fallback executes.
"""
mock_col = MagicMock()
# First batch returns data, second batch returns empty (end of pagination)
mock_col.get.side_effect = [
{"documents": docs, "metadatas": metas},
{"documents": [], "metadatas": []},
{"documents": docs, "metadatas": metas}, # fast-path batch
{"documents": docs, "metadatas": metas}, # fallback batch (< MAX_DRAWERS → fallback)
]
Comment on lines +74 to 87

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The helper docstring says it provides “two identical responses: one consumed by the fast path and one by the fallback”, but several tests pass >= MAX_DRAWERS docs so the fallback isn’t executed and the second response is unused. Consider rewording the docstring/comments to clarify that the second response is only consumed when the fallback path is taken.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — updated docstring to accurately state the second response is only consumed when the fast path returns fewer than MAX_DRAWERS results.

return mock_col

Expand Down Expand Up @@ -141,9 +148,13 @@ def test_layer1_with_wing_filter():
result = layer.generate()

assert "ESSENTIAL STORY" in result
# Verify wing filter was passed
# Verify wing filter was passed in the first (fast-path) call.
# The fast-path combines wing with an importance pre-filter via $and.
call_kwargs = mock_col.get.call_args_list[0][1]
assert call_kwargs.get("where") == {"wing": "project_x"}
where = call_kwargs.get("where", {})
assert "$and" in where
assert {"wing": "project_x"} in where["$and"]

Copilot AI Apr 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts that the fast-path where contains the wing filter, but it no longer asserts that the importance pre-filter is also present. Adding an assertion for { "importance": {"$gte": 3} } inside the $and list would prevent regressions where the optimization silently stops applying the importance constraint.

Suggested change
assert {"wing": "project_x"} in where["$and"]
assert {"wing": "project_x"} in where["$and"]
assert {"importance": {"$gte": 3}} in where["$and"]

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added assert {"importance": {"$gte": 3}} in where["$and"] to verify the importance pre-filter is present.

assert {"importance": {"$gte": 3}} in where["$and"]


def test_layer1_truncates_long_snippets():
Expand Down Expand Up @@ -201,12 +212,69 @@ def test_layer1_importance_from_various_keys():
assert "ESSENTIAL STORY" in result


def test_layer1_pagination_stops_at_max_scan():
"""_fetch_drawers() paginates in _BATCH (500) chunks and stops at MAX_SCAN."""
_BATCH = 500
# Build a full batch of 500 docs (first page) and a partial second page
batch_docs = [f"doc{i}" for i in range(_BATCH)]
batch_metas = [{"room": "r", "importance": 5} for _ in range(_BATCH)]
partial_docs = [f"doc{i}" for i in range(100)]
partial_metas = [{"room": "r", "importance": 5} for _ in range(100)]

mock_col = MagicMock()
# Fast path: page 1 (full batch) -> page 2 (partial, triggers break)
mock_col.get.side_effect = [
{"documents": batch_docs, "metadatas": batch_metas},
{"documents": partial_docs, "metadatas": partial_metas},
]

with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake")
result = layer.generate()

# Fast path got 600 results (>= MAX_DRAWERS=15), so no fallback needed.
# Two get() calls: first batch of 500, second batch of 100 (< _BATCH -> break).
assert mock_col.get.call_count == 2
assert "ESSENTIAL STORY" in result


def test_layer1_pagination_caps_at_max_scan():
"""_fetch_drawers() stops reading once MAX_SCAN is reached, even mid-pagination."""
_BATCH = 500
batch_docs = [f"doc{i}" for i in range(_BATCH)]
batch_metas = [{"room": "r", "importance": 5} for _ in range(_BATCH)]

mock_col = MagicMock()
# Return full batches every time — the loop should stop after hitting MAX_SCAN
mock_col.get.return_value = {"documents": batch_docs, "metadatas": batch_metas}

with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
patch("mempalace.layers._get_collection", return_value=mock_col),
):
mock_cfg.return_value.palace_path = "/fake"
layer = Layer1(palace_path="/fake")
layer.MAX_SCAN = 1200 # Should stop after 3 pages (500+500+500 >= 1200)
result = layer.generate()

# Fast path: 3 pages to reach >= MAX_SCAN, then stops.
# No fallback since 1500 >= MAX_DRAWERS.
assert mock_col.get.call_count == 3
assert "ESSENTIAL STORY" in result


def test_layer1_batch_exception_breaks():
"""If col.get raises on a batch, loop breaks gracefully."""
"""If the fast-path raises, fallback scan still returns results gracefully."""
mock_col = MagicMock()
# Call 1: fast-path raises — caught, fast-path resets to empty
# Call 2: fallback returns one doc successfully
mock_col.get.side_effect = [
{"documents": ["doc1"], "metadatas": [{"room": "r"}]},
RuntimeError("batch error"),
{"documents": ["doc1"], "metadatas": [{"room": "r", "importance": 5}]},
]
with (
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
Expand Down
Loading