Skip to content

Commit 2611d4e

Browse files
committed
fix(layers): fetch the most recent drawers for L1 wake-up
PR #1630 fixed the L1 wake-up ordering by adding filed_at as a secondary sort key, and documented what it could not fix: "The fetch still scans up to MAX_SCAN=2000 drawers in collection (insertion) order, so on a very large unscoped wing the most-recent drawers may fall outside the cap before sorting... A SQL-side most-recent-N by filed_at fetch would fix the unscoped case but is a larger change left for a follow-up." This is that follow-up. On a 149k-drawer palace the sort was correct and the input was not: the 2000 drawers Layer1 scored were the oldest backfill slice, so wake-up permanently opened on the first files ever mined and never on this week's sessions. Backend capability rather than a pgvector special case: - BaseCollection.get_recent(limit, where, order_field, include) returns up to limit records newest-first on an ISO-8601 metadata field. The ABC default pages through get() and sorts the window locally, which is exactly what Layer1 did inline, so every backend that does not override it behaves as before. - PgVectorCollection overrides it with ORDER BY metadata->>%s DESC NULLS LAST, id pushed into the scan, and PgVectorBackend advertises the supports_recency_order capability token. That is exact at any table size. Filters that pgvector cannot push down exactly keep the existing local post-filter path. - EmbeddingCollection forwards get_recent explicitly. Without the forwarder, MRO would resolve the concrete ABC default on the wrapper and shadow the inner backend's pushdown (the invariant test_wrapper_forwards_all_concrete_basecollection_methods guards). - Layer1._fetch_candidates uses the capability and falls back to the previous paged scan when a collection predates get_recent or the backend errors, so wake-up degrades instead of failing. recency_sort_key is shared so every local sort orders identically: records missing the field, holding an empty string, or holding a non-string sort last instead of raising on a str/None comparison. No ordering semantics change for Layer1 itself. importance stays the primary key and filed_at the tiebreak; only the candidate window changes, from "the first 2000 rows the backend hands back" to "the 2000 most recently filed". Measured on the 149k-drawer palace this was written for: wake-up now leads with the newest sessions and renders in 0.72s. Tests: base default (ordering, missing/odd timestamps, window cap, where passthrough, dict-shaped get), pgvector pushdown (SQL text and bind order, filter pushdown, local-filter fallback, include, zero limit, custom order field), and Layer1 (capability used, wing filter forwarded, fallback when the capability is missing or raises).
1 parent 1d113e4 commit 2611d4e

9 files changed

Lines changed: 712 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88

99
## [Unreleased]
1010

11+
### Bug Fixes
12+
13+
- **Layer 1 wake-up fetches the most recent drawers instead of an arbitrary scan window.** `BaseCollection.get_recent()` returns the newest N by `filed_at`; pgvector overrides it with an `ORDER BY ... DESC LIMIT n` pushdown (capability token `supports_recency_order`), and backends without pushdown keep the previous scan-and-sort behavior. On palaces larger than the 2,000-drawer scan cap, wake-up no longer leads with the oldest backfill. (#1630)
14+
1115
---
1216

1317
## [3.7.0] — 2026-08-02

mempalace/backends/base.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,30 @@ class LexicalResult:
351351
hits: list[LexicalHit]
352352

353353

354+
def recency_sort_key(meta: Optional[dict], order_field: str = "filed_at") -> tuple[int, str]:
355+
"""Sort key for newest-first ordering on an ISO-8601 metadata field.
356+
357+
Returns ``(1, value)`` for a usable timestamp string and ``(0, "")``
358+
otherwise, so that with ``reverse=True`` records missing the field sort
359+
last instead of raising on a str/None comparison. Backends that implement
360+
:meth:`BaseCollection.get_recent` with a local sort MUST use this key so
361+
every backend orders identically.
362+
"""
363+
value = (meta or {}).get(order_field)
364+
if not isinstance(value, str) or not value:
365+
return (0, "")
366+
return (1, value)
367+
368+
369+
def _recency_order(metadatas: list[dict], order_field: str) -> list[int]:
370+
"""Indices into ``metadatas``, newest first, missing timestamps last."""
371+
return sorted(
372+
range(len(metadatas)),
373+
key=lambda i: recency_sort_key(metadatas[i], order_field),
374+
reverse=True,
375+
)
376+
377+
354378
# ---------------------------------------------------------------------------
355379
# Collection contract
356380
# ---------------------------------------------------------------------------
@@ -501,6 +525,74 @@ def get_all_metadata(self, where: Optional[dict] = None) -> list[dict]:
501525
offset += len(batch_meta)
502526
return all_meta
503527

528+
def get_recent(
529+
self,
530+
*,
531+
limit: int,
532+
where: Optional[dict] = None,
533+
order_field: str = "filed_at",
534+
include: Optional[list[str]] = None,
535+
) -> GetResult:
536+
"""Return up to ``limit`` records, newest first by ``order_field``.
537+
538+
``order_field`` names a metadata key holding an ISO-8601 timestamp
539+
string (``filed_at`` for drawers). Ordering is descending on that
540+
string, which is chronological for ISO-8601. Records whose value is
541+
missing, empty, or not a string sort last.
542+
543+
The default implementation pages through :meth:`get` in storage order
544+
up to ``limit`` records and sorts that window locally. That is exact
545+
for a collection no larger than ``limit`` and *approximate* above it:
546+
the window is whatever the backend hands back first, so the genuinely
547+
newest records can fall outside it (issue #1630's known limitation for
548+
Layer 1 wake-up). Backends able to push the ordering into storage MUST
549+
override this and advertise the ``supports_recency_order`` capability
550+
token, which promises exactness at any collection size.
551+
552+
Callers that need the guarantee should check the token rather than
553+
assume it; the default is always available so no backend breaks.
554+
"""
555+
if limit <= 0:
556+
return GetResult.empty()
557+
include = ["documents", "metadatas"] if include is None else list(include)
558+
559+
ids: list[str] = []
560+
documents: list[str] = []
561+
metadatas: list[dict] = []
562+
offset = 0
563+
fetched = 0
564+
page_size = min(500, limit)
565+
while fetched < limit:
566+
kwargs: dict = {"include": include, "limit": page_size, "offset": offset}
567+
if where:
568+
kwargs["where"] = where
569+
batch = self.get(**kwargs)
570+
batch_ids = list(batch.get("ids") or [])
571+
batch_docs = list(batch.get("documents") or [])
572+
batch_metas = list(batch.get("metadatas") or [])
573+
page_len = max(len(batch_ids), len(batch_docs), len(batch_metas))
574+
if not page_len:
575+
break
576+
# Pad the projections the caller did not request so the three
577+
# lists stay index-aligned for the sort below.
578+
ids.extend(batch_ids or [""] * page_len)
579+
documents.extend(batch_docs or [""] * page_len)
580+
metadatas.extend(batch_metas or [{}] * page_len)
581+
offset += page_len
582+
fetched += page_len
583+
if page_len < page_size:
584+
break
585+
586+
n = min(len(ids), len(documents), len(metadatas))
587+
ids, documents, metadatas = ids[:n], documents[:n], metadatas[:n]
588+
order = _recency_order(metadatas, order_field)[:limit]
589+
return GetResult(
590+
ids=[ids[i] for i in order],
591+
documents=[documents[i] for i in order],
592+
metadatas=[metadatas[i] for i in order],
593+
embeddings=None,
594+
)
595+
504596
def facet_counts(
505597
self,
506598
field: str,

mempalace/backends/embedding_wrapper.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,21 @@ def health(self):
155155
def lexical_search(self, *, query: str, n_results: int = 10, where: Optional[dict] = None):
156156
return self._inner.lexical_search(query=query, n_results=n_results, where=where)
157157

158+
def get_recent(
159+
self,
160+
*,
161+
limit: int,
162+
where: Optional[dict] = None,
163+
order_field: str = "filed_at",
164+
include: Optional[list[str]] = None,
165+
):
166+
# Concrete on ``BaseCollection`` (the scan-and-sort default), so MRO
167+
# would resolve it here and shadow a backend that pushes the ordering
168+
# into storage. Forward explicitly.
169+
return self._inner.get_recent(
170+
limit=limit, where=where, order_field=order_field, include=include
171+
)
172+
158173
def facet_counts(
159174
self, field: str, where: Optional[dict] = None, limit: int = 1000
160175
) -> dict[str, int]:

mempalace/backends/pgvector.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,7 @@ def scroll_rows(
677677
with_document: bool = True,
678678
limit: Optional[int] = None,
679679
offset: Optional[int] = None,
680+
order_field: Optional[str] = None,
680681
) -> list[dict]:
681682
qi = _quote_identifier(table)
682683
params: list = []
@@ -694,7 +695,21 @@ def scroll_rows(
694695
# primary key gives OFFSET a stable order (an unordered scan may skip
695696
# or repeat rows across pages); callers that scroll the whole table
696697
# pass neither bound, leaving their SQL unchanged.
697-
if limit is not None or offset:
698+
if order_field is not None:
699+
# Newest-first on an ISO-8601 metadata field. ISO-8601 sorts
700+
# chronologically as text, so ``metadata->>field DESC`` needs no
701+
# timestamp cast (a cast would also fail hard on one malformed
702+
# value). NULLS LAST keeps records without the field at the end;
703+
# ``id`` breaks ties so the order is total and stable.
704+
params.append(order_field)
705+
sql += " ORDER BY metadata->>%s DESC NULLS LAST, id"
706+
if limit is not None:
707+
params.append(int(limit))
708+
sql += " LIMIT %s"
709+
if offset:
710+
params.append(int(offset))
711+
sql += " OFFSET %s"
712+
elif limit is not None or offset:
698713
sql += " ORDER BY id"
699714
if limit is not None:
700715
params.append(int(limit))
@@ -869,6 +884,7 @@ def _scroll(
869884
with_document=True,
870885
limit=None,
871886
offset=None,
887+
order_field=None,
872888
) -> list[dict]:
873889
self._ensure_open()
874890
if not self._table_exists():
@@ -882,6 +898,7 @@ def _scroll(
882898
with_document=with_document,
883899
limit=limit,
884900
offset=offset,
901+
order_field=order_field,
885902
)
886903

887904
def get_all_metadata(self, where=None) -> list[dict]:
@@ -1170,6 +1187,42 @@ def get(
11701187
embeddings=[row["embedding"] or [] for row in rows] if spec.embeddings else None,
11711188
)
11721189

1190+
def get_recent(self, *, limit, where=None, order_field="filed_at", include=None):
1191+
"""Newest-first fetch with the ordering pushed into SQL.
1192+
1193+
The base implementation scans a window in storage order and sorts it
1194+
locally, so on a collection larger than ``limit`` the genuinely newest
1195+
records can be missing from the window entirely (#1630). Postgres can
1196+
do the whole thing: ``ORDER BY metadata->>'filed_at' DESC ... LIMIT n``
1197+
is exact at any table size, which is why this backend advertises
1198+
``supports_recency_order``.
1199+
1200+
Filters that ``metadata @> ...`` cannot express exactly fall back to
1201+
the local post-filter path (same correctness contract as ``get``), and
1202+
there the SQL order is still applied before the filter, so the result
1203+
is the newest ``limit`` rows *that match* only when the pushdown was
1204+
exact. The inexact case re-sorts locally after filtering.
1205+
"""
1206+
if limit is not None and limit <= 0:
1207+
return GetResult.empty()
1208+
_validate_where(where)
1209+
spec = _IncludeSpec.resolve(include, default_distances=False)
1210+
local_filter = _requires_local_filter(where)
1211+
rows = self._scroll(
1212+
where=None if local_filter else where,
1213+
with_embedding=spec.embeddings,
1214+
limit=None if local_filter else limit,
1215+
order_field=order_field,
1216+
)
1217+
if local_filter:
1218+
rows = [row for row in rows if _matches_where(row["metadata"], where)][:limit]
1219+
return GetResult(
1220+
ids=[row["id"] for row in rows],
1221+
documents=[row["document"] for row in rows] if spec.documents else [],
1222+
metadatas=[row["metadata"] for row in rows] if spec.metadatas else [],
1223+
embeddings=[row["embedding"] or [] for row in rows] if spec.embeddings else None,
1224+
)
1225+
11731226
def delete(self, *, ids=None, where=None):
11741227
_validate_where(where)
11751228
if not self._table_exists():
@@ -1291,6 +1344,7 @@ class PgVectorBackend(BaseBackend):
12911344
"supports_embeddings_out",
12921345
"supports_metadata_filters",
12931346
"supports_lexical_search",
1347+
"supports_recency_order",
12941348
"supports_namespace_isolation",
12951349
"supports_server_side_indexes",
12961350
"server_mode",

mempalace/layers.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -87,28 +87,47 @@ class Layer1:
8787

8888
MAX_DRAWERS = 15 # at most 15 moments in wake-up
8989
MAX_CHARS = 3200 # hard cap on total L1 text (~800 tokens)
90-
MAX_SCAN = 2000 # don't scan more than this for L1 generation
90+
MAX_SCAN = 2000 # size of the candidate window pulled for L1 generation
9191

9292
def __init__(self, palace_path: str = None, wing: str = None):
9393
cfg = MempalaceConfig()
9494
self.palace_path = palace_path or cfg.palace_path
9595
self.wing = wing
9696

97-
def generate(self) -> str:
98-
"""Pull top drawers from ChromaDB and format as compact L1 text."""
99-
try:
100-
col = _get_collection(self.palace_path, create=False)
101-
except Exception:
102-
return "## L1 — No palace found. Run: mempalace mine <dir>"
97+
def _fetch_candidates(self, col) -> tuple[list, list]:
98+
"""Fetch the L1 candidate window: the MAX_SCAN most recently filed drawers.
10399
104-
# Fetch all drawers in batches to avoid SQLite variable limit (~999)
100+
Uses the backend's ``get_recent`` capability, which pushes
101+
``ORDER BY filed_at DESC LIMIT n`` into storage where the backend can
102+
(pgvector today) and otherwise falls back to the scan-then-sort default
103+
in ``BaseCollection``. That default is what this method used to do
104+
inline, so backends without pushdown behave exactly as before.
105+
106+
Third-party collections predating ``get_recent`` (or any backend error)
107+
degrade to the inline paged scan below rather than failing wake-up.
108+
"""
109+
where = {"wing": self.wing} if self.wing else None
110+
getter = getattr(col, "get_recent", None)
111+
if getter is not None:
112+
try:
113+
result = getter(
114+
limit=self.MAX_SCAN,
115+
where=where,
116+
order_field="filed_at",
117+
include=["documents", "metadatas"],
118+
)
119+
return list(result.documents or []), list(result.metadatas or [])
120+
except Exception:
121+
pass # capability missing or backend hiccup — page it manually
122+
123+
# Fetch in batches to avoid SQLite variable limit (~999)
105124
_BATCH = 500
106125
docs, metas = [], []
107126
offset = 0
108127
while True:
109128
kwargs = {"include": ["documents", "metadatas"], "limit": _BATCH, "offset": offset}
110-
if self.wing:
111-
kwargs["where"] = {"wing": self.wing}
129+
if where:
130+
kwargs["where"] = where
112131
try:
113132
batch = col.get(**kwargs)
114133
except Exception:
@@ -122,6 +141,16 @@ def generate(self) -> str:
122141
offset += len(batch_docs)
123142
if len(batch_docs) < _BATCH or len(docs) >= self.MAX_SCAN:
124143
break
144+
return docs, metas
145+
146+
def generate(self) -> str:
147+
"""Pull top drawers from the palace and format as compact L1 text."""
148+
try:
149+
col = _get_collection(self.palace_path, create=False)
150+
except Exception:
151+
return "## L1 — No palace found. Run: mempalace mine <dir>"
152+
153+
docs, metas = self._fetch_candidates(col)
125154

126155
if not docs:
127156
return "## L1 — No memories yet."
@@ -136,6 +165,10 @@ def generate(self) -> str:
136165
# newest first. This keeps importance as the primary key for the day a
137166
# scoring pass populates it, while making the "recent filing" half of
138167
# the promise true today with data we already have.
168+
# The candidate window this sorts is now the MAX_SCAN *most recently
169+
# filed* drawers rather than the first MAX_SCAN the backend happened to
170+
# hand back, so on a palace larger than MAX_SCAN the newest drawers are
171+
# actually in the running (#1630's known limitation).
139172
scored = []
140173
for doc, meta in zip(docs, metas):
141174
meta = meta or {}

0 commit comments

Comments
 (0)