From 431cdbc0ab7f9815c29340b077a70df3984ba53d Mon Sep 17 00:00:00 2001 From: BigAkins Date: Mon, 11 May 2026 22:58:48 -0500 Subject: [PATCH 1/4] Add search text matcher boundary --- AkFit/AkFit/Search/SearchTextMatcher.swift | 218 ++++++++++++++++++ .../Search/SupabaseFoodSearchService.swift | 180 +-------------- AkFit/AkFit/Views/Search/SearchView.swift | 56 +---- AkFit/AkFitTests/SearchTextMatcherTests.swift | 71 ++++++ 4 files changed, 300 insertions(+), 225 deletions(-) create mode 100644 AkFit/AkFit/Search/SearchTextMatcher.swift create mode 100644 AkFit/AkFitTests/SearchTextMatcherTests.swift diff --git a/AkFit/AkFit/Search/SearchTextMatcher.swift b/AkFit/AkFit/Search/SearchTextMatcher.swift new file mode 100644 index 0000000..eeec299 --- /dev/null +++ b/AkFit/AkFit/Search/SearchTextMatcher.swift @@ -0,0 +1,218 @@ +import Foundation + +/// Pure search text helpers shared by Supabase-backed search and type-ahead. +enum SearchTextMatcher { + + /// Normalizes user-entered query text and applies known aliases. + static func normalizedQuery(_ text: String) -> String { + applyQueryAliases(normalizeForSearch(text)) + } + + /// Rewrites known query aliases to their canonical normalized forms. + /// Called AFTER `normalizeForSearch` so the input is already lowercased + /// with punctuation stripped. Handles cases where normalization alone + /// can't bridge the gap (e.g. "innout" -> "in n out"). + static func applyQueryAliases(_ normalized: String) -> String { + // Longest patterns first to avoid partial replacement. + let aliases: [(from: String, to: String)] = [ + ("in and out", "in n out"), + ("innout", "in n out"), + ] + var result = normalized + for alias in aliases { + if result.contains(alias.from) { + result = result.replacingOccurrences(of: alias.from, with: alias.to) + } + } + return result + } + + /// Normalizes text for search comparison: lowercased, apostrophes removed, + /// hyphens/commas/parentheses replaced with spaces, whitespace collapsed. + /// Mirrors the Postgres `search_text` column transform. + static func normalizeForSearch(_ text: String) -> String { + text.lowercased() + .replacingOccurrences(of: "'", with: "") + .replacingOccurrences(of: "\u{2019}", with: "") // right single quote (iOS keyboard) + .replacingOccurrences(of: "\u{2018}", with: "") // left single quote + .replacingOccurrences(of: "-", with: " ") + .replacingOccurrences(of: ",", with: " ") + .replacingOccurrences(of: "(", with: " ") + .replacingOccurrences(of: ")", with: " ") + .split(separator: " ") + .joined(separator: " ") + } + + /// Reduces a word to a rough stem by stripping common English plural + /// suffixes. Not a full Porter stemmer; just enough to map + /// "strawberries" <-> "strawberry", "blueberries" <-> "blueberry", etc. + static func stemWord(_ word: String) -> String { + let w = word.lowercased() + guard w.count > 3 else { return w } + // -ies -> -y (strawberries -> strawberry) + if w.hasSuffix("ies") { return String(w.dropLast(3)) + "y" } + // -ches, -shes, -xes, -zes, -ses -> drop -es + if w.hasSuffix("es") { + let stem = String(w.dropLast(2)) + if stem.hasSuffix("ch") || stem.hasSuffix("sh") || + stem.hasSuffix("x") || stem.hasSuffix("z") || stem.hasSuffix("s") { + return stem + } + } + // trailing -s (but not -ss) -> drop -s + if w.hasSuffix("s") && !w.hasSuffix("ss") { + return String(w.dropLast(1)) + } + return w + } + + /// Stems every word in a normalized string for comparison. + static func stemmedForm(_ text: String) -> String { + text.split(separator: " ").map { stemWord(String($0)) }.joined(separator: " ") + } + + /// Returns true when every normalized query word matches the term either + /// directly or through the rough plural stem. + static func matchesAllQueryWords(term: String, normalizedQuery: String) -> Bool { + guard !normalizedQuery.isEmpty else { return false } + let words = normalizedQuery.split(separator: " ").map(String.init) + let stemmedWords = words.map { stemWord($0) } + let normalizedTerm = normalizeForSearch(term) + let stemmedTerm = stemmedForm(normalizedTerm) + return words.allSatisfy { normalizedTerm.contains($0) } || + stemmedWords.allSatisfy { stemmedTerm.contains($0) } + } + + /// Levenshtein edit distance between two strings. Used for typo tolerance + /// in type-ahead suggestions. O(n*m) but only called on short food names. + static func editDistance(_ a: String, _ b: String) -> Int { + let a = Array(a), b = Array(b) + let m = a.count, n = b.count + if m == 0 { return n } + if n == 0 { return m } + var prev = Array(0...n) + var curr = [Int](repeating: 0, count: n + 1) + for i in 1...m { + curr[0] = i + for j in 1...n { + let cost = a[i-1] == b[j-1] ? 0 : 1 + curr[j] = min(prev[j] + 1, curr[j-1] + 1, prev[j-1] + cost) + } + prev = curr + } + return prev[n] + } + + /// Scores how closely `name` matches `query` (query is already normalized). + /// Lower = better match. Supports stem-aware comparison so "strawberry" + /// matches "strawberries" at full quality. + static func matchScore(name: String, query q: String) -> Int { + let n = normalizeForSearch(name) + if n == q { return 0 } + + let nStemmed = stemmedForm(n) + let qStemmed = stemmedForm(q) + if nStemmed == qStemmed { return 0 } + + if n.hasPrefix(q) || nStemmed.hasPrefix(qStemmed) { return 1 } + + let qWords = q.split(separator: " ").map(String.init) + let nWords = n.split(separator: " ").map(String.init) + let qStems = qWords.map { stemWord($0) } + let nStems = nWords.map { stemWord($0) } + + if qWords.count <= 1 { + let qStem = qStems[0] + if nWords.first == q || nStems.first == qStem { return 2 } + if nWords.contains(where: { $0.hasPrefix(q) }) { return 3 } + if nStems.contains(where: { $0.hasPrefix(qStem) }) { return 3 } + return 4 + } + + // Multi-word: check if every query word is a prefix of some name word + // (with stem-aware fallback). + let allWordPrefixes = qWords.indices.allSatisfy { i in + nWords.contains(where: { $0.hasPrefix(qWords[i]) }) || + nStems.contains(where: { $0.hasPrefix(qStems[i]) }) + } + if allWordPrefixes { + if let fq = qWords.first, let fn = nWords.first, + fn.hasPrefix(fq) || stemWord(fn).hasPrefix(stemWord(fq)) { + return 1 + } + return 2 + } + return 3 + } + + static func isPlainFoodQuery(_ normalizedQuery: String) -> Bool { + let words = normalizedQuery.split(separator: " ").map(String.init) + return words.count <= 2 && words.allSatisfy { $0.allSatisfy(\.isLetter) } + } + + /// Words that indicate a dessert or processed item. When the user's query + /// is a plain food word (e.g. "strawberry") these results should rank below + /// the whole-food match. + private static let dessertKeywords: Set = [ + "milkshake", "shake", "ice cream", "smoothie", "cake", "pie", + "cookie", "brownie", "muffin", "donut", "pastry", "candy", + "frosting", "sundae", "parfait", + ] + + /// Returns `true` when a normalized food name looks like a dessert or + /// processed item; used to add a ranking penalty for plain food queries. + static func isDessertOrProcessed(_ normalizedName: String) -> Bool { + dessertKeywords.contains(where: { normalizedName.contains($0) }) + } + + /// Returns up to `limit` suggestion terms that match `query`, ranked by + /// match quality. This is the former SearchView type-ahead behavior as a + /// pure helper so it can be unit-tested without UI state. + static func suggestions(for query: String, in suggestionPool: [String], limit: Int = 6) -> [String] { + let normalized = normalizedQuery(query) + guard normalized.count >= 1 else { return [] } + + let words = normalized.split(separator: " ").map(String.init) + let penalizeDesserts = isPlainFoodQuery(normalized) + + // Substring + stem matching (primary) + var matches = suggestionPool.filter { term in + matchesAllQueryWords(term: term, normalizedQuery: normalized) + } + + // Fuzzy fallback: if fewer than 3 substring matches, try edit distance + // on each word of the food name. Only for queries >= 3 chars to avoid + // noise on very short inputs. + if matches.count < 3 && normalized.count >= 3 { + let fuzzy = suggestionPool.filter { term in + guard !matches.contains(term) else { return false } + let nWords = normalizeForSearch(term).split(separator: " ").map(String.init) + return words.allSatisfy { qw in + nWords.contains { nw in + // Allow edit distance <= 2, but scale: for short words (<=4 chars) only allow 1 + let maxDist = qw.count <= 4 ? 1 : 2 + return editDistance(qw, nw) <= maxDist || + editDistance(stemWord(qw), stemWord(nw)) <= maxDist + } + } + } + matches.append(contentsOf: fuzzy) + } + + return matches + .sorted { a, b in + var sa = matchScore(name: a, query: normalized) + var sb = matchScore(name: b, query: normalized) + if penalizeDesserts { + let na = normalizeForSearch(a) + let nb = normalizeForSearch(b) + if isDessertOrProcessed(na) { sa += 1 } + if isDessertOrProcessed(nb) { sb += 1 } + } + if sa != sb { return sa < sb } + return a.count < b.count + } + .prefix(limit) + .map { $0 } + } +} diff --git a/AkFit/AkFit/Search/SupabaseFoodSearchService.swift b/AkFit/AkFit/Search/SupabaseFoodSearchService.swift index 6dc742c..b0d108e 100644 --- a/AkFit/AkFit/Search/SupabaseFoodSearchService.swift +++ b/AkFit/AkFit/Search/SupabaseFoodSearchService.swift @@ -17,7 +17,7 @@ struct SupabaseFoodSearchService: FoodSearchService { // Normalize the query the same way the DB search_text column is normalized: // remove apostrophes, replace hyphens with spaces, collapse whitespace. // Then apply known aliases (e.g. "innout" → "in n out"). - let normalized = Self.applyQueryAliases(Self.normalizeForSearch(q)) + let normalized = SearchTextMatcher.normalizedQuery(q) guard !normalized.isEmpty else { return [] } let words = normalized.split(separator: " ").map(String.init) @@ -49,25 +49,24 @@ struct SupabaseFoodSearchService: FoodSearchService { .limit(100) .execute() .value - let stemmedWords = words.map { Self.stemWord($0) } rows = candidates.filter { row in - let n = Self.normalizeForSearch(row.foodName) - let nStemmed = Self.stemmedForm(n) - return words.allSatisfy { w in n.contains(w) } || - stemmedWords.allSatisfy { sw in nStemmed.contains(sw) } + SearchTextMatcher.matchesAllQueryWords( + term: row.foodName, + normalizedQuery: normalized + ) } } // Re-rank client-side by match quality so exact/prefix matches surface // before weaker substring hits. Dessert/processed items get a penalty // so whole foods rank above them for plain queries like "strawberry". - let isPlainFoodQuery = words.count <= 2 && words.allSatisfy { $0.allSatisfy(\.isLetter) } + let isPlainFoodQuery = SearchTextMatcher.isPlainFoodQuery(normalized) return rows.map(FoodItem.init).sorted { a, b in - var sa = Self.matchScore(name: a.name, query: normalized) - var sb = Self.matchScore(name: b.name, query: normalized) + var sa = SearchTextMatcher.matchScore(name: a.name, query: normalized) + var sb = SearchTextMatcher.matchScore(name: b.name, query: normalized) if isPlainFoodQuery { - if Self.isDessertOrProcessed(Self.normalizeForSearch(a.name)) { sa += 1 } - if Self.isDessertOrProcessed(Self.normalizeForSearch(b.name)) { sb += 1 } + if SearchTextMatcher.isDessertOrProcessed(SearchTextMatcher.normalizeForSearch(a.name)) { sa += 1 } + if SearchTextMatcher.isDessertOrProcessed(SearchTextMatcher.normalizeForSearch(b.name)) { sb += 1 } } if sa != sb { return sa < sb } // Within the same rank, shorter names are simpler/more generic. @@ -78,165 +77,6 @@ struct SupabaseFoodSearchService: FoodSearchService { } } - /// Rewrites known query aliases to their canonical normalized forms. - /// Called AFTER `normalizeForSearch` so the input is already lowercased - /// with punctuation stripped. Handles cases where normalization alone - /// can't bridge the gap (e.g. "innout" → "in n out"). - static func applyQueryAliases(_ normalized: String) -> String { - // Longest patterns first to avoid partial replacement. - let aliases: [(from: String, to: String)] = [ - ("in and out", "in n out"), - ("innout", "in n out"), - ] - var result = normalized - for alias in aliases { - if result.contains(alias.from) { - result = result.replacingOccurrences(of: alias.from, with: alias.to) - } - } - return result - } - - /// Normalizes text for search comparison: lowercased, apostrophes removed, - /// hyphens/commas/parentheses replaced with spaces, whitespace collapsed. - /// Mirrors the Postgres `search_text` column transform. - /// - /// Internal access so `SearchView` can use the same normalization for - /// type-ahead suggestion matching. - static func normalizeForSearch(_ text: String) -> String { - text.lowercased() - .replacingOccurrences(of: "'", with: "") - .replacingOccurrences(of: "\u{2019}", with: "") // right single quote (iOS keyboard) - .replacingOccurrences(of: "\u{2018}", with: "") // left single quote - .replacingOccurrences(of: "-", with: " ") - .replacingOccurrences(of: ",", with: " ") - .replacingOccurrences(of: "(", with: " ") - .replacingOccurrences(of: ")", with: " ") - .split(separator: " ") - .joined(separator: " ") - } - - /// Reduces a word to a rough stem by stripping common English plural - /// suffixes. Not a full Porter stemmer — just enough to map - /// "strawberries" ↔ "strawberry", "blueberries" ↔ "blueberry", etc. - static func stemWord(_ word: String) -> String { - let w = word.lowercased() - guard w.count > 3 else { return w } - // -ies → -y (strawberries → strawberry) - if w.hasSuffix("ies") { return String(w.dropLast(3)) + "y" } - // -ches, -shes, -xes, -zes, -ses → drop -es - if w.hasSuffix("es") { - let stem = String(w.dropLast(2)) - if stem.hasSuffix("ch") || stem.hasSuffix("sh") || - stem.hasSuffix("x") || stem.hasSuffix("z") || stem.hasSuffix("s") { - return stem - } - } - // trailing -s (but not -ss) → drop -s - if w.hasSuffix("s") && !w.hasSuffix("ss") { - return String(w.dropLast(1)) - } - return w - } - - /// Stems every word in a normalized string for comparison. - static func stemmedForm(_ text: String) -> String { - text.split(separator: " ").map { stemWord(String($0)) }.joined(separator: " ") - } - - /// Levenshtein edit distance between two strings. Used for typo tolerance - /// in type-ahead suggestions. O(n*m) but only called on short food names. - static func editDistance(_ a: String, _ b: String) -> Int { - let a = Array(a), b = Array(b) - let m = a.count, n = b.count - if m == 0 { return n } - if n == 0 { return m } - var prev = Array(0...n) - var curr = [Int](repeating: 0, count: n + 1) - for i in 1...m { - curr[0] = i - for j in 1...n { - let cost = a[i-1] == b[j-1] ? 0 : 1 - curr[j] = min(prev[j] + 1, curr[j-1] + 1, prev[j-1] + cost) - } - prev = curr - } - return prev[n] - } - - /// Scores how closely `name` matches `query` (both already normalized). - /// Lower = better match. Supports stem-aware comparison so "strawberry" - /// matches "strawberries" at full quality, and applies a category penalty - /// for desserts/processed items when the query is a plain food word. - /// - /// Single-word query: - /// 0 – exact match or stem-exact ("egg" → "egg", "strawberry" → "strawberries") - /// 1 – name starts with (or stemmed prefix) - /// 2 – first word exact/stem-exact - /// 3 – any word starts with - /// 4 – substring only - /// +1 penalty for dessert/processed names when query looks like a plain food - /// - /// Multi-word query: - /// 0 – exact match - /// 1 – name starts with full query OR every query word matches a - /// name-word prefix and the first word aligns - /// 2 – every query word matches a name-word prefix - /// 3 – all words present as substrings (guaranteed by filter) - static func matchScore(name: String, query q: String) -> Int { - let n = normalizeForSearch(name) - if n == q { return 0 } - - let nStemmed = stemmedForm(n) - let qStemmed = stemmedForm(q) - if nStemmed == qStemmed { return 0 } - - if n.hasPrefix(q) || nStemmed.hasPrefix(qStemmed) { return 1 } - - let qWords = q.split(separator: " ").map(String.init) - let nWords = n.split(separator: " ").map(String.init) - let qStems = qWords.map { stemWord($0) } - let nStems = nWords.map { stemWord($0) } - - if qWords.count <= 1 { - let qStem = qStems[0] - if nWords.first == q || nStems.first == qStem { return 2 } - if nWords.contains(where: { $0.hasPrefix(q) }) { return 3 } - if nStems.contains(where: { $0.hasPrefix(qStem) }) { return 3 } - return 4 - } - - // Multi-word: check if every query word is a prefix of some name word - // (with stem-aware fallback). - let allWordPrefixes = qWords.indices.allSatisfy { i in - nWords.contains(where: { $0.hasPrefix(qWords[i]) }) || - nStems.contains(where: { $0.hasPrefix(qStems[i]) }) - } - if allWordPrefixes { - if let fq = qWords.first, let fn = nWords.first, - fn.hasPrefix(fq) || stemWord(fn).hasPrefix(stemWord(fq)) { - return 1 - } - return 2 - } - return 3 - } - - /// Words that indicate a dessert or processed item. When the user's query - /// is a plain food word (e.g. "strawberry") these results should rank below - /// the whole-food match. - private static let dessertKeywords: Set = [ - "milkshake", "shake", "ice cream", "smoothie", "cake", "pie", - "cookie", "brownie", "muffin", "donut", "pastry", "candy", - "frosting", "sundae", "parfait", - ] - - /// Returns `true` when a normalized food name looks like a dessert or - /// processed item — used to add a ranking penalty for plain food queries. - static func isDessertOrProcessed(_ normalizedName: String) -> Bool { - dessertKeywords.contains(where: { normalizedName.contains($0) }) - } - /// Returns a small, curated set of foods for the empty-state "Suggestions" /// section. Values come from `generic_foods` so they are always consistent /// with search results. diff --git a/AkFit/AkFit/Views/Search/SearchView.swift b/AkFit/AkFit/Views/Search/SearchView.swift index 2a668d7..117639a 100644 --- a/AkFit/AkFit/Views/Search/SearchView.swift +++ b/AkFit/AkFit/Views/Search/SearchView.swift @@ -579,61 +579,7 @@ struct SearchView: View { /// names). Supports multi-word queries, stem-aware matching ("strawberry" /// finds "Strawberries"), and fuzzy fallback for typos. private func matchingSuggestions(for query: String) -> [String] { - let normalized = SupabaseFoodSearchService.applyQueryAliases( - SupabaseFoodSearchService.normalizeForSearch(query) - ) - guard normalized.count >= 1 else { return [] } - - let words = normalized.split(separator: " ").map(String.init) - let stemmedWords = words.map { SupabaseFoodSearchService.stemWord($0) } - let isPlainFoodQuery = words.count <= 2 && words.allSatisfy { $0.allSatisfy(\.isLetter) } - - // Substring + stem matching (primary) - var matches = suggestionPool.filter { term in - let n = SupabaseFoodSearchService.normalizeForSearch(term) - let nStemmed = SupabaseFoodSearchService.stemmedForm(n) - return words.allSatisfy { n.contains($0) } || - stemmedWords.allSatisfy { sw in nStemmed.contains(sw) } - } - - // Fuzzy fallback: if fewer than 3 substring matches, try edit distance - // on each word of the food name. Only for queries ≥ 3 chars to avoid - // noise on very short inputs. - if matches.count < 3 && normalized.count >= 3 { - let fuzzy = suggestionPool.filter { term in - guard !matches.contains(term) else { return false } - let nWords = SupabaseFoodSearchService.normalizeForSearch(term) - .split(separator: " ").map(String.init) - return words.allSatisfy { qw in - nWords.contains { nw in - // Allow edit distance ≤ 2, but scale: for short words (≤4 chars) only allow 1 - let maxDist = qw.count <= 4 ? 1 : 2 - return SupabaseFoodSearchService.editDistance(qw, nw) <= maxDist || - SupabaseFoodSearchService.editDistance( - SupabaseFoodSearchService.stemWord(qw), - SupabaseFoodSearchService.stemWord(nw) - ) <= maxDist - } - } - } - matches.append(contentsOf: fuzzy) - } - - return matches - .sorted { a, b in - var sa = SupabaseFoodSearchService.matchScore(name: a, query: normalized) - var sb = SupabaseFoodSearchService.matchScore(name: b, query: normalized) - if isPlainFoodQuery { - let na = SupabaseFoodSearchService.normalizeForSearch(a) - let nb = SupabaseFoodSearchService.normalizeForSearch(b) - if SupabaseFoodSearchService.isDessertOrProcessed(na) { sa += 1 } - if SupabaseFoodSearchService.isDessertOrProcessed(nb) { sb += 1 } - } - if sa != sb { return sa < sb } - return a.count < b.count - } - .prefix(6) - .map { $0 } + SearchTextMatcher.suggestions(for: query, in: suggestionPool) } // MARK: - Search logic diff --git a/AkFit/AkFitTests/SearchTextMatcherTests.swift b/AkFit/AkFitTests/SearchTextMatcherTests.swift new file mode 100644 index 0000000..1ef218d --- /dev/null +++ b/AkFit/AkFitTests/SearchTextMatcherTests.swift @@ -0,0 +1,71 @@ +import Testing +@testable import AkFit + +struct SearchTextMatcherTests { + + @Test func normalizedQuery_removesApostrophesAndSplitsHyphens() { + #expect(SearchTextMatcher.normalizedQuery("McDonald's") == "mcdonalds") + #expect(SearchTextMatcher.normalizedQuery("Dave\u{2019}s Hot-Chicken") == "daves hot chicken") + } + + @Test func normalizedQuery_handlesRestaurantAliases() { + #expect(SearchTextMatcher.normalizedQuery("Chick-fil-A") == "chick fil a") + #expect(SearchTextMatcher.normalizedQuery("chick fil a") == "chick fil a") + #expect(SearchTextMatcher.normalizedQuery("In-N-Out") == "in n out") + #expect(SearchTextMatcher.normalizedQuery("innout") == "in n out") + } + + @Test func stemWord_handlesSimplePlurals() { + #expect(SearchTextMatcher.stemWord("strawberries") == "strawberry") + #expect(SearchTextMatcher.stemWord("blueberries") == "blueberry") + #expect(SearchTextMatcher.stemWord("eggs") == "egg") + #expect(SearchTextMatcher.stemWord("glass") == "glass") + } + + @Test func suggestions_matchHyphenatedRestaurantQueries() { + let pool = [ + "Chicken Breast, cooked", + "Chick-fil-A Chicken Sandwich", + "In-N-Out Burger", + ] + + #expect(SearchTextMatcher.suggestions(for: "chick fil a", in: pool).first == "Chick-fil-A Chicken Sandwich") + #expect(SearchTextMatcher.suggestions(for: "innout", in: pool).first == "In-N-Out Burger") + #expect(SearchTextMatcher.suggestions(for: "In-N-Out", in: pool).first == "In-N-Out Burger") + } + + @Test func suggestions_matchSimplePluralForms() { + let pool = [ + "Strawberries", + "Strawberry Milkshake", + "Blueberries", + ] + + let matches = SearchTextMatcher.suggestions(for: "strawberry", in: pool) + + #expect(matches.contains("Strawberries")) + #expect(matches.contains("Strawberry Milkshake")) + } + + @Test func suggestions_rankWholeFoodBeforeDessertForPlainQuery() { + let pool = [ + "Strawberry Milkshake", + "Strawberry", + "Strawberry Ice Cream", + ] + + let matches = SearchTextMatcher.suggestions(for: "strawberry", in: pool) + + #expect(matches.first == "Strawberry") + } + + @Test func suggestions_returnEmptyForObviousNonMatch() { + let pool = [ + "Chicken Breast, cooked", + "Greek Yogurt, plain nonfat", + "In-N-Out Burger", + ] + + #expect(SearchTextMatcher.suggestions(for: "zzzz", in: pool).isEmpty) + } +} From 701307a35d05d64e75662cad4ca15807e54e0dd0 Mon Sep 17 00:00:00 2001 From: BigAkins Date: Mon, 11 May 2026 22:59:27 -0500 Subject: [PATCH 2/4] Add files --- .memsearch/.index.pid | 1 + .memsearch/memory/2026-05-11.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 .memsearch/.index.pid create mode 100644 .memsearch/memory/2026-05-11.md diff --git a/.memsearch/.index.pid b/.memsearch/.index.pid new file mode 100644 index 0000000..d0debe6 --- /dev/null +++ b/.memsearch/.index.pid @@ -0,0 +1 @@ +55537 diff --git a/.memsearch/memory/2026-05-11.md b/.memsearch/memory/2026-05-11.md new file mode 100644 index 0000000..8240da8 --- /dev/null +++ b/.memsearch/memory/2026-05-11.md @@ -0,0 +1,18 @@ + +## Session 22:45 + +### 22:45 + +- Codex: {"title":"Add search helper tests"} + +### 22:52 + +- User asked: Implement the next AkFit cleanup-only PR. +- Codex: Implemented the cleanup-only search boundary. No commit made. + +**Files Changed** +- [SearchTextMatcher.swift](/Users/aki/Documents/AkTalkToEM/AkCodeForEm/Project%20Workspace/AkPerso/AkFit/AkFit/AkFit/Search/SearchTextMatcher.swift) new pure helper +- [SupabaseFoodSearchService.swift](/Users/aki/Documents/AkTalkToEM/AkCodeForEm/Project%20Workspace/AkPerso/AkFit/AkFit/AkFit/Search/SupabaseFoodSearchService.swift) now delegates normalization/filtering/scoring +- [SearchView.swift](/Users/aki/Documents/AkTalkToEM/AkCodeForEm/Project%20Workspace/AkPerso/AkFit/AkFit/AkFit/Views/Search/SearchView.swift) type-ahead matching now delegates to helper +- [SearchTextMatcherTests.swift](/Users/aki/Documents/AkTalkToEM/AkCodeForEm/Project%20Workspace/AkPerso/AkFit/AkFit/AkFitTests/SearchTextMatcherTests.swif... + From 633242622b870ccf5c6b060a34f7c8934bb20782 Mon Sep 17 00:00:00 2001 From: BigAkins Date: Tue, 12 May 2026 00:02:06 -0500 Subject: [PATCH 3/4] git ignore update --- .memsearch/.index.pid | 2 +- .memsearch/memory/2026-05-11.md | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.memsearch/.index.pid b/.memsearch/.index.pid index d0debe6..814974a 100644 --- a/.memsearch/.index.pid +++ b/.memsearch/.index.pid @@ -1 +1 @@ -55537 +5188 diff --git a/.memsearch/memory/2026-05-11.md b/.memsearch/memory/2026-05-11.md index 8240da8..38e7812 100644 --- a/.memsearch/memory/2026-05-11.md +++ b/.memsearch/memory/2026-05-11.md @@ -16,3 +16,6 @@ - [SearchView.swift](/Users/aki/Documents/AkTalkToEM/AkCodeForEm/Project%20Workspace/AkPerso/AkFit/AkFit/AkFit/Views/Search/SearchView.swift) type-ahead matching now delegates to helper - [SearchTextMatcherTests.swift](/Users/aki/Documents/AkTalkToEM/AkCodeForEm/Project%20Workspace/AkPerso/AkFit/AkFit/AkFitTests/SearchTextMatcherTests.swif... + +## Session 23:59 + From bdbe8553821e661fc8d1a5df11610c0dbccbe584 Mon Sep 17 00:00:00 2001 From: BigAkins Date: Tue, 12 May 2026 00:27:13 -0500 Subject: [PATCH 4/4] Patch search matcher cleanup --- .gitignore | 3 +- .memsearch/.index.pid | 1 - .memsearch/memory/2026-05-11.md | 21 ------ AkFit/AkFit/Search/SearchTextMatcher.swift | 71 +++++++++++++------ .../Search/SupabaseFoodSearchService.swift | 14 ++-- AkFit/AkFit/Views/Search/SearchView.swift | 3 +- AkFit/AkFitTests/SearchTextMatcherTests.swift | 30 ++++++++ 7 files changed, 92 insertions(+), 51 deletions(-) delete mode 100644 .memsearch/.index.pid delete mode 100644 .memsearch/memory/2026-05-11.md diff --git a/.gitignore b/.gitignore index c53a07d..944f7a3 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ CLAUDE.md .claude/ .vscode/ .codex/ +.memsearch/ .agents/ AGENTS.md docs/ui-reference/ @@ -60,4 +61,4 @@ fastlane/test_output # Logs *.log -skills-lock.json \ No newline at end of file +skills-lock.json diff --git a/.memsearch/.index.pid b/.memsearch/.index.pid deleted file mode 100644 index 814974a..0000000 --- a/.memsearch/.index.pid +++ /dev/null @@ -1 +0,0 @@ -5188 diff --git a/.memsearch/memory/2026-05-11.md b/.memsearch/memory/2026-05-11.md deleted file mode 100644 index 38e7812..0000000 --- a/.memsearch/memory/2026-05-11.md +++ /dev/null @@ -1,21 +0,0 @@ - -## Session 22:45 - -### 22:45 - -- Codex: {"title":"Add search helper tests"} - -### 22:52 - -- User asked: Implement the next AkFit cleanup-only PR. -- Codex: Implemented the cleanup-only search boundary. No commit made. - -**Files Changed** -- [SearchTextMatcher.swift](/Users/aki/Documents/AkTalkToEM/AkCodeForEm/Project%20Workspace/AkPerso/AkFit/AkFit/AkFit/Search/SearchTextMatcher.swift) new pure helper -- [SupabaseFoodSearchService.swift](/Users/aki/Documents/AkTalkToEM/AkCodeForEm/Project%20Workspace/AkPerso/AkFit/AkFit/AkFit/Search/SupabaseFoodSearchService.swift) now delegates normalization/filtering/scoring -- [SearchView.swift](/Users/aki/Documents/AkTalkToEM/AkCodeForEm/Project%20Workspace/AkPerso/AkFit/AkFit/AkFit/Views/Search/SearchView.swift) type-ahead matching now delegates to helper -- [SearchTextMatcherTests.swift](/Users/aki/Documents/AkTalkToEM/AkCodeForEm/Project%20Workspace/AkPerso/AkFit/AkFit/AkFitTests/SearchTextMatcherTests.swif... - - -## Session 23:59 - diff --git a/AkFit/AkFit/Search/SearchTextMatcher.swift b/AkFit/AkFit/Search/SearchTextMatcher.swift index eeec299..49e7810 100644 --- a/AkFit/AkFit/Search/SearchTextMatcher.swift +++ b/AkFit/AkFit/Search/SearchTextMatcher.swift @@ -3,6 +3,27 @@ import Foundation /// Pure search text helpers shared by Supabase-backed search and type-ahead. enum SearchTextMatcher { + /// Precomputed query text used to avoid re-splitting and re-stemming the + /// same query while filtering candidate rows or suggestion terms. + struct QueryMatch { + let normalized: String + let words: [String] + let stemmedWords: [String] + let isPlainFoodQuery: Bool + + init(normalizedQuery: String) { + normalized = normalizedQuery + words = normalizedQuery.split(separator: " ").map(String.init) + stemmedWords = words.map { SearchTextMatcher.stemWord($0) } + isPlainFoodQuery = words.count <= 2 && words.allSatisfy { $0.allSatisfy(\.isLetter) } + } + } + + /// Returns normalized query text plus precomputed word/stem metadata. + static func queryMatch(for query: String) -> QueryMatch { + QueryMatch(normalizedQuery: normalizedQuery(query)) + } + /// Normalizes user-entered query text and applies known aliases. static func normalizedQuery(_ text: String) -> String { applyQueryAliases(normalizeForSearch(text)) @@ -74,13 +95,17 @@ enum SearchTextMatcher { /// Returns true when every normalized query word matches the term either /// directly or through the rough plural stem. static func matchesAllQueryWords(term: String, normalizedQuery: String) -> Bool { - guard !normalizedQuery.isEmpty else { return false } - let words = normalizedQuery.split(separator: " ").map(String.init) - let stemmedWords = words.map { stemWord($0) } + matchesAllQueryWords(term: term, queryMatch: QueryMatch(normalizedQuery: normalizedQuery)) + } + + /// Returns true when every precomputed query word matches the term either + /// directly or through the rough plural stem. + static func matchesAllQueryWords(term: String, queryMatch: QueryMatch) -> Bool { + guard !queryMatch.normalized.isEmpty else { return false } let normalizedTerm = normalizeForSearch(term) let stemmedTerm = stemmedForm(normalizedTerm) - return words.allSatisfy { normalizedTerm.contains($0) } || - stemmedWords.allSatisfy { stemmedTerm.contains($0) } + return queryMatch.words.allSatisfy { normalizedTerm.contains($0) } || + queryMatch.stemmedWords.allSatisfy { stemmedTerm.contains($0) } } /// Levenshtein edit distance between two strings. Used for typo tolerance @@ -107,18 +132,24 @@ enum SearchTextMatcher { /// Lower = better match. Supports stem-aware comparison so "strawberry" /// matches "strawberries" at full quality. static func matchScore(name: String, query q: String) -> Int { + matchScore(name: name, queryMatch: QueryMatch(normalizedQuery: q)) + } + + /// Scores how closely `name` matches a precomputed query. Lower = better. + static func matchScore(name: String, queryMatch: QueryMatch) -> Int { + let q = queryMatch.normalized let n = normalizeForSearch(name) if n == q { return 0 } let nStemmed = stemmedForm(n) - let qStemmed = stemmedForm(q) + let qStemmed = queryMatch.stemmedWords.joined(separator: " ") if nStemmed == qStemmed { return 0 } if n.hasPrefix(q) || nStemmed.hasPrefix(qStemmed) { return 1 } - let qWords = q.split(separator: " ").map(String.init) + let qWords = queryMatch.words let nWords = n.split(separator: " ").map(String.init) - let qStems = qWords.map { stemWord($0) } + let qStems = queryMatch.stemmedWords let nStems = nWords.map { stemWord($0) } if qWords.count <= 1 { @@ -146,8 +177,7 @@ enum SearchTextMatcher { } static func isPlainFoodQuery(_ normalizedQuery: String) -> Bool { - let words = normalizedQuery.split(separator: " ").map(String.init) - return words.count <= 2 && words.allSatisfy { $0.allSatisfy(\.isLetter) } + QueryMatch(normalizedQuery: normalizedQuery).isPlainFoodQuery } /// Words that indicate a dessert or processed item. When the user's query @@ -169,25 +199,26 @@ enum SearchTextMatcher { /// match quality. This is the former SearchView type-ahead behavior as a /// pure helper so it can be unit-tested without UI state. static func suggestions(for query: String, in suggestionPool: [String], limit: Int = 6) -> [String] { - let normalized = normalizedQuery(query) - guard normalized.count >= 1 else { return [] } + suggestions(for: queryMatch(for: query), in: suggestionPool, limit: limit) + } - let words = normalized.split(separator: " ").map(String.init) - let penalizeDesserts = isPlainFoodQuery(normalized) + /// Returns up to `limit` suggestion terms for a precomputed query match. + static func suggestions(for queryMatch: QueryMatch, in suggestionPool: [String], limit: Int = 6) -> [String] { + guard queryMatch.normalized.count >= 1 else { return [] } // Substring + stem matching (primary) var matches = suggestionPool.filter { term in - matchesAllQueryWords(term: term, normalizedQuery: normalized) + matchesAllQueryWords(term: term, queryMatch: queryMatch) } // Fuzzy fallback: if fewer than 3 substring matches, try edit distance // on each word of the food name. Only for queries >= 3 chars to avoid // noise on very short inputs. - if matches.count < 3 && normalized.count >= 3 { + if matches.count < 3 && queryMatch.normalized.count >= 3 { let fuzzy = suggestionPool.filter { term in guard !matches.contains(term) else { return false } let nWords = normalizeForSearch(term).split(separator: " ").map(String.init) - return words.allSatisfy { qw in + return queryMatch.words.allSatisfy { qw in nWords.contains { nw in // Allow edit distance <= 2, but scale: for short words (<=4 chars) only allow 1 let maxDist = qw.count <= 4 ? 1 : 2 @@ -201,9 +232,9 @@ enum SearchTextMatcher { return matches .sorted { a, b in - var sa = matchScore(name: a, query: normalized) - var sb = matchScore(name: b, query: normalized) - if penalizeDesserts { + var sa = matchScore(name: a, queryMatch: queryMatch) + var sb = matchScore(name: b, queryMatch: queryMatch) + if queryMatch.isPlainFoodQuery { let na = normalizeForSearch(a) let nb = normalizeForSearch(b) if isDessertOrProcessed(na) { sa += 1 } diff --git a/AkFit/AkFit/Search/SupabaseFoodSearchService.swift b/AkFit/AkFit/Search/SupabaseFoodSearchService.swift index b0d108e..611972f 100644 --- a/AkFit/AkFit/Search/SupabaseFoodSearchService.swift +++ b/AkFit/AkFit/Search/SupabaseFoodSearchService.swift @@ -17,10 +17,11 @@ struct SupabaseFoodSearchService: FoodSearchService { // Normalize the query the same way the DB search_text column is normalized: // remove apostrophes, replace hyphens with spaces, collapse whitespace. // Then apply known aliases (e.g. "innout" → "in n out"). - let normalized = SearchTextMatcher.normalizedQuery(q) + let queryMatch = SearchTextMatcher.queryMatch(for: q) + let normalized = queryMatch.normalized guard !normalized.isEmpty else { return [] } - let words = normalized.split(separator: " ").map(String.init) + let words = queryMatch.words do { let rows: [GenericFoodRow] @@ -52,7 +53,7 @@ struct SupabaseFoodSearchService: FoodSearchService { rows = candidates.filter { row in SearchTextMatcher.matchesAllQueryWords( term: row.foodName, - normalizedQuery: normalized + queryMatch: queryMatch ) } } @@ -60,11 +61,10 @@ struct SupabaseFoodSearchService: FoodSearchService { // Re-rank client-side by match quality so exact/prefix matches surface // before weaker substring hits. Dessert/processed items get a penalty // so whole foods rank above them for plain queries like "strawberry". - let isPlainFoodQuery = SearchTextMatcher.isPlainFoodQuery(normalized) return rows.map(FoodItem.init).sorted { a, b in - var sa = SearchTextMatcher.matchScore(name: a.name, query: normalized) - var sb = SearchTextMatcher.matchScore(name: b.name, query: normalized) - if isPlainFoodQuery { + var sa = SearchTextMatcher.matchScore(name: a.name, queryMatch: queryMatch) + var sb = SearchTextMatcher.matchScore(name: b.name, queryMatch: queryMatch) + if queryMatch.isPlainFoodQuery { if SearchTextMatcher.isDessertOrProcessed(SearchTextMatcher.normalizeForSearch(a.name)) { sa += 1 } if SearchTextMatcher.isDessertOrProcessed(SearchTextMatcher.normalizeForSearch(b.name)) { sb += 1 } } diff --git a/AkFit/AkFit/Views/Search/SearchView.swift b/AkFit/AkFit/Views/Search/SearchView.swift index 117639a..5522e0e 100644 --- a/AkFit/AkFit/Views/Search/SearchView.swift +++ b/AkFit/AkFit/Views/Search/SearchView.swift @@ -579,7 +579,8 @@ struct SearchView: View { /// names). Supports multi-word queries, stem-aware matching ("strawberry" /// finds "Strawberries"), and fuzzy fallback for typos. private func matchingSuggestions(for query: String) -> [String] { - SearchTextMatcher.suggestions(for: query, in: suggestionPool) + let queryMatch = SearchTextMatcher.queryMatch(for: query) + return SearchTextMatcher.suggestions(for: queryMatch, in: suggestionPool) } // MARK: - Search logic diff --git a/AkFit/AkFitTests/SearchTextMatcherTests.swift b/AkFit/AkFitTests/SearchTextMatcherTests.swift index 1ef218d..9fa8f69 100644 --- a/AkFit/AkFitTests/SearchTextMatcherTests.swift +++ b/AkFit/AkFitTests/SearchTextMatcherTests.swift @@ -47,6 +47,36 @@ struct SearchTextMatcherTests { #expect(matches.contains("Strawberry Milkshake")) } + @Test func precomputedQueryMatch_preservesWordMatchingBehavior() { + let normalized = SearchTextMatcher.normalizedQuery("strawberry") + let queryMatch = SearchTextMatcher.QueryMatch(normalizedQuery: normalized) + + #expect( + SearchTextMatcher.matchesAllQueryWords(term: "Strawberries", queryMatch: queryMatch) == + SearchTextMatcher.matchesAllQueryWords(term: "Strawberries", normalizedQuery: normalized) + ) + #expect( + SearchTextMatcher.matchesAllQueryWords(term: "Blueberries", queryMatch: queryMatch) == + SearchTextMatcher.matchesAllQueryWords(term: "Blueberries", normalizedQuery: normalized) + ) + } + + @Test func precomputedQueryMatch_preservesSuggestionResults() { + let pool = [ + "Chicken Breast, cooked", + "Chick-fil-A Chicken Sandwich", + "In-N-Out Burger", + "Strawberries", + "Strawberry Milkshake", + ] + let queryMatch = SearchTextMatcher.queryMatch(for: "strawberry") + + #expect( + SearchTextMatcher.suggestions(for: queryMatch, in: pool) == + SearchTextMatcher.suggestions(for: "strawberry", in: pool) + ) + } + @Test func suggestions_rankWholeFoodBeforeDessertForPlainQuery() { let pool = [ "Strawberry Milkshake",