Skip to content

Fix/search ranking - #226

Open
utkarsha741 wants to merge 5 commits into
OpenAgentHQ:mainfrom
utkarsha741:fix/search-ranking
Open

Fix/search ranking#226
utkarsha741 wants to merge 5 commits into
OpenAgentHQ:mainfrom
utkarsha741:fix/search-ranking

Conversation

@utkarsha741

Copy link
Copy Markdown

Registry: search ranking by relevance (closes #79)

Problem

RegistryService.search() delegated straight to the adapter's search(),
which does an unweighted substring match across name/alias/capability/
category/description (ModelAlias.matches_query). All matches are
equally "relevant" regardless of where the query matched, so a model
whose description happens to mention the query ranks the same as an
exact name match.

Change

  • Added ModelAlias.match_score(spec, query) -> float in domain/model.py,
    next to the existing matches_query. Pure function, no I/O — scores a
    spec by where the query matched: name (exact > prefix > substring) >
    alias > capability > category > description, using fixed weights.
  • Added ScoredModelSpec (pydantic model: spec: ModelSpec, score: float)
    in domain/model.py, following the same pattern as ModelInfo.
  • RegistryService.search() now pulls the full catalog via list_all(),
    scores every candidate with ModelAlias.match_score, drops zero-score
    results, and sorts by (-score, name) for deterministic ordering. It no
    longer calls the adapter's own search().
  • Blank/whitespace-only queries return [] (previously matches_query
    treated an empty query as "match everything" — kept that method's
    behavior unchanged for existing callers, but search() now short-circuits
    before scoring).

Breaking change

RegistryService.search() return type changes from List[ModelSpec] to
List[ScoredModelSpec]. Updated call sites:

  • modeldock/__init__.py::search()
  • CLI modeldock search — now prints the score alongside each result

Why domain, not core?

The scoring rule is a pure business rule about what counts as a better
match
, with no I/O — the same category as the existing matches_query.
Keeping it in domain/ (rather than duplicating matching logic in
core/registry.py) keeps core/ as pure composition, per AGENT.md.

Testing

  • New unit tests in tests/unit/core/test_registry.py using a local
    _StaticRegistry fixture (deterministic 3-model catalog) covering:
    • results carry .spec/.score
    • exact name match ranks first and outscores a buried description hit
    • capability match outranks description-only match
    • case-insensitivity
    • empty query and no-match query both return []
  • Existing BundledRegistry tests untouched — that adapter's own
    search() is unaffected by this change.
  • Ran ruff, mypy --strict, bandit, pytest locally — all passing.

Checklist

  • Branch named feature/search-ranking
  • domain/ and ports/ stay pure (no I/O added)
  • Type hints, Pydantic v2, no generic Exception
  • Tests added
  • Docs updated — N/A unless README's md.search() example needs a
    note about scored results (check if you changed the SDK's return shape)

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds relevance-ranked catalog search: RegistryService.search now scores every spec from list_all() via the new ModelAlias.match_score instead of delegating to the adapter's own search, returning ScoredModelSpec pairs sorted by descending score with alphabetical name tie-breaks. Scoring weights name matches (exact > prefix > substring) above capability and alias matches, above category, above description-only mentions (issue #79), and is case-insensitive using Capability/Category .value for cross-version-stable formatting. A blank or whitespace-only query returns [], and queries matching nothing return [].

Worth a look

  • RegistryService.search now returns ScoredModelSpec instead of ModelSpecsrc/modeldock/core/registry.py:27 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • search() return type changed from List[ModelSpec] to List[ScoredModelSpec] breaks callerssrc/modeldock/core/registry.py:27 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • search() no longer delegates to registry.search, ignoring adapter-specific search semanticssrc/modeldock/core/registry.py:27 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • RegistryService.search no longer delegates to RegistryPort.searchsrc/modeldock/core/registry.py:38 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Combined lower-priority signals can outrank a name matchsrc/modeldock/domain/model.py:363 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 403 functions depend on the 100 functions this change touches.

Health — this change adds coupling hotspots:

  • new: load_settings() — 13 callers, 6 callees
  • new: RegistryService — 12 callers, 6 callees
  • new: _manager() — 21 callers, 3 callees
  • new: test_composite_versions_and_resolve_delegate() — 0 callers, 7 callees
  • new: test_huggingface_specs_carry_provenance() — 0 callers, 7 callees
  • new: test_cli_console_helpers() — 0 callers, 6 callees

Verification — 403 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 390 function(s) in the blast radius were not formally verified this run

· 6 more finding(s) on lines outside this diff (see the check run).

@himanshu231204

Copy link
Copy Markdown
Member

Code Review Suggestions

Thanks for the PR — the core scoring logic is well-placed and the _StaticRegistry fixture approach is the right call. A few things to address before merge:


Critical

1. _StaticRegistry.get() misuses ModelNotFoundError (tests/unit/test_registry.py:33)

ModelNotFoundError.__init__ takes name: str and formats its own message internally. Passing a pre-formatted string produces a doubled message like "Model 'Unknown model: llama3' was not found in the registry.".

# current
raise ModelNotFoundError(f"Unknown model: {ref.name}")

# fix
raise ModelNotFoundError(ref.name)

2. test_registry_service_search_delegates name is now wrong

After this PR, RegistryService.search() calls list_all(), not the adapter's search(). The test still passes but its name describes the opposite of what the code does. Rename it (e.g. test_registry_service_search_returns_results) and consider asserting the returned items are ScoredModelSpec instances.


3. PR description has an unfilled placeholder

- `modeldock/__init__.py::search()` — <describe your actual fix here>

Please confirm whether __init__.py and the CLI search command were updated for the List[ModelSpec]List[ScoredModelSpec] return-type change, and document what changed.


Important

4. Alias scoring path is never exercised by the tests

ranking_registry gives llama3 the alias "llama", but searching "llama" also hits the name-prefix path ("llama3".startswith("llama")), so _ALIAS_EXACT / _ALIAS_SUBSTRING are never the reason a score is non-zero. Add a model whose name doesn't contain the query at all, so the alias path is the only one that fires:

ModelSpec(
    name="meta-chat",
    aliases=["llama"],   # alias matches, name does not
    category=Category.CHAT,
    capabilities=[Capability.CHAT],
    description="...",
)

Then assert it appears in results when searching "llama".


5. q == category or q in category is redundant (domain/model.py)

Equality is a special case of substring containment, so the q == category branch never fires independently. Simplify to:

if q in category:
    score += cls._CATEGORY_MATCH

6. Name/alias mutual exclusivity should be documented

The elif chain means a spec earns at most one score from the name/alias tier, while capability, category, and description are additive (separate if blocks). This distinction isn't obvious from the weights comment block and will confuse future maintainers. Add a note:

# Name/alias tier is exclusive (elif chain) — a spec earns at most one of
# these. Capability, category, and description are additive (separate if
# blocks) and can stack.
_NAME_EXACT = 100.0
...

7. RegistryPort.search() is now dead code from the service's perspective

RegistryService.search() no longer calls the adapter's search() — it uses list_all(). Please either remove search() from RegistryPort and all implementations (if it's truly unused), or add a comment explaining why it's retained.


Minor

  • Blank query guard (registry.py): if not query or not query.strip() — the not query check is redundant since "".strip() is already falsy. if not query.strip(): is sufficient.
  • ScoredModelSpec.score: Consider score: float = Field(ge=0.0) to enforce the non-negative invariant at the type level.
  • Tie-breaking: No test covers the alphabetical tie-break (-score, name sort key). Worth adding a case with two equal-scoring models to pin that behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Registry: search ranking by relevance

2 participants