From 15f1f26866d5c61d5d46ef0a35ab42d96ff16dcd Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 12 Sep 2026 03:32:30 -0700 Subject: [PATCH 1/6] fix(search): three fields that answered with the whole catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #276/#277 stopped the marketplace matchers searching the licence and the version, because an administrative value repeated across most entries turns a short query into the shelf. The same drive measured three more fields doing it, and found the three copies of the matcher out of step over which fields they search at all. **D1 — a three-letter term matched inside any word, however long.** Measured on the shipped 37-entry extension shelf, three ways agreeing: `lab` -> 37 of 37, `gen` -> 36, `age` -> 36. Per hit, 32 of `lab`'s matched only as an infix of `baranzinilab` — the organization — and 33 each of `gen`'s and `age`'s only as an infix of `...Agent` in the extension's own NAME. So it is a rule and not a field: excluding `organization` fixes one of the three, and nothing can exclude a name. On the skills shelf the same rule had `ing` matching 88 of 129, `ion` 84, `ica` 33. The matcher already grades an anchored match above an unanchored one (a prefix scores 2, an infix 1); only the admission gate did not. An unanchored match now needs four characters, or half the word it sits in. Two arms because each closes a case the other gets wrong, both measured over the registry's own 807-word vocabulary: a flat four-character floor loses `rna` inside scRNA/rRNA/miRNA and `sem` inside RSEM (87 hits removed); a flat half-the-word ratio loses `omics` inside transcriptomics and `flow` inside workflows (143 removed). Together, 73 removed over 15 of the 807 queries, 34 of them the `lab` flood. Half is the proportion the infix rule's own documented case sits at — `heatmap` is 7 of `complexheatmap`'s 14. After: `lab` 6, `gen` 6, `age` 6, and `BaranziniLab` still 36, `UCSF` 7, `SPOKEAgent` 1 — verified in the running Browse-extensions modal, on the real website page, and in the Rust catalog. **D2 — a skill's `category` named most of the catalog.** `Core` is 57 of 129 rows and `Biomedical` 63, so `core` returned 59, `cor` 59, `ore` 61, `biomedical` 65. Every surface that shows the category answers it with a control — the Browse skills modal's own `All` / `Core skills` / `Developer & authoring` / `Biomedical analysis` filter, whose Developer chip was measured showing exactly the 9 rows `developer` returned, and three `data-facet="category"` chips on the website shelf — and the website's matcher never searched the field, so the three were not in step while `registry.ts` claimed the fields were the same. Dropped from the Rust catalog and the desktop port; after, `core` 2, `biomedical` 2, and every row still returned says the word itself. **D3 — the website searched the invocation mode.** `cardFields` pushed the whole `.skill-type` line at Name weight, so an administrative label on 100% of cards was searched: `invocable` -> 62 of 132, `auto` -> 74, `user` -> 62, `applied` -> 70, against 0 and 4 in the app. `auto` is a genuinely topical query (autoimmune, automation, autoencoder). The line also carries the skill's slug, which IS its registry id and is worth Name weight, so the slug is kept and the mode dropped — `initSkills` already reads that same text into `card._type` for the two facet chips, so searching its words was a weaker second copy of a control the page has. After: `invocable` 0, `auto` 5, and `/ucsf-hpc` still finds its skill. **Parity, measured rather than asserted.** A new differential in `baam-search.test.mjs` drives the real page for every distinct word in the catalog (795 queries a shelf) and compares the visible cards against the canonical field list, restated independently. On origin/main that reports 10 skill and 2 extension disagreements; it now reports 0 and 0. Closing the last three needed two more fixes in the same class: * the website searched neither an extension's id nor its manifest name, so `codegraphagent` and `playwrightagent` found their extension in the app and nothing on the site (the headings read "CodeGraph Agent"). `data-registry-id` is now rendered and searched at Name weight, as the static SPOKEAgent card already declared it. * version tokens were blanked by PATTERN, so a bare number was read as a version wherever it appeared: `13485` (ISO 13485, a keyword of `regulatory-quality-systems`) was unfindable on the site. Blanked by the card's own version now, the way the licence already was — `namesOnlyTheLicense` became `saysOnly`, which is what both callers needed. The skills differential reads the three grids, not the shelf: the featured strip repeats three skills as hand-written cards whose prose, tags and `data-tags` have drifted from their grid twins, and the registry is built from the grids, so a differential against it cannot speak about them. That is a content divergence on the page, noted in the test. `catalog_search_mirrors.rs` is the first test that reads all three copies: the two thresholds agree in all three, `substantial_infix`/`substantialInfix` carries both arms in all three, and neither skill search reads the category again while both extension searches still read the organization. Mutating any one of them reddens it. No registry datum touched. `build-registry.mjs --check` current (37 extensions, 129 skills), `check-consistency.mjs --check` clean, landing suites 16/16, 23/23, 56/56, 21/21. `cargo test -p biorouter --lib` 4025 passed; vitest `src/components/baam` 105 passed. --- crates/biorouter/src/catalog_search.rs | 107 ++++++- crates/biorouter/src/marketplace.rs | 140 ++++++++- .../biorouter/tests/catalog_search_mirrors.rs | 167 +++++++++++ docs/extensions/skill-catalog.md | 6 + landing/baam.html | 83 +++++- landing/marketplace-search.js | 42 ++- landing/scripts/baam-search.test.mjs | 274 ++++++++++++++++++ ui/desktop/src/components/baam/registry.ts | 14 +- ui/desktop/src/components/baam/search.test.ts | 157 +++++++++- ui/desktop/src/components/baam/search.ts | 87 +++++- 10 files changed, 1046 insertions(+), 31 deletions(-) create mode 100644 crates/biorouter/tests/catalog_search_mirrors.rs diff --git a/crates/biorouter/src/catalog_search.rs b/crates/biorouter/src/catalog_search.rs index 7aee905e4..64b5d2b02 100644 --- a/crates/biorouter/src/catalog_search.rs +++ b/crates/biorouter/src/catalog_search.rs @@ -32,8 +32,11 @@ //! 4. then the order the entries were given in — the registry's is by id, the //! installed skills' by name — so a result never reshuffles. //! -//! Two rules keep the union from drowning the useful hits, and both were needed -//! by the measured query itself: +//! Three rules keep the union from drowning the useful hits, and the first two +//! were needed by the measured query itself. The third — an unanchored match has +//! to be worth something, see [`substantial_infix`] — closes the same failure one +//! step further in: a query that finds everything says nothing, whether it got +//! there through a repeated field or through a three-letter morpheme. //! //! * **A term under three characters matches whole words only.** `r` has to //! find the R language; as a substring it matched nearly every entry. The @@ -52,7 +55,19 @@ pub(crate) enum Weight { /// Free prose: a description. Prose = 1, - /// Curated labels: tags, keywords, a category, an organization, a bundle. + /// Curated labels: tags, keywords, an organization, a bundle. + /// + /// ⚠ **Not a curation bucket a surface also offers as a filter control.** A + /// skill's `category` was here, and it is the licence's defect again: `Core` + /// names 57 of the shipped registry's 129 skills and `Biomedical` 63, so + /// `core` returned 59 and `biomedical` 65 — half the catalog, ranked by a + /// word the user did not mean. Every surface that shows the category answers + /// it with a control instead — the desktop Browse-skills modal's own category + /// filter (`All` / `Core skills` / `Developer & authoring` / + /// `Biomedical analysis`, measured showing exactly the 9 Developer rows that + /// `developer` used to return), and three `data-facet="category"` chips on the + /// website shelf — and the website never searched it, so dropping it is also + /// what brings the three matchers into step. See `MarketplaceCatalog::search_skills`. Label = 2, /// What the entry is called: its id and names. Name = 3, @@ -118,6 +133,10 @@ pub(crate) const EXTENSION_NOISE: &[&str] = &["extension", "extensions"]; /// Below this many characters a term matches whole words only. const MIN_PARTIAL_CHARS: usize = 3; +/// At or above this many characters a term may match anywhere inside a word, +/// however long the word. Below it, [`substantial_infix`] asks for half. +const MIN_INFIX_CHARS: usize = 4; + /// One entry a search returned, with the query terms it matched. #[derive(Debug)] pub struct CatalogSearchHit<'a, T> { @@ -240,9 +259,52 @@ fn terms(query: &str, noise: &[&str]) -> Vec { } } +/// Is `term`, found inside `word` without touching its start, enough of that +/// word to be a search rather than a morpheme? +/// +/// The matcher already grades an anchored match above an unanchored one — a +/// prefix scores 2, an infix 1 — and [`MIN_PARTIAL_CHARS`] was the only +/// admission gate, so three characters bought a match anywhere inside any word. +/// Measured on the 37-entry extension shelf of the shipped registry, that is +/// what made three-letter queries return it whole: `lab` → **37 of 37**, `gen` → +/// 36, `age` → 36. Per hit, 32 of `lab`'s were an infix of `baranzinilab` — the +/// organization — and 33 each of `gen`'s and `age`'s an infix of `…Agent` in the +/// extension's own NAME. So this is not a field that can be dropped, the way the +/// licence and the version were: `lab` alone would be fixed by dropping +/// `organization`, and nothing can drop a name. What all three share is a +/// three-letter term with no boundary on either side. On the skills shelf the +/// same rule had `ing` matching 88 of 129, `ion` 84 and `ica` 33. +/// +/// So an unanchored match needs either [`MIN_INFIX_CHARS`] characters, or half +/// the word it sits in. Two arms rather than one number, because each closes a +/// case the other gets wrong, and both were measured over the shipped registry's +/// own vocabulary (807 distinct catalog words, every query a visitor could be +/// echoing back): +/// +/// * A flat four-character floor drops the hits a short term earns inside a +/// SHORT word: `rna` in `scRNA`, `rRNA`, `miRNA`, `piRNA` and `sem` in `RSEM` +/// are the search, not a morpheme. It cost `rna` `single-cell` and +/// `microbiome`, and `sem` `rna-quantification` — 87 hits removed in total. +/// * A flat half-the-word ratio drops the hits a LONG term earns inside a longer +/// compound, which is most of a biomedical vocabulary: `omics` stopped finding +/// `transcriptomics`, `metabolomics` and `epigenomics`, and `flow` stopped +/// finding `workflows`. 143 hits removed, 30 queries touched. +/// +/// Together: 73 hits removed over 15 of the 807, of which 34 are the `lab` +/// flood; the rest are `pro` inside "reproducible"/"improving", `logs` inside +/// "pathology", `end` inside "frontend"/"appendix". Half is also the proportion +/// this rule's own documented case sits at — `heatmap` is 7 of +/// `complexheatmap`'s 14 — so the arm that admits a short term is calibrated to +/// the example the infix rule exists for, rather than to the queries it refuses. +fn substantial_infix(term: &str, word: &str) -> bool { + let term_chars = term.chars().count(); + term_chars >= MIN_INFIX_CHARS || term_chars * 2 >= word.chars().count() +} + /// How well `term` matches one field word: 3 for the whole word, 2 for its /// start, 1 for anywhere inside it (`heatmap` in `complexheatmap`), 0 for no -/// match. A short term matches whole words only. +/// match. A short term matches whole words only, and a term that is short +/// relative to the word matches only at its start — see [`substantial_infix`]. fn strength(term: &str, word: &str) -> u32 { if word == term { 3 @@ -250,7 +312,7 @@ fn strength(term: &str, word: &str) -> u32 { 0 } else if word.starts_with(term) { 2 - } else if word.contains(term) { + } else if word.contains(term) && substantial_infix(term, word) { 1 } else { 0 @@ -508,6 +570,41 @@ mod tests { assert_eq!(search.hits[1].matched_terms, ["scripting"]); } + /// An unanchored match has to be worth something. Three characters is enough + /// to search from the START of a word — `gen` really does find `genomics` — + /// and, inside a long one, is a morpheme: `lab` inside `BaranziniLab` and + /// `gen` inside `…Agent` returned the whole 37-entry extension shelf. + /// + /// Asserted against [`strength`] directly, one word at a time, because at + /// catalog level the same query reaches the same entry through several words + /// and a count hides which rule admitted it. + #[test] + fn a_short_term_matches_inside_a_word_only_when_it_is_half_of_it() { + // The three measured floods, at the word each of them came through. + assert_eq!(strength("lab", "baranzinilab"), 0, "3 of 12"); + assert_eq!(strength("gen", "cdwagent"), 0, "3 of 8"); + assert_eq!(strength("age", "language"), 0, "3 of 8"); + // Unanchored is the only thing refused. The start of a word still counts + // at three characters, and the whole word always counts. + assert_eq!(strength("gen", "genomics"), 2); + assert_eq!(strength("lab", "labarchives"), 2); + assert_eq!(strength("lab", "lab"), 3); + // A short term inside a SHORT word is the search, not a morpheme — and + // these are the hits a flat four-character floor would have cost. + assert_eq!(strength("rna", "scrna"), 1, "3 of 5"); + assert_eq!(strength("rna", "rrna"), 1, "3 of 4"); + assert_eq!(strength("sem", "rsem"), 1, "3 of 4"); + assert_eq!(strength("age", "image"), 1, "3 of 5"); + // At four characters a term is unanchored anywhere, however long the + // word — which is what keeps a compound biomedical vocabulary findable. + assert_eq!(strength("omics", "transcriptomics"), 1); + assert_eq!(strength("flow", "workflows"), 1); + // The case the infix rule was written for sits exactly on the boundary + // the short arm draws, so it would pass on either arm. + assert_eq!(strength("heatmap", "complexheatmap"), 1, "7 of 14"); + assert!(substantial_infix("heatmap", "complexheatmap")); + } + #[test] fn a_long_term_matches_inside_a_word_and_a_plural_finds_its_singular() { assert_eq!( diff --git a/crates/biorouter/src/marketplace.rs b/crates/biorouter/src/marketplace.rs index c43a797ec..ccf96f912 100644 --- a/crates/biorouter/src/marketplace.rs +++ b/crates/biorouter/src/marketplace.rs @@ -153,12 +153,24 @@ impl MarketplaceCatalog { /// Rank every skill against a free-text query, matched as documented in /// `catalog_search.rs`. + /// + /// ⚠ **`category` is not among the fields, and that is the licence's argument + /// one field further on.** A curation bucket names most of the catalog — + /// measured over the shipped registry, `Core` is 57 of 129 skills and + /// `Biomedical` 63 — so searching it turned a short query into the shelf: + /// `core` → 59 of 129, `cor` → 59, `ore` → 61, `biomedical` → 65. None of + /// those answers was about a topic the user named. Every surface that shows + /// the category offers it as a CONTROL instead — `Filter` chips in the + /// desktop Browse-skills modal (`BrowseSkillsModal.tsx`), three + /// `data-facet="category"` chips on the website shelf — and the website's + /// matcher never searched the field at all, so leaving it out is also what + /// puts the three copies of this matcher in step. `skill_type` is absent for + /// the same reason and always has been: it is the `data-type` facet. pub fn search_skills(&self, query: &str) -> CatalogSearch<'_, MarketplaceSkillDescriptor> { rank(query, SKILL_NOISE, self.skills.values(), |entry| { let mut fields = vec![ (entry.registry_id.as_str(), Weight::Name), (entry.name.as_str(), Weight::Name), - (entry.category.as_str(), Weight::Label), (entry.description.as_str(), Weight::Prose), ]; fields.extend( @@ -1173,6 +1185,132 @@ mod tests { ); } + /// A three-letter query is not the whole shelf. Measured in the + /// Browse-extensions modal on 2026-09-12 against the shipped 37-entry + /// registry, and reproduced here before the fix: `lab` → **37 of 37**, `gen` + /// → 36, `age` → 36. Per hit, 32 of `lab`'s matched only as an infix of + /// `baranzinilab` (the organization) and 33 each of `gen`'s and `age`'s only + /// as an infix of `…Agent` in the extension's own NAME — so this is a rule, + /// not a field: dropping `organization` fixes one of the three and no + /// catalog can drop a name. See `catalog_search::substantial_infix`. + /// + /// The counts below are upper bounds rather than equalities: a new extension + /// whose prose says "lab" must not fail this test. What it pins is that the + /// answer is a handful and not the shelf, and that the three names the shelf + /// is browsed BY survive intact. + #[test] + fn a_three_letter_query_does_not_return_the_whole_extension_shelf() { + let catalog = MarketplaceCatalog::from_bytes(EMBEDDED_REGISTRY).unwrap(); + let shelf = catalog.browse_extensions(ProviderTier::Private).len(); + assert!(shelf >= 30, "measured against 37 entries; now {shelf}"); + + // Guard: the words the flood came through are still in the catalog, so a + // pass here means the rule refused them rather than the registry having + // stopped saying them. + let names = catalog + .browse_extensions(ProviderTier::Private) + .iter() + .filter(|entry| entry.name.to_lowercase().contains("agent")) + .count(); + let orgs = catalog + .browse_extensions(ProviderTier::Private) + .iter() + .filter(|entry| entry.organization.to_lowercase().contains("baranzinilab")) + .count(); + assert!( + names >= 20 && orgs >= 20, + "the shelf no longer says `Agent` ({names}) or `BaranziniLab` ({orgs}), so this test \ + would pass vacuously" + ); + + for (query, was) in [("lab", 37), ("gen", 36), ("age", 36)] { + let now = catalog + .search_extensions(ProviderTier::Private, query) + .len(); + assert!( + now <= 8, + "`{query}` returned {now} of {shelf}; it returned {was} before the infix rule \ + required half the word" + ); + } + + // What a visitor actually browses this shelf by, all three of which + // reach their entries as WHOLE words and so are untouched. + let ids = |query: &str| -> Vec { + catalog + .search_extensions(ProviderTier::Private, query) + .hits + .iter() + .map(|hit| hit.entry.registry_id.clone()) + .collect() + }; + assert_eq!(ids("SPOKEAgent"), ["spokeagent"]); + assert_eq!(ids("BaranziniLab").len(), orgs, "the lab, by its own name"); + let ucsf = ids("UCSF"); + assert!( + ucsf.len() >= 5 && ucsf.contains(&"ucsfhpcagent".to_owned()), + "UCSF by name: {ucsf:?}" + ); + } + + /// A curation bucket is not a search term. Measured on the shipped registry + /// before the fix: `core` → 59 of 129 skills, `cor` → 59, `ore` → 61, + /// `biomedical` → 65, because `Core` is the category of 57 rows and + /// `Biomedical` of 63. It is the licence's defect one field on, and it is + /// also where the three copies of this matcher had drifted: the website never + /// searched the field, so the desktop modal and the model's tool answered a + /// query the website did not. Every surface offers the category as a control + /// instead. + #[test] + fn a_skills_category_is_a_filter_control_and_not_a_searched_field() { + let catalog = MarketplaceCatalog::from_bytes(EMBEDDED_REGISTRY).unwrap(); + let shelf = catalog.browse_skills().len(); + + // Guard, again: the buckets have to be big for the refusal to mean + // anything, and these are the counts the numbers above were measured at. + for bucket in ["Core", "Biomedical"] { + let rows = catalog + .browse_skills() + .iter() + .filter(|entry| entry.category == bucket) + .count(); + assert!( + rows * 3 >= shelf, + "`{bucket}` names only {rows} of {shelf} skills, so searching it would no longer \ + return most of the shelf and this test would pass vacuously" + ); + } + + // Every skill the bucket's own name still finds says that word itself. + for (query, was) in [("core", 59), ("biomedical", 65), ("developer", 9)] { + let hits = skill_ids(&catalog.search_skills(query)); + assert!( + hits.len() * 4 < shelf, + "`{query}` returned {} of {shelf} (was {was})", + hits.len() + ); + for id in &hits { + let entry = catalog.resolve_skill_for_install(id).unwrap(); + let said_elsewhere = [ + entry.registry_id.as_str(), + entry.name.as_str(), + entry.description.as_str(), + ] + .into_iter() + .chain(entry.tags.iter().map(String::as_str)) + .chain(entry.keywords.iter().map(String::as_str)) + .any(|text| text.to_lowercase().contains(query)); + assert!( + said_elsewhere, + "`{id}` matched `{query}` through nothing but its category" + ); + } + } + // Browsing is untouched: the category is dropped from what is SEARCHED, + // not from the catalog — it is still what the modal groups by. + assert_eq!(catalog.search_skills("").len(), shelf); + } + /// The extension catalog shares the matcher, and the caller filter runs /// before the ranking: a public caller's multi-word query can match the /// private row's words and still never be shown it. diff --git a/crates/biorouter/tests/catalog_search_mirrors.rs b/crates/biorouter/tests/catalog_search_mirrors.rs new file mode 100644 index 000000000..7925b8933 --- /dev/null +++ b/crates/biorouter/tests/catalog_search_mirrors.rs @@ -0,0 +1,167 @@ +//! The marketplace matcher exists three times, and this is where the three are +//! held to one rule. +//! +//! `crates/biorouter/src/catalog_search.rs` is canonical. It is ported to +//! `ui/desktop/src/components/baam/search.ts` (the Browse modals) and to +//! `landing/marketplace-search.js` (the BAAM shelves at biorouter.ucsf.edu), and +//! each port has its own tests — but nothing could see the three at once, so what +//! drifted was not a rule but the FIELD LIST each caller hands the rule. Measured +//! on 2026-09-12 against the shipped registry: the Rust catalog and the desktop +//! modal searched a skill's `category` and the website never did, so `core` +//! answered 59 of 129 skills in the app and 2 of 132 cards on the site, and +//! `registry.ts` claimed the fields were the same. +//! +//! This is the only test that reads all three files, so it is deliberately about +//! text rather than behaviour: the numeric rules, and the one field list whose +//! divergence was invisible. Behaviour is pinned where each copy lives — +//! `catalog_search::tests` and `marketplace::tests` here, +//! `search.test.ts` / `registry.test.ts` in the desktop app, and +//! `landing/scripts/baam-search.test.mjs`, whose differential runs the website's +//! real page against the canonical field list over 795 catalog queries. + +use std::fs; +use std::path::PathBuf; + +fn repo(path: &str) -> String { + let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + file.push("../.."); + file.push(path); + fs::read_to_string(&file).unwrap_or_else(|error| panic!("reading {}: {error}", file.display())) +} + +/// The integer a `const NAME = 4;` / `const NAME: usize = 4;` line declares. +/// Deliberately strict: a constant that has become an expression is a change this +/// test should notice rather than skip. +fn declared_number(source: &str, name: &str) -> u32 { + let line = source + .lines() + .find(|line| line.contains(name) && line.contains('=') && line.trim_end().ends_with(';')) + .unwrap_or_else(|| panic!("no `{name} = …;` declaration")); + let digits: String = line + .rsplit('=') + .next() + .unwrap() + .chars() + .filter(char::is_ascii_digit) + .collect(); + digits + .parse() + .unwrap_or_else(|_| panic!("`{name}` is declared as `{}`, not a number", line.trim())) +} + +/// The body of the function `name` opens, up to the first `}` that closes a line +/// at the indent a function is declared at in one of these three files — column +/// zero (Rust module level, an exported TS function), two (inside the website's +/// IIFE) or four (a Rust `impl`). Whichever closes EARLIEST is the function's own, +/// which is enough to read one declaration without parsing three languages. +fn function_body<'a>(source: &'a str, name: &str) -> &'a str { + let (_, rest) = source + .split_once(name) + .unwrap_or_else(|| panic!("no function `{name}`")); + ["\n}", "\n }", "\n }"] + .iter() + .filter_map(|close| rest.split_once(close).map(|(body, _)| body)) + .min_by_key(|body| body.len()) + .unwrap_or(rest) +} + +const RUST: &str = "crates/biorouter/src/catalog_search.rs"; +const DESKTOP: &str = "ui/desktop/src/components/baam/search.ts"; +const WEBSITE: &str = "landing/marketplace-search.js"; + +/// One number in three files. `MIN_INFIX_CHARS` is the newer of the two and the +/// reason this test exists: a port that took the rule's shape and not its +/// threshold would pass its own tests and answer a different catalog. +#[test] +fn the_three_matchers_declare_the_same_thresholds() { + for constant in ["MIN_PARTIAL_CHARS", "MIN_INFIX_CHARS"] { + let rust = declared_number(&repo(RUST), constant); + for port in [DESKTOP, WEBSITE] { + assert_eq!( + declared_number(&repo(port), constant), + rust, + "{port} declares a different {constant} from {RUST}" + ); + } + } + // And they are not the same number, which is the whole rule: three characters + // is enough to search from the start of a word and not from inside one. + assert!( + declared_number(&repo(RUST), "MIN_INFIX_CHARS") + > declared_number(&repo(RUST), "MIN_PARTIAL_CHARS") + ); +} + +/// Both arms, in all three. A port that kept only the threshold arm loses `rna` +/// inside `scRNA`; one that kept only the ratio arm loses `omics` inside +/// `transcriptomics`. Either way its own tests still pass. +#[test] +fn the_three_matchers_admit_an_unanchored_match_on_the_same_two_arms() { + for (file, name) in [ + (RUST, "fn substantial_infix"), + (DESKTOP, "export function substantialInfix"), + (WEBSITE, "function substantialInfix"), + ] { + let source = repo(file); + let body = function_body(&source, name); + assert!( + body.contains("MIN_INFIX_CHARS"), + "{file}: `{name}` does not read MIN_INFIX_CHARS" + ); + assert!( + body.contains("* 2 >="), + "{file}: `{name}` has no half-the-word arm: {body}" + ); + } +} + +/// The field list, which is what actually drifted. A skill's `category` is a +/// filter control on every surface that shows it — three chips in the desktop +/// modal, three `data-facet="category"` chips on the website shelf — and naming +/// 57 and 63 of 129 rows, searching it returned half the catalog. +/// +/// Asserted as an absence, so each half also asserts the function was really +/// found and read: a body that no longer mentions `keywords` has not been parsed. +#[test] +fn neither_skill_search_reads_the_category_a_facet_already_answers() { + for (file, name) in [ + ( + "crates/biorouter/src/marketplace.rs", + "pub fn search_skills", + ), + ( + "ui/desktop/src/components/baam/registry.ts", + "export function rankSkills", + ), + ] { + let source = repo(file); + let body = function_body(&source, name); + assert!( + body.contains("keywords"), + "{file}: `{name}` was not read — its body mentions no keywords field: {body}" + ); + assert!( + !body.contains("category"), + "{file}: `{name}` searches a skill's category again: {body}" + ); + } + // The organization is NOT the same case and stays searched: people browse the + // extensions shelf by the lab that publishes it, and no surface offers the + // full organization as a control. + for (file, name) in [ + ( + "crates/biorouter/src/marketplace.rs", + "pub fn search_extensions", + ), + ( + "ui/desktop/src/components/baam/registry.ts", + "export function rankExtensions", + ), + ] { + let source = repo(file); + assert!( + function_body(&source, name).contains("organization"), + "{file}: `{name}` stopped searching the organization" + ); + } +} diff --git a/docs/extensions/skill-catalog.md b/docs/extensions/skill-catalog.md index 526aefcf2..0b4bc8854 100644 --- a/docs/extensions/skill-catalog.md +++ b/docs/extensions/skill-catalog.md @@ -238,6 +238,12 @@ query it **ranks** the skills this conversation has enabled, through - A word under three characters matches whole words only, so `r` finds the R language rather than every word with an r in it. Filler (`for`, `about`, `skill`) is dropped, and a plural falls back to its singular. +- A word matches **inside** a longer word only when it is four characters or + half that word: `omics` finds `transcriptomics` and `rna` finds `scRNA`, but + `gen` no longer finds `…Agent` and `lab` no longer finds `BaranziniLab`. Three + characters buried in a long word is a morpheme rather than a search, and on the + marketplace catalog it returned nearly everything — `lab` matched 37 of 37 + extensions, `ing` 88 of 129 skills. - The conversation's switches apply **first**: a skill turned off here is never scored, returned or counted. diff --git a/landing/baam.html b/landing/baam.html index b03474e73..0f25ba5c3 100644 --- a/landing/baam.html +++ b/landing/baam.html @@ -3805,9 +3805,41 @@

Stay in the loop

var W = window.MarketplaceSearch.Weight; var fields = []; var push = function (el, weight) { if (el) fields.push([el.textContent, weight]); }; + // The catalog's own ID and manifest name, at Name weight — the two fields + // `catalog_search.rs` searches and this page renders nowhere. An extension + // whose id is one word and whose heading is two was therefore unfindable by + // the id a visitor would have read off the marketplace: measured over the + // registry's whole vocabulary, `codegraphagent` and `playwrightagent` each + // found their extension in the app and nothing here, while the headings say + // "CodeGraph Agent" and "Playwright Agent". Read from the attribute rather + // than derived from the download filename, because `spokeagent-0.4.1.brxt` + // is exactly why `data-registry-id` exists — deriving it would put the + // version's digits back into the searched text. + if (card.dataset.registryId) fields.push([card.dataset.registryId, W.Name]); + if (card.dataset.extensionName) fields.push([card.dataset.extensionName, W.Name]); push(card.querySelector('h3'), W.Name); - // "User-invocable · /scientific-research" — carries the skill's slug. - push(card.querySelector('.skill-type'), W.Name); + // ⚠ The `.skill-type` line, but only its SLUG. The line is + // "User-invocable · /scientific-research", or "5 skills · auto-applied", or + // "Auto-applied · R plotting" — the slug is the skill's registry id, which + // `catalog_search.rs` and the desktop port both search at Name weight, and + // everything else in it is the invocation MODE, which is a facet. `initSkills` + // reads that same text into `card._type` for the User-invocable / + // Auto-applied chips to filter on, so searching its words is a weaker second + // copy of a control the page already has — and the mode is on 100% of cards, + // so it answered with most of the shelf: measured on the live page, + // `invocable` showed 62 of 132 skill cards and `auto` 74, against 0 and 4 in + // the app. `auto` is a real topical query here (autoimmune, automation, + // autoencoder), so that one cost a search a visitor was actually making. + // Dropping the element outright would have lost the slug, which is the one + // part worth Name weight; the mode's own words are not searched anywhere else, + // and the app never searched the registry's `type` field at all. + var typeLine = card.querySelector('.skill-type'); + if (typeLine) { + String(typeLine.textContent).split('·').forEach(function (part) { + var text = part.trim(); + if (text.charAt(0) === '/') fields.push([text, W.Name]); + }); + } push(card.querySelector('.ext-desc, .skill-desc, .wf-desc'), W.Prose); // The organization, WITHOUT the version. "BaranziniLab · UCSF · v0.2.0" // splits into the words `v0`, `2`, `0`, so a bare query token of `2` or `0` @@ -3817,7 +3849,13 @@

Stay in the loop

// desktop port searches one, so stripping it is also what keeps the three // matchers in step. var org = card.querySelector('.ext-org'); - if (org) fields.push([String(org.textContent).replace(/[·|]\s*v[\d.]+\s*$/, ''), W.Label]); + var version = ''; + if (org) { + var orgText = String(org.textContent); + var tail = /[·|]\s*(v[\d.]+)\s*$/.exec(orgText); + if (tail) version = tail[1]; + fields.push([orgText.replace(/[·|]\s*v[\d.]+\s*$/, ''), W.Label]); + } // ⚠ Not `data-tags` as it stands. That blob is // `name + organization + version + description + tags`, so it carries two // things a catalog is not searched by, and BOTH were measured matching @@ -3831,12 +3869,20 @@

Stay in the loop

// dropping them here is what keeps the three matchers in step. Separators // are preserved and only the offending token is blanked, so a phrase // spanning two keywords still matches as it did. + // + // ⚠ Both are dropped by the card's OWN value, never by a pattern. Blanking + // "anything shaped like a version" — which is what `/^v?\d+(\.\d+)*$/` did — + // reads a bare number as a version wherever it appears, and a skill card's + // `data-tags` carries no version at all: `13485` (ISO 13485, a keyword of + // `regulatory-quality-systems`) was the one query in 795 where this page and + // the canonical matcher still disagreed, and the page was the wrong one. var licenceWords = wordsOf(card.dataset.license); + var versionWords = wordsOf(version); if (card.dataset.tags) { fields.push([ String(card.dataset.tags).replace(/[^\s,]+/g, function (t) { - if (namesOnlyTheLicense(t, licenceWords)) return ''; - return /^v?\d+(?:\.\d+)*$/.test(t) ? '' : t; + if (saysOnly(t, licenceWords)) return ''; + return saysOnly(t, versionWords) ? '' : t; }), W.Label, ]); @@ -3848,7 +3894,7 @@

Stay in the loop

if (chip.hasAttribute('data-privacy-badge') || chip.hasAttribute('data-affiliation-badge')) return; // By WORDS, not by equality: `apache` and `Apache-2.0` are the same licence // spelled two ways, and equality drops only the second. - if (namesOnlyTheLicense(chip.textContent, licenceWords)) return; + if (saysOnly(chip.textContent, licenceWords)) return; fields.push([chip.textContent, W.Label]); }); return fields; @@ -4048,6 +4094,11 @@

Stay in the loop

const orgToken = /BRXT/i.test(orgStr) ? 'brxt' : /UCSF/i.test(orgStr) ? 'ucsf' : ''; const keywords = escapeHtml([extension.name, extension.organization, extension.version, extension.description, ...(extension.tags || [])].join(' ')); const extName = escapeHtml(extension.extension_name || ''); + // The catalog's own id, which `cardFields` searches at Name weight because + // `catalog_search.rs` does and nothing on the card renders it. Declared here + // rather than derived from the download filename for the reason the static + // cards declare it: `spokeagent-0.4.1.brxt` is not the id `spokeagent`. + const registryId = escapeHtml(extension.id || ''); const featCls = featured ? ' is-feat' : ''; const eyebrow = featured ? '
Featured
' : ''; // The authored cards carry `data-affiliation` and this renderer did not, so @@ -4060,7 +4111,7 @@

Stay in the loop

const affAttr = affiliation.length ? ` data-affiliation="${escapeHtml(affiliation.join(' '))}"` : ''; - return `
+ return `
${eyebrow}
@@ -4201,9 +4252,17 @@

Stay in the loop

return String(text == null ? '' : text).toLowerCase().split(WORD_BREAK).filter(Boolean); } - function namesOnlyTheLicense(label, licenseWords) { - const words = wordsOf(label); - return words.length > 0 && words.every(w => licenseWords.indexOf(w) !== -1); + /* Does `label` say nothing that `words` does not? + By WORDS, not by equality: `apache` and `Apache-2.0` are one licence spelled + two ways, and equality drops only the second. Two administrative values reach + the searched text as labels and both are dropped through here — the licence + (`card.dataset.license`) and the version (the `· v0.2.0` tail of `.ext-org`) + — which is why it is named for the test rather than for the licence. + An empty label says nothing at all, which is not the same as saying only + these words, so it is kept. */ + function saysOnly(label, words) { + const labelWords = wordsOf(label); + return labelWords.length > 0 && labelWords.every(w => words.indexOf(w) !== -1); } function searchHaystack(card) { @@ -4214,13 +4273,13 @@

Stay in the loop

if (licenseWords.length) { card.querySelectorAll('.tag').forEach(chip => { const label = chip.textContent; - if (label && namesOnlyTheLicense(label, licenseWords)) text = text.split(label).join(' '); + if (label && saysOnly(label, licenseWords)) text = text.split(label).join(' '); }); } // Separators are preserved and only a licence token is blanked, so a phrase // spanning two keywords still matches as it did. const tags = String(card.dataset.tags || '') - .replace(/[^\s,]+/g, t => (namesOnlyTheLicense(t, licenseWords) ? '' : t)); + .replace(/[^\s,]+/g, t => (saysOnly(t, licenseWords) ? '' : t)); return (text + ' ' + tags).toLowerCase(); } diff --git a/landing/marketplace-search.js b/landing/marketplace-search.js index e496dc519..04ff85b11 100644 --- a/landing/marketplace-search.js +++ b/landing/marketplace-search.js @@ -26,6 +26,11 @@ * * Filler words are dropped ("a skill about R" is `r`), because in a union a * word like `for` or `about` inflates the term count of every card whose * prose happens to use it. + * * An unanchored match has to be worth something — see `substantialInfix`. + * Three characters is enough to search FROM THE START of a word and not + * enough to search inside a long one: measured on this page against the live + * registry, `lab` showed 37 of 37 extension cards through `BaranziniLab` and + * `gen` and `age` 36 each through `…Agent`. * * **License is deliberately NOT searched**, matching the Rust matcher's field * list. That is why the callers build a weighted field list out of the card's @@ -73,6 +78,10 @@ /* Below this many characters a term matches whole words only. */ var MIN_PARTIAL_CHARS = 3; + /* At or above this many characters a term may match anywhere inside a word, + however long the word. Below it, `substantialInfix` asks for half. */ + var MIN_INFIX_CHARS = 4; + /* Split at every character that is not a letter or digit — whitespace and punctuation alike, so `r-scripting` is `r` + `scripting` and `ggplot2` stays one word. The Unicode classes stand in for Rust's @@ -103,14 +112,42 @@ return meaningful.length > 0 ? meaningful : all; } + /* Is `term`, found inside `word` without touching its start, enough of that + word to be a search rather than a morpheme? + + The matcher already grades an anchored match above an unanchored one — a + prefix scores 2, an infix 1 — and MIN_PARTIAL_CHARS was the only admission + gate, so three characters bought a match anywhere inside any word. Measured + on this page against the live 37-entry extensions shelf: `lab` -> 37 of 37, + `gen` -> 36, `age` -> 36, through an infix of `baranzinilab` in the org line + and of `…Agent` in each card's own heading. So it is a rule, not a field: + dropping the org line fixes one of the three and nothing can drop a heading. + + An unanchored match therefore needs either MIN_INFIX_CHARS characters or half + the word it sits in. Two arms, because each closes a case the other gets + wrong, and both were measured over the registry's own vocabulary (807 words): + a flat four-character floor loses `rna` inside `scRNA`/`rRNA`/`miRNA` and + `sem` inside `RSEM`; a flat half-the-word ratio loses `omics` inside + `transcriptomics` and `flow` inside `workflows`. Half is the proportion the + infix rule's own documented case sits at — `heatmap` is 7 of + `complexheatmap`'s 14. + + `substantial_infix` in catalog_search.rs; a change here is a change there and + in ui/desktop/src/components/baam/search.ts. */ + function substantialInfix(term, word) { + var termChars = Array.from(term).length; + return termChars >= MIN_INFIX_CHARS || termChars * 2 >= Array.from(word).length; + } + /* How well `term` matches one field word: 3 for the whole word, 2 for its start, 1 for anywhere inside it (`heatmap` in `complexheatmap`), 0 for no - match. A short term matches whole words only. */ + match. A short term matches whole words only, and a term that is short + relative to the word matches only at its start — see `substantialInfix`. */ function strength(term, word) { if (word === term) return 3; if (Array.from(term).length < MIN_PARTIAL_CHARS) return 0; if (word.indexOf(term) === 0) return 2; - if (word.indexOf(term) !== -1) return 1; + if (word.indexOf(term) !== -1) return substantialInfix(term, word) ? 1 : 0; return 0; } @@ -264,6 +301,7 @@ words: words, terms: terms, writtenIn: writtenIn, + substantialInfix: substantialInfix, rank: rank, matching: matching }; diff --git a/landing/scripts/baam-search.test.mjs b/landing/scripts/baam-search.test.mjs index 8ec668577..cf2d8c8bd 100644 --- a/landing/scripts/baam-search.test.mjs +++ b/landing/scripts/baam-search.test.mjs @@ -27,6 +27,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { createReadStream, existsSync, statSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { dirname, extname, join, normalize, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -169,6 +170,36 @@ test('the verbatim bonus respects word boundaries, as the canonical matcher does ); }); +// An unanchored match has to be worth something. `substantial_infix` in +// catalog_search.rs and `substantialInfix` in the desktop port assert the same +// words; this is the third copy of that rule and so the third copy of the test. +test('a short term matches inside a word only when it is half of it', () => { + const { substantialInfix } = Search; + // The three floods measured on this page, at the word each came through. + assert.equal(substantialInfix('lab', 'baranzinilab'), false, '3 of 12'); + assert.equal(substantialInfix('gen', 'cdwagent'), false, '3 of 8'); + assert.equal(substantialInfix('age', 'language'), false, '3 of 8'); + // A short term inside a SHORT word is the search, not a morpheme — the hits a + // flat four-character floor would have cost. + assert.equal(substantialInfix('rna', 'scrna'), true, '3 of 5'); + assert.equal(substantialInfix('sem', 'rsem'), true, '3 of 4'); + // At four characters a term is unanchored anywhere, however long the word, + // which is what keeps a compound biomedical vocabulary findable. + assert.equal(substantialInfix('omics', 'transcriptomics'), true); + assert.equal(substantialInfix('flow', 'workflows'), true); + // The case the infix rule was written for sits exactly on the short arm's + // boundary, so it would pass on either arm. + assert.equal(substantialInfix('heatmap', 'complexheatmap'), true, '7 of 14'); + // And only the UNANCHORED match is refused: the start of a word still counts + // at three characters, and the whole word always counts. + const strengthOf = (term, word) => + Search.rank(term, [], [{ id: 'x', name: word, description: '', tags: [] }], entryFields).hits + .length; + assert.equal(strengthOf('gen', 'genomics'), 1, 'a prefix still matches'); + assert.equal(strengthOf('lab', 'lab'), 1, 'a whole word still matches'); + assert.equal(strengthOf('gen', 'cdwagent'), 0, 'an infix of a long word does not'); +}); + test('license is not a searched field', () => { // `prose-only` says "Apache-2.0" in its description, so it is found; nothing // is found by a `license` key, because `entryFields` never offers one. @@ -322,6 +353,249 @@ if (!existsSync(PLAYWRIGHT)) { await page.close(); }); + /* ── The three matchers, differentially ───────────────────────────────── + The rule lives in three places — `crates/biorouter/src/catalog_search.rs`, + `ui/desktop/src/components/baam/search.ts` and `marketplace-search.js` — and + what each one SEARCHES lives in a fourth: the field list its caller hands + over. That is where the three had drifted. `catalog_search.rs` and the + desktop port searched a skill's `category`; this page never did, so a + visitor and a model reading the same catalog got different answers to + `core` — 59 of 129 there against 2 of 132 here. + + A unit test of any one matcher cannot see that, so the contract is restated + HERE, independently, as `expectedFields` below, and every card the real page + shows is compared against it over the whole catalog vocabulary. A fourth + copy is the point: if any implementation drifts from the contract, this + fails, and it fails whichever of the four moved. */ + + /** The canonical searched fields, per `MarketplaceCatalog::search_extensions`. */ + const extensionFields = (entry) => [ + [entry.id, Search.Weight.Name], + [entry.extension_name, Search.Weight.Name], + [entry.name, Search.Weight.Name], + [entry.organization, Search.Weight.Label], + [entry.description, Search.Weight.Prose], + ...(entry.tags || []) + .filter((tag) => !namesOnlyTheLicense(tag, entry.license)) + .map((tag) => [tag, Search.Weight.Label]), + ]; + + /** + * The canonical searched fields, per `MarketplaceCatalog::search_skills`. + * Neither `category` nor `type` is here: each is a curation value the page + * answers with a facet chip, and each named most of the shelf. + */ + const skillFields = (entry) => [ + [entry.id, Search.Weight.Name], + [entry.name, Search.Weight.Name], + [entry.description, Search.Weight.Prose], + ...(entry.tags || []) + .filter((tag) => !namesOnlyTheLicense(tag, entry.license)) + .map((tag) => [tag, Search.Weight.Label]), + ...(entry.keywords || []) + .filter((kw) => !namesOnlyTheLicense(kw, entry.license)) + .map((kw) => [kw, Search.Weight.Label]), + ]; + + /** A label that says nothing its entry's own licence does not. */ + function namesOnlyTheLicense(label, license) { + const labelWords = Search.words(label); + if (labelWords.length === 0) return false; + const licenseWords = Search.words(license || ''); + return labelWords.every((word) => licenseWords.includes(word)); + } + + /** + * What a visitor might type: every distinct word the catalog itself uses, plus + * the queries that measured the four defects this file guards. Derived from the + * registry rather than listed, so a new entry widens the comparison. + */ + function corpus(registry) { + const words = new Set([ + 'lab', 'gen', 'age', 'core', 'cor', 'ore', 'biomedical', 'developer', + 'invocable', 'auto', 'user', 'applied', 'apache', 'Apache-2.0', 'PACS', + 'rna', 'omics', 'flow', 'heatmap', 'UCSF', 'BaranziniLab', 'SPOKEAgent', + 'single cell', 'variant calling', 'R scripting ggplot visualization', + ]); + const add = (value) => { + if (typeof value === 'string') for (const word of Search.words(value)) words.add(word); + else if (Array.isArray(value)) value.forEach(add); + }; + for (const entry of registry.extensions) { + [entry.id, entry.name, entry.organization, entry.tags].forEach(add); + } + for (const entry of registry.skills) { + [entry.id, entry.name, entry.tags, entry.keywords].forEach(add); + } + return [...words].sort(); + } + + /** + * Every query's visible cards, keyed by download URL, read out of the real page + * in ONE round trip. `oninput="runFilter()"` is the shelf's own entry point, so + * this drives exactly what typing drives; doing it per query over Playwright + * would be ~800 round trips. + */ + async function shelfAnswers(page, cardSelector, queries) { + return page.evaluate( + ({ cardSelector, queries }) => { + const box = document.getElementById('baam-search'); + const cards = [...document.querySelectorAll(cardSelector)]; + const urlOf = (card) => { + const link = card.querySelector('.skill-dl-btn, .brxt-chip'); + return link ? link.getAttribute('href') : ''; + }; + const out = { '': cards.map(urlOf) }; + for (const query of queries) { + box.value = query; + box.dispatchEvent(new Event('input', { bubbles: true })); + // A filtered shelf un-collapses, so every card it kept is displayed; + // `.skill-grid.collapsed > .skill-card:nth-child(n+9)` is the same + // `display: none` a refused card gets, which is why the browse case is + // read off the DOM above rather than asked for here. + out[query] = cards + .filter((card) => getComputedStyle(card).display !== 'none') + .map(urlOf); + } + box.value = ''; + box.dispatchEvent(new Event('input', { bubbles: true })); + return out; + }, + { cardSelector, queries } + ); + } + + /** + * ⚠ The skills comparison reads the three GRIDS, not the whole shelf. + * `build-registry.mjs` derives every registry row from `#core-skill-grid`, + * `#dev-skill-grid` and `#bio-skill-grid`, so those cards stand one-to-one + * against the rows. The `#skills-featured` strip above them repeats three of + * those skills as hand-written cards, and the copies have DRIFTED — measured on + * the live page, the featured `ggplot2 Visualization` reads "Publication-quality + * ggplot2 figures in R — font sizing, palettes, themes" where its grid twin + * reads "Applies ggplot2 best-practice style", and carries a `Figures` tag and + * seven `data-tags` the grid card has none of. That is prose the registry does + * not describe, so a differential against the registry cannot speak about it: it + * is a content divergence on the page, not a matcher one. + */ + for (const [label, shelf, selector, key, fields, noise] of [ + ['extensions', 'extensions', '#extensions-section .ext-card', 'extensions', extensionFields, Search.EXTENSION_NOISE], + ['skills', 'skills', '#skills-section .skill-grid .skill-card', 'skills', skillFields, Search.SKILL_NOISE], + ]) { + test(`the ${label} shelf answers every catalog query the canonical field list does`, async () => { + const registry = JSON.parse(await readFile(join(LANDING, 'registry.json'), 'utf8')); + const entries = registry[key]; + const page = await shelfPage(shelf === 'extensions' ? null : shelf); + const queries = corpus(registry); + assert.ok(queries.length > 300, `the corpus is only ${queries.length} queries`); + + // A card is identified by the download link the registry row carries, so the + // comparison is between two sets of ROWS and never depends on DOM order. + const answers = await shelfAnswers(page, selector, queries); + const cards = new Set(answers['']); + assert.ok(!cards.has(''), 'a card was not identified by its download link'); + assert.equal(cards.size, answers[''].length, `${label}: two cards share a download link`); + assert.equal(cards.size, entries.length, `${label}: cards drawn vs registry rows`); + + const slug = (url) => url.split('/').pop(); + const mismatches = []; + for (const query of queries) { + const want = Search.rank(query, noise, entries, fields).hits.map((hit) => hit.entry.download); + const got = answers[query]; + const extra = got.filter((url) => !want.includes(url)).map(slug); + const missing = want.filter((url) => !got.includes(url)).map(slug); + if (extra.length || missing.length) { + mismatches.push( + `${query}: page ${got.length}, canonical ${want.length}` + + (extra.length ? `; page only ${extra.join(',')}` : '') + + (missing.length ? `; canonical only ${missing.join(',')}` : '') + ); + } + } + assert.deepEqual( + mismatches, + [], + `${mismatches.length} of ${queries.length} ${label} queries disagree with the canonical ` + + `field list:\n ${mismatches.slice(0, 25).join('\n ')}` + ); + await page.close(); + }); + } + + test('a three-letter query does not return the whole extensions shelf', async () => { + // Measured on this page against the live 37-entry registry before the fix: + // `lab` 37 of 37 (through an infix of `BaranziniLab` in the org line), `gen` + // and `age` 36 each (through an infix of `…Agent` in the heading). + const page = await shelfPage(); + const total = await page.$$eval('#extensions-section .ext-card', (els) => els.length); + assert.ok(total >= 30, `measured against 37 cards; now ${total}`); + for (const [query, was] of [['lab', 37], ['gen', 36], ['age', 36]]) { + const shown = await search(page, query, EXTS); + assert.ok( + shown.length <= 8, + `"${query}" showed ${shown.length} of ${total} cards (${was} before): ${shown.join(', ')}` + ); + } + // What a visitor browses this shelf BY has to survive, and all three reach + // their cards as whole words. + const lab = await search(page, 'BaranziniLab', EXTS); + assert.ok(lab.length >= 20, `the lab by its own name: ${lab.length} of ${total}`); + const ucsf = await search(page, 'UCSF', EXTS); + assert.ok(ucsf.length >= 5 && ucsf.length < total, `UCSF: ${ucsf.length} of ${total}`); + assert.deepEqual(await search(page, 'SPOKEAgent', EXTS), ['spokeagent']); + await page.close(); + }); + + test('the invocation mode is a facet, and the slug beside it is still searched', async () => { + // `.skill-type` is "User-invocable · /scientific-research" — a MODE, which + // `initSkills` reads into `card._type` for the two chips to filter on, and a + // SLUG, which is the skill's registry id. The whole line went in at Name + // weight, so the mode — an administrative label on 100% of cards — was + // searched: measured here, `invocable` showed 62 of 132 and `auto` 74, while + // the app answered 0 and 4. `auto` is a real topical query (autoimmune, + // automation, autoencoder), so that one cost a search a visitor makes. + const page = await shelfPage('skills'); + const total = await page.$$eval('#skills-section .skill-card', (els) => els.length); + for (const [query, was] of [['invocable', 62], ['auto', 74], ['user', 62], ['applied', 70]]) { + const shown = await search(page, query, SKILLS); + assert.ok( + shown.length * 4 < total, + `"${query}" showed ${shown.length} of ${total} cards (${was} before the mode was dropped)` + ); + } + // The slug is the half worth keeping: it is the only place a skill's id is + // rendered, and two of these skills say their id nowhere else on the card. + for (const slug of ['ucsf-hpc', 'scientific-machine-learning', 'gpu-compute-optimization']) { + const shown = await search(page, slug, SKILLS); + assert.ok(shown.length >= 1, `the slug /${slug} found nothing`); + } + await page.close(); + }); + + test('a skills category is a filter chip, not a searched word', async () => { + // This page was already right, and is pinned so it stays the side the other + // two were brought to: `Core` names 57 of the registry's 129 skills and + // `Biomedical` 63, so searching the bucket returned half the shelf — which + // is what `catalog_search.rs` and the desktop modal were doing (59 and 65). + const page = await shelfPage('skills'); + const total = await page.$$eval('#skills-section .skill-card', (els) => els.length); + for (const query of ['core', 'biomedical', 'developer']) { + const shown = await search(page, query, SKILLS); + assert.ok( + shown.length * 4 < total, + `"${query}" showed ${shown.length} of ${total} cards, i.e. its whole bucket` + ); + } + // The bucket is still reachable — by its chip, which is where it belongs. + await page.fill('#baam-search', ''); + await page.click('.fchip[data-facet="category"][data-match="developer"]'); + const chipped = await page.$$eval(SKILLS, (els) => + els.filter((e) => getComputedStyle(e).display !== 'none').length + ); + assert.ok(chipped > 0 && chipped * 4 < total, `the Developer chip showed ${chipped}`); + await page.close(); + }); + test('an emptied box restores the whole shelf', async () => { const page = await shelfPage('skills'); const total = await page.$$eval('#skills-section .skill-card', (els) => els.length); diff --git a/ui/desktop/src/components/baam/registry.ts b/ui/desktop/src/components/baam/registry.ts index f5197a97f..e605ea093 100644 --- a/ui/desktop/src/components/baam/registry.ts +++ b/ui/desktop/src/components/baam/registry.ts @@ -414,6 +414,19 @@ function labelFields( * ⚠ Excluding the FIELD was not enough, because the registry publishes the * licence again as a tag and a keyword, and those are searched — see * {@link namesOnlyTheLicense}, which is what {@link labelFields} applies. + * + * ⚠ **It also excludes `category`, and the three matchers are three.** The claim + * above is about Rust; the website's `landing/baam.html` assembles its own field + * list out of the DOM and is the copy that drifts. It never searched the category + * — measured on the live shelf, `core` → 2 of 132 cards and `biomedical` → 2, + * against 59 and 65 here — so this modal and the model answered a query the + * website did not. The website is the one that was right: `Core` names 57 of 129 + * skills and `Biomedical` 63, and every surface showing the category offers it as + * a control (this modal's own `All` / `Core skills` / `Developer & authoring` / + * `Biomedical analysis` filter, the shelf's three `data-facet="category"` chips), + * so searching it turned a short query into the shelf under a word nobody meant. `type` is absent for the same + * reason and always was here: it is the `data-type` facet, and the website + * searched its rendered text until this change. */ export function rankSkills( skills: readonly RegistrySkill[], @@ -422,7 +435,6 @@ export function rankSkills( return rankEntries(query, SKILL_NOISE, skills, (skill) => [ [skill.id, Weight.Name], [skill.name, Weight.Name], - [skill.category, Weight.Label], [skill.description, Weight.Prose], ...labelFields(skill.tags, skill.license), ...labelFields(skill.keywords, skill.license), diff --git a/ui/desktop/src/components/baam/search.test.ts b/ui/desktop/src/components/baam/search.test.ts index 9ad410f31..cece28580 100644 --- a/ui/desktop/src/components/baam/search.test.ts +++ b/ui/desktop/src/components/baam/search.test.ts @@ -16,6 +16,7 @@ import { scoreEntry, searchTerms, SKILL_NOISE, + substantialInfix, Weight, writtenIn, type SearchField, @@ -141,6 +142,42 @@ describe('marketplace search — matching and ranking', () => { expect(ids(rank('scripts'))).toEqual(['r-scripting', 'prose-only']); }); + /// An unanchored match has to be worth something. Three characters is enough to + /// search from the START of a word — `gen` really does find `genomics` — and, + /// inside a long one, is a morpheme: measured in Browse extensions against the + /// shipped 37-entry registry, `lab` listed **37 of 37** through an infix of + /// `baranzinilab`, and `gen` and `age` 36 each through an infix of `…Agent` in + /// each extension's own name. `substantial_infix` in `catalog_search.rs` asserts + /// these same words. + it('matches a short term inside a word only when it is half of it', () => { + // The three measured floods, at the word each of them came through. + expect(substantialInfix('lab', 'baranzinilab'), '3 of 12').toBe(false); + expect(substantialInfix('gen', 'cdwagent'), '3 of 8').toBe(false); + expect(substantialInfix('age', 'language'), '3 of 8').toBe(false); + // A short term inside a SHORT word is the search, not a morpheme — the hits a + // flat four-character floor would have cost. + expect(substantialInfix('rna', 'scrna'), '3 of 5').toBe(true); + expect(substantialInfix('rna', 'rrna'), '3 of 4').toBe(true); + expect(substantialInfix('sem', 'rsem'), '3 of 4').toBe(true); + expect(substantialInfix('age', 'image'), '3 of 5').toBe(true); + // At four characters a term is unanchored anywhere, however long the word, + // which is what keeps a compound biomedical vocabulary findable. + expect(substantialInfix('omics', 'transcriptomics')).toBe(true); + expect(substantialInfix('flow', 'workflows')).toBe(true); + // The case the infix rule was written for sits exactly on the short arm's + // boundary, so it would pass on either arm. + expect(substantialInfix('heatmap', 'complexheatmap'), '7 of 14').toBe(true); + + // And through the matcher: only the UNANCHORED match is refused. + const entry = (name: string): Entry => ({ id: 'x', name, description: '', tags: [] }); + const found = (term: string, name: string) => + rankEntries(term, [], [entry(name)], fields).hits.length; + expect(found('gen', 'Genomics'), 'a prefix still matches').toBe(1); + expect(found('lab', 'Lab'), 'a whole word still matches').toBe(1); + expect(found('gen', 'CDWAgent'), 'an infix of a long word does not').toBe(0); + expect(found('rna', 'scRNA-seq'), 'an infix of a short word does').toBe(1); + }); + it('returns the union, ranked by terms matched and then by where they matched', () => { const search = rank('R scripting'); expect(ids(search)).toEqual(['r-scripting', 'prose-only']); @@ -421,7 +458,6 @@ describe('the fields each catalog searches, and what a match in each is worth', // The matcher this replaced never searched the id. ['id', { id: 'zebrafish-imaging' }, 'zebrafish'], ['name', { name: 'Zebrafish Imaging' }, 'zebrafish'], - ['category', { category: 'Biomedical' }, 'biomedical'], ['description', { description: 'Segments zebrafish embryos.' }, 'zebrafish'], ['tag', { tags: ['Zebrafish'] }, 'zebrafish'], ['keyword', { keywords: ['zebrafish'] }, 'zebrafish'], @@ -476,6 +512,37 @@ describe('the fields each catalog searches, and what a match in each is worth', ).toHaveLength(1); }); + /// ⚠ **The category is a filter CONTROL, not a searched field.** It was one, and + /// it is the licence's defect one field further on: `Core` names 57 of the + /// registry's 129 skills and `Biomedical` 63, so `core` listed 59 of them here + /// and `biomedical` 65 — half the modal, under a word nobody typed for a topic. + /// This modal already answers the question with its own filter — `All` / + /// `Core skills` / `Developer & authoring` / `Biomedical analysis`, whose + /// Developer chip was measured showing exactly the 9 rows `developer` returned — + /// and the website's copy of this matcher never searched the field at all, so + /// the three were not in step. + /// `type` is the same shape — the `Auto-applied` / `User-invocable` facet — and + /// was never searched here. + it('does not search a skill category or type, in either spelling', () => { + for (const query of ['core', 'cor', 'ore', 'biomedical', 'developer']) { + expect( + rankSkills([{ ...blankSkill, category: 'Biomedical' }], query).hits, + `category, ${query}` + ).toEqual([]); + } + expect( + rankSkills([{ ...blankSkill, type: 'User-invocable · /blank' }], 'invocable').hits + ).toEqual([]); + // Neither is dropped from the CATALOG: the modal still groups by category and + // the card still shows the type. Only the matcher stops reading them. + expect( + rankSkills( + [{ ...blankSkill, category: 'Biomedical', description: 'Biomedical imaging.' }], + 'biomedical' + ).hits + ).toHaveLength(1); + }); + it('ranks a skill matched by id or name above a label, and a label above prose', () => { const skills: RegistrySkill[] = [ { ...blankSkill, id: 'in-the-description', description: 'Segments zebrafish embryos.' }, @@ -711,3 +778,91 @@ describe('a licence republished as a label is not searchable through it', () => expect(matched).toBeGreaterThan(166); }); }); + +/** + * The two modals against the catalog the app actually ships — `registry.fallback.json`, + * the snapshot `build-registry.mjs` writes from `landing/baam.html` beside + * `landing/registry.json`, so these are the counts a user sees offline and (bar a + * newer fetch) online. + * + * Bounds rather than equalities: a new entry whose prose says "lab" must not fail + * this. What it pins is that a short query answers with a handful and not the + * shelf, and that what the shelf is browsed BY still answers in full. + */ +describe('the shipped catalog: a short query is not the whole shelf', () => { + const { extensions, skills } = FALLBACK_REGISTRY; + + it('answers a three-letter extensions query with a handful, not all 37', () => { + // Guard: the words the flood came through are still in the catalog, so a pass + // here means the rule refused them and not that the registry stopped saying + // them. + expect( + extensions.filter((entry) => /agent/i.test(entry.name ?? '')).length, + 'cards whose name says Agent' + ).toBeGreaterThan(19); + expect( + extensions.filter((entry) => /baranzinilab/i.test(entry.organization ?? '')).length, + 'cards whose organization says BaranziniLab' + ).toBeGreaterThan(19); + + // Measured on this registry before the rule: 37 of 37, 36, 36. + for (const [query, was] of [ + ['lab', 37], + ['gen', 36], + ['age', 36], + ] as const) { + const hits = rankExtensions(extensions, query).hits; + expect(hits.length, `${query} (was ${was} of ${extensions.length})`).toBeLessThan(9); + } + + // What the shelf is browsed BY: all three reach their rows as whole words. + expect(ids(rankExtensions(extensions, 'SPOKEAgent'))).toEqual(['spokeagent']); + expect(rankExtensions(extensions, 'BaranziniLab').hits.length).toBeGreaterThan(19); + const ucsf = rankExtensions(extensions, 'UCSF').hits; + expect(ucsf.length).toBeGreaterThan(4); + expect(ucsf.length).toBeLessThan(extensions.length); + expect(ids(rankExtensions(extensions, 'UCSF'))).toContain('ucsfhpcagent'); + }); + + it('answers a curation-bucket query with the skills that say the word, not the bucket', () => { + // Guard: the buckets are still most of the catalog, which is what made + // searching them return most of it. + for (const bucket of ['Core', 'Biomedical']) { + const rows = skills.filter((entry) => entry.category === bucket).length; + expect(rows * 3, `${bucket} names ${rows} of ${skills.length} skills`).toBeGreaterThan( + skills.length + ); + } + + // Measured on this registry before the field was dropped: 59, 59, 61, 65, 9. + for (const [query, was] of [ + ['core', 59], + ['cor', 59], + ['ore', 61], + ['biomedical', 65], + ['developer', 9], + ] as const) { + const hits = rankSkills(skills, query).hits; + expect(hits.length * 4, `${query} (was ${was} of ${skills.length})`).toBeLessThan( + skills.length + ); + // Every row still returned says the word itself, somewhere the matcher reads. + for (const hit of hits) { + const said = [ + hit.entry.id, + hit.entry.name, + hit.entry.description, + ...(hit.entry.tags ?? []), + ...(hit.entry.keywords ?? []), + ].some((text) => String(text).toLowerCase().includes(query)); + expect(said, `${hit.entry.id} matched ${query} through nothing but its category`).toBe( + true + ); + } + } + + // Browsing is untouched: the category is dropped from what is SEARCHED, not + // from the catalog, and it is still what the modal groups by. + expect(rankSkills(skills, '').hits.length).toBe(skills.length); + }); +}); diff --git a/ui/desktop/src/components/baam/search.ts b/ui/desktop/src/components/baam/search.ts index e4b6fd762..0bde1d540 100644 --- a/ui/desktop/src/components/baam/search.ts +++ b/ui/desktop/src/components/baam/search.ts @@ -9,10 +9,16 @@ * `skills__searchMarketplaceSkills` on that user's behalf read the same catalog, * and the same words must find the same entries, ranked the same way. Only a tie * can fall differently, because each side breaks ties by its own registry order — - * the document's here, the id's in Rust. **A change to a rule below is a change - * to both files**, which is how the word-boundary rule PR #266 added arrived - * here; the types it returns are `CatalogSearch` / `CatalogSearchHit` there and - * {@link SearchResult} / {@link SearchHit} here. + * the document's here, the id's in Rust. The types it returns are `CatalogSearch` + * / `CatalogSearchHit` there and {@link SearchResult} / {@link SearchHit} here. + * + * ⚠ **A change to a rule below is a change to THREE files, not two.** The third + * is the website's own copy, `landing/marketplace-search.js`, which the BAAM + * shelves at biorouter.ucsf.edu call — and it is the copy that drifts, because + * nothing imports it from here and its callers assemble their fields out of the + * DOM. This header said "both files" while the three had already diverged over + * which fields they search; the divergence is recorded where the fields are + * chosen, in `registry.ts`. * * ⚠ **A query is a set of words, not a substring.** The matcher this replaced * asked whether the WHOLE lowercased query occurred inside a single field — the @@ -37,8 +43,11 @@ * start of one, or inside one); * 4. then registry order, so a result never reshuffles. * - * Two rules keep the union from drowning the useful hits, both needed by the - * measured query itself: + * Three rules keep the union from drowning the useful hits; the first two were + * needed by the measured query itself, and the third — {@link substantialInfix} — + * closes the same failure one step further in: a query that finds everything + * says nothing, whether it got there through a repeated field or through a + * three-letter morpheme. * * - **A term under three characters matches whole words only.** `r` has to find * the R language; as a substring it matched nearly every entry. The query as @@ -59,7 +68,20 @@ export const Weight = { /** Free prose: a description. */ Prose: 1, - /** Curated labels: tags, keywords, a category, an organization. */ + /** + * Curated labels: tags, keywords, an organization. + * + * ⚠ **Not a curation bucket the surface also offers as a filter control.** A + * skill's `category` was here, and it is the licence's defect one field on: + * `Core` names 57 of the registry's 129 skills and `Biomedical` 63, so `core` + * listed 59 and `biomedical` 65 — half the modal, under a word the user did + * not mean. The modal already answers the category with its own filter — + * `All` / `Core skills` / `Developer & authoring` / `Biomedical analysis`, and + * the Developer chip was measured showing exactly the 9 rows `developer` used to + * return — the website shelf with three `data-facet="category"` chips, and the + * website's matcher never searched the field at all. See `rankSkills` in + * `registry.ts`. + */ Label: 2, /** What the entry is called: its registry id and names. */ Name: 3, @@ -133,6 +155,12 @@ export const EXTENSION_NOISE: readonly string[] = ['extension', 'extensions']; /** Below this many characters a term matches whole words only. */ const MIN_PARTIAL_CHARS = 3; +/** + * At or above this many characters a term may match anywhere inside a word, + * however long the word. Below it, {@link substantialInfix} asks for half. + */ +const MIN_INFIX_CHARS = 4; + /** The best a single term can score: a whole-word match (3) in a name (3). */ const MAX_TERM_QUALITY = 3 * Weight.Name; @@ -270,16 +298,57 @@ export function searchTerms(query: string, noise: readonly string[] = []): strin return meaningful.length > 0 ? meaningful : all; } +/** + * Is `term`, found inside `word` without touching its start, enough of that word + * to be a search rather than a morpheme? + * + * The matcher already grades an anchored match above an unanchored one — a prefix + * scores 2, an infix 1 — and {@link MIN_PARTIAL_CHARS} was the only admission + * gate, so three characters bought a match anywhere inside any word. Measured in + * the Browse-extensions modal against the shipped 37-entry registry: `lab` → **37 + * of 37**, `gen` → 36, `age` → 36. Per hit, 32 of `lab`'s matched only as an + * infix of `baranzinilab` (the organization) and 33 each of `gen`'s and `age`'s + * only as an infix of `…Agent` in the extension's own NAME — so this is a rule, + * not a field: dropping `organization` fixes one of the three, and nothing can + * drop a name. In Browse skills the same rule had `ing` matching 88 of 129, `ion` + * 84 and `ica` 33. + * + * So an unanchored match needs either {@link MIN_INFIX_CHARS} characters or half + * the word it sits in. Two arms rather than one number, because each closes a + * case the other gets wrong, and both were measured over the registry's own + * vocabulary (807 distinct catalog words): + * + * - A flat four-character floor drops what a short term earns inside a SHORT + * word — `rna` in `scRNA`/`rRNA`/`miRNA`/`piRNA`, `sem` in `RSEM` — costing + * `rna` the `single-cell` and `microbiome` skills. 87 hits removed. + * - A flat half-the-word ratio drops what a LONG term earns inside a longer + * compound, which is most of a biomedical vocabulary: `omics` stopped finding + * `transcriptomics`, `flow` stopped finding `workflows`. 143 hits removed. + * + * Together: 73 hits removed over 15 of the 807 queries, 34 of them the `lab` + * flood. Half is also the proportion this rule's own documented case sits at — + * `heatmap` is 7 of `complexheatmap`'s 14 — so the arm that admits a short term + * is calibrated to the example the infix rule exists for. + * + * Exported so the two arms can be asserted directly, the way Rust asserts them + * from inside the module. + */ +export function substantialInfix(term: string, word: string): boolean { + const termChars = charCount(term); + return termChars >= MIN_INFIX_CHARS || termChars * 2 >= charCount(word); +} + /** * How well `term` matches one field word: 3 for the whole word, 2 for its * start, 1 for anywhere inside it (`heatmap` in `complexheatmap`), 0 for no - * match. A short term matches whole words only. + * match. A short term matches whole words only, and a term that is short + * relative to the word matches only at its start — see {@link substantialInfix}. */ function strength(term: string, word: string): number { if (word === term) return 3; if (charCount(term) < MIN_PARTIAL_CHARS) return 0; if (word.startsWith(term)) return 2; - if (word.includes(term)) return 1; + if (word.includes(term)) return substantialInfix(term, word) ? 1 : 0; return 0; } From bac7146cf695c2dc8c83e799d2a0e4405d620685 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 12 Sep 2026 03:36:50 -0700 Subject: [PATCH 2/6] docs(search): the morpheme figures are the before state, measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ica` was written as 33 — the count AFTER the category field was dropped — in a sentence describing the rule before either change. Measured on the shipped registry with the category still searched and the 3-character infix still admitted: `ing` 88 of 129 skills, `ica` 85, `ion` 84, `cal` 80, `tio` 77. --- crates/biorouter/src/catalog_search.rs | 3 ++- ui/desktop/src/components/baam/search.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/biorouter/src/catalog_search.rs b/crates/biorouter/src/catalog_search.rs index 64b5d2b02..46749123a 100644 --- a/crates/biorouter/src/catalog_search.rs +++ b/crates/biorouter/src/catalog_search.rs @@ -273,7 +273,8 @@ fn terms(query: &str, noise: &[&str]) -> Vec { /// licence and the version were: `lab` alone would be fixed by dropping /// `organization`, and nothing can drop a name. What all three share is a /// three-letter term with no boundary on either side. On the skills shelf the -/// same rule had `ing` matching 88 of 129, `ion` 84 and `ica` 33. +/// same rule had `ing` matching 88 of 129 skills, `ica` 85, `ion` 84, `cal` 80 +/// and `tio` 77 — every one of them a morpheme. /// /// So an unanchored match needs either [`MIN_INFIX_CHARS`] characters, or half /// the word it sits in. Two arms rather than one number, because each closes a diff --git a/ui/desktop/src/components/baam/search.ts b/ui/desktop/src/components/baam/search.ts index 0bde1d540..f4ed41849 100644 --- a/ui/desktop/src/components/baam/search.ts +++ b/ui/desktop/src/components/baam/search.ts @@ -310,8 +310,9 @@ export function searchTerms(query: string, noise: readonly string[] = []): strin * infix of `baranzinilab` (the organization) and 33 each of `gen`'s and `age`'s * only as an infix of `…Agent` in the extension's own NAME — so this is a rule, * not a field: dropping `organization` fixes one of the three, and nothing can - * drop a name. In Browse skills the same rule had `ing` matching 88 of 129, `ion` - * 84 and `ica` 33. + * drop a name. In Browse skills the same rule had `ing` matching 88 of 129 + * skills, `ica` 85, `ion` 84, `cal` 80 and `tio` 77 — every one of them a + * morpheme. * * So an unanchored match needs either {@link MIN_INFIX_CHARS} characters or half * the word it sits in. Two arms rather than one number, because each closes a From f5823e9caaf342b9fc92e9f1f54ce2d4d92e4d1c Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 12 Sep 2026 03:41:43 -0700 Subject: [PATCH 3/6] fix(skills): the marketplace search tool no longer advertises the category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SearchMarketplaceSkillsParams::query`'s doc comment IS the model-facing schema — schemars emits it as the property's `description`, and for a Gemini-bound model it is the only channel — and it still told a model to match a `category`. `MarketplaceCatalog::search_skills` stopped reading that field because `Core` names 57 of 129 registry entries and `Biomedical` 63, so a model following the schema would have asked for half the registry and been told two. It now names the fields that are searched and points a model at the `category` every row still reports. --- crates/biorouter/src/agents/skills_extension.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/crates/biorouter/src/agents/skills_extension.rs b/crates/biorouter/src/agents/skills_extension.rs index 5501d24c4..100ece7a5 100644 --- a/crates/biorouter/src/agents/skills_extension.rs +++ b/crates/biorouter/src/agents/skills_extension.rs @@ -599,9 +599,16 @@ struct RemoveSkillPackageParams { #[derive(Debug, Serialize, Deserialize, JsonSchema)] struct SearchMarketplaceSkillsParams { - /// Match a registry id, name, category, description, tag or keyword. Omit - /// to list every entry in the registry. See `SearchSkillsParams::query` for - /// why this doc comment is load-bearing. + /// Match a registry id, name, description, tag or keyword. Omit to list + /// every entry in the registry. See `SearchSkillsParams::query` for why this + /// doc comment is load-bearing. + /// + /// ⚠ **Not the `category`.** It names most of the catalog — `Core` 57 of 129 + /// entries, `Biomedical` 63 — so searching it answered half the registry + /// under a word the caller meant as a topic, and + /// `MarketplaceCatalog::search_skills` stopped reading it. Every row still + /// reports its `category`, so omit the query and read the buckets off the + /// listing rather than querying one by name. #[serde(default)] query: Option, offset: Option, From 6410539bb8629e296a116c9fea32e3191c40aee0 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 12 Sep 2026 03:43:06 -0700 Subject: [PATCH 4/6] test(baam): drop an unmeasured count from the corpus comment It said "the four defects this file guards"; the file guards the infix rule, the invocation mode, the extension id and manifest name, the version pattern and the category axis it pins as already-correct. A number nothing counts is worse than none. --- landing/scripts/baam-search.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/landing/scripts/baam-search.test.mjs b/landing/scripts/baam-search.test.mjs index cf2d8c8bd..f1bc206a4 100644 --- a/landing/scripts/baam-search.test.mjs +++ b/landing/scripts/baam-search.test.mjs @@ -407,7 +407,7 @@ if (!existsSync(PLAYWRIGHT)) { /** * What a visitor might type: every distinct word the catalog itself uses, plus - * the queries that measured the four defects this file guards. Derived from the + * the queries that measured each defect this file guards. Derived from the * registry rather than listed, so a new entry widens the comparison. */ function corpus(registry) { From a4bb91eff8ed29417c174cf4904f3492b8093d0b Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 12 Sep 2026 03:57:42 -0700 Subject: [PATCH 5/6] test(search): the failure message states the whole rule "before the infix rule required half the word" named one of the two arms. An unanchored match needs four characters OR half its word, and a message that names half of a rule sends the next reader to the wrong constant. --- crates/biorouter/src/marketplace.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/biorouter/src/marketplace.rs b/crates/biorouter/src/marketplace.rs index ccf96f912..3915afcf6 100644 --- a/crates/biorouter/src/marketplace.rs +++ b/crates/biorouter/src/marketplace.rs @@ -1229,8 +1229,8 @@ mod tests { .len(); assert!( now <= 8, - "`{query}` returned {now} of {shelf}; it returned {was} before the infix rule \ - required half the word" + "`{query}` returned {now} of {shelf}; it returned {was} before an unanchored \ + match had to be four characters or half its word" ); } From e1d5a74b9199cf584a2c0037a91920f78c06b3bf Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Sat, 12 Sep 2026 06:09:46 -0700 Subject: [PATCH 6/6] docs(search): drop the corpus figures nobody can reproduce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The independent review of this PR found the "807 distinct catalog words" figure unverifiable, and it is repeated in all three copies of the `substantial_infix` doc with two derived claims leaning on it ("15 of the 807", "73 hits removed over 15 of the 807"). Measured 2026-09-12 from `landing/registry.json`, over exactly the fields these matchers search (extension: id, extension_name, name, organization, description, non-licence tags; skill: id, name, description, non-licence tags and keywords): 1,445 distinct words with the prose descriptions 773 without them No combination of fields yields 807. The reviewer independently brute-forced every combination and got the same answer, measuring the corpus at 795 by the definition it reconstructed — and this change's OWN siblings already said 795 (`catalog_search_mirrors.rs`, `landing/baam.html`). Three figures for one corpus is how a number nobody re-measures drifts, which is the argument for deleting it rather than picking one. So the unverifiable arithmetic goes — 807, "73 hits removed over 15", and the per-arm "87" and "143 hits removed, 30 queries touched", none of which the review could reproduce either (its own isolated measurement of the infix rule was 234 hits over 40 queries). What stays is everything that DOES reproduce exactly, all of it re-measured by the reviewer: `lab` 37 of 37, `gen` 36, `age` 36, `ing` 88, `ica` 85, `ion` 84, `cal` 80, `tio` 77, and `heatmap` 7 of `complexheatmap`'s 14. The qualitative point the deleted sentence carried — that the `lab` flood dominates what the two arms remove — is kept without the false precision. Each copy now states a corpus size only WITH the field set that produces it. Comment-only: no statement changed in any of the three files. `cargo fmt --check` clean, `node --check` on the JS, Prettier clean on the TS, `landing/scripts/baam-search.test.mjs` 23/23. --- crates/biorouter/src/catalog_search.rs | 24 ++++++++++++++++-------- landing/marketplace-search.js | 7 +++++-- ui/desktop/src/components/baam/search.ts | 18 ++++++++++++------ 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/crates/biorouter/src/catalog_search.rs b/crates/biorouter/src/catalog_search.rs index 46749123a..ab9207a43 100644 --- a/crates/biorouter/src/catalog_search.rs +++ b/crates/biorouter/src/catalog_search.rs @@ -278,22 +278,30 @@ fn terms(query: &str, noise: &[&str]) -> Vec { /// /// So an unanchored match needs either [`MIN_INFIX_CHARS`] characters, or half /// the word it sits in. Two arms rather than one number, because each closes a -/// case the other gets wrong, and both were measured over the shipped registry's -/// own vocabulary (807 distinct catalog words, every query a visitor could be -/// echoing back): +/// case the other gets wrong, and each was checked against the shipped +/// registry's own vocabulary — every word a visitor could be echoing back. +/// +/// ⚠ The counts that used to sit here were **not reproducible** and are gone. +/// Measured 2026-09-12 from `landing/registry.json`, over exactly the fields +/// these matchers search: **1,445** distinct words with the prose descriptions, +/// **773** without them. No combination of fields yields the 807 this comment +/// claimed, and this change's own siblings say 795 (`catalog_search_mirrors.rs`, +/// `landing/baam.html`) — three figures for one corpus is how a number nobody +/// re-measures drifts. Quote a corpus size only together with the field set that +/// produces it. /// /// * A flat four-character floor drops the hits a short term earns inside a /// SHORT word: `rna` in `scRNA`, `rRNA`, `miRNA`, `piRNA` and `sem` in `RSEM` /// are the search, not a morpheme. It cost `rna` `single-cell` and -/// `microbiome`, and `sem` `rna-quantification` — 87 hits removed in total. +/// `microbiome`, and `sem` `rna-quantification`. /// * A flat half-the-word ratio drops the hits a LONG term earns inside a longer /// compound, which is most of a biomedical vocabulary: `omics` stopped finding /// `transcriptomics`, `metabolomics` and `epigenomics`, and `flow` stopped -/// finding `workflows`. 143 hits removed, 30 queries touched. +/// finding `workflows`. /// -/// Together: 73 hits removed over 15 of the 807, of which 34 are the `lab` -/// flood; the rest are `pro` inside "reproducible"/"improving", `logs` inside -/// "pathology", `end` inside "frontend"/"appendix". Half is also the proportion +/// What the two arms remove together is dominated by the `lab` flood; the rest +/// is `pro` inside "reproducible"/"improving", `logs` inside "pathology", `end` +/// inside "frontend"/"appendix". Half is also the proportion /// this rule's own documented case sits at — `heatmap` is 7 of /// `complexheatmap`'s 14 — so the arm that admits a short term is calibrated to /// the example the infix rule exists for, rather than to the queries it refuses. diff --git a/landing/marketplace-search.js b/landing/marketplace-search.js index 04ff85b11..6bcd77514 100644 --- a/landing/marketplace-search.js +++ b/landing/marketplace-search.js @@ -125,8 +125,11 @@ An unanchored match therefore needs either MIN_INFIX_CHARS characters or half the word it sits in. Two arms, because each closes a case the other gets - wrong, and both were measured over the registry's own vocabulary (807 words): - a flat four-character floor loses `rna` inside `scRNA`/`rRNA`/`miRNA` and + wrong, and each was checked against the registry's own vocabulary. (The + "807 words" this comment used to claim is not reproducible: measured + 2026-09-12 over exactly the searched fields, the catalog has 1,445 distinct + words with the prose descriptions and 773 without. Quote a corpus size only + with the field set that produces it.) A flat four-character floor loses `rna` inside `scRNA`/`rRNA`/`miRNA` and `sem` inside `RSEM`; a flat half-the-word ratio loses `omics` inside `transcriptomics` and `flow` inside `workflows`. Half is the proportion the infix rule's own documented case sits at — `heatmap` is 7 of diff --git a/ui/desktop/src/components/baam/search.ts b/ui/desktop/src/components/baam/search.ts index f4ed41849..2489cca13 100644 --- a/ui/desktop/src/components/baam/search.ts +++ b/ui/desktop/src/components/baam/search.ts @@ -316,18 +316,24 @@ export function searchTerms(query: string, noise: readonly string[] = []): strin * * So an unanchored match needs either {@link MIN_INFIX_CHARS} characters or half * the word it sits in. Two arms rather than one number, because each closes a - * case the other gets wrong, and both were measured over the registry's own - * vocabulary (807 distinct catalog words): + * case the other gets wrong, and each was checked against the registry's own + * vocabulary. + * + * ⚠ The counts that used to sit here were NOT reproducible and are gone. + * Measured 2026-09-12 from `landing/registry.json`, over exactly the fields + * these matchers search: 1,445 distinct words with the prose descriptions, 773 + * without. No field combination yields the 807 this comment claimed. Quote a + * corpus size only with the field set that produces it. * * - A flat four-character floor drops what a short term earns inside a SHORT * word — `rna` in `scRNA`/`rRNA`/`miRNA`/`piRNA`, `sem` in `RSEM` — costing - * `rna` the `single-cell` and `microbiome` skills. 87 hits removed. + * `rna` the `single-cell` and `microbiome` skills. * - A flat half-the-word ratio drops what a LONG term earns inside a longer * compound, which is most of a biomedical vocabulary: `omics` stopped finding - * `transcriptomics`, `flow` stopped finding `workflows`. 143 hits removed. + * `transcriptomics`, `flow` stopped finding `workflows`. * - * Together: 73 hits removed over 15 of the 807 queries, 34 of them the `lab` - * flood. Half is also the proportion this rule's own documented case sits at — + * What the two arms remove together is dominated by the `lab` flood. Half is + * also the proportion this rule's own documented case sits at — * `heatmap` is 7 of `complexheatmap`'s 14 — so the arm that admits a short term * is calibrated to the example the infix rule exists for. *