Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/main/kotlin/tribixbite/cleverkeys/BigramModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,46 @@ class BigramModel private constructor() {
return min(max(multiplier, 0.1f), 10.0f)
}

/**
* Get next word predictions based on previous word context.
* Returns words that are likely to follow the given context.
*
* @param context List of previous words (typically last 1-2 words)
* @param maxPredictions Maximum number of predictions to return
* @return List of (word, probability) pairs sorted by probability (descending)
*/
fun getPredictedWords(context: List<String>?, maxPredictions: Int = 5): List<Pair<String, Float>> {
if (context.isNullOrEmpty()) {
// No context: return most common words from unigram model
val unigramProbs = languageUnigramProbs[currentLanguage] ?: languageUnigramProbs["en"]
return unigramProbs?.entries
?.sortedByDescending { it.value }
?.take(maxPredictions)
?.map { Pair(it.key, it.value) }
?: emptyList()
}

// Get the previous word (use the last word in context)
// Thread-safe: create local copy to avoid concurrent modification
val prevWord = context.lastOrNull()?.lowercase() ?: return emptyList()

// Get language-specific bigram probabilities
val bigramProbs = languageBigramProbs[currentLanguage] ?: languageBigramProbs["en"]

// Find all bigrams that start with the previous word
val predictions = mutableListOf<Pair<String, Float>>()

bigramProbs?.forEach { (bigramKey, prob) ->
val parts = bigramKey.split("|")
if (parts.size == 2 && parts[0] == prevWord) {
predictions.add(Pair(parts[1], prob))
}
}

// Sort by probability (descending) and take top N
return predictions.sortedByDescending { it.second }.take(maxPredictions)
}

/**
* Add a bigram observation (for user adaptation)
*/
Expand Down
52 changes: 50 additions & 2 deletions src/main/kotlin/tribixbite/cleverkeys/SuggestionHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class SuggestionHandler(
) {
companion object {
private const val TAG = "SuggestionHandler"
private const val SPACE_CHARACTER = " " // Trigger character for next word predictions
}

/**
Expand Down Expand Up @@ -572,7 +573,7 @@ class SuggestionHandler(
}

if (config.autocorrect_enabled && predictionCoordinator.getWordPredictor() != null &&
text == " " && !inTermuxApp) {
text == SPACE_CHARACTER && !inTermuxApp) {
val correctedWord = predictionCoordinator.getWordPredictor()?.autoCorrect(completedWord)

// If correction was made, replace the typed word
Expand Down Expand Up @@ -616,7 +617,14 @@ class SuggestionHandler(
// Reset current word
contextTracker.clearCurrentWord()
predictionCoordinator.getWordPredictor()?.reset()
suggestionBar?.clearSuggestions()

// NEW: Show next word predictions after completing a word (space/punctuation)
// Only trigger for space character to avoid excessive predictions
if (text == SPACE_CHARACTER && config.word_prediction_enabled) {
updateNextWordPredictions()
} else {
suggestionBar?.clearSuggestions()
}
}
text.length > 1 -> {
// Multi-character input (paste, etc) - reset
Expand Down Expand Up @@ -698,6 +706,46 @@ class SuggestionHandler(
}
}

/**
* Update predictions for the next word based on context.
* Called after user completes a word (e.g., after typing space).
*/
private fun updateNextWordPredictions() {
// Copy context to be thread-safe
val contextWords = contextTracker.getContextWords().toList()

if (BuildConfig.ENABLE_VERBOSE_LOGGING) {
Log.d(TAG, "updateNextWordPredictions called with context: $contextWords")
}

// Cancel previous task if running
currentPredictionTask?.cancel(true)

// Submit new prediction task
currentPredictionTask = predictionExecutor.submit {
if (Thread.currentThread().isInterrupted) return@submit

// Get next word predictions (no partial word, only context)
val result = predictionCoordinator.getWordPredictor()?.predictNextWords(contextWords)

if (Thread.currentThread().isInterrupted || result == null || result.words.isEmpty()) {
return@submit
}

if (BuildConfig.ENABLE_VERBOSE_LOGGING) {
Log.d(TAG, "Next word predictions: ${result.words}")
}

// Post result to UI thread
suggestionBar?.post {
suggestionBar?.let { bar ->
bar.setShowDebugScores(config.swipe_show_debug_scores)
bar.setSuggestionsWithScores(result.words, result.scores)
}
}
}
}

/**
* Smart delete last word - deletes the last auto-inserted word or last typed word.
* Handles edge cases to avoid deleting too much text.
Expand Down
125 changes: 125 additions & 0 deletions src/main/kotlin/tribixbite/cleverkeys/WordPredictor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ class WordPredictor {
private const val MAX_RECENT_WORDS = 20 // Keep last 20 words for language detection
private const val PREFIX_INDEX_MAX_LENGTH = 3 // Index prefixes up to 3 chars

// Next word prediction scoring constants
private const val BIGRAM_SCORE_SCALE = 10000 // Scale bigram probability (0-1) to 0-10000 range
private const val FALLBACK_SCORE_MULTIPLIER = 0.5f // Multiplier for high-frequency words without bigram match
private const val PERSONALIZATION_BOOST_DIVISOR = 4.0f // Divisor for personalization boost (0-6 range β†’ 1.0-2.5 multiplier)
private const val FREQUENCY_NORMALIZATION_FACTOR = 1000.0f // Normalization factor for frequency in scoring
private const val BIGRAM_EXPANSION_FACTOR = 3 // Get 3x more bigram candidates than requested for better filtering

// Static flag to signal all WordPredictor instances need to reload custom/user/disabled words
@Volatile
private var needsReload = false
Expand Down Expand Up @@ -1175,6 +1182,124 @@ class WordPredictor {
return dictionary.get().size
}

/**
* Predict next words based on previous context (no partial word being typed).
* This is called after the user completes a word (e.g., after space/punctuation).
*
* Unlike predictInternal which completes a partial word, this predicts what
* word should come next based on the completed words in context.
*
* @param context List of previous completed words
* @param maxPredictions Maximum number of predictions to return (default 5)
* @return PredictionResult with next word predictions
*/
fun predictNextWords(context: List<String>, maxPredictions: Int = 5): PredictionResult {
// OPTIMIZATION v3 (perftodos3.md): Use android.os.Trace for system-level profiling
android.os.Trace.beginSection("WordPredictor.predictNextWords")
try {
// Check if dictionary changes require reload
checkAndReload()

if (BuildConfig.ENABLE_VERBOSE_LOGGING) {
Log.d(TAG, "Predicting next words with context: $context")
}

val candidates = mutableListOf<WordCandidate>()

// Get bigram predictions from BigramModel
val bigramPredictions = bigramModel?.getPredictedWords(context, maxPredictions * BIGRAM_EXPANSION_FACTOR) ?: emptyList()

if (BuildConfig.ENABLE_VERBOSE_LOGGING) {
Log.d(TAG, "BigramModel returned ${bigramPredictions.size} predictions")
}

// Score each bigram prediction
for ((word, bigramProb) in bigramPredictions) {
// Skip disabled words
if (isWordDisabled(word)) {
continue
}

// Check if word exists in dictionary
val frequency = dictionary.get()[word] ?: continue

// Calculate score combining bigram probability and frequency
// Bigram probability is 0-1, scale to 0-10000 range for scoring
val bigramScore = (bigramProb * BIGRAM_SCORE_SCALE).toInt()

// User adaptation multiplier
val adaptationMultiplier = adaptationManager?.getAdaptationMultiplier(word) ?: 1.0f

// Dynamic context boost from learned N-gram patterns (ContextModel)
val contextAwareEnabled = config?.context_aware_predictions_enabled ?: true
val dynamicContextBoost = if (contextAwareEnabled && contextModel != null && context.isNotEmpty()) {
contextModel?.getContextBoost(word, context) ?: 1.0f
} else {
1.0f
}

// Personalization boost from user's typing frequency and recency
val personalizationEnabled = config?.personalized_learning_enabled ?: true
val personalizationMultiplier = if (personalizationEnabled && personalizationEngine != null) {
val boost = personalizationEngine?.getPersonalizationBoost(word) ?: 0.0f
1.0f + (boost / PERSONALIZATION_BOOST_DIVISOR)
} else {
1.0f
}

// Combine all signals
// Bigram score is primary signal for next word prediction
// Frequency provides secondary ranking
val frequencyFactor = 1.0f + ln1p((frequency / FREQUENCY_NORMALIZATION_FACTOR).toDouble()).toFloat()
val finalScore = bigramScore *
adaptationMultiplier *
personalizationMultiplier *
dynamicContextBoost *
frequencyFactor

candidates.add(WordCandidate(word, finalScore.toInt()))
}

// If we don't have enough predictions from bigrams, add high-frequency words
if (candidates.size < maxPredictions) {
// Get most common words from dictionary that aren't already in candidates
val existingWords = candidates.map { it.word }.toSet()

dictionary.get().entries
.asSequence()
.filter { !isWordDisabled(it.key) && !existingWords.contains(it.key) }
.sortedByDescending { it.value }
.take(maxPredictions - candidates.size)
.forEach { (word, frequency) ->
// Lower base score since these don't have context
val adaptationMultiplier = adaptationManager?.getAdaptationMultiplier(word) ?: 1.0f
val score = (frequency * FALLBACK_SCORE_MULTIPLIER * adaptationMultiplier).toInt()
candidates.add(WordCandidate(word, score))
}
}

// Sort by score and extract top N
candidates.sortByDescending { it.score }

val predictions = mutableListOf<String>()
val scores = mutableListOf<Int>()

for (candidate in candidates.take(maxPredictions)) {
predictions.add(candidate.word)
scores.add(candidate.score)
}

if (BuildConfig.ENABLE_VERBOSE_LOGGING) {
Log.d(TAG, "Next word predictions (${predictions.size}): $predictions")
Log.d(TAG, "Scores: $scores")
}

return PredictionResult(predictions, scores)
} finally {
android.os.Trace.endSection()
}
}

/**
* Helper class to store word candidates with scores
*/
Expand Down
Loading