6060# Bound the population that reaches the quadratic fallback clustering pass while
6161# allowing the storage scan to page in smaller batches and skip pending rows.
6262DISTILL_CLUSTER_LIMIT = 2000
63+ # Carry a small, bounded set of incomplete clusters into the next sweep. This keeps
64+ # interleaved subjects from being permanently split across rotating windows without
65+ # turning a maintenance pass into an unbounded full-table scan.
66+ DISTILL_PENDING_LIMIT = 512
6367# Cursor name for the bounded episodic sweep; the value is scoped by workspace/repo.
6468DISTILL_CURSOR_NAME = "episodic-consolidation"
6569
@@ -203,29 +207,54 @@ def _scan_memory_window(store, flt: SearchFilter, *, mtypes: list[MemoryType],
203207 batch_size : int , prompt_only : bool = False ,
204208 max_records : Optional [int ] = None ,
205209 exclude_relation : Optional [str ] = None ,
206- start_after_id : str = "" ) -> tuple [list [MemoryRecord ], str ]:
210+ start_after_id : str = "" , overlap : int = 0 ,
211+ advance_records : Optional [int ] = None ,
212+ ) -> tuple [list [MemoryRecord ], str ]:
207213 """Read one bounded keyset window and return its next persistent cursor.
208214
209215 ``Store.list_memories_page`` orders by id. When a bounded window reaches the
210216 end, the empty cursor deliberately makes the *next* sweep wrap to the start;
211217 this rotates maintenance over all eligible rows without materializing or
212- clustering the full population on every run.
218+ clustering the full population on every run. ``overlap`` retains a bounded
219+ suffix of the raw keyset window for the next sweep, which keeps clusters that
220+ straddle a maintenance boundary intact. ``advance_records`` is a raw-row
221+ progress floor used when filtering leaves fewer than ``max_records`` eligible
222+ rows; it prevents a bounded sweep from pinning its cursor on an excluded page.
213223 """
214224 size = max (1 , int (batch_size ))
215225 cap = None if max_records is None else max (0 , int (max_records ))
216- if cap == 0 :
226+ advance_cap = (
227+ None if advance_records is None else max (0 , int (advance_records ))
228+ )
229+ if cap == 0 or advance_cap == 0 :
217230 return [], str (start_after_id or "" )
218231 after_id = str (start_after_id or "" )
219232 records : list [MemoryRecord ] = []
233+ window_ids : list [str ] = []
220234 scoped = _replace (flt , mtypes = mtypes )
221235 next_cursor = ""
236+
237+ def cursor_for_window () -> str :
238+ retained = min (max (0 , int (overlap )), max (0 , len (window_ids ) - 1 ))
239+ return window_ids [len (window_ids ) - retained - 1 ]
240+
222241 while True :
223- page = store .list_memories_page (scoped , after_id = after_id , limit = size )
242+ if advance_cap is not None and len (window_ids ) >= advance_cap :
243+ next_cursor = cursor_for_window ()
244+ break
245+ remaining = None if cap is None else max (1 , cap - len (records ))
246+ page_limit = size if remaining is None else min (size , remaining )
247+ if advance_cap is not None :
248+ page_limit = min (page_limit , advance_cap - len (window_ids ))
249+ page = store .list_memories_page (
250+ scoped , after_id = after_id , limit = page_limit ,
251+ )
224252 if not page :
225253 # The persisted cursor was at the end of the keyspace. Start the next
226254 # sweep from the beginning instead of retrying an empty tail forever.
227255 break
228256 next_after = page [- 1 ].id
257+ window_ids .extend (memory .id for memory in page )
229258 page_size = len (page )
230259 if exclude_relation :
231260 excluded = _linked_memory_ids (
@@ -240,12 +269,22 @@ def _scan_memory_window(store, flt: SearchFilter, *, mtypes: list[MemoryType],
240269 else :
241270 records .extend (page )
242271 if cap is not None and len (records ) >= cap :
243- next_cursor = next_after
272+ next_cursor = cursor_for_window ()
273+ break
274+ if advance_cap is not None and len (window_ids ) >= advance_cap :
275+ next_cursor = cursor_for_window ()
244276 break
245- if next_after == after_id or page_size < size :
277+ if next_after == after_id or page_size < page_limit :
246278 # End-of-keyspace: clear the cursor for the next invocation.
247279 break
248280 after_id = next_after
281+ if (
282+ not next_cursor
283+ and advance_cap is not None
284+ and window_ids
285+ and len (window_ids ) >= max (1 , advance_cap - max (0 , int (overlap )))
286+ ):
287+ next_cursor = cursor_for_window ()
249288 records .sort (
250289 key = lambda memory : (
251290 memory .ingested_at if memory .ingested_at is not None else float ("-inf" ),
@@ -256,6 +295,90 @@ def _scan_memory_window(store, flt: SearchFilter, *, mtypes: list[MemoryType],
256295 return records [:cap ] if cap is not None else records , next_cursor
257296
258297
298+ def _decode_distill_cursor (value : str ) -> tuple [str , list [str ]]:
299+ """Read a legacy keyset cursor or the current cursor-plus-candidates state."""
300+ raw = str (value or "" )
301+ if not raw .startswith ("{" ):
302+ return raw , []
303+ try :
304+ state = json .loads (raw )
305+ except (TypeError , ValueError ):
306+ return raw , []
307+ if not isinstance (state , dict ):
308+ return raw , []
309+ cursor = state .get ("cursor" )
310+ pending = state .get ("pending" )
311+ if not isinstance (cursor , str ) or not isinstance (pending , list ):
312+ return raw , []
313+ ids = list (dict .fromkeys (
314+ str (memory_id ) for memory_id in pending if str (memory_id or "" )
315+ ))
316+ return cursor , ids [:DISTILL_PENDING_LIMIT ]
317+
318+
319+ def _encode_distill_cursor (cursor : str , pending_ids : list [str ]) -> str :
320+ """Persist bounded partial-cluster candidates alongside the scan cursor."""
321+ normalized = list (dict .fromkeys (
322+ str (memory_id ) for memory_id in pending_ids if str (memory_id or "" )
323+ ))[:DISTILL_PENDING_LIMIT ]
324+ if not normalized :
325+ return str (cursor or "" )
326+ return json .dumps (
327+ {"cursor" : str (cursor or "" ), "pending" : normalized },
328+ ensure_ascii = False ,
329+ separators = ("," , ":" ),
330+ )
331+
332+
333+ def _load_distill_candidates (
334+ store , flt : SearchFilter , pending_ids : list [str ], * , now : float ,
335+ ) -> list [MemoryRecord ]:
336+ """Reload prior partial-cluster sources, dropping deleted or ineligible rows."""
337+ from engraphis .core .store import memory_matches_filter
338+
339+ if not pending_ids :
340+ return []
341+ records : list [MemoryRecord ] = []
342+ for memory_id in dict .fromkeys (pending_ids ):
343+ memory = store .get_memory (memory_id )
344+ if (
345+ memory is not None
346+ and memory .mtype == MemoryType .EPISODIC
347+ and memory_matches_filter (memory , flt , at = now )
348+ and prompt_eligible (memory .provenance , memory .metadata )
349+ ):
350+ records .append (memory )
351+ if not records :
352+ return []
353+ linked = _linked_memory_ids (
354+ store , [memory .id for memory in records ], relation = "consolidates" ,
355+ )
356+ return [memory for memory in records if memory .id not in linked ]
357+
358+
359+ def _pending_distill_ids (clusters : list [list [MemoryRecord ]], * , min_cluster : int ) -> list [str ]:
360+ """Select bounded candidates whose cluster needs a later sweep to complete.
361+
362+ Multi-record partial clusters are preferred because they carry positive evidence
363+ that a subject is recurring. Singleton records with an explicit subject key are
364+ retained too; unrelated singleton noise is intentionally not allowed to consume
365+ the whole carry-over budget.
366+ """
367+ incomplete = [cluster for cluster in clusters if 0 < len (cluster ) < min_cluster ]
368+ prioritized = [
369+ cluster for cluster in incomplete
370+ if len (cluster ) > 1 or any (memory .subject_key for memory in cluster )
371+ ]
372+ selected : list [str ] = []
373+ for cluster in prioritized :
374+ for memory in cluster [- max (1 , min_cluster - 1 ):]:
375+ if memory .id not in selected :
376+ selected .append (memory .id )
377+ if len (selected ) >= DISTILL_PENDING_LIMIT :
378+ return selected
379+ return selected
380+
381+
259382def _scan_memories (store , flt : SearchFilter , * , mtypes : list [MemoryType ],
260383 batch_size : int , prompt_only : bool = False ,
261384 max_records : Optional [int ] = None ,
@@ -687,19 +810,30 @@ def consolidate(engine, *, workspace_id: str, repo_id: Optional[str] = None,
687810 except Exception as exc :
688811 report ["errors" ].append (_error_entry (retry_cluster , exc ))
689812
690- distill_cursor = store .get_maintenance_cursor (
813+ distill_state = store .get_maintenance_cursor (
691814 workspace_id , repo_id , DISTILL_CURSOR_NAME ,
692815 )
816+ distill_cursor , pending_ids = _decode_distill_cursor (distill_state )
817+ distill_overlap = max (0 , int (min_cluster ) - 1 )
693818 episodic , next_distill_cursor = _scan_memory_window (
694819 store , flt , mtypes = [MemoryType .EPISODIC ],
695820 batch_size = DISTILL_SCAN_LIMIT , prompt_only = True ,
696- max_records = DISTILL_CLUSTER_LIMIT ,
821+ max_records = DISTILL_CLUSTER_LIMIT + distill_overlap ,
697822 exclude_relation = "consolidates" ,
698823 start_after_id = distill_cursor ,
824+ overlap = distill_overlap ,
825+ advance_records = DISTILL_CLUSTER_LIMIT + distill_overlap ,
699826 )
700- if not dry_run :
701- store .set_maintenance_cursor (
702- workspace_id , repo_id , DISTILL_CURSOR_NAME , next_distill_cursor ,
827+ prior_candidates = _load_distill_candidates (store , flt , pending_ids , now = now )
828+ if prior_candidates :
829+ by_id = {memory .id : memory for memory in [* prior_candidates , * episodic ]}
830+ episodic = sorted (
831+ by_id .values (),
832+ key = lambda memory : (
833+ memory .ingested_at if memory .ingested_at is not None else float ("-inf" ),
834+ memory .id ,
835+ ),
836+ reverse = True ,
703837 )
704838 # A digest inherits its owner from its first source. Cluster only records that have
705839 # the exact same owner, otherwise a workspace sweep could write one repo's digest with
@@ -711,6 +845,14 @@ def consolidate(engine, *, workspace_id: str, repo_id: Optional[str] = None,
711845 owner_memories , threshold = subject_jaccard , store = store , flt = flt ,
712846 )
713847 ]
848+ if not dry_run :
849+ store .set_maintenance_cursor (
850+ workspace_id , repo_id , DISTILL_CURSOR_NAME ,
851+ _encode_distill_cursor (
852+ next_distill_cursor ,
853+ _pending_distill_ids (clusters , min_cluster = min_cluster ),
854+ ),
855+ )
714856
715857 if structured :
716858 report ["structured" ] = {"enabled" : True , "attempted" : 0 , "succeeded" : 0 ,
@@ -1424,11 +1566,15 @@ def consolidate_profiles(engine, *, workspace_id: str, repo_id: Optional[str] =
14241566 profile_cursor = store .get_maintenance_cursor (
14251567 workspace_id , repo_id , PROFILE_CURSOR_NAME ,
14261568 )
1569+ profile_overlap = max (0 , int (min_mentions ) - 1 )
14271570 profile_memories , next_profile_cursor = _scan_memory_window (
14281571 store , flt , mtypes = DURABLE_TYPES ,
14291572 batch_size = PROFILE_SCAN_LIMIT , prompt_only = True ,
1430- max_records = PROFILE_MEMORY_LIMIT , exclude_relation = PROFILE_RELATION ,
1573+ max_records = PROFILE_MEMORY_LIMIT + profile_overlap ,
1574+ exclude_relation = PROFILE_RELATION ,
14311575 start_after_id = profile_cursor ,
1576+ overlap = profile_overlap ,
1577+ advance_records = PROFILE_MEMORY_LIMIT + profile_overlap ,
14321578 )
14331579 if not dry_run :
14341580 store .set_maintenance_cursor (
0 commit comments