Desktop: the Browse modals match a search word by word, ranked like the model's search (F5) - #255
Merged
Merged
Conversation
The desktop BAAM search had the defect #242 fixed in the model-facing search: `skillMatches` and `extensionMatches` asked whether the WHOLE query occurred inside one field. Measured in the real Browse skills modal against seven rows of landing/registry.json: R scripting ggplot visualization -> 0 rows (each word finds skills) r-scripting -> 0 rows (the id was not searched) ggplot -> 2 rows `search.ts` ports crates/biorouter/src/marketplace/search.rs rule for rule: split at whitespace and punctuation, a hit on ANY term, terms under three characters match whole words only, filler dropped unless nothing else is left, a plural falls back to its singular, ranked by verbatim match, then terms matched, then field weight x match quality, then registry order. `scoreEntry` is the pure per-entry function (score 0 is no match, plus the matched terms); `rankSkills` / `rankExtensions` replace the two matchers and search exactly the Rust catalog's fields. Differences from the old matcher, each deliberate: - The id (and an extension's `extension_name`) is searched. - The license is not. Every one of the registry's 166 entries is Apache-2.0, so it separated nothing, and under word matching it made `PACS` list all 129 skills (`pac` is inside `apache`). The Rust catalog never searched it. - A query of only spaces browses; it used to search for the spaces and show "No skills match your search." - Under a query, Browse skills shows one ranked list headed "Matches" instead of the category headings, which would have put a Core skill matching one word above a Biomedical skill matching all of them. Browsing (no query) is unchanged. The category filter still applies. - Browsing reads no field, so a malformed entry is still listed, and a search skips missing fields instead of throwing in render. Full registry (129 skills, 37 extensions), before -> after: `R scripting ggplot visualization` 0 -> 11, `r-scripting` 0 -> 9, `ggplot` 2 -> 2, extensions `SPOKE knowledge graph` 0 -> 3. Parity was checked against the Rust matcher itself, compiled from search.rs into a std-only harness: over 1,345 skill and 336 extension queries on the full registry (11,920 hits), rankSkills/rankExtensions return the same entries, matched terms and order, with 0 mismatches.
…match first The guide's BAAM section walked through Browse Extensions without saying how its search box reads a query. Both browsers now rank a phrase the way the agent's marketplace search does (F5), which is the one thing a user typing several words needs to know.
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.
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.
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.
Summary
#242 fixed QA finding F5 for the model-facing marketplace search. The desktop app's own BAAM search had the same defect in TypeScript:
skillMatches/extensionMatchesinui/desktop/src/components/baam/registry.tschecked whether the whole query was a substring of a single field.This PR ports
crates/biorouter/src/catalog_search.rs(landed on main via #242 asmarketplace/search.rs; moved, and given the word-boundary rule, by #266) to TypeScript asui/desktop/src/components/baam/search.ts, and both Browse modals use it:scoreEntryis the pure per-entry function: it returns ascore(0 means no match) and thematchedTerms.rankSkills/rankExtensionsreplace the old matchers. Their only callers wereBrowseSkillsModal,BrowseExtensionsModaland one test mock, and all three are updated.Measured before → after
In the real
BrowseSkillsModal, rendered with seven rows copied verbatim fromlanding/registry.json, on the unfixed code:R scripting ggplot visualizationggplotr-scriptingOn the full registry (129 skills, 37 extensions): the phrase goes 0 → 11 (the same count #242 reports for the model-facing search),
r-scripting0 → 9, and the extension querySPOKE knowledge graph0 → 3.Behaviour changes, each deliberate
extension_name), as in the Rust catalog.Apache-2.0, so the field never told entries apart. Under word matching it also madePACSlist all 129 skills, because the singularpacis insideapache. The Rust catalog never searched it, and dropping it gives exact parity (below).apachestill finds the 49 skills that carry it as a tag or keyword.isRegistryDocumentonly checks that each entry is an object.Parity with the Rust matcher
⚠ This was measured before #266, against the file at
marketplace/search.rs; the realignment section below re-measures the one rule that has changed since. I compiled the realsearch.rs(byte-identical to main's at the time) into a small std-only Rust harness and diffed it against the TypeScript functions on the full registry. The query set was 1,345 skill and 336 extension queries: hand-written phrases, every id, name, tag and keyword, and description openings, including filler, plurals, short terms, punctuation and non-ASCII text.rankSkills/rankExtensionsreturn the same entries, matched terms and order: 0 mismatches over 11,920 compared hits.To check that the harness can fail, I mutated the TypeScript. Raising the short-term threshold by one produced 82 mismatches, and deleting a single filler word produced 13.
The only allowed divergence is tie order. Rust breaks ties by id (a
BTreeMap), while the modal breaks them by the registry document's order, which is also the order it browses in.Tests
search.test.ts(43 tests):written_inboundary casesBrowseSkillsModal.test.tsx(+8): the QA queries typed into the real modal, the grouped browse view against the ranked "Matches" view, a whitespace-only query, the category filter during a search, and the empty state.BrowseExtensionsModal.test.tsx(+1): the ranked list on screen. TheextensionMatchesmock is gone; that suite now runs the real search.keywordsbeing dropped from the searched fields.Realigned onto #266 (
catalog_search.rs)#266 changes two things under this port, and the header's own sentence — "A change to a rule below is a change to both files" — is what made that findable. Both are answered here, one commit each:
1. The file moved.
crates/biorouter/src/marketplace/search.rs→crates/biorouter/src/catalog_search.rs,MarketplaceSearch/MarketplaceSearchHit→CatalogSearch/CatalogSearchHit.search.tsandsearch.test.tsnow cite the real path and names. The sentence is kept word for word.2. The whole-query rank rule changed, and
search.ts:231still had the substring test it replaced. A query now counts as written only where it starts and ends at a word boundary (written_in), the same edges the three-character term rule already enforced.Measured, before → after
Same harness, the real
search.tsbundled twice — this branch's previous commit and the new one — ranking the livelanding/registry.json(129 skills + 37 extensions), 38 queries × both catalogs = 76 searches, full ordered hit lists compared. Control run (old vs old): 0 of 76 changed, so a 0 means something.Rdys pmlAIRs pThat is #266's blast radius reproduced from the TypeScript side, drop for drop: 0 start / 198 stop / 7 of 76, every one a query whose every term is short, and
R125→8 with its whole surviving list in the same order. Every survivor list is a prefix of its before-list except the twos psearches — #266's "only two searches change their first result, both fors p". The other 33 queries (ggplot,complexheatmap,python,rna,knowledge graph,differential expression,R scripting ggplot visualization,r-scripting,SPOKE knowledge graph,c++,ucsf, …) return the identical list in the identical order in both catalogs.Fail-before, on the rule this replaces: a one-letter
Rreturned 125 of 129 skills, 117 of them matching no term at all, and rankedempirical-paper-submission-rrabover-scripting.The word boundary, and why it is not
\bwrittenInis a transliteration ofwritten_in, not a regular expression, and that is measured rather than assumed. JavaScript defines\bover[A-Za-z0-9_]alone, so a\b-built test disagrees with Rust on 4 of the 11 cases now asserted:\bc++ code++snake_casecase_is a word character to\b, not to Rustnaïvenaïïis a boundary to\b, a letter to Rust𝐚rnarnaThe 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 matching no letter class, so the phrase would be read as written where Rust refuses it. It also tries every position rather than the first occurrence, because a refused occurrence can overlap an accepted one (a ainba a a).WORD_CHARis spelled as the explicit complement of theWORD_BREAKthatwords()already splits on, so a term and the query around it can never be held to different edges.Tests
Three existing tests change — the same three #266 changed in Rust — and no modal test moves (15 + 12 keep passing):
still finds a verbatim occurrence: itsdyhalf asserted the leak as a feature, exactly as Rust'sa_verbatim_occurrence_is_still_a_hitdid. Replaced by three tests: a one-letter query as a whole word, a phrase found only inside longer words, anddy/s pboth empty.ranks a verbatim match above every term match, even one matching more terms: the premise is now unreachable. A phrase written in a field has every one of its own words in that field as a whole word, so a written entry always matches every term. Re-pitted against placement instead (prose vs two names), withcatalog_search.rs's owntidy code→[styler, code-tidy]ranking beside it.Ralone over the fixture: 6 hits, 3 matching no term → the 3 skills that are about R.Added: a direct
writtenIndescribe carrying Rust's sixwritten_incases plus the four\bdivergences.writtenInis exported for it, the way Rust asserts them from inside the module.Mutation check. Reverting the body to
return text.includes(phrase)and changing nothing else fails 6 of 43 tests, both boundary tests among them. The one new test that survives is the score/packing one — correctly: it pins the tier arithmetic, not the boundary rule.Follow-up, deliberately not in this PR
#266 found the same whole-phrase filter a third and fourth time, both renderer TypeScript with no shared code:
ui/desktop/src/components/skills/SkillsView.tsx:72-86(Settings → Skills) andui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.tsx:84-101(the composer picker). Pointing them at this branch'ssearch.tsis the right move, and it does not fit yet:SkillCatalogEntryis a union — a single skill, or a bundle row whose searchable text isdisplayName,nameand its member skill names. The Rust matcher has no bundle row: it ranks individual skills and carries the bundle as aLabelfield on each member (skills_extension.rs::search_fields). A member's name is therefore aNamefield of a different entry there, and weighting it on the bundle row would be a TypeScript-only rule — in the one file whose whole value is that every rule is shared, with no Rust row to diff it against.SkillsViewdiscards the ranking. It re-groups its filtered entries by provenance (Biorouter / project / per-extension / other) and renders them grouped, so the ranking would be thrown away unless that view switches to one ranked list under a query, the wayBrowseSkillsModaldoes here. That is a layout decision with its own tests, not a port.Verification
npx vitest run src/components/baam/: 84 passed (80 before the realignment)npm run lint:check: exit 0 (re-run after the realignment)npm run format:check/npx prettier --checkon both changed files: exit 0npm run test:run: 5,061 passed, 1 skipped, 0 failed, but the run exits 1. Every test passes; theafterAllteardown (browser.close()on a real Playwright Chromium) insrc/utils/artifactCdnAssets.browser.test.tshangs past 30 s. It fails the same way when run alone and on pristinemain(6455bc2), so it is not caused by this branch. With that one file excluded: 445/445 files, exit 0. I've flagged it as a separate task.🤖 Generated with Claude Code