Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions src/modeldock/core/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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.
Expand Down
78 changes: 78 additions & 0 deletions src/modeldock/domain/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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
124 changes: 122 additions & 2 deletions tests/unit/test_registry.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,140 @@
"""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:
svc = RegistryService(fake_registry)
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")
Expand Down