Skip to content

Commit e15ab77

Browse files
authored
Fixes bug with suggestion case learning (#584) (#585)
1 parent f88c49d commit e15ab77

6 files changed

Lines changed: 424 additions & 20 deletions

File tree

app/src/main/java/com/urik/keyboard/UrikInputMethodService.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1820,7 +1820,7 @@ class UrikInputMethodService :
18201820
try {
18211821
autoCapitalizePronounI()
18221822
learnWordAndInvalidateCache(
1823-
wordState.normalizedBuffer,
1823+
wordState.buffer,
18241824
InputMethod.TYPED,
18251825
)
18261826
currentInputConnection?.finishComposingText()
@@ -1841,7 +1841,7 @@ class UrikInputMethodService :
18411841
return@launch
18421842
} else {
18431843
spellConfirmationState = SpellConfirmationState.AWAITING_CONFIRMATION
1844-
pendingWordForLearning = wordState.normalizedBuffer
1844+
pendingWordForLearning = wordState.buffer
18451845
highlightCurrentWord()
18461846

18471847
val suggestions = textInputProcessor.getSuggestions(wordState.normalizedBuffer)
@@ -1954,7 +1954,7 @@ class UrikInputMethodService :
19541954
if (result.shouldHighlight) {
19551955
spellConfirmationState =
19561956
SpellConfirmationState.AWAITING_CONFIRMATION
1957-
pendingWordForLearning = result.wordState.normalizedBuffer
1957+
pendingWordForLearning = result.wordState.buffer
19581958
highlightCurrentWord()
19591959
}
19601960
}
@@ -2746,7 +2746,7 @@ class UrikInputMethodService :
27462746
textInputProcessor.getSuggestions(wordState.normalizedBuffer)
27472747

27482748
spellConfirmationState = SpellConfirmationState.AWAITING_CONFIRMATION
2749-
pendingWordForLearning = wordState.normalizedBuffer
2749+
pendingWordForLearning = wordState.buffer
27502750

27512751
highlightCurrentWord()
27522752
val displaySuggestions = applyCapitalizationToSuggestions(suggestions, isCurrentWordAtSentenceStart)

app/src/main/java/com/urik/keyboard/data/database/LearnedWord.kt

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@ data class LearnedWord(
5454
@ColumnInfo(name = "last_used")
5555
val lastUsed: Long = System.currentTimeMillis(),
5656
) {
57+
/**
58+
* Returns new instance with incremented frequency and updated timestamp.
59+
*/
60+
fun incrementFrequency(): LearnedWord =
61+
copy(
62+
frequency = frequency + 1,
63+
lastUsed = System.currentTimeMillis(),
64+
)
65+
66+
/**
67+
* Increments frequency and promotes casing when the new variant
68+
* demonstrates higher intentionality than the stored form.
69+
*
70+
*/
71+
fun updateCasingIfPreferred(newWord: String): LearnedWord {
72+
val updated = incrementFrequency()
73+
if (word == newWord) return updated
74+
return if (casingIntentScore(newWord) > casingIntentScore(word)) {
75+
updated.copy(word = newWord)
76+
} else {
77+
updated
78+
}
79+
}
80+
5781
companion object {
5882
/**
5983
* Creates LearnedWord with calculated grapheme count.
@@ -82,9 +106,6 @@ data class LearnedWord(
82106
lastUsed = lastUsed,
83107
)
84108

85-
/**
86-
* Calculates visible character count using ICU4J grapheme clusters
87-
*/
88109
fun calculateGraphemeCount(text: String): Int {
89110
val iterator = BreakIterator.getCharacterInstance()
90111
iterator.setText(text)
@@ -94,16 +115,21 @@ data class LearnedWord(
94115
}
95116
return count
96117
}
97-
}
98118

99-
/**
100-
* Returns new instance with incremented frequency and updated timestamp.
101-
*/
102-
fun incrementFrequency(): LearnedWord =
103-
copy(
104-
frequency = frequency + 1,
105-
lastUsed = System.currentTimeMillis(),
106-
)
119+
fun casingIntentScore(word: String): Int {
120+
if (word.isEmpty()) return 0
121+
val hasUpper = word.any { it.isUpperCase() }
122+
val hasLower = word.any { it.isLowerCase() }
123+
return when {
124+
!hasUpper -> 0
125+
!hasLower && word.length <= 3 -> 3
126+
!hasLower -> 1
127+
word.drop(1).any { it.isUpperCase() } -> 4
128+
word[0].isUpperCase() -> 2
129+
else -> 0
130+
}
131+
}
132+
}
107133
}
108134

109135
/**

app/src/main/java/com/urik/keyboard/data/database/LearnedWordDao.kt

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,17 +183,18 @@ interface LearnedWordDao {
183183
suspend fun upsertWord(word: LearnedWord): Long
184184

185185
/**
186-
* Learns word with frequency tracking and FTS synchronization.
186+
* Learns word with frequency tracking and casing promotion.
187187
*
188-
* If word exists: increments frequency, updates timestamp
189-
* If new: inserts with frequency=1
188+
* If word exists: increments frequency, promotes casing when the new
189+
* variant has higher intentionality (e.g., "iPhone" over "iphone").
190+
* If new: inserts with frequency=1.
190191
* FTS table automatically synchronized in transaction.
191192
*/
192193
@Transaction
193194
suspend fun learnWord(word: LearnedWord) {
194195
val existing = findExactWord(word.languageTag, word.wordNormalized)
195196
if (existing != null) {
196-
val updated = existing.incrementFrequency()
197+
val updated = existing.updateCasingIfPreferred(word.word)
197198
updateWord(updated)
198199
} else {
199200
insertWord(word)
@@ -274,8 +275,15 @@ interface LearnedWordDao {
274275
suspend fun importWordWithMerge(word: LearnedWord) {
275276
val existing = findExactWord(word.languageTag, word.wordNormalized)
276277
if (existing != null) {
278+
val preferredWord =
279+
if (LearnedWord.casingIntentScore(word.word) > LearnedWord.casingIntentScore(existing.word)) {
280+
word.word
281+
} else {
282+
existing.word
283+
}
277284
val merged =
278285
existing.copy(
286+
word = preferredWord,
279287
frequency = existing.frequency + word.frequency,
280288
lastUsed = maxOf(existing.lastUsed, word.lastUsed),
281289
)

app/src/test/java/com/urik/keyboard/data/database/LearnedWordDaoTest.kt

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,220 @@ class LearnedWordDaoTest {
420420
assertEquals(1, swipedCount)
421421
}
422422

423+
@Test
424+
fun `learnWord promotes camelCase over lowercase`() =
425+
runTest {
426+
dao.learnWord(createTestWord("iphone", "iphone", "en"))
427+
dao.learnWord(createTestWord("iPhone", "iphone", "en"))
428+
429+
val retrieved = dao.findExactWord("en", "iphone")
430+
assertEquals("iPhone", retrieved?.word)
431+
assertEquals(2, retrieved?.frequency)
432+
}
433+
434+
@Test
435+
fun `learnWord promotes title case over lowercase`() =
436+
runTest {
437+
dao.learnWord(createTestWord("maryland", "maryland", "en"))
438+
dao.learnWord(createTestWord("Maryland", "maryland", "en"))
439+
440+
val retrieved = dao.findExactWord("en", "maryland")
441+
assertEquals("Maryland", retrieved?.word)
442+
assertEquals(2, retrieved?.frequency)
443+
}
444+
445+
@Test
446+
fun `learnWord does not demote camelCase to lowercase`() =
447+
runTest {
448+
dao.learnWord(createTestWord("iPhone", "iphone", "en"))
449+
dao.learnWord(createTestWord("iphone", "iphone", "en"))
450+
451+
val retrieved = dao.findExactWord("en", "iphone")
452+
assertEquals("iPhone", retrieved?.word)
453+
assertEquals(2, retrieved?.frequency)
454+
}
455+
456+
@Test
457+
fun `learnWord does not demote McLaren to lowercase`() =
458+
runTest {
459+
dao.learnWord(createTestWord("McLaren", "mclaren", "en"))
460+
repeat(5) {
461+
dao.learnWord(createTestWord("mclaren", "mclaren", "en"))
462+
}
463+
464+
val retrieved = dao.findExactWord("en", "mclaren")
465+
assertEquals("McLaren", retrieved?.word)
466+
assertEquals(6, retrieved?.frequency)
467+
}
468+
469+
@Test
470+
fun `learnWord promotes short acronym over lowercase`() =
471+
runTest {
472+
dao.learnWord(createTestWord("api", "api", "en"))
473+
dao.learnWord(createTestWord("API", "api", "en"))
474+
475+
val retrieved = dao.findExactWord("en", "api")
476+
assertEquals("API", retrieved?.word)
477+
}
478+
479+
@Test
480+
fun `learnWord promotes long ALL CAPS over lowercase`() =
481+
runTest {
482+
dao.learnWord(createTestWord("guhh", "guhh", "en"))
483+
dao.learnWord(createTestWord("GUHH", "guhh", "en"))
484+
485+
val retrieved = dao.findExactWord("en", "guhh")
486+
assertEquals("GUHH", retrieved?.word)
487+
}
488+
489+
@Test
490+
fun `learnWord does not demote ALL CAPS to lowercase`() =
491+
runTest {
492+
dao.learnWord(createTestWord("NASA", "nasa", "en"))
493+
dao.learnWord(createTestWord("nasa", "nasa", "en"))
494+
495+
val retrieved = dao.findExactWord("en", "nasa")
496+
assertEquals("NASA", retrieved?.word)
497+
}
498+
499+
@Test
500+
fun `learnWord promotes camelCase over ALL CAPS`() =
501+
runTest {
502+
dao.learnWord(createTestWord("IPHONE", "iphone", "en"))
503+
dao.learnWord(createTestWord("iPhone", "iphone", "en"))
504+
505+
val retrieved = dao.findExactWord("en", "iphone")
506+
assertEquals("iPhone", retrieved?.word)
507+
}
508+
509+
@Test
510+
fun `learnWord promotes camelCase over title case`() =
511+
runTest {
512+
dao.learnWord(createTestWord("Iphone", "iphone", "en"))
513+
dao.learnWord(createTestWord("iPhone", "iphone", "en"))
514+
515+
val retrieved = dao.findExactWord("en", "iphone")
516+
assertEquals("iPhone", retrieved?.word)
517+
}
518+
519+
@Test
520+
fun `learnWord keeps same casing stable across repeated learns`() =
521+
runTest {
522+
repeat(10) {
523+
dao.learnWord(createTestWord("hello", "hello", "en"))
524+
}
525+
526+
val retrieved = dao.findExactWord("en", "hello")
527+
assertEquals("hello", retrieved?.word)
528+
assertEquals(10, retrieved?.frequency)
529+
}
530+
531+
@Test
532+
fun `importWordWithMerge promotes camelCase casing`() =
533+
runTest {
534+
dao.insertWord(createTestWord("iphone", "iphone", "en", frequency = 50))
535+
536+
val imported = createTestWord("iPhone", "iphone", "en", frequency = 3)
537+
dao.importWordWithMerge(imported)
538+
539+
val retrieved = dao.findExactWord("en", "iphone")
540+
assertEquals("iPhone", retrieved?.word)
541+
assertEquals(53, retrieved?.frequency)
542+
}
543+
544+
@Test
545+
fun `casingIntentScore scores lowercase as 0`() {
546+
assertEquals(0, LearnedWord.casingIntentScore("hello"))
547+
assertEquals(0, LearnedWord.casingIntentScore("iphone"))
548+
assertEquals(0, LearnedWord.casingIntentScore("fasty"))
549+
}
550+
551+
@Test
552+
fun `casingIntentScore scores long ALL CAPS as 1`() {
553+
assertEquals(1, LearnedWord.casingIntentScore("HELLO"))
554+
assertEquals(1, LearnedWord.casingIntentScore("GUHH"))
555+
assertEquals(1, LearnedWord.casingIntentScore("FASTY"))
556+
}
557+
558+
@Test
559+
fun `casingIntentScore scores title case as 2`() {
560+
assertEquals(2, LearnedWord.casingIntentScore("Maryland"))
561+
assertEquals(2, LearnedWord.casingIntentScore("Google"))
562+
assertEquals(2, LearnedWord.casingIntentScore("Hello"))
563+
}
564+
565+
@Test
566+
fun `casingIntentScore scores short acronym as 3`() {
567+
assertEquals(3, LearnedWord.casingIntentScore("API"))
568+
assertEquals(3, LearnedWord.casingIntentScore("US"))
569+
assertEquals(3, LearnedWord.casingIntentScore("UK"))
570+
}
571+
572+
@Test
573+
fun `casingIntentScore scores mixed case as 4`() {
574+
assertEquals(4, LearnedWord.casingIntentScore("iPhone"))
575+
assertEquals(4, LearnedWord.casingIntentScore("McLaren"))
576+
assertEquals(4, LearnedWord.casingIntentScore("myAPI"))
577+
assertEquals(4, LearnedWord.casingIntentScore("DeepMind"))
578+
}
579+
580+
@Test
581+
fun `casingIntentScore returns 0 for empty string`() {
582+
assertEquals(0, LearnedWord.casingIntentScore(""))
583+
}
584+
585+
@Test
586+
fun `casingIntentScore hierarchy mixed beats title beats lowercase`() {
587+
val lowercase = LearnedWord.casingIntentScore("iphone")
588+
val allCaps = LearnedWord.casingIntentScore("IPHONE")
589+
val titleCase = LearnedWord.casingIntentScore("Iphone")
590+
val mixedCase = LearnedWord.casingIntentScore("iPhone")
591+
592+
assertTrue(mixedCase > titleCase)
593+
assertTrue(titleCase > allCaps)
594+
assertTrue(allCaps > lowercase)
595+
}
596+
597+
@Test
598+
fun `updateCasingIfPreferred promotes to higher intent`() {
599+
val word = createTestWord("iphone", "iphone", "en", frequency = 10)
600+
val updated = word.updateCasingIfPreferred("iPhone")
601+
602+
assertEquals("iPhone", updated.word)
603+
assertEquals(11, updated.frequency)
604+
}
605+
606+
@Test
607+
fun `updateCasingIfPreferred does not demote to lower intent`() {
608+
val word = createTestWord("iPhone", "iphone", "en", frequency = 10)
609+
val updated = word.updateCasingIfPreferred("iphone")
610+
611+
assertEquals("iPhone", updated.word)
612+
assertEquals(11, updated.frequency)
613+
}
614+
615+
@Test
616+
fun `updateCasingIfPreferred keeps same casing`() {
617+
val word = createTestWord("iPhone", "iphone", "en", frequency = 10)
618+
val updated = word.updateCasingIfPreferred("iPhone")
619+
620+
assertEquals("iPhone", updated.word)
621+
assertEquals(11, updated.frequency)
622+
}
623+
624+
@Test
625+
fun `importWordWithMerge does not demote camelCase`() =
626+
runTest {
627+
dao.insertWord(createTestWord("iPhone", "iphone", "en", frequency = 50))
628+
629+
val imported = createTestWord("iphone", "iphone", "en", frequency = 100)
630+
dao.importWordWithMerge(imported)
631+
632+
val retrieved = dao.findExactWord("en", "iphone")
633+
assertEquals("iPhone", retrieved?.word)
634+
assertEquals(150, retrieved?.frequency)
635+
}
636+
423637
/**
424638
* Creates test word with defaults for frequency, source, and timestamps.
425639
*/

0 commit comments

Comments
 (0)