fix(skills): rank an installed-skill search by the words of the query, not all of them (F5) - #266
Merged
Merged
Conversation
The tokenising, ranking matcher written for the two marketplace searches is about to serve the installed-skill search as well, which has nothing to do with the marketplace. It moves from `marketplace/search.rs` to a neutral `catalog_search.rs`; its result types drop the `Marketplace` prefix (`CatalogSearch`, `CatalogSearchHit`), and `rank`, `Weight` and the noise lists become crate-visible so a second module can call them. No behaviour changes. The matcher's own seven tests and every marketplace test pass unchanged.
`skills__searchSkills` kept an installed skill only when its name,
description and bundle held EVERY word of the query as a substring, so the
phrase a model composes on a user's behalf found nothing unless one skill
happened to say all of it. Measured with a ggplot skill ("Publication-quality
ggplot2 visualization guide for R...") and an r-scripting skill installed:
searchSkills {query: "R scripting ggplot visualization"}
-> {"total": 0, ..., "skills": []}
That is QA finding F5 again, on the installed catalog instead of the
marketplace and through different code. It is fixed the same way, through
`catalog_search::rank` rather than a second matcher: an any-term union,
ranked by terms matched and then by where (name > bundle > description),
with short terms matching whole words only and filler dropped. The same query
now returns ggplot (3 terms), r-scripting (2) and python-scripting (1), and
never rna-qc or variant-calling, whose text is full of the letter r but
never says R.
The page keeps its shape (total/offset/limit/returned/next_offset/skills and
every provenance and removal field) and adds what the marketplace page
carries: `terms`, a per-row `matchedTerms`, and, on zero hits, a `guidance`
sentence counting the skills this conversation has enabled. It names only
searchSkills, because an app agent holds searchSkills and loadSkill alone
and this handler cannot see the roster.
Unchanged, and now pinned on the search path too: the conversation's
switches run before the ranking (a switched-off skill is neither returned
nor counted), `removable` and `removalTarget` come through a ranked row,
pagination walks the ranking, and an empty or wordless query is still the
listing, carrying none of the new fields.
One existing assertion encoded the AND and changes on purpose:
`bio-bundle rna` returned 1 skill; it now returns all 5 in the bundle, with
`rna-qc` first as the only one matching all three words.
The matcher ranks an entry that holds the whole query first, and it tested
that with a plain substring check. That let a query which is itself one
short term back in through every word containing it, undoing the rule that a
term under three characters matches whole words only. Measured once the
installed-skill search moved onto the matcher:
searchSkills {query: "R"}
-> all 5 fixture skills; python-scripting, rna-qc and variant-calling
came back with `matchedTerms: []`, found by the letter r alone
In the matcher's own fixtures `rank("R")` returned complex-plots (through
"Draws") and prose-only as well. The same check ranked noise first inside a
phrase: for `R scripting`, an entry saying "snippets for scripting"
outranked one that said both words, because "fo[r scripting]" contains the
query.
The whole query now counts as written only where it starts and ends at a
word boundary. Term matching is untouched, so every entry a word of the query
matches is still returned; what goes is an entry found by nothing but a
fragment of a word, and the rank such a fragment bought. The two queries
above now return r-scripting then ggplot, and put the entry matching both
words first. Every marketplace and extension search test passes unchanged.
`a_verbatim_occurrence_is_still_a_hit` asserted the leak as a feature (`dy`
found r-scripting inside "Tidyverse"). It is replaced by a test that the
query as written still ranks first, but only as whole words.
The skill-catalog reference described what a searchSkills row carries but not how a query picks the rows. It now says: ranked by any word through the shared catalog_search matcher, short words whole-word only, the conversation's switches first, and the terms/matchedTerms/guidance fields a ranked page adds, with the F5 measurement that motivated it.
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
PR #266 moves crates/biorouter/src/marketplace/search.rs to crates/biorouter/src/catalog_search.rs so the installed-skill search can be its second caller, renaming MarketplaceSearch/MarketplaceSearchHit to CatalogSearch/CatalogSearchHit. This file's header cited the old path, and the sentence directly under it — "A change to a rule below is a change to both files" — is the whole reason the port is maintainable. A header that names a path which no longer exists retires that sentence silently, which is the one outcome it exists to prevent. So: the path, the two type names, and the reason the file moved. The sentence itself is kept word for word, and the rule change it obliged is the commit after this one.
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
PR #266 changed the whole-query rank rule in the Rust matcher this file ports: a query counts as written only where it starts and ends at a word boundary (`written_in`), not wherever it occurs as a substring. The old test that asserted the substring behaviour, `a_verbatim_occurrence_is_ still_a_hit`, had pinned the leak as a feature. `search.ts` still asked `text.includes(query.phrase)`, so the Browse modals and the model's own search were about to disagree on exactly the queries #266 changed. Measured over the live landing/registry.json (129 skills, 37 extensions), 38 queries x both catalogs = 76 searches, full ranking both ways, the same file bundled before and after: 0 entries start being returned, 198 stop, 7 of 76 searches change skills `R` 125 -> 8 all 8 in the same order skills `dy` 6 -> 0 skills `s p` 6 -> 1 skills `ml` 9 -> 3 top 3 unchanged skills `AI` 24 -> 2 top 2 unchanged extensions `R` 37 -> 1 codegraphagent, already first extensions `s p` 7 -> 1 Every one of the 7 is a query whose every term is under three characters; the 33 realistic queries — ggplot, complexheatmap, knowledge graph, `R scripting ggplot visualization`, r-scripting, c++ ... — return the identical list in the identical order in both catalogs. That reproduces #266's own numbers from the TypeScript side, drop for drop. A one-letter `R` used to return 125 of 129 skills with 117 of them matching no term at all, ranking empirical-paper-submission-rr above r-scripting. `writtenIn` is a transliteration of `written_in`, not a regular expression, and the reason is measured: a `\b`-built test disagrees with Rust on 4 of the 11 cases now asserted. JavaScript defines `\b` over `[A-Za-z0-9_]` alone, so it reads `_` as a letter where Rust does not (`case` IS written in "snake_case") and every non-ASCII letter as a boundary where Rust does not (`naï` is NOT written in "naïve"); and `++` in "c++" needs no boundary at all, since an edge that is not a letter or digit is one. The scan compares code points, like Rust's `char_indices`: read as UTF-16 units the trailing half of an astral letter is a lone surrogate that matches no letter class, so `rna` would be read as written in "\u{1D41A}rna". `WORD_CHAR` is spelled as the explicit complement of the `WORD_BREAK` that `words` already splits on, so a term and the query around it can never be held to different edges. Three existing tests change, the same three #266 changed in Rust, and no modal test moves: - the `dy` half of `still finds a verbatim occurrence` is inverted, and the test becomes three: a one-letter query as a whole word, a phrase found only inside longer words, and `dy`/`s p` both empty. - `ranks a verbatim match above every term match, even one matching more terms` loses its premise. A phrase written in a field has every one of its own words in that field as a whole word, so a written entry now always matches EVERY term; the tier is re-pitted against placement instead, prose against two names, and `catalog_search.rs`'s own `tidy code` -> [styler, code-tidy] ranking is asserted beside it. - `R` alone over the fixture goes from 6 hits, 3 of them matching no term, to the 3 skills that are about R. `writtenIn` is exported so its boundary cases can be asserted directly, the way Rust asserts them from inside the module: its six `written_in` cases port over verbatim, plus the four characters `\b` gets wrong. Reverting the body to `text.includes(phrase)` fails 6 of the 43 tests, both boundary tests among them.
Broccolito
added a commit
that referenced
this pull request
Sep 12, 2026
…re two counts Follow-up to merging 133 commits of main into this branch. Nothing here changes behaviour a user can see; each item is a claim in the six commits that the merge made false. **The canonical matcher moved, so the port's three citations were dead.** PR #266 renamed `crates/biorouter/src/marketplace/search.rs` to `crates/biorouter/src/catalog_search.rs`. `landing/marketplace-search.js`, `landing/scripts/baam-search.test.mjs` and `landing/baam.html` each named the old path as the module to read for the reasoning -- the one pointer a future reader of a port most needs. All three now name the new path and say when it moved, the way `ui/desktop/src/components/baam/search.ts` already does on main. **And PR #266 did not only move it -- it changed a rule the port mirrors.** The verbatim-phrase bonus became `written_in`: present as a substring AND not buried inside a longer word at either end, with the boundary imposed only by an end that is itself a word character. The port still asked `indexOf(...) !== -1`, so it had drifted from the module it claims to mirror rule for rule. Ported, with that module's own six assertions run against it. Worth being exact about the blast radius, in both directions: the shelves call `matching`, which collapses `rank` to a membership set, so **nothing on the page moves** -- a visitor sees the same cards in the same order either way. That is also why the drift could have sat there indefinitely, and why the rule is now pinned by a test rather than left to the next reader to notice. **`declared_model_names`' own doc contradicted the comment 100 lines below it.** The route's `Ok(None)` arm still said "six of those nine ship a curated `with_models(...)` catalog" -- the figure produced by the instrument the function's doc-comment exists to warn against, which undercounts by three because `ProviderMetadata::new` also takes a `model_names` list. In a commit whose subject is copy that does not contradict itself, that is the wrong comment to leave standing. It now reads nine, measured off `known_models`, and points at the function that explains the measurement. **The subagent count moved 197 -> 198 while this branch waited.** The whole point of that CLAUDE.md line is that a wrong figure is worse than none, because this repo asserts "pre + N" against it. Re-measured on the merged tree: BIOROUTER_DISABLE_KEYRING=true cargo test -p biorouter --lib -- subagent test result: ok. 198 passed; 0 failed; 3794 filtered out so the line carries 198 and today's date, and now also records that it moved by one in a single week -- which is the argument for re-measuring rather than for trusting any figure written here, this one included. Verified: node --test landing/scripts/baam-search.test.mjs 17/17 (the browser half drives the real page, so the baam.html edit is covered); baam-privacy-facet.test.mjs 14/14; build-registry.mjs --check reports all three outputs current, and no registry datum is touched.
This was referenced Sep 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
QA finding F5 — a multi-word query returns nothing — measured in the 2026-09-10
composer run, applied to the installed skill catalog. PR #255 fixes the desktop BAAM
Browse modals; this fixes
skills__searchSkills, the tool a model calls to find a skillthe user already has. Zero file overlap between the two, so they land independently.
searchSkillskept a skill only when its name, description and bundle held everyword of the query as a substring. A model composing
R scripting ggplot visualizationona user's behalf got
total: 0with a ggplot skill and an R-scripting skill installed, andtold the user nothing was installed for the job.
Before / after, measured
The same
searchSkillstool call, run against six installed fixture skills (ggplot,python-scripting,r-scripting,rna-qc,variant-calling,spoke-knowledge-graph)written as real
SKILL.mdfiles and read back by the real scanner. Before is thepre-branch parent
d6b39693with the same harness checked out onto it — not areconstruction:
R scripting ggplot visualizationr-scriptingSPOKE knowledge graphRTwo things to read out of the last row. Before, a one-letter
Rreturned every fixture —rna-qcandvariant-callinghave no R in them, only the letter — and it rankedpython-scripting first, because both names contain the letter and the tie broke
alphabetically. After,
Ris the two skills that are actually about R, name first,description second.
SPOKE knowledge graphis the control: a query whose words all sit inone skill was never broken and is unchanged.
Fail-before evidence
Each behaviour the branch claims is pinned by a test that fails on the parent:
an_installed_skill_search_ranks_every_skill_matching_a_word_of_the_phraseassertstotal == 3; measured 0 atd6b39693(table above).a_one_letter_installed_skill_query_matches_whole_words_onlyasserts["r-scripting", "ggplot"]; measured 6 rows in the wrong order atd6b39693.a_one_letter_query_matches_whole_words_onlyanda_phrase_found_only_inside_other_words_is_not_the_query_as_writtenpin the matcher's ownregression, which appeared the moment the installed search moved onto it: the whole-query
check was a plain substring test, so
Rcame back through every word containing theletter, three rows with
matchedTerms: [].test_search_skills_matches_name_description_and_bundlechanges on purpose:bio-bundle rnareturned 1, now returns all 5 in the bundle withrna-qcfirst as theonly one matching all three words. That assertion was the AND.
What the move changed for the marketplace path
e26eb59bmovesmarketplace/search.rstocatalog_search.rsso a second caller can useit. Proof it is behaviour-neutral — normalize the three renames
(
MarketplaceSearch→CatalogSearch,MarketplaceSearchHit→CatalogSearchHit,pub(super)→pub(crate)) and diff the file across the move:The entire output is the module doc comment, plus
terms()narrowing frompub(super)toprivate. Not one executable line differs.
marketplace.rschanges only its imports, tworeturn types and a doc path; both
fieldsclosures are untouched. Nothing outside thecrate ever referenced
MarketplaceSearch/MarketplaceSearchHit(git grepat the parent:no hits outside the module), so the rename breaks no consumer.
The marketplace's behaviour does change, in
4de29d1a, and deliberately: the whole-queryrank check goes from
text.contains(phrase)towritten_in(text, phrase)— the querycounts only where it starts and ends at a word boundary, the same edges the short-term rule
already enforced. Quantified against the live
landing/registry.json(129 skills + 37extensions), 38 queries × both catalogs = 76 searches, full ranking both ways:
three characters:
R,dy,s p,ml,AI. The other 31 queries —ggplot,heatmap,complexheatmap,python,rna,single cell,knowledge graph,differential expression,spatial transcriptomics,c++,ucsf… — return theidentical list in the identical order.
heatmapincomplexheatmap) through term matching, which is untouched. In the 7 that move, the topis preserved or improved: skills
R125→8 with an unchanged top-5; extensionsR37→1 (
codegraphagent, which was already UCSF (and maybe external) token usage mismatch #1);ml9→3, top-3 unchanged;AI24→2,top-2 unchanged;
dy6→0 ands p6→1. Only two searches change their first result,both for
s p.a_verbatim_occurrence_is_still_a_hitis replaced rather than deleted. It asserted twothings: that
rank("s p")is empty (kept verbatim in the replacement) and thatrank("dy")finds
r-scriptinginside "Tidyverse" withmatchedTerms: [](inverted). The second wasthe leak, asserted as a feature — it is the same substring the three-character rule exists
to refuse. No real assertion was lost.
Verification
Merged
origin/main(6 commits, no conflicts — they touchbridge.rsandtests/privacy_capability.rsonly).Deliberately out of scope
ui/desktop/src/components/skills/SkillsView.tsx, lines74–85) and the composer skill picker
(
ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.tsx, lines 91–98) bothstill ask
name/description/bundle.toLowerCase().includes(wholeQuery)— the same defect,a third and fourth time. They are renderer TypeScript with no shared code with this
Rust matcher, so fixing them here would mean writing a third implementation of the rule.
The right move is to point both at the TypeScript port PR Desktop: the Browse modals match a search word by word, ranked like the model's search (F5) #255 adds
(
ui/desktop/src/components/baam/search.ts) once it lands; filed as a follow-up ratherthan grown into this PR, which touches no renderer file.
change, not this branch's: its header cites
crates/biorouter/src/marketplace/search.rs(nowcatalog_search.rs) and says "A changeto a rule below is a change to both files"; and
search.ts:231implements thewhole-query check as
text.toLowerCase().includes(query.phrase)— the substring rule4de29d1areplaces. Portingwritten_inis a few lines and belongs on that branch, soits Rust↔TS parity harness keeps passing.
🤖 Generated with Claude Code