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
87 changes: 87 additions & 0 deletions crates/biorouter/src/catalog_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,52 @@ fn words(text: &str) -> impl Iterator<Item = String> + '_ {
.map(str::to_lowercase)
}

/// Does `label` say nothing that `license` does not — is every word of it a word
/// of the licence? A catalog whose entries carry a licence drops such a label
/// when it assembles an entry's searchable text.
///
/// The licence itself is not a searchable field: every entry in the BAAM
/// registry is Apache-2.0, so it separates nothing, and both catalog searches
/// leave the field out for that reason.
///
/// ⚠ **Leaving the FIELD out was not enough.** A registry republishes the licence
/// as one of the entry's own tag chips — and, for a skill, again among its
/// keywords — and labels are searched, rightly: `MCP`, `ELN` and `Imaging` are
/// exactly what a tag is for. Measured in the Browse-extensions modal on
/// 2026-09-12 against the live 37-entry registry, with the field already gone:
/// `PACS` → 31 of 37, `pac` → 31, `apache` → 31, and not one of the 31 about
/// PACS. The three counts agreeing is the identification — `PACS` reaches `pac`
/// through the plural fallback in [`term_strength`], `pac` is inside `apache`,
/// and 31 rows wear an `Apache-2.0` chip. Removing the field had moved the defect
/// one field over, where a test asserting "the licence is not searched" still
/// passed.
///
/// Compared by WORDS rather than by equality, because the second spelling is not
/// the first: the tag is `Apache-2.0` and the keyword is `apache`. An equality
/// test drops the tag and keeps the keyword, which is the same half-fix again.
///
/// What this deliberately does not do: drop every label (`MCP`, `Imaging`, `ELN`,
/// `Registry` are real search value), or name a licence in the matcher
/// (`Apache`, `MIT` — the next licence reopens the hole). The cost of the word
/// test is a licence id built from a topical word — `Python-2.0`, `Ruby`,
/// `PostgreSQL` — on an entry that also tags itself with that word; the tag is
/// then dropped for saying only what the licence says. No entry in the shipped
/// registry is such a case (measured over all 166: the rule drops the 129 licence
/// labels and nothing else), and an equality test pays a smaller version of the
/// same cost.
pub(crate) fn names_only_the_license(label: &str, license: &str) -> bool {
let license_words: Vec<String> = words(license).collect();
let mut label_words = words(label);
match label_words.next() {
// An empty label says nothing at all, which is not the same as saying
// only the licence: leave it, so the rule stays about the licence.
None => false,
Some(first) => {
license_words.contains(&first) && label_words.all(|word| license_words.contains(&word))
}
}
}

/// The distinct terms of `query`, in the order written, without filler.
fn terms(query: &str, noise: &[&str]) -> Vec<String> {
let mut all: Vec<String> = Vec::new();
Expand Down Expand Up @@ -556,4 +602,45 @@ mod tests {
assert!(search.terms.is_empty());
assert_eq!(ids(&search), ["complex-plots", "prose-only", "r-scripting"]);
}

/// The rule a catalog applies to its own labels. Both spellings the BAAM
/// registry publishes go, which is the whole point — `Apache-2.0` is the tag
/// and `apache` is the keyword, and an equality test would keep the second
/// and leave `PACS` matching 49 skills through it.
#[test]
fn a_label_naming_only_the_licence_is_recognised_in_either_spelling() {
for label in [
"Apache-2.0",
"apache",
"APACHE",
"apache 2.0",
"2.0",
"Apache/2.0",
] {
assert!(
names_only_the_license(label, "Apache-2.0"),
"`{label}` says nothing `Apache-2.0` does not"
);
}
// A label that says anything else stays searchable, including one that
// merely contains a word of the licence.
for label in [
"Apache Spark",
"MCP",
"ELN",
"Imaging",
"Registry",
"apachex",
] {
assert!(
!names_only_the_license(label, "Apache-2.0"),
"`{label}` says more than the licence"
);
}
// An empty label says nothing at all, which is not the same as saying
// only the licence; and an entry with no licence has none to drop.
assert!(!names_only_the_license("", "Apache-2.0"));
assert!(!names_only_the_license(" - ", "Apache-2.0"));
assert!(!names_only_the_license("Apache-2.0", ""));
}
}
146 changes: 143 additions & 3 deletions crates/biorouter/src/marketplace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ use futures::StreamExt;
use serde::Deserialize;
use url::Url;

use crate::catalog_search::{rank, CatalogSearch, Weight, EXTENSION_NOISE, SKILL_NOISE};
use crate::catalog_search::{
names_only_the_license, rank, CatalogSearch, Weight, EXTENSION_NOISE, SKILL_NOISE,
};
use crate::config::paths::Paths;
use crate::privacy::affiliation::InstitutionId;
use crate::privacy::{ExtensionAffiliation, ProviderTier};
Expand Down Expand Up @@ -133,7 +135,13 @@ impl MarketplaceCatalog {
(entry.organization.as_str(), Weight::Label),
(entry.description.as_str(), Weight::Prose),
];
fields.extend(entry.tags.iter().map(|tag| (tag.as_str(), Weight::Label)));
fields.extend(
entry
.tags
.iter()
.filter(|tag| !names_only_the_license(tag, &entry.license))
.map(|tag| (tag.as_str(), Weight::Label)),
);
fields
},
)
Expand All @@ -153,11 +161,18 @@ impl MarketplaceCatalog {
(entry.category.as_str(), Weight::Label),
(entry.description.as_str(), Weight::Prose),
];
fields.extend(entry.tags.iter().map(|tag| (tag.as_str(), Weight::Label)));
fields.extend(
entry
.tags
.iter()
.filter(|tag| !names_only_the_license(tag, &entry.license))
.map(|tag| (tag.as_str(), Weight::Label)),
);
fields.extend(
entry
.keywords
.iter()
.filter(|keyword| !names_only_the_license(keyword, &entry.license))
.map(|keyword| (keyword.as_str(), Weight::Label)),
);
fields
Expand Down Expand Up @@ -1033,6 +1048,131 @@ mod tests {
assert!(phrase.len() >= 2, "{phrase:?}");
}

/// A licence is not searchable, and leaving the FIELD out did not make that
/// true. Measured in the Browse-extensions modal on 2026-09-12 against the
/// live 37-entry registry, with the field already gone: `PACS` → 31 of 37,
/// `pac` → 31, `apache` → 31, `Apache-2.0` → 32, empty → 37, `zzzznope` → 0.
/// The three counts agreeing is the identification — `PACS` reaches `pac`
/// through the plural fallback, `pac` is inside `apache`, and 31 rows
/// republish `Apache-2.0` as one of their own TAG chips, which are searched.
/// None of the 31 was about PACS.
///
/// What this must NOT do is narrow the substring rule: `pac` is inside
/// "PacBio", "package" and "workspace", and those hits stay.
#[test]
fn a_licence_republished_as_a_label_is_not_searchable_through_it() {
let catalog = MarketplaceCatalog::from_bytes(EMBEDDED_REGISTRY).unwrap();

// A word of a licence, as a whole label. Spelled out here rather than
// taken from `catalog_search` so this test cannot be satisfied by the
// same mistake the fix makes.
let is_a_licence_word = |license: &str, label: &str| {
license
.split(|c: char| !c.is_alphanumeric())
.filter(|word| !word.is_empty())
.any(|word| word.eq_ignore_ascii_case(label))
};

// Guard. If the registry stops republishing its licence as a label there
// is nothing here to refuse, and every assertion below would pass while
// proving nothing — which is exactly how the fix before this one looked
// covered. Three counts, because there are three label paths.
let tagged_extensions = catalog
.browse_extensions(ProviderTier::Private)
.iter()
.filter(|entry| {
entry
.tags
.iter()
.any(|tag| tag.eq_ignore_ascii_case(&entry.license))
})
.count();
let tagged_skills = catalog
.browse_skills()
.iter()
.filter(|entry| {
entry
.tags
.iter()
.any(|tag| tag.eq_ignore_ascii_case(&entry.license))
})
.count();
let keyworded_skills = catalog
.browse_skills()
.iter()
.filter(|entry| {
entry
.keywords
.iter()
.any(|keyword| is_a_licence_word(&entry.license, keyword))
})
.count();
assert!(
tagged_extensions >= 2 && tagged_skills >= 2 && keyworded_skills >= 2,
"the shipped registry no longer republishes its licence as a label \
(extensions tagged {tagged_extensions}, skills tagged {tagged_skills}, skills \
keyworded {keyworded_skills}) — this test would pass vacuously"
);

// `apache` occurs nowhere in the registry except each entry's own
// licence, so it finds nothing at all. Measured before the fix: 31
// extensions and 49 skills, none of them about Apache anything.
let extension_ids = |search: &CatalogSearch<'_, MarketplaceExtensionDescriptor>| {
search
.hits
.iter()
.map(|hit| hit.entry.registry_id.clone())
.collect::<Vec<String>>()
};
assert_eq!(
extension_ids(&catalog.search_extensions(ProviderTier::Private, "apache")),
Vec::<String>::new()
);
assert_eq!(
skill_ids(&catalog.search_skills("apache")),
Vec::<String>::new()
);

// The query as the QA run typed it. The plural fallback and the
// substring rule both stay: a skill that really says PACS is found, and
// a skill whose only `pac` was `Apache-2.0` is not.
let pacs = skill_ids(&catalog.search_skills("PACS"));
assert!(
pacs.contains(&"biomedical-imaging-pathology".to_owned()),
"a skill whose keywords say `pacs` must still be found: {pacs:?}"
);
assert!(
pacs.contains(&"long-read-sequencing".to_owned()),
"`pac` inside `PacBio` is the substring rule working, not the defect: {pacs:?}"
);
for licence_only in ["empirical-research-router", "causal-identification-gates"] {
assert!(
!pacs.contains(&licence_only.to_owned()),
"`{licence_only}`'s only `pac` is its `Apache-2.0` label: {pacs:?}"
);
}
let pacs_extensions =
extension_ids(&catalog.search_extensions(ProviderTier::Private, "PACS"));
for licence_only in ["benchlingagent", "dnanexusagent", "omeroagent"] {
assert!(
!pacs_extensions.contains(&licence_only.to_owned()),
"`{licence_only}` is not about PACS; its only `pac` is `Apache-2.0`: \
{pacs_extensions:?}"
);
}

// The browse case is untouched — the licence label is dropped from what
// is SEARCHED, not from the catalog.
assert_eq!(
catalog.search_skills("").len(),
catalog.browse_skills().len()
);
assert_eq!(
catalog.search_extensions(ProviderTier::Private, "").len(),
catalog.browse_extensions(ProviderTier::Private).len()
);
}

/// 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
61 changes: 58 additions & 3 deletions landing/baam.html
Original file line number Diff line number Diff line change
Expand Up @@ -4089,6 +4089,61 @@ <h4>Stay in the loop</h4>
}
}

/* ── What a card is searched BY ─────────────────────────────
The licence is not part of it. Every card in this catalog is Apache-2.0, so
it separates nothing, and it reached the haystack THREE ways: `data-license`
(on every card), the `Apache-2.0` chip the catalog publishes in the tag row
(part of the card's own textContent, on an authored skill card), and — on a
skill — the `apache` keyword in `data-tags` derived from that chip. Because
`data-license` alone is universal, the shelf was worse than the catalog:
measured here on 2026-09-12, `apache`, `Apache-2.0` and `pac` each returned
ALL 37 extension cards and ALL 132 skill cards, not one of them about Apache
anything. After this: 0, 0, 1 and 0, 0, 7.

The same overlap in the app's own matchers is worse in a different way,
because those split a query into words and fall a plural back to its
singular: measured in the Browse-extensions modal the same day, `PACS`
returned 31 of 37 extensions via `pac` inside `apache`. Fixed there in one
rule — `names_only_the_license` in crates/biorouter/src/catalog_search.rs
and its port in ui/desktop/src/components/baam/search.ts — and this is the
third copy of it. By WORDS and not by equality, because the chip says
`Apache-2.0` while the keyword says `apache`, and an equality test drops
only the first.

Only the licence goes. `MCP`, `ELN`, `Imaging` and `Registry` are what a tag
is for, and the chip still RENDERS — this drops it from what is searched,
not from the card. Note that `extCardHtml` and `buildExtChips` already
filter `/^apache/i`, for row space and chip noise rather than for search;
that near-miss is part of why this looked handled. */
const WORD_BREAK = /[^\p{Alphabetic}\p{N}]+/u;

function wordsOf(text) {
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);
}

function searchHaystack(card) {
const licenseWords = wordsOf(card.dataset.license);
// A chip is part of `textContent`, so a licence chip is removed from that
// string rather than skipped while assembling one.
let text = card.textContent;
if (licenseWords.length) {
card.querySelectorAll('.tag').forEach(chip => {
const label = chip.textContent;
if (label && namesOnlyTheLicense(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));
return (text + ' ' + tags).toLowerCase();
}

function filterExtensions(q) {
const bar = document.getElementById('ext-chips');
const active = activeChips(bar);
Expand All @@ -4099,7 +4154,7 @@ <h4>Stay in the loop</h4>
const cards = document.querySelectorAll('#extensions-section .ext-card');
let visible = 0;
cards.forEach(c => {
const hay = (c.textContent + ' ' + (c.dataset.tags || '') + ' ' + (c.dataset.license || '')).toLowerCase();
const hay = searchHaystack(c);
let show = (!q || hay.indexOf(q) !== -1);
if (show) {
for (let i = 0; i < facets.length; i++) {
Expand Down Expand Up @@ -4179,7 +4234,7 @@ <h4>Stay in the loop</h4>
const cards = document.querySelectorAll('#skills-section .skill-card');
let visible = 0;
cards.forEach(c => {
const hay = (c.textContent + ' ' + (c.dataset.tags || '') + ' ' + (c.dataset.license || '')).toLowerCase();
const hay = searchHaystack(c);
let show = (!q || hay.indexOf(q) !== -1);
if (show) {
for (let i = 0; i < facets.length; i++) {
Expand Down Expand Up @@ -4230,7 +4285,7 @@ <h4>Stay in the loop</h4>
const cards = document.querySelectorAll('#workflows-section .wf-card');
let visible = 0;
cards.forEach(c => {
const hay = (c.textContent + ' ' + (c.dataset.tags || '')).toLowerCase();
const hay = searchHaystack(c);
const show = !q || hay.indexOf(q) !== -1;
c.style.display = show ? '' : 'none';
if (show) visible++;
Expand Down
Loading
Loading