From 0610a2bf27b7eac1f90961ce431977991b15ddf2 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 7 Sep 2026 21:34:29 +0100 Subject: [PATCH 1/4] feat(rsvp): coordinate phrase timing and meaningful emphasis --- .../com/kairo/reader/core/model/RsvpFrame.kt | 4 + .../com/kairo/reader/core/rsvp/RsvpEngine.kt | 9 +- .../reader/core/rsvp/RsvpGenerationOptions.kt | 7 +- .../core/rsvp/analysis/RsvpPhraseAnalysis.kt | 36 +++++- .../core/rsvp/analysis/RsvpThoughtPlan.kt | 119 ++++++++++++++++++ .../rsvp/engine/RsvpFramePostProcessor.kt | 2 + .../core/rsvp/engine/RsvpUnitBuilder.kt | 17 ++- .../rsvp/timing/RsvpSessionTimingPolicy.kt | 13 +- .../reader/core/rsvp/timing/RsvpUnitTiming.kt | 58 ++++++--- .../core/rsvp/RsvpGenerationOptionsTest.kt | 8 +- .../reader/core/rsvp/RsvpThoughtFlowTest.kt | 109 ++++++++++++++++ 11 files changed, 353 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpThoughtPlan.kt create mode 100644 app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt diff --git a/app/src/main/java/com/kairo/reader/core/model/RsvpFrame.kt b/app/src/main/java/com/kairo/reader/core/model/RsvpFrame.kt index c249cd26..f48d2ac5 100644 --- a/app/src/main/java/com/kairo/reader/core/model/RsvpFrame.kt +++ b/app/src/main/java/com/kairo/reader/core/model/RsvpFrame.kt @@ -19,4 +19,8 @@ data class RsvpFrame( // fragments such as "ha-ha-ha". A null end means the complete final token. val displayOriginalStartCharacterOffset: Int = 0, val displayOriginalEndCharacterOffset: Int? = null, + // Shared thought boundaries stay in source coordinates across grouping and split words. + val phraseStartTokenIndex: Int? = null, + val phraseEndTokenIndexExclusive: Int? = null, + val endsPhrase: Boolean = false, ) diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt index 4d3f7b52..0b06ef3e 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt @@ -147,7 +147,7 @@ private fun generateFramesWithNormalizedConfig( } else { null }, - analysis = analyzeExpandedTokens(expanded, config), + analysis = analyzeExpandedTokens(expanded, config, options.languagePolicy), frames = mutableListOf(), state = createContextState(tokens, expanded[cursor].originalIndex), rhythm = createRhythmState(config), @@ -405,8 +405,11 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { config = config, state = state, selectedWordCursors = scoredSelection?.selectedWordCursors, + phraseEndTokenIndexExclusive = analysis.thoughtCues[wordCursor]?.endTokenIndexExclusive, ) + val unitCues = (wordCursor until nextCursor).mapNotNull { analysis.thoughtCues[it] } + val durationMs = computeUnitDurationMs( RsvpUnitTimingInput( @@ -432,6 +435,7 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { scoredSelection?.boundaryStrengthBeforeMilli ?: 0, explicitSpeakerTag = scoredSelection?.dialogueRole == RsvpDialogueRole.SPEAKER_TAG, + thoughtCues = unitCues, ), ) @@ -456,6 +460,9 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { expanded[frameStartCursor].sourceCharacterStart, displayOriginalEndCharacterOffset = expanded.getOrNull(nextCursor - 1)?.sourceCharacterEndExclusive, + phraseStartTokenIndex = unitCues.firstOrNull()?.startTokenIndex, + phraseEndTokenIndexExclusive = unitCues.lastOrNull()?.endTokenIndexExclusive, + endsPhrase = unitCues.lastOrNull()?.isLastWord == true, ) return consumeContextPunctuation(nextCursor) diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpGenerationOptions.kt b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpGenerationOptions.kt index a7dc0f10..218f3afc 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpGenerationOptions.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpGenerationOptions.kt @@ -70,7 +70,8 @@ data class RsvpPaceEstimationOptions( segmentationStrategy = segmentationStrategy, ) } else { - RsvpGenerationOptions.LEGACY + // The fallback still reads the English estimator sample; only grouping is legacy. + RsvpGenerationOptions(languagePolicy = RsvpLanguagePolicy.ENGLISH) } companion object { @@ -101,7 +102,9 @@ object RsvpSegmentationRolloutResolver { segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, ) val strategy = - if (isDebugBuild && scoredOptions.usesScoredSegmentation(config)) { + if ((isDebugBuild || languagePolicy == RsvpLanguagePolicy.ENGLISH) && + scoredOptions.usesScoredSegmentation(config) + ) { RsvpSegmentationStrategy.SCORED_DP_V2 } else { RsvpSegmentationStrategy.LEGACY_GREEDY diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseAnalysis.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseAnalysis.kt index 5b39c752..ba4345f5 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseAnalysis.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpPhraseAnalysis.kt @@ -6,6 +6,7 @@ import com.kairo.reader.core.model.RsvpConfig import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.model.isSentenceEndingPunctuation +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy import com.kairo.reader.core.rsvp.engine.CLAUSE_ANTICIPATORY_CONTOUR import com.kairo.reader.core.rsvp.engine.CLAUSE_PRE_BOUNDARY_CONTOUR import com.kairo.reader.core.rsvp.engine.CLAUSE_RESTART_CONTOUR @@ -26,6 +27,7 @@ internal data class RsvpTokenAnalysis( /** Expanded indices of em/en-dash tokens that open or close a paired aside within a sentence. */ val pairedEmDashIndices: Set, val phraseContours: Map, + val thoughtCues: Map = emptyMap(), ) { companion object { val EMPTY = @@ -42,6 +44,7 @@ internal data class RsvpTokenAnalysis( internal fun analyzeExpandedTokens( expanded: List, config: RsvpConfig, + languagePolicy: RsvpLanguagePolicy = RsvpLanguagePolicy.UNKNOWN, ): RsvpTokenAnalysis { if (expanded.isEmpty()) return RsvpTokenAnalysis.EMPTY @@ -50,6 +53,7 @@ internal fun analyzeExpandedTokens( val asides = HashSet() val pairedDashes = HashSet() val contours = HashMap() + val thoughtCues = RsvpThoughtPlan.analyze(expanded, config, languagePolicy) val breathGroup = ArrayList() var previousWord: Token? = null var emDashAsideCloseIndex = -1 @@ -92,7 +96,7 @@ internal fun analyzeExpandedTokens( } } if (config.useFocalStress) { - addFocalWord(breathGroup, focal) + addFocalWord(breathGroup, focal, thoughtCues) } breathGroup.clear() applyRestartContour(tier = tier, afterIndex = boundaryIndex) @@ -102,6 +106,9 @@ internal fun analyzeExpandedTokens( val token = entry.token when (token.type) { TokenType.WORD -> { + if (startsNewThought(entry, breathGroup, thoughtCues)) { + applyBoundaryEffects(RsvpPunctuationTier.CLAUSE_BREAK, index - 1) + } if (config.useParentheticalAside && emDashAsideCloseIndex > index) { asides += entry.expandedIndex } @@ -110,7 +117,7 @@ internal fun analyzeExpandedTokens( } TokenType.PARAGRAPH_BREAK, TokenType.PAGE_BREAK -> { if (config.useFocalStress) { - addFocalWord(breathGroup, focal) + addFocalWord(breathGroup, focal, thoughtCues) } breathGroup.clear() previousWord = null @@ -134,9 +141,7 @@ internal fun analyzeExpandedTokens( prevWord = previousWord, nextToken = nextTokenAfter(expanded, index), ) - if (tier == RsvpPunctuationTier.SENTENCE_END || - tier == RsvpPunctuationTier.CLAUSE_BREAK - ) { + if (tier.isThoughtBoundary()) { applyBoundaryEffects(tier = tier, boundaryIndex = index) } if (index >= emDashAsideCloseIndex) { @@ -146,7 +151,7 @@ internal fun analyzeExpandedTokens( } } if (config.useFocalStress) { - addFocalWord(breathGroup, focal) + addFocalWord(breathGroup, focal, thoughtCues) } return RsvpTokenAnalysis( @@ -155,14 +160,33 @@ internal fun analyzeExpandedTokens( emDashAsideIndices = if (config.useParentheticalAside) asides else emptySet(), pairedEmDashIndices = pairedDashes, phraseContours = contours, + thoughtCues = thoughtCues, ) } +private fun RsvpPunctuationTier.isThoughtBoundary(): Boolean = + this == RsvpPunctuationTier.SENTENCE_END || this == RsvpPunctuationTier.CLAUSE_BREAK + +private fun startsNewThought( + entry: ExpandedToken, + group: List, + cues: Map, +): Boolean = + group.isNotEmpty() && + cues[entry.expandedIndex]?.startTokenIndex == entry.originalIndex && + group.last().originalIndex != entry.originalIndex + private fun addFocalWord( group: List, focal: MutableSet, + thoughtCues: Map, ) { if (group.isEmpty()) return + val protected = group.filter { thoughtCues[it.expandedIndex]?.protectedEmphasis == true } + if (protected.isNotEmpty()) { + protected.forEach { focal += it.expandedIndex } + return + } if (group.size == 1) { focal += group.first().expandedIndex return diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpThoughtPlan.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpThoughtPlan.kt new file mode 100644 index 00000000..7319c8b6 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpThoughtPlan.kt @@ -0,0 +1,119 @@ +package com.kairo.reader.core.rsvp.analysis + +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.core.rsvp.engine.ExpandedToken +import com.kairo.reader.core.rsvp.timing.RsvpPunctuationTier +import com.kairo.reader.core.rsvp.timing.RsvpPunctuationTimingPolicy +import kotlin.math.sqrt + +/** Source-based phrase boundaries are shared by timing, replay and peripheral context. */ +internal data class RsvpThoughtCue( + val startTokenIndex: Int, + val endTokenIndexExclusive: Int, + val isLastWord: Boolean, + val protectedEmphasis: Boolean, + val integrationHoldMs: Double, +) + +internal object RsvpThoughtPlan { + fun analyze( + expanded: List, + config: RsvpConfig, + languagePolicy: RsvpLanguagePolicy, + ): Map { + val cues = HashMap() + val words = ArrayList() + val english = languagePolicy == RsvpLanguagePolicy.ENGLISH + var previousModals = emptySet() + + fun finish(endExclusive: Int) { + if (words.isEmpty()) return + val protected = protectedWords(words, english, previousModals) + val hold = integrationHold(words, config) + val start = words.first().originalIndex + words.forEachIndexed { index, word -> + cues[word.expandedIndex] = RsvpThoughtCue( + startTokenIndex = start, + endTokenIndexExclusive = endExclusive, + isLastWord = index == words.lastIndex, + protectedEmphasis = word.expandedIndex in protected, + integrationHoldMs = if (index == words.lastIndex) hold else 0.0, + ) + } + previousModals = words.map { normalizeWord(it.token.text) }.filter { it in MODALS }.toSet() + words.clear() + } + + expanded.forEachIndexed { index, entry -> + val token = entry.token + when (token.type) { + TokenType.WORD -> { + if (english && token.isClauseBoundary && !token.isSubwordChunk) finish(entry.originalIndex) + words += entry + } + TokenType.PARAGRAPH_BREAK, TokenType.PAGE_BREAK -> { + finish(entry.originalIndex) + previousModals = emptySet() + } + TokenType.PUNCTUATION -> { + val tier = RsvpPunctuationTimingPolicy.resolveTier( + token, + words.lastOrNull()?.token, + expanded.getOrNull(index + 1)?.token, + ) + if (tier == RsvpPunctuationTier.CLAUSE_BREAK || tier == RsvpPunctuationTier.SENTENCE_END) { + finish(entry.originalIndex + 1) + } + } + } + } + finish((expanded.lastOrNull()?.originalIndex ?: -1) + 1) + return cues + } + + private fun protectedWords( + words: List, + english: Boolean, + previousModals: Set, + ): Set = buildSet { + words.forEachIndexed { index, entry -> + val token = entry.token + if (token.isSubwordChunk) return@forEachIndexed + val word = normalizeWord(token.text) + val previous = words.getOrNull(index - 1)?.token?.text?.let(::normalizeWord) + val contrast = english && + ( + word in NEGATIONS || + word in CONTRAST_MARKERS || + previous in CONTRAST_MARKERS || + (word in MODALS && previousModals.any { it != word }) + ) + if (contrast || token.text.any(Char::isDigit)) add(entry.expandedIndex) + } + } + + private fun integrationHold(words: List, config: RsvpConfig): Double { + if (!config.useAdaptiveTiming) return 0.0 + // Count source words once: spelling chunks must not manufacture cognitive load. + val sourceWords = words.distinctBy { it.originalIndex } + val informationWords = sourceWords.distinctBy { normalizeWord(it.token.text) } + val density = informationWords.sumOf { + (1.0 - it.token.frequencyScore).coerceIn(0.0, 1.0) + + (it.token.complexityMultiplier - 1.0).coerceIn(0.0, 1.0) + + if (it.token.text.any(Char::isDigit)) NUMBER_LOAD else 0.0 + } + val lengthLoad = (informationWords.size - EASY_PHRASE_WORDS).coerceAtLeast(0).toDouble() + val load = (density - EASY_DENSITY).coerceAtLeast(0.0) + sqrt(lengthLoad) + return (load * HOLD_PER_LOAD_MS).coerceAtMost(config.adaptiveDifficultyMaxHoldMs.toDouble()) + } + + private val NEGATIONS = setOf("not", "never", "neither", "nor", "no", "cannot", "can't", "won't", "isn't", "wasn't") + private val CONTRAST_MARKERS = setOf("but", "instead", "rather", "however", "only", "except") + private val MODALS = setOf("can", "could", "may", "might", "must", "shall", "should", "will", "would") + private const val NUMBER_LOAD = 0.75 + private const val EASY_PHRASE_WORDS = 7 + private const val EASY_DENSITY = 2.0 + private const val HOLD_PER_LOAD_MS = 14.0 +} diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt index 27ab7ee3..b21366d0 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpFramePostProcessor.kt @@ -103,6 +103,8 @@ private fun splitFrameForBlink( displayOriginalEndExclusive = frame.displayOriginalEndExclusive, displayOriginalStartCharacterOffset = frame.displayOriginalStartCharacterOffset, displayOriginalEndCharacterOffset = frame.displayOriginalEndCharacterOffset, + phraseStartTokenIndex = frame.phraseStartTokenIndex, + phraseEndTokenIndexExclusive = frame.phraseEndTokenIndexExclusive, ), ) } diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpUnitBuilder.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpUnitBuilder.kt index 83a702df..2e2c8a9a 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpUnitBuilder.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpUnitBuilder.kt @@ -14,8 +14,9 @@ internal fun buildUnit( config: RsvpConfig, state: ContextState, selectedWordCursors: List? = null, + phraseEndTokenIndexExclusive: Int? = null, ): UnitBuildResult { - val cursor = UnitCursor(expandedTokens, state, startCursor) + val cursor = UnitCursor(expandedTokens, state, startCursor, phraseEndTokenIndexExclusive) cursor.consumeLeadingPunctuation() val firstWord = cursor.consumeFirstWord() ?: return UnitBuildResult(cursor.unitTokens, startCursor, cursor.index) @@ -32,7 +33,12 @@ internal fun buildUnit( ) } -private class UnitCursor(private val expandedTokens: List, private val state: ContextState, startCursor: Int,) { +private class UnitCursor( + private val expandedTokens: List, + private val state: ContextState, + startCursor: Int, + private val phraseEndTokenIndexExclusive: Int?, +) { val unitTokens = mutableListOf() var index = startCursor.coerceIn(0, expandedTokens.lastIndex) private set @@ -73,6 +79,7 @@ private class UnitCursor(private val expandedTokens: List, privat var characters = firstWord.text.length var canContinue = true while (words < maxWords && canContinue) { + if (atThoughtBoundary()) return val candidate = expandedTokens.getOrNull(index)?.token val previousWord = unitTokens.lastOrNull { it.type == TokenType.WORD } val combinedCharacters = characters + (candidate?.text?.length ?: 0) @@ -104,6 +111,7 @@ private class UnitCursor(private val expandedTokens: List, privat var words = 1 var characters = visibleCodePointCount(firstWord.token.text) while (words < targetWords) { + if (atThoughtBoundary()) return val candidateExpanded = expandedTokens.getOrNull(index) ?: return if (candidateExpanded.expandedIndex != selectedWordCursors[words]) return val candidate = candidateExpanded.token @@ -155,4 +163,9 @@ private class UnitCursor(private val expandedTokens: List, privat state.consume(token) index += 1 } + + private fun atThoughtBoundary(): Boolean = + phraseEndTokenIndexExclusive?.let { end -> + (expandedTokens.getOrNull(index)?.originalIndex ?: end) >= end + } ?: false } diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicy.kt b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicy.kt index 7d178cd5..8d1ca36f 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicy.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpSessionTimingPolicy.kt @@ -47,18 +47,20 @@ internal object RsvpSessionTimingPolicy { config: RsvpConfig, frameIndex: Int, rampStartIndex: Int, + preparationScale: Double = 1.0, ): Double { val rampFrames = config.rampUpFrames if (rampStartIndex <= 0 || rampStartIndex < rampFrames) return 1.0 val offset = frameIndex - rampStartIndex if (rampFrames <= 0 || offset < 0 || offset >= rampFrames) return 1.0 - return rampUpMultiplier(offset, rampFrames) + return 1.0 + (rampUpMultiplier(offset, rampFrames) - 1.0) * preparationScale.coerceIn(0.0, 1.0) } fun resumeDelayMs( config: RsvpConfig, frameIndex: Int, rampStartIndex: Int, + preparationScale: Double = 1.0, ): Long { if (rampStartIndex <= 0 || rampStartIndex < config.rampUpFrames || @@ -66,9 +68,13 @@ internal object RsvpSessionTimingPolicy { ) { return 0L } - return config.startDelayMs + return (config.startDelayMs * preparationScale.coerceIn(0.0, 1.0)).toLong() } + fun resumePreparationScale(pausedMs: Long): Double = + ((pausedMs - BRIEF_PAUSE_MS).coerceAtLeast(0L).toDouble() / REORIENTATION_WINDOW_MS) + .coerceIn(MIN_RESUME_PREPARATION, 1.0) + private fun rampUpMultiplier( offset: Int, rampFrames: Int, @@ -100,4 +106,7 @@ internal object RsvpSessionTimingPolicy { private const val RAMP_UP_INITIAL_MULTIPLIER = 1.35 private const val RAMP_UP_REDUCTION = 0.35 private const val RAMP_DOWN_INCREASE = 0.25 + private const val BRIEF_PAUSE_MS = 1_000L + private const val REORIENTATION_WINDOW_MS = 9_000.0 + private const val MIN_RESUME_PREPARATION = 0.15 } diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpUnitTiming.kt b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpUnitTiming.kt index 7061e89f..136ca897 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpUnitTiming.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpUnitTiming.kt @@ -7,6 +7,7 @@ import com.kairo.reader.core.model.RsvpConfig import com.kairo.reader.core.model.RsvpConfigConstraints import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.analysis.RsvpThoughtCue import com.kairo.reader.core.rsvp.analysis.contextShapingMultiplier import com.kairo.reader.core.rsvp.analysis.emphasisMultiplier import com.kairo.reader.core.rsvp.analysis.frameDifficulty @@ -63,6 +64,7 @@ internal data class RsvpUnitTimingInput( val afterPairedEmDash: Boolean = false, val rhythmBoundaryStrengthMilli: Int = 0, val explicitSpeakerTag: Boolean = false, + val thoughtCues: List = emptyList(), ) private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { @@ -85,6 +87,7 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { val afterPairedEmDash = input.afterPairedEmDash val rhythmBoundaryStrengthMilli = input.rhythmBoundaryStrengthMilli val explicitSpeakerTag = input.explicitSpeakerTag + val thoughtCues = input.thoughtCues val msPerWord = config.tempoMsPerWord.toDouble() val pauseScale = pauseScale(msPerWord, config) val clausePauseScale = @@ -185,6 +188,7 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { private data class FrameWordTiming( val duration: Double, + val expressionMs: Double, val enteredDialogue: Boolean, val exitedDialogue: Boolean, val sawParentheticalWord: Boolean, @@ -196,6 +200,8 @@ private class FrameWordContext(contextBefore: ContextSnapshot) { var enteredDialogue = false var exitedDialogue = false var sawParentheticalWord = false + var wordOrdinal = 0 + var expressionMs = 0.0 } private fun computeFrameWordTiming(context: RsvpUnitTimingContext): FrameWordTiming { @@ -214,6 +220,7 @@ private fun computeFrameWordTiming(context: RsvpUnitTimingContext): FrameWordTim } return FrameWordTiming( duration = duration, + expressionMs = state.expressionMs, enteredDialogue = state.enteredDialogue, exitedDialogue = state.exitedDialogue, sawParentheticalWord = state.sawParentheticalWord, @@ -246,6 +253,8 @@ private fun wordDurationContribution( token: Token, ): Double { val config = context.config + val thoughtCue = context.thoughtCues.getOrNull(state.wordOrdinal++) + val protectedEmphasis = config.useProsodyPacing && thoughtCue?.protectedEmphasis == true val inAside = config.useParentheticalAside && (state.parentheticalDepth > 0 || context.emDashAside) val dialogueMultiplier = if (config.useDialogueDetection && state.inDialogue) { @@ -324,21 +333,34 @@ private fun wordDurationContribution( wordDurationMs(token, context.msPerWord, config) * parentheticalMultiplier * dialogueMultiplier * - (if (index == context.firstWordIndex) context.startBoost else 1.0) * - clauseMultiplier * - terminalMultiplier * - emphasis * - prosody * - dialogueEntry * - context.speakerTagMultiplier * - context.focalSuppression * - context.anticipatoryLanding * - phraseContourMultiplier(context.phraseContour, context.speedStrength) * - context.phraseShapeMultiplier * - givenness - return max(duration, wordFloorMs(token, config).toDouble()) + context.speakerTagMultiplier + val expression = coordinateRsvpExpression( + if (index == context.firstWordIndex) context.startBoost else 1.0, + clauseMultiplier, + terminalMultiplier, + emphasis, + if (protectedEmphasis) max(1.0, prosody) else prosody, + dialogueEntry, + if (protectedEmphasis) 1.0 else context.focalSuppression, + context.anticipatoryLanding, + phraseContourMultiplier(context.phraseContour, context.speedStrength), + context.phraseShapeMultiplier, + if (protectedEmphasis) 1.0 else givenness, + if (protectedEmphasis) 1.0 + (THOUGHT_EMPHASIS * context.prosodyStrength) else 1.0, + ) + val baseline = max(duration, wordFloorMs(token, config).toDouble()) + state.expressionMs += baseline * (expression - 1.0) + return baseline } +/** Bound overlapping automatic cues while leaving explicit difficulty and pause settings intact. */ +internal fun coordinateRsvpExpression(vararg multipliers: Double): Double = + (1.0 + multipliers.sumOf { it - 1.0 }).coerceIn(MIN_EXPRESSION, MAX_EXPRESSION) + +private const val MIN_EXPRESSION = 0.82 +private const val MAX_EXPRESSION = 1.4 +private const val THOUGHT_EMPHASIS = 0.08 + private class FramePunctuationContext(contextBefore: ContextSnapshot) { var parentheticalDepth = contextBefore.parentheticalDepth var inDialogue = contextBefore.inDialogue @@ -468,7 +490,15 @@ internal fun computeUnitDurationMs(input: RsvpUnitTimingInput): Long = // Now add punctuation pauses on top of the smoothed word duration. // These pauses are intentionally NOT smoothed so they remain prominent. - var totalDuration = smoothedWordDuration + // Smooth the underlying beat, then restore the planned expression so the EMA cannot + // flatten a contrast or drag its emphasis onto the following word. + val expression = + if (wordTiming.duration > 0.0) 1.0 + wordTiming.expressionMs / wordTiming.duration else 1.0 + var totalDuration = max( + smoothedWordDuration * expression, + words.sumOf { wordFloorMs(it, config).toDouble() }, + ) + totalDuration += thoughtCues.sumOf { it.integrationHoldMs } if (words.isNotEmpty()) { when (boundaryForBoost) { BoundaryBefore.SENTENCE -> { diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt index 829f9db9..9428cfcf 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt @@ -21,10 +21,14 @@ class RsvpGenerationOptionsTest { } @Test - fun releaseRolloutAlwaysRemainsLegacy() { + fun releaseRolloutUsesScoredEnglishAndKeepsOtherLanguagesConservative() { RsvpLanguagePolicy.entries.forEach { policy -> assertEquals( - RsvpSegmentationStrategy.LEGACY_GREEDY, + if (policy == RsvpLanguagePolicy.ENGLISH) { + RsvpSegmentationStrategy.SCORED_DP_V2 + } else { + RsvpSegmentationStrategy.LEGACY_GREEDY + }, RsvpSegmentationRolloutResolver.resolve( languagePolicy = policy, config = eligibleConfig, diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt new file mode 100644 index 00000000..5bd82ad4 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt @@ -0,0 +1,109 @@ +package com.kairo.reader.core.rsvp + +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.analysis.RsvpThoughtPlan +import com.kairo.reader.core.rsvp.engine.ExpandedToken +import com.kairo.reader.core.rsvp.timing.coordinateRsvpExpression +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpThoughtFlowTest : ComprehensionRsvpTestBase() { + @Test + fun protectedModalRetainsExtraTimeAfterStrongRhythmSmoothing() { + val tokens = listOf( + w("She"), w("said"), w("he"), w("could"), w("leave"), p("."), + w("She"), w("said"), w("he"), w("should"), w("leave"), p(".") + ) + val config = stableConfig.copy(smoothingAlpha = 0.1) + val english = engine.generateFrames(tokens, 0, config, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) + val unknown = engine.generateFrames(tokens, 0, config) + val emphasized = english.first { it.originalTokenIndex == 9 } + val neutral = unknown.first { it.originalTokenIndex == 9 } + assertTrue(emphasized.durationMs > neutral.durationMs) + } + + @Test + fun contrastProtectsShortModalWordsAndNegationWithExplicitEnglishPolicy() { + val tokens = listOf( + w("She"), w("said"), w("he"), w("could"), w("leave"), p("."), + w("She"), w("never"), w("said"), w("he"), w("should"), p(".") + ) + val cues = plan(tokens) + assertTrue(cues.getValue(7).protectedEmphasis) + assertTrue(cues.getValue(10).protectedEmphasis) + assertFalse(cues.getValue(3).protectedEmphasis) + val unknown = plan(tokens, language = RsvpLanguagePolicy.UNKNOWN) + assertFalse(unknown.getValue(7).protectedEmphasis) + assertFalse(unknown.getValue(10).protectedEmphasis) + } + + @Test + fun repeatedContrastWordKeepsProtection() { + val cues = plan(listOf(w("ready"), p(","), w("but"), w("ready"), p("."))) + assertTrue(cues.getValue(3).protectedEmphasis) + } + + @Test + fun integrationTimeBelongsOnlyToTheDensePhraseLandingAndIsCapped() { + val easy = listOf(w("we"), w("are"), w("ready"), p(".")) + val dense = List(12) { w("concept$it").copy(frequencyScore = 0.1, complexityMultiplier = 1.8) } + p(".") + assertEquals(0.0, plan(easy).values.sumOf { it.integrationHoldMs }, 0.0) + val cues = plan(dense) + assertTrue(cues.getValue(11).integrationHoldMs > 0.0) + assertEquals(1, cues.values.count { it.integrationHoldMs > 0.0 }) + assertTrue(cues.values.sumOf { it.integrationHoldMs } <= stableConfig.adaptiveDifficultyMaxHoldMs) + assertEquals(0.0, plan(dense, stableConfig.copy(useAdaptiveTiming = false)).values.sumOf { it.integrationHoldMs }, 0.0) + } + + @Test + fun abbreviationsAndDecimalsStayInsideTheirThought() { + val tokens = listOf(w("Dr"), p("."), w("Smith"), w("paid"), w("3"), p("."), w("14"), p(".")) + val cues = plan(tokens) + assertEquals(1, cues.values.map { it.startTokenIndex }.distinct().size) + assertTrue(cues.getValue(6).isLastWord) + assertFalse(cues.getValue(4).isLastWord) + } + + @Test + fun phraseMetadataAndSourceCoverageSurviveGroupingAndSplitWords() { + val tokens = listOf(w("in"), w("the"), w("extraordinary"), p(","), w("but"), w("not"), w("today"), p(".")) + val config = stableConfig.copy(enablePhraseChunking = true, maxWordsPerUnit = 2, maxChunkLength = 6) + val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH, RsvpSegmentationStrategy.SCORED_DP_V2) + val frames = engine.generateFrames(tokens, 0, config, options) + val words = frames.filter { it.tokens.any { token -> token.type == TokenType.WORD } } + assertEquals( + tokens.indices.filter { tokens[it].type == TokenType.WORD }, + words.flatMap { frame -> + (frame.displayOriginalStartIndex until frame.displayOriginalEndExclusive).filter { tokens[it].type == TokenType.WORD } + }.distinct() + ) + assertEquals(2, words.count { it.endsPhrase }) + assertTrue(words.all { it.phraseStartTokenIndex != null && it.phraseEndTokenIndexExclusive != null }) + val chunks = words.filter { it.originalTokenIndex == 2 } + assertTrue(chunks.size > 1) + assertEquals(chunks.size, chunks.map { it.resumeCursor }.distinct().size) + } + + @Test + fun simultaneousExpressionCuesAreBoundedAndNeutralIsIdentity() { + assertEquals(1.0, coordinateRsvpExpression(1.0, 1.0), 0.0) + assertTrue(coordinateRsvpExpression(1.3, 1.3, 1.3, 1.3) < 1.3 * 1.3) + assertTrue(coordinateRsvpExpression(0.8, 0.8, 0.8) >= 0.8) + } + + private fun plan( + tokens: List, + config: RsvpConfig = stableConfig, + language: RsvpLanguagePolicy = RsvpLanguagePolicy.ENGLISH, + ) = RsvpThoughtPlan.analyze( + tokens.mapIndexed { index, token -> + ExpandedToken(token, index, index, 0, token.text.length) + }, + config, + language + ) +} From 05b98f2960041dfa285d6a7db26899c2643fe4da Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 7 Sep 2026 21:34:56 +0100 Subject: [PATCH 2/4] feat(rsvp): align replay and context with thought boundaries --- .../ui/rsvp/RsvpContextContentResolver.kt | 38 ++++++- .../reader/ui/rsvp/RsvpPeripheralContext.kt | 13 ++- .../reader/ui/rsvp/RsvpScreenConstants.kt | 4 +- .../kairo/reader/ui/rsvp/RsvpScreenEffects.kt | 36 ++---- .../reader/ui/rsvp/RsvpScreenInteractions.kt | 36 +++++- .../kairo/reader/ui/rsvp/RsvpScreenRuntime.kt | 24 +++- app/src/main/res/values/strings.xml | 8 +- .../reader/ui/rsvp/RsvpContextAssistTest.kt | 4 +- .../reader/ui/rsvp/RsvpThoughtPlaybackTest.kt | 104 ++++++++++++++++++ 9 files changed, 215 insertions(+), 52 deletions(-) create mode 100644 app/src/test/java/com/kairo/reader/ui/rsvp/RsvpThoughtPlaybackTest.kt diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpContextContentResolver.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpContextContentResolver.kt index 086dc831..af9bca10 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpContextContentResolver.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpContextContentResolver.kt @@ -86,12 +86,16 @@ internal fun rememberRsvpContextContent( } else { 0 } + val stableWindow = remember(tokens, frame.phraseStartTokenIndex, frame.phraseEndTokenIndexExclusive) { + resolveStablePeripheralWindow(tokens, frame) + } + val peripheralWindow = stableWindow ?: window val previous = - remember(tokens, window, contextColor, previousWords) { + remember(tokens, peripheralWindow, contextColor, previousWords) { buildPeripheralContextText( tokens = tokens, - startIndex = window.startIndex, - endExclusive = window.focusStartIndex, + startIndex = peripheralWindow.startIndex, + endExclusive = peripheralWindow.focusStartIndex, maxWords = previousWords, takeLast = true, color = contextColor, @@ -100,14 +104,14 @@ internal fun rememberRsvpContextContent( ) } val upcoming = - remember(tokens, window, contextColor, upcomingWords) { + remember(tokens, peripheralWindow, contextColor, upcomingWords) { if (upcomingWords == 0) { AnnotatedString("") } else { buildPeripheralContextText( tokens = tokens, - startIndex = window.focusEndExclusive, - endExclusive = window.endExclusive, + startIndex = peripheralWindow.focusEndExclusive, + endExclusive = peripheralWindow.endExclusive, maxWords = upcomingWords, takeLast = false, color = contextColor, @@ -122,6 +126,28 @@ internal fun rememberRsvpContextContent( ) } +/** Keep neighbouring thoughts fixed while the active phrase plays at the central focus. */ +internal fun resolveStablePeripheralWindow(tokens: List, frame: RsvpFrame): RsvpContextWindow? { + val phraseStart = frame.phraseStartTokenIndex ?: return null + val phraseEnd = frame.phraseEndTokenIndexExclusive ?: return null + if (phraseStart !in tokens.indices || phraseEnd !in (phraseStart + 1)..tokens.size) return null + var start = phraseStart + var previousWords = 0 + while (start > 0 && previousWords < CONTEXT_CLAUSE_PREVIOUS_WORDS) { + if (tokens[start - 1].isParagraphBoundary()) break + start-- + if (tokens[start].type == TokenType.WORD) previousWords++ + } + var end = phraseEnd + var upcomingWords = 0 + while (end < tokens.size && upcomingWords < CONTEXT_CLAUSE_UPCOMING_WORDS) { + if (tokens[end].isParagraphBoundary()) break + if (tokens[end].type == TokenType.WORD) upcomingWords++ + end++ + } + return RsvpContextWindow(start, end, phraseStart, phraseEnd) +} + internal fun resolveRsvpContextWindow( tokens: List, frameStartIndex: Int, diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpPeripheralContext.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpPeripheralContext.kt index 61285b8a..12fb090d 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpPeripheralContext.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpPeripheralContext.kt @@ -126,8 +126,8 @@ internal fun rememberContextFocusEnvelope( val textMeasurer = rememberTextMeasurer() val focusStyle = rememberRsvpContextTextStyle(fontSizeSp, fontFamily, fontWeight) val frameRange = - remember(frameIndex, frames.size) { - resolveContextEnvelopeFrameRange( + remember(frameIndex, frames) { + resolveThoughtEnvelopeFrameRange(frames, frameIndex) ?: resolveContextEnvelopeFrameRange( frameIndex = frameIndex, frameCount = frames.size, blockSize = CONTEXT_ENVELOPE_BLOCK_FRAMES, @@ -168,6 +168,15 @@ internal fun rememberContextFocusEnvelope( return ContextFocusEnvelope(leftReserve = leftReserve, rightReserve = rightReserve) } +internal fun resolveThoughtEnvelopeFrameRange(frames: List, frameIndex: Int): IntRange? { + val phraseStart = frames.getOrNull(frameIndex)?.phraseStartTokenIndex ?: return null + var start = frameIndex + var end = frameIndex + 1 + while (start > 0 && frames[start - 1].phraseStartTokenIndex == phraseStart) start-- + while (end < frames.size && frames[end].phraseStartTokenIndex == phraseStart) end++ + return start until end +} + @Composable internal fun RsvpPeripheralCueText( text: AnnotatedString, diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenConstants.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenConstants.kt index 819f4edc..d9d2879a 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenConstants.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenConstants.kt @@ -30,8 +30,8 @@ const val SWEEP_SWIPE_THRESHOLD_PX = 30f const val SWEEP_FRAME_STEP = 1 const val REGRESSION_PACE_STEP = 0.06f const val REGRESSION_PACE_MAX_SCALE = 1.18f -const val REGRESSION_RECOVERY_START_FRAMES = 14 -const val REGRESSION_RECOVERY_STEP = 0.006f +const val REGRESSION_RECOVERY_START_PHRASES = 1 +const val REGRESSION_RECOVERY_STEP = 0.02f const val REPLAY_PHRASE_MAX_WORDS = 12 const val POSITIONING_BIAS_PER_PX = 0.0015f diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt index 5faee42e..af869b10 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenEffects.kt @@ -197,7 +197,8 @@ internal fun RsvpSessionResetEffect( runtime.isScrubbing = false runtime.isExiting = false runtime.comprehensionPaceScale = 1f - runtime.stableFramesSinceRegression = 0 + runtime.stablePhrasesSinceRegression = 0 + runtime.replayPreparationPending = false runtime.dragAxis = RsvpDragAxis.NONE runtime.dragAccumulator = ZERO_FLOAT runtime.dragAccumulatorX = ZERO_FLOAT @@ -246,12 +247,14 @@ internal fun RsvpPlaybackLoopEffect( config, runtime.frameIndex, runtime.rampStartFrameIndex, + runtime.resumePreparationScale, ) val resumeDelayMs = RsvpSessionTimingPolicy.resumeDelayMs( config, runtime.frameIndex, runtime.rampStartFrameIndex, + runtime.resumePreparationScale, ) val frameMs = (frame.durationMs * rampMultiplier) @@ -266,31 +269,9 @@ internal fun RsvpPlaybackLoopEffect( scaledMs = floorMs } val now = SystemClock.elapsedRealtime() - val chained = runtime.scheduledFrameIndex == runtime.frameIndex - 1 - val overshootMs = - if (chained && runtime.nextFrameAtMs > 0L) { - (now - runtime.nextFrameAtMs).coerceAtLeast(0L) - } else { - 0L - } - val candidateTarget = - if (chained && runtime.nextFrameAtMs > 0L) { - runtime.nextFrameAtMs + scaledMs - } else { - now + scaledMs - } - val softenedCatchUp = - if (overshootMs > 0L) { - (overshootMs * (1.0 - CATCH_UP_FACTOR)).roundToLong() - } else { - 0L - } - val targetMs = - if (candidateTarget < now) { - now + scaledMs - } else { - candidateTarget + softenedCatchUp - } + // A delayed UI frame must never borrow exposure time from the following thought. + // Schedule the full reading interval; actual effective pace includes rendering overhead. + val targetMs = now + scaledMs runtime.scheduledFrameIndex = runtime.frameIndex runtime.nextFrameAtMs = targetMs val delayMs = (targetMs - now).coerceAtLeast(MIN_FRAME_DELAY_MS) @@ -306,6 +287,7 @@ internal fun RsvpPlaybackLoopEffect( recoverRsvpRegressionPace( runtime = runtime, enabled = config.useRegressionAdaptivePacing, + endsPhrase = frame.endsPhrase, ) runtime.frameIndex += 1 } @@ -337,8 +319,6 @@ internal fun holdAtLoadingFrameBoundary(context: RsvpUiContext) { runtime.nextFrameAtMs = 0L } -private const val CATCH_UP_FACTOR = 0.25 - @Composable internal fun RsvpAutoHideControlsEffect(runtime: RsvpRuntimeState) { LaunchedEffect(runtime.showControls, runtime.isPlaying) { diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt index b3319fa8..b85dbce8 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenInteractions.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput +import com.kairo.reader.core.model.RsvpFrame import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.model.nearestWordIndex @@ -141,8 +142,8 @@ internal fun replayPreviousPhrase(context: RsvpUiContext) { fallbackIndex = context.state.book.startIndex, ) val replayTokenIndex = findReplayPhraseStartTokenIndex(tokens, currentTokenIndex) - val targetFrameIndex = - alignFrameIndex( + val targetFrameIndex = findPlannedReplayFrameIndex(frames, runtime.frameIndex) + ?: alignFrameIndex( frames = frames, tokenIndex = replayTokenIndex, frameIndexMap = context.frameState.frameIndexMap, @@ -154,6 +155,8 @@ internal fun replayPreviousPhrase(context: RsvpUiContext) { } runtime.completed = false runtime.rampStartFrameIndex = runtime.frameIndex + runtime.resumePreparationScale = 1.0 + runtime.replayPreparationPending = !runtime.isPlaying runtime.scheduledFrameIndex = -1 runtime.nextFrameAtMs = 0L registerRsvpRegression(runtime, context.state.profile.config.useRegressionAdaptivePacing) @@ -161,6 +164,21 @@ internal fun replayPreviousPhrase(context: RsvpUiContext) { context.haptics.onFrameStep() } +/** At a phrase entrance replay the preceding thought; inside it replay its first loaded word. */ +internal fun findPlannedReplayFrameIndex(frames: List, frameIndex: Int): Int? { + val current = frames.getOrNull(frameIndex) ?: return null + val phraseStart = current.phraseStartTokenIndex ?: return null + var target = frameIndex + while (target > 0 && frames[target - 1].phraseStartTokenIndex == phraseStart) target-- + if (target < frameIndex) return target + var previous = frameIndex - 1 + while (previous >= 0 && frames[previous].phraseStartTokenIndex == null) previous-- + if (previous < 0) return target + val previousStart = frames[previous].phraseStartTokenIndex + while (previous > 0 && frames[previous - 1].phraseStartTokenIndex == previousStart) previous-- + return previous +} + internal fun findReplayPhraseStartTokenIndex( tokens: List, currentTokenIndex: Int, @@ -212,21 +230,23 @@ internal fun registerRsvpRegression( runtime.comprehensionPaceScale = (runtime.comprehensionPaceScale + REGRESSION_PACE_STEP) .coerceAtMost(REGRESSION_PACE_MAX_SCALE) - runtime.stableFramesSinceRegression = 0 + runtime.stablePhrasesSinceRegression = 0 } internal fun recoverRsvpRegressionPace( runtime: RsvpRuntimeState, enabled: Boolean, + endsPhrase: Boolean, ) { if (!enabled) { runtime.comprehensionPaceScale = 1f - runtime.stableFramesSinceRegression = 0 + runtime.stablePhrasesSinceRegression = 0 return } if (runtime.comprehensionPaceScale <= 1f) return - runtime.stableFramesSinceRegression += 1 - if (runtime.stableFramesSinceRegression < REGRESSION_RECOVERY_START_FRAMES) return + if (!endsPhrase) return + runtime.stablePhrasesSinceRegression += 1 + if (runtime.stablePhrasesSinceRegression <= REGRESSION_RECOVERY_START_PHRASES) return runtime.comprehensionPaceScale = (runtime.comprehensionPaceScale - REGRESSION_RECOVERY_STEP).coerceAtLeast(1f) } @@ -482,6 +502,10 @@ internal fun resumePlayback(runtime: RsvpRuntimeState) { runtime.scheduledFrameIndex = -1 runtime.nextFrameAtMs = 0L runtime.isPlaying = true + if (runtime.replayPreparationPending) { + runtime.resumePreparationScale = 1.0 + runtime.replayPreparationPending = false + } } private val REPLAY_BOUNDARY_PUNCTUATION = diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt index baa64167..7cfa3ebe 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenRuntime.kt @@ -1,6 +1,7 @@ package com.kairo.reader.ui.rsvp import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf @@ -8,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.kairo.reader.core.model.BionicReadingPreferences import com.kairo.reader.core.model.RsvpFrame +import com.kairo.reader.core.rsvp.timing.RsvpSessionTimingPolicy import com.kairo.reader.data.rsvp.RsvpFrameIndexMap internal data class RsvpFrameLoadState( @@ -36,7 +38,10 @@ internal data class RsvpUiContext( internal enum class RsvpDragAxis { NONE, HORIZONTAL, VERTICAL } -internal class RsvpRuntimeState(private val onPlaybackStateChanged: (isPlaying: Boolean, completed: Boolean) -> Unit = { _, _ -> },) { +internal class RsvpRuntimeState( + private val onPlaybackStateChanged: (isPlaying: Boolean, completed: Boolean) -> Unit = { _, _ -> }, + private val monotonicTimeMs: () -> Long = { System.nanoTime() / NANOS_PER_MILLISECOND }, +) { var currentTempoMsPerWord by mutableLongStateOf(0L) var showTempoIndicator by mutableStateOf(false) var showFontSizeIndicator by mutableStateOf(false) @@ -52,7 +57,10 @@ internal class RsvpRuntimeState(private val onPlaybackStateChanged: (isPlaying: var currentFontFamily by mutableStateOf(DEFAULT_FONT_FAMILY) var currentTextBrightness by mutableFloatStateOf(DEFAULT_TEXT_BRIGHTNESS) var comprehensionPaceScale by mutableFloatStateOf(1f) - var stableFramesSinceRegression by mutableIntStateOf(0) + var stablePhrasesSinceRegression by mutableIntStateOf(0) + var resumePreparationScale by mutableDoubleStateOf(1.0) + var replayPreparationPending = false + private var pausedAtMs: Long? = null var frameIndex by mutableIntStateOf(0) var currentTokenIndex by mutableIntStateOf(0) var currentResumeCursor by mutableIntStateOf(0) @@ -64,6 +72,16 @@ internal class RsvpRuntimeState(private val onPlaybackStateChanged: (isPlaying: get() = playbackIsPlaying set(value) { if (playbackIsPlaying == value) return + if (value) { + resumePreparationScale = pausedAtMs?.let { + RsvpSessionTimingPolicy.resumePreparationScale( + (monotonicTimeMs() - it).coerceAtLeast(0L), + ) + } ?: 1.0 + pausedAtMs = null + } else { + pausedAtMs = monotonicTimeMs() + } playbackIsPlaying = value onPlaybackStateChanged(playbackIsPlaying, playbackCompleted) } @@ -92,3 +110,5 @@ internal class RsvpRuntimeState(private val onPlaybackStateChanged: (isPlaying: var wasPlayingBeforeScrub by mutableStateOf(true) var lastPositionSaveMs by mutableLongStateOf(0L) } + +private const val NANOS_PER_MILLISECOND = 1_000_000L diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 01841581..5472d6cd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -225,13 +225,13 @@ Clause cues Continuous ticker Keeps the classic single-focus RSVP display. - Fades the nearest previous word beside the ORP focus. - Adds faint previous and upcoming cues around the ORP focus. + Keeps a faint cue from the previous phrase steady beside the focus. + Keeps faint previous and upcoming cues steady while each phrase plays. Keeps previous and upcoming text moving through the ORP line without resetting at sentence breaks. Context assist - Show a faint previous-word cue beside the ORP focus. + Show quiet context beside the focus. Ease after rereading - Temporarily softens the pace after replaying or scrubbing backwards. + Eases replayed text, then gently returns to your pace over the following phrases. Display Only affects the RSVP screen. Speed limits diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpContextAssistTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpContextAssistTest.kt index 25e6eb96..b2390ff0 100644 --- a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpContextAssistTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpContextAssistTest.kt @@ -517,8 +517,8 @@ class RsvpContextAssistTest { registerRsvpRegression(runtime, enabled = true) assertEquals(1f + REGRESSION_PACE_STEP, runtime.comprehensionPaceScale, 0.0001f) - repeat(REGRESSION_RECOVERY_START_FRAMES + 1) { - recoverRsvpRegressionPace(runtime, enabled = true) + repeat(REGRESSION_RECOVERY_START_PHRASES + 1) { + recoverRsvpRegressionPace(runtime, enabled = true, endsPhrase = true) } assertTrue(runtime.comprehensionPaceScale < 1f + REGRESSION_PACE_STEP) assertTrue(runtime.comprehensionPaceScale >= 1f) diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpThoughtPlaybackTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpThoughtPlaybackTest.kt new file mode 100644 index 00000000..1e380cf5 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpThoughtPlaybackTest.kt @@ -0,0 +1,104 @@ +package com.kairo.reader.ui.rsvp + +import com.kairo.reader.core.model.RsvpFrame +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.timing.RsvpSessionTimingPolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpThoughtPlaybackTest { + @Test + fun pausedReplayKeepsPreparationEvenAfterABriefPause() { + var now = 0L + val runtime = RsvpRuntimeState(monotonicTimeMs = { now }) + runtime.isPlaying = false + runtime.replayPreparationPending = true + now = 100L + resumePlayback(runtime) + assertEquals(1.0, runtime.resumePreparationScale, 0.0) + assertEquals(false, runtime.replayPreparationPending) + } + + @Test + fun replayUsesTheWholePlannedPhraseAndTreatsLaterSplitChunksAsInsideIt() { + val frames = listOf(frame(0, 0, 2), frame(1, 0, 2), frame(2, 2, 4), frame(2, 2, 4), frame(3, 2, 4)) + assertEquals(0, findPlannedReplayFrameIndex(frames, 1)) + assertEquals(0, findPlannedReplayFrameIndex(frames, 2)) + assertEquals(2, findPlannedReplayFrameIndex(frames, 3)) + assertEquals(2, findPlannedReplayFrameIndex(frames, 4)) + } + + @Test + fun recoveryWaitsForPhrasesAndRepeatedReplayRetainsMoreTime() { + val runtime = RsvpRuntimeState() + registerRsvpRegression(runtime, true) + val initial = runtime.comprehensionPaceScale + repeat(100) { recoverRsvpRegressionPace(runtime, true, endsPhrase = false) } + assertEquals(initial, runtime.comprehensionPaceScale) + recoverRsvpRegressionPace(runtime, true, endsPhrase = true) + assertEquals(initial, runtime.comprehensionPaceScale) + registerRsvpRegression(runtime, true) + assertTrue(runtime.comprehensionPaceScale > initial) + repeat(20) { recoverRsvpRegressionPace(runtime, true, endsPhrase = true) } + assertEquals(1f, runtime.comprehensionPaceScale) + } + + @Test + fun resumePreparationMeasuresTheInterruptionWithoutChangingTheSelectedTempo() { + var now = 0L + val runtime = RsvpRuntimeState(monotonicTimeMs = { now }) + runtime.currentTempoMsPerWord = 200L + runtime.isPlaying = false + now = 500L + resumePlayback(runtime) + val brief = runtime.resumePreparationScale + runtime.isPlaying = false + now = 20_000L + resumePlayback(runtime) + assertTrue(brief < runtime.resumePreparationScale) + assertEquals(1.0, runtime.resumePreparationScale, 0.0) + assertEquals(200L, runtime.currentTempoMsPerWord) + assertTrue(RsvpSessionTimingPolicy.resumePreparationScale(-1L) >= 0.0) + } + + @Test + fun peripheralContentAndReservedWidthStayFixedThroughoutAThought() { + val tokens = "before this active thought ends next words".split(" ").map(::word) + val first = frame(2, 2, 5) + val last = frame(4, 2, 5) + assertEquals(resolveStablePeripheralWindow(tokens, first), resolveStablePeripheralWindow(tokens, last)) + val window = requireNotNull(resolveStablePeripheralWindow(tokens, first)) + assertEquals(2, window.focusStartIndex) + assertEquals(5, window.focusEndExclusive) + val frames = listOf(frame(0, 0, 2), frame(1, 0, 2), first, frame(3, 2, 5), last) + assertEquals(2..4, resolveThoughtEnvelopeFrameRange(frames, 2)) + assertEquals(2..4, resolveThoughtEnvelopeFrameRange(frames, 4)) + } + + @Test + fun peripheralContextDoesNotCrossParagraphs() { + val tokens = listOf( + word("before"), + Token("\n", TokenType.PARAGRAPH_BREAK), + word("thought"), + Token("\n", TokenType.PARAGRAPH_BREAK), + word("after") + ) + val window = requireNotNull(resolveStablePeripheralWindow(tokens, frame(2, 2, 3))) + assertEquals(2, window.startIndex) + assertEquals(3, window.endExclusive) + } + + private fun word(text: String) = Token(text, TokenType.WORD) + + private fun frame(index: Int, start: Int, end: Int) = RsvpFrame( + tokens = listOf(word("word")), + durationMs = 200L, + originalTokenIndex = index, + phraseStartTokenIndex = start, + phraseEndTokenIndexExclusive = end, + endsPhrase = index == end - 1, + ) +} From 70975df99ed23f62cf179b5aa67a22519d5704d0 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 7 Sep 2026 22:16:58 +0100 Subject: [PATCH 3/4] refactor(rsvp): replace legacy grouping with scored segmentation --- .../com/kairo/reader/core/model/TokenUtils.kt | 2 +- .../com/kairo/reader/core/rsvp/RsvpEngine.kt | 47 +++---- .../core/rsvp/RsvpEstimatedReadingPace.kt | 4 +- .../reader/core/rsvp/RsvpGenerationOptions.kt | 97 ++------------- .../reader/core/rsvp/RsvpPaceEstimator.kt | 2 +- .../core/rsvp/analysis/RsvpWordPacing.kt | 80 ------------ .../core/rsvp/engine/RsvpUnitBuilder.kt | 37 +----- .../core/rsvp/segmentation/RsvpDpSegmenter.kt | 12 +- .../data/rsvp/RsvpFrameRepositoryImpl.kt | 85 +++++-------- .../ui/navigation/NavigationStateUtils.kt | 2 +- .../kairo/reader/ui/navigation/ReaderRoute.kt | 17 +-- .../kairo/reader/ui/navigation/RsvpRoute.kt | 10 +- .../kairo/reader/ui/rsvp/RsvpScreenModels.kt | 2 +- .../rsvp/ComprehensionRsvpChunkingTest.kt | 18 ++- .../rsvp/ComprehensionRsvpResumeCursorTest.kt | 2 +- .../core/rsvp/RsvpGenerationOptionsTest.kt | 117 ++++++------------ .../reader/core/rsvp/RsvpPaceEstimatorTest.kt | 47 ++----- .../core/rsvp/RsvpPhaseTwoSegmentationTest.kt | 50 ++------ .../core/rsvp/RsvpRhythmSmoothingTest.kt | 19 +-- .../core/rsvp/RsvpScoredSegmentationTest.kt | 28 ++--- .../reader/core/rsvp/RsvpThoughtFlowTest.kt | 2 +- .../data/rsvp/RsvpFrameRepositoryImplTest.kt | 77 ++++++++---- 22 files changed, 232 insertions(+), 525 deletions(-) diff --git a/app/src/main/java/com/kairo/reader/core/model/TokenUtils.kt b/app/src/main/java/com/kairo/reader/core/model/TokenUtils.kt index 40525f18..6e7d1a76 100644 --- a/app/src/main/java/com/kairo/reader/core/model/TokenUtils.kt +++ b/app/src/main/java/com/kairo/reader/core/model/TokenUtils.kt @@ -303,7 +303,7 @@ private fun splitLongWordToken( token.copy( text = text, // Phrase chunking across subword splits is blocked via isSubwordChunk in - // isPhraseChunkCandidate; isClauseBoundary must stay truthful because the timing + // the scored segmenter; isClauseBoundary must stay truthful because the timing // model reads it (clause holds would otherwise fire on every chunk of a long word). isClauseBoundary = if (isLast) token.isClauseBoundary else false, isDialogue = token.isDialogue, diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt index 0b06ef3e..128d09ba 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEngine.kt @@ -88,7 +88,7 @@ class ComprehensionRsvpEngine : RsvpEngine { tokens = tokens, startIndex = startIndex, config = config, - options = RsvpGenerationOptions.LEGACY, + options = RsvpGenerationOptions.DEFAULT, ) override fun generateFrames( @@ -110,7 +110,7 @@ private data class RsvpGenerationContext( val expanded: List, val config: RsvpConfig, val options: RsvpGenerationOptions, - val atomStream: RsvpAtomStream?, + val atomStream: RsvpAtomStream, val analysis: RsvpTokenAnalysis, val frames: MutableList, val state: ContextState, @@ -136,17 +136,12 @@ private fun generateFramesWithNormalizedConfig( expanded = expanded, config = config, options = options, - atomStream = - if (options.usesScoredSegmentation(config)) { - RsvpAtomStream.build( - expandedTokens = expanded, - languagePolicy = options.languagePolicy, - useDialogueDetection = config.useDialogueDetection, - useParentheticalAside = config.useParentheticalAside, - ) - } else { - null - }, + atomStream = RsvpAtomStream.build( + expandedTokens = expanded, + languagePolicy = options.languagePolicy, + useDialogueDetection = config.useDialogueDetection, + useParentheticalAside = config.useParentheticalAside, + ), analysis = analyzeExpandedTokens(expanded, config, options.languagePolicy), frames = mutableListOf(), state = createContextState(tokens, expanded[cursor].originalIndex), @@ -396,7 +391,7 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { val wordCursor = findFirstWordCursor(expanded, cursor) if (wordCursor >= expanded.size) return null val frameStartCursor = cursor - val scoredSelection = selectScoredFrame(cursor) + val selection = selectFrame(cursor) val contextBefore = state.snapshot() val (frameTokens, frameOriginalIndex, nextCursor) = buildUnit( @@ -404,7 +399,7 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { startCursor = cursor, config = config, state = state, - selectedWordCursors = scoredSelection?.selectedWordCursors, + selectedWordCursors = selection.selectedWordCursors, phraseEndTokenIndexExclusive = analysis.thoughtCues[wordCursor]?.endTokenIndexExclusive, ) @@ -432,9 +427,9 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { (frameStartCursor until nextCursor).any { it in analysis.pairedEmDashIndices }, afterPairedEmDash = followsPairedEmDash(wordCursor), rhythmBoundaryStrengthMilli = - scoredSelection?.boundaryStrengthBeforeMilli ?: 0, + selection.boundaryStrengthBeforeMilli, explicitSpeakerTag = - scoredSelection?.dialogueRole == RsvpDialogueRole.SPEAKER_TAG, + selection.dialogueRole == RsvpDialogueRole.SPEAKER_TAG, thoughtCues = unitCues, ), ) @@ -468,17 +463,13 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { return consumeContextPunctuation(nextCursor) } -private fun RsvpGenerationContext.selectScoredFrame(cursor: Int): RsvpSegmentationDecision? { - if (!options.usesScoredSegmentation(config)) return null - val atoms = atomStream ?: return null - return RsvpDpSegmenter - .selectWordCount( - atomStream = atoms, - startCursor = cursor, - config = config, - languagePolicy = options.languagePolicy, - ) -} +private fun RsvpGenerationContext.selectFrame(cursor: Int): RsvpSegmentationDecision = + RsvpDpSegmenter.selectWordCount( + atomStream = atomStream, + startCursor = cursor, + config = config, + languagePolicy = options.languagePolicy, + ) /** * Whether the boundary punctuation directly before this word is the closing (or opening) dash of diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEstimatedReadingPace.kt b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEstimatedReadingPace.kt index 9b2baa2c..eb434918 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEstimatedReadingPace.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpEstimatedReadingPace.kt @@ -23,7 +23,7 @@ object RsvpEstimatedReadingPace { sessionTempoMsPerWord: Long? = null, fallbackEstimatedWpm: Int = 0, languageTag: String? = null, - paceOptions: RsvpPaceEstimationOptions = RsvpPaceEstimationOptions.LEGACY, + paceOptions: RsvpPaceEstimationOptions = RsvpPaceEstimationOptions.DEFAULT, ): Int { val effectiveConfig = sessionTempoMsPerWord @@ -96,7 +96,7 @@ object RsvpEstimatedReadingPace { sessionTempoMsPerWord: Long?, fallbackEstimatedWpm: Int = 0, languageTag: String? = null, - paceOptions: RsvpPaceEstimationOptions = RsvpPaceEstimationOptions.LEGACY, + paceOptions: RsvpPaceEstimationOptions = RsvpPaceEstimationOptions.DEFAULT, ): Int { val effectiveTempoMsPerWord = sessionTempoMsPerWord diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpGenerationOptions.kt b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpGenerationOptions.kt index 218f3afc..88898bab 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpGenerationOptions.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpGenerationOptions.kt @@ -2,7 +2,6 @@ package com.kairo.reader.core.rsvp import com.kairo.reader.core.language.LanguageFamily import com.kairo.reader.core.language.LanguageFamilyClassifier -import com.kairo.reader.core.model.RsvpConfig enum class RsvpLanguagePolicy { ENGLISH, @@ -24,97 +23,25 @@ enum class RsvpLanguagePolicy { } } -enum class RsvpSegmentationStrategy { - LEGACY_GREEDY, - SCORED_DP_V2, -} - -data class RsvpGenerationOptions( - val languagePolicy: RsvpLanguagePolicy = RsvpLanguagePolicy.UNKNOWN, - val segmentationStrategy: RsvpSegmentationStrategy = RsvpSegmentationStrategy.LEGACY_GREEDY, -) { - fun asPaceEstimationOptions(): RsvpPaceEstimationOptions = - if (languagePolicy == RsvpLanguagePolicy.ENGLISH) { - RsvpPaceEstimationOptions(segmentationStrategy = segmentationStrategy) - } else { - // The estimator currently uses an English sample. Until there are representative - // samples for each language family, do not apply a non-English policy to that text. - RsvpPaceEstimationOptions.LEGACY - } +/** Language changes the scoring evidence, never the segmentation implementation. */ +data class RsvpGenerationOptions(val languagePolicy: RsvpLanguagePolicy = RsvpLanguagePolicy.UNKNOWN,) { + // The estimator currently has one English sample. Do not apply another language's + // rules to it; add representative samples before expanding estimation policies. + fun asPaceEstimationOptions(): RsvpPaceEstimationOptions = RsvpPaceEstimationOptions.DEFAULT companion object { - val LEGACY = RsvpGenerationOptions() + val DEFAULT = RsvpGenerationOptions() + + fun fromLanguageTag(languageTag: String?): RsvpGenerationOptions = + RsvpGenerationOptions(RsvpLanguagePolicy.fromLanguageTag(languageTag)) } } -internal fun RsvpGenerationOptions.usesScoredSegmentation(config: RsvpConfig): Boolean = - segmentationStrategy == RsvpSegmentationStrategy.SCORED_DP_V2 && - when (languagePolicy) { - RsvpLanguagePolicy.ENGLISH -> - config.maxWordsPerUnit in SUPPORTED_ENGLISH_SCORED_WORD_COUNTS - RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, - RsvpLanguagePolicy.CJK, - RsvpLanguagePolicy.RTL -> - config.maxWordsPerUnit in SUPPORTED_NON_ENGLISH_SCORED_WORD_COUNTS - RsvpLanguagePolicy.UNKNOWN -> false - } - -data class RsvpPaceEstimationOptions( - val sampleLanguagePolicy: RsvpLanguagePolicy = RsvpLanguagePolicy.ENGLISH, - val segmentationStrategy: RsvpSegmentationStrategy = RsvpSegmentationStrategy.LEGACY_GREEDY, -) { +data class RsvpPaceEstimationOptions(val sampleLanguagePolicy: RsvpLanguagePolicy = RsvpLanguagePolicy.ENGLISH,) { fun asGenerationOptions(): RsvpGenerationOptions = - if (sampleLanguagePolicy == RsvpLanguagePolicy.ENGLISH) { - RsvpGenerationOptions( - languagePolicy = sampleLanguagePolicy, - segmentationStrategy = segmentationStrategy, - ) - } else { - // The fallback still reads the English estimator sample; only grouping is legacy. - RsvpGenerationOptions(languagePolicy = RsvpLanguagePolicy.ENGLISH) - } + RsvpGenerationOptions(languagePolicy = RsvpLanguagePolicy.ENGLISH) companion object { - val LEGACY = RsvpPaceEstimationOptions() - } -} - -object RsvpSegmentationRolloutResolver { - fun resolve( - languageTag: String?, - config: RsvpConfig, - isDebugBuild: Boolean, - ): RsvpGenerationOptions = - resolve( - languagePolicy = RsvpLanguagePolicy.fromLanguageTag(languageTag), - config = config, - isDebugBuild = isDebugBuild, - ) - - fun resolve( - languagePolicy: RsvpLanguagePolicy, - config: RsvpConfig, - isDebugBuild: Boolean, - ): RsvpGenerationOptions { - val scoredOptions = - RsvpGenerationOptions( - languagePolicy = languagePolicy, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, - ) - val strategy = - if ((isDebugBuild || languagePolicy == RsvpLanguagePolicy.ENGLISH) && - scoredOptions.usesScoredSegmentation(config) - ) { - RsvpSegmentationStrategy.SCORED_DP_V2 - } else { - RsvpSegmentationStrategy.LEGACY_GREEDY - } - return RsvpGenerationOptions( - languagePolicy = languagePolicy, - segmentationStrategy = strategy, - ) + val DEFAULT = RsvpPaceEstimationOptions() } } - -private val SUPPORTED_ENGLISH_SCORED_WORD_COUNTS = 1..3 -private val SUPPORTED_NON_ENGLISH_SCORED_WORD_COUNTS = 1..2 diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpPaceEstimator.kt b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpPaceEstimator.kt index d9a3a4ad..69a7f686 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/RsvpPaceEstimator.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/RsvpPaceEstimator.kt @@ -21,7 +21,7 @@ object RsvpPaceEstimator { fun estimateWpm( config: RsvpConfig, - options: RsvpPaceEstimationOptions = RsvpPaceEstimationOptions.LEGACY, + options: RsvpPaceEstimationOptions = RsvpPaceEstimationOptions.DEFAULT, ): Int { val steadyConfig = config.copy( diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPacing.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPacing.kt index 22783028..04e1ef24 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPacing.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPacing.kt @@ -456,22 +456,7 @@ internal fun multiWordPenalty(wordCount: Int): Double = else -> MULTI_WORD_FRAME_PENALTY } -internal enum class PhraseChunkReason { - TIGHT_PAIR, - PRONOUN_AUXILIARY, - AUXILIARY_CONTENT, - COHERENT_SHORT_PAIR, - GENERAL_SHORT_PAIR, - SUBWORD, - TRAILING_HYPHEN, - CLAUSE_BOUNDARY, - COORDINATING_CONJUNCTION, - SEMANTIC_ANCHOR, - NO_AFFINITY, -} - internal data class PhraseChunkPairFeatures( - val legacyCompatible: Boolean, val tightPair: Boolean, val pronounAuxiliaryBridge: Boolean, val auxiliaryContentBridge: Boolean, @@ -480,20 +465,6 @@ internal data class PhraseChunkPairFeatures( val gluePair: Boolean, val bothCommon: Boolean, val coherenceScoreMilli: Int, - val reasons: List, -) - -private data class PhraseChunkDecisionEvidence( - val tightPair: Boolean, - val pronounAuxiliaryBridge: Boolean, - val auxiliaryContentBridge: Boolean, - val coherentShortPair: Boolean, - val generalShortPair: Boolean, - val hasSubword: Boolean, - val trailingHyphen: Boolean, - val clauseBoundary: Boolean, - val coordinatingConjunction: Boolean, - val semanticAnchor: Boolean, ) internal fun analyzePhraseChunkPair( @@ -530,26 +501,7 @@ internal fun analyzePhraseChunkPair( val generalShortPair = (glue && bothShort) || (bothShort && bothCommon) val tightPair = pairKey in TIGHT_PAIR_HINTS - val hasSubword = prev.isSubwordChunk || next.isSubwordChunk - val trailingHyphen = prev.text.endsWith("-") - val clauseBoundary = prev.isClauseBoundary || next.isClauseBoundary - val coordinatingConjunction = ClauseDetector.isCoordinatingConjunction(prevLower) - val semanticAnchor = nextLower in SEMANTIC_ANCHOR_WORDS - val decisionEvidence = - PhraseChunkDecisionEvidence( - tightPair = tightPair, - pronounAuxiliaryBridge = pronounAuxiliaryBridge, - auxiliaryContentBridge = auxiliaryContentBridge, - coherentShortPair = coherentShortPair, - generalShortPair = generalShortPair, - hasSubword = hasSubword, - trailingHyphen = trailingHyphen, - clauseBoundary = clauseBoundary, - coordinatingConjunction = coordinatingConjunction, - semanticAnchor = semanticAnchor, - ) return PhraseChunkPairFeatures( - legacyCompatible = decisionEvidence.isLegacyCompatible(), tightPair = tightPair, pronounAuxiliaryBridge = pronounAuxiliaryBridge, auxiliaryContentBridge = auxiliaryContentBridge, @@ -558,41 +510,9 @@ internal fun analyzePhraseChunkPair( gluePair = glue, bothCommon = bothCommon, coherenceScoreMilli = (coherenceScore.coerceIn(0.0, 1.0) * FIXED_POINT_SCALE).toInt(), - reasons = decisionEvidence.reasons(), ) } -private fun PhraseChunkDecisionEvidence.isLegacyCompatible(): Boolean { - val disqualified = hasSubword || trailingHyphen || clauseBoundary || coordinatingConjunction - return when { - disqualified -> false - tightPair -> true - semanticAnchor -> false - pronounAuxiliaryBridge || auxiliaryContentBridge -> true - else -> coherentShortPair || generalShortPair - } -} - -private fun PhraseChunkDecisionEvidence.reasons(): List = - buildList { - if (tightPair) add(PhraseChunkReason.TIGHT_PAIR) - if (pronounAuxiliaryBridge) add(PhraseChunkReason.PRONOUN_AUXILIARY) - if (auxiliaryContentBridge) add(PhraseChunkReason.AUXILIARY_CONTENT) - if (coherentShortPair) add(PhraseChunkReason.COHERENT_SHORT_PAIR) - if (generalShortPair) add(PhraseChunkReason.GENERAL_SHORT_PAIR) - if (hasSubword) add(PhraseChunkReason.SUBWORD) - if (trailingHyphen) add(PhraseChunkReason.TRAILING_HYPHEN) - if (clauseBoundary) add(PhraseChunkReason.CLAUSE_BOUNDARY) - if (coordinatingConjunction) add(PhraseChunkReason.COORDINATING_CONJUNCTION) - if (semanticAnchor) add(PhraseChunkReason.SEMANTIC_ANCHOR) - if (isEmpty()) add(PhraseChunkReason.NO_AFFINITY) - } - -internal fun isPhraseChunkCandidate( - prev: Token, - next: Token, -): Boolean = analyzePhraseChunkPair(prev, next).legacyCompatible - internal fun terminalWordMultiplier( wordIndex: Int, word: Token, diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpUnitBuilder.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpUnitBuilder.kt index 2e2c8a9a..a9bb2786 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpUnitBuilder.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpUnitBuilder.kt @@ -3,7 +3,6 @@ package com.kairo.reader.core.rsvp.engine import com.kairo.reader.core.model.RsvpConfig import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType -import com.kairo.reader.core.rsvp.analysis.isPhraseChunkCandidate import com.kairo.reader.core.rsvp.segmentation.visibleCodePointCount import com.kairo.reader.core.rsvp.text.isOpeningPunctuation import com.kairo.reader.core.rsvp.text.isQuoteChar @@ -13,18 +12,14 @@ internal fun buildUnit( startCursor: Int, config: RsvpConfig, state: ContextState, - selectedWordCursors: List? = null, + selectedWordCursors: List, phraseEndTokenIndexExclusive: Int? = null, ): UnitBuildResult { val cursor = UnitCursor(expandedTokens, state, startCursor, phraseEndTokenIndexExclusive) cursor.consumeLeadingPunctuation() val firstWord = cursor.consumeFirstWord() ?: return UnitBuildResult(cursor.unitTokens, startCursor, cursor.index) - if (selectedWordCursors == null) { - cursor.consumePhraseWords(firstWord.token, config) - } else { - cursor.consumeSelectedPhraseWords(firstWord, config, selectedWordCursors) - } + cursor.consumeSelectedPhraseWords(firstWord, config, selectedWordCursors) cursor.consumeTrailingPunctuation() return UnitBuildResult( tokens = cursor.unitTokens, @@ -68,34 +63,6 @@ private class UnitCursor( return firstWord } - fun consumePhraseWords( - firstWord: Token, - config: RsvpConfig, - ) { - val maxWords = config.maxWordsPerUnit.coerceAtLeast(1) - if (!config.enablePhraseChunking || maxWords <= 1) return - val maxChars = config.maxCharsPerUnit.coerceAtLeast(1) - var words = 1 - var characters = firstWord.text.length - var canContinue = true - while (words < maxWords && canContinue) { - if (atThoughtBoundary()) return - val candidate = expandedTokens.getOrNull(index)?.token - val previousWord = unitTokens.lastOrNull { it.type == TokenType.WORD } - val combinedCharacters = characters + (candidate?.text?.length ?: 0) - canContinue = - candidate?.type == TokenType.WORD && - previousWord != null && - combinedCharacters <= maxChars && - isPhraseChunkCandidate(previousWord, candidate) - if (canContinue && candidate != null) { - consume(candidate) - words += 1 - characters = combinedCharacters - } - } - } - fun consumeSelectedPhraseWords( firstWord: ExpandedToken, config: RsvpConfig, diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpDpSegmenter.kt b/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpDpSegmenter.kt index 149e8995..d9d78e16 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpDpSegmenter.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/segmentation/RsvpDpSegmenter.kt @@ -28,13 +28,8 @@ internal object RsvpDpSegmenter { val window = buildWindow(atomStream, firstWordCursor, languagePolicy) if (window.words.size <= 1) return singleWordDecision(atomStream, firstWordCursor) - val policyLimit = - if (languagePolicy == RsvpLanguagePolicy.ENGLISH) { - MAX_ENGLISH_SCORED_WORDS_PER_UNIT - } else { - MAX_NON_ENGLISH_SCORED_WORDS_PER_UNIT - } - val candidateLimit = config.maxWordsPerUnit.coerceIn(1, policyLimit) + // The rolling horizon bounds even stale or oversized persisted width settings. + val candidateLimit = config.maxWordsPerUnit.coerceIn(1, RsvpSegmentationWeightsV2.HORIZON_WORDS) val bestScores = IntArray(window.words.size + 1) val bestWidths = IntArray(window.words.size) { 1 } val bestComponents = arrayOfNulls>(window.words.size) @@ -514,7 +509,4 @@ internal object RsvpDpSegmenter { val pairs: List, val artificialHorizon: Boolean, ) - - private const val MAX_ENGLISH_SCORED_WORDS_PER_UNIT = 3 - private const val MAX_NON_ENGLISH_SCORED_WORDS_PER_UNIT = 2 } diff --git a/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImpl.kt b/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImpl.kt index 5ceb812c..68fbccfc 100644 --- a/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImpl.kt +++ b/app/src/main/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImpl.kt @@ -12,7 +12,7 @@ import com.kairo.reader.core.rsvp.RsvpGenerationOptions import com.kairo.reader.core.rsvp.engine.applyPlaybackEffects import com.kairo.reader.core.rsvp.engine.frameTimingKey import com.kairo.reader.core.rsvp.engine.normalizedForPlayback -import com.kairo.reader.core.rsvp.usesScoredSegmentation +import com.kairo.reader.core.rsvp.segmentation.RsvpSegmentationWeightsV2 import com.kairo.reader.data.token.TokenRepository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -74,7 +74,7 @@ class RsvpFrameRepositoryImpl( chapterIndex = chapterIndex, config = config, startIndex = startIndex, - options = RsvpGenerationOptions.LEGACY, + options = RsvpGenerationOptions.DEFAULT, ) override suspend fun getFrames( @@ -141,7 +141,7 @@ class RsvpFrameRepositoryImpl( chapterIndex = chapterIndex, config = config, startIndex = startIndex, - options = RsvpGenerationOptions.LEGACY, + options = RsvpGenerationOptions.DEFAULT, ) } @@ -192,7 +192,7 @@ class RsvpFrameRepositoryImpl( startIndex = startIndex, config = config, maxTokenCount = maxTokenCount, - options = RsvpGenerationOptions.LEGACY, + options = RsvpGenerationOptions.DEFAULT, ) override suspend fun getPreviewFrames( @@ -211,42 +211,29 @@ class RsvpFrameRepositoryImpl( if (safeStartIndex >= visibleEndExclusive) { return RsvpFrameSet(frames = emptyList(), baseTempoMs = config.tempoMsPerWord) } - val useScoredSegmentation = options.usesScoredSegmentation(config) - val endExclusive = - if (useScoredSegmentation) { - previewLookaheadEndExclusive( - tokens = tokens, - visibleEndExclusive = visibleEndExclusive, - requiredWordCount = previewLookaheadWordCount(config), - ) - } else { - visibleEndExclusive - } - // Keep the source prefix available for bracket/quote state reconstruction. + val endExclusive = previewLookaheadEndExclusive( + tokens = tokens, + visibleEndExclusive = visibleEndExclusive, + requiredWordCount = previewLookaheadWordCount(config), + ) + // All previews keep source context and scorer lookahead, including unknown languages. val previewTokens = tokens.subList(0, endExclusive) - val frames = - withContext(previewDispatcher) { + val frames = withContext(previewDispatcher) { + val generated = engine.generateFrames(previewTokens, safeStartIndex, config, options) + val visible = generated.filter { it.displayOriginalEndExclusive <= visibleEndExclusive } + if (visible.isNotEmpty() || !config.enablePhraseChunking) { + visible + } else { + // A tiny preview budget can bisect the first scored group. Ask the same model + // for single-word units until full frames arrive; never expose lookahead text. engine.generateFrames( - tokens = previewTokens, - startIndex = safeStartIndex, - config = config, - options = options, - ) - }.map { frame -> - frame.asPreviewFrame( - tokenCount = tokens.size, - visibleEndExclusive = - visibleEndExclusive.takeIf { useScoredSegmentation }, - ) - }.let { previewFrames -> - if (useScoredSegmentation) { - previewFrames.filter { frame -> - frame.displayOriginalEndExclusive <= visibleEndExclusive - } - } else { - previewFrames - } + previewTokens, + safeStartIndex, + config.copy(enablePhraseChunking = false), + options, + ).filter { it.displayOriginalEndExclusive <= visibleEndExclusive } } + }.map { it.asPreviewFrame(visibleEndExclusive) } return RsvpFrameSet(frames = frames, baseTempoMs = config.tempoMsPerWord) } @@ -330,15 +317,8 @@ class RsvpFrameRepositoryImpl( blinkMode = BlinkMode.OFF, ) - private fun RsvpFrame.asPreviewFrame( - tokenCount: Int, - visibleEndExclusive: Int? = null, - ): RsvpFrame = - copy( - nextOriginalTokenIndex = - nextOriginalTokenIndex - .coerceIn(0, minOf(tokenCount, visibleEndExclusive ?: tokenCount)), - ) + private fun RsvpFrame.asPreviewFrame(visibleEndExclusive: Int): RsvpFrame = + copy(nextOriginalTokenIndex = nextOriginalTokenIndex.coerceIn(0, visibleEndExclusive)) override fun clearCache() { val deferredToCancel = @@ -385,11 +365,11 @@ class RsvpFrameRepositoryImpl( } private fun previewLookaheadWordCount(config: RsvpConfig): Int = - PREVIEW_MIN_LOOKAHEAD_WORDS + - ( - config.rampDownFrames.coerceAtLeast(0) * - config.maxWordsPerUnit.coerceIn(1, PREVIEW_MAX_SCORED_WORDS_PER_FRAME) - ) + ( + RsvpSegmentationWeightsV2.HORIZON_WORDS - 1L + + config.rampDownFrames.coerceAtLeast(0).toLong() * + config.maxWordsPerUnit.coerceIn(1, RsvpSegmentationWeightsV2.HORIZON_WORDS) + ).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() private fun previewLookaheadEndExclusive( tokens: List, @@ -410,6 +390,3 @@ private fun previewLookaheadEndExclusive( } return cursor } - -private const val PREVIEW_MIN_LOOKAHEAD_WORDS = 5 -private const val PREVIEW_MAX_SCORED_WORDS_PER_FRAME = 3 diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/NavigationStateUtils.kt b/app/src/main/java/com/kairo/reader/ui/navigation/NavigationStateUtils.kt index b53fc640..e22a2afa 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/NavigationStateUtils.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/NavigationStateUtils.kt @@ -31,7 +31,7 @@ internal fun rememberReaderEstimatedWpm( fallbackEstimatedWpm: Int, dispatcherProvider: DispatcherProvider, languageTag: String? = null, - paceOptions: RsvpPaceEstimationOptions = RsvpPaceEstimationOptions.LEGACY, + paceOptions: RsvpPaceEstimationOptions = RsvpPaceEstimationOptions.DEFAULT, ): Int { val estimatedWpm by produceState( initialValue = fallbackEstimatedWpm, diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/ReaderRoute.kt b/app/src/main/java/com/kairo/reader/ui/navigation/ReaderRoute.kt index e7ad2058..f02b3b8c 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/ReaderRoute.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/ReaderRoute.kt @@ -46,7 +46,6 @@ import com.kairo.reader.core.model.UserPreferences import com.kairo.reader.core.model.nearestWordIndex import com.kairo.reader.core.rsvp.RsvpConfigResolver import com.kairo.reader.core.rsvp.RsvpGenerationOptions -import com.kairo.reader.core.rsvp.RsvpSegmentationRolloutResolver import com.kairo.reader.ui.reader.FileReaderImageBoundsResolver import com.kairo.reader.ui.reader.ReaderScreen import com.kairo.reader.ui.reader.ReaderUiState @@ -150,7 +149,7 @@ internal fun ReaderRoute(input: ReaderRouteInput) { val resolvedRsvpConfig = RsvpConfigResolver.resolve(prefs.rsvpConfig, book.languageTag) val rsvpGenerationOptions = - rememberReaderRsvpGenerationOptions(container, book.languageTag, resolvedRsvpConfig) + rememberReaderRsvpGenerationOptions(book.languageTag) val readerEstimatedWpm = rememberReaderEstimatedWpm( baseConfig = resolvedRsvpConfig, @@ -252,18 +251,8 @@ internal fun ReaderRoute(input: ReaderRouteInput) { } @Composable -private fun rememberReaderRsvpGenerationOptions( - container: KairoApplication, - languageTag: String?, - config: RsvpConfig, -): RsvpGenerationOptions = - remember(languageTag, config) { - RsvpSegmentationRolloutResolver.resolve( - languageTag = languageTag, - config = config, - isDebugBuild = container.isDebuggableBuild(), - ) - } +private fun rememberReaderRsvpGenerationOptions(languageTag: String?): RsvpGenerationOptions = + remember(languageTag) { RsvpGenerationOptions.fromLanguageTag(languageTag) } @Composable private fun rememberReaderViewModel(container: KairoApplication): ReaderViewModel { diff --git a/app/src/main/java/com/kairo/reader/ui/navigation/RsvpRoute.kt b/app/src/main/java/com/kairo/reader/ui/navigation/RsvpRoute.kt index aa628bcd..b5ec8f3b 100644 --- a/app/src/main/java/com/kairo/reader/ui/navigation/RsvpRoute.kt +++ b/app/src/main/java/com/kairo/reader/ui/navigation/RsvpRoute.kt @@ -18,7 +18,7 @@ import com.kairo.reader.core.model.RsvpFontWeight import com.kairo.reader.core.model.UserPreferences import com.kairo.reader.core.model.buildWordCountByToken import com.kairo.reader.core.rsvp.RsvpConfigResolver -import com.kairo.reader.core.rsvp.RsvpSegmentationRolloutResolver +import com.kairo.reader.core.rsvp.RsvpGenerationOptions import com.kairo.reader.data.sessions.ReadingSessionLocation import com.kairo.reader.ui.rsvp.ReadingPresentationMode import com.kairo.reader.ui.rsvp.RsvpBookContext @@ -127,12 +127,8 @@ internal fun RsvpRoute( val resolvedRsvpConfig = RsvpConfigResolver.resolve(prefs.rsvpConfig, routeData.languageTag) val generationOptions = - remember(routeData.languageTag, resolvedRsvpConfig) { - RsvpSegmentationRolloutResolver.resolve( - languageTag = routeData.languageTag, - config = resolvedRsvpConfig, - isDebugBuild = container.isDebuggableBuild(), - ) + remember(routeData.languageTag) { + RsvpGenerationOptions.fromLanguageTag(routeData.languageTag) } fun saveRsvpPosition( targetChapterIndex: Int, diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenModels.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenModels.kt index e859dce6..22befff7 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenModels.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenModels.kt @@ -31,7 +31,7 @@ data class RsvpBookContext( val startIndex: Int, val startResumeCursor: Int = -1, val sessionStartIndex: Int = startIndex, - val generationOptions: RsvpGenerationOptions = RsvpGenerationOptions.LEGACY, + val generationOptions: RsvpGenerationOptions = RsvpGenerationOptions.DEFAULT, ) data class RsvpProfileContext(val config: RsvpConfig, val selectedProfileId: String, val customProfiles: List,) diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpChunkingTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpChunkingTest.kt index ca6c4944..4948da5d 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpChunkingTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpChunkingTest.kt @@ -114,7 +114,7 @@ class ComprehensionRsvpChunkingTest : ComprehensionRsvpTestBase() { ) val tokens = listOf(w("in"), w("the"), w("house"), w("today")) - val frames = engine.generateFrames(tokens, 0, config) + val frames = engine.generateFrames(tokens, 0, config, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) val firstWords = frames.first().tokens.filter { it.type == TokenType.WORD }.map { it.text } assertEquals(listOf("in", "the", "house"), firstWords) @@ -130,7 +130,7 @@ class ComprehensionRsvpChunkingTest : ComprehensionRsvpTestBase() { ) val tokens = listOf(w("I"), w("was"), w("reading"), w("slowly")) - val frames = engine.generateFrames(tokens, 0, config) + val frames = engine.generateFrames(tokens, 0, config, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) val firstWords = frames.first().tokens.filter { it.type == TokenType.WORD }.map { it.text } @@ -146,8 +146,18 @@ class ComprehensionRsvpChunkingTest : ComprehensionRsvpTestBase() { maxCharsPerUnit = 14, ) - val unhinted = engine.generateFrames(listOf(w("go"), w("not"), w("there")), 0, config) - val hinted = engine.generateFrames(listOf(w("not"), w("yet"), w("ready")), 0, config) + val unhinted = engine.generateFrames( + listOf(w("go"), w("not"), w("there")), + 0, + config, + RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + ) + val hinted = engine.generateFrames( + listOf(w("not"), w("yet"), w("ready")), + 0, + config, + RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + ) assertEquals( listOf("go"), diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpResumeCursorTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpResumeCursorTest.kt index 2220dd33..82c15953 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpResumeCursorTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpResumeCursorTest.kt @@ -75,7 +75,7 @@ class ComprehensionRsvpResumeCursorTest : ComprehensionRsvpTestBase() { ) val tokens = listOf(w("in"), w("the"), w("house"), w("today")) - val firstFrame = engine.generateFrames(tokens, 0, config).first() + val firstFrame = engine.generateFrames(tokens, 0, config, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)).first() assertEquals(0, firstFrame.originalTokenIndex) assertEquals(3, firstFrame.nextOriginalTokenIndex) diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt index 9428cfcf..55f5d884 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpGenerationOptionsTest.kt @@ -1,98 +1,59 @@ package com.kairo.reader.core.rsvp import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class RsvpGenerationOptionsTest { - private val eligibleConfig = - RsvpConfig( - enablePhraseChunking = true, - maxWordsPerUnit = 3, - ) - @Test - fun defaultsRemainLegacyAndConservative() { - assertEquals(RsvpLanguagePolicy.UNKNOWN, RsvpGenerationOptions().languagePolicy) - assertEquals( - RsvpSegmentationStrategy.LEGACY_GREEDY, - RsvpGenerationOptions().segmentationStrategy, + fun languageResolutionHasNoBuildOrWidthGate() { + val fixtures = mapOf( + "en-GB" to RsvpLanguagePolicy.ENGLISH, + "eng" to RsvpLanguagePolicy.ENGLISH, + "fr" to RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, + "ja" to RsvpLanguagePolicy.CJK, + "ar" to RsvpLanguagePolicy.RTL, + null to RsvpLanguagePolicy.UNKNOWN, + "und" to RsvpLanguagePolicy.UNKNOWN, ) + fixtures.forEach { (tag, policy) -> + assertEquals(policy, RsvpGenerationOptions.fromLanguageTag(tag).languagePolicy) + } } @Test - fun releaseRolloutUsesScoredEnglishAndKeepsOtherLanguagesConservative() { + fun everyLanguageAndPersistedWidthUsesBoundedScoredUnitsWithoutLosingText() { + val tokens = listOf("a", "b", "c", "d", "e", "f", "g").map { Token(it, TokenType.WORD) } + val engine = ComprehensionRsvpEngine() RsvpLanguagePolicy.entries.forEach { policy -> - assertEquals( - if (policy == RsvpLanguagePolicy.ENGLISH) { - RsvpSegmentationStrategy.SCORED_DP_V2 - } else { - RsvpSegmentationStrategy.LEGACY_GREEDY - }, - RsvpSegmentationRolloutResolver.resolve( - languagePolicy = policy, - config = eligibleConfig, - isDebugBuild = false, - ).segmentationStrategy, - ) + listOf(1, 2, 3, 4, 6, Int.MAX_VALUE).forEach { width -> + val config = RsvpConfig(enablePhraseChunking = true, maxWordsPerUnit = width, maxCharsPerUnit = 30) + val frames = engine.generateFrames(tokens, 0, config, RsvpGenerationOptions(policy)) + assertEquals(tokens.map(Token::text), frames.flatMap { it.tokens }.map(Token::text)) + assertTrue(frames.all { it.tokens.size in 1..minOf(width, 6) }) + assertTrue(frames.all { it.durationMs > 0 }) + assertTrue(frames.all { it.phraseStartTokenIndex != null }) + } } } @Test - fun debugRolloutUsesExplicitLanguagePoliciesWithinTheirSupportedWidths() { - assertEquals( - RsvpSegmentationStrategy.SCORED_DP_V2, - RsvpSegmentationRolloutResolver.resolve( - languageTag = "en-GB", - config = eligibleConfig, - isDebugBuild = true, - ).segmentationStrategy, - ) - assertEquals( - RsvpSegmentationStrategy.SCORED_DP_V2, - RsvpSegmentationRolloutResolver.resolve( - languageTag = "eng", - config = eligibleConfig, - isDebugBuild = true, - ).segmentationStrategy, - ) - listOf("fr", "ja", "ar").forEach { languageTag -> - assertEquals( - RsvpSegmentationStrategy.SCORED_DP_V2, - RsvpSegmentationRolloutResolver.resolve( - languageTag = languageTag, - config = eligibleConfig.copy(maxWordsPerUnit = 2), - isDebugBuild = true, - ).segmentationStrategy, - ) - } - assertEquals( - RsvpSegmentationStrategy.SCORED_DP_V2, - RsvpSegmentationRolloutResolver.resolve( - languageTag = "en", - config = eligibleConfig.copy(enablePhraseChunking = false), - isDebugBuild = true, - ).segmentationStrategy, - ) + fun unknownLanguageCanGroupWithoutEnablingEnglishRules() { + val tokens = listOf("le", "chat").map { Token(it, TokenType.WORD) } + val config = RsvpConfig(enablePhraseChunking = true, maxWordsPerUnit = 2) + val frames = ComprehensionRsvpEngine().generateFrames(tokens, 0, config) + assertEquals(listOf("le", "chat"), frames.first().tokens.map(Token::text)) + assertEquals(RsvpLanguagePolicy.UNKNOWN, RsvpGenerationOptions.DEFAULT.languagePolicy) + } - val ineligible = - listOf( - RsvpSegmentationRolloutResolver.resolve("fr", eligibleConfig, true), - RsvpSegmentationRolloutResolver.resolve(null, eligibleConfig, true), - RsvpSegmentationRolloutResolver.resolve( - "fr", - eligibleConfig.copy(maxWordsPerUnit = 3), - true, - ), - RsvpSegmentationRolloutResolver.resolve( - "en", - eligibleConfig.copy(maxWordsPerUnit = 4), - true, - ), - ) - assertEquals( - List(ineligible.size) { RsvpSegmentationStrategy.LEGACY_GREEDY }, - ineligible.map(RsvpGenerationOptions::segmentationStrategy), - ) + @Test + fun everyPaceEstimateUsesTheLanguageOfItsActualSample() { + RsvpLanguagePolicy.entries.forEach { policy -> + val sample = RsvpGenerationOptions(policy).asPaceEstimationOptions().asGenerationOptions() + assertEquals(RsvpLanguagePolicy.ENGLISH, sample.languagePolicy) + } } } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPaceEstimatorTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPaceEstimatorTest.kt index 6f67cc5e..33402375 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPaceEstimatorTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPaceEstimatorTest.kt @@ -16,56 +16,33 @@ class RsvpPaceEstimatorTest { } @Test - fun paceCacheIdentitySeparatesLegacyFromEnglishScoredStrategy() { - val config = - RsvpConfig( - enablePhraseChunking = true, - maxWordsPerUnit = 3, - maxCharsPerUnit = 24, - ) - val legacyOptions = RsvpPaceEstimationOptions.LEGACY - val scoredOptions = - RsvpPaceEstimationOptions( - sampleLanguagePolicy = RsvpLanguagePolicy.ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, - ) - val legacy = - RsvpEstimatedReadingPace.estimateWpm( - config = config, - paceOptions = legacyOptions, - ) - val scored = - RsvpEstimatedReadingPace.estimateWpm( - config = config, - paceOptions = scoredOptions, - ) - - assertTrue(legacy > 0) - assertTrue(scored > 0) - assertNotEquals( - EstimatedWpmCacheKey(config, legacyOptions, targetLanguageTag = "en"), - EstimatedWpmCacheKey(config, scoredOptions, targetLanguageTag = "en"), - ) + fun paceCacheRetainsConfigurationAndTargetLanguageIdentity() { + val config = RsvpConfig(enablePhraseChunking = true, maxWordsPerUnit = 3) + val options = RsvpPaceEstimationOptions.DEFAULT + assertTrue(RsvpEstimatedReadingPace.estimateWpm(config, paceOptions = options) > 0) + val english = EstimatedWpmCacheKey(config, options, targetLanguageTag = "en") + assertNotEquals(english, EstimatedWpmCacheKey(config, options, targetLanguageTag = "fr")) + assertNotEquals(english, EstimatedWpmCacheKey(config.copy(maxWordsPerUnit = 1), options, targetLanguageTag = "en")) } @Test - fun nonEnglishSamplePolicyDoesNotApplyEnglishScoringToEnglishSample() { + fun unsupportedSamplePolicyStillUsesTheActualEnglishSample() { val config = RsvpConfig( enablePhraseChunking = true, maxWordsPerUnit = 3, maxCharsPerUnit = 24, ) - val legacy = RsvpPaceEstimator.estimateWpm(config) - val ineligibleScored = + val defaultEstimate = RsvpPaceEstimator.estimateWpm(config) + val normalizedEstimate = RsvpPaceEstimator.estimateWpm( config, RsvpPaceEstimationOptions( sampleLanguagePolicy = RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, + ), ) - assertEquals(legacy, ineligibleScored) + assertEquals(defaultEstimate, normalizedEstimate) } } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPhaseTwoSegmentationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPhaseTwoSegmentationTest.kt index cf40bb31..7b9fdf91 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPhaseTwoSegmentationTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPhaseTwoSegmentationTest.kt @@ -14,7 +14,6 @@ import com.kairo.reader.core.rsvp.segmentation.RsvpSegmentationReason import com.kairo.reader.core.tokenization.TokenizerRegistry import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -40,10 +39,8 @@ class RsvpPhaseTwoSegmentationTest { fun namedEntitiesCreateObservableGroupingWithoutOverridingHardLimits() { val entity = listOf(word("New"), word("York"), word("City"), word("expanded")) - val legacy = engine.generateFrames(entity, 0, config, RsvpGenerationOptions.LEGACY) val scored = engine.generateFrames(entity, 0, config, englishOptions) - assertNotEquals(listOf("New", "York", "City"), legacy.first().words()) assertEquals(listOf("New", "York", "City"), scored.first().words()) val connector = @@ -248,13 +245,6 @@ class RsvpPhaseTwoSegmentationTest { assertTrue(wordRoles.all { it == RsvpDialogueRole.DIALOGUE_CONTENT }) val playbackConfig = config.copy(maxWordsPerUnit = 2, useDialogueDetection = true) - val legacyFrames = - engine.generateFrames( - tokens = tokens, - startIndex = 0, - config = playbackConfig, - options = RsvpGenerationOptions.LEGACY, - ) val frames = engine.generateFrames( tokens = tokens, @@ -262,35 +252,15 @@ class RsvpPhaseTwoSegmentationTest { config = playbackConfig, options = options(fixture.policy), ) - // Native quote scanning annotates roles only. Visibility and exact-start punctuation - // ownership remain the legacy builder's contract (for example, leading « is - // context-only today). - assertEquals( - legacyFrames.map { frame -> frame.tokens.map(Token::text) }, - frames.map { frame -> frame.tokens.map(Token::text) }, - ) - assertEquals( - legacyFrames.map { frame -> - listOf( - frame.resumeCursor, - frame.nextOriginalTokenIndex, - frame.displayOriginalStartIndex, - frame.displayOriginalEndExclusive, - frame.displayOriginalStartCharacterOffset, - frame.displayOriginalEndCharacterOffset, - ) - }, - frames.map { frame -> - listOf( - frame.resumeCursor, - frame.nextOriginalTokenIndex, - frame.displayOriginalStartIndex, - frame.displayOriginalEndExclusive, - frame.displayOriginalStartCharacterOffset, - frame.displayOriginalEndCharacterOffset, - ) - }, - ) + // Re-generating from each visual source start must preserve opening punctuation + // as well as the logical word cursor, regardless of native quote forms. + frames.forEach { frame -> + val resumed = engine.generateFrames(tokens, frame.displayOriginalStartIndex, playbackConfig, options(fixture.policy)) + assertEquals(frame.tokens.map(Token::text), resumed.first().tokens.map(Token::text)) + assertEquals(frame.resumeCursor, resumed.first().resumeCursor) + assertEquals(frame.displayOriginalStartIndex, resumed.first().displayOriginalStartIndex) + assertEquals(frame.displayOriginalEndExclusive, resumed.first().displayOriginalEndExclusive) + } assertTrue(frames.any { it.words().size == 2 }) } } @@ -327,7 +297,7 @@ class RsvpPhaseTwoSegmentationTest { private fun options(policy: RsvpLanguagePolicy): RsvpGenerationOptions = RsvpGenerationOptions( languagePolicy = policy, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, + ) private fun tokenize( diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpRhythmSmoothingTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpRhythmSmoothingTest.kt index 32342a55..8fa25f36 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpRhythmSmoothingTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpRhythmSmoothingTest.kt @@ -81,7 +81,7 @@ class RsvpRhythmSmoothingTest { } @Test - fun scoredClauseTransitionChangesTheNextCadenceWithoutChangingFrameOwnership() { + fun scoredSingleWordReadingPreservesFrameOwnershipAcrossSmoothingSettings() { val config = RsvpConfig( tempoMsPerWord = 150L, @@ -109,7 +109,12 @@ class RsvpRhythmSmoothingTest { Token(text = "it", type = TokenType.WORD, frequencyScore = 1.0), ) val engine = ComprehensionRsvpEngine() - val legacy = engine.generateFrames(tokens, 0, config, RsvpGenerationOptions.LEGACY) + val unsmoothed = engine.generateFrames( + tokens, + 0, + config.copy(smoothingAlpha = 1.0), + RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + ) val scored = engine.generateFrames( tokens, @@ -117,14 +122,14 @@ class RsvpRhythmSmoothingTest { config, RsvpGenerationOptions( languagePolicy = RsvpLanguagePolicy.ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, + ), ) - assertEquals(legacy.map(RsvpFrame::tokens), scored.map(RsvpFrame::tokens)) - assertEquals(legacy.first().durationMs, scored.first().durationMs) - assertNotEquals(legacy[1].durationMs, scored[1].durationMs) - assertTrue(scored[1].durationMs < legacy[1].durationMs) + assertEquals(unsmoothed.map(RsvpFrame::tokens), scored.map(RsvpFrame::tokens)) + assertEquals(unsmoothed.first().durationMs, scored.first().durationMs) + assertNotEquals(unsmoothed[1].durationMs, scored[1].durationMs) + assertTrue(scored[1].durationMs > unsmoothed[1].durationMs) } private fun rhythm(alpha: Double): RhythmState = diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpScoredSegmentationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpScoredSegmentationTest.kt index 58c1bcf4..3e80cfeb 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpScoredSegmentationTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpScoredSegmentationTest.kt @@ -32,18 +32,18 @@ class RsvpScoredSegmentationTest { private val scoredOptions = RsvpGenerationOptions( languagePolicy = RsvpLanguagePolicy.ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, + ) @Test - fun defaultOverloadMatchesExplicitLegacyFrames() { + fun defaultOverloadMatchesExplicitUnknownLanguageScoring() { val tokens = listOf(word("in"), word("the"), word("quiet"), word("house")) val defaultFrames = engine.generateFrames(tokens, 0, stableConfig) - val explicitLegacy = - engine.generateFrames(tokens, 0, stableConfig, RsvpGenerationOptions.LEGACY) + val explicitDefault = + engine.generateFrames(tokens, 0, stableConfig, RsvpGenerationOptions.DEFAULT) - assertEquals(defaultFrames, explicitLegacy) + assertEquals(defaultFrames, explicitDefault) } @Test @@ -55,10 +55,8 @@ class RsvpScoredSegmentationTest { word("puzzle", frequency = 0.0, complexity = 1.8, syllables = 3), ) - val legacy = engine.generateFrames(tokens, 0, stableConfig) val scored = engine.generateFrames(tokens, 0, stableConfig, scoredOptions) - assertEquals(listOf("in", "the", "puzzle"), legacy.first().words()) assertEquals(listOf("in", "the"), scored.first().words()) assertEquals(listOf("puzzle"), scored[1].words()) } @@ -117,14 +115,14 @@ class RsvpScoredSegmentationTest { } @Test - fun persistedWidthsAboveThreeStayOnLegacyPath() { - val config = stableConfig.copy(maxWordsPerUnit = 4, maxCharsPerUnit = 40) - val tokens = listOf(word("in"), word("the"), word("quiet"), word("old"), word("house")) - - val legacy = engine.generateFrames(tokens, 0, config, RsvpGenerationOptions.LEGACY) - val explicitScored = engine.generateFrames(tokens, 0, config, scoredOptions) - - assertEquals(legacy, explicitScored) + fun persistedWidthsAboveThreeRetainDifficultyAwareScoring() { + val tokens = listOf(word("in"), word("the"), word("puzzle", frequency = 0.0, complexity = 1.8, syllables = 3)) + listOf(4, 6, Int.MAX_VALUE).forEach { width -> + val config = stableConfig.copy(maxWordsPerUnit = width, maxCharsPerUnit = 40) + val frames = engine.generateFrames(tokens, 0, config, scoredOptions) + assertEquals(listOf("in", "the"), frames.first().words()) + assertEquals(listOf("puzzle"), frames[1].words()) + } } @Test diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt index 5bd82ad4..fe66e927 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpThoughtFlowTest.kt @@ -72,7 +72,7 @@ class RsvpThoughtFlowTest : ComprehensionRsvpTestBase() { fun phraseMetadataAndSourceCoverageSurviveGroupingAndSplitWords() { val tokens = listOf(w("in"), w("the"), w("extraordinary"), p(","), w("but"), w("not"), w("today"), p(".")) val config = stableConfig.copy(enablePhraseChunking = true, maxWordsPerUnit = 2, maxChunkLength = 6) - val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH, RsvpSegmentationStrategy.SCORED_DP_V2) + val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) val frames = engine.generateFrames(tokens, 0, config, options) val words = frames.filter { it.tokens.any { token -> token.type == TokenType.WORD } } assertEquals( diff --git a/app/src/test/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImplTest.kt b/app/src/test/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImplTest.kt index 790f3871..c0d389e7 100644 --- a/app/src/test/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImplTest.kt +++ b/app/src/test/java/com/kairo/reader/data/rsvp/RsvpFrameRepositoryImplTest.kt @@ -11,7 +11,6 @@ import com.kairo.reader.core.rsvp.ComprehensionRsvpEngine import com.kairo.reader.core.rsvp.RsvpEngine import com.kairo.reader.core.rsvp.RsvpGenerationOptions import com.kairo.reader.core.rsvp.RsvpLanguagePolicy -import com.kairo.reader.core.rsvp.RsvpSegmentationStrategy import com.kairo.reader.data.token.TokenRepository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -233,7 +232,7 @@ class RsvpFrameRepositoryImplTest { val scored = RsvpGenerationOptions( languagePolicy = RsvpLanguagePolicy.ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, + ) var frameSet: RsvpFrameSet? = null @@ -285,7 +284,8 @@ class RsvpFrameRepositoryImplTest { val frames = requireNotNull(preview).frames assertEquals(listOf(4), engine.startIndexes) - assertEquals(listOf(7), engine.tokenCounts) + // The engine receives lookahead, but displayed frames keep the requested range. + assertEquals(listOf(10), engine.tokenCounts) assertEquals(3, frames.size) assertEquals(4, frames.first().originalTokenIndex) assertEquals(7, frames.last().nextOriginalTokenIndex) @@ -302,21 +302,21 @@ class RsvpFrameRepositoryImplTest { val scored = RsvpGenerationOptions( languagePolicy = RsvpLanguagePolicy.ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, + ) - val legacyRequest = backgroundScope.launch { - repository.getFrames(bookId, 0, RsvpConfig(), options = RsvpGenerationOptions.LEGACY) + val unknownRequest = backgroundScope.launch { + repository.getFrames(bookId, 0, RsvpConfig(), options = RsvpGenerationOptions.DEFAULT) } advanceUntilIdle() - legacyRequest.join() + unknownRequest.join() val scoredRequest = backgroundScope.launch { repository.getFrames(bookId, 0, RsvpConfig(), options = scored) } advanceUntilIdle() scoredRequest.join() - assertEquals(listOf(RsvpGenerationOptions.LEGACY, scored), engine.generationOptions) + assertEquals(listOf(RsvpGenerationOptions.DEFAULT, scored), engine.generationOptions) assertEquals(listOf(0, 0), engine.startIndexes) assertEquals(2, repository.cacheSize()) } @@ -329,7 +329,7 @@ class RsvpFrameRepositoryImplTest { val scored = RsvpGenerationOptions( languagePolicy = RsvpLanguagePolicy.ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, + ) val tokens = (0 until 12).map { index -> Token(text = "w$index", type = TokenType.WORD) } var preview: RsvpFrameSet? = null @@ -362,7 +362,7 @@ class RsvpFrameRepositoryImplTest { } @Test - fun previewFallbacksUseOnlyThePriorVisibleSlice() = runTest { + fun everyLanguageAndWidthReceivesScoredPreviewLookahead() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) val engine = CountingEngine() val repository = repository(dispatcher, engine) @@ -375,19 +375,19 @@ class RsvpFrameRepositoryImplTest { val scoredEnglish = RsvpGenerationOptions( languagePolicy = RsvpLanguagePolicy.ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, + ) - val fallbackCases = + val policyCases = listOf( RsvpGenerationOptions( languagePolicy = RsvpLanguagePolicy.ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.LEGACY_GREEDY, + ) to eligibleConfig, scoredEnglish.copy(languagePolicy = RsvpLanguagePolicy.UNKNOWN) to eligibleConfig, scoredEnglish to eligibleConfig.copy(maxWordsPerUnit = 4), ) - fallbackCases.forEach { (options, config) -> + policyCases.forEach { (options, config) -> val request = backgroundScope.launch { repository.getPreviewFrames( @@ -402,11 +402,11 @@ class RsvpFrameRepositoryImplTest { request.join() } - assertEquals(List(fallbackCases.size) { 4 }, engine.tokenCounts) + assertEquals(List(policyCases.size) { 12 }, engine.tokenCounts) } @Test - fun legacyPreviewFramesAndDurationsMatchDirectVisibleSliceGeneration() = runTest { + fun defaultPreviewMatchesFullLookaheadWithoutExposingTailText() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) val engine = ComprehensionRsvpEngine() val repository = repository(dispatcher, engine) @@ -425,7 +425,7 @@ class RsvpFrameRepositoryImplTest { val visibleTokens = tokens.take(4) val expected = engine.generateFrames( - tokens = visibleTokens, + tokens = tokens, startIndex = 0, config = config, ) @@ -444,11 +444,38 @@ class RsvpFrameRepositoryImplTest { advanceUntilIdle() request.join() - assertEquals(expected, requireNotNull(preview).frames) + assertEquals( + expected.filter { it.displayOriginalEndExclusive <= visibleTokens.size } + .map { it.copy(nextOriginalTokenIndex = it.nextOriginalTokenIndex.coerceAtMost(visibleTokens.size)) }, + requireNotNull(preview).frames, + ) } @Test - fun concurrentStrategiesDoNotShareInFlightGeneration() = runTest { + fun singleTokenPreviewDoesNotDisappearOrExposeScoredLookahead() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val engine = ComprehensionRsvpEngine() + val repository = repository(dispatcher, engine) + val tokens = listOf("in", "the", "quiet", "library") + .map { Token(text = it, type = TokenType.WORD) } + val config = RsvpConfig(enablePhraseChunking = true, maxWordsPerUnit = 3) + + for (policy in listOf(RsvpLanguagePolicy.UNKNOWN, RsvpLanguagePolicy.ENGLISH)) { + val options = RsvpGenerationOptions(languagePolicy = policy) + assertTrue(engine.generateFrames(tokens, 0, config, options).first().tokens.count { it.type == TokenType.WORD } > 1) + val request = backgroundScope.async { + repository.getPreviewFrames(tokens, 0, config, maxTokenCount = 1, options = options) + } + advanceUntilIdle() + val frames = request.await().frames + assertEquals(listOf("in"), frames.flatMap(RsvpFrame::tokens).map(Token::text)) + assertTrue(frames.all { it.displayOriginalEndExclusive <= 1 }) + assertEquals(1, frames.last().nextOriginalTokenIndex) + } + } + + @Test + fun concurrentLanguagePoliciesDoNotShareInFlightGeneration() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) val engine = CountingEngine() val repository = repository(dispatcher, engine) @@ -456,20 +483,20 @@ class RsvpFrameRepositoryImplTest { val scored = RsvpGenerationOptions( languagePolicy = RsvpLanguagePolicy.ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, + ) - val legacy = backgroundScope.async { - repository.getFrames(bookId, 0, RsvpConfig(), options = RsvpGenerationOptions.LEGACY) + val unknown = backgroundScope.async { + repository.getFrames(bookId, 0, RsvpConfig(), options = RsvpGenerationOptions.DEFAULT) } val scoredRequest = backgroundScope.async { repository.getFrames(bookId, 0, RsvpConfig(), options = scored) } advanceUntilIdle() - legacy.await() + unknown.await() scoredRequest.await() - assertEquals(setOf(RsvpGenerationOptions.LEGACY, scored), engine.generationOptions.toSet()) + assertEquals(setOf(RsvpGenerationOptions.DEFAULT, scored), engine.generationOptions.toSet()) assertEquals(2, engine.startIndexes.size) } @@ -481,7 +508,7 @@ class RsvpFrameRepositoryImplTest { val scored = RsvpGenerationOptions( languagePolicy = RsvpLanguagePolicy.ENGLISH, - segmentationStrategy = RsvpSegmentationStrategy.SCORED_DP_V2, + ) repository.prefetchFrames( From 29e44699cc0e16cb75f4f58e12197678caf8f952 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Tue, 8 Sep 2026 08:26:50 +0100 Subject: [PATCH 4/4] build: update Kotlin to satisfy dependency lint --- README.md | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8596ae45..1da79374 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ The reader, tokenizer, persistence layer, and RSVP engine all work toward the sa ## Built With -- Kotlin 2.4.10 +- Kotlin 2.4.20 - Jetpack Compose - AndroidX Navigation - Room diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0ed6b8e..09ea10d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "9.4.0" -kotlin = "2.4.10" +kotlin = "2.4.20" compileSdk = "37" targetSdk = "37" minSdk = "24"