diff --git a/app/src/main/java/com/urik/keyboard/ui/keyboard/components/ResidualScorer.kt b/app/src/main/java/com/urik/keyboard/ui/keyboard/components/ResidualScorer.kt index b6decb1..bc83bd2 100644 --- a/app/src/main/java/com/urik/keyboard/ui/keyboard/components/ResidualScorer.kt +++ b/app/src/main/java/com/urik/keyboard/ui/keyboard/components/ResidualScorer.kt @@ -75,6 +75,25 @@ class ResidualScorer const val FREQ_TIER_TOP100_BOOST = 1.60f const val FREQ_TIER_TOP1000_BOOST = 1.35f const val FREQ_TIER_TOP5000_BOOST = 1.15f + const val SHORT_WORD_FIDELITY_BONUS_4 = 1.08f + const val SHORT_WORD_FIDELITY_BONUS_3 = 1.12f + const val SHORT_WORD_FIDELITY_BONUS_2 = 1.15f + const val SHORT_WORD_FIDELITY_MIN_RATIO_QUALITY = 0.90f + const val SHORT_WORD_SPATIAL_WEIGHT = 0.85f + const val SHORT_WORD_FREQ_WEIGHT = 0.15f + const val SHORT_WORD_SPATIAL_THRESHOLD = 0.60f + const val SHORT_PATH_DAMPENER_MIN_WORD_LENGTH = 4 + const val SHORT_PATH_DAMPENER_MIN_EXPECTED_PX = 100f + const val SHORT_PATH_DAMPENER_MILD_RATIO = 0.50f + const val SHORT_PATH_DAMPENER_MODERATE_RATIO = 0.35f + const val SHORT_PATH_DAMPENER_SEVERE_RATIO = 0.20f + const val SHORT_PATH_DAMPENER_MILD_PENALTY = 0.85f + const val SHORT_PATH_DAMPENER_MODERATE_PENALTY = 0.60f + const val SHORT_PATH_DAMPENER_SEVERE_PENALTY = 0.35f + + const val REPEATED_LETTER_NO_DWELL_ATTENUATION = 0.40f + const val NON_CONSECUTIVE_REVISIT_MIN_SPAN = 3 + } data class CandidateResult( @@ -132,7 +151,7 @@ class ResidualScorer val isClusteredWord = pathGeometryAnalyzer.isClusteredWord(entry.word, keyPositions) val pointsPerLetter = signal.rawPointCount.toFloat() / entry.word.length.toFloat() - val optimalRatio = if (entry.word.length <= 3) 3.0f else 4.0f + val optimalRatio = if (entry.word.length <= 3) 6.0f else 4.0f val ratioQuality = pointsPerLetter / optimalRatio val spatialScore = calculateGeometricSpatialScore( @@ -249,6 +268,9 @@ class ResidualScorer val pathResidualPenalty = calculatePathResidualPenalty( signal.pathLength, expectedPathLen, ) + val shortPathDampener = calculateShortPathDampener( + signal.pathLength, expectedPathLen, entry.word.length, isClusteredWord, + ) val passthroughPenalty = calculatePassthroughPenalty(entry.word, signal) @@ -260,7 +282,7 @@ class ResidualScorer traversalPenalty * orderPenalty * vertexLengthPenalty * pathCoherenceMultiplier * boundsPenalty * pathLengthMultiplier * pathResidualPenalty * - passthroughPenalty + passthroughPenalty * shortPathDampener val residual = 1.0f - combinedScore.coerceIn(0f, 1f) @@ -450,11 +472,36 @@ class ResidualScorer letterScore = maxOf(letterScore, TRAVERSAL_FLOOR_SCORE) } - if (letterIndex > 0 && word[letterIndex] == word[letterIndex - 1]) { - val repeatedLetterBoost = pathGeometryAnalyzer.detectRepeatedLetterSignal( - swipePath, keyPos, reuseLetterPathIndices.lastOrNull() ?: 0, closestPointIndex, - ) - letterScore *= (1f + repeatedLetterBoost) + if (letterIndex > 0) { + val isConsecutiveRepeat = word[letterIndex] == word[letterIndex - 1] + var prevOccurrence = -1 + if (!isConsecutiveRepeat) { + for (k in letterIndex - 1 downTo 0) { + if (word[k].lowercaseChar() == lowerChar) { + prevOccurrence = k + break + } + } + } + if (isConsecutiveRepeat) { + val repeatedLetterBoost = pathGeometryAnalyzer.detectRepeatedLetterSignal( + swipePath, keyPos, reuseLetterPathIndices.lastOrNull() ?: 0, closestPointIndex, + ) + letterScore *= (1f + repeatedLetterBoost) + } else if (prevOccurrence >= 0) { + val prevPathIdx = reuseLetterPathIndices[prevOccurrence] + val pathSpan = kotlin.math.abs(closestPointIndex - prevPathIdx) + val repeatedLetterBoost = pathGeometryAnalyzer.detectRepeatedLetterSignal( + swipePath, keyPos, prevPathIdx, closestPointIndex, + ) + if (repeatedLetterBoost > 0f) { + letterScore *= (1f + repeatedLetterBoost) + } else if (pathSpan >= NON_CONSECUTIVE_REVISIT_MIN_SPAN) { + letterScore *= 1f + } else { + letterScore *= REPEATED_LETTER_NO_DWELL_ATTENUATION + } + } } reuseLetterPathIndices.add(closestPointIndex) @@ -657,15 +704,16 @@ class ResidualScorer private fun calculateLengthBonus( wordLength: Int, ratioQuality: Float, - ): Float { - if (ratioQuality < LENGTH_BONUS_MIN_RATIO_QUALITY) return 1.0f - return when { - wordLength >= 8 -> 1.25f - wordLength == 7 -> 1.18f - wordLength == 6 -> 1.12f - wordLength == 5 -> 1.06f - else -> 1.0f - } + ): Float = when { + ratioQuality >= SHORT_WORD_FIDELITY_MIN_RATIO_QUALITY && wordLength == 2 -> SHORT_WORD_FIDELITY_BONUS_2 + ratioQuality >= SHORT_WORD_FIDELITY_MIN_RATIO_QUALITY && wordLength == 3 -> SHORT_WORD_FIDELITY_BONUS_3 + ratioQuality >= SHORT_WORD_FIDELITY_MIN_RATIO_QUALITY && wordLength == 4 -> SHORT_WORD_FIDELITY_BONUS_4 + ratioQuality < LENGTH_BONUS_MIN_RATIO_QUALITY -> 1.0f + wordLength >= 8 -> 1.25f + wordLength == 7 -> 1.18f + wordLength == 6 -> 1.12f + wordLength == 5 -> 1.06f + else -> 1.0f } private fun calculateBoundsPenalty( @@ -731,6 +779,7 @@ class ResidualScorer } return when { entry.word.length == 2 && adjustedSpatialScore > 0.75f -> 0.88f to 0.12f + entry.word.length in 3..4 && adjustedSpatialScore >= SHORT_WORD_SPATIAL_THRESHOLD -> SHORT_WORD_SPATIAL_WEIGHT to SHORT_WORD_FREQ_WEIGHT frequencyRatio >= 10.0f -> minOf(baselineSpatialWeight, 0.50f) to maxOf(baselineFreqWeight, 0.50f) frequencyRatio >= 5.0f -> minOf(baselineSpatialWeight, 0.55f) to maxOf(baselineFreqWeight, 0.45f) frequencyRatio >= 3.0f -> minOf(baselineSpatialWeight, 0.58f) to maxOf(baselineFreqWeight, 0.42f) @@ -790,6 +839,24 @@ class ResidualScorer } } + private fun calculateShortPathDampener( + physicalPathLength: Float, + expectedWordPathLength: Float, + wordLength: Int, + isClusteredWord: Boolean, + ): Float { + if (wordLength < SHORT_PATH_DAMPENER_MIN_WORD_LENGTH) return 1.0f + if (isClusteredWord) return 1.0f + if (expectedWordPathLength < SHORT_PATH_DAMPENER_MIN_EXPECTED_PX) return 1.0f + val ratio = physicalPathLength / expectedWordPathLength + if (ratio >= SHORT_PATH_DAMPENER_MILD_RATIO) return 1.0f + return when { + ratio < SHORT_PATH_DAMPENER_SEVERE_RATIO -> SHORT_PATH_DAMPENER_SEVERE_PENALTY + ratio < SHORT_PATH_DAMPENER_MODERATE_RATIO -> SHORT_PATH_DAMPENER_MODERATE_PENALTY + else -> SHORT_PATH_DAMPENER_MILD_PENALTY + } + } + private fun calculatePassthroughPenalty(word: String, signal: SwipeSignal): Float { if (signal.passthroughKeys.isEmpty()) return 1.0f val intentionalKeys = signal.traversedKeys - signal.passthroughKeys diff --git a/app/src/main/java/com/urik/keyboard/ui/keyboard/components/StreamingScoringEngine.kt b/app/src/main/java/com/urik/keyboard/ui/keyboard/components/StreamingScoringEngine.kt index 01b2d0d..897110e 100644 --- a/app/src/main/java/com/urik/keyboard/ui/keyboard/components/StreamingScoringEngine.kt +++ b/app/src/main/java/com/urik/keyboard/ui/keyboard/components/StreamingScoringEngine.kt @@ -128,6 +128,7 @@ class StreamingScoringEngine if (currentKeyPositions.isEmpty()) return val elapsedMs = (System.nanoTime() - gestureStartTimeNanos) / 1_000_000L + val beforeSize = liveCandidates.size when { elapsedMs >= TRAVERSAL_PRUNE_MS && tickCount >= 3 -> { @@ -421,7 +422,7 @@ class StreamingScoringEngine private const val START_ANCHOR_RADIUS = 85f private const val BOUNDS_MARGIN = 60f private const val TRAVERSAL_RADIUS = 40f - private const val BOUNDS_SAFETY_MARGIN = 1 + private const val BOUNDS_SAFETY_MARGIN = 2 private const val TRAVERSAL_MIN_OVERLAP = 0.30f private const val LIVE_SET_CAPACITY = 50 private const val EXCELLENT_CANDIDATE_THRESHOLD = 0.95f diff --git a/app/src/main/java/com/urik/keyboard/ui/keyboard/components/SwipeDetector.kt b/app/src/main/java/com/urik/keyboard/ui/keyboard/components/SwipeDetector.kt index 5d547c5..a07f9cd 100644 --- a/app/src/main/java/com/urik/keyboard/ui/keyboard/components/SwipeDetector.kt +++ b/app/src/main/java/com/urik/keyboard/ui/keyboard/components/SwipeDetector.kt @@ -494,6 +494,13 @@ class SwipeDetector cachedTransformPoint.set(firstPointX, firstPointY) _swipeListener?.onSwipeStart(cachedTransformPoint) + + val buffered = ringBuffer.snapshot() + for (i in 1 until buffered.size) { + cachedTransformPoint.set(buffered[i].x, buffered[i].y) + _swipeListener?.onSwipeUpdate(cachedTransformPoint) + } + lastUpdateTime = System.currentTimeMillis() } } @@ -807,7 +814,7 @@ class SwipeDetector private const val SLOW_MOVEMENT_VELOCITY_THRESHOLD = 0.5f private const val UI_UPDATE_INTERVAL_MS = 16 private const val TAP_DURATION_THRESHOLD_MS = 350L - private const val MAX_SWIPE_VELOCITY_PX_PER_MS = 5f + private const val MAX_SWIPE_VELOCITY_PX_PER_MS = 9f private const val PECK_LATE_DISPLACEMENT_RATIO = 0.95f private const val HIGH_VELOCITY_DISTANCE_MULTIPLIER = 1.5f private const val GHOST_DENSITY_VELOCITY_GATE = 2.0f diff --git a/app/src/test/java/com/urik/keyboard/ui/keyboard/components/ResidualScorerShortWordTest.kt b/app/src/test/java/com/urik/keyboard/ui/keyboard/components/ResidualScorerShortWordTest.kt new file mode 100644 index 0000000..48ef011 --- /dev/null +++ b/app/src/test/java/com/urik/keyboard/ui/keyboard/components/ResidualScorerShortWordTest.kt @@ -0,0 +1,218 @@ +package com.urik.keyboard.ui.keyboard.components + +import android.graphics.PointF +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ResidualScorerShortWordTest { + + private lateinit var pathGeometryAnalyzer: PathGeometryAnalyzer + private lateinit var scorer: ResidualScorer + + private val colemakKeyPositions = mapOf( + 'q' to PointF(30f, 50f), 'w' to PointF(80f, 50f), 'f' to PointF(130f, 50f), + 'p' to PointF(180f, 50f), 'g' to PointF(230f, 50f), 'j' to PointF(280f, 50f), + 'l' to PointF(330f, 50f), 'u' to PointF(380f, 50f), 'y' to PointF(430f, 50f), + 'a' to PointF(40f, 130f), 'r' to PointF(90f, 130f), 's' to PointF(140f, 130f), + 't' to PointF(190f, 130f), 'd' to PointF(240f, 130f), 'h' to PointF(290f, 130f), + 'n' to PointF(340f, 130f), 'e' to PointF(390f, 130f), 'i' to PointF(440f, 130f), + 'o' to PointF(490f, 130f), + 'z' to PointF(90f, 210f), 'x' to PointF(140f, 210f), 'c' to PointF(190f, 210f), + 'v' to PointF(240f, 210f), 'b' to PointF(290f, 210f), 'k' to PointF(340f, 210f), + 'm' to PointF(390f, 210f), + ) + + @Before + fun setup() { + pathGeometryAnalyzer = PathGeometryAnalyzer() + scorer = ResidualScorer(pathGeometryAnalyzer) + } + + @Test + fun `short word aid scores higher than arrested on short Colemak path`() { + val shortPath = generateLinearPath( + colemakKeyPositions['a']!!, + colemakKeyPositions['i']!!, + colemakKeyPositions['d']!!, + pointsPerSegment = 5, + ) + + val sigmaCache = buildSigmaCache(colemakKeyPositions) + val neighborhoodCache = pathGeometryAnalyzer.computeKeyNeighborhoods(colemakKeyPositions) + + val signal = SwipeSignal.extract( + shortPath, colemakKeyPositions, pathGeometryAnalyzer, sigmaCache, shortPath.size, + ) + + val aidEntry = makeEntry("aid", 50_000) + val arrestedEntry = makeEntry("arrested", 80_000_000) + + val aidResult = scorer.scoreCandidate( + aidEntry, signal, colemakKeyPositions, sigmaCache, neighborhoodCache, 80_000_000L, + ) + val arrestedResult = scorer.scoreCandidate( + arrestedEntry, signal, colemakKeyPositions, sigmaCache, neighborhoodCache, 80_000_000L, + ) + + assertTrue( + "aid (${aidResult?.combinedScore}) should outscore arrested (${arrestedResult?.combinedScore}) on a short a→i→d path", + (aidResult?.combinedScore ?: 0f) > (arrestedResult?.combinedScore ?: 0f), + ) + } + + @Test + fun `short word the scores higher than together on short path`() { + val shortPath = generateLinearPath( + colemakKeyPositions['t']!!, + colemakKeyPositions['h']!!, + colemakKeyPositions['e']!!, + pointsPerSegment = 5, + ) + + val sigmaCache = buildSigmaCache(colemakKeyPositions) + val neighborhoodCache = pathGeometryAnalyzer.computeKeyNeighborhoods(colemakKeyPositions) + + val signal = SwipeSignal.extract( + shortPath, colemakKeyPositions, pathGeometryAnalyzer, sigmaCache, shortPath.size, + ) + + val theEntry = makeEntry("the", 500_000_000) + val togetherEntry = makeEntry("together", 20_000_000) + + val theResult = scorer.scoreCandidate( + theEntry, signal, colemakKeyPositions, sigmaCache, neighborhoodCache, 500_000_000L, + ) + val togetherResult = scorer.scoreCandidate( + togetherEntry, signal, colemakKeyPositions, sigmaCache, neighborhoodCache, 500_000_000L, + ) + + assertTrue( + "the (${theResult?.combinedScore}) should outscore together (${togetherResult?.combinedScore}) on a short t→h→e path", + (theResult?.combinedScore ?: 0f) > (togetherResult?.combinedScore ?: 0f), + ) + } + + @Test + fun `dogs still scores well on appropriate-length Colemak path`() { + val path = generateLinearPath( + colemakKeyPositions['d']!!, + colemakKeyPositions['o']!!, + colemakKeyPositions['g']!!, + colemakKeyPositions['s']!!, + pointsPerSegment = 8, + ) + + val sigmaCache = buildSigmaCache(colemakKeyPositions) + val neighborhoodCache = pathGeometryAnalyzer.computeKeyNeighborhoods(colemakKeyPositions) + + val signal = SwipeSignal.extract( + path, colemakKeyPositions, pathGeometryAnalyzer, sigmaCache, path.size, + ) + + val dogsEntry = makeEntry("dogs", 10_000_000) + + val dogsResult = scorer.scoreCandidate( + dogsEntry, signal, colemakKeyPositions, sigmaCache, neighborhoodCache, 10_000_000L, + ) + + assertTrue( + "dogs should score reasonably (got ${dogsResult?.combinedScore})", + (dogsResult?.combinedScore ?: 0f) > 0.20f, + ) + } + + @Test + fun `long word bonus does not regress for legitimate long gestures`() { + val longPath = generateLinearPath( + colemakKeyPositions['t']!!, + colemakKeyPositions['o']!!, + colemakKeyPositions['g']!!, + colemakKeyPositions['e']!!, + colemakKeyPositions['t']!!, + colemakKeyPositions['h']!!, + colemakKeyPositions['e']!!, + colemakKeyPositions['r']!!, + pointsPerSegment = 8, + ) + + val sigmaCache = buildSigmaCache(colemakKeyPositions) + val neighborhoodCache = pathGeometryAnalyzer.computeKeyNeighborhoods(colemakKeyPositions) + + val signal = SwipeSignal.extract( + longPath, colemakKeyPositions, pathGeometryAnalyzer, sigmaCache, longPath.size, + ) + + val togetherEntry = makeEntry("together", 20_000_000) + + val result = scorer.scoreCandidate( + togetherEntry, signal, colemakKeyPositions, sigmaCache, neighborhoodCache, 20_000_000L, + ) + + assertTrue( + "together should score well on a full-length path (got ${result?.combinedScore})", + (result?.combinedScore ?: 0f) > 0.20f, + ) + } + + private fun generateLinearPath( + vararg keyPoints: PointF, + pointsPerSegment: Int = 5, + ): List { + val result = ArrayList() + var timestamp = 0L + for (i in 0 until keyPoints.size - 1) { + val from = keyPoints[i] + val to = keyPoints[i + 1] + for (j in 0 until pointsPerSegment) { + val t = j.toFloat() / pointsPerSegment + result.add( + SwipeDetector.SwipePoint( + x = from.x + (to.x - from.x) * t, + y = from.y + (to.y - from.y) * t, + timestamp = timestamp, + velocity = 1.0f, + ), + ) + timestamp += 8L + } + } + result.add( + SwipeDetector.SwipePoint( + x = keyPoints.last().x, + y = keyPoints.last().y, + timestamp = timestamp, + velocity = 0.5f, + ), + ) + return result + } + + private fun buildSigmaCache( + positions: Map, + ): Map = + positions.keys.associateWith { char -> + pathGeometryAnalyzer.calculateAdaptiveSigma(char, positions) + } + + private fun makeEntry( + word: String, + frequency: Long, + ): SwipeDetector.DictionaryEntry = + SwipeDetector.DictionaryEntry( + word = word, + frequencyScore = kotlin.math.ln(frequency.toFloat() + 1f) / 20f, + rawFrequency = frequency, + firstChar = word.first().lowercaseChar(), + uniqueLetterCount = word.toSet().size, + frequencyTier = SwipeDetector.FrequencyTier.fromRank( + if (frequency > 100_000_000) 0 + else if (frequency > 10_000_000) 500 + else if (frequency > 1_000_000) 3000 + else 10000, + ), + ) +}