Skip to content

W-569: stem query suffixes so inflected words reach their keywords - #185

Merged
wr merged 5 commits into
mainfrom
wells/w-569-query-stemming
Aug 4, 2026
Merged

W-569: stem query suffixes so inflected words reach their keywords#185
wr merged 5 commits into
mainfrom
wells/w-569-query-stemming

Conversation

@wr

@wr wr commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Replaces #184, which GitHub auto-closed when its base branch was deleted by the #183 squash-merge. Same commits, rebased onto main.

:ghosted: returned nothing, while :ghosting: returned 👻. The keyword was in the corpus the whole time — the query couldn't reach it. FzyScorer.score rejects any needle longer than its haystack:

guard n > 0, m > 0, n <= m else { return nil }

ghosted is 7 characters, ghost is 5. The match dies before the DP runs, so no scoring change reaches it.

What works now

:ghosted     →  👻
:deployed    →  🚀
:launching   →  🚀
:cursed      →  🤬 🖕   (via "curse")
:parties     →  🎉

Design

QueryStemmer.stems(of:) returns candidate spellings, not one canonical stem — -ing alone can't distinguish shipping (undo a doubled consonant) from celebrating (restore a dropped e) from blocking (plain trim).

It runs only when the raw query found nothing, so a query that already works keeps byte-identical ranking and pays nothing.

FuzzyMatcher.acceptedStems then decides which candidate to search, and the ordering rules are the substance of this PR — each one exists because the previous version had a concrete counterexample:

rule without it
Drop candidates absent from the corpus moviesmovy matches 🎑 (m‑o‑v‑y in "moon_viewing_ceremony") and shadows movie → 🎥
Exact corpus term beats a mere prefix skiesskie prefixes "skier", so ⛷️ wins and sky is never tried
Then prefer the longer candidate hopedhop returns rabbits and shadows hope; baredbar shadows bare
-ies ranks above the generic trims, -ied below skies is sky + s (the plural of ski is skis), but skied is ski + ed — one rule for both gets one of them wrong

stemMatch is a plain character compare with no DP, so gating costs far less than the fzy pass it guards.

Cost

Bounded at three cheap prefix sweeps plus one fuzzy sweep, and only on a query that already returned nothing. Queries that match directly are untouched.

Test plan

  • 297 tests pass
  • QueryStemmerTests — 10 cases over the rules: plain stems, -ies/-iedy, restored e, consonant doubling, press never becoming pres, uninflected queries yielding nothing, no stem below 3 characters
  • FuzzyMatcherTests.stemsAreOrderedByHowSolidlyTheyExist — the ordering table above, including both skies and skied
  • inventedStemDoesNotShadowTheRealOne, stemsThatAreNotWordsYieldNothing, longerStemChangesWhatSurfaces, stemmingOnlyRunsWhenTheQueryFoundNothing

Review

Three rounds of adversarial review found four real ordering defects (movies, untied, bared/hoped, skied); all are fixed and covered by tests. A fifth reported case, copses → 🚓, was not actioned: copse is absent from the corpus in every form, so there is no correct result to return.

Refs W-569

wr added 5 commits August 4, 2026 16:16
`:ghosted:` returned nothing while `:ghosting:` returned 👻. fzy rejects
a needle longer than its haystack, so the match died before scoring —
no amount of tuning reached it.

Strip common English inflections and retry, but only when the query as
typed found nothing. That gating is what makes the rules safe to keep
crude: a wrong guess leaves an empty picker empty, and a query that
already works keeps its exact ranking and pays nothing. Candidates go
out most-likely-first, so in practice an inflected query costs one extra
corpus scan, not one per rule.

Refs: W-569
Stemming guesses several spellings and can't know which is a word, so
taking the first candidate that returned anything let a nonsense stem
win on an accidental fuzzy hit: `movies` → `movy` finds 🎑 (m-o-v-y
inside "moon_viewing_ceremony") and shadowed `movie` → 🎥 behind it.
`untied` → `unty` surfaced 📍 the same way.

Gate each candidate on whether it starts some haystack — a plain
character compare, no DP, so it costs far less than the fzy pass it
guards.

Refs: W-569
The corpus check rejects invented spellings, but not a trim that lands
on a different real word: `hoped` → `hop` returns rabbits and shadows
`hope`, `bared` → `bar` shadows `bare`. Order accepted candidates by
length so the one retaining most of what was typed is searched first,
keeping the stemmer's own order for ties.

Refs: W-569
Ordering purely by length broke `skies`: the longest accepted candidate
is `skie`, which isn't a word but prefixes `skier`, so ⛷️ won and `sky`
was never tried. Length was standing in for "is this real", and it's a
bad proxy.

Rank by match strength first — an exact corpus term beats one that
merely prefixes a longer word — then by length, then by the stemmer's
own order. `skies` → sky, ski, skie; `hoped` → hope, hop; `movies` →
movie, movi.

Refs: W-569
They aren't equally trustworthy. `-ies` is reliably the plural of a `-y`
noun — `skies` is sky + s, never ski + es, since the plural of ski is
skis — so it outranks the generic trims. `-ied` carries no such
guarantee: `carried` is carry + ied but `skied` is ski + ed, so it goes
last and wins only when the plain trim found no real word.

Treating them as one rule meant `skied` resolved to sky.

Refs: W-569
@linear-code

linear-code Bot commented Aug 4, 2026

Copy link
Copy Markdown

W-569

@wr
wr merged commit 9c4365f into main Aug 4, 2026
3 checks passed
@wr
wr deleted the wells/w-569-query-stemming branch August 4, 2026 20:17

@wr-claude-reviewer wr-claude-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean implementation. The design decisions are well-reasoned — gating on empty results means the hot path is untouched, and ranking by corpus existence before length prevents the moviesmovy → 🎑 shadow problem. The commit series telling the story of each ordering rule as it was added (with the counterexample it fixed) is exactly the right level of detail.

A few things I traced through explicitly:

  • stemMatch returning .exact early on the first exact hit is correct; it continues scanning after .prefix because an exact match may still exist later in the pool.
  • The undoubled guard (stem.count > minStem, not >=) ensures the result is ≥ 3 chars after dropping the doubled consonant — correct.
  • The hasSuffix(["s","s"]) guard on trim("s") correctly blocks presspres without blocking starsstar (only one trailing s).
  • The -ied rule going last (after all trim calls) means corpus-exact results from the direct trims always appear in acceptedStems before the -ied reconstruct, even when both are present — the skied ordering is correct.
  • Swapping "cursed" out of queryWithNoRealMatchReturnsNothing is the right call; it now returns results via "curse".

Nit: QueryStemmer.swift imports Foundation but nothing in the file requires it — all types and methods ([Character], Array, String, !"aeiou".contains(last)) are stdlib. Minor, zero runtime impact, but can be dropped.

@wr wr mentioned this pull request Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant