W-569: stem query suffixes so inflected words reach their keywords - #185
Merged
Conversation
`: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
There was a problem hiding this comment.
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 movies → movy → 🎑 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:
stemMatchreturning.exactearly on the first exact hit is correct; it continues scanning after.prefixbecause an exact match may still exist later in the pool.- The
undoubledguard (stem.count > minStem, not>=) ensures the result is ≥ 3 chars after dropping the doubled consonant — correct. - The
hasSuffix(["s","s"])guard ontrim("s")correctly blockspress→preswithout blockingstars→star(only one trailings). - The
-iedrule going last (after alltrimcalls) means corpus-exact results from the direct trims always appear inacceptedStemsbefore the-iedreconstruct, even when both are present — theskiedordering is correct. - Swapping
"cursed"out ofqueryWithNoRealMatchReturnsNothingis 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.scorerejects any needle longer than its haystack:ghostedis 7 characters,ghostis 5. The match dies before the DP runs, so no scoring change reaches it.What works now
Design
QueryStemmer.stems(of:)returns candidate spellings, not one canonical stem —-ingalone can't distinguishshipping(undo a doubled consonant) fromcelebrating(restore a droppede) fromblocking(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.acceptedStemsthen 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:movies→movymatches 🎑 (m‑o‑v‑y in "moon_viewing_ceremony") and shadowsmovie→ 🎥skies→skieprefixes "skier", so ⛷️ wins andskyis never triedhoped→hopreturns rabbits and shadowshope;bared→barshadowsbare-iesranks above the generic trims,-iedbelowskiesis sky + s (the plural of ski is skis), butskiedis ski + ed — one rule for both gets one of them wrongstemMatchis 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
QueryStemmerTests— 10 cases over the rules: plain stems,-ies/-ied→y, restorede, consonant doubling,pressnever becomingpres, uninflected queries yielding nothing, no stem below 3 charactersFuzzyMatcherTests.stemsAreOrderedByHowSolidlyTheyExist— the ordering table above, including bothskiesandskiedinventedStemDoesNotShadowTheRealOne,stemsThatAreNotWordsYieldNothing,longerStemChangesWhatSurfaces,stemmingOnlyRunsWhenTheQueryFoundNothingReview
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:copseis absent from the corpus in every form, so there is no correct result to return.Refs W-569