From 0b915b76cec816e2fb7ea00827be41c8087d3ac5 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:42:11 +0000 Subject: [PATCH] fix: use max() instead of min() in LinkResolver search fallback The search fallback (strategy 5) was using min() to select the best match from FTS results, which selected the WORST match instead of the best. On both SQLite and Postgres backends, higher scores indicate better matches (FTS rank, vector similarity normalized to [0,1], hybrid fusion). Using max() correctly returns the highest-scoring result. Fixes #640 Co-authored-by: Paul Hernandez Signed-off-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- src/basic_memory/services/link_resolver.py | 2 +- tests/services/test_link_resolver.py | 49 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/basic_memory/services/link_resolver.py b/src/basic_memory/services/link_resolver.py index 526afa55..0a27b1c6 100644 --- a/src/basic_memory/services/link_resolver.py +++ b/src/basic_memory/services/link_resolver.py @@ -302,7 +302,7 @@ class LinkResolver: if results: # Look for best match - best_match = min(results, key=lambda x: x.score) # pyright: ignore + best_match = max(results, key=lambda x: x.score) # pyright: ignore logger.trace( f"Selected best match from {len(results)} results: {best_match.permalink}" ) diff --git a/tests/services/test_link_resolver.py b/tests/services/test_link_resolver.py index 38ba44bb..99679bc0 100644 --- a/tests/services/test_link_resolver.py +++ b/tests/services/test_link_resolver.py @@ -2,6 +2,7 @@ import uuid from datetime import datetime, timezone +from unittest.mock import AsyncMock, patch import pytest @@ -10,6 +11,7 @@ import pytest_asyncio from basic_memory.models.knowledge import Entity as EntityModel from basic_memory.repository import EntityRepository from basic_memory.schemas.base import Entity as EntitySchema +from basic_memory.schemas.search import SearchItemType, SearchResult from basic_memory.services.link_resolver import LinkResolver @@ -901,3 +903,50 @@ async def test_resolve_link_non_uuid_falls_through(link_resolver, test_entities, result = await link_resolver.resolve_link("Core Service") assert result is not None assert result.permalink == f"{project_prefix}/components/core-service" + + +# ============================================================================ +# Regression tests: search fallback score selection (Issue #640) +# ============================================================================ + + +@pytest.mark.asyncio +async def test_search_fallback_selects_best_match_not_worst( + link_resolver, search_service, test_entities, project_prefix +): + """Regression test for GitHub Issue #640. + + The search fallback (strategy 5) must select the highest-scoring match + using max(), not the lowest-scoring match using min(). + + Scenario: Searching for a partial title triggers FTS which returns multiple + results with different scores. The result with the highest score (best match) + must be returned, not the result with the lowest score (worst match). + """ + better_permalink = f"{project_prefix}/components/auth-service" + worse_permalink = f"{project_prefix}/specs/core-features" + + # Construct two mock search results with controlled scores + better_result = SearchResult( + title="Auth Service", + type=SearchItemType.ENTITY, + score=0.9, # High score = better match + permalink=better_permalink, + file_path="components/Auth Service.md", + ) + worse_result = SearchResult( + title="Core Features", + type=SearchItemType.ENTITY, + score=0.1, # Low score = worse match + permalink=worse_permalink, + file_path="specs/Core Features.md", + ) + + # "Auth Serv" has no exact title or permalink match, so strategy 5 is triggered. + # Mock search to return both results in worst-first order to ensure max() is tested. + with patch.object(search_service, "search", new=AsyncMock(return_value=[worse_result, better_result])): + result = await link_resolver.resolve_link("Auth Serv", use_search=True, strict=False) + + assert result is not None + # Must return the entity with the HIGHEST score, not the lowest + assert result.permalink == better_permalink