From 37331d53ef3ec2128e8ad3656c146990e5dcf9bc Mon Sep 17 00:00:00 2001 From: Anto01 Date: Sat, 11 Apr 2026 12:55:10 -0400 Subject: [PATCH] fix: rank memories globally before budget walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-type ranking was still starving later types: when a p05 query matched a 'knowledge' memory best but 'project' came first in the type order, the project-type candidates filled the budget before the knowledge-type pool was even ranked. Collect all candidates into a single pool, dedupe by id, then rank the whole pool once against the query before walking the flat budget. Python's stable sort preserves insertion order (which still reflects the caller's memory_types order) as a natural tiebreaker when scores are equal. Regression surfaced by the retrieval eval harness: p05-vendor-signal still missing 'Zygo' after 5aeeb1c — the vendor memory was type=knowledge but never reached the ranker because type=project consumed the budget first. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/atocore/memory/service.py | 42 +++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/atocore/memory/service.py b/src/atocore/memory/service.py index 3c9f607..2979f50 100644 --- a/src/atocore/memory/service.py +++ b/src/atocore/memory/service.py @@ -389,29 +389,37 @@ def get_memories_for_context( if not query_tokens: query_tokens = None - # Flat budget across types so paragraph-length project memories - # aren't starved by an even slice. Types are still walked in order - # (identity/preference first when they're the input), so earlier - # types still get first pick when the budget is tight. + # Collect ALL candidates across the requested types into one + # pool, then rank globally before the budget walk. Ranking per + # type and walking types in order would starve later types when + # the first type's candidates filled the budget — even if a + # later-type candidate matched the query perfectly. Type order + # is preserved as a stable tiebreaker inside + # ``_rank_memories_for_query`` via Python's stable sort. + pool: list[Memory] = [] + seen_ids: set[str] = set() for mtype in memory_types: - # Raise the fetch limit above the budget slice so query-relevance - # ordering has a real pool to rerank. Without a query, the extras - # just fall off the end harmlessly. - candidates = get_memories( + for mem in get_memories( memory_type=mtype, project=project, min_confidence=0.5, limit=30, - ) - if query_tokens is not None: - candidates = _rank_memories_for_query(candidates, query_tokens) - for mem in candidates: - entry = f"[{mem.memory_type}] {mem.content}" - entry_len = len(entry) + 1 - if entry_len > available - used: + ): + if mem.id in seen_ids: continue - selected_entries.append(entry) - used += entry_len + seen_ids.add(mem.id) + pool.append(mem) + + if query_tokens is not None: + pool = _rank_memories_for_query(pool, query_tokens) + + for mem in pool: + entry = f"[{mem.memory_type}] {mem.content}" + entry_len = len(entry) + 1 + if entry_len > available - used: + continue + selected_entries.append(entry) + used += entry_len if not selected_entries: return "", 0