Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-15
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# fix-annotation-search-matching

Stop annotation search from matching the middle of a column name, and give the
query builder the same label-aware matching the annotation dropdown has.
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
## Context

Two pickers filter the same annotation list with two different, hand-written rules:
`annotation-select.ts` matches `column.includes(q) || label(column).includes(q)`, and
`query-condition-row.ts` matches `column.includes(q)`. Neither is tested, and the first is
what produces the `ted` → four-Biocentral-rows result.

## Goals / Non-Goals

**Goals:**

- A query only matches text the reader can account for.
- Searching by raw column name keeps working.
- One rule, one implementation, both pickers.

**Non-Goals:**

- Fuzzy matching, ranking, or scoring. The list is short and grouped by source.
- Highlighting the matched substring. Worth doing, but it is a rendering change in two
components and does not depend on this.
- Touching the value picker (`query-value-picker.ts`), which searches annotation _values_,
not column names.

## Decisions

### Match column names by word, labels by substring

A query matches a column when it begins at a word boundary of the column name, or is a
substring of the friendly label. Query and column are both normalised on `_`/`-` first.

Normalising _both_ sides is the part that is easy to get wrong. A rule that only splits the
column and then asks for `word.startsWith(query)` looks equivalent and is not: no word can
ever start with a needle that still contains a separator, so every multi-word column stops
matching its own name. On this registry that was 16 of 38 columns — typing `predicted_`
emptied the list and never recovered.

The asymmetry is the point. Column names are machine identifiers built by joining words,
so a word boundary is meaningful in them and a mid-word hit is almost always an accident —
`ted` inside `predicted` is the whole bug. Labels are prose the reader is looking at, so a
mid-word hit there is exactly what they meant: `cellular` should still find
`Subcellular location`, and `membrane` should still find `Transmembrane`.

Worked through against the real registry:

| query | matches | via |
| ----------- | ----------------------------------------------- | ------------------------- |
| `ted` | `ted_domains` only | label and first word |
| `predicted` | all four Biocentral | first word of each column |
| `membrane` | `predicted_membrane`, `predicted_transmembrane` | word; label substring |
| `cellular` | `predicted_subcellular_location` | label substring |
| `loc` | `predicted_subcellular_location` | word `location` |

Alternatives considered:

- **Match the label only.** Kills the `predicted` case and every other search by real
column name, which is how anyone reading a bundle's schema looks things up.
- **Match labels first, fall back to column names when nothing matched.** Fixes `ted` too,
but the result set then depends on whether some _other_ annotation happened to match,
so the same query behaves differently in different datasets.
- **Substring on both, and mark why a row matched.** The better end state, but it is a
rendering change in two components; the matching rule should be right regardless.

### One helper, in the registry module

`annotationMatchesQuery` goes beside `annotationLabel` and `annotationSource` in
`packages/utils`, which already own per-column presentation knowledge. Both pickers call
it. Today they disagree — the query builder never matches labels at all — and that is only
possible because the rule is written twice.

## Risks / Trade-offs

- **A mid-word column search stops working** — `cellular` no longer matches
`predicted_subcellular_location` _by column name_. It still matches by label, and every
registry column has a label. A column with no registry entry falls back to a label
derived from its own name, so the word rule still reaches it.
- **A match can still be on text the picker does not draw, and this change does not fix
that.** The dropdown renders only the label, so a column-name hit (`predicted` → four rows
reading "Membrane", "Signal peptide", …) is unexplained. The query builder renders only the
column name, so a label hit (`swiss` → `reviewed`) is unexplained in the other direction.
This removes the worst case — a hit on neither, which is what `ted` was — but "every match
is accountable" needs the pickers to show what matched. `query-value-picker` already
highlights matched substrings via `_highlightMatch`; copying that, and rendering both label
and column name, is the real end state. Deliberately out of scope here.

## Migration Plan

None. Pure filtering behaviour, no stored state.

## Open Questions

None.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
## Why

Typing `ted` into the annotation dropdown returns the four Biocentral predictions —
Membrane, Signal peptide, Subcellular location, Transmembrane — above the one annotation
the reader was looking for. Every one of them matches on `predic**ted**_*`, the raw column
name, and none of them matches on anything the dropdown actually displays:

```
MATCH predicted_subcellular_location key:Y label("Subcellular location"):n
MATCH predicted_membrane key:Y label("Membrane"):n
MATCH predicted_signal_peptide key:Y label("Signal peptide"):n
MATCH predicted_transmembrane key:Y label("Transmembrane"):n
MATCH ted_domains key:Y label("TED domains"):Y
```

Searching the column name is deliberate and worth keeping — it is how someone who knows
`predicted_membrane` finds it. Matching an arbitrary substring of it is what makes the
result unexplainable, because the matched text is never on screen.

The query builder's annotation picker has the same flaw and one more: it matches the raw
column name _only_, so a reader who searches for the label they can see finds nothing.

## What Changes

- Match a query against a column name by word, not by arbitrary substring: the query must
begin where a word does. Query and column are both split on `_`/`-`, so a column is still
findable by its own full name.
- Keep matching the friendly label as a substring, so partial words the reader can actually
see still work.
- Move the rule into one shared helper and have both the annotation dropdown and the query
builder's annotation picker use it, so the two pickers stop disagreeing.

## Capabilities

### Modified Capabilities

- `annotation-presentation`: annotation search matches the displayed label or a word of the
column name, and behaves identically in both pickers.

## Impact

- Adds one exported helper beside `annotationLabel`/`annotationSource` in `packages/utils`.
- Changes the filter in `annotation-select.ts` and `query-condition-row.ts` to call it.
- No data, storage, URL or API change. Only which rows a query shows.
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
## ADDED Requirements

### Requirement: Annotation search matches a label substring or a column-name word

Every annotation picker SHALL match a search query against an annotation when the query is a
substring of that annotation's label, or begins at a word boundary of its column name, and
SHALL NOT match on an arbitrary substring of the column name. A column SHALL remain findable
by its own full name, separators included. All annotation pickers SHALL apply this same rule,
through one shared implementation.

#### Scenario: A query does not match the middle of a column name

- **WHEN** the reader searches for `ted`
- **THEN** `ted_domains` is offered
- **AND** `predicted_membrane`, `predicted_signal_peptide`, `predicted_subcellular_location`
and `predicted_transmembrane` are not offered, because `ted` appears only inside the word
`predicted`

#### Scenario: A column name is still searchable by word

- **WHEN** the reader searches for `predicted`
- **THEN** every `predicted_*` annotation is offered

#### Scenario: A column is findable by its own full name

- **WHEN** the reader types a complete column name such as `predicted_membrane`
- **THEN** that annotation is offered, the separator notwithstanding

#### Scenario: A partial word of a label still matches

- **WHEN** the reader searches for `cellular`
- **THEN** the annotation labelled `Subcellular location` is offered

#### Scenario: Both pickers agree

- **WHEN** the same query is entered in the annotation dropdown and in the query builder's
annotation picker
- **THEN** both offer the same annotations
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
## 1. Regression Coverage

- [x] 1.1 Cover the reported case: `ted` matches `ted_domains` and none of `predicted_*`.
- [x] 1.2 Cover that `predicted`, `membrane`, `cellular` and `loc` still match.
- [x] 1.3 Make both pickers filter through one shared function, and test that function.

## 2. Implementation

- [x] 2.1 Add `annotationMatchesQuery` beside the other per-column helpers in `packages/utils`.
- [x] 2.2 Call it from the annotation dropdown.
- [x] 2.3 Call it from the query builder's annotation picker, which matched no labels before.

## 3. Verification

- [x] 3.1 Run the affected package tests and the `pnpm precommit` gate.
- [x] 3.2 Confirm the reported case in the running app.
- [x] 3.3 Archive this change before the merge.
37 changes: 37 additions & 0 deletions openspec/specs/annotation-presentation/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,40 @@ accessible and dismissable, and SHALL be absent when there is no description and

- **WHEN** a documentation popover is open and the user presses Escape or clicks outside it
- **THEN** the popover closes

### Requirement: Annotation search matches a label substring or a column-name word

Every annotation picker SHALL match a search query against an annotation when the query is a
substring of that annotation's label, or begins at a word boundary of its column name, and
SHALL NOT match on an arbitrary substring of the column name. A column SHALL remain findable
by its own full name, separators included. All annotation pickers SHALL apply this same rule,
through one shared implementation.

#### Scenario: A query does not match the middle of a column name

- **WHEN** the reader searches for `ted`
- **THEN** `ted_domains` is offered
- **AND** `predicted_membrane`, `predicted_signal_peptide`, `predicted_subcellular_location`
and `predicted_transmembrane` are not offered, because `ted` appears only inside the word
`predicted`

#### Scenario: A column name is still searchable by word

- **WHEN** the reader searches for `predicted`
- **THEN** every `predicted_*` annotation is offered

#### Scenario: A column is findable by its own full name

- **WHEN** the reader types a complete column name such as `predicted_membrane`
- **THEN** that annotation is offered, the separator notwithstanding

#### Scenario: A partial word of a label still matches

- **WHEN** the reader searches for `cellular`
- **THEN** the annotation labelled `Subcellular location` is offered

#### Scenario: Both pickers agree

- **WHEN** the same query is entered in the annotation dropdown and in the query builder's
annotation picker
- **THEN** both offer the same annotations
26 changes: 26 additions & 0 deletions packages/core/src/components/control-bar/annotation-categories.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
annotationMatchesQuery,
annotationSource,
compareTaxonomyRank,
type Annotation,
Expand Down Expand Up @@ -86,3 +87,28 @@ export function groupAnnotations(

return groups;
}

/**
* Group annotations and keep only those matching a search query, dropping any
* section left empty.
*
* Both pickers filter through here, so "the dropdown and the query builder
* agree" is structural rather than a convention each has to keep honouring.
*/
export function filterGroupedAnnotations(
annotations: string[],
query: string,
definitions?: Readonly<Record<string, Pick<Annotation, 'runtime'>>>,
): GroupedAnnotation[] {
const grouped = groupAnnotations(annotations, definitions);
if (!query.trim()) return grouped;

return grouped
.map((group) => ({
...group,
annotations: group.annotations.filter((annotation) =>
annotationMatchesQuery(annotation, query, definitions?.[annotation]),
),
}))
.filter((group) => group.annotations.length > 0);
}
Loading
Loading