diff --git a/crates/biorouter/src/agents/skills_extension.rs b/crates/biorouter/src/agents/skills_extension.rs index 5501d24c..100ece7a 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, diff --git a/crates/biorouter/src/catalog_search.rs b/crates/biorouter/src/catalog_search.rs index 7aee905e..ab9207a4 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,61 @@ 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 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 +/// 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`. +/// * 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`. +/// +/// 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. +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 +321,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 +579,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 c43a797e..3915afcf 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 an unanchored \ + match had to be four characters or half its 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 00000000..7925b893 --- /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 526aefcf..0b4bc885 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 b03474e7..0f25ba5c 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 e496dc51..6bcd7751 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,45 @@ 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 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 + `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 +304,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 8ec66857..f1bc206a 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 each defect 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 f5197a97..e605ea09 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 9ad410f3..cece2858 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 e4b6fd76..2489cca1 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,64 @@ 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 + * 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 + * 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. + * - 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`. + * + * 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. + * + * 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; }