2323 swallows unpickle errors and rebuilds.
2424
2525Threading:
26- - Reads (``search``) are safe to call concurrently.
27- - ``save_index`` is concurrency-safe (see ``bm25.save_pickled``): racing
28- saves to the same path each write a unique temp file then atomically
29- replace, so they never interleave into a corrupt file. Last writer wins.
30- ``build_index`` has no shared state, so parallel rebuilds are wasteful
31- (duplicated encode work) but correct.
26+ - ``search``, ``build_index`` and ``_incremental_build`` all call
27+ ``model.encode()``, which is NOT thread-safe on MPS: concurrent encodes from
28+ different threads DEADLOCK (and corrupt each other's tensors). The daemon
29+ serialises ALL inference — search and (incremental) rebuild — onto a single
30+ thread via ``agent_mem_daemon/_inference.py``; callers outside that funnel
31+ must not run these concurrently. (The earlier note here — "parallel rebuilds
32+ are wasteful but correct" — was wrong: they hang.)
33+ - ``save_index`` is concurrency-safe (see ``bm25.save_pickled``): racing saves
34+ to the same path each write a unique temp file then atomically replace, so
35+ they never interleave into a corrupt file. Last writer wins.
3236"""
3337
3438from __future__ import annotations
@@ -325,6 +329,90 @@ def build_index(
325329 )
326330
327331
332+ def _incremental_build (
333+ cached : EmbeddingIndex ,
334+ knowledge_dir : Path ,
335+ model_name : str ,
336+ ) -> EmbeddingIndex :
337+ """Rebuild re-encoding ONLY the entries that changed since ``cached``.
338+
339+ Reuses ``cached``'s embedding row for every entry whose path is unchanged and
340+ whose mtime hasn't advanced (matching :func:`is_stale`); encodes only new or
341+ modified entries; drops deleted ones. A full :func:`build_index` re-encodes
342+ the whole corpus — expensive on the priming hot path, which rebuilds after
343+ every Scholar write. The ``model.encode`` here is funnelled onto the daemon's
344+ single inference thread by the callers (``_inference.run``), so it stays
345+ MPS-safe. Falls back to a full build if the cached rows can't be reused.
346+ """
347+ knowledge_dir = knowledge_dir .expanduser ().resolve ()
348+ n_cached = cached .embeddings .shape [0 ] if cached .embeddings .ndim == 2 else 0
349+ by_path = {rec .path : i for i , rec in enumerate (cached .docs )}
350+
351+ docs : list [_DocRecord ] = []
352+ rows : list [_FloatArray ] = []
353+ encode_texts : list [str ] = []
354+ encode_slots : list [int ] = []
355+ for md in _iter_markdown (knowledge_dir ):
356+ try :
357+ mtime = md .stat ().st_mtime
358+ except OSError :
359+ continue
360+ path = str (md )
361+ ci = by_path .get (path )
362+ if ci is not None and ci < n_cached and cached .docs [ci ].mtime + 1e-6 >= mtime :
363+ # Unchanged → reuse the cached embedding row (refresh stored mtime so
364+ # the next staleness check is exact).
365+ docs .append (_DocRecord (path = path , mtime = mtime , raw_text = cached .docs [ci ].raw_text ))
366+ rows .append (cached .embeddings [ci ])
367+ continue
368+ try :
369+ raw = md .read_text (encoding = "utf-8" )
370+ except (OSError , UnicodeDecodeError ):
371+ continue
372+ text = _embedding_text (raw ).strip ()
373+ if not text :
374+ continue
375+ docs .append (_DocRecord (path = path , mtime = mtime , raw_text = raw ))
376+ rows .append (np .zeros ((0 ,), dtype = np .float32 )) # placeholder; overwritten below
377+ encode_texts .append (text )
378+ encode_slots .append (len (docs ) - 1 )
379+
380+ if not docs :
381+ return EmbeddingIndex (
382+ knowledge_dir = knowledge_dir ,
383+ model_name = model_name ,
384+ docs = [],
385+ embeddings = np .zeros ((0 , 0 ), dtype = np .float32 ),
386+ built_at = time .time (),
387+ )
388+
389+ if encode_texts :
390+ model = _load_model (model_name )
391+ fresh = model .encode (
392+ [_format_doc (t , model_name ) for t in encode_texts ],
393+ convert_to_numpy = True ,
394+ normalize_embeddings = True ,
395+ show_progress_bar = False ,
396+ batch_size = 8 ,
397+ ).astype (np .float32 )
398+ for i , slot in enumerate (encode_slots ):
399+ rows [slot ] = fresh [i ]
400+
401+ try :
402+ embeddings : _FloatArray = np .vstack (rows ).astype (np .float32 )
403+ except ValueError :
404+ # Ragged rows (corrupt / dimension-mismatched cache) — full rebuild.
405+ return build_index (knowledge_dir , model_name = model_name )
406+
407+ return EmbeddingIndex (
408+ knowledge_dir = knowledge_dir ,
409+ model_name = model_name ,
410+ docs = docs ,
411+ embeddings = embeddings ,
412+ built_at = time .time (),
413+ )
414+
415+
328416def save_index (index : EmbeddingIndex , path : Path | None = None ) -> Path :
329417 """Pickle the index atomically. Returns the path written.
330418
@@ -343,11 +431,15 @@ def load_or_build(
343431 force_rebuild : bool = False ,
344432 index_path : Path | None = None ,
345433) -> EmbeddingIndex :
346- """Load the persisted index if fresh, otherwise rebuild and save.
434+ """Load the persisted index if fresh; otherwise rebuild (incrementally when
435+ possible) and save.
347436
348437 "Fresh" means the index was built against the same ``knowledge_dir`` and
349438 ``model_name``, every file we tracked is still present with the same mtime,
350- and no new ``.md`` files have appeared.
439+ and no new ``.md`` files have appeared. When the cache is compatible but
440+ stale we re-encode ONLY the changed/new entries (see :func:`_incremental_build`)
441+ rather than the whole corpus — the corpus rebuild is what made priming slow
442+ on the hot path after every Scholar write.
351443 """
352444 knowledge_dir = knowledge_dir .expanduser ().resolve ()
353445 target = index_path or _default_index_path (knowledge_dir )
@@ -358,12 +450,17 @@ def load_or_build(
358450 cached is not None
359451 and cached .knowledge_dir == knowledge_dir
360452 and cached .model_name == model_name
361- and not is_stale (
362- cached .docs ,
363- {str (p ): p .stat ().st_mtime for p in _iter_markdown (knowledge_dir )},
364- )
365453 ):
366- return cached
454+ current = {str (p ): p .stat ().st_mtime for p in _iter_markdown (knowledge_dir )}
455+ if not is_stale (cached .docs , current ):
456+ return cached
457+ # Compatible but stale → re-encode only the entries that changed.
458+ index = _incremental_build (cached , knowledge_dir , model_name )
459+ try :
460+ save_index (index , target )
461+ except OSError :
462+ pass
463+ return index
367464
368465 index = build_index (knowledge_dir , model_name = model_name )
369466 try :
0 commit comments