diff --git a/Cotabby/App/Coordinators/EmojiPickerController.swift b/Cotabby/App/Coordinators/EmojiPickerController.swift index b31fb007..6a89fe5d 100644 --- a/Cotabby/App/Coordinators/EmojiPickerController.swift +++ b/Cotabby/App/Coordinators/EmojiPickerController.swift @@ -18,7 +18,19 @@ import Logging @MainActor final class EmojiPickerController { private var machine = EmojiTriggerStateMachine() - private let matcher: EmojiMatcher + /// Built on first use rather than injected ready-made: the bundled catalog decode plus index + /// costs a few MB of resident strings, and most sessions never open the picker. The one-time + /// build lands on the first `:` capture's first match request, which is user-paced. + private let matcherProvider: @MainActor () -> EmojiMatcher + private var cachedMatcher: EmojiMatcher? + private var matcher: EmojiMatcher { + if let cachedMatcher { + return cachedMatcher + } + let built = matcherProvider() + cachedMatcher = built + return built + } private let panel: any EmojiPickerPanelPresenting private let focusModel: any SuggestionFocusProviding private let inputMonitor: any EmojiInputIntercepting @@ -66,7 +78,7 @@ final class EmojiPickerController { private static let longPauseNanoseconds: UInt64 = 8_000_000_000 init( - matcher: EmojiMatcher, + matcherProvider: @MainActor @escaping () -> EmojiMatcher, panel: any EmojiPickerPanelPresenting, focusModel: any SuggestionFocusProviding, inputMonitor: any EmojiInputIntercepting, @@ -77,7 +89,7 @@ final class EmojiPickerController { emojiUsage: @MainActor @escaping () -> EmojiUsageSnapshot, recordEmojiUsage: @MainActor @escaping (String) -> Void ) { - self.matcher = matcher + self.matcherProvider = matcherProvider self.panel = panel self.focusModel = focusModel self.inputMonitor = inputMonitor diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index 4eecdc9c..9eed835a 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -188,16 +188,14 @@ final class CotabbyAppEnvironment { // Constructed once at app scope so the underlying `NSSpellChecker` document tag survives // across coordinator state transitions instead of churning per keystroke. let spellChecker = CurrentWordSpellChecker() - let enabledSpellingLanguages = SpellingDictionaryCatalog.languages( - for: suggestionSettings.enabledSpellingDictionaryCodes - ) - // Preserve the existing warm English path when it is enabled. A sole non-English choice is - // also preloaded; broader multilingual sets stay lazy so app launch never builds every index. - let preloadSpellingLanguage = enabledSpellingLanguages.count == 1 - ? enabledSpellingLanguages.first - : enabledSpellingLanguages.first(where: { $0 == .english }) + // No launch-time preload: a fully built index costs tens of MB resident for a feature that + // is only consulted once the typo gate actually finds a misspelling, and the first + // consultation triggers the same background build (`cachedIndexOrRequestLoad`) with the + // designed NSSpellChecker fallback ranking corrections until it lands. The only cost of + // staying cold is that the first correction or two after launch rank via the system + // checker instead of corpus frequency. let symSpellCorrector = SymSpellCorrector( - preloadLanguage: preloadSpellingLanguage + preloadLanguage: nil ) let suggestionCoordinator = SuggestionCoordinator( permissionManager: permissionManager, @@ -221,7 +219,9 @@ final class CotabbyAppEnvironment { // The emoji picker is a sibling to the suggestion coordinator. It reuses the input monitor, // focus model, and inserter, but owns its own trigger state machine and floating panel. let emojiPickerController = EmojiPickerController( - matcher: EmojiMatcher(catalog: EmojiCatalog.bundled()), + // Deferred: decoding and indexing the bundled emoji catalog costs a few MB of resident + // strings that most sessions never use; the picker builds it on first `:` capture. + matcherProvider: { EmojiMatcher(catalog: EmojiCatalog.bundled()) }, panel: EmojiPickerPanelController(), focusModel: focusModel, inputMonitor: inputMonitor, diff --git a/Cotabby/Models/EmojiUsageStore.swift b/Cotabby/Models/EmojiUsageStore.swift index edf43157..3947549e 100644 --- a/Cotabby/Models/EmojiUsageStore.swift +++ b/Cotabby/Models/EmojiUsageStore.swift @@ -35,6 +35,12 @@ final class EmojiUsageStore { /// Cap on stored recents: ample for the panel (which shows ~24) while keeping the persisted blob /// small. Older aliases fall off the end as new emoji are committed. private static let recentsCap = 50 + /// Bounds for the frequency map, which previously grew one entry per unique emoji forever. + /// Frequency only breaks ties inside a relevance tier, so trimming the rarest entries cannot + /// change which emoji match a query. Trimming triggers above `frequencyCap` and cuts down to + /// `frequencyTrimTarget` so steady use does not re-sort the map on every commit. + private static let frequencyCap = 300 + private static let frequencyTrimTarget = 200 private static let storageKey = "cotabbyEmojiUsage" private struct Persisted: Codable { @@ -68,9 +74,31 @@ final class EmojiUsageStore { recents.removeLast(recents.count - Self.recentsCap) } frequency[alias, default: 0] += 1 + trimFrequencyIfNeeded() persist() } + /// Drops the least-used aliases once the map outgrows its cap, keeping current recents so a + /// just-used emoji can never lose its favorite ranking to the trim. + private func trimFrequencyIfNeeded() { + guard frequency.count > Self.frequencyCap else { + return + } + + let keepAlways = Set(recents) + let removable = frequency + .filter { !keepAlways.contains($0.key) } + .sorted { $0.value < $1.value } + // The effective floor is the larger of the trim target and the protected recents, so the + // bound holds by construction even if `recentsCap` is ever raised past + // `frequencyTrimTarget` instead of silently under-trimming. + let effectiveTarget = max(Self.frequencyTrimTarget, keepAlways.count) + let overflow = frequency.count - effectiveTarget + for (alias, _) in removable.prefix(overflow) { + frequency.removeValue(forKey: alias) + } + } + /// Immutable snapshot for the pure ranker and recents helper. func snapshot() -> EmojiUsageSnapshot { EmojiUsageSnapshot(recentAliases: recents, frequency: frequency) diff --git a/CotabbyTests/EmojiPickerControllerTests.swift b/CotabbyTests/EmojiPickerControllerTests.swift index 8b1f036f..edd8613e 100644 --- a/CotabbyTests/EmojiPickerControllerTests.swift +++ b/CotabbyTests/EmojiPickerControllerTests.swift @@ -127,7 +127,7 @@ final class EmojiPickerControllerTests: XCTestCase { let usageRecorder = UsageRecorder() usage = usageRecorder controller = EmojiPickerController( - matcher: EmojiMatcher(catalog: catalog), + matcherProvider: { EmojiMatcher(catalog: catalog) }, panel: panel, focusModel: focus, inputMonitor: monitor, diff --git a/CotabbyTests/EmojiUsageStoreTests.swift b/CotabbyTests/EmojiUsageStoreTests.swift index 8b27528a..f5085a3d 100644 --- a/CotabbyTests/EmojiUsageStoreTests.swift +++ b/CotabbyTests/EmojiUsageStoreTests.swift @@ -56,6 +56,35 @@ final class EmojiUsageStoreTests: XCTestCase { } } + func test_frequencyIsBoundedAndKeepsHeavyHitters() { + runOnMainActor { + let sut = EmojiUsageStore(defaults: InMemoryDefaults()) + // A heavy favorite that must survive trimming even after it falls out of recents. + for _ in 0..<10 { + sut.record(alias: "joy") + } + // Flood with one-shot unique aliases to push the map past its cap several times over. + for index in 0..<320 { + sut.record(alias: "flood\(index)") + } + + let snapshot = sut.snapshot() + // Exactly 220: the 301st unique alias trips the cap and trims down to the 200 target, + // and the remaining 20 flood inserts land afterwards. An exact bound catches both a + // trim that never fires and one that removes fewer entries than the target demands. + XCTAssertEqual( + snapshot.frequency.count, + 220, + "The frequency map must trim to its target instead of growing one entry per unique emoji forever." + ) + XCTAssertEqual( + snapshot.frequency["joy"], + 10, + "Trimming removes the rarest entries; heavy hitters keep their counts." + ) + } + } + func test_clearForgetsEverything() { runOnMainActor { let sut = EmojiUsageStore(defaults: InMemoryDefaults())