diff --git a/src/modeldock/core/registry.py b/src/modeldock/core/registry.py index bdf947f..3864636 100644 --- a/src/modeldock/core/registry.py +++ b/src/modeldock/core/registry.py @@ -3,12 +3,18 @@ Implements search/info/categories/recommend by composing the registry adapter. See Architecture.md §9. """ - from __future__ import annotations from typing import List -from modeldock.domain.model import Category, ModelInfo, ModelRef, ModelSpec +from modeldock.domain.model import ( + Category, + ModelAlias, + ModelInfo, + ModelRef, + ModelSpec, + ScoredModelSpec, +) from modeldock.ports.registry import RegistryPort @@ -18,9 +24,26 @@ class RegistryService: def __init__(self, registry: RegistryPort) -> None: self._registry = registry - def search(self, query: str) -> List[ModelSpec]: - """Search the catalog by name/alias/capability/category.""" - return self._registry.search(query) + def search(self, query: str) -> List[ScoredModelSpec]: + """Search the catalog by name/alias/capability/category/description. + + Ranks results by relevance rather than delegating to the adapter's + own ``search`` — name matches outrank capability matches, which + outrank description-only matches (see issue #79 / ``ModelAlias. + match_score``) — and returns each match paired with its score, + highest first. Ties are broken alphabetically by model name for + stable, predictable output. Returns ``[]`` for a blank query. + """ + if not query or not query.strip(): + return [] + + results = [ + ScoredModelSpec(spec=spec, score=ModelAlias.match_score(spec, query)) + for spec in self._registry.list_all() + ] + relevant = [result for result in results if result.score > 0] + relevant.sort(key=lambda result: (-result.score, result.spec.name)) + return relevant def info(self, name: str, installed_tags: List[str] | None = None) -> ModelInfo: """Return metadata for a model, enriched with installed tags. diff --git a/src/modeldock/domain/model.py b/src/modeldock/domain/model.py index cec63b5..f1c7718 100644 --- a/src/modeldock/domain/model.py +++ b/src/modeldock/domain/model.py @@ -144,6 +144,18 @@ def from_ref(cls, ref: ModelRef) -> ModelSpec: ) +class ScoredModelSpec(BaseModel): + """A ``ModelSpec`` paired with its relevance score for a search query. + + Returned by ``RegistryService.search`` (see issue #79). Not normalized to + any fixed range — ``score`` is only meaningful for ordering results + against each other within the same query. + """ + + spec: ModelSpec + score: float + + class ModelInfo(BaseModel): """Catalog metadata enriched with the tags/versions installed locally. @@ -276,6 +288,19 @@ def __hash__(self) -> int: class ModelAlias: """Pure alias-resolution rules mapping friendly names to canonical specs.""" + # --- Relevance weights for match_score, per issue #79 --- + # Name outranks capability, which outranks description. Sub-tiers within + # "name" reward how close the match is (exact > prefix > substring). + _NAME_EXACT = 100.0 + _NAME_PREFIX = 60.0 + _NAME_SUBSTRING = 30.0 + _ALIAS_EXACT = 45.0 + _ALIAS_SUBSTRING = 20.0 + _CAPABILITY_EXACT = 20.0 + _CAPABILITY_SUBSTRING = 10.0 + _CATEGORY_MATCH = 8.0 + _DESCRIPTION_SUBSTRING = 3.0 + @staticmethod def resolve(value: str, registry: RegistryPort) -> ModelSpec: """Resolve a friendly name/tag to a ``ModelSpec`` via the registry. @@ -302,3 +327,56 @@ def matches_query(spec: ModelSpec, query: str) -> bool: haystack += [a.lower() for a in spec.aliases] haystack += [c.value for c in spec.capabilities] return any(q in field for field in haystack) + + @classmethod + def match_score(cls, spec: ModelSpec, query: str) -> float: + """Score how relevant ``spec`` is to a free-text search ``query``. + + Ranking order (issue #79): name > capability > description, with + alias and category treated as name-adjacent and capability-adjacent + signals respectively. Higher is more relevant; 0.0 means no match + (including for an empty/whitespace-only query — callers should treat + that as "no query", unlike ``matches_query``, which treats it as + "match everything"). + + ``Capability`` and ``Category`` are ``str`` subclasses; ``.value`` is + used rather than ``str(member)`` because plain-Enum ``__str__`` + formatting (e.g. ``"Capability.CHAT"``) is inconsistent across the + Python 3.9–3.12 versions this project supports, while ``.value`` is + always the plain lowercase string. + """ + q = query.strip().lower() + if not q: + return 0.0 + + name = spec.name.lower() + aliases = [a.lower() for a in spec.aliases] + capabilities = [c.value for c in spec.capabilities] + category = spec.category.value + description = spec.description.lower() + + score = 0.0 + + if name == q: + score += cls._NAME_EXACT + elif name.startswith(q): + score += cls._NAME_PREFIX + elif q in name: + score += cls._NAME_SUBSTRING + elif q in aliases: + score += cls._ALIAS_EXACT + elif any(q in alias for alias in aliases): + score += cls._ALIAS_SUBSTRING + + if q in capabilities: + score += cls._CAPABILITY_EXACT + elif any(q in cap for cap in capabilities): + score += cls._CAPABILITY_SUBSTRING + + if q == category or q in category: + score += cls._CATEGORY_MATCH + + if q in description: + score += cls._DESCRIPTION_SUBSTRING + + return score diff --git a/tests/unit/test_registry.py b/tests/unit/test_registry.py index 7009890..916fb78 100644 --- a/tests/unit/test_registry.py +++ b/tests/unit/test_registry.py @@ -1,13 +1,75 @@ """Unit tests for RegistryService and BundledRegistry.""" - from __future__ import annotations +from typing import List + import pytest from modeldock.adapters.registry import BundledRegistry from modeldock.common.errors import ModelNotFoundError from modeldock.core.registry import RegistryService -from modeldock.domain.model import Category, ModelRef +from modeldock.domain.model import Capability, Category, ModelRef, ModelSpec + + +class _StaticRegistry: + """A minimal RegistryPort stand-in with a fixed, known catalog. + + Used for ranking-specific tests so assertions don't depend on whatever + the shared ``fake_registry`` fixture happens to contain. + """ + + def __init__(self, specs: List[ModelSpec]) -> None: + self._specs = specs + + def list_all(self) -> List[ModelSpec]: + return list(self._specs) + + def get(self, ref: ModelRef) -> ModelSpec: + for spec in self._specs: + if spec.name == ref.name: + return spec + raise ModelNotFoundError(f"Unknown model: {ref.name}") + + def search(self, query: str) -> List[ModelSpec]: + # Intentionally naive/unranked — RegistryService.search must rank via + # list_all() + domain scoring, not depend on this method at all. + q = query.lower() + return [s for s in self._specs if q in s.name.lower()] + + def recommend(self, task: str) -> List[ModelSpec]: + return [] + + def by_category(self, category: Category) -> List[ModelSpec]: + return [s for s in self._specs if s.category == category] + + +@pytest.fixture() +def ranking_registry() -> _StaticRegistry: + return _StaticRegistry( + [ + ModelSpec( + name="llama3", + aliases=["llama"], + category=Category.CHAT, + capabilities=[Capability.CHAT], + description="Meta's Llama 3 chat model.", + ), + ModelSpec( + name="qwen3", + aliases=[], + category=Category.CODING, + capabilities=[Capability.CHAT, Capability.TOOL_USE], + description="A coding-focused model, plays well with llama-style prompts.", + ), + ModelSpec( + name="minilm", + aliases=[], + category=Category.EMBEDDING, + capabilities=[Capability.EMBED], + description="Small embedding model, unrelated to chat models.", + ), + ] + ) def test_registry_service_search_delegates(fake_registry: object) -> None: @@ -15,6 +77,64 @@ def test_registry_service_search_delegates(fake_registry: object) -> None: assert svc.search("llama") +def test_registry_service_search_returns_scored_results( + ranking_registry: _StaticRegistry, +) -> None: + svc = RegistryService(ranking_registry) + results = svc.search("llama") + assert results + for result in results: + assert hasattr(result, "spec") + assert hasattr(result, "score") + assert result.score > 0 + + +def test_registry_service_search_ranks_exact_name_match_first( + ranking_registry: _StaticRegistry, +) -> None: + svc = RegistryService(ranking_registry) + results = svc.search("llama3") + assert results[0].spec.name == "llama3" + # An exact name match should score strictly higher than a mention + # buried in another model's description. + assert results[0].score > results[-1].score + + +def test_registry_service_search_ranks_capability_over_description( + ranking_registry: _StaticRegistry, +) -> None: + svc = RegistryService(ranking_registry) + results = svc.search("chat") + order = [r.spec.name for r in results] + # "llama3"/"qwen3" declare the "chat" capability directly; "minilm" + # only mentions "chat models" in its description. Capability should + # outrank a description-only mention. + assert order.index("llama3") < order.index("minilm") + assert order.index("qwen3") < order.index("minilm") + + +def test_registry_service_search_is_case_insensitive( + ranking_registry: _StaticRegistry, +) -> None: + svc = RegistryService(ranking_registry) + assert svc.search("LLAMA3")[0].spec.name == "llama3" + + +def test_registry_service_search_empty_query_returns_empty( + ranking_registry: _StaticRegistry, +) -> None: + svc = RegistryService(ranking_registry) + assert svc.search("") == [] + assert svc.search(" ") == [] + + +def test_registry_service_search_no_match_returns_empty( + ranking_registry: _StaticRegistry, +) -> None: + svc = RegistryService(ranking_registry) + assert svc.search("totally-unrelated-xyz") == [] + + def test_registry_service_info_resolves(fake_registry: object) -> None: svc = RegistryService(fake_registry) spec = svc.info("llama3")