Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions crates/biorouter/src/agents/skills_extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
offset: Option<usize>,
Expand Down
116 changes: 111 additions & 5 deletions crates/biorouter/src/catalog_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -240,17 +259,69 @@ fn terms(query: &str, noise: &[&str]) -> Vec<String> {
}
}

/// 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
} else if term.chars().count() < MIN_PARTIAL_CHARS {
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
Expand Down Expand Up @@ -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!(
Expand Down
140 changes: 139 additions & 1 deletion crates/biorouter/src/marketplace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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<String> {
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.
Expand Down
Loading
Loading