From 70133edb0dcc1227ca422f8e5e7c44f714997ed0 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 14 Sep 2026 21:45:22 +0100 Subject: [PATCH 01/16] feat(rsvp): bundle reproducible English frequency data --- .../main/assets/licenses/wordfreq-NOTICE.md | 76 ++++++++++++++++++ .../reading/english-frequency.tsv.gz | Bin 0 -> 132195 bytes scripts/export-reading-frequency.py | 23 ++++++ 3 files changed, 99 insertions(+) create mode 100644 app/src/main/assets/licenses/wordfreq-NOTICE.md create mode 100644 app/src/main/resources/reading/english-frequency.tsv.gz create mode 100644 scripts/export-reading-frequency.py diff --git a/app/src/main/assets/licenses/wordfreq-NOTICE.md b/app/src/main/assets/licenses/wordfreq-NOTICE.md new file mode 100644 index 00000000..7c5141c2 --- /dev/null +++ b/app/src/main/assets/licenses/wordfreq-NOTICE.md @@ -0,0 +1,76 @@ +Kairo English frequency lookup + +Derived from wordfreq 3.1.1 by Robyn Speer: https://github.com/rspeer/wordfreq +Kairo filters the first 30,000 entries to English alphabetic words and contractions, +exports integer hundredths of Zipf frequency, sorts and gzip-compresses the table. +This adapted data is licensed under CC BY-SA 4.0: +https://creativecommons.org/licenses/by-sa/4.0/ +Source and reproducible exporter: scripts/export-reading-frequency.py. +No wordfreq runtime code is bundled. SUBTLEX, by Marc Brysbaert et al., is freely available data. + +Upstream attribution follows: + +wordfreq +Copyright 2022 Robyn Speer + +# Attribution notes + +Robyn Speer must be credited as Robyn Speer, which is her maiden name, used on academic work. +Crediting her as Elia Robyn Lake (her married name) will make the credit less effective, as it will +not line up with other work. + +Crediting Robyn Speer by a different name than one of the above is a serious violation of the license, +in which case you do not have permission to use, copy, or redistribute wordfreq. + +If you use wordfreq in academic work, you must cite it. See "Citing wordfreq" in README.md. + +# Included licenses + +`wordfreq` is freely redistributable under the Apache license (see +`LICENSE.txt`), and it includes data files that may be +redistributed under a Creative Commons Attribution-ShareAlike 4.0 +license (). + +`wordfreq` contains data extracted from Google Books Ngrams +() and Google Books Syntactic Ngrams +(). +The terms of use of this data are: + + Ngram Viewer graphs and data may be freely used for any purpose, although + acknowledgement of Google Books Ngram Viewer as the source, and inclusion + of a link to http://books.google.com/ngrams, would be appreciated. + +`wordfreq` also contains data derived from the following Creative Commons-licensed +sources: + +- The Leeds Internet Corpus, from the University of Leeds Centre for Translation + Studies () + +- Wikipedia, the free encyclopedia () + +- ParaCrawl, a multilingual Web crawl () + +It contains data from OPUS OpenSubtitles 2018 +(), whose data originates from the +OpenSubtitles project () and may be used with +attribution to OpenSubtitles. + +It contains data from various SUBTLEX word lists: SUBTLEX-US, SUBTLEX-UK, +SUBTLEX-CH, SUBTLEX-DE, and SUBTLEX-NL, created by Marc Brysbaert et al. +(see citations below) and available at +. + +I (Robyn Speer) have obtained permission by e-mail from Marc Brysbaert to +distribute these wordlists in wordfreq, to be used for any purpose, not just +for academic use, under these conditions: + +- Wordfreq and code derived from it must credit the SUBTLEX authors. +- It must remain clear that SUBTLEX is freely available data. + +These terms are similar to the Creative Commons Attribution-ShareAlike license. + +Some additional data was collected by a custom application that watched the +streaming Twitter API, in accordance with Twitter's Developer Agreement & +Policy. This software gives statistics about words that were commonly used on +Twitter; it does not display or republish any Twitter content, and does not +contain any content from after Twitter's sale. diff --git a/app/src/main/resources/reading/english-frequency.tsv.gz b/app/src/main/resources/reading/english-frequency.tsv.gz new file mode 100644 index 0000000000000000000000000000000000000000..3a47e12017f083b6e674e83ab29d7eded98b8c47 GIT binary patch literal 132195 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 Date: Mon, 14 Sep 2026 21:45:46 +0100 Subject: [PATCH 02/16] feat(rsvp): persist reading support and preserve legacy profiles --- .../com/kairo/reader/core/model/Models.kt | 16 +++++++--- .../core/model/RsvpConfigConstraints.kt | 1 + .../reader/core/model/RsvpProfileDefaults.kt | 4 +++ .../rsvp/engine/RsvpConfigNormalization.kt | 3 ++ .../kairo/reader/data/preferences/PrefKeys.kt | 2 ++ .../preferences/RsvpConfigPreferenceCodec.kt | 8 +++++ .../data/preferences/RsvpProfileJsonCodec.kt | 11 +++++++ .../core/model/RsvpProfileDefaultsTest.kt | 2 +- .../preferences/RsvpPreferenceCodecsTest.kt | 31 +++++++++++++++++++ 9 files changed, 73 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/kairo/reader/core/model/Models.kt b/app/src/main/java/com/kairo/reader/core/model/Models.kt index ef469717..ec539081 100644 --- a/app/src/main/java/com/kairo/reader/core/model/Models.kt +++ b/app/src/main/java/com/kairo/reader/core/model/Models.kt @@ -114,7 +114,7 @@ data class RsvpConfig( * Tempo in milliseconds for a baseline, easy word. * * This is the primary speed control for the engine. The actual time per unit is then shaped - * by readability floors, difficulty (length/syllables/rarity/complexity), punctuation, context, + * by readability floors, bounded reading demand, punctuation, context, * and rhythm smoothing. * * An estimated WPM can be derived from this, but WPM is not the direct control. @@ -128,7 +128,8 @@ data class RsvpConfig( val longWordMinMs: Long = 120L, val longWordChars: Int = 10, /** - * Difficulty model. + * Legacy difficulty fields retained only for lossless preference/profile round trips. + * Playback uses difficultWordSupport and the unified ReadingDemandAnalyzer instead. * - syllableExtraMs: additional time per syllable beyond the first. * - rarityExtraMaxMs: additional time for rare words (0..max) based on frequencyScore. * - complexityStrength: how strongly to apply Token.complexityMultiplier (0..1). @@ -137,7 +138,7 @@ data class RsvpConfig( val rarityExtraMaxMs: Long = 65L, val complexityStrength: Double = 0.65, /** - * Length curve. Adds time for longer words smoothly instead of abrupt thresholds. + * Legacy length curve, retained for profile compatibility; ignored by playback. * lengthStrength controls overall impact; lengthExponent controls how quickly it grows. */ val lengthStrength: Double = 0.9, @@ -206,7 +207,7 @@ data class RsvpConfig( /** * Rhythm shaping. * smoothingAlpha is EMA smoothing (0..1). Lower = steadier but less responsive. - * maxSpeedupFactor/maxSlowdownFactor clamp jitter between adjacent units. + * maxSpeedupFactor/maxSlowdownFactor clamp per-word beat changes, excluding protected difficulty time. */ val smoothingAlpha: Double = 0.35, val maxSpeedupFactor: Double = 1.25, @@ -234,12 +235,19 @@ data class RsvpConfig( /** Global punctuation breathing multiplier. 1.0 is neutral. */ val punctuationPauseFactor: Double = 1.08, val useAdaptiveTiming: Boolean = true, + /** Legacy adaptive difficulty cap; ignored by playback. */ val adaptiveDifficultyMaxHoldMs: Long = 70L, + /** Reading support (0..2) for both word recognition and phrase processing; retains its original storage key. */ + val difficultWordSupport: Double = 1.0, + /** Legacy phrase allowance retained for profile compatibility; ignored by playback. */ + val phraseBreathingRoomMs: Long = adaptiveDifficultyMaxHoldMs, + /** Legacy complex-word hold; ignored by playback. */ val complexWordHoldMs: Long = 45L, val useClausePausing: Boolean = true, val useDialogueDetection: Boolean = true, val useProsodyPacing: Boolean = true, val prosodyStrength: Double = 1.0, + /** Legacy threshold; ignored by playback. */ val complexWordThreshold: Double = 1.3, val clausePauseFactor: Double = 1.25, val blinkMode: BlinkMode = BlinkMode.OFF, diff --git a/app/src/main/java/com/kairo/reader/core/model/RsvpConfigConstraints.kt b/app/src/main/java/com/kairo/reader/core/model/RsvpConfigConstraints.kt index 99f7b233..d0907285 100644 --- a/app/src/main/java/com/kairo/reader/core/model/RsvpConfigConstraints.kt +++ b/app/src/main/java/com/kairo/reader/core/model/RsvpConfigConstraints.kt @@ -2,6 +2,7 @@ package com.kairo.reader.core.model /** User-editable RSVP bounds shared by UI, persistence, profile codecs, and engine validation. */ object RsvpConfigConstraints { + const val MAX_DIFFICULT_WORD_SUPPORT = 2.0 const val PERCENT_SCALE = 100.0 const val MIN_WORD_MS = 20L diff --git a/app/src/main/java/com/kairo/reader/core/model/RsvpProfileDefaults.kt b/app/src/main/java/com/kairo/reader/core/model/RsvpProfileDefaults.kt index 1ef9451f..12d4f7ce 100644 --- a/app/src/main/java/com/kairo/reader/core/model/RsvpProfileDefaults.kt +++ b/app/src/main/java/com/kairo/reader/core/model/RsvpProfileDefaults.kt @@ -163,6 +163,8 @@ private fun skimReadingDefaults() = naturalReadingDefaults().copy( lengthStrength = 0.65, lengthExponent = 1.20, adaptiveDifficultyMaxHoldMs = 25L, + phraseBreathingRoomMs = 25L, + difficultWordSupport = 0.5, complexWordHoldMs = 20L, complexWordThreshold = 1.50, useProsodyPacing = false, @@ -204,6 +206,8 @@ private fun studyReadingDefaults() = naturalReadingDefaults().copy( lengthStrength = 1.05, lengthExponent = 1.35, adaptiveDifficultyMaxHoldMs = 100L, + phraseBreathingRoomMs = 100L, + difficultWordSupport = 1.5, complexWordHoldMs = 65L, complexWordThreshold = 1.20, prosodyStrength = 0.60, diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpConfigNormalization.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpConfigNormalization.kt index 762d1332..d652b494 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpConfigNormalization.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpConfigNormalization.kt @@ -8,6 +8,9 @@ import com.kairo.reader.core.model.effectiveBlinkMode internal fun RsvpConfig.normalizedForPlayback(): RsvpConfig { val safeMinWordMs = minWordMs.coerceAtLeast(1L) return copy( + difficultWordSupport = (difficultWordSupport.takeIf { it.isFinite() } ?: 1.0) + .coerceIn(0.0, Constraints.MAX_DIFFICULT_WORD_SUPPORT), + phraseBreathingRoomMs = phraseBreathingRoomMs.coerceIn(0L, Constraints.MAX_ADAPTIVE_HOLD_MS), tempoMsPerWord = tempoMsPerWord.coerceAtLeast(1L), minWordMs = safeMinWordMs, longWordMinMs = longWordMinMs.coerceAtLeast(safeMinWordMs), diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt index 57906635..11ee9930 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt @@ -61,6 +61,8 @@ internal object PrefKeys { val rampUpFrames = intPreferencesKey("ramp_up_frames") val rampDownFrames = intPreferencesKey("ramp_down_frames") val useAdaptiveTiming = booleanPreferencesKey("use_adaptive_timing") + val difficultWordSupport = doublePreferencesKey("difficult_word_support") + val phraseBreathingRoomMs = longPreferencesKey("phrase_breathing_room_ms") val adaptiveDifficultyMaxHoldMs = longPreferencesKey("adaptive_difficulty_max_hold_ms") val complexWordHoldMs = longPreferencesKey("complex_word_hold_ms") val complexWordThreshold = doublePreferencesKey("complex_word_threshold") diff --git a/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt b/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt index c6dc765c..1c11b3aa 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt @@ -132,6 +132,8 @@ internal class RsvpConfigPreferenceCodec(private val keys: PrefKeys, private val private fun writeAdaptiveTiming(prefs: MutablePreferences, config: RsvpConfig) { prefs[keys.useAdaptiveTiming] = config.useAdaptiveTiming prefs[keys.adaptiveDifficultyMaxHoldMs] = config.adaptiveDifficultyMaxHoldMs + prefs[keys.difficultWordSupport] = config.difficultWordSupport + prefs[keys.phraseBreathingRoomMs] = config.phraseBreathingRoomMs prefs[keys.complexWordHoldMs] = config.complexWordHoldMs prefs[keys.complexWordThreshold] = config.complexWordThreshold } @@ -418,6 +420,12 @@ internal class RsvpConfigPreferenceCodec(private val keys: PrefKeys, private val copy( useAdaptiveTiming = prefs.readOrDefault(keys.useAdaptiveTiming, defaults.useAdaptiveTiming), + difficultWordSupport = (prefs[keys.difficultWordSupport]?.takeIf { it.isFinite() } ?: defaults.difficultWordSupport) + .coerceIn(0.0, Constraints.MAX_DIFFICULT_WORD_SUPPORT), + phraseBreathingRoomMs = ( + prefs[keys.phraseBreathingRoomMs] ?: prefs[keys.adaptiveDifficultyMaxHoldMs] + ?: defaults.phraseBreathingRoomMs + ).coerceIn(0L, Constraints.MAX_ADAPTIVE_HOLD_MS), adaptiveDifficultyMaxHoldMs = prefs.readOrDefault( keys.adaptiveDifficultyMaxHoldMs, diff --git a/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt b/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt index 20797fe4..5802c3fd 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt @@ -154,6 +154,8 @@ internal class RsvpProfileJsonCodec(private val onMalformed: (Throwable) -> Unit private fun JSONObject.putAdaptiveTiming(config: RsvpConfig) { put("useAdaptiveTiming", config.useAdaptiveTiming) put("adaptiveDifficultyMaxHoldMs", config.adaptiveDifficultyMaxHoldMs) + put("difficultWordSupport", config.difficultWordSupport) + put("phraseBreathingRoomMs", config.phraseBreathingRoomMs) put("complexWordHoldMs", config.complexWordHoldMs) put("complexWordThreshold", config.complexWordThreshold) } @@ -367,6 +369,15 @@ internal class RsvpProfileJsonCodec(private val onMalformed: (Throwable) -> Unit ): RsvpConfig = copy( useAdaptiveTiming = obj.optBoolean("useAdaptiveTiming", defaults.useAdaptiveTiming), + difficultWordSupport = ( + obj.optDouble("difficultWordSupport", defaults.difficultWordSupport) + .takeIf { it.isFinite() } ?: defaults.difficultWordSupport + ) + .coerceIn(0.0, RsvpConfigConstraints.MAX_DIFFICULT_WORD_SUPPORT), + phraseBreathingRoomMs = obj.optLong( + "phraseBreathingRoomMs", + obj.optLong("adaptiveDifficultyMaxHoldMs", defaults.phraseBreathingRoomMs), + ).coerceIn(0L, RsvpConfigConstraints.MAX_ADAPTIVE_HOLD_MS), adaptiveDifficultyMaxHoldMs = obj.optLong( "adaptiveDifficultyMaxHoldMs", diff --git a/app/src/test/java/com/kairo/reader/core/model/RsvpProfileDefaultsTest.kt b/app/src/test/java/com/kairo/reader/core/model/RsvpProfileDefaultsTest.kt index ab88080d..f4756a74 100644 --- a/app/src/test/java/com/kairo/reader/core/model/RsvpProfileDefaultsTest.kt +++ b/app/src/test/java/com/kairo/reader/core/model/RsvpProfileDefaultsTest.kt @@ -173,7 +173,7 @@ class RsvpProfileDefaultsTest { assertFalse(study.useFocalStress) assertTrue(study.longWordMinMs > balanced.longWordMinMs) assertTrue(study.maxChunkLength >= 32) - assertTrue(study.adaptiveDifficultyMaxHoldMs > balanced.adaptiveDifficultyMaxHoldMs) + assertTrue(study.difficultWordSupport > balanced.difficultWordSupport) assertEquals(7, RsvpProfile.entries.map { it.defaultConfig().profileCadenceIdentity() }.toSet().size) } diff --git a/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt b/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt index f0ec7270..4b159424 100644 --- a/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt +++ b/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt @@ -67,9 +67,40 @@ class RsvpPreferenceCodecsTest { assertEquals(listOf(profile), profileCodec.parseCustomProfiles(json.toString())) } + @Test + fun legacyAllowancesRoundTripWhileReadingSupportKeepsItsStoredValue() { + val prefs = mutablePreferencesOf() + prefs[PrefKeys.adaptiveDifficultyMaxHoldMs] = 95L + val legacy = preferenceCodec.readRsvpConfig(prefs, RsvpConfig()) + assertEquals(95L, legacy.phraseBreathingRoomMs) + assertEquals(1.0, legacy.difficultWordSupport, 0.0) + preferenceCodec.writeRsvpConfig(prefs, legacy.copy(phraseBreathingRoomMs = 0L, difficultWordSupport = 1.5)) + assertEquals(0L, preferenceCodec.readRsvpConfig(prefs, RsvpConfig()).phraseBreathingRoomMs) + val json = """[{"id":"user:old","name":"Old","config":{"adaptiveDifficultyMaxHoldMs":95}}]""" + val restored = profileCodec.parseCustomProfiles(json).single().config + assertEquals(95L, restored.phraseBreathingRoomMs) + assertEquals(1.0, restored.difficultWordSupport, 0.0) + } + + @Test + fun comprehensionControlsClampMalformedStoredValues() { + val prefs = mutablePreferencesOf() + prefs[PrefKeys.difficultWordSupport] = Double.NaN + prefs[PrefKeys.phraseBreathingRoomMs] = -5L + val decoded = preferenceCodec.readRsvpConfig(prefs, RsvpConfig()) + assertEquals(1.0, decoded.difficultWordSupport, 0.0) + assertEquals(0L, decoded.phraseBreathingRoomMs) + val json = """[{"id":"user:bad","name":"Bad","config":{"difficultWordSupport":99,"phraseBreathingRoomMs":-1}}]""" + val restored = profileCodec.parseCustomProfiles(json).single().config + assertEquals(2.0, restored.difficultWordSupport, 0.0) + assertEquals(0L, restored.phraseBreathingRoomMs) + } + private fun distinctiveConfig(): RsvpConfig = RsvpConfig( tempoMsPerWord = 137L, + difficultWordSupport = 1.4, + phraseBreathingRoomMs = 123L, minWordMs = 41L, longWordMinMs = 171L, longWordChars = 11, From a31ff6738a06bd2e651c8f79f6f81dc1ee64fc81 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 14 Sep 2026 21:46:11 +0100 Subject: [PATCH 03/16] feat(rsvp): unify reading demand and protect word timing --- .../rsvp/RsvpPunctuationTimingDeviceTest.kt | 36 +++++ .../ui/rsvp/RsvpReadingDemandDeviceTest.kt | 47 ++++++ .../com/kairo/reader/core/model/RsvpFrame.kt | 2 + .../com/kairo/reader/core/rsvp/RsvpEngine.kt | 36 ++--- .../core/rsvp/analysis/RsvpReadingDemand.kt | 140 ++++++++++++++++++ .../core/rsvp/analysis/RsvpThoughtPlan.kt | 42 ++++-- .../core/rsvp/analysis/RsvpWordPacing.kt | 99 ++----------- .../core/rsvp/engine/RsvpEngineConstants.kt | 10 -- .../core/rsvp/engine/RsvpEngineTypes.kt | 60 +------- .../rsvp/engine/RsvpFramePostProcessor.kt | 2 +- .../core/rsvp/segmentation/RsvpDpSegmenter.kt | 2 +- .../reader/core/rsvp/timing/RsvpTiming.kt | 86 +++-------- .../reader/core/rsvp/timing/RsvpUnitTiming.kt | 81 +++++----- .../rsvp/ComprehensionRsvpChunkingTest.kt | 1 + .../core/rsvp/ComprehensionRsvpEngineTest.kt | 4 +- .../rsvp/ComprehensionRsvpPunctuationTest.kt | 2 +- .../core/rsvp/RsvpProtectedTimingTest.kt | 120 +++++++++++++++ .../reader/core/rsvp/RsvpReadingDemandTest.kt | 130 ++++++++++++++++ .../core/rsvp/RsvpScoredSegmentationTest.kt | 14 +- .../reader/core/rsvp/RsvpThoughtFlowTest.kt | 30 +++- .../rsvp/segmentation/RsvpAtomizationTest.kt | 5 +- .../timing/RsvpBoundaryIntegrationTest.kt | 2 - 22 files changed, 632 insertions(+), 319 deletions(-) create mode 100644 app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt create mode 100644 app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt create mode 100644 app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt create mode 100644 app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt index c1b6b22d..c35b11a6 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt @@ -8,6 +8,8 @@ import com.kairo.reader.TestActivity 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.RsvpGenerationOptions +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy import com.kairo.reader.ui.theme.KairoTheme import org.junit.Assert.assertTrue import org.junit.Test @@ -54,5 +56,39 @@ class RsvpPunctuationTimingDeviceTest { } } + @Test + fun difficultWordAllowanceRemainsVisibleAfterEasyWords() { + val easy = Token("cat", TokenType.WORD, frequencyScore = 0.5, syllableCount = 1) + val difficult = easy.copy(text = "quizzacious") + val fixture = RsvpDeviceFixture( + listOf(easy, easy, easy, difficult, easy), + RsvpConfig( + tempoMsPerWord = 150L, smoothingAlpha = 0.01, maxSlowdownFactor = 1.01, + difficultWordSupport = 2.0, maxChunkLength = 32, + useAdaptiveTiming = false, useProsodyPacing = false, useFocalStress = false, + useClausePausing = false, useDialogueDetection = false, + startDelayMs = 0L, endDelayMs = 0L, rampUpFrames = 0, rampDownFrames = 0, + ), + ) + fixture.state = fixture.state.copy( + book = fixture.state.book.copy(generationOptions = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)), + ) + ActivityScenario.launch(TestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { KairoTheme { RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) } } + } + val deadline = SystemClock.elapsedRealtime() + 15_000L + while (!fixture.finished && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20L) + assertTrue("Playback must finish", fixture.finished) + val index = fixture.consumed.indexOfFirst { it.originalTokenIndex == 3 } + assertTrue("Difficult word must be consumed after the warmup", index > 0) + val frame = fixture.consumed[index] + val displayedMs = fixture.consumedAtMs[index] - fixture.consumedAtMs[index - 1] + assertTrue("Reading allowance must survive smoothing", frame.protectedWordMs >= 160L) + assertTrue("Difficult word must retain its reading beat and allowance", frame.durationMs >= 290L) + assertTrue("Real playback must honour its duration: $displayedMs vs ${frame.durationMs}", displayedMs >= frame.durationMs) + } + } + private fun word(text: String) = Token(text, TokenType.WORD) } diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt new file mode 100644 index 00000000..40575960 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt @@ -0,0 +1,47 @@ +package com.kairo.reader.ui.rsvp + +import android.os.SystemClock +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.ComprehensionRsvpEngine +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RsvpReadingDemandDeviceTest { + @Test + fun packagedLookupSupportsEnglishPlaybackAndRepeatedPreparationIsDeterministic() { + val words = listOf("the", "people", "considered", "quizzacious", "12345", "neuroplasticity", "again") + val tokens = List(5000) { Token(words[it % words.size], TokenType.WORD) } + val config = RsvpConfig( + enablePhraseChunking = false, + maxChunkLength = 32, + startDelayMs = 0L, + endDelayMs = 0L, + rampUpFrames = 0, + rampDownFrames = 0, + ) + val engine = ComprehensionRsvpEngine() + val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + val times = mutableListOf() + var expectedDurations: List? = null + repeat(3) { + val before = SystemClock.elapsedRealtimeNanos() + val frames = engine.generateFrames(tokens, 0, config, options) + times += (SystemClock.elapsedRealtimeNanos() - before) / 1_000_000L + assertEquals(tokens.size, frames.size) + assertTrue(frames[3].protectedWordMs > frames[0].protectedWordMs) + val durations = frames.map { frame -> frame.durationMs } + expectedDurations?.let { expected -> assertEquals(expected, durations) } + expectedDurations = durations + } + Log.i("RsvpDemandBenchmark", "5000 English words, full generation: first=${times.first()} ms, warm=${times.drop(1)} ms") + } +} 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 b8bd089d..7f4a13c5 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 @@ -30,4 +30,6 @@ data class RsvpFrame( // Live tempo changes scale the reading portion independently of this hold. // Null allows callers without generated timing to fall back to the punctuation policy. val punctuationHoldMs: Long? = null, + // Lexical and phrase processing time that must remain on the visible word. + val protectedWordMs: Long = 0L, ) 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 f5874b83..a88f2fcd 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 @@ -6,19 +6,14 @@ import com.kairo.reader.core.model.RsvpResumeCursor import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.model.splitTokenForRsvp +import com.kairo.reader.core.rsvp.analysis.ReadingDemandAnalyzer import com.kairo.reader.core.rsvp.analysis.RsvpTokenAnalysis import com.kairo.reader.core.rsvp.analysis.analyzeExpandedTokens import com.kairo.reader.core.rsvp.analysis.nextTokenAfter -import com.kairo.reader.core.rsvp.analysis.recordGivennessWord import com.kairo.reader.core.rsvp.analysis.shouldKeepFullFocalDuration import com.kairo.reader.core.rsvp.engine.BoundaryBefore import com.kairo.reader.core.rsvp.engine.ContextState import com.kairo.reader.core.rsvp.engine.ExpandedToken -import com.kairo.reader.core.rsvp.engine.FLOW_EMA_ALPHA -import com.kairo.reader.core.rsvp.engine.FLOW_MAX_BOOST -import com.kairo.reader.core.rsvp.engine.FLOW_MAX_SLOWDOWN -import com.kairo.reader.core.rsvp.engine.FLOW_STRENGTH -import com.kairo.reader.core.rsvp.engine.FlowState import com.kairo.reader.core.rsvp.engine.MAX_ANTICIPATORY_LANDING_BOOST import com.kairo.reader.core.rsvp.engine.MIN_FOCAL_SUPPORT_COMPRESSION import com.kairo.reader.core.rsvp.engine.MIN_FRAME_MS @@ -73,7 +68,7 @@ interface RsvpEngine { * * Core idea: * - Build *reading units* (1–2 word phrases + attached punctuation) that match language flow. - * - Compute unit durations via a difficulty model (length/syllables/rarity/complexity) + breath pauses. + * - Compute unit durations via one bounded reading-demand allowance + breath pauses. * - Apply context shaping (parentheticals/quotes) and rhythm shaping (EMA smoothing + jitter clamps). * * The result is a calm, legible cadence where long words and punctuation never "flash" away. @@ -115,7 +110,6 @@ private data class RsvpGenerationContext( val frames: MutableList, val state: ContextState, val rhythm: RhythmState, - val flow: FlowState, val prose: ProseState, ) @@ -128,7 +122,12 @@ private fun generateFramesWithNormalizedConfig( if (tokens.isEmpty()) return emptyList() val analysisStartIndex = resolveAnalysisStartIndex(tokens, startIndex) - val expanded = buildExpandedTokens(tokens, analysisStartIndex, config) + val expanded = ReadingDemandAnalyzer.attach( + buildExpandedTokens(tokens, analysisStartIndex, config), + tokens, + analysisStartIndex, + options.languagePolicy, + ) val cursor = resolveFirstPlaybackCursor(expanded, startIndex) ?: return emptyList() val context = RsvpGenerationContext( @@ -146,8 +145,7 @@ private fun generateFramesWithNormalizedConfig( frames = mutableListOf(), state = createContextState(tokens, expanded[cursor].originalIndex), rhythm = createRhythmState(config), - flow = createFlowState(), - prose = createProseState(expanded, cursor, config), + prose = createProseState(expanded, cursor), ) var nextCursor = cursor @@ -261,18 +259,9 @@ private fun createRhythmState(config: RsvpConfig): RhythmState = maxSlowdownFactor = config.maxSlowdownFactor, ) -private fun createFlowState(): FlowState = - FlowState( - alpha = FLOW_EMA_ALPHA, - maxBoost = FLOW_MAX_BOOST, - maxSlowdown = FLOW_MAX_SLOWDOWN, - strength = FLOW_STRENGTH, - ) - private fun createProseState( expanded: List, playbackCursor: Int, - config: RsvpConfig, ): ProseState { val prose = ProseState() var previousWord: Token? = null @@ -283,9 +272,6 @@ private fun createProseState( when (token.type) { TokenType.WORD -> { prose.onWordShown() - if (config.useAdaptiveTiming) { - recordGivennessWord(token, prose) - } previousWord = token } TokenType.PUNCTUATION -> { @@ -340,7 +326,6 @@ private fun RsvpGenerationContext.appendBreakFrame(cursor: Int): Int? { displayOriginalEndCharacterOffset = expanded[cursor].sourceCharacterEndExclusive, ) rhythm.reset() - flow.reset() if (cursorToken.type == TokenType.PAGE_BREAK) { prose.onPageBreak() } else { @@ -412,7 +397,6 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { config = config, contextBefore = contextBefore, rhythm = rhythm, - flow = flow, prevToken = expanded.getOrNull(cursor - 1)?.token, prevWord = findPrevWord(expanded, beforeIndex = cursor), nextToken = expanded.getOrNull(nextCursor)?.token, @@ -431,6 +415,7 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { explicitSpeakerTag = selection.dialogueRole == RsvpDialogueRole.SPEAKER_TAG, thoughtCues = unitCues, + readingDemands = (wordCursor until nextCursor).mapNotNull { expanded[it].readingDemand }, ), ) @@ -439,6 +424,7 @@ private fun RsvpGenerationContext.appendReadingFrame(cursor: Int): Int? { tokens = frameTokens, durationMs = timing.durationMs, punctuationHoldMs = timing.punctuationHoldMs, + protectedWordMs = timing.protectedWordMs, originalTokenIndex = frameOriginalIndex, resumeCursor = RsvpResumeCursor.fromCharacterOffset( diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt new file mode 100644 index 00000000..91a17f14 --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt @@ -0,0 +1,140 @@ +@file:Suppress("MagicNumber") + +package com.kairo.reader.core.rsvp.analysis + +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.core.rsvp.engine.ExpandedToken +import java.text.Normalizer +import java.util.Locale +import java.util.zip.GZIPInputStream +import kotlin.math.exp +import kotlin.math.ln + +/** One recognition estimate, with a separate small novelty allowance. Neither learns from the reader. */ +internal data class RsvpReadingDemand(val recognition: Double, val novelty: Double = 0.0, val share: Double = 1.0) { + val difficulty: Double get() = 1.0 - exp(-recognition) + + fun allowanceMs(tempoMs: Double): Double { + val cap = (tempoMs * 0.8).coerceIn(80.0, 180.0) + // Novelty consumes only the remaining headroom; it cannot inflate the recognition cap. + return share * cap * (1.0 - exp(-(recognition + novelty))) + } +} + +internal object ReadingDemandAnalyzer { + fun normalize(text: String): String = Normalizer.normalize(text, Normalizer.Form.NFC).lowercase(Locale.ROOT).replace('’', '\'') + .trim { !it.isLetterOrDigit() } + + fun analyze(token: Token, policy: RsvpLanguagePolicy, previousOccurrences: Int = 0): RsvpReadingDemand { + val word = normalize(token.text) + val length = word.codePoints().filter { Character.isLetterOrDigit(it) }.count().toInt() + val digits = word.count(Char::isDigit) + val lengthBase = if (policy == RsvpLanguagePolicy.CJK) 1 else 3 + val lengthScale = if (policy == RsvpLanguagePolicy.CJK) 2.0 else 4.0 + val visual = 0.55 * ln(1.0 + (length - lengthBase).coerceAtLeast(0) / lengthScale) + val english = policy == RsvpLanguagePolicy.ENGLISH && word.isNotEmpty() && word.all { it in 'a'..'z' || it == '\'' } + val rarity = when { + digits > 0 -> 0.35 * ln(1.0 + digits) + english -> EnglishReadingFrequency.zipf(word)?.let { ((5.5 - it) / 3.0).coerceIn(0.0, 1.0) } + ?: spellingFallback(word) + else -> 0.0 // No English frequency or pronunciation assumptions for other scripts/languages. + } + val novelty = if (english) { + 0.25 * rarity / (1.0 + previousOccurrences.coerceIn(0, 8)) + } else { + 0.0 + } + return RsvpReadingDemand(visual + 0.85 * rarity, novelty) + } + + /** Build from source positions before splitting. Seeking cannot turn a repeated term into a first occurrence. */ + fun plan(tokens: List, startIndex: Int, policy: RsvpLanguagePolicy): Map { + var paragraphStart = startIndex.coerceIn(0, tokens.size) + while (paragraphStart > 0 && + tokens[paragraphStart - 1].type != TokenType.PARAGRAPH_BREAK && + tokens[paragraphStart - 1].type != TokenType.PAGE_BREAK + ) { + paragraphStart-- + } + val seen = object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 2048 + } + val result = HashMap() + for (index in paragraphStart until tokens.size) { + val token = tokens[index] + when (token.type) { + TokenType.PARAGRAPH_BREAK, TokenType.PAGE_BREAK -> seen.clear() + TokenType.PUNCTUATION -> Unit + TokenType.WORD -> { + val key = normalize(token.text) + val count = seen[key] ?: 0 + if (index >= startIndex) result[index] = analyze(token, policy, count) + seen[key] = (count + 1).coerceAtMost(8) + } + } + } + return result + } + + fun attach( + expanded: List, + tokens: List, + startIndex: Int, + policy: RsvpLanguagePolicy, + ): List { + val demands = plan(tokens, startIndex, policy) + val sourceLengths = HashMap() + for (entry in expanded) { + if (entry.token.type == TokenType.WORD) { + sourceLengths.merge(entry.originalIndex, displayedLength(entry.token), Int::plus) + } + } + return expanded.map { entry -> + val demand = demands[entry.originalIndex] + entry.copy( + readingDemand = demand?.copy( + share = displayedLength(entry.token).toDouble() / sourceLengths.getValue(entry.originalIndex), + ) + ) + } + } + + fun displayedLength(token: Token): Int { + val start = token.highlightStart + val end = token.highlightEndExclusive + val validHighlight = start != null && end != null && start >= 0 && end > start && end <= token.text.length + val visible = if (token.isSubwordChunk && validHighlight) { + token.text.substring(requireNotNull(start), requireNotNull(end)) + } else { + token.text + } + return visible.codePoints().filter { Character.isLetterOrDigit(it) }.count().toInt().coerceAtLeast(1) + } + + private fun spellingFallback(word: String): Double { + var run = 0 + var longest = 0 + for (letter in word) { + run = if (letter in "aeiouy'") 0 else run + 1 + longest = maxOf(longest, run) + } + // Missing from a finite table is uncertainty, not proof that the word is maximally difficult. + return (0.65 + (longest - 2).coerceAtLeast(0) * 0.06).coerceAtMost(0.9) + } +} + +internal object EnglishReadingFrequency { + private val frequencies: Map by lazy { + val stream = checkNotNull(javaClass.getResourceAsStream("/reading/english-frequency.tsv.gz")) + GZIPInputStream(stream).bufferedReader(Charsets.UTF_8).useLines { lines -> + lines.associate { line -> + val separator = line.indexOf('\t') + line.substring(0, separator) to line.substring(separator + 1).toInt() + } + } + } + + fun zipf(word: String): Double? = frequencies[word]?.div(100.0) +} 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 index 7319c8b6..639cb53c 100644 --- 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 @@ -15,6 +15,7 @@ internal data class RsvpThoughtCue( val isLastWord: Boolean, val protectedEmphasis: Boolean, val integrationHoldMs: Double, + val processingHoldMs: Double = 0.0, ) internal object RsvpThoughtPlan { @@ -31,7 +32,10 @@ internal object RsvpThoughtPlan { fun finish(endExclusive: Int) { if (words.isEmpty()) return val protected = protectedWords(words, english, previousModals) - val hold = integrationHold(words, config) + val hold = integrationHold(words, config, languagePolicy) + val sourceWords = words.distinctBy { it.originalIndex } + val processingShare = if (sourceWords.size > 1) PROCESSING_SHARE else 0.0 + val lastChunks = words.associateBy { it.originalIndex } val start = words.first().originalIndex words.forEachIndexed { index, word -> cues[word.expandedIndex] = RsvpThoughtCue( @@ -39,7 +43,12 @@ internal object RsvpThoughtPlan { endTokenIndexExclusive = endExclusive, isLastWord = index == words.lastIndex, protectedEmphasis = word.expandedIndex in protected, - integrationHoldMs = if (index == words.lastIndex) hold else 0.0, + integrationHoldMs = if (index == words.lastIndex) hold * (1.0 - processingShare) else 0.0, + processingHoldMs = if (lastChunks[word.originalIndex] == word) { + hold * processingShare / sourceWords.size + } else { + 0.0 + }, ) } previousModals = words.map { normalizeWord(it.token.text) }.filter { it in MODALS }.toSet() @@ -94,26 +103,31 @@ internal object RsvpThoughtPlan { } } - 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 + private fun integrationHold( + words: List, + config: RsvpConfig, + languagePolicy: RsvpLanguagePolicy, + ): Double { + val informationWords = words.distinctBy { it.originalIndex } + .distinctBy { normalizeWord(it.token.text) } + val contentCount = informationWords.count { + languagePolicy != RsvpLanguagePolicy.ENGLISH || + (EnglishReadingFrequency.zipf(ReadingDemandAnalyzer.normalize(it.token.text)) ?: 0.0) < COMMON_WORD_ZIPF } + val numbers = informationWords.count { it.token.text.any(Char::isDigit) } 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()) + val load = (contentCount - EASY_CONTENT_WORDS).coerceAtLeast(0) + numbers * NUMBER_LOAD + sqrt(lengthLoad) + return (load * HOLD_PER_LOAD_MS).coerceAtMost(PHRASE_ALLOWANCE_MS) * config.difficultWordSupport } 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 COMMON_WORD_ZIPF = 5.0 + private const val EASY_CONTENT_WORDS = 3 + private const val PHRASE_ALLOWANCE_MS = 70.0 + private const val PROCESSING_SHARE = 0.4 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/analysis/RsvpWordPacing.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPacing.kt index 04e1ef24..ba330f35 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 @@ -5,11 +5,11 @@ import com.kairo.reader.core.linguistics.DialogueAnalyzer 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.RsvpLanguagePolicy import com.kairo.reader.core.rsvp.engine.ACRONYM_EMPHASIS_BOOST import com.kairo.reader.core.rsvp.engine.AUXILIARY_BRIDGE_MAX_CHARS import com.kairo.reader.core.rsvp.engine.AUXILIARY_BRIDGE_WORDS import com.kairo.reader.core.rsvp.engine.AUXILIARY_CONTENT_MAX_CHARS -import com.kairo.reader.core.rsvp.engine.AUXILIARY_CONTENT_MIN_FREQUENCY import com.kairo.reader.core.rsvp.engine.BoundaryBefore import com.kairo.reader.core.rsvp.engine.COHERENCE_HOLD_MS import com.kairo.reader.core.rsvp.engine.CONTENT_WORD_STRESS_BOOST @@ -20,8 +20,6 @@ import com.kairo.reader.core.rsvp.engine.FUNCTION_BRIDGE_WORDS import com.kairo.reader.core.rsvp.engine.FUNCTION_WORDS import com.kairo.reader.core.rsvp.engine.FUNCTION_WORD_GLIDE_LIGHT import com.kairo.reader.core.rsvp.engine.FUNCTION_WORD_GLIDE_STRONG -import com.kairo.reader.core.rsvp.engine.GIVENNESS_GLIDE -import com.kairo.reader.core.rsvp.engine.GIVENNESS_MIN_CHARS import com.kairo.reader.core.rsvp.engine.GLUE_WORDS import com.kairo.reader.core.rsvp.engine.MAX_BOUNDARY_TAIL_LIFT import com.kairo.reader.core.rsvp.engine.MAX_EMPHASIS_MULTIPLIER @@ -36,7 +34,6 @@ import com.kairo.reader.core.rsvp.engine.PHRASE_PRE_BOUNDARY_LIFT import com.kairo.reader.core.rsvp.engine.PRONOUN_BRIDGE_MAX_CHARS import com.kairo.reader.core.rsvp.engine.PRONOUN_BRIDGE_WORDS import com.kairo.reader.core.rsvp.engine.PROPER_NOUN_BOOST -import com.kairo.reader.core.rsvp.engine.ProseState import com.kairo.reader.core.rsvp.engine.SEMANTIC_ANCHOR_BOOST import com.kairo.reader.core.rsvp.engine.SEMANTIC_ANCHOR_WORDS import com.kairo.reader.core.rsvp.engine.TIGHT_PAIR_HINTS @@ -340,46 +337,6 @@ internal fun phraseBoundaryShapeMultiplier( return multiplier } -/** - * Given/new glide: words shown recently read lighter on re-mention. - * - * Only substantial non-function words are tracked, so the within-phrase evenness of glue words is - * untouched. Mutates [prose] (records this word as seen) — call exactly once per displayed word. - */ -internal fun givennessGlideMultiplier( - token: Token, - prose: ProseState, - speedStrength: Double, -): Double { - val key = givennessKey(token) ?: return 1.0 - - val given = prose.isGiven(key) - prose.record(key) - if (!given) return 1.0 - - return 1.0 - (GIVENNESS_GLIDE * speedStrength.coerceIn(0.0, 1.0)) -} - -internal fun recordGivennessWord( - token: Token, - prose: ProseState, -) { - val key = givennessKey(token) ?: return - prose.record(key) -} - -private fun givennessKey(token: Token): String? { - if (token.isSubwordChunk) return null - val key = givennessKey(token.text) - return key.takeIf { it.length >= GIVENNESS_MIN_CHARS && !isFunctionWord(it) } -} - -private fun givennessKey(text: String): String = - normalizeWord(text) - .removeSuffix("'s") - .removeSuffix("’s") - .filter { it.isLetter() } - internal fun isPhraseBreakBefore( currentLower: String, nextLower: String, @@ -417,12 +374,6 @@ internal fun isPhraseBreakBefore( return false } -internal fun frameDifficulty(words: List): Double { - if (words.isEmpty()) return 0.0 - val total = words.sumOf { (1.0 - wordEase(it)) } - return (total / words.size).coerceIn(0.0, 1.0) -} - internal fun shouldPreferHold( prev: Token, next: Token, @@ -437,7 +388,8 @@ internal fun shouldPreferHold( prev.text.length <= MAX_GLUE_PAIR_WORD_CHARS && next.text.length <= MAX_GLUE_PAIR_WORD_CHARS val easyPair = - wordEase(prev) >= EASY_PAIR_THRESHOLD && wordEase(next) >= EASY_PAIR_THRESHOLD + wordEase(prev, RsvpLanguagePolicy.ENGLISH) >= EASY_PAIR_THRESHOLD && + wordEase(next, RsvpLanguagePolicy.ENGLISH) >= EASY_PAIR_THRESHOLD // Check coherence score for high-coherence pairs val coherence = ClauseDetector.getCoherenceScore(prevLower, nextLower) @@ -474,6 +426,8 @@ internal fun analyzePhraseChunkPair( val prevLower = prev.text.lowercase() val nextLower = next.text.lowercase() val pairKey = "$prevLower $nextLower" + val previousFrequency = EnglishReadingFrequency.zipf(ReadingDemandAnalyzer.normalize(prev.text)) ?: 0.0 + val nextFrequency = EnglishReadingFrequency.zipf(ReadingDemandAnalyzer.normalize(next.text)) ?: 0.0 val pronounAuxiliaryBridge = prevLower in PRONOUN_BRIDGE_WORDS && @@ -484,7 +438,7 @@ internal fun analyzePhraseChunkPair( prevLower in AUXILIARY_BRIDGE_WORDS && nextLower !in SEMANTIC_ANCHOR_WORDS && next.text.length <= AUXILIARY_CONTENT_MAX_CHARS && - next.frequencyScore >= AUXILIARY_CONTENT_MIN_FREQUENCY + nextFrequency >= AUXILIARY_CONTENT_MIN_ZIPF val coherenceScore = ClauseDetector.getCoherenceScore(prevLower, nextLower) val coherentShortPair = coherenceScore >= PHRASE_CHUNK_COHERENCE_THRESHOLD && @@ -496,8 +450,8 @@ internal fun analyzePhraseChunkPair( prev.text.length <= MAX_GENERAL_CHUNK_PREV_CHARS && next.text.length <= MAX_GENERAL_CHUNK_NEXT_CHARS val bothCommon = - prev.frequencyScore >= COMMON_WORD_FREQUENCY_THRESHOLD && - next.frequencyScore >= COMMON_WORD_FREQUENCY_THRESHOLD + previousFrequency >= COMMON_WORD_ZIPF_THRESHOLD && + nextFrequency >= COMMON_WORD_ZIPF_THRESHOLD val generalShortPair = (glue && bothShort) || (bothShort && bothCommon) val tightPair = pairKey in TIGHT_PAIR_HINTS @@ -570,31 +524,8 @@ internal fun boundaryTailLiftWeight( ) } -internal fun wordEase(word: Token): Double { - val letters = word.text.count { it.isLetterOrDigit() }.coerceAtLeast(1) - val lengthScore = - ( - (letters - WORD_EASE_BASE_CHARS).coerceAtLeast(0) / - WORD_EASE_LENGTH_SCALE - ).coerceIn(0.0, 1.0) - val syllableScore = - ( - (word.syllableCount - 1).coerceAtLeast(0) / - WORD_EASE_SYLLABLE_SCALE - ).coerceIn(0.0, 1.0) - val rarityScore = (1.0 - word.frequencyScore).coerceIn(0.0, 1.0) - val complexityScore = (word.complexityMultiplier - 1.0).coerceAtLeast( - 0.0 - ).coerceIn(0.0, 1.0) - - val difficulty = - (lengthScore * WORD_EASE_LENGTH_WEIGHT) + - (syllableScore * WORD_EASE_SYLLABLE_WEIGHT) + - (rarityScore * WORD_EASE_RARITY_WEIGHT) + - (complexityScore * WORD_EASE_COMPLEXITY_WEIGHT) - - return (1.0 - difficulty).coerceIn(0.0, 1.0) -} +internal fun wordEase(word: Token, policy: RsvpLanguagePolicy = RsvpLanguagePolicy.UNKNOWN): Double = + 1.0 - ReadingDemandAnalyzer.analyze(word, policy).difficulty private const val MAX_SPEAKER_TAG_FRAME_WORDS = 3 private const val MIN_DIALOGUE_SPEED_STRENGTH = 0.15 @@ -610,12 +541,6 @@ private const val MAX_COHERENT_CHUNK_PREV_CHARS = 5 private const val MAX_COHERENT_CHUNK_NEXT_CHARS = 8 private const val MAX_GENERAL_CHUNK_PREV_CHARS = 4 private const val MAX_GENERAL_CHUNK_NEXT_CHARS = 7 -private const val COMMON_WORD_FREQUENCY_THRESHOLD = 0.7 +private const val COMMON_WORD_ZIPF_THRESHOLD = 4.5 +private const val AUXILIARY_CONTENT_MIN_ZIPF = 4.0 private const val FIXED_POINT_SCALE = 1000 -private const val WORD_EASE_BASE_CHARS = 4 -private const val WORD_EASE_LENGTH_SCALE = 8.0 -private const val WORD_EASE_SYLLABLE_SCALE = 4.0 -private const val WORD_EASE_LENGTH_WEIGHT = 0.35 -private const val WORD_EASE_SYLLABLE_WEIGHT = 0.25 -private const val WORD_EASE_RARITY_WEIGHT = 0.25 -private const val WORD_EASE_COMPLEXITY_WEIGHT = 0.15 diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineConstants.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineConstants.kt index 980b480e..8662039d 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineConstants.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineConstants.kt @@ -16,7 +16,6 @@ internal const val MAX_MIN_PAUSE_SCALE = 0.97 internal const val BASE_MS_PER_WORD_AT_300 = 200.0 internal const val DEFAULT_CLAUSE_PAUSE_FACTOR = 1.25 internal const val MIN_BLINK_MS = 16L -internal const val ADAPTIVE_DIFFICULTY_FLOOR = 0.35 internal const val CLAUSE_BOUNDARY_HOLD_MS = 55.0 internal const val ADAPTIVE_HOLD_MAX_MS = 160.0 internal const val EASY_PAIR_THRESHOLD = 0.72 @@ -46,10 +45,6 @@ internal const val PHRASE_ONSET_LIFT = 0.05 // time, later mentions read lighter. Tracked in a small LRU of recently shown content words, // cleared at page breaks (scene changes re-introduce). The glide also roughly cancels the // proper-noun emphasis boost on re-mentions. -internal const val GIVENNESS_GLIDE = 0.08 -internal const val GIVENNESS_MIN_CHARS = 5 -internal const val GIVENNESS_MAX_ENTRIES = 160 -internal const val GIVENNESS_INITIAL_CAPACITY = 64 // Em-dash role shaping. A dash is not one thing: paired dashes wrap an aside and should read as // a light dip in register (parenthesis-like), not two full clause stops in a row; a dash that is @@ -87,16 +82,11 @@ internal const val FUNCTION_BRIDGE_COHERENCE_THRESHOLD = 0.65 internal const val PRONOUN_BRIDGE_MAX_CHARS = 6 internal const val AUXILIARY_BRIDGE_MAX_CHARS = 6 internal const val AUXILIARY_CONTENT_MAX_CHARS = 8 -internal const val AUXILIARY_CONTENT_MIN_FREQUENCY = 0.45 internal const val MIN_PROSODY_MULTIPLIER = 0.88 internal const val MAX_PROSODY_MULTIPLIER = 1.18 internal const val CLAUSE_LEAD_BOOST_MS = 20.0 internal const val SENTENCE_END_BREAK_BOOST_MS = 40.0 internal const val EMBEDDED_QUOTE_FACTOR = 0.45 -internal const val FLOW_EMA_ALPHA = 0.25 -internal const val FLOW_MAX_BOOST = 0.04 -internal const val FLOW_MAX_SLOWDOWN = 0.05 -internal const val FLOW_STRENGTH = 0.12 internal const val MAX_BOUNDARY_TAIL_LIFT = 0.30 internal const val MIN_BOUNDARY_TAIL_LIFT_STRENGTH = 0.85 internal const val MIN_LANDING_HOLD_MS = 8.0 diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineTypes.kt b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineTypes.kt index 55a1e724..90be2dd4 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineTypes.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/engine/RsvpEngineTypes.kt @@ -2,6 +2,7 @@ package com.kairo.reader.core.rsvp.engine import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.rsvp.analysis.RsvpReadingDemand import kotlin.math.max internal data class ExpandedToken( @@ -10,6 +11,7 @@ internal data class ExpandedToken( val expandedIndex: Int, val sourceCharacterStart: Int, val sourceCharacterEndExclusive: Int, + val readingDemand: RsvpReadingDemand? = null, ) internal data class PhraseContour(val preBoundaryWeight: Double, val restartWeight: Double,) { @@ -71,24 +73,13 @@ internal data class ContextSnapshot(val parentheticalDepth: Int, val inDialogue: * Sequential prose memory carried across frames during generation. * * Tracks how deep into the current sentence the reader is (for sentence wrap-up pauses) and - * which content words have been shown recently (for given/new pacing). Frames are generated in + * phrase timing. Frames are generated in * reading order, so this state is deterministic for a given token stream. */ internal class ProseState { var wordsInSentence: Int = 0 private set - private val seenWords = - object : LinkedHashMap( - GIVENNESS_INITIAL_CAPACITY, - GIVENNESS_CACHE_LOAD_FACTOR, - true, - ) { - override fun removeEldestEntry( - eldest: MutableMap.MutableEntry? - ): Boolean = size > GIVENNESS_MAX_ENTRIES - } - fun onWordShown() { wordsInSentence++ } @@ -103,19 +94,9 @@ internal class ProseState { fun onPageBreak() { wordsInSentence = 0 - seenWords.clear() - } - - /** Whether [key] was shown recently; refreshes its recency when found. */ - fun isGiven(key: String): Boolean = seenWords[key] != null - - fun record(key: String) { - seenWords[key] = true } } -private const val GIVENNESS_CACHE_LOAD_FACTOR = 0.75f - internal class RhythmState { private var ema: Double? = null private val smoothingAlpha: Double @@ -176,38 +157,3 @@ internal class RhythmState { private const val RHYTHM_BOUNDARY_SCALE = 1000 private const val PARTIAL_BOUNDARY_RESEED_MAX = 0.25 - -internal class FlowState( - private val alpha: Double, - private val maxBoost: Double, - private val maxSlowdown: Double, - private val strength: Double, -) { - private var ema: Double? = null - - fun apply( - difficulty: Double, - speedStrength: Double, - isBoundary: Boolean, - ): Double { - if (isBoundary) { - ema = difficulty - return 1.0 - } - - val prev = ema ?: difficulty - val delta = difficulty - prev - - // Gentle flow adjustment - reduce variation for smoother cadence - val multiplier = - (1.0 + (delta * strength * speedStrength)) - .coerceIn(1.0 - maxSlowdown, 1.0 + maxBoost) - - ema = prev + (alpha * (difficulty - prev)) - return multiplier - } - - fun reset() { - ema = null - } -} 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 072d0c48..b6a83b65 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 @@ -62,7 +62,7 @@ private fun splitFrameForBlink( val repeatedWord = firstWord.text.equals(nextWord.text, ignoreCase = true) if ((!repeatedWord && shouldHold) || isHardBoundary(frame.tokens, nextWord)) return listOf(frame) - val floorMs = max(wordFloorMs(firstWord, config), MIN_FRAME_MS) + val floorMs = max(wordFloorMs(firstWord, config), MIN_FRAME_MS + frame.protectedWordMs) val maxBlink = (frame.durationMs - floorMs - (frame.punctuationHoldMs ?: 0L)).coerceAtLeast(0L) val punctuationFactor = blinkPunctuationFactor(frame.tokens) val blinkMs = min((WORD_SEPARATION_MS * punctuationFactor).roundToLong(), maxBlink) 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 d9d78e16..1bf46f17 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 @@ -139,7 +139,7 @@ internal object RsvpDpSegmenter { val visibleCharacters = visibleCodePointCount(token.text) val difficulty = if (languagePolicy == RsvpLanguagePolicy.ENGLISH) { - ((1.0 - wordEase(token)) * RsvpSegmentationWeightsV2.FIXED_POINT_SCALE) + ((1.0 - wordEase(token, languagePolicy)) * RsvpSegmentationWeightsV2.FIXED_POINT_SCALE) .roundToInt() .coerceIn(0, RsvpSegmentationWeightsV2.FIXED_POINT_SCALE) } else { diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt index 51f69d17..ef5d35c1 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt @@ -7,7 +7,9 @@ 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.model.wordFloorMsForReadability -import com.kairo.reader.core.rsvp.engine.ADAPTIVE_DIFFICULTY_FLOOR +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.core.rsvp.analysis.ReadingDemandAnalyzer +import com.kairo.reader.core.rsvp.analysis.RsvpReadingDemand import com.kairo.reader.core.rsvp.engine.ADAPTIVE_HOLD_MAX_MS import com.kairo.reader.core.rsvp.engine.BASE_MS_PER_WORD_AT_300 import com.kairo.reader.core.rsvp.engine.BoundaryBefore @@ -38,30 +40,19 @@ import com.kairo.reader.core.rsvp.text.isEmbeddedQuote import com.kairo.reader.core.rsvp.text.isQuoteChar import kotlin.math.max import kotlin.math.pow -import kotlin.math.roundToLong internal fun adaptiveHoldMs( words: List, - difficulty: Double, config: RsvpConfig, speedStrength: Double, hardBoundary: Boolean, nextWord: Token?, clauseConfigStrength: Double, ): Double { - if (!config.useAdaptiveTiming || words.isEmpty() || nextWord == null) return 0.0 + if (!config.useAdaptiveTiming || words.isEmpty()) return 0.0 - val difficultyScale = - ( - (difficulty - ADAPTIVE_DIFFICULTY_FLOOR).coerceAtLeast(0.0) / - (1.0 - ADAPTIVE_DIFFICULTY_FLOOR) - ) - .coerceIn(0.0, 1.0) - var hold = difficultyScale * config.adaptiveDifficultyMaxHoldMs * speedStrength - - if (words.any { it.complexityMultiplier >= config.complexWordThreshold }) { - hold += config.complexWordHoldMs * speedStrength - } + if (nextWord == null) return 0.0 + var hold = 0.0 val lastWord = words.lastOrNull() if (!hardBoundary && config.useClausePausing && lastWord?.isClauseBoundary == true) { @@ -86,56 +77,29 @@ internal fun adaptiveHoldMs( return hold.coerceAtMost(ADAPTIVE_HOLD_MAX_MS * speedStrength) } -internal fun wordDurationMs( +internal data class RsvpWordTimingBudget(val beatMs: Double, val protectedMs: Double) + +internal fun wordDurationMs(word: Token, msPerWord: Double, config: RsvpConfig): Double = + wordTimingBudget(word, msPerWord, config).let { it.beatMs + it.protectedMs } + +internal fun wordTimingBudget( word: Token, msPerWord: Double, config: RsvpConfig, -): Double { + readingDemand: RsvpReadingDemand? = null, +): RsvpWordTimingBudget { val text = word.text - val fullLetters = text.count { it.isLetterOrDigit() }.coerceAtLeast(1) - val (letters, syllables) = - if (word.isSubwordChunk && - word.highlightStart != null && - word.highlightEndExclusive != null && - word.highlightEndExclusive > word.highlightStart && - word.highlightEndExclusive <= text.length - ) { - val chunkText = text.substring(word.highlightStart, word.highlightEndExclusive) - val chunkLetters = chunkText.count { it.isLetterOrDigit() }.coerceAtLeast(1) - val ratio = - (chunkLetters.toDouble() / fullLetters.toDouble()) - .coerceIn(MIN_SUBWORD_LENGTH_RATIO, 1.0) - val scaledSyllables = - max(1.0, word.syllableCount.toDouble() * ratio).roundToLong().toInt() - chunkLetters to scaledSyllables - } else { - fullLetters to word.syllableCount - } - - val lengthCurve = - run { - val x = - ( - (letters - LENGTH_CURVE_BASE_CHARS).coerceAtLeast(0) / - LENGTH_CURVE_CHAR_SCALE - ) - 1.0 + config.lengthStrength * (x.pow(config.lengthExponent)) - } - - val complexityComponent = - 1.0 + (max(0.0, word.complexityMultiplier - 1.0) * config.complexityStrength) - - val rarityExtra = (1.0 - word.frequencyScore).coerceIn(0.0, 1.0) * config.rarityExtraMaxMs - val syllableExtra = max(0, syllables - 1) * config.syllableExtraMs - - var duration = (msPerWord * lengthCurve * complexityComponent) + rarityExtra + syllableExtra + val letters = ReadingDemandAnalyzer.displayedLength(word) + val demand = readingDemand ?: ReadingDemandAnalyzer.analyze(word, RsvpLanguagePolicy.UNKNOWN) + val difficultyExtra = demand.allowanceMs(msPerWord) * config.difficultWordSupport + var duration = msPerWord // Dynamism: let easy, predictable words glide below the baseline tempo so the cadence rises // and falls with difficulty rather than only ever adding time. Hard words are untouched (they // already earned length/rarity time). Mid-word continuations (hyphen/subword) are left alone, // and the unit-level word floor downstream keeps every frame readable. if (!word.isSubwordChunk && !text.endsWith("-")) { - val ease = word.frequencyScore.coerceIn(0.0, 1.0) + val ease = 1.0 - demand.difficulty if (ease > DYNAMISM_EASE_PIVOT) { val t = (ease - DYNAMISM_EASE_PIVOT) / (1.0 - DYNAMISM_EASE_PIVOT) val compressionStrength = speedStrength(msPerWord) @@ -143,15 +107,14 @@ internal fun wordDurationMs( } } + var protectedMs = difficultyExtra if (letters >= config.longWordChars) { - duration = max(duration, config.longWordMinMs.toDouble()) + protectedMs = max(protectedMs, config.longWordMinMs.toDouble() - duration) } - if (text.endsWith("-")) { - duration += msPerWord * HYPHEN_CONTINUATION_HOLD_FACTOR + protectedMs += msPerWord * HYPHEN_CONTINUATION_HOLD_FACTOR } - - return duration + return RsvpWordTimingBudget(beatMs = duration, protectedMs = protectedMs) } internal fun punctuationPauseMs( @@ -389,9 +352,6 @@ private const val BASE_EXPRESSION_STRENGTH = 0.30 private const val PHRASE_END_HOLD_FACTOR = 0.6 private const val COHERENCE_GROUP_THRESHOLD = 0.5 private const val COHERENCE_HOLD_REDUCTION = 0.4 -private const val MIN_SUBWORD_LENGTH_RATIO = 0.2 -private const val LENGTH_CURVE_BASE_CHARS = 4 -private const val LENGTH_CURVE_CHAR_SCALE = 10.0 private const val HYPHEN_CONTINUATION_HOLD_FACTOR = 0.25 private const val MIN_PAUSE_TEMPO_RATIO = 0.12 private const val MAX_PAUSE_TEMPO_RATIO = 2.5 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 3cf64d5c..61d9a32c 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,11 +7,10 @@ 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.RsvpReadingDemand 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 -import com.kairo.reader.core.rsvp.analysis.givennessGlideMultiplier import com.kairo.reader.core.rsvp.analysis.isEmDashChar import com.kairo.reader.core.rsvp.analysis.multiWordPenalty import com.kairo.reader.core.rsvp.analysis.phraseBoundaryShapeMultiplier @@ -28,7 +27,6 @@ import com.kairo.reader.core.rsvp.engine.DIALOGUE_ENTRY_BOOST import com.kairo.reader.core.rsvp.engine.EM_DASH_ASIDE_CONTOUR_FACTOR import com.kairo.reader.core.rsvp.engine.EM_DASH_ASIDE_PAUSE_FACTOR import com.kairo.reader.core.rsvp.engine.EM_DASH_INTERRUPTION_PAUSE_FACTOR -import com.kairo.reader.core.rsvp.engine.FlowState import com.kairo.reader.core.rsvp.engine.MIN_FRAME_MS import com.kairo.reader.core.rsvp.engine.PAGE_BREAK_RETENTION_BOOST import com.kairo.reader.core.rsvp.engine.PARAGRAPH_BREAK_RETENTION_BOOST @@ -49,7 +47,6 @@ internal data class RsvpUnitTimingInput( val config: RsvpConfig, val contextBefore: ContextSnapshot, val rhythm: RhythmState, - val flow: FlowState, val prevToken: Token?, val prevWord: Token?, val nextToken: Token?, @@ -65,6 +62,7 @@ internal data class RsvpUnitTimingInput( val rhythmBoundaryStrengthMilli: Int = 0, val explicitSpeakerTag: Boolean = false, val thoughtCues: List = emptyList(), + val readingDemands: List = emptyList(), ) private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { @@ -72,7 +70,6 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { val config = input.config val contextBefore = input.contextBefore val rhythm = input.rhythm - val flow = input.flow val prevToken = input.prevToken val prevWord = input.prevWord val nextToken = input.nextToken @@ -88,6 +85,7 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { val rhythmBoundaryStrengthMilli = input.rhythmBoundaryStrengthMilli val explicitSpeakerTag = input.explicitSpeakerTag val thoughtCues = input.thoughtCues + val readingDemands = input.readingDemands val msPerWord = config.tempoMsPerWord.toDouble() val pauseScale = pauseScale(msPerWord, config) val clausePauseScale = @@ -190,6 +188,8 @@ private class RsvpUnitTimingContext(input: RsvpUnitTimingInput) { private data class FrameWordTiming( val duration: Double, val expressionMs: Double, + val protectedMs: Double, + val pauseMs: Double, val enteredDialogue: Boolean, val exitedDialogue: Boolean, val sawParentheticalWord: Boolean, @@ -203,6 +203,8 @@ private class FrameWordContext(contextBefore: ContextSnapshot) { var sawParentheticalWord = false var wordOrdinal = 0 var expressionMs = 0.0 + var protectedMs = 0.0 + var pauseMs = 0.0 } private fun computeFrameWordTiming(context: RsvpUnitTimingContext): FrameWordTiming { @@ -214,7 +216,7 @@ private fun computeFrameWordTiming(context: RsvpUnitTimingContext): FrameWordTim TokenType.WORD -> { duration += wordDurationContribution(context, state, index, token) context.prose?.onWordShown() - if (token.pauseAfterMs > 0L) duration += token.pauseAfterMs * context.pauseScale + if (token.pauseAfterMs > 0L) state.pauseMs += token.pauseAfterMs * context.pauseScale } else -> Unit } @@ -222,6 +224,8 @@ private fun computeFrameWordTiming(context: RsvpUnitTimingContext): FrameWordTim return FrameWordTiming( duration = duration, expressionMs = state.expressionMs, + protectedMs = state.protectedMs, + pauseMs = state.pauseMs, enteredDialogue = state.enteredDialogue, exitedDialogue = state.exitedDialogue, sawParentheticalWord = state.sawParentheticalWord, @@ -254,6 +258,7 @@ private fun wordDurationContribution( token: Token, ): Double { val config = context.config + val demand = context.readingDemands.getOrNull(state.wordOrdinal) val thoughtCue = context.thoughtCues.getOrNull(state.wordOrdinal++) val protectedEmphasis = config.useProsodyPacing && thoughtCue?.protectedEmphasis == true val inAside = config.useParentheticalAside && (state.parentheticalDepth > 0 || context.emDashAside) @@ -324,14 +329,10 @@ private fun wordDurationContribution( } else { 1.0 } - val givenness = - if (config.useAdaptiveTiming && context.prose != null) { - givennessGlideMultiplier(token, context.prose, context.speedStrength) - } else { - 1.0 - } + val wordBudget = wordTimingBudget(token, context.msPerWord, config, demand) + state.protectedMs += wordBudget.protectedMs val duration = - wordDurationMs(token, context.msPerWord, config) * + wordBudget.beatMs * parentheticalMultiplier * dialogueMultiplier * context.speakerTagMultiplier @@ -346,10 +347,9 @@ private fun wordDurationContribution( context.anticipatoryLanding, if (config.useProsodyPacing) phraseContourMultiplier(context.phraseContour, context.expressionStrength) else 1.0, 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()) + val baseline = duration state.expressionMs += baseline * (expression - 1.0) return baseline } @@ -459,14 +459,14 @@ private fun punctuationPauseForToken( private val CLOSING_ASIDE_PUNCTUATION = setOf(')', ']', '}') -internal data class RsvpUnitTiming(val durationMs: Long, val punctuationHoldMs: Long) +internal data class RsvpUnitTiming(val durationMs: Long, val punctuationHoldMs: Long, val protectedWordMs: Long) internal fun computeUnitDurationMs(input: RsvpUnitTimingInput): Long = computeUnitTiming(input).durationMs internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = with(RsvpUnitTimingContext(input)) { val wordTiming = computeFrameWordTiming(this) - var duration = wordTiming.duration + val duration = wordTiming.duration val transitionHold = transitionHoldMs( @@ -476,29 +476,16 @@ internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = speedStrength = speedStrength, prosodyStrength = prosodyStrength, ) - if (transitionHold > 0.0) { - duration += transitionHold - } - duration *= multiWordPenalty(words.size) - - // Apply flow and rhythm smoothing to word duration BEFORE adding punctuation pauses. - // This ensures punctuation pauses are not reduced by the smoothing algorithms. + // Smooth the per-word beat, so changing frame width cannot consume reading time. + // Difficulty, split-word holds and phrase processing bypass this smoother. val hardBoundary = isHardBoundary(frameTokens, nextToken) - val difficulty = frameDifficulty(words) - duration *= - flow.apply( - difficulty = difficulty, - speedStrength = speedStrength, - isBoundary = hardBoundary, - ) - val smoothedWordDuration = rhythm.apply( - rawMs = duration, + rawMs = duration / words.size.coerceAtLeast(1), isBoundary = hardBoundary, boundaryStrengthMilli = rhythmBoundaryStrengthMilli, - ) + ) * words.size.coerceAtLeast(1) * multiWordPenalty(words.size) // Now add punctuation pauses on top of the smoothed word duration. // These pauses are intentionally NOT smoothed so they remain prominent. @@ -507,9 +494,10 @@ internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = val expression = if (wordTiming.duration > 0.0) 1.0 + wordTiming.expressionMs / wordTiming.duration else 1.0 var totalDuration = max( - smoothedWordDuration * expression, + smoothedWordDuration * expression + wordTiming.protectedMs, words.sumOf { wordFloorMs(it, config).toDouble() }, ) + totalDuration += wordTiming.pauseMs + transitionHold + thoughtCues.sumOf { it.processingHoldMs } var integrationHold = thoughtCues.sumOf { it.integrationHoldMs } if (words.isNotEmpty()) { when (boundaryForBoost) { @@ -556,17 +544,16 @@ internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = val scaled = base * pagePauseScale totalDuration += max(scaled, floor) * pageBreaks } + val adaptiveHold = adaptiveHoldMs( + words = words, + config = config, + speedStrength = speedStrength, + hardBoundary = hardBoundary, + nextWord = nextWord, + clauseConfigStrength = clauseConfigStrength, + ) + totalDuration += adaptiveHold if (paragraphBreaks == 0 && pageBreaks == 0) { - totalDuration += - adaptiveHoldMs( - words = words, - difficulty = difficulty, - config = config, - speedStrength = speedStrength, - hardBoundary = hardBoundary, - nextWord = nextWord, - clauseConfigStrength = clauseConfigStrength, - ) if (!hardBoundary && config.useClausePausing && nextWord?.isClauseBoundary == true @@ -596,6 +583,10 @@ internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = return RsvpUnitTiming( durationMs = totalDuration.toLong().coerceAtLeast(MIN_FRAME_MS), + protectedWordMs = ( + wordTiming.protectedMs + wordTiming.pauseMs + adaptiveHold + transitionHold + + thoughtCues.sumOf { it.processingHoldMs } + if (punctuationHold > 0.0) 0.0 else integrationHold + ).toLong(), punctuationHoldMs = (punctuationHold + if (punctuationHold > 0.0) integrationHold else 0.0).toLong(), ) } 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 4948da5d..fd26149d 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 @@ -22,6 +22,7 @@ class ComprehensionRsvpChunkingTest : ComprehensionRsvpTestBase() { fun hyphenatedWordsAddMicroPauseBetweenParts() { val config = stableConfig.copy( + difficultWordSupport = 0.0, rarityExtraMaxMs = 0L, syllableExtraMs = 0L, complexityStrength = 0.0, diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpEngineTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpEngineTest.kt index cb9fdc1a..40bef14d 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpEngineTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpEngineTest.kt @@ -442,7 +442,7 @@ class ComprehensionRsvpEngineTest { } @Test - fun adaptiveHoldAddsTimeForDifficultWords() { + fun readingSupportAddsTimeForDifficultWords() { val baseConfig = RsvpConfig( tempoMsPerWord = 115L, @@ -476,7 +476,7 @@ class ComprehensionRsvpEngineTest { engine.generateFrames( tokens = tokens, startIndex = 0, - config = baseConfig.copy(useAdaptiveTiming = false), + config = baseConfig.copy(difficultWordSupport = 0.0), ) assertTrue(adaptive[0].durationMs > baseline[0].durationMs) diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpPunctuationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpPunctuationTest.kt index e26bb355..b22823d2 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpPunctuationTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/ComprehensionRsvpPunctuationTest.kt @@ -88,7 +88,7 @@ class ComprehensionRsvpPunctuationTest : ComprehensionRsvpTestBase() { ) assertTrue(abbrevEnd.isNotEmpty() && normalEnd.isNotEmpty()) - val diff = abs(abbrevEnd[0].durationMs - normalEnd[0].durationMs) + val diff = abs(requireNotNull(abbrevEnd[0].punctuationHoldMs) - requireNotNull(normalEnd[0].punctuationHoldMs)) assertTrue("Expected abbreviation at sentence end to pause like a sentence end", diff <= 5L) } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt new file mode 100644 index 00000000..8b340bf8 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt @@ -0,0 +1,120 @@ +package com.kairo.reader.core.rsvp + +import com.kairo.reader.core.model.BlinkMode +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.RsvpReadingDemand +import com.kairo.reader.core.rsvp.engine.BoundaryBefore +import com.kairo.reader.core.rsvp.engine.ContextSnapshot +import com.kairo.reader.core.rsvp.engine.RhythmState +import com.kairo.reader.core.rsvp.timing.RsvpUnitTimingInput +import com.kairo.reader.core.rsvp.timing.computeUnitTiming +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpProtectedTimingTest { + private val easy = Token("cat", TokenType.WORD) + private val config = RsvpConfig( + tempoMsPerWord = 150L, minWordMs = 40L, longWordMinMs = 120L, + useAdaptiveTiming = false, useProsodyPacing = false, useClausePausing = false, + useDialogueDetection = false, usePunctuationLandingHold = false, + startDelayMs = 0L, endDelayMs = 0L, rampUpFrames = 0, rampDownFrames = 0, + ) + + @Test + fun difficultWordKeepsItsAllowanceAfterEasyWordsEvenWithNearFrozenRhythm() { + val rhythm = rhythm() + val baseline = duration(listOf(easy), rhythm) + repeat(8) { duration(listOf(easy), rhythm) } + val hard = duration(listOf(easy), rhythm, demands = listOf(RsvpReadingDemand(2.0))) + val unsupported = duration(listOf(easy), rhythm(), config.copy(difficultWordSupport = 0.0), listOf(RsvpReadingDemand(2.0))) + assertTrue(hard.protectedWordMs > 80L) + assertTrue(hard.durationMs >= unsupported.durationMs + hard.protectedWordMs - 15L) + assertTrue(kotlin.math.abs(duration(listOf(easy), rhythm).durationMs - baseline.durationMs) <= 1L) + } + + @Test + fun evenSmallAllowancesBypassSmoothing() { + val rhythm = rhythm() + val baseline = duration(listOf(easy), rhythm) + repeat(8) { duration(listOf(easy), rhythm) } + val held = duration(listOf(easy), rhythm, demands = listOf(RsvpReadingDemand(0.03))) + assertTrue(held.protectedWordMs in 3L..4L) + assertTrue(held.durationMs >= baseline.durationMs + 3L) + } + + @Test + fun explicitWordPausesAreAddedAfterTheDisplayFloor() { + val settings = config.copy(minWordMs = 220L) + val baseline = duration(listOf(easy), rhythm(), settings) + val held = duration(listOf(easy.copy(pauseAfterMs = 50L)), rhythm(), settings) + assertTrue(held.durationMs >= baseline.durationMs + 40L) + } + + @Test + fun groupingDoesNotLoseReadingTimeOrSlowTheNextSingleWord() { + val rhythm = rhythm() + for (count in listOf(1, 2, 3, 1, 3, 2, 1)) { + val independent = duration(List(count) { easy }, rhythm()) + assertEquals(independent, duration(List(count) { easy }, rhythm)) + } + } + + @Test + fun supportScalesAllowanceAndLeavesFloorsAlone() { + val demand = listOf(RsvpReadingDemand(1.0)) + val off = duration(listOf(easy), rhythm(), config.copy(difficultWordSupport = 0.0), demand) + val normal = duration(listOf(easy), rhythm(), config, demand) + val strong = duration(listOf(easy), rhythm(), config.copy(difficultWordSupport = 2.0), demand) + assertEquals(0L, off.protectedWordMs) + assertTrue(normal.protectedWordMs > 0L) + assertTrue(kotlin.math.abs(strong.protectedWordMs - 2 * normal.protectedWordMs) <= 1L) + for (support in listOf(0.0, 1.0, 2.0)) { + assertEquals(220L, duration(listOf(easy), rhythm(), config.copy(minWordMs = 220L, difficultWordSupport = support)).durationMs) + } + } + + @Test + fun easyCompanionsAndFinalPositionCannotDiluteAWordAllowance() { + val hard = RsvpReadingDemand(2.0) + val solo = duration(listOf(easy), rhythm(), demands = listOf(hard)) + val grouped = duration(listOf(easy, easy), rhythm(), demands = listOf(RsvpReadingDemand(0.0), hard)) + assertEquals(solo.protectedWordMs, grouped.protectedWordMs) + val engine = ComprehensionRsvpEngine() + val tokens = listOf(easy, Token("quizzacious", TokenType.WORD)) + val frames = engine.generateFrames(tokens, 0, config.copy(maxChunkLength = 30), RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) + assertTrue(frames.last().protectedWordMs > 0) + } + + @Test + fun displayedAllowanceSurvivesWordSeparation() { + val tokens = listOf(easy, easy, Token("quizzacious", TokenType.WORD), easy) + val frames = ComprehensionRsvpEngine().generateFrames( + tokens, + 0, + config.copy(blinkMode = BlinkMode.SUBTLE, maxChunkLength = 30), + RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH), + ) + val hardFrame = frames.first { !it.isWordSeparation && it.originalTokenIndex == 2 } + assertTrue(hardFrame.protectedWordMs > 80L) + assertTrue(hardFrame.durationMs > hardFrame.protectedWordMs) + assertTrue(frames.none { it.isWordSeparation && it.protectedWordMs != 0L }) + } + + private fun rhythm() = RhythmState(smoothingAlpha = 0.01, maxSpeedupFactor = 1.01, maxSlowdownFactor = 1.01) + + private fun duration( + words: List, + rhythm: RhythmState, + settings: RsvpConfig = config, + demands: List = emptyList(), + ) = computeUnitTiming( + RsvpUnitTimingInput( + frameTokens = words, config = settings, contextBefore = ContextSnapshot(0, false), rhythm = rhythm, + prevToken = easy, prevWord = easy, nextToken = easy, nextWord = easy, boundaryBefore = BoundaryBefore.NONE, + readingDemands = demands, + ) + ) +} diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt new file mode 100644 index 00000000..0b8ca43d --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt @@ -0,0 +1,130 @@ +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.EnglishReadingFrequency +import com.kairo.reader.core.rsvp.analysis.ReadingDemandAnalyzer +import com.kairo.reader.core.rsvp.analysis.RsvpReadingDemand +import com.kairo.reader.core.rsvp.engine.ExpandedToken +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpReadingDemandTest { + private val english = RsvpLanguagePolicy.ENGLISH + private fun word(text: String) = Token(text, TokenType.WORD) + private fun demand(text: String, seen: Int = 0) = ReadingDemandAnalyzer.analyze(word(text), english, seen) + + @Test + fun bundledFrequenciesDistinguishActualFamiliarityFromLetterRarity() { + assertNotNull(EnglishReadingFrequency.zipf("extraordinary")) + assertTrue(EnglishReadingFrequency.zipf("people")!! > EnglishReadingFrequency.zipf("quartz")!!) + assertTrue(demand("people").recognition < demand("xqtrpz").recognition) + assertTrue(demand("the").allowanceMs(150.0) == 0.0) + } + + @Test + fun recognitionUsesTextRatherThanLegacyTokenEstimates() { + val token = word("extraordinary") + assertEquals( + ReadingDemandAnalyzer.analyze(token, english), + ReadingDemandAnalyzer.analyze( + token.copy(syllableCount = 99, frequencyScore = 0.0, complexityMultiplier = 99.0), + english, + ) + ) + } + + @Test + fun allowanceIsSmoothMonotoneAndBoundedAtEveryTempo() { + for (tempo in listOf(20.0, 150.0, 500.0, 1000.0)) { + val samples = (0..100).map { RsvpReadingDemand(it / 10.0).allowanceMs(tempo) } + assertEquals(0.0, samples.first(), 0.0) + assertTrue(samples.zipWithNext().all { (a, b) -> b >= a }) + assertTrue(samples.last() <= 180.0) + assertTrue(samples[2] - samples[1] < samples[1] - samples[0]) + } + } + + @Test + fun repetitionOnlyReducesNoveltyAndNeverRecognition() { + val first = demand("quizzacious") + val repeated = demand("quizzacious", 8) + assertEquals(first.recognition, repeated.recognition, 0.0) + assertTrue(first.novelty > repeated.novelty) + assertTrue(first.allowanceMs(150.0) > repeated.allowanceMs(150.0)) + assertTrue(repeated.allowanceMs(150.0) >= first.copy(novelty = 0.0).allowanceMs(150.0)) + assertTrue(first.allowanceMs(150.0) - repeated.allowanceMs(150.0) < 30.0) + } + + @Test + fun sourcePlanIsStableAcrossSeekingSentenceBoundariesAndParagraphResets() { + val tokens = listOf( + word("quizzacious"), + Token(".", TokenType.PUNCTUATION), + word("quizzacious"), + Token("\n", TokenType.PARAGRAPH_BREAK), + word("quizzacious") + ) + val complete = ReadingDemandAnalyzer.plan(tokens, 0, english) + val resumed = ReadingDemandAnalyzer.plan(tokens, 2, english) + assertEquals(complete[2], resumed[2]) + assertEquals(complete[0], complete[4]) + assertTrue(complete.getValue(0).novelty > complete.getValue(2).novelty) + assertEquals(complete, ReadingDemandAnalyzer.plan(tokens, 0, english)) + } + + @Test + fun unknownLanguagesDoNotInheritEnglishSpellingOrFrequencyPenalties() { + for (policy in RsvpLanguagePolicy.entries.filter { it != english }) { + assertEquals(ReadingDemandAnalyzer.analyze(word("people"), policy), ReadingDemandAnalyzer.analyze(word("xqtrpz"), policy)) + assertEquals(0.0, ReadingDemandAnalyzer.analyze(word("école"), policy).novelty, 0.0) + } + assertTrue(ReadingDemandAnalyzer.analyze(word("123456"), RsvpLanguagePolicy.UNKNOWN).recognition > 0.0) + } + + @Test + fun normalizationIsIndependentOfDeviceLocale() { + val previous = Locale.getDefault() + try { + Locale.setDefault(Locale.forLanguageTag("tr")) + assertEquals("isn't", ReadingDemandAnalyzer.normalize("ISN’T")) + assertEquals(ReadingDemandAnalyzer.normalize("école"), ReadingDemandAnalyzer.normalize("e\u0301cole")) + } finally { + Locale.setDefault(previous) + } + } + + @Test + fun splitWordsShareOneSourceAllowanceWithoutManufacturingNovelty() { + val token = word("quizzacious") + val pieces = listOf( + ExpandedToken(token.copy(isSubwordChunk = true, highlightStart = 0, highlightEndExclusive = 4), 0, 0, 0, 11), + ExpandedToken(token.copy(isSubwordChunk = true, highlightStart = 4, highlightEndExclusive = 11), 0, 1, 0, 11), + ) + val attached = ReadingDemandAnalyzer.attach(pieces, listOf(token), 0, english) + assertEquals(demand(token.text).allowanceMs(150.0), attached.sumOf { it.readingDemand!!.allowanceMs(150.0) }, 0.001) + assertEquals(attached[0].readingDemand!!.novelty, attached[1].readingDemand!!.novelty, 0.0) + } + + @Test + fun obsoleteTuningCannotStackAnotherAllowanceAndSupportWorksWithCadenceDisabled() { + val config = RsvpConfig(startDelayMs = 0, endDelayMs = 0, rampUpFrames = 0, rampDownFrames = 0, useAdaptiveTiming = false) + val tokens = listOf(word("quizzacious"), word("12345"), word("neuroplasticity")) + val engine = ComprehensionRsvpEngine() + fun frames(settings: RsvpConfig) = engine.generateFrames(tokens, 0, settings, RsvpGenerationOptions(english)) + val normal = frames(config) + val legacy = frames( + config.copy( + syllableExtraMs = 100, rarityExtraMaxMs = 100, complexityStrength = 1.0, + lengthStrength = 3.0, lengthExponent = 3.0, complexWordHoldMs = 400, adaptiveDifficultyMaxHoldMs = 400, + complexWordThreshold = 1.0, phraseBreathingRoomMs = 400 + ) + ) + assertEquals(normal, legacy) + assertTrue(normal.sumOf { it.protectedWordMs } > frames(config.copy(difficultWordSupport = 0.0)).sumOf { it.protectedWordMs }) + } +} 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 3e80cfeb..f6fcc991 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 @@ -52,13 +52,13 @@ class RsvpScoredSegmentationTest { listOf( word("in"), word("the"), - word("puzzle", frequency = 0.0, complexity = 1.8, syllables = 3), + word("xqtrpz"), ) val scored = engine.generateFrames(tokens, 0, stableConfig, scoredOptions) assertEquals(listOf("in", "the"), scored.first().words()) - assertEquals(listOf("puzzle"), scored[1].words()) + assertEquals(listOf("xqtrpz"), scored[1].words()) } @Test @@ -67,7 +67,7 @@ class RsvpScoredSegmentationTest { listOf( word("in"), word("the"), - word("puzzle", frequency = 0.0, complexity = 1.8, syllables = 3), + word("xqtrpz"), word("on"), word("the"), word("table"), @@ -76,7 +76,7 @@ class RsvpScoredSegmentationTest { val frames = engine.generateFrames(tokens, 0, stableConfig, scoredOptions) assertEquals( - listOf(listOf("in", "the"), listOf("puzzle"), listOf("on", "the", "table")), + listOf(listOf("in", "the"), listOf("xqtrpz"), listOf("on", "the", "table")), frames.map { frame -> frame.words() }, ) } @@ -116,12 +116,12 @@ class RsvpScoredSegmentationTest { @Test fun persistedWidthsAboveThreeRetainDifficultyAwareScoring() { - val tokens = listOf(word("in"), word("the"), word("puzzle", frequency = 0.0, complexity = 1.8, syllables = 3)) + val tokens = listOf(word("in"), word("the"), word("xqtrpz")) 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()) + assertEquals(listOf("xqtrpz"), frames[1].words()) } } @@ -131,7 +131,7 @@ class RsvpScoredSegmentationTest { listOf( word("in"), word("the"), - word("puzzle", frequency = 0.0, complexity = 1.8, syllables = 3), + word("xqtrpz"), word("on"), word("the"), word("table"), 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 fe66e927..7f3648cd 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 @@ -55,8 +55,34 @@ class RsvpThoughtFlowTest : ComprehensionRsvpTestBase() { 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) + assertTrue(cues.values.sumOf { it.integrationHoldMs } <= 70.0 * stableConfig.difficultWordSupport) + assertEquals(0.0, plan(dense, stableConfig.copy(difficultWordSupport = 0.0)).values.sumOf { it.integrationHoldMs }, 0.0) + } + + @Test + fun densePhrasesReceiveSomeProcessingTimeBeforeTheirLandingWithinOneBudget() { + val tokens = List(8) { w("concept$it").copy(frequencyScore = 0.1, complexityMultiplier = 1.8) } + val config = stableConfig.copy(phraseBreathingRoomMs = 120L) + val cues = plan(tokens, config).values.toList() + assertTrue(cues.dropLast(1).all { it.processingHoldMs > 0.0 }) + assertTrue(cues.last().integrationHoldMs > cues.sumOf { it.processingHoldMs }) + assertEquals(70.0, cues.sumOf { it.processingHoldMs + it.integrationHoldMs }, 0.001) + val disabled = plan(tokens, config.copy(difficultWordSupport = 0.0)).values + assertTrue(disabled.all { it.processingHoldMs == 0.0 && it.integrationHoldMs == 0.0 }) + val adaptiveOff = plan(tokens, config.copy(useAdaptiveTiming = false)).values + assertEquals(cues, adaptiveOff.toList()) + } + + @Test + fun splittingAWordDoesNotMultiplyItsPhraseProcessingAllowance() { + val tokens = List(8) { w("concept$it").copy(frequencyScore = 0.1, complexityMultiplier = 1.8) } + val original = tokens.mapIndexed { index, token -> ExpandedToken(token, index, index, 0, token.text.length) } + val split = original.flatMap { entry -> + listOf(entry.copy(token = entry.token.copy(isSubwordChunk = true)), entry.copy(token = entry.token.copy(isSubwordChunk = true))) + }.mapIndexed { index, entry -> entry.copy(expandedIndex = index) } + fun total(words: List) = RsvpThoughtPlan.analyze(words, stableConfig, RsvpLanguagePolicy.ENGLISH) + .values.sumOf { it.processingHoldMs + it.integrationHoldMs } + assertEquals(total(original), total(split), 0.001) } @Test diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomizationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomizationTest.kt index 531644d6..e3ce46c5 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomizationTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/segmentation/RsvpAtomizationTest.kt @@ -181,7 +181,7 @@ class RsvpAtomizationTest { @Test fun disabledParentheticalAnalysisCannotInfluenceScoredGrouping() { - val tokens = listOf(punctuation("("), word("zog"), word("mif"), punctuation(")")) + val tokens = listOf(punctuation("("), word("red"), word("hat"), punctuation(")")) val baseConfig = RsvpConfig( enablePhraseChunking = true, @@ -205,7 +205,8 @@ class RsvpAtomizationTest { assertEquals(2, enabled.selectedWordCount) assertTrue(enabled.components.any { it.reason == RsvpSegmentationReason.PARENTHETICAL_COHESION }) - assertEquals(1, disabled.selectedWordCount) + // Familiar words can still group; disabling the aside must remove its affinity. + assertTrue(enabled.pathScore > disabled.pathScore) assertTrue(disabled.components.none { it.reason == RsvpSegmentationReason.PARENTHETICAL_COHESION }) } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpBoundaryIntegrationTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpBoundaryIntegrationTest.kt index b3ab3f63..937a5a3a 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpBoundaryIntegrationTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/timing/RsvpBoundaryIntegrationTest.kt @@ -6,7 +6,6 @@ import com.kairo.reader.core.model.TokenType import com.kairo.reader.core.rsvp.analysis.RsvpThoughtCue import com.kairo.reader.core.rsvp.engine.BoundaryBefore import com.kairo.reader.core.rsvp.engine.ContextSnapshot -import com.kairo.reader.core.rsvp.engine.FlowState import com.kairo.reader.core.rsvp.engine.RhythmState import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -32,7 +31,6 @@ class RsvpBoundaryIntegrationTest { config = config, contextBefore = ContextSnapshot(0, false), rhythm = RhythmState(1.0, 2.0, 2.0), - flow = FlowState(1.0, 1.0, 1.0, 0.0), prevToken = null, prevWord = null, nextToken = Token("Next", TokenType.WORD), From d349444d32324cf9e306d7417e0ab664a0247ff7 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 14 Sep 2026 21:46:35 +0100 Subject: [PATCH 04/16] feat(settings): simplify RSVP tuning to speed and reading support --- .../ui/settings/RsvpSettingsParityTest.kt | 47 ++++- .../settings/RsvpAdvancedSettingsContent.kt | 165 ++++++------------ .../settings/RsvpEssentialSettingsContent.kt | 79 ++------- .../ui/settings/SettingsSearchRsvpEntries.kt | 63 ++----- .../main/res/values-de/settings_search.xml | 3 - app/src/main/res/values-de/strings.xml | 22 +-- .../main/res/values-es/settings_search.xml | 3 - app/src/main/res/values-es/strings.xml | 22 +-- .../main/res/values-fr/settings_search.xml | 3 - app/src/main/res/values-fr/strings.xml | 22 +-- .../main/res/values-ja/settings_search.xml | 3 - app/src/main/res/values-ja/strings.xml | 22 +-- .../res/values-pt-rBR/settings_search.xml | 3 - app/src/main/res/values-pt-rBR/strings.xml | 22 +-- .../res/values-zh-rCN/settings_search.xml | 3 - app/src/main/res/values-zh-rCN/strings.xml | 22 +-- app/src/main/res/values/settings_search.xml | 3 - app/src/main/res/values/strings.xml | 22 +-- 18 files changed, 176 insertions(+), 353 deletions(-) diff --git a/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt index a184f208..7710a574 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt @@ -2,6 +2,9 @@ package com.kairo.reader.ui.settings import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.hasAnyAncestor @@ -9,11 +12,15 @@ import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performSemanticsAction import androidx.test.ext.junit.runners.AndroidJUnit4 import com.kairo.reader.R import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.RsvpConfig import com.kairo.reader.core.model.UserPreferences import com.kairo.reader.ui.theme.KairoFocusedReadingTheme import com.kairo.reader.ui.theme.KairoTheme @@ -97,12 +104,48 @@ class RsvpSettingsParityTest { composeRule.onNode(isDialog()).assertDoesNotExist() } + @Test + fun readingSupportCommitsWithOnlyTwoEssentialSlidersAndFloorsRemainAdvanced() { + val prefs = mutableStateOf(UserPreferences()) + composeRule.setContent { + KairoTheme { + SettingsFixture( + prefs.value, + onConfigChange = { update -> prefs.value = prefs.value.copy(rsvpConfig = update(prefs.value.rsvpConfig)) }, + onBack = {}, + ) + } + } + val resources = composeRule.activity.resources + val sliders = SemanticsMatcher.keyIsDefined(SemanticsActions.SetProgress) + // The essential sliders are speed and reading support. + composeRule.onAllNodes(sliders).assertCountEquals(2) + composeRule.onNodeWithText(resources.getString(R.string.rsvp_difficult_word_support_title)).performScrollTo().assertIsDisplayed() + composeRule.onAllNodes(sliders)[1].performSemanticsAction(SemanticsActions.SetProgress) { it(150f) } + composeRule.runOnIdle { assertEquals(1.5, prefs.value.rsvpConfig.difficultWordSupport, 0.0) } + composeRule.onNodeWithText(resources.getString(R.string.rsvp_adaptive_pacing_title)).assertDoesNotExist() + val screenshot = composeRule.onRoot().captureToImage().asAndroidBitmap() + val directory = requireNotNull(composeRule.activity.getExternalFilesDir("ui-review")) + java.io.File(directory, "rsvp-comprehension-controls.png").outputStream().use { + screenshot.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) + } + composeRule.onNodeWithText(resources.getString(R.string.rsvp_difficult_word_support_title)).assertExists() + composeRule.onNodeWithText(resources.getString(R.string.settings_advanced_title)).performScrollTo().performClick() + composeRule.onNodeWithText(resources.getString(R.string.rsvp_readability_floors_title)).performScrollTo().performClick() + composeRule.onNodeWithText(resources.getString(R.string.rsvp_min_word_time_title)).performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText(resources.getString(R.string.rsvp_long_word_min_title)).performScrollTo().assertIsDisplayed() + } + @androidx.compose.runtime.Composable - private fun SettingsFixture(prefs: UserPreferences, onBack: () -> Unit) { + private fun SettingsFixture( + prefs: UserPreferences, + onConfigChange: ((RsvpConfig) -> RsvpConfig) -> Unit = {}, + onBack: () -> Unit, + ) { RsvpSettingsScreen( preferences = prefs, onSelectRsvpProfile = {}, onSaveRsvpProfile = { _, _ -> }, onDeleteRsvpProfile = {}, - onRsvpTempoMsPerWordChange = {}, onRsvpConfigChange = {}, onUnlockExtremeSpeedChange = {}, + onRsvpTempoMsPerWordChange = {}, onRsvpConfigChange = onConfigChange, onUnlockExtremeSpeedChange = {}, onRsvpFontSizeChange = {}, onRsvpTextBrightnessChange = {}, onRsvpFontWeightChange = {}, onRsvpFontFamilyChange = {}, onRsvpVerticalBiasChange = {}, onRsvpHorizontalBiasChange = {}, onBack = onBack, diff --git a/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt b/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt index a7d64545..feb4fcbc 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt @@ -79,91 +79,71 @@ internal fun RsvpAdvancedSettingsContent( ), ) { DeferredSliderRow( - title = stringResource(R.string.rsvp_long_word_threshold_title), - valueLabel = { resources.getString(R.string.format_chars, it.toInt()) }, - rawValue = config.longWordChars.toFloat(), + title = stringResource(R.string.rsvp_min_word_time_title), + subtitle = stringResource(R.string.rsvp_min_word_time_subtitle), + valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, + rawValue = config.minWordMs.toFloat(), onCommit = { newValue -> updateConfig { it.copy( - longWordChars = - newValue.toInt().coerceIn( - Constraints.MIN_LONG_WORD_CHARS, - Constraints.MAX_LONG_WORD_CHARS, - ), + minWordMs = + newValue.toLong().coerceIn(Constraints.MIN_WORD_MS, Constraints.MAX_WORD_MS), ) } }, - valueRange = - Constraints.MIN_LONG_WORD_CHARS.toFloat()..Constraints.MAX_LONG_WORD_CHARS.toFloat(), + valueRange = Constraints.MIN_WORD_MS.toFloat()..Constraints.MAX_WORD_MS.toFloat(), ) DeferredSliderRow( - title = stringResource(R.string.rsvp_split_word_pause_title), - subtitle = stringResource(R.string.rsvp_split_word_pause_subtitle), - valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, - rawValue = config.subwordChunkPauseMs.toFloat(), + title = stringResource(R.string.rsvp_long_word_min_title), + subtitle = stringResource(R.string.rsvp_long_word_min_subtitle), + valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, + rawValue = config.longWordMinMs.toFloat(), onCommit = { newValue -> updateConfig { it.copy( - subwordChunkPauseMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_SUBWORD_PAUSE_MS), + longWordMinMs = + newValue.toLong().coerceIn( + Constraints.MIN_LONG_WORD_MS, + Constraints.MAX_LONG_WORD_MS, + ), ) } }, - valueRange = 0f..Constraints.MAX_SUBWORD_PAUSE_MS.toFloat(), + valueRange = + Constraints.MIN_LONG_WORD_MS.toFloat()..Constraints.MAX_LONG_WORD_MS.toFloat(), ) - } - - ExpandableSettingsSection( - title = stringResource(R.string.rsvp_difficulty_model_title), - summary = - stringResource( - R.string.rsvp_difficulty_model_summary, - config.syllableExtraMs, - config.rarityExtraMaxMs, - formatPercent(resources, config.complexityStrength), - ), - ) { DeferredSliderRow( - title = stringResource(R.string.rsvp_syllable_boost_title), - valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, - rawValue = config.syllableExtraMs.toFloat(), + title = stringResource(R.string.rsvp_long_word_threshold_title), + valueLabel = { resources.getString(R.string.format_chars, it.toInt()) }, + rawValue = config.longWordChars.toFloat(), onCommit = { newValue -> updateConfig { it.copy( - syllableExtraMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_SYLLABLE_EXTRA_MS), + longWordChars = + newValue.toInt().coerceIn( + Constraints.MIN_LONG_WORD_CHARS, + Constraints.MAX_LONG_WORD_CHARS, + ), ) } }, - valueRange = 0f..Constraints.MAX_SYLLABLE_EXTRA_MS.toFloat(), + valueRange = + Constraints.MIN_LONG_WORD_CHARS.toFloat()..Constraints.MAX_LONG_WORD_CHARS.toFloat(), ) DeferredSliderRow( - title = stringResource(R.string.rsvp_rarity_boost_title), + title = stringResource(R.string.rsvp_split_word_pause_title), + subtitle = stringResource(R.string.rsvp_split_word_pause_subtitle), valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, - rawValue = config.rarityExtraMaxMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - rarityExtraMaxMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_RARITY_EXTRA_MS), - ) - } - }, - valueRange = 0f..Constraints.MAX_RARITY_EXTRA_MS.toFloat(), - ) - DeferredSliderRow( - title = stringResource(R.string.rsvp_complexity_strength_title), - valueLabel = { resources.getString(R.string.format_percent, it.toInt()) }, - rawValue = (config.complexityStrength * Constraints.PERCENT_SCALE).toFloat(), + rawValue = config.subwordChunkPauseMs.toFloat(), onCommit = { newValue -> updateConfig { it.copy( - complexityStrength = - (newValue / Constraints.PERCENT_SCALE).coerceIn(0.0, 1.0), + subwordChunkPauseMs = + newValue.toLong().coerceIn(0L, Constraints.MAX_SUBWORD_PAUSE_MS), ) } }, - valueRange = 0f..Constraints.PERCENT_SCALE.toFloat(), + valueRange = 0f..Constraints.MAX_SUBWORD_PAUSE_MS.toFloat(), ) } @@ -226,6 +206,16 @@ internal fun RsvpAdvancedSettingsContent( }, valueRange = 0f..Constraints.MAX_PERIOD_PAUSE_MS.toFloat(), ) + DeferredSliderRow( + title = stringResource(R.string.rsvp_sentence_end_pause_title), + subtitle = stringResource(R.string.rsvp_sentence_end_pause_subtitle), + valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, + rawValue = config.sentenceEndPauseMs.toFloat(), + onCommit = { value -> + updateConfig { it.copy(sentenceEndPauseMs = value.toLong().coerceIn(0L, Constraints.MAX_SENTENCE_END_PAUSE_MS)) } + }, + valueRange = 0f..Constraints.MAX_SENTENCE_END_PAUSE_MS.toFloat(), + ) DeferredSliderRow( title = stringResource(R.string.rsvp_punctuation_dash), valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, @@ -523,67 +513,6 @@ internal fun RsvpAdvancedSettingsContent( } } - ExpandableSettingsSection( - title = stringResource(R.string.rsvp_adaptive_pacing_title), - summary = - stringResource( - R.string.rsvp_adaptive_pacing_summary, - config.adaptiveDifficultyMaxHoldMs, - config.complexWordHoldMs, - formatMultiplier(resources, config.complexWordThreshold), - ), - ) { - DeferredSliderRow( - title = stringResource(R.string.rsvp_difficulty_boost_title), - subtitle = stringResource(R.string.rsvp_difficulty_boost_subtitle), - valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, - rawValue = config.adaptiveDifficultyMaxHoldMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - adaptiveDifficultyMaxHoldMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_ADAPTIVE_HOLD_MS), - ) - } - }, - valueRange = 0f..Constraints.MAX_ADAPTIVE_HOLD_MS.toFloat(), - ) - DeferredSliderRow( - title = stringResource(R.string.rsvp_complex_word_boost_title), - subtitle = stringResource(R.string.rsvp_complex_word_boost_subtitle), - valueLabel = { resources.getString(R.string.format_plus_ms, it.toLong()) }, - rawValue = config.complexWordHoldMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - complexWordHoldMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_ADAPTIVE_HOLD_MS), - ) - } - }, - valueRange = 0f..Constraints.MAX_ADAPTIVE_HOLD_MS.toFloat(), - ) - DeferredSliderRow( - title = stringResource(R.string.rsvp_complex_word_threshold_title), - subtitle = stringResource(R.string.rsvp_complex_word_threshold_subtitle), - valueLabel = { resources.getString(R.string.format_multiplier, it) }, - rawValue = config.complexWordThreshold.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - complexWordThreshold = - newValue.toDouble().coerceIn( - Constraints.MIN_COMPLEX_WORD_THRESHOLD, - Constraints.MAX_COMPLEX_WORD_THRESHOLD, - ), - ) - } - }, - valueRange = - Constraints.MIN_COMPLEX_WORD_THRESHOLD.toFloat()..Constraints.MAX_COMPLEX_WORD_THRESHOLD.toFloat(), - ) - } - ExpandableSettingsSection( title = stringResource(R.string.rsvp_rhythm_title), summary = @@ -594,6 +523,12 @@ internal fun RsvpAdvancedSettingsContent( stringResource(blinkModeLabelRes(config.blinkMode)), ), ) { + SettingsSwitchRow( + title = stringResource(R.string.rsvp_adaptive_pacing_title), + subtitle = stringResource(R.string.rsvp_adaptive_pacing_subtitle), + checked = config.useAdaptiveTiming, + onCheckedChange = { enabled -> updateConfig { it.copy(useAdaptiveTiming = enabled) } }, + ) DeferredSliderRow( title = stringResource(R.string.rsvp_stability_title), subtitle = stringResource(R.string.rsvp_stability_subtitle), diff --git a/app/src/main/java/com/kairo/reader/ui/settings/RsvpEssentialSettingsContent.kt b/app/src/main/java/com/kairo/reader/ui/settings/RsvpEssentialSettingsContent.kt index 9b13ff17..b80f1e16 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/RsvpEssentialSettingsContent.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/RsvpEssentialSettingsContent.kt @@ -87,67 +87,26 @@ internal fun RsvpEssentialSettingsContent( }, valueRange = RsvpSpeedControl.MIN_SPEED..RsvpSpeedControl.MAX_SPEED, ) - ExpandableSettingsSection( - title = stringResource(R.string.rsvp_readability_floors_title), - summary = stringResource(R.string.rsvp_quick_tune_subtitle), - ) { - DeferredSliderRow( - title = stringResource(R.string.rsvp_min_word_time_title), - subtitle = stringResource(R.string.rsvp_min_word_time_subtitle), - valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, - rawValue = config.minWordMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - minWordMs = - newValue.toLong().coerceIn(Constraints.MIN_WORD_MS, Constraints.MAX_WORD_MS), - ) - } - }, - valueRange = Constraints.MIN_WORD_MS.toFloat()..Constraints.MAX_WORD_MS.toFloat(), - ) - DeferredSliderRow( - title = stringResource(R.string.rsvp_long_word_min_title), - subtitle = stringResource(R.string.rsvp_long_word_min_subtitle), - valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, - rawValue = config.longWordMinMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - longWordMinMs = - newValue.toLong().coerceIn( - Constraints.MIN_LONG_WORD_MS, - Constraints.MAX_LONG_WORD_MS, - ), - ) - } - }, - valueRange = - Constraints.MIN_LONG_WORD_MS.toFloat()..Constraints.MAX_LONG_WORD_MS.toFloat(), - ) - DeferredSliderRow( - title = stringResource(R.string.rsvp_sentence_end_pause_title), - subtitle = stringResource(R.string.rsvp_sentence_end_pause_subtitle), - valueLabel = { resources.getString(R.string.format_ms, it.toLong()) }, - rawValue = config.sentenceEndPauseMs.toFloat(), - onCommit = { newValue -> - updateConfig { - it.copy( - sentenceEndPauseMs = - newValue.toLong().coerceIn(0L, Constraints.MAX_SENTENCE_END_PAUSE_MS), - ) - } - }, - valueRange = 0f..Constraints.MAX_SENTENCE_END_PAUSE_MS.toFloat(), - ) - } - SettingsSwitchRow( - title = stringResource(R.string.rsvp_adaptive_pacing_title), - subtitle = stringResource(R.string.rsvp_adaptive_pacing_subtitle), - checked = config.useAdaptiveTiming, - onCheckedChange = { enabled -> - updateConfig { it.copy(useAdaptiveTiming = enabled) } + DeferredSliderRow( + title = stringResource(R.string.rsvp_difficult_word_support_title), + subtitle = stringResource(R.string.rsvp_difficult_word_support_subtitle), + valueLabel = { + when (it.toInt()) { + 0 -> resources.getString(R.string.rsvp_reading_support_off) + 200 -> resources.getString(R.string.rsvp_reading_support_strong) + else -> resources.getString(R.string.format_percent, it.toInt()) + } + }, + rawValue = (config.difficultWordSupport * Constraints.PERCENT_SCALE).toFloat(), + onCommit = { value -> + updateConfig { + it.copy( + difficultWordSupport = (value / Constraints.PERCENT_SCALE) + .coerceIn(0.0, Constraints.MAX_DIFFICULT_WORD_SUPPORT) + ) + } }, + valueRange = 0f..(Constraints.MAX_DIFFICULT_WORD_SUPPORT * Constraints.PERCENT_SCALE).toFloat(), ) } diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt index 3694cfc6..a1c35024 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt @@ -3,6 +3,13 @@ package com.kairo.reader.ui.settings import com.kairo.reader.R internal val rsvpSearchEntries = listOf( + SettingsSearchEntry( + id = "rsvp.rsvp_difficult_word_support_title", + page = SettingsSearchPage.RSVP, + titleRes = R.string.rsvp_difficult_word_support_title, + descriptionRes = R.string.rsvp_difficult_word_support_subtitle, + sectionRes = R.string.rsvp_quick_tune_title, + ), SettingsSearchEntry( id = "rsvp.rsvp_reading_speed_title", page = SettingsSearchPage.RSVP, @@ -16,6 +23,7 @@ internal val rsvpSearchEntries = listOf( titleRes = R.string.rsvp_min_word_time_title, descriptionRes = R.string.rsvp_min_word_time_subtitle, sectionRes = R.string.rsvp_readability_floors_title, + advanced = true, ), SettingsSearchEntry( id = "rsvp.rsvp_long_word_min_title", @@ -23,20 +31,23 @@ internal val rsvpSearchEntries = listOf( titleRes = R.string.rsvp_long_word_min_title, descriptionRes = R.string.rsvp_long_word_min_subtitle, sectionRes = R.string.rsvp_readability_floors_title, + advanced = true, ), SettingsSearchEntry( id = "rsvp.rsvp_sentence_end_pause_title", page = SettingsSearchPage.RSVP, titleRes = R.string.rsvp_sentence_end_pause_title, descriptionRes = R.string.rsvp_sentence_end_pause_subtitle, - sectionRes = R.string.rsvp_readability_floors_title, + sectionRes = R.string.rsvp_punctuation_pauses_title, + advanced = true, ), SettingsSearchEntry( id = "rsvp.rsvp_adaptive_pacing_title", page = SettingsSearchPage.RSVP, titleRes = R.string.rsvp_adaptive_pacing_title, descriptionRes = R.string.rsvp_adaptive_pacing_subtitle, - sectionRes = R.string.rsvp_readability_floors_title, + sectionRes = R.string.rsvp_rhythm_title, + advanced = true, ), SettingsSearchEntry( id = "rsvp.rsvp_phrase_chunking_title", @@ -128,30 +139,6 @@ internal val rsvpSearchEntries = listOf( sectionRes = R.string.rsvp_readability_floors_title, advanced = true, ), - SettingsSearchEntry( - id = "rsvp.rsvp_syllable_boost_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_syllable_boost_title, - descriptionRes = R.string.settings_search_syllables_description, - sectionRes = R.string.rsvp_difficulty_model_title, - advanced = true, - ), - SettingsSearchEntry( - id = "rsvp.rsvp_rarity_boost_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_rarity_boost_title, - descriptionRes = R.string.settings_search_rarity_description, - sectionRes = R.string.rsvp_difficulty_model_title, - advanced = true, - ), - SettingsSearchEntry( - id = "rsvp.rsvp_complexity_strength_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_complexity_strength_title, - descriptionRes = R.string.settings_search_complexity_description, - sectionRes = R.string.rsvp_difficulty_model_title, - advanced = true, - ), SettingsSearchEntry( id = "rsvp.rsvp_punctuation_breathing_title", page = SettingsSearchPage.RSVP, @@ -297,30 +284,6 @@ internal val rsvpSearchEntries = listOf( advanced = true, requiresRes = R.string.rsvp_parenthetical_aside_title, ), - SettingsSearchEntry( - id = "rsvp.rsvp_difficulty_boost_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_difficulty_boost_title, - descriptionRes = R.string.rsvp_difficulty_boost_subtitle, - sectionRes = R.string.rsvp_adaptive_pacing_title, - advanced = true, - ), - SettingsSearchEntry( - id = "rsvp.rsvp_complex_word_boost_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_complex_word_boost_title, - descriptionRes = R.string.rsvp_complex_word_boost_subtitle, - sectionRes = R.string.rsvp_adaptive_pacing_title, - advanced = true, - ), - SettingsSearchEntry( - id = "rsvp.rsvp_complex_word_threshold_title", - page = SettingsSearchPage.RSVP, - titleRes = R.string.rsvp_complex_word_threshold_title, - descriptionRes = R.string.rsvp_complex_word_threshold_subtitle, - sectionRes = R.string.rsvp_adaptive_pacing_title, - advanced = true, - ), SettingsSearchEntry( id = "rsvp.rsvp_stability_title", page = SettingsSearchPage.RSVP, diff --git a/app/src/main/res/values-de/settings_search.xml b/app/src/main/res/values-de/settings_search.xml index e5e3a380..b64ead62 100644 --- a/app/src/main/res/values-de/settings_search.xml +++ b/app/src/main/res/values-de/settings_search.xml @@ -8,9 +8,6 @@ Größe des beim Lesen angezeigten Textes. Farben und ein helles oder dunkles Lesedesign auswählen. Wortlänge, ab der die Mindestzeit für lange Wörter gilt. - Zusätzliche Lesezeit für Wörter mit mehr Silben. - Zusätzliche Lesezeit für seltenere Wörter. - Festlegen, wie stark die Wortschwierigkeit die Lesezeit beeinflusst. Das Tempo für Text in Klammern anpassen. Die Lesegeschwindigkeit für Dialoge anpassen. Den RSVP-Text auf dem Bildschirm nach oben oder unten verschieben. diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 900747ea..03727853 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -128,9 +128,8 @@ Zusatzzeit für lange oder technische Wörter. Pause am Satzende Atempause an Satzgrenzen. - Adaptives Tempo - Fügt extra Halt für schwierige Wörter und Klauseln hinzu. - Schwierigkeit +%1$d ms | Komplexität +%2$d ms | Schwelle %3$s + Automatischer Rhythmus + Passt Satzübergänge und Satzenden an. Die Leseunterstützung funktioniert unabhängig davon. Anzeige Beeinflusst nur die Schnellleseansicht. Geschwindigkeitsgrenzen @@ -139,16 +138,11 @@ Extreme Geschwindigkeiten freischalten Erlaubt bis %1$d ms (kann unlesbar werden). Erlaubt sehr hohe Geschwindigkeiten (kann schnell unlesbar werden). - Lesbarkeitsgrenzen + Mindestanzeigezeiten Schwelle %1$d Zeichen | Aufteilung +%2$d ms Schwelle für lange Wörter Pause zwischen Segmenten Extra Zeit zwischen Segmenten langer Wörter. - Schwierigkeitsmodell - Silben +%1$d ms | Seltenheit +%2$d ms | Stärke %3$s - Silben-Boost - Seltenheits-Boost - Komplexitätsstärke Interpunktionspausen Atmung %1$s | Komma %2$d ms | Punkt %3$d ms | Absatz %4$d ms | Seite %5$s Komma @@ -170,12 +164,6 @@ Klammern %1$s | Dialog %2$s Klammern Dialogtempo - Schwierigkeits-Boost - Maximale Zusatzzeit für schwierige Wörter. - Komplexe Wörter-Boost - Extra Halt für Wörter über der Komplexitätsschwelle. - Schwelle für komplexe Wörter - Niedriger = mehr Wörter gelten als komplex. Rhythmus Stabilität %1$s | Klausel %2$s | Trennung %3$s Stabilität @@ -641,4 +629,8 @@ Zeigt vorherige und kommende Wörter über Teilsatz- und Satzgrenzen hinweg in der Lesezeile. Beginnt bei einem neuen Absatz neu. Zeigt umgebende Wörter des aktuellen Teilsatzes, soweit Platz ist. Das Hauptwort bleibt fest. Pausiere, um den Teilsatz anzusehen. Zeigt nur den Hauptlesetext. + Leseunterstützung + Mehr Zeit für anspruchsvolle Wörter und dichte Phrasen. + Aus + Stark diff --git a/app/src/main/res/values-es/settings_search.xml b/app/src/main/res/values-es/settings_search.xml index ff0f486b..1fd23c86 100644 --- a/app/src/main/res/values-es/settings_search.xml +++ b/app/src/main/res/values-es/settings_search.xml @@ -8,9 +8,6 @@ Tamaño del texto que se muestra al leer. Elige los colores y un tema de lectura claro u oscuro. Longitud de palabra a partir de la cual se aplica el tiempo mínimo para palabras largas. - Tiempo de lectura adicional para palabras con más sílabas. - Tiempo de lectura adicional para palabras menos frecuentes. - Ajusta cuánto influye la dificultad de las palabras en el tiempo de lectura. Ajusta el ritmo del texto entre paréntesis. Ajusta la velocidad de lectura de los diálogos. Mueve el texto RSVP hacia arriba o abajo en la pantalla. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8d61c419..8c9809c4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -128,9 +128,8 @@ Tiempo extra para palabras largas o técnicas. Pausa al final de frase Respiro en los límites de las oraciones. - Ritmo adaptativo - Añade retención extra en palabras y cláusulas difíciles. - Dificultad +%1$d ms | Complejidad +%2$d ms | Umbral %3$s + Ritmo automático + Ajusta las transiciones y los finales de oración. El apoyo a la lectura funciona de forma independiente. Visualización Solo afecta a la pantalla de lectura rápida. Límites de velocidad @@ -139,16 +138,11 @@ Desbloquear velocidades extremas Permite hasta %1$d ms (puede ser ilegible). Permite velocidades muy altas (pueden volverse ilegibles rápidamente). - Umbrales de legibilidad + Tiempos mínimos de visualización Umbral %1$d caracteres | División +%2$d ms Umbral de palabra larga Pausa entre fragmentos Tiempo extra entre fragmentos de palabras largas. - Modelo de dificultad - Sílabas +%1$d ms | Rareza +%2$d ms | Intensidad %3$s - Impulso por sílabas - Impulso por rareza - Intensidad de complejidad Pausas de puntuación Respiración %1$s | Coma %2$d ms | Punto %3$d ms | Párrafo %4$d ms | Página %5$s Coma @@ -170,12 +164,6 @@ Paréntesis %1$s | Diálogo %2$s Paréntesis Ritmo de diálogo - Impulso de dificultad - Máxima retención extra para palabras difíciles. - Impulso de palabras complejas - Retención extra para palabras sobre el umbral de complejidad. - Umbral de palabra compleja - Más bajo = más palabras consideradas complejas. Ritmo Estabilidad %1$s | Cláusula %2$s | Separación %3$s Estabilidad @@ -647,4 +635,8 @@ Muestra palabras anteriores y siguientes entre cláusulas y oraciones en la línea de lectura. Se reinicia al comenzar un párrafo. Muestra palabras alrededor de la actual dentro de la cláusula si hay espacio, sin mover la palabra principal. Pausa para revisar la cláusula. Muestra solo el texto principal de lectura. + Apoyo a la lectura + Más tiempo para palabras difíciles y frases densas. + Desactivado + Intenso diff --git a/app/src/main/res/values-fr/settings_search.xml b/app/src/main/res/values-fr/settings_search.xml index 184a0086..9b910724 100644 --- a/app/src/main/res/values-fr/settings_search.xml +++ b/app/src/main/res/values-fr/settings_search.xml @@ -8,9 +8,6 @@ Taille du texte affiché pendant la lecture. Choisissez les couleurs et un thème de lecture clair ou sombre. Longueur à partir de laquelle le temps minimum des mots longs s’applique. - Temps de lecture supplémentaire pour les mots avec plus de syllabes. - Temps de lecture supplémentaire pour les mots moins courants. - Réglez l’influence de la difficulté des mots sur le temps de lecture. Ajustez le rythme du texte entre parenthèses. Ajustez la vitesse de lecture des dialogues. Déplacez le texte RSVP vers le haut ou le bas de l’écran. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f5ad3c43..596aa4cd 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -128,9 +128,8 @@ Temps supplémentaire pour les mots longs ou techniques. Pause en fin de phrase Respiration aux limites de phrase. - Rythme adaptatif - Ajoute du temps aux mots et propositions difficiles. - Difficulté +%1$d ms | Complexité +%2$d ms | Seuil %3$s + Rythme automatique + Ajuste les transitions et les fins de phrase. L’aide à la lecture fonctionne indépendamment. Affichage Affecte uniquement l\'écran de lecture rapide. Limites de vitesse @@ -139,16 +138,11 @@ Déverrouiller les vitesses extrêmes Autorise jusqu\'à %1$d ms (peut devenir illisible). Autorise des vitesses très élevées (peut devenir rapidement illisible). - Planchers de lisibilité + Durées minimales d’affichage Seuil %1$d caractères | Découpage +%2$d ms Seuil de mot long Pause entre segments Temps supplémentaire entre segments de mots longs. - Modèle de difficulté - Syllabes +%1$d ms | Rareté +%2$d ms | Force %3$s - Boost syllabique - Boost de rareté - Force de complexité Pauses de ponctuation Respiration %1$s | Virgule %2$d ms | Point %3$d ms | Paragraphe %4$d ms | Page %5$s Virgule @@ -170,12 +164,6 @@ Parenthèses %1$s | Dialogue %2$s Parenthèses Rythme de dialogue - Boost de difficulté - Temps supplémentaire max pour les mots difficiles. - Boost de mots complexes - Temps supplémentaire pour les mots au-dessus du seuil de complexité. - Seuil de mot complexe - Plus bas = plus de mots considérés complexes. Rythme Stabilité %1$s | Proposition %2$s | Séparation %3$s Stabilité @@ -647,4 +635,8 @@ Affiche les mots précédents et suivants sur la ligne de lecture, au-delà des propositions et des phrases. Repart à zéro à chaque paragraphe. Affiche les mots autour du mot actuel dans la proposition selon la place disponible, sans déplacer le mot principal. Faites une pause pour revoir la proposition. Affiche uniquement le texte principal. + Aide à la lecture + Plus de temps pour les mots difficiles et les phrases denses. + Désactivée + Forte diff --git a/app/src/main/res/values-ja/settings_search.xml b/app/src/main/res/values-ja/settings_search.xml index 9e6eedd5..7a2a7584 100644 --- a/app/src/main/res/values-ja/settings_search.xml +++ b/app/src/main/res/values-ja/settings_search.xml @@ -8,9 +8,6 @@ 読書中に表示する文字の大きさ。 色と、明るいまたは暗い読書テーマを選びます。 長い単語の最小表示時間を適用する文字数。 - 音節の多い単語に追加する読書時間。 - 出現頻度の低い単語に追加する読書時間。 - 単語の難しさが読書時間に与える影響を調整します。 括弧内のテキストのペースを調整します。 会話文の読書速度を調整します。 画面上のRSVPテキストを上下に移動します。 diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ce537658..355a3ee9 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -128,9 +128,8 @@ 長い・専門的な単語に追加時間。 文末ポーズ 文の区切りに余白を追加します。 - 適応ペース - 難しい単語や節に追加の停止を加えます。 - 難度 +%1$dミリ秒 | 複雑度 +%2$dミリ秒 | しきい値 %3$s + 自動リズム + 節の切り替わりと文末の間を調整します。読書サポートは独立して動作します。 表示 速読画面のみに影響します。 速度制限 @@ -139,16 +138,11 @@ 極端な速度を解除 %1$dミリ秒 まで許可(読めなくなる可能性があります)。 非常に高い速度を許可(すぐに読めなくなる可能性があります)。 - 可読性の下限 + 最小表示時間 しきい値 %1$d 文字 | 分割 +%2$dミリ秒 長い単語のしきい値 分割間のポーズ 長い単語の分割間に追加時間。 - 難易度モデル - 音節 +%1$dミリ秒 | 希少度 +%2$dミリ秒 | 強度 %3$s - 音節ブースト - 希少度ブースト - 複雑度の強度 句読点のポーズ 余白 %1$s | カンマ %2$dミリ秒 | ピリオド %3$dミリ秒 | 段落 %4$dミリ秒 | ページ %5$s カンマ @@ -170,12 +164,6 @@ 括弧 %1$s | 会話 %2$s 括弧 会話ペース - 難易度ブースト - 難しい単語への最大追加停止。 - 複雑単語ブースト - 複雑度しきい値を超える単語に追加停止。 - 複雑単語のしきい値 - 低いほど複雑な単語に判定されます。 リズム 安定性 %1$s | 句 %2$s | 単語の区切り %3$s 安定性 @@ -635,4 +623,8 @@ 節や文の境界を越えて前後の単語を読書行に表示します。新しい段落でリセットします。 中心の単語を固定し、空きスペースに現在の節の前後の単語を表示します。一時停止すると節を確認できます。 メインの読書テキストのみ表示します。 + 読書サポート + 難しい単語や内容の濃いフレーズに時間を追加します。 + オフ + 強 diff --git a/app/src/main/res/values-pt-rBR/settings_search.xml b/app/src/main/res/values-pt-rBR/settings_search.xml index cd48ee10..76610e00 100644 --- a/app/src/main/res/values-pt-rBR/settings_search.xml +++ b/app/src/main/res/values-pt-rBR/settings_search.xml @@ -8,9 +8,6 @@ Tamanho do texto exibido durante a leitura. Escolha as cores e um tema de leitura claro ou escuro. Comprimento a partir do qual o tempo mínimo para palavras longas se aplica. - Tempo de leitura adicional para palavras com mais sílabas. - Tempo de leitura adicional para palavras menos comuns. - Ajuste quanto a dificuldade das palavras influencia o tempo de leitura. Ajuste o ritmo do texto entre parênteses. Ajuste a velocidade de leitura dos diálogos. Mova o texto RSVP para cima ou para baixo na tela. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 0a066647..fb8ec6e2 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -128,9 +128,8 @@ Tempo extra para palavras longas ou técnicas. Pausa no fim da frase Respiro nos limites das frases. - Ritmo adaptativo - Adiciona tempo extra em palavras e cláusulas difíceis. - Dificuldade +%1$d ms | Complexidade +%2$d ms | Limite %3$s + Ritmo automático + Ajusta as transições e os finais de frase. O apoio à leitura funciona de forma independente. Exibição Afeta apenas a tela de leitura rápida. Limites de velocidade @@ -139,16 +138,11 @@ Desbloquear velocidades extremas Permite até %1$d ms (pode ficar ilegível). Permite velocidades muito altas (podem ficar ilegíveis rapidamente). - Limites de legibilidade + Tempos mínimos de exibição Limite %1$d caracteres | Divisão +%2$d ms Limite de palavra longa Pausa entre partes Tempo extra entre partes de palavras longas. - Modelo de dificuldade - Sílabas +%1$d ms | Raridade +%2$d ms | Força %3$s - Impulso de sílabas - Impulso de raridade - Força da complexidade Pausas de pontuação Respiro %1$s | Vírgula %2$d ms | Ponto %3$d ms | Parágrafo %4$d ms | Página %5$s Vírgula @@ -170,12 +164,6 @@ Parênteses %1$s | Diálogo %2$s Parênteses Ritmo de diálogo - Impulso de dificuldade - Máximo de tempo extra para palavras difíceis. - Impulso de palavras complexas - Tempo extra para palavras acima do limite de complexidade. - Limite de palavra complexa - Mais baixo = mais palavras consideradas complexas. Ritmo Estabilidade %1$s | Cláusula %2$s | Separação %3$s Estabilidade @@ -647,4 +635,8 @@ Mostra palavras anteriores e seguintes entre orações e frases na linha de leitura. Reinicia em um novo parágrafo. Mostra palavras ao redor da atual na oração quando há espaço, sem mover a palavra principal. Pause para rever a oração. Mostra apenas o texto principal da leitura. + Apoio à leitura + Mais tempo para palavras difíceis e frases densas. + Desativado + Forte diff --git a/app/src/main/res/values-zh-rCN/settings_search.xml b/app/src/main/res/values-zh-rCN/settings_search.xml index d9fecb84..d63fa707 100644 --- a/app/src/main/res/values-zh-rCN/settings_search.xml +++ b/app/src/main/res/values-zh-rCN/settings_search.xml @@ -8,9 +8,6 @@ 阅读时显示的文字大小。 选择颜色以及浅色或深色阅读主题。 开始应用长词最短显示时间的单词长度。 - 为音节较多的单词增加阅读时间。 - 为不常见的单词增加阅读时间。 - 调整单词难度对阅读时间的影响程度。 调整括号内文字的阅读节奏。 调整对话的阅读速度。 在屏幕上向上或向下移动RSVP文字。 diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6e1114ef..f698a207 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -128,9 +128,8 @@ 为长词或专业词提供额外时间。 句末停顿 在句子边界留出空隙。 - 自适应节奏 - 为难词和从句添加额外停顿。 - 难度 +%1$d毫秒 | 复杂度 +%2$d毫秒 | 阈值 %3$s + 自动节奏 + 调整分句过渡和句末停顿。阅读辅助独立生效。 显示 只影响速读屏幕。 速度限制 @@ -139,16 +138,11 @@ 解锁极限速度 允许最低至 %1$d毫秒(可能难以阅读)。 允许非常高的速度(可能很快难以阅读)。 - 可读性下限 + 最短显示时间 阈值 %1$d 字符 | 拆分 +%2$d毫秒 长词阈值 分段停顿 长词分段之间的额外时间。 - 难度模型 - 音节 +%1$d毫秒 | 稀有度 +%2$d毫秒 | 强度 %3$s - 音节增强 - 稀有度增强 - 复杂度强度 标点停顿 呼吸 %1$s | 逗号 %2$d毫秒 | 句号 %3$d毫秒 | 段落 %4$d毫秒 | 页面 %5$s 逗号 @@ -170,12 +164,6 @@ 括号 %1$s | 对话 %2$s 括号 对话节奏 - 难度增强 - 难词的最大额外停顿。 - 复杂词增强 - 为超过复杂度阈值的词提供额外停顿。 - 复杂词阈值 - 越低 = 更多词被视为复杂。 节奏 稳定性 %1$s | 从句 %2$s | 单词间隔 %3$s 稳定性 @@ -635,4 +623,8 @@ 在阅读行跨分句和句子显示前后的词。在新段落处重置。 主词保持固定,在空间允许时显示当前分句中前后的词。暂停可回顾分句。 仅显示主要阅读文本。 + 阅读辅助 + 为难词和信息密集的短语增加时间。 + 关闭 + 强 diff --git a/app/src/main/res/values/settings_search.xml b/app/src/main/res/values/settings_search.xml index 6f0bebcb..76fb6318 100644 --- a/app/src/main/res/values/settings_search.xml +++ b/app/src/main/res/values/settings_search.xml @@ -8,9 +8,6 @@ Font size of the text shown while reading. Choose colours and a reading theme, including light and dark mode. Word length at which the long-word minimum time applies. - Extra reading time for words with more syllables. - Extra reading time for less common words. - Adjust how strongly word difficulty affects reading time. Adjust pacing for text inside parentheses. Adjust reading speed for dialogue. Move the RSVP text higher or lower on the screen. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5622cc27..7d0a7702 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -213,9 +213,8 @@ Extra time for long or technical words. Sentence end pause Breathing room at sentence boundaries. - Adaptive pacing - Adds extra hold on difficult words and clauses. - Difficulty +%1$dms | Complex +%2$dms | Threshold %3$s + Automatic cadence + Shape clause transitions and sentence endings. Reading support works independently. Comprehension Peripheral context, natural phrase units and easier rereading. Context assist @@ -234,17 +233,12 @@ Unlock extreme speeds Allows down to %1$dms (can become unreadable). Allows very high speeds (can quickly become unreadable). - Readability floors + Minimum display times Threshold %1$d chars | Split +%2$dms Long-word threshold Split-word pause Extra time between long-word chunks. - Difficulty model - Syll +%1$dms | Rarity +%2$dms | Strength %3$s - Syllable boost - Rarity boost - Complexity strength Punctuation pauses Breathing %1$s | Comma %2$dms | Period %3$dms | Paragraph %4$dms | Page %5$s Punctuation breathing @@ -277,12 +271,6 @@ Reads bracketed or dash-bounded asides as faster, quieter side remarks. Aside pace Lower = faster parenthetical aside words and punctuation. - Difficulty boost - Max extra hold for difficult words. - Complex word boost - Extra hold for words above the complexity threshold. - Complex word threshold - Lower = more words considered complex. Rhythm Stability %1$s | Clause %2$s | Separation %3$s Stability @@ -659,4 +647,8 @@ Shows preceding and upcoming words across clause and sentence boundaries on the reading line. Resets at a new paragraph. Shows surrounding words from the current clause where space allows, keeping the main word fixed. Pause to review the clause. Shows only the main reading text. + Reading support + Extra time for demanding words and dense phrases. + Off + Strong From 1b974e727847a035c8f757c084328a076908a293 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 14 Sep 2026 21:46:44 +0100 Subject: [PATCH 05/16] docs(rsvp): explain reading demand and validation boundaries --- README.md | 2 +- docs/rsvp-comprehension-timing.md | 100 ++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 docs/rsvp-comprehension-timing.md diff --git a/README.md b/README.md index 4ca04d39..7d2a74f8 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ The result is an Android reader that sits somewhere between an ebook app, a spee - Clean full-screen RSVP playback with minimal interface clutter - ORP highlighting support -- Adaptive pacing based on word length, syllables, punctuation, clause boundaries, and difficulty +- Offline reading support with bounded word demand, real English word frequencies, and phrase breathing room - Optional peripheral context cues that stay inside the ORP focal band - Phrase replay by double tap or playback control, with temporary rereading-aware pace easing - Phrase chunking, blink modes, readability floors, and punctuation tuning diff --git a/docs/rsvp-comprehension-timing.md b/docs/rsvp-comprehension-timing.md new file mode 100644 index 00000000..65a53aa0 --- /dev/null +++ b/docs/rsvp-comprehension-timing.md @@ -0,0 +1,100 @@ +# RSVP reading demand and comprehension timing + +Kairo uses a deterministic reading-demand calculation. There is no trained timing +model, reader history, network inference, or runtime Python dependency. + +## One word allowance + +`ReadingDemandAnalyzer` computes recognition demand once from a smooth logarithmic +length curve and word rarity. English rarity uses a bundled table of 29,665 words +and contractions, derived from wordfreq 3.1.1. Unlisted English words use a moderate +spelling fallback, with a bounded consonant-cluster adjustment. Missing from the +table does not imply maximum difficulty. Digits receive a structural allowance. +Other language policies use script-appropriate length scaling and digit structure; +they do not inherit English frequency or pronunciation assumptions. UNKNOWN stays +conservative until the book has an explicit language policy. + +Repeated English terms receive less of a small novelty allowance. Recognition time +never decreases because of repetition. Counts use normalized source words, reset at +paragraph/page boundaries, saturate at eight prior occurrences and retain at most +2,048 distinct recent words. Seeking seeds these counters from the containing +paragraph, so a reload does not invent a first occurrence. This is local text state, +not evidence that the reader understands a term. + +The extra word time is: + +``` +cap = clamp(0.8 × base tempo, 80 ms, 180 ms) +allowance = support × cap × (1 − exp(−recognition − novelty)) +``` + +Both recognition and novelty share this cap. At Strong (200%), the maximum is twice +the cap. Constants are engineering defaults, not coefficients fitted to eye tracking. +Split-word frames divide one source allowance by their displayed letter/digit counts; +they cannot multiply novelty or manufacture another whole-word allowance. + +The old syllable/rarity/complexity stack, complex-word threshold hold, difficulty-flow +EMA and given/new beat compression no longer affect playback. Segmentation obtains +its difficulty evidence from the same recognition calculation; it remains independent +of tempo and support strength. + +## A separate phrase allowance + +Phrase processing uses distinct information-word count, numbers and phrase length, +not another spelling-complexity sum. Dense phrases receive at most 70 ms at 100% +support. The same support slider scales this allowance. Existing phrase distribution +is retained: 40% through a multiword phrase and 60% at its landing. A single source +word receives any allowance at its landing. Split words count once. + +Rhythm smoothing affects the beat only. Word allowances and phrase processing bypass +it, remain protected during live speed changes and cannot be consumed by word +separation. Minimum display times and explicit split-word pauses remain authoritative. +Overlapping automatic landing cues use the strongest cue; explicit punctuation pauses +remain additive. + +## Controls and compatibility + +The everyday sliders are **Speed** and **Reading support** (Off–200%, labelled Strong +at the maximum). The separate phrase slider and six difficulty fine-tuning controls +are removed from both settings and search. **Automatic cadence** is in Advanced → +Rhythm and controls existing clause/sentence shaping independently of Reading support. +Punctuation preferences and readability floors remain separate. + +Reading support retains the `difficult_word_support` DataStore key and +`difficultWordSupport` profile JSON property. Existing values are preserved and clamped +to 0–2; older profiles without this field receive 100%. The retired numeric fields +still round-trip to avoid destructive changes to saved profiles, but playback ignores +them. This is a new timing policy, not an exact reproduction of old custom tuning. +Study selects 150% support and Sprint 50%; other existing presets retain 100%. + +## Lookup data and reproducibility + +The compressed lookup occupies 132,195 bytes and is loaded lazily once. It is shipped +as a Java resource, including in the Android APK. `scripts/export-reading-frequency.py` +reproduces it with wordfreq 3.1.1; it exports statistics and does not train weights. +The previous experimental `scripts/reading-model/train.py` is not used by this path. +See `app/src/main/assets/licenses/wordfreq-NOTICE.md` for attribution, upstream +sources and the CC BY-SA 4.0 license covering the derived frequency table. + +## Validation boundary + +Regression coverage checks monotonic bounded word time, real frequency lookup, +independence from old token scores and controls, limited novelty decay, language +fallbacks, locale-independent normalization, source-based seeking, split-word +conservation, final/grouped words, smoothing, floors, word separation and persistence. +Device tests exercise real exposure and the two-slider settings UI. These checks +establish implementation behavior; improved comfort or comprehension still needs +reading trials at matched total presentation time. + +### Device measurements (2026-09-13) + +All eight targeted checks passed on the connected CPH2493: three settings parity/ +control tests, two real-exposure tests, two sweep/reload tests, and one packaged +English-lookup/determinism test. The final settings wording was rechecked separately. +The mixed 5,000-word English fixture took 1,658 ms on its first full generation and +1,000/894 ms on two warm full generations. The separate 5,000-word repository fixture +took 1,333 ms initially and 21/21/22 ms on cached reloads. These fixtures measure +different operations and are smoke measurements, not a comparison with the old engine. + +Final `qualityGate` and instrumented APK assembly passed: 860 unit tests, no failures, +errors or skips; formatting and Detekt passed; Android Lint reported no errors or warnings. From ef5c10ad9ad0401d0096fce0b69d07dd5a186eff Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 21 Sep 2026 22:04:35 +0100 Subject: [PATCH 06/16] fix(rsvp): preserve reading allowances above display floors --- .../core/rsvp/engine/RsvpFramePostProcessor.kt | 9 ++++++++- .../kairo/reader/core/rsvp/timing/RsvpTiming.kt | 4 ---- .../reader/core/rsvp/timing/RsvpUnitTiming.kt | 4 +++- .../reader/core/rsvp/RsvpProtectedTimingTest.kt | 16 +++++++++++++++- 4 files changed, 26 insertions(+), 7 deletions(-) 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 b6a83b65..2bc2702e 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 @@ -56,13 +56,20 @@ private fun splitFrameForBlink( val firstWord = frame.tokens.singleWordOrNull() val nextWord = nextTokens.singleWordOrNull() if (firstWord == null || nextWord == null) return listOf(frame) + // A moving highlight is one continuous word, not a repeated-word flash. + if (firstWord.isSubwordChunk && + nextWord.isSubwordChunk && + frame.originalTokenIndex == next?.originalTokenIndex + ) { + return listOf(frame) + } val shouldHold = frame.tokens.none { it.type == TokenType.PUNCTUATION } && shouldPreferHold(firstWord, nextWord) val repeatedWord = firstWord.text.equals(nextWord.text, ignoreCase = true) if ((!repeatedWord && shouldHold) || isHardBoundary(frame.tokens, nextWord)) return listOf(frame) - val floorMs = max(wordFloorMs(firstWord, config), MIN_FRAME_MS + frame.protectedWordMs) + val floorMs = max(wordFloorMs(firstWord, config), MIN_FRAME_MS) + frame.protectedWordMs val maxBlink = (frame.durationMs - floorMs - (frame.punctuationHoldMs ?: 0L)).coerceAtLeast(0L) val punctuationFactor = blinkPunctuationFactor(frame.tokens) val blinkMs = min((WORD_SEPARATION_MS * punctuationFactor).roundToLong(), maxBlink) diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt index ef5d35c1..7226bc53 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/timing/RsvpTiming.kt @@ -89,7 +89,6 @@ internal fun wordTimingBudget( readingDemand: RsvpReadingDemand? = null, ): RsvpWordTimingBudget { val text = word.text - val letters = ReadingDemandAnalyzer.displayedLength(word) val demand = readingDemand ?: ReadingDemandAnalyzer.analyze(word, RsvpLanguagePolicy.UNKNOWN) val difficultyExtra = demand.allowanceMs(msPerWord) * config.difficultWordSupport var duration = msPerWord @@ -108,9 +107,6 @@ internal fun wordTimingBudget( } var protectedMs = difficultyExtra - if (letters >= config.longWordChars) { - protectedMs = max(protectedMs, config.longWordMinMs.toDouble() - duration) - } if (text.endsWith("-")) { protectedMs += msPerWord * HYPHEN_CONTINUATION_HOLD_FACTOR } 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 61d9a32c..4f822c1d 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 @@ -494,9 +494,11 @@ internal fun computeUnitTiming(input: RsvpUnitTimingInput): RsvpUnitTiming = val expression = if (wordTiming.duration > 0.0) 1.0 + wordTiming.expressionMs / wordTiming.duration else 1.0 var totalDuration = max( - smoothedWordDuration * expression + wordTiming.protectedMs, + smoothedWordDuration * expression, words.sumOf { wordFloorMs(it, config).toDouble() }, ) + // Reading support is extra exposure, even when the readability floor exceeds the beat. + totalDuration += wordTiming.protectedMs totalDuration += wordTiming.pauseMs + transitionHold + thoughtCues.sumOf { it.processingHoldMs } var integrationHold = thoughtCues.sumOf { it.integrationHoldMs } if (words.isNotEmpty()) { diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt index 8b340bf8..6a5bf392 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt @@ -94,7 +94,7 @@ class RsvpProtectedTimingTest { val frames = ComprehensionRsvpEngine().generateFrames( tokens, 0, - config.copy(blinkMode = BlinkMode.SUBTLE, maxChunkLength = 30), + config.copy(blinkMode = BlinkMode.SUBTLE, maxChunkLength = 0), RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH), ) val hardFrame = frames.first { !it.isWordSeparation && it.originalTokenIndex == 2 } @@ -103,6 +103,20 @@ class RsvpProtectedTimingTest { assertTrue(frames.none { it.isWordSeparation && it.protectedWordMs != 0L }) } + @Test + fun readabilityFloorCannotConsumeTheDifficultWordAllowance() { + for (tempo in listOf(50L, 150L, 300L)) { + for (count in listOf(1, 2, 3)) { + val settings = config.copy(tempoMsPerWord = tempo, minWordMs = 400L) + val words = List(count) { easy } + val demands = List(count) { RsvpReadingDemand(1.5) } + val off = duration(words, rhythm(), settings.copy(difficultWordSupport = 0.0), demands) + val on = duration(words, rhythm(), settings, demands) + assertTrue("tempo=$tempo count=$count", on.durationMs >= off.durationMs + on.protectedWordMs - 1L) + } + } + } + private fun rhythm() = RhythmState(smoothingAlpha = 0.01, maxSpeedupFactor = 1.01, maxSlowdownFactor = 1.01) private fun duration( From b0032ecbed7429565462490256bfd10443bc3fbb Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 21 Sep 2026 22:04:58 +0100 Subject: [PATCH 07/16] fix(rsvp): account for familiar inflections and decoding demand --- .../core/rsvp/analysis/RsvpReadingDemand.kt | 33 +++++++++++++++++-- .../reader/core/rsvp/RsvpReadingDemandTest.kt | 9 +++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt index 91a17f14..47c8eb93 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpReadingDemand.kt @@ -37,7 +37,7 @@ internal object ReadingDemandAnalyzer { val english = policy == RsvpLanguagePolicy.ENGLISH && word.isNotEmpty() && word.all { it in 'a'..'z' || it == '\'' } val rarity = when { digits > 0 -> 0.35 * ln(1.0 + digits) - english -> EnglishReadingFrequency.zipf(word)?.let { ((5.5 - it) / 3.0).coerceIn(0.0, 1.0) } + english -> EnglishReadingFrequency.recognitionZipf(word)?.let { ((5.5 - it) / 3.0).coerceIn(0.0, 1.0) } ?: spellingFallback(word) else -> 0.0 // No English frequency or pronunciation assumptions for other scripts/languages. } @@ -46,7 +46,10 @@ internal object ReadingDemandAnalyzer { } else { 0.0 } - return RsvpReadingDemand(visual + 0.85 * rarity, novelty) + // Familiarity can help recognition, but cannot erase the work of decoding a long word. + // Use a floor, not another additive syllable allowance on top of length and rarity. + val decoding = if (english) decodingDemand(word, length) else 0.0 + return RsvpReadingDemand(maxOf(visual + 0.85 * rarity, decoding), novelty) } /** Build from source positions before splitting. Seeking cannot turn a repeated term into a first occurrence. */ @@ -113,6 +116,17 @@ internal object ReadingDemandAnalyzer { return visible.codePoints().filter { Character.isLetterOrDigit(it) }.count().toInt().coerceAtLeast(1) } + private fun decodingDemand(word: String, length: Int): Double { + val vowelGroups = word.indices.count { index -> + word[index] in "aeiouy" && (index == 0 || word[index - 1] !in "aeiouy") + } + // Vowel groups are an orthographic cue, not a claim about exact spoken syllables. + val ending = word.removeSuffix("'s").removeSuffix("s") + val silentEnding = ending.endsWith("e") && !ending.endsWith("le") + val groups = (vowelGroups - if (silentEnding) 1 else 0).coerceAtLeast(1) + return 0.14 * (length - 7).coerceAtLeast(0) + 0.08 * (groups - 3).coerceAtLeast(0) + } + private fun spellingFallback(word: String): Double { var run = 0 var longest = 0 @@ -137,4 +151,19 @@ internal object EnglishReadingFrequency { } fun zipf(word: String): Double? = frequencies[word]?.div(100.0) + + /** A familiar base form provides evidence for simple inflections, with a small recognition cost. */ + fun recognitionZipf(word: String): Double? { + val candidates = buildList { + zipf(word)?.let(::add) + val bases = buildList { + if (word.endsWith("'s")) add(word.dropLast(2)) + if (word.endsWith("s") && !word.endsWith("ss")) add(word.dropLast(1)) + if (word.endsWith("es")) add(word.dropLast(2)) + if (word.endsWith("ies")) add(word.dropLast(3) + "y") + } + bases.mapNotNull(::zipf).forEach { add(it - 0.15) } + } + return candidates.maxOrNull() + } } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt index 0b8ca43d..e684ccdf 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt @@ -110,6 +110,15 @@ class RsvpReadingDemandTest { assertEquals(attached[0].readingDemand!!.novelty, attached[1].readingDemand!!.novelty, 0.0) } + @Test + fun frequencyBackoffOnlyUsesKnownFormsAndDoesNotReplaceSurfaceFrequency() { + val singular = EnglishReadingFrequency.zipf("instructor")!! + assertTrue(EnglishReadingFrequency.recognitionZipf("instructors")!! > EnglishReadingFrequency.zipf("instructors")!!) + assertTrue(EnglishReadingFrequency.recognitionZipf("instructors")!! < singular) + assertEquals(EnglishReadingFrequency.zipf("representatives"), EnglishReadingFrequency.recognitionZipf("representatives")) + assertEquals(null, EnglishReadingFrequency.recognitionZipf("quizzacious")) + } + @Test fun obsoleteTuningCannotStackAnotherAllowanceAndSupportWorksWithCadenceDisabled() { val config = RsvpConfig(startDelayMs = 0, endDelayMs = 0, rampUpFrames = 0, rampDownFrames = 0, useAdaptiveTiming = false) From 53e9bc58fba950e7c8986dd7a61d057b87c02e2b Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 21 Sep 2026 22:05:23 +0100 Subject: [PATCH 08/16] fix(rsvp): retain highlights through text composition and windowing --- .../com/kairo/reader/ui/rsvp/RsvpOrpLayout.kt | 8 +++ .../com/kairo/reader/ui/rsvp/RsvpOrpText.kt | 15 +++++ .../com/kairo/reader/ui/rsvp/RsvpOrpTypes.kt | 2 + .../ui/rsvp/RsvpDifficultyHighlightTest.kt | 62 +++++++++++++++++++ 4 files changed, 87 insertions(+) create mode 100644 app/src/test/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightTest.kt diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpLayout.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpLayout.kt index bc7ca577..cbf06ea6 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpLayout.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpLayout.kt @@ -160,6 +160,7 @@ internal fun OrpAlignedTextLayout( highlightStart = display.content.highlightStart, highlightEndExclusive = display.content.highlightEndExclusive, highlightColor = colors.highlightColor, + additionalHighlights = display.content.additionalHighlights, pivotHighlightVisible = layout.pivotHighlightVisible, ) } @@ -278,6 +279,7 @@ private fun resolveOrpDisplay( highlightStart = content.highlightStart, highlightEndExclusive = content.highlightEndExclusive, highlightColor = colors.highlightColor, + additionalHighlights = content.additionalHighlights, pivotHighlightVisible = pivotHighlightVisible, ) val baseMeasured = @@ -323,6 +325,7 @@ private fun resolveOrpDisplay( highlightStart = windowed.highlightStart, highlightEndExclusive = windowed.highlightEndExclusive, highlightColor = colors.highlightColor, + additionalHighlights = windowed.additionalHighlights, pivotHighlightVisible = pivotHighlightVisible, ) val windowedMeasured = @@ -423,6 +426,11 @@ private fun buildWindowedContent( wordCount = content.wordCount, highlightStart = highlightStart, highlightEndExclusive = highlightEndExclusive, + additionalHighlights = content.additionalHighlights.mapNotNull { range -> + val start = maxOf(range.first, window.start) + val end = minOf(range.last + 1, window.endExclusive) + if (end > start) (prefixOffset + start - window.start) until (prefixOffset + end - window.start) else null + }, ) } diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt index 423d1b4b..49441e02 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt @@ -42,6 +42,7 @@ internal fun buildOrpAnnotatedText( highlightEndExclusive: Int, highlightColor: Color, pivotHighlightVisible: Boolean = true, + additionalHighlights: List = emptyList(), ): AnnotatedString = buildAnnotatedString { append(fullText) @@ -56,6 +57,11 @@ internal fun buildOrpAnnotatedText( ) } } + additionalHighlights.forEach { range -> + val start = range.first.coerceIn(0, fullText.length) + val end = (range.last + 1).coerceIn(start, fullText.length) + if (end > start) addStyle(SpanStyle(color = highlightColor), start, end) + } if (pivotHighlightVisible && fullText.isNotEmpty()) { val safeIndex = pivotPosition.coerceIn(0, fullText.lastIndex) addStyle( @@ -102,6 +108,7 @@ internal fun buildOrpTextContent( wordCount = wordCount, highlightStart = state.highlightStart, highlightEndExclusive = state.highlightEndExclusive, + additionalHighlights = state.additionalHighlights.toList(), ) } @@ -130,6 +137,14 @@ private fun appendWord( state.highlightEndExclusive = safeEnd } } + } else { + val highlightStart = token.highlightStart + val highlightEnd = token.highlightEndExclusive + if (highlightStart != null && highlightEnd != null) { + val safeStart = highlightStart.coerceIn(0, token.text.length) + val safeEnd = highlightEnd.coerceIn(safeStart, token.text.length) + if (safeEnd > safeStart) state.additionalHighlights += (start + safeStart) until (start + safeEnd) + } } state.needsSpace = true } diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpTypes.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpTypes.kt index 3b40d861..c7eba00a 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpTypes.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpTypes.kt @@ -28,6 +28,7 @@ internal data class OrpTextContent( val wordCount: Int, val highlightStart: Int, val highlightEndExclusive: Int, + val additionalHighlights: List = emptyList(), ) internal data class OrpBounds( @@ -50,4 +51,5 @@ internal data class OrpTextBuildState( var firstWordEndExclusive: Int = INVALID_INDEX, var highlightStart: Int = INVALID_INDEX, var highlightEndExclusive: Int = INVALID_INDEX, + val additionalHighlights: MutableList = mutableListOf(), ) diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightTest.kt new file mode 100644 index 00000000..d45e0df2 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightTest.kt @@ -0,0 +1,62 @@ +package com.kairo.reader.ui.rsvp + +import androidx.compose.ui.graphics.Color +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 RsvpDifficultyHighlightTest { + @Test + fun everyDifficultWordInAPhraseRetainsItsHighlightAndPivot() { + val tokens = listOf(word("the"), highlighted("quartz"), highlighted("quizzacious")) + val content = buildOrpTextContent(tokens) + val plain = buildOrpTextContent(tokens.map { it.copy(highlightStart = null, highlightEndExclusive = null) }) + assertEquals(plain.pivotPosition, content.pivotPosition) + val annotated = buildOrpAnnotatedText( + content.fullText, + content.pivotPosition, + Color.Red, + content.highlightStart, + content.highlightEndExclusive, + Color.Blue, + additionalHighlights = content.additionalHighlights, + ) + assertEquals( + listOf("quartz", "quizzacious"), + annotated.spanStyles.filter { it.item.color == Color.Blue }.map { + annotated.text.substring(it.start, it.end) + } + ) + assertEquals(Color.Red, annotated.spanStyles.last().item.color) + } + + @Test + fun movingWordPartHighlightRemainsWhenThePivotHighlightIsHidden() { + val content = buildOrpTextContent( + listOf( + highlighted("neuroplasticity").copy( + isSubwordChunk = true, + highlightStart = 5, + highlightEndExclusive = 10, + ) + ) + ) + val annotated = buildOrpAnnotatedText( + content.fullText, + content.pivotPosition, + Color.Red, + content.highlightStart, + content.highlightEndExclusive, + Color.Blue, + pivotHighlightVisible = false, + ) + assertEquals("neuroplasticity", annotated.text) + assertEquals("plast", annotated.text.substring(annotated.spanStyles.single().start, annotated.spanStyles.single().end)) + assertTrue(annotated.spanStyles.all { it.item.color == Color.Blue }) + } + + private fun word(text: String) = Token(text, TokenType.WORD) + private fun highlighted(text: String) = word(text).copy(highlightStart = 0, highlightEndExclusive = text.length) +} From 20f01f69d277cc760cf19b2ba61cf6b0a8d7fbfd Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 21 Sep 2026 22:05:52 +0100 Subject: [PATCH 09/16] feat(rsvp): select word-part support by familiarity and spelling --- .../rsvp/RsvpPunctuationTimingDeviceTest.kt | 2 +- .../ui/rsvp/RsvpReadingDemandDeviceTest.kt | 2 +- .../com/kairo/reader/core/rsvp/RsvpEngine.kt | 11 +- .../core/rsvp/analysis/RsvpWordPartSupport.kt | 134 ++++++++++++++ .../core/rsvp/RsvpPhaseTwoSegmentationTest.kt | 3 + .../reader/core/rsvp/RsvpPresetCadenceTest.kt | 15 +- .../core/rsvp/RsvpProtectedTimingTest.kt | 19 ++ .../reader/core/rsvp/RsvpReadingDemandTest.kt | 31 ++++ .../core/rsvp/RsvpRhythmSmoothingTest.kt | 2 +- .../core/rsvp/RsvpWordPartSupportTest.kt | 169 ++++++++++++++++++ 10 files changed, 376 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt create mode 100644 app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt index c35b11a6..0a61de0f 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpPunctuationTimingDeviceTest.kt @@ -64,7 +64,7 @@ class RsvpPunctuationTimingDeviceTest { listOf(easy, easy, easy, difficult, easy), RsvpConfig( tempoMsPerWord = 150L, smoothingAlpha = 0.01, maxSlowdownFactor = 1.01, - difficultWordSupport = 2.0, maxChunkLength = 32, + difficultWordSupport = 2.0, maxChunkLength = 0, useAdaptiveTiming = false, useProsodyPacing = false, useFocalStress = false, useClausePausing = false, useDialogueDetection = false, startDelayMs = 0L, endDelayMs = 0L, rampUpFrames = 0, rampDownFrames = 0, diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt index 40575960..781fce42 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpReadingDemandDeviceTest.kt @@ -36,7 +36,7 @@ class RsvpReadingDemandDeviceTest { val before = SystemClock.elapsedRealtimeNanos() val frames = engine.generateFrames(tokens, 0, config, options) times += (SystemClock.elapsedRealtimeNanos() - before) / 1_000_000L - assertEquals(tokens.size, frames.size) + assertEquals(tokens.indices.toList(), frames.map { it.originalTokenIndex }.distinct()) assertTrue(frames[3].protectedWordMs > frames[0].protectedWordMs) val durations = frames.map { frame -> frame.durationMs } expectedDurations?.let { expected -> assertEquals(expected, durations) } 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 a88f2fcd..0809d909 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 @@ -5,9 +5,9 @@ import com.kairo.reader.core.model.RsvpFrame import com.kairo.reader.core.model.RsvpResumeCursor import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType -import com.kairo.reader.core.model.splitTokenForRsvp import com.kairo.reader.core.rsvp.analysis.ReadingDemandAnalyzer import com.kairo.reader.core.rsvp.analysis.RsvpTokenAnalysis +import com.kairo.reader.core.rsvp.analysis.RsvpWordPartSupport import com.kairo.reader.core.rsvp.analysis.analyzeExpandedTokens import com.kairo.reader.core.rsvp.analysis.nextTokenAfter import com.kairo.reader.core.rsvp.analysis.shouldKeepFullFocalDuration @@ -123,7 +123,7 @@ private fun generateFramesWithNormalizedConfig( val analysisStartIndex = resolveAnalysisStartIndex(tokens, startIndex) val expanded = ReadingDemandAnalyzer.attach( - buildExpandedTokens(tokens, analysisStartIndex, config), + buildExpandedTokens(tokens, analysisStartIndex, config, options.languagePolicy), tokens, analysisStartIndex, options.languagePolicy, @@ -161,16 +161,13 @@ private fun buildExpandedTokens( tokens: List, analysisStartIndex: Int, config: RsvpConfig, + languagePolicy: RsvpLanguagePolicy, ): List = tokens .subList(analysisStartIndex, tokens.size) .flatMapIndexed { index, token -> var sourceCursor = 0 - splitTokenForRsvp( - token = token, - maxChunkLength = config.maxChunkLength, - subwordChunkPauseMs = config.subwordChunkPauseMs, - ).map { splitToken -> + RsvpWordPartSupport.split(token, config, languagePolicy).map { splitToken -> val sourceStart = if (splitToken.text == token.text) { 0 diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt new file mode 100644 index 00000000..0708a65a --- /dev/null +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt @@ -0,0 +1,134 @@ +package com.kairo.reader.core.rsvp.analysis + +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.splitTokenForRsvp +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import java.util.Locale + +/** Presentation policy using family familiarity and spelling shape, independent of timing scores. */ +internal object RsvpWordPartSupport { + fun split(token: Token, config: RsvpConfig, policy: RsvpLanguagePolicy): List { + val base = automaticBase(token, config, policy) + val supported = base?.takeIf(::benefitsFromParts)?.let { + splitTokenForRsvp(token.copy(text = it), PART_LENGTH, config.subwordChunkPauseMs) + } + if (supported == null) return configuredSplit(token, config) + + // Keep the same boundaries across the family. A plural/possessive ending stays on the + // final part instead of moving every boundary or manufacturing an extra reading beat. + val parts = supported.mapIndexed { index, part -> + part.copy( + text = token.text, + highlightEndExclusive = if (index == supported.lastIndex) token.text.length else part.highlightEndExclusive, + ) + } + val exceedsExplicitLimit = parts.any { + requireNotNull(it.highlightEndExclusive) - requireNotNull(it.highlightStart) > config.maxChunkLength + } + return if (exceedsExplicitLimit) configuredSplit(token, config) else parts + } + + private fun automaticBase(token: Token, config: RsvpConfig, policy: RsvpLanguagePolicy): String? { + if (policy != RsvpLanguagePolicy.ENGLISH || + token.type != TokenType.WORD || + config.difficultWordSupport <= 0.0 || + config.maxChunkLength <= PART_LENGTH || + token.isSubwordChunk + ) { + return null + } + val word = token.text.lowercase(Locale.ROOT).replace('’', '\'') + val stem = word.removeSuffix("'s").removeSuffix("'") + if (stem.isEmpty() || stem.any { it !in 'a'..'z' }) return null + return regularBase(stem) + } + + /** Regular inflections work without a dictionary; ambiguous -es/-is forms need an attested base. */ + private fun regularBase(word: String): String = when { + word.endsWith("es") && EnglishReadingFrequency.zipf(word.dropLast(ES_LENGTH) + "is") != null -> + word.dropLast(ES_LENGTH) + "is" + word.endsWith("ies") -> word.dropLast(IES_LENGTH) + "y" + SIBILANT_PLURALS.any(word::endsWith) -> word.dropLast(ES_LENGTH) + word.endsWith("s") && SINGULAR_S_ENDINGS.none(word::endsWith) -> word.dropLast(1) + else -> word + } + + private fun benefitsFromParts(base: String): Boolean { + val vowelGroups = base.indices.count { index -> + base[index] in VOWELS && (index == 0 || base[index - 1] !in VOWELS) + } + val silentEnding = base.endsWith("e") && !base.endsWith("le") + val groups = vowelGroups - if (silentEnding) 1 else 0 + if (base.length < UNCOMMON_BASE_LENGTH || groups < UNCOMMON_VOWEL_GROUPS) return false + // Very long spellings still benefit from parts even when the vocabulary is familiar. + if (base.length >= LONG_BASE_LENGTH && groups >= MIN_VOWEL_GROUPS) return true + val familiarity = familiarityZipf(base) + return when { + familiarity == null -> + // An absent entry is uncertain, so require stronger spelling evidence than + // for a known uncommon word. Short names and ordinary unknowns stay whole. + (base.length >= MIN_BASE_LENGTH && groups >= UNCOMMON_VOWEL_GROUPS) || + (base.length >= UNCOMMON_BASE_LENGTH && groups >= MIN_VOWEL_GROUPS) + familiarity <= UNCOMMON_ZIPF -> + base.length >= UNCOMMON_BASE_LENGTH && groups >= UNCOMMON_VOWEL_GROUPS + familiarity <= FAMILIAR_ZIPF -> + base.length >= MIN_BASE_LENGTH && groups >= MIN_VOWEL_GROUPS + else -> false + } + } + + private fun familiarityZipf(base: String): Double? { + var candidates = listOf(base) + var familiarity = EnglishReadingFrequency.zipf(base) + // Two bounded steps cover forms such as deepen-ing and cleanli-ness. + // Only an attested word supplies evidence; speculative stems never count as rare. + repeat(MAX_STEM_DEPTH) { depth -> + candidates = candidates.flatMap(::regularStems).distinct() + val attested = candidates.mapNotNull(EnglishReadingFrequency::zipf).maxOrNull() + ?.minus(STEM_FAMILIARITY_COST * (depth + 1)) + familiarity = listOfNotNull(familiarity, attested).maxOrNull() + } + return familiarity + } + + private fun regularStems(word: String): List { + val suffix = FAMILIAR_SUFFIXES.firstOrNull(word::endsWith) ?: return emptyList() + val stem = word.dropLast(suffix.length) + return buildList { + add(stem) + if (suffix in SILENT_E_SUFFIXES) add(stem + "e") + if (stem.endsWith("i")) add(stem.dropLast(1) + "y") + if (suffix in VERB_SUFFIXES && stem.length >= MIN_STEM_LENGTH && stem.last() == stem[stem.lastIndex - 1]) { + add(stem.dropLast(1)) + } + }.filter { it.length >= MIN_STEM_LENGTH } + } + + private fun configuredSplit(token: Token, config: RsvpConfig): List = + splitTokenForRsvp(token, config.maxChunkLength, config.subwordChunkPauseMs) + + private const val PART_LENGTH = 6 + + // Four vowel groups also occur in everyday prose at 10–13 letters. Requiring + // 14 made support effectively disappear outside unusually long terminology. + private const val MIN_BASE_LENGTH = 10 + private const val MIN_VOWEL_GROUPS = 4 + private const val LONG_BASE_LENGTH = 14 + private const val UNCOMMON_BASE_LENGTH = 8 + private const val UNCOMMON_VOWEL_GROUPS = 3 + private const val UNCOMMON_ZIPF = 3.5 + private const val FAMILIAR_ZIPF = 4.3 + private const val STEM_FAMILIARITY_COST = 0.15 + private const val MIN_STEM_LENGTH = 3 + private const val MAX_STEM_DEPTH = 2 + private const val IES_LENGTH = 3 + private const val ES_LENGTH = 2 + private const val VOWELS = "aeiouy" + private val SIBILANT_PLURALS = listOf("sses", "shes", "ches", "xes", "zzes") + private val SINGULAR_S_ENDINGS = listOf("ss", "us", "is", "ous") + private val FAMILIAR_SUFFIXES = listOf("ing", "ness", "ment", "less", "ed", "ly", "en") + private val SILENT_E_SUFFIXES = setOf("ing", "ed", "ment") + private val VERB_SUFFIXES = setOf("ing", "ed") +} 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 7b9fdf91..48475e15 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 @@ -37,6 +37,9 @@ class RsvpPhaseTwoSegmentationTest { @Test fun namedEntitiesCreateObservableGroupingWithoutOverridingHardLimits() { + // Isolate phrase grouping: automatic word parts legitimately take priority + // over keeping a long member such as Organization inside a named-entity phrase. + val config = config.copy(maxChunkLength = 0) val entity = listOf(word("New"), word("York"), word("City"), word("expanded")) val scored = engine.generateFrames(entity, 0, config, englishOptions) diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPresetCadenceTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPresetCadenceTest.kt index b19b18ad..a9b7a785 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPresetCadenceTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpPresetCadenceTest.kt @@ -27,14 +27,25 @@ class RsvpPresetCadenceTest { ) @Test - fun allProfilesKeepSourceWordsAndShowWholeOrdinaryTermsAtMultipleSpeeds() { + fun allProfilesKeepSourceWordsAndSupportMultisyllabicProseAtMultipleSpeeds() { for (tempo in listOf(80L, 150L, 250L)) { RsvpProfile.entries.forEach { profile -> val frames = engine.generateFrames(tokens, 0, profile.defaultConfig().copy(tempoMsPerWord = tempo), options) assertEquals( tokens.filter { it.type == TokenType.WORD }.map { it.text }, - frames.flatMap { it.tokens }.filter { it.type == TokenType.WORD }.map { it.text }, + frames.flatMap { it.tokens } + .filter { it.type == TokenType.WORD && (!it.isSubwordChunk || it.highlightStart == 0) } + .map { it.text }, ) + val displayed = frames.flatMap { it.tokens } + assertTrue(displayed.filter { it.text == "distinguish" }.all { !it.isSubwordChunk }) + assertTrue(displayed.filter { it.text == "measurements" }.all { !it.isSubwordChunk }) + for (supported in listOf("uncertainty", "observations")) { + val parts = displayed.filter { it.text == supported } + assertTrue("$profile at $tempo: $supported should receive moving support", parts.size > 1) + assertTrue(parts.all { it.isSubwordChunk }) + assertEquals(supported, parts.joinToString("") { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) + } assertTrue(frames.all { it.durationMs > 0 }) } } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt index 6a5bf392..ab81b2cd 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpProtectedTimingTest.kt @@ -117,6 +117,25 @@ class RsvpProtectedTimingTest { } } + @Test + fun longWordSupportRemainsVisibleAboveItsFloorWithSeparationEnabled() { + val settings = config.copy( + longWordMinMs = 400L, + maxChunkLength = 32, + enablePhraseChunking = false, + blinkMode = BlinkMode.SUBTLE, + ) + val tokens = listOf(easy, Token("neuroplasticity", TokenType.WORD), easy) + val frames = ComprehensionRsvpEngine().generateFrames(tokens, 0, settings, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) + val hard = frames.filter { !it.isWordSeparation && it.originalTokenIndex == 1 } + assertTrue(hard.size > 1) + assertTrue(hard.all { it.protectedWordMs > 0 }) + assertTrue(hard.all { it.durationMs >= settings.longWordMinMs + it.protectedWordMs }) + val firstPart = frames.indexOf(hard.first()) + val lastPart = frames.indexOf(hard.last()) + assertTrue(frames.subList(firstPart, lastPart + 1).none { it.isWordSeparation }) + } + private fun rhythm() = RhythmState(smoothingAlpha = 0.01, maxSpeedupFactor = 1.01, maxSlowdownFactor = 1.01) private fun duration( diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt index e684ccdf..8c8e8802 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt @@ -110,6 +110,37 @@ class RsvpReadingDemandTest { assertEquals(attached[0].readingDemand!!.novelty, attached[1].readingDemand!!.novelty, 0.0) } + @Test + fun difficultWordsUseProgressivePartsWithoutAWholeWordFlash() { + val tokens = listOf(word("the"), word("neuroplasticity"), word("the")) + val engine = ComprehensionRsvpEngine() + val config = RsvpConfig( + maxChunkLength = 32, + enablePhraseChunking = false, + startDelayMs = 0, + endDelayMs = 0, + rampUpFrames = 0, + rampDownFrames = 0, + ) + fun frames(settings: RsvpConfig) = engine.generateFrames(tokens, 0, settings, RsvpGenerationOptions(english)) + val supported = frames(config) + assertEquals(null, supported.first().tokens.first().highlightStart) + assertTrue(frames(config.copy(difficultWordSupport = 0.0)).all { frame -> frame.tokens.all { it.highlightStart == null } }) + assertEquals(3, frames(config.copy(maxChunkLength = 0)).size) + val split = supported.flatMap { it.tokens }.filter { it.isSubwordChunk } + assertEquals(listOf("neuro", "plast", "icity"), split.map { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) + assertEquals(0, split.first().highlightStart) + assertEquals(tokens[1].text.length, split.last().highlightEndExclusive) + assertTrue(split.zipWithNext().all { (a, b) -> a.highlightEndExclusive == b.highlightStart }) + assertTrue(split.all { it.text == tokens[1].text }) + assertEquals(split, frames(config.copy(maxChunkLength = 6)).flatMap { it.tokens }.filter { it.isSubwordChunk }) + val resumed = engine.generateFrames(tokens, 1, config, RsvpGenerationOptions(english)) + assertEquals(split, resumed.flatMap { it.tokens }.filter { it.isSubwordChunk }) + val hardFrames = supported.filter { it.originalTokenIndex == 1 } + assertTrue(hardFrames.dropLast(1).all { it.nextOriginalTokenIndex == 1 }) + assertEquals(2, hardFrames.last().nextOriginalTokenIndex) + } + @Test fun frequencyBackoffOnlyUsesKnownFormsAndDoesNotReplaceSurfaceFrequency() { val singular = EnglishReadingFrequency.zipf("instructor")!! 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 8fa25f36..8d189a2a 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 @@ -87,7 +87,7 @@ class RsvpRhythmSmoothingTest { tempoMsPerWord = 150L, enablePhraseChunking = false, maxWordsPerUnit = 2, - maxChunkLength = 40, + maxChunkLength = 0, // Keep this test about whole-word smoothing, independent of moving-part support. startDelayMs = 0L, endDelayMs = 0L, rampUpFrames = 0, diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt new file mode 100644 index 00000000..1248c704 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt @@ -0,0 +1,169 @@ +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.model.splitTokenForRsvp +import com.kairo.reader.core.rsvp.analysis.ReadingDemandAnalyzer +import com.kairo.reader.core.rsvp.analysis.RsvpWordPartSupport +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpWordPartSupportTest { + private val config = RsvpConfig(maxChunkLength = 32, subwordChunkPauseMs = 35L) + private val english = RsvpLanguagePolicy.ENGLISH + + @Test + fun familiarAndShortUncertainWordsStayWhole() { + val bases = listOf( + "instructor", "teacher", "student", "instruction", "whispered", "distinguish", + "comfortable", "everything", "conversation", "information", "international", + "professional", "opportunity", "university", "explanation", "measurement", "Mavik", "Zalorin", + ) + for (base in bases) { + for (form in listOf(base, base.uppercase(Locale.ROOT), "$base's")) { + assertEquals(form, 1, parts(form).size) + } + } + } + + @Test + fun uncommonFamiliesKeepTheSameDecisionAcrossTheOldTimingCutoff() { + for ((singular, plural) in listOf("slateboard" to "slateboards", "crucible" to "crucibles")) { + val before = ReadingDemandAnalyzer.analyze(word(singular), english) + val after = ReadingDemandAnalyzer.analyze(word(plural), english) + assertTrue(before.difficulty < 0.68 && after.difficulty > 0.68) + assertTrue(after.allowanceMs(150.0) > before.allowanceMs(150.0)) + assertTrue(after.allowanceMs(150.0) - before.allowanceMs(150.0) < 10.0) + assertTrue(parts(singular).size > 1) + assertFamily(singular, listOf(plural)) + } + } + + @Test + fun longStructuredFamiliesKeepTheirPartCountAndBoundariesAcrossInflections() { + val families = listOf( + "observation" to "observations", + "examination" to "examinations", + "crucible" to "crucibles", + "dormitory" to "dormitories", + "alabaster" to "alabasters", + "representative" to "representatives", + "responsibility" to "responsibilities", + "neuroplasticity" to "neuroplasticities", + "photosynthesis" to "photosyntheses", + "misrepresentation" to "misrepresentations", + "misinterpretation" to "misinterpretations", + "administration" to "administrations", + "electrocardiography" to "electrocardiographies", + "unconsciousness" to "unconsciousnesses", + "counterargument" to "counterarguments", + ) + for ((base, plural) in families) { + assertTrue(base, parts(base).size > 1) + assertFamily(base, listOf(plural, "$base's", "$base’s", "$plural'", base.uppercase(Locale.ROOT))) + } + } + + @Test + fun commonFamiliesAndVerbFormsDoNotBecomeDifficultBecauseTheirFormIsLessFrequent() { + for ((base, plural) in listOf( + "instructor" to "instructors", + "university" to "universities", + "explanation" to "explanations", + "measurement" to "measurements", + )) { + assertEquals(base, 1, parts(base).size) + assertFamily(base, listOf(plural, "$base's", "$plural'", plural.uppercase(Locale.ROOT))) + } + for (form in listOf("whispered", "whispering", "deepening", "retrieving", "painfully", "nervously", "cleanliness", "unfairness")) { + assertEquals(form, 1, parts(form).size) + } + } + + @Test + fun uncommonOrUnfamiliarComplexWordsReceiveSupportWithoutNeedingFourteenLetters() { + for (text in listOf("crucible", "crystalline", "dormitory", "equatorial", "pantomime", "alabaster", "slateboard", "quizzacious")) { + assertTrue(text, parts(text).size > 1) + } + } + + @Test + fun generatedUnknownFamiliesStayStableAcrossLengthAndPartCountBoundaries() { + // Exercise missing dictionary entries and the old 6-character and score cliffs over + // many family shapes, rather than maintaining an exception list for reported words. + for (length in 7..24) { + for ((ending, pluralEnding) in listOf("or" to "ors", "ity" to "ities", "ation" to "ations", "ness" to "nesses")) { + val prefix = "bada".repeat(length).take(length - ending.length) + val base = prefix + ending + val plural = prefix + pluralEnding + assertFamily(base, listOf(plural, "$base's", "$plural'", base.uppercase(Locale.ROOT))) + } + } + } + + @Test + fun displayPlanIgnoresLegacyDifficultyScoresTempoAndPositiveSupportStrength() { + for (text in listOf("slateboard", "crucibles", "representative", "neuroplasticity")) { + val original = word(text) + val changed = original.copy(syllableCount = 99, frequencyScore = 0.0, complexityMultiplier = 99.0) + for (tempo in listOf(50L, 150L, 500L)) { + for (support in listOf(0.25, 1.0, 2.0)) { + val settings = config.copy(tempoMsPerWord = tempo, difficultWordSupport = support) + assertEquals(ranges(parts(text)), ranges(RsvpWordPartSupport.split(changed, settings, english))) + } + } + } + } + + @Test + fun languageOptOutAndExplicitLimitsKeepTheirConfiguredBehavior() { + for (policy in RsvpLanguagePolicy.entries.filter { it != english }) { + for (text in listOf("neuroplasticity", "阅读理解阅读理解阅读理解", "المستشفيات")) { + val token = word(text) + assertEquals(splitTokenForRsvp(token, 32, 35L), RsvpWordPartSupport.split(token, config, policy)) + } + } + for (text in listOf("1234567890123456", "well-understood", "électromagnétique")) { + assertEquals(splitTokenForRsvp(word(text), 32, 35L), parts(text)) + } + for (settings in listOf(config.copy(difficultWordSupport = 0.0), config.copy(maxChunkLength = 0))) { + assertEquals(1, RsvpWordPartSupport.split(word("neuroplasticity"), settings, english).size) + } + for (limit in listOf(4, 6, 7)) { + val token = word("electrocardiographies") + val split = RsvpWordPartSupport.split(token, config.copy(maxChunkLength = limit), english) + assertTrue(split.all { it.highlightEndExclusive!! - it.highlightStart!! <= limit }) + assertEquals(token.text, split.joinToString("") { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) + } + } + + @Test + fun automaticPartsPreserveSourceMetadataAndFinalPause() { + val token = word("representatives").copy(pauseAfterMs = 90L, isClauseBoundary = true, isDialogue = true, linkChapterIndex = 3) + val split = RsvpWordPartSupport.split(token, config, english) + assertTrue(split.all { it.text == token.text && it.linkChapterIndex == 3 && it.isDialogue }) + assertTrue(split.dropLast(1).all { !it.isClauseBoundary && it.pauseAfterMs == 35L }) + assertTrue(split.last().isClauseBoundary) + assertEquals(90L, split.last().pauseAfterMs) + } + + private fun assertFamily(base: String, forms: List) { + val expected = parts(base) + for (form in forms) { + val actual = parts(form) + assertEquals("$base / $form", expected.size, actual.size) + if (expected.size > 1) { + assertEquals(form, ranges(expected).dropLast(1), ranges(actual).dropLast(1)) + assertEquals(form, form.length, actual.last().highlightEndExclusive) + assertEquals(form, actual.joinToString("") { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) + } + } + } + + private fun word(text: String) = Token(text, TokenType.WORD) + private fun parts(text: String) = RsvpWordPartSupport.split(word(text), config, english) + private fun ranges(tokens: List) = tokens.map { it.highlightStart to it.highlightEndExclusive } +} From 7ecdea4c36fc0ac389b0e542cad5341c63de378d Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 21 Sep 2026 22:06:20 +0100 Subject: [PATCH 10/16] test(rsvp): verify highlight selection and on-device playback --- .../rsvp/RsvpDifficultyHighlightDeviceTest.kt | 169 ++++++++++++++++++ .../ui/rsvp/RsvpWordPartPlaybackDeviceTest.kt | 80 +++++++++ 2 files changed, 249 insertions(+) create mode 100644 app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt create mode 100644 app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpWordPartPlaybackDeviceTest.kt diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt new file mode 100644 index 00000000..366a8ec8 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt @@ -0,0 +1,169 @@ +package com.kairo.reader.ui.rsvp + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.RsvpConfig +import com.kairo.reader.core.model.RsvpProfile +import com.kairo.reader.core.model.Token +import com.kairo.reader.core.model.TokenType +import com.kairo.reader.core.model.defaultConfig +import com.kairo.reader.core.rsvp.ComprehensionRsvpEngine +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.ui.theme.KairoTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RsvpDifficultyHighlightDeviceTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun difficultWordsMoveLeftToRightWithoutMovingTheRenderedWord() { + val source = listOf("the", "neuroplasticity", "changed").map { Token(it, TokenType.WORD) } + val config = RsvpConfig( + maxChunkLength = 32, + enablePhraseChunking = false, + longWordMinMs = 400L, + startDelayMs = 0, + endDelayMs = 0, + rampUpFrames = 0, + rampDownFrames = 0, + ) + val engine = ComprehensionRsvpEngine() + val options = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH) + val parts = engine.generateFrames(source, 0, config, options).filter { it.originalTokenIndex == 1 } + assertTrue(parts.size > 1) + assertTrue(parts.all { it.durationMs >= config.longWordMinMs + it.protectedWordMs }) + var shown by mutableStateOf(parts.first().tokens) + composeRule.setContent { + KairoTheme { + Box(Modifier.size(360.dp, 180.dp)) { + OrpAlignedText( + tokens = shown, + typography = OrpTypography(28f, FontFamily.Monospace, FontWeight.Normal), + colors = OrpColors(Color.Black, Color.Red, Color.Gray, Color.Blue), + layout = OrpTextLayout( + horizontalBias = 0.5f, + lockPivot = true, + smoothTranslation = false, + preferWindowing = false, + simplifyPunctuation = false, + guideVisible = true, + pivotHighlightVisible = true, + guideThickness = 1f, + ), + ) + } + } + } + var initialPivot: Float? = null + for (part in parts) { + composeRule.runOnIdle { shown = part.tokens } + val token = part.tokens.single() + val layout = renderedLayout() + val text = layout.layoutInput.text + assertEquals("neuroplasticity", text.text) + assertEquals(token.text.substring(token.highlightStart!!, token.highlightEndExclusive!!), highlightedText(layout)) + val pivot = text.spanStyles.last { it.item.color == Color.Red }.start + val pivotX = layout.getBoundingBox(pivot).center.x + initialPivot?.let { assertEquals(it, pivotX, 0.001f) } + initialPivot = pivotX + } + } + + @Test + fun ordinaryInflectionsStayWholeWhileLongerTermsReceiveMovingParts() { + val words = listOf("Instructors", "representatives", "respresentatives", "responsibility") + val tokens = words.map { Token(it, TokenType.WORD) } + val config = RsvpProfile.BALANCED.defaultConfig().copy(enablePhraseChunking = false) + val frames = ComprehensionRsvpEngine().generateFrames(tokens, 0, config, RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)) + val ordinary = frames.filter { it.originalTokenIndex == 0 && !it.isWordSeparation } + assertEquals(1, ordinary.size) + assertEquals(null, ordinary.single().tokens.single().highlightStart) + for (index in 1..words.lastIndex) { + val parts = frames.filter { it.originalTokenIndex == index && !it.isWordSeparation }.flatMap { it.tokens } + assertTrue(words[index], parts.size > 1) + assertTrue(parts.all { it.isSubwordChunk && it.text == words[index] }) + assertEquals(0, parts.first().highlightStart) + assertEquals(words[index].length, parts.last().highlightEndExclusive) + } + } + + @Test + fun wordFamiliesKeepTheSamePresentationWithPackagedDataAndNormalPreset() { + val words = + listOf("instructor", "instructors", "university", "Universities", "representative", "representatives", "representative’s") + val config = RsvpProfile.BALANCED.defaultConfig().copy(enablePhraseChunking = false) + val frames = ComprehensionRsvpEngine().generateFrames( + words.map { Token(it, TokenType.WORD) }, + 0, + config, + RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH), + ) + fun parts(index: Int) = frames.filter { it.originalTokenIndex == index && !it.isWordSeparation }.flatMap { it.tokens } + for (index in 0..3) { + assertEquals(words[index], 1, parts(index).size) + assertEquals(null, parts(index).single().highlightStart) + } + val base = parts(4) + assertTrue(base.size > 1) + for (index in 5..6) { + val inflected = parts(index) + assertEquals(base.size, inflected.size) + assertEquals(base.dropLast(1).map { it.highlightEndExclusive }, inflected.dropLast(1).map { it.highlightEndExclusive }) + assertEquals(words[index].length, inflected.last().highlightEndExclusive) + } + } + + @Test + fun selectionDistinguishesFamiliarLongWordsFromUncommonShorterFamilies() { + val ordinary = listOf("comfortable", "everything", "conversation", "whispering", "explanation") + val supported = listOf("crucible", "Crucibles", "crystalline", "slateboard", "slateboards", "dormitory", "dormitories") + val frames = ComprehensionRsvpEngine().generateFrames( + (ordinary + supported).map { Token(it, TokenType.WORD) }, + 0, + RsvpProfile.BALANCED.defaultConfig(), + RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH), + ) + for (text in ordinary) { + val shown = frames.flatMap { it.tokens }.filter { it.text == text } + assertEquals(text, 1, shown.size) + assertTrue(shown.none { it.isSubwordChunk }) + } + for (text in supported) { + val shown = frames.flatMap { it.tokens }.filter { it.text == text } + assertTrue(text, shown.size > 1 && shown.all { it.isSubwordChunk }) + } + } + + private fun renderedLayout(): TextLayoutResult { + val layouts = mutableListOf() + composeRule.onNodeWithTag("rsvp-focus-word").performSemanticsAction(SemanticsActions.GetTextLayoutResult) { it(layouts) } + return layouts.single() + } + + private fun highlightedText(layout: TextLayoutResult): String { + val text = layout.layoutInput.text + return text.spanStyles.filter { it.item.color == Color.Blue }.joinToString("") { text.text.substring(it.start, it.end) } + } +} diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpWordPartPlaybackDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpWordPartPlaybackDeviceTest.kt new file mode 100644 index 00000000..12a8a803 --- /dev/null +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpWordPartPlaybackDeviceTest.kt @@ -0,0 +1,80 @@ +package com.kairo.reader.ui.rsvp + +import android.os.SystemClock +import androidx.activity.compose.setContent +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.Chapter +import com.kairo.reader.core.model.RsvpProfile +import com.kairo.reader.core.model.defaultConfig +import com.kairo.reader.core.rsvp.RsvpGenerationOptions +import com.kairo.reader.core.rsvp.RsvpLanguagePolicy +import com.kairo.reader.core.tokenization.Tokenizer +import com.kairo.reader.ui.theme.KairoTheme +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Covers tokenization, repository, phrase generation and actual on-device playback together. */ +@RunWith(AndroidJUnit4::class) +class RsvpWordPartPlaybackDeviceTest { + @Test + fun normalProseReceivesAdvancingWordPartsDuringPlayback() { + val tokens = Tokenizer().tokenize( + Chapter( + index = 0, + title = "Word support fixture", + htmlContent = "", + plainText = "She saw the crystalline rock and made observations before the instructors came home.", + ), + ) + val config = RsvpProfile.BALANCED.defaultConfig().copy( + tempoMsPerWord = 131L, + maxChunkLength = 24, + difficultWordSupport = 2.0, + startDelayMs = 0L, + endDelayMs = 0L, + rampUpFrames = 0, + rampDownFrames = 0, + ) + val fixture = RsvpDeviceFixture(tokens, config) + fixture.state = fixture.state.copy( + book = fixture.state.book.copy(generationOptions = RsvpGenerationOptions(RsvpLanguagePolicy.ENGLISH)), + ) + ActivityScenario.launch(TestActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { KairoTheme { RsvpScreen(fixture.state, fixture.callbacks, fixture.dependencies) } } + } + val deadline = SystemClock.elapsedRealtime() + 15_000L + while (!fixture.finished && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20L) + assertTrue("Playback must finish", fixture.finished) + for (text in listOf("crystalline", "observations")) { + assertPlayedParts(fixture, tokens.indexOfFirst { it.text == text }, text) + } + val ordinary = fixture.consumed.flatMap { it.tokens }.filter { it.text == "instructors" } + assertEquals(1, ordinary.size) + assertTrue(ordinary.none { it.isSubwordChunk }) + } + } + + private fun assertPlayedParts(fixture: RsvpDeviceFixture, sourceIndex: Int, text: String) { + val positions = fixture.consumed.indices.filter { index -> + fixture.consumed[index].tokens.any { it.text == text && it.isSubwordChunk } + } + assertTrue("$text needs moving highlights", positions.size > 1) + assertEquals("No gaps between parts", (positions.first()..positions.last()).toList(), positions) + val parts = positions.map { index -> + val frame = fixture.consumed[index] + assertEquals(sourceIndex, frame.originalTokenIndex) + assertTrue(!frame.isWordSeparation) + val displayedMs = fixture.consumedAtMs[index] - fixture.consumedAtMs[index - 1] + assertTrue("Part must remain visible for its reading time", displayedMs >= frame.durationMs) + frame.tokens.single { it.isSubwordChunk } + } + assertEquals(0, parts.first().highlightStart) + parts.zipWithNext().forEach { (left, right) -> assertEquals(left.highlightEndExclusive, right.highlightStart) } + assertEquals(text, parts.joinToString("") { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) + } +} From f4ef350ab7906c167cdbda319f3ce6626d5c01fc Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Mon, 21 Sep 2026 22:06:44 +0100 Subject: [PATCH 11/16] docs(rsvp): describe word selection and protected reading time --- docs/rsvp-comprehension-timing.md | 56 +++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/docs/rsvp-comprehension-timing.md b/docs/rsvp-comprehension-timing.md index 65a53aa0..1682416e 100644 --- a/docs/rsvp-comprehension-timing.md +++ b/docs/rsvp-comprehension-timing.md @@ -6,8 +6,12 @@ model, reader history, network inference, or runtime Python dependency. ## One word allowance `ReadingDemandAnalyzer` computes recognition demand once from a smooth logarithmic -length curve and word rarity. English rarity uses a bundled table of 29,665 words -and contractions, derived from wordfreq 3.1.1. Unlisted English words use a moderate +length curve and word rarity. For English, a structural decoding floor also accounts +for longer spelling and estimated vowel groups. It is a minimum recognition score, +not a second timing allowance; familiarity cannot erase this decoding work. English rarity uses a bundled table of 29,665 words +and contractions, derived from wordfreq 3.1.1. Familiar base forms can support simple +plurals and possessives: recognition uses the higher of the surface frequency and a +known base frequency minus 0.15 Zipf units. Unknown bases contribute no evidence. Unlisted English words use a moderate spelling fallback, with a bounded consonant-cluster adjustment. Missing from the table does not imply maximum difficulty. Digits receive a structural allowance. Other language policies use script-appropriate length scaling and digit structure; @@ -34,9 +38,48 @@ Split-word frames divide one source allowance by their displayed letter/digit co they cannot multiply novelty or manufacture another whole-word allowance. The old syllable/rarity/complexity stack, complex-word threshold hold, difficulty-flow -EMA and given/new beat compression no longer affect playback. Segmentation obtains -its difficulty evidence from the same recognition calculation; it remains independent -of tempo and support strength. +EMA and given/new beat compression no longer affect playback. Phrase scoring obtains +its difficulty evidence from the same recognition calculation, independently of +tempo and support strength. Automatic word-part expansion is separate. + +## A separate word-part presentation policy + +`RsvpWordPartSupport` decides whether moving highlights can help with a long spelling. +It combines family-level vocabulary familiarity with spelling shape, independently of +the timing score, token difficulty metadata, tempo, or repetition. There is no timing-score cutoff that converts a small timing +change into extra display frames. Words below the spelling thresholds still receive +their gradual reading allowance while remaining whole. + +Automatic support is English-only. Selection uses the regular noun base: + +- Very long spellings (14 letters, four estimated vowel groups) receive parts regardless of familiarity. +- Known uncommon vocabulary (Zipf at most 3.5) needs eight letters and three vowel groups. +- Moderately familiar vocabulary (Zipf at most 4.3) needs ten letters and four vowel groups. +- Missing dictionary entries need stronger spelling evidence: ten letters and three groups, + or eight letters and four groups. Absence alone never triggers highlighting. +- Familiar words below the very-long-word threshold remain whole. Attested stems with regular + `-ed`, `-ing`, `-ly`, `-ness`, `-ment`, `-less`, and `-en` endings provide familiarity evidence + with a small 0.15 Zipf cost per step. At most two steps are considered, including silent-e, + doubled-consonant and y/i spelling changes; unattested stems supply no evidence. + +These are legibility and general familiarity heuristics, not predictions of the reader's vocabulary. +Regular plurals and possessives share +that base even when it is missing from the frequency table. Ambiguous `-es` / `-is` +inflections also share a base when it is attested in the dictionary. Selection uses that +base's familiarity, so rarer inflections cannot flip the decision. The base is used only to +plan the display; the original spelling and capitalisation stay on screen. + +Base words are divided into balanced parts of up to six characters. Inflection +endings stay on the final part, preserving the number of beats and earlier boundaries +across the family. These are character chunks, not exact spoken syllables. Existing +explicit chunk limits remain authoritative, including tighter limits that can require +more parts. Numbers, hyphenated words, other scripts and non-English policies retain +their configured splitting behavior. A zero limit disables splitting. + +Each part keeps its reading beat and display floor, while the source word's extra +recognition allowance is shared across the parts. No blank separation frame is +inserted between parts of the same source word. Turning Reading support off restores +the configured length-only splitting policy; a zero chunk length disables splitting. ## A separate phrase allowance @@ -48,7 +91,8 @@ word receives any allowance at its landing. Split words count once. Rhythm smoothing affects the beat only. Word allowances and phrase processing bypass it, remain protected during live speed changes and cannot be consumed by word -separation. Minimum display times and explicit split-word pauses remain authoritative. +separation. Word allowances are added after the minimum display floor, so a short +beat or a high readability floor cannot swallow the difficult-word support. Minimum display times and explicit split-word pauses remain authoritative. Overlapping automatic landing cues use the strongest cue; explicit punctuation pauses remain additive. From 12c5befc1e7d7ac386f5c1d4dfc982900dc4f1fb Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Tue, 22 Sep 2026 21:52:40 +0100 Subject: [PATCH 12/16] fix(rsvp): separate word-part guidance from reading support Keep the reading-support slider continuous, preserve existing Off preferences, and avoid treating every distinct non-English word as dense. Cover the supported import languages and profile persistence. --- .../ui/settings/RsvpSettingsParityTest.kt | 2 ++ .../com/kairo/reader/core/model/Models.kt | 2 ++ .../core/rsvp/analysis/RsvpThoughtPlan.kt | 9 +++++-- .../core/rsvp/analysis/RsvpWordPartSupport.kt | 2 +- .../kairo/reader/data/preferences/PrefKeys.kt | 1 + .../preferences/RsvpConfigPreferenceCodec.kt | 3 +++ .../data/preferences/RsvpProfileJsonCodec.kt | 5 ++++ .../settings/RsvpAdvancedSettingsContent.kt | 6 +++++ .../ui/settings/SettingsSearchRsvpEntries.kt | 8 ++++++ app/src/main/res/values-de/strings.xml | 4 ++- app/src/main/res/values-es/strings.xml | 4 ++- app/src/main/res/values-fr/strings.xml | 4 ++- app/src/main/res/values-ja/strings.xml | 4 ++- app/src/main/res/values-pt-rBR/strings.xml | 4 ++- app/src/main/res/values-zh-rCN/strings.xml | 4 ++- app/src/main/res/values/strings.xml | 4 ++- .../core/rsvp/RsvpGenerationOptionsTest.kt | 27 +++++++++++++++++++ .../reader/core/rsvp/RsvpReadingDemandTest.kt | 5 +++- .../reader/core/rsvp/RsvpThoughtFlowTest.kt | 17 ++++++++++++ .../core/rsvp/RsvpWordPartSupportTest.kt | 6 +++-- .../preferences/RsvpPreferenceCodecsTest.kt | 15 +++++++++++ docs/rsvp-comprehension-timing.md | 23 +++++++++++----- 22 files changed, 139 insertions(+), 20 deletions(-) diff --git a/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt index 7710a574..1b10da09 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/settings/RsvpSettingsParityTest.kt @@ -132,6 +132,8 @@ class RsvpSettingsParityTest { composeRule.onNodeWithText(resources.getString(R.string.rsvp_difficult_word_support_title)).assertExists() composeRule.onNodeWithText(resources.getString(R.string.settings_advanced_title)).performScrollTo().performClick() composeRule.onNodeWithText(resources.getString(R.string.rsvp_readability_floors_title)).performScrollTo().performClick() + composeRule.onNodeWithText(resources.getString(R.string.rsvp_word_parts_title)).performScrollTo().assertIsDisplayed().performClick() + composeRule.runOnIdle { assertTrue(!prefs.value.rsvpConfig.showDifficultWordParts) } composeRule.onNodeWithText(resources.getString(R.string.rsvp_min_word_time_title)).performScrollTo().assertIsDisplayed() composeRule.onNodeWithText(resources.getString(R.string.rsvp_long_word_min_title)).performScrollTo().assertIsDisplayed() } diff --git a/app/src/main/java/com/kairo/reader/core/model/Models.kt b/app/src/main/java/com/kairo/reader/core/model/Models.kt index ec539081..bfc6073f 100644 --- a/app/src/main/java/com/kairo/reader/core/model/Models.kt +++ b/app/src/main/java/com/kairo/reader/core/model/Models.kt @@ -239,6 +239,8 @@ data class RsvpConfig( val adaptiveDifficultyMaxHoldMs: Long = 70L, /** Reading support (0..2) for both word recognition and phrase processing; retains its original storage key. */ val difficultWordSupport: Double = 1.0, + /** Show selected long English words as moving word parts, independently of extra reading time. */ + val showDifficultWordParts: Boolean = true, /** Legacy phrase allowance retained for profile compatibility; ignored by playback. */ val phraseBreathingRoomMs: Long = adaptiveDifficultyMaxHoldMs, /** Legacy complex-word hold; ignored by playback. */ 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 index 639cb53c..91078853 100644 --- 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 @@ -110,9 +110,14 @@ internal object RsvpThoughtPlan { ): Double { val informationWords = words.distinctBy { it.originalIndex } .distinctBy { normalizeWord(it.token.text) } - val contentCount = informationWords.count { - languagePolicy != RsvpLanguagePolicy.ENGLISH || + // Without a language-specific familiarity model, word count is not evidence of density. + // Keep length and numbers as the shared fallback for non-English and unknown text. + val contentCount = if (languagePolicy == RsvpLanguagePolicy.ENGLISH) { + informationWords.count { (EnglishReadingFrequency.zipf(ReadingDemandAnalyzer.normalize(it.token.text)) ?: 0.0) < COMMON_WORD_ZIPF + } + } else { + 0 } val numbers = informationWords.count { it.token.text.any(Char::isDigit) } val lengthLoad = (informationWords.size - EASY_PHRASE_WORDS).coerceAtLeast(0).toDouble() diff --git a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt index 0708a65a..6048e95b 100644 --- a/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt +++ b/app/src/main/java/com/kairo/reader/core/rsvp/analysis/RsvpWordPartSupport.kt @@ -33,7 +33,7 @@ internal object RsvpWordPartSupport { private fun automaticBase(token: Token, config: RsvpConfig, policy: RsvpLanguagePolicy): String? { if (policy != RsvpLanguagePolicy.ENGLISH || token.type != TokenType.WORD || - config.difficultWordSupport <= 0.0 || + !config.showDifficultWordParts || config.maxChunkLength <= PART_LENGTH || token.isSubwordChunk ) { diff --git a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt index 11ee9930..5ea7e814 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/PrefKeys.kt @@ -62,6 +62,7 @@ internal object PrefKeys { val rampDownFrames = intPreferencesKey("ramp_down_frames") val useAdaptiveTiming = booleanPreferencesKey("use_adaptive_timing") val difficultWordSupport = doublePreferencesKey("difficult_word_support") + val showDifficultWordParts = booleanPreferencesKey("show_difficult_word_parts") val phraseBreathingRoomMs = longPreferencesKey("phrase_breathing_room_ms") val adaptiveDifficultyMaxHoldMs = longPreferencesKey("adaptive_difficulty_max_hold_ms") val complexWordHoldMs = longPreferencesKey("complex_word_hold_ms") diff --git a/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt b/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt index 1c11b3aa..95cc8121 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/RsvpConfigPreferenceCodec.kt @@ -133,6 +133,7 @@ internal class RsvpConfigPreferenceCodec(private val keys: PrefKeys, private val prefs[keys.useAdaptiveTiming] = config.useAdaptiveTiming prefs[keys.adaptiveDifficultyMaxHoldMs] = config.adaptiveDifficultyMaxHoldMs prefs[keys.difficultWordSupport] = config.difficultWordSupport + prefs[keys.showDifficultWordParts] = config.showDifficultWordParts prefs[keys.phraseBreathingRoomMs] = config.phraseBreathingRoomMs prefs[keys.complexWordHoldMs] = config.complexWordHoldMs prefs[keys.complexWordThreshold] = config.complexWordThreshold @@ -422,6 +423,8 @@ internal class RsvpConfigPreferenceCodec(private val keys: PrefKeys, private val prefs.readOrDefault(keys.useAdaptiveTiming, defaults.useAdaptiveTiming), difficultWordSupport = (prefs[keys.difficultWordSupport]?.takeIf { it.isFinite() } ?: defaults.difficultWordSupport) .coerceIn(0.0, Constraints.MAX_DIFFICULT_WORD_SUPPORT), + showDifficultWordParts = prefs[keys.showDifficultWordParts] + ?: (defaults.showDifficultWordParts && prefs[keys.difficultWordSupport] != 0.0), phraseBreathingRoomMs = ( prefs[keys.phraseBreathingRoomMs] ?: prefs[keys.adaptiveDifficultyMaxHoldMs] ?: defaults.phraseBreathingRoomMs diff --git a/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt b/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt index 5802c3fd..a185d3d0 100644 --- a/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt +++ b/app/src/main/java/com/kairo/reader/data/preferences/RsvpProfileJsonCodec.kt @@ -155,6 +155,7 @@ internal class RsvpProfileJsonCodec(private val onMalformed: (Throwable) -> Unit put("useAdaptiveTiming", config.useAdaptiveTiming) put("adaptiveDifficultyMaxHoldMs", config.adaptiveDifficultyMaxHoldMs) put("difficultWordSupport", config.difficultWordSupport) + put("showDifficultWordParts", config.showDifficultWordParts) put("phraseBreathingRoomMs", config.phraseBreathingRoomMs) put("complexWordHoldMs", config.complexWordHoldMs) put("complexWordThreshold", config.complexWordThreshold) @@ -374,6 +375,10 @@ internal class RsvpProfileJsonCodec(private val onMalformed: (Throwable) -> Unit .takeIf { it.isFinite() } ?: defaults.difficultWordSupport ) .coerceIn(0.0, RsvpConfigConstraints.MAX_DIFFICULT_WORD_SUPPORT), + showDifficultWordParts = obj.optBoolean( + "showDifficultWordParts", + defaults.showDifficultWordParts && obj.optDouble("difficultWordSupport", defaults.difficultWordSupport) != 0.0, + ), phraseBreathingRoomMs = obj.optLong( "phraseBreathingRoomMs", obj.optLong("adaptiveDifficultyMaxHoldMs", defaults.phraseBreathingRoomMs), diff --git a/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt b/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt index feb4fcbc..d059a705 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/RsvpAdvancedSettingsContent.kt @@ -78,6 +78,12 @@ internal fun RsvpAdvancedSettingsContent( config.subwordChunkPauseMs, ), ) { + SettingsSwitchRow( + title = stringResource(R.string.rsvp_word_parts_title), + subtitle = stringResource(R.string.rsvp_word_parts_subtitle), + checked = config.showDifficultWordParts, + onCheckedChange = { enabled -> updateConfig { it.copy(showDifficultWordParts = enabled) } }, + ) DeferredSliderRow( title = stringResource(R.string.rsvp_min_word_time_title), subtitle = stringResource(R.string.rsvp_min_word_time_subtitle), diff --git a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt index a1c35024..8f0245b9 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/SettingsSearchRsvpEntries.kt @@ -10,6 +10,14 @@ internal val rsvpSearchEntries = listOf( descriptionRes = R.string.rsvp_difficult_word_support_subtitle, sectionRes = R.string.rsvp_quick_tune_title, ), + SettingsSearchEntry( + id = "rsvp.rsvp_word_parts_title", + page = SettingsSearchPage.RSVP, + titleRes = R.string.rsvp_word_parts_title, + descriptionRes = R.string.rsvp_word_parts_subtitle, + sectionRes = R.string.rsvp_readability_floors_title, + advanced = true, + ), SettingsSearchEntry( id = "rsvp.rsvp_reading_speed_title", page = SettingsSearchPage.RSVP, diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 03727853..2ce236cb 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -630,7 +630,9 @@ Zeigt umgebende Wörter des aktuellen Teilsatzes, soweit Platz ist. Das Hauptwort bleibt fest. Pausiere, um den Teilsatz anzusehen. Zeigt nur den Hauptlesetext. Leseunterstützung - Mehr Zeit für anspruchsvolle Wörter und dichte Phrasen. + Mehr Zeit für Wörter und Phrasen; bei Englisch zählt auch die Vertrautheit mit Wörtern. + Wortteil-Hilfe + Hebt Teile ausgewählter langer englischer Wörter nacheinander hervor. Die zusätzliche Zeit wird separat eingestellt. Aus Stark diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8c9809c4..70333acc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -636,7 +636,9 @@ Muestra palabras alrededor de la actual dentro de la cláusula si hay espacio, sin mover la palabra principal. Pausa para revisar la cláusula. Muestra solo el texto principal de lectura. Apoyo a la lectura - Más tiempo para palabras difíciles y frases densas. + Más tiempo para palabras y frases; en inglés también se considera la familiaridad con cada palabra. + Guía por partes + Resalta por partes algunas palabras largas en inglés. El tiempo adicional se ajusta por separado. Desactivado Intenso diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 596aa4cd..72fafa1e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -636,7 +636,9 @@ Affiche les mots autour du mot actuel dans la proposition selon la place disponible, sans déplacer le mot principal. Faites une pause pour revoir la proposition. Affiche uniquement le texte principal. Aide à la lecture - Plus de temps pour les mots difficiles et les phrases denses. + Plus de temps pour les mots et les phrases ; en anglais, la familiarité des mots compte aussi. + Guidage par segments + Surligne successivement les segments de certains longs mots anglais. Le temps supplémentaire se règle séparément. Désactivée Forte diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 355a3ee9..401398bd 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -624,7 +624,9 @@ 中心の単語を固定し、空きスペースに現在の節の前後の単語を表示します。一時停止すると節を確認できます。 メインの読書テキストのみ表示します。 読書サポート - 難しい単語や内容の濃いフレーズに時間を追加します。 + 単語やフレーズに時間を追加します。英語では単語のなじみやすさも考慮します。 + 単語の分割表示 + 一部の長い英単語を、強調箇所を動かしながら分割表示します。追加時間は別に調整できます。 オフ 強 diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index fb8ec6e2..cff149dc 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -636,7 +636,9 @@ Mostra palavras ao redor da atual na oração quando há espaço, sem mover a palavra principal. Pause para rever a oração. Mostra apenas o texto principal da leitura. Apoio à leitura - Mais tempo para palavras difíceis e frases densas. + Mais tempo para palavras e frases; em inglês, também considera a familiaridade das palavras. + Guia por partes + Destaca em partes algumas palavras longas em inglês. O tempo extra é ajustado separadamente. Desativado Forte diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f698a207..0b56883e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -624,7 +624,9 @@ 主词保持固定,在空间允许时显示当前分句中前后的词。暂停可回顾分句。 仅显示主要阅读文本。 阅读辅助 - 为难词和信息密集的短语增加时间。 + 为单词和短语增加时间;英语还会考虑词语的熟悉程度。 + 单词分段提示 + 移动高亮,分段显示部分较长的英语单词。额外时间可单独调整。 关闭 强 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7d0a7702..7274d4cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -648,7 +648,9 @@ Shows surrounding words from the current clause where space allows, keeping the main word fixed. Pause to review the clause. Shows only the main reading text. Reading support - Extra time for demanding words and dense phrases. + Extra time for words and phrases; English also uses word familiarity. + Word-part guidance + Show selected long English words with a moving highlight. Extra time is controlled separately. Off Strong 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 55f5d884..6c67cf32 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,8 +1,10 @@ package com.kairo.reader.core.rsvp +import com.kairo.reader.core.model.Chapter 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.tokenization.TokenizerRegistry import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -14,7 +16,11 @@ class RsvpGenerationOptionsTest { "en-GB" to RsvpLanguagePolicy.ENGLISH, "eng" to RsvpLanguagePolicy.ENGLISH, "fr" to RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, + "de" to RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, + "es" to RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, + "pt-BR" to RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, "ja" to RsvpLanguagePolicy.CJK, + "zh-CN" to RsvpLanguagePolicy.CJK, "ar" to RsvpLanguagePolicy.RTL, null to RsvpLanguagePolicy.UNKNOWN, "und" to RsvpLanguagePolicy.UNKNOWN, @@ -24,6 +30,27 @@ class RsvpGenerationOptionsTest { } } + @Test + fun translatedLanguageBookPassagesKeepTheirTokensThroughPlayback() { + val passages = mapOf( + "de" to "Das Haus steht neben dem alten Fluss.", + "es" to "La casa está cerca del río antiguo.", + "fr" to "La maison est près de la rivière.", + "pt-BR" to "A casa fica perto do rio antigo.", + "ja" to "私は本を読んでいます。", + "zh-CN" to "我正在阅读这本书。", + ) + val engine = ComprehensionRsvpEngine() + val config = RsvpConfig(enablePhraseChunking = true, maxWordsPerUnit = 2, maxChunkLength = 32) + passages.forEach { (tag, passage) -> + val chapter = Chapter(0, tag, "", passage) + val tokens = TokenizerRegistry.resolve(tag).tokenize(chapter) + val frames = engine.generateFrames(tokens, 0, config, RsvpGenerationOptions.fromLanguageTag(tag)) + assertEquals(tag, tokens.map(Token::text), frames.flatMap { it.tokens }.map(Token::text)) + assertTrue(tag, frames.all { it.durationMs > 0L }) + } + } + @Test fun everyLanguageAndPersistedWidthUsesBoundedScoredUnitsWithoutLosingText() { val tokens = listOf("a", "b", "c", "d", "e", "f", "g").map { Token(it, TokenType.WORD) } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt index 8c8e8802..45ebb055 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpReadingDemandTest.kt @@ -125,7 +125,10 @@ class RsvpReadingDemandTest { fun frames(settings: RsvpConfig) = engine.generateFrames(tokens, 0, settings, RsvpGenerationOptions(english)) val supported = frames(config) assertEquals(null, supported.first().tokens.first().highlightStart) - assertTrue(frames(config.copy(difficultWordSupport = 0.0)).all { frame -> frame.tokens.all { it.highlightStart == null } }) + val noExtraTime = frames(config.copy(difficultWordSupport = 0.0)) + assertEquals(supported.map { it.tokens }, noExtraTime.map { it.tokens }) + assertTrue(supported.sumOf { it.protectedWordMs } > noExtraTime.sumOf { it.protectedWordMs }) + assertTrue(frames(config.copy(showDifficultWordParts = false)).all { frame -> frame.tokens.all { it.highlightStart == null } }) assertEquals(3, frames(config.copy(maxChunkLength = 0)).size) val split = supported.flatMap { it.tokens }.filter { it.isSubwordChunk } assertEquals(listOf("neuro", "plast", "icity"), split.map { it.text.substring(it.highlightStart!!, it.highlightEndExclusive!!) }) 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 7f3648cd..e6fec448 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 @@ -73,6 +73,23 @@ class RsvpThoughtFlowTest : ComprehensionRsvpTestBase() { assertEquals(cues, adaptiveOff.toList()) } + @Test + fun languagesWithoutFamiliarityDataUseLengthAndNumbersInsteadOfTreatingEveryWordAsDense() { + val routine = listOf("the", "cat", "is", "in", "house", "with", "a", "dog").map(::w) + val expected = 14.0 // Eight distinct words contribute one length-load unit. + for (policy in listOf( + RsvpLanguagePolicy.DEFAULT_NON_ENGLISH, + RsvpLanguagePolicy.CJK, + RsvpLanguagePolicy.RTL, + RsvpLanguagePolicy.UNKNOWN, + )) { + val cues = plan(routine, language = policy).values + assertEquals(policy.name, expected, cues.sumOf { it.processingHoldMs + it.integrationHoldMs }, 0.001) + val numbered = plan(routine.dropLast(1) + w("2026"), language = policy).values + assertTrue(policy.name, numbered.sumOf { it.processingHoldMs + it.integrationHoldMs } > expected) + } + } + @Test fun splittingAWordDoesNotMultiplyItsPhraseProcessingAllowance() { val tokens = List(8) { w("concept$it").copy(frequencyScore = 0.1, complexityMultiplier = 1.8) } diff --git a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt index 1248c704..8075245f 100644 --- a/app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt +++ b/app/src/test/java/com/kairo/reader/core/rsvp/RsvpWordPartSupportTest.kt @@ -110,7 +110,7 @@ class RsvpWordPartSupportTest { val original = word(text) val changed = original.copy(syllableCount = 99, frequencyScore = 0.0, complexityMultiplier = 99.0) for (tempo in listOf(50L, 150L, 500L)) { - for (support in listOf(0.25, 1.0, 2.0)) { + for (support in listOf(0.0, 0.01, 0.25, 1.0, 2.0)) { val settings = config.copy(tempoMsPerWord = tempo, difficultWordSupport = support) assertEquals(ranges(parts(text)), ranges(RsvpWordPartSupport.split(changed, settings, english))) } @@ -129,9 +129,11 @@ class RsvpWordPartSupportTest { for (text in listOf("1234567890123456", "well-understood", "électromagnétique")) { assertEquals(splitTokenForRsvp(word(text), 32, 35L), parts(text)) } - for (settings in listOf(config.copy(difficultWordSupport = 0.0), config.copy(maxChunkLength = 0))) { + for (settings in listOf(config.copy(showDifficultWordParts = false), config.copy(maxChunkLength = 0))) { assertEquals(1, RsvpWordPartSupport.split(word("neuroplasticity"), settings, english).size) } + assertTrue(parts("neuroplasticity").size > 1) + assertTrue(RsvpWordPartSupport.split(word("neuroplasticity"), config.copy(difficultWordSupport = 0.0), english).size > 1) for (limit in listOf(4, 6, 7)) { val token = word("electrocardiographies") val split = RsvpWordPartSupport.split(token, config.copy(maxChunkLength = limit), english) diff --git a/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt b/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt index 4b159424..14c20e95 100644 --- a/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt +++ b/app/src/test/java/com/kairo/reader/data/preferences/RsvpPreferenceCodecsTest.kt @@ -74,12 +74,26 @@ class RsvpPreferenceCodecsTest { val legacy = preferenceCodec.readRsvpConfig(prefs, RsvpConfig()) assertEquals(95L, legacy.phraseBreathingRoomMs) assertEquals(1.0, legacy.difficultWordSupport, 0.0) + assertTrue(legacy.showDifficultWordParts) preferenceCodec.writeRsvpConfig(prefs, legacy.copy(phraseBreathingRoomMs = 0L, difficultWordSupport = 1.5)) assertEquals(0L, preferenceCodec.readRsvpConfig(prefs, RsvpConfig()).phraseBreathingRoomMs) val json = """[{"id":"user:old","name":"Old","config":{"adaptiveDifficultyMaxHoldMs":95}}]""" val restored = profileCodec.parseCustomProfiles(json).single().config assertEquals(95L, restored.phraseBreathingRoomMs) assertEquals(1.0, restored.difficultWordSupport, 0.0) + assertTrue(restored.showDifficultWordParts) + } + + @Test + fun existingOffSupportKeepsAutomaticWordPartsOffUntilTheNewSwitchIsChosen() { + val prefs = mutablePreferencesOf() + prefs[PrefKeys.difficultWordSupport] = 0.0 + assertTrue(!preferenceCodec.readRsvpConfig(prefs, RsvpConfig()).showDifficultWordParts) + prefs[PrefKeys.showDifficultWordParts] = true + assertTrue(preferenceCodec.readRsvpConfig(prefs, RsvpConfig()).showDifficultWordParts) + + val oldJson = """[{"id":"user:off","name":"Off","config":{"difficultWordSupport":0}}]""" + assertTrue(!profileCodec.parseCustomProfiles(oldJson).single().config.showDifficultWordParts) } @Test @@ -100,6 +114,7 @@ class RsvpPreferenceCodecsTest { RsvpConfig( tempoMsPerWord = 137L, difficultWordSupport = 1.4, + showDifficultWordParts = false, phraseBreathingRoomMs = 123L, minWordMs = 41L, longWordMinMs = 171L, diff --git a/docs/rsvp-comprehension-timing.md b/docs/rsvp-comprehension-timing.md index 1682416e..001c51e2 100644 --- a/docs/rsvp-comprehension-timing.md +++ b/docs/rsvp-comprehension-timing.md @@ -78,14 +78,17 @@ their configured splitting behavior. A zero limit disables splitting. Each part keeps its reading beat and display floor, while the source word's extra recognition allowance is shared across the parts. No blank separation frame is -inserted between parts of the same source word. Turning Reading support off restores -the configured length-only splitting policy; a zero chunk length disables splitting. +inserted between parts of the same source word. **Word-part guidance** in Advanced → +Readability controls this presentation separately from Reading support. Turning it +off restores configured length-only splitting; a zero chunk length disables splitting. ## A separate phrase allowance -Phrase processing uses distinct information-word count, numbers and phrase length, -not another spelling-complexity sum. Dense phrases receive at most 70 ms at 100% -support. The same support slider scales this allowance. Existing phrase distribution +For English, phrase processing uses distinct information-word count, numbers and +phrase length, not another spelling-complexity sum. Other language policies and +UNKNOWN use phrase length and numbers only; without language-specific familiarity +data, distinct words alone do not imply density. Phrases receive at most 70 ms at +100% support. The same support slider scales this allowance. Existing phrase distribution is retained: 40% through a multiword phrase and 60% at its landing. A single source word receives any allowance at its landing. Split words count once. @@ -99,14 +102,20 @@ remain additive. ## Controls and compatibility The everyday sliders are **Speed** and **Reading support** (Off–200%, labelled Strong -at the maximum). The separate phrase slider and six difficulty fine-tuning controls +at the maximum). Reading support scales extra time without changing word-part frame +count. **Word-part guidance** is a separate Advanced switch, on by default; it is +English-only and does not change the extra-time strength. The separate phrase slider and six difficulty fine-tuning controls are removed from both settings and search. **Automatic cadence** is in Advanced → Rhythm and controls existing clause/sentence shaping independently of Reading support. Punctuation preferences and readability floors remain separate. Reading support retains the `difficult_word_support` DataStore key and `difficultWordSupport` profile JSON property. Existing values are preserved and clamped -to 0–2; older profiles without this field receive 100%. The retired numeric fields +to 0–2; older profiles without this field receive 100%. Word-part guidance persists as +`show_difficult_word_parts` / `showDifficultWordParts`. Older profiles default to on, +except when their stored Reading support was Off; that choice keeps guidance off until +the new switch is changed. +The retired numeric fields still round-trip to avoid destructive changes to saved profiles, but playback ignores them. This is a new timing policy, not an exact reproduction of old custom tuning. Study selects 150% support and Sprint 50%; other existing presets retain 100%. From 893f49acf9cdad1140807901c4d45a287c01c94d Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Tue, 22 Sep 2026 21:53:37 +0100 Subject: [PATCH 13/16] feat(rsvp): give pivot and word parts distinct accessible accents Use theme primary and tertiary accents with contrast-safe fallback, underline the active cues, and show both in the theme preview. Cover built-in and custom palettes. --- .../com/kairo/reader/ui/rsvp/RsvpOrpText.kt | 7 +- .../com/kairo/reader/ui/rsvp/RsvpScreenUi.kt | 6 +- .../reader/ui/settings/ThemeLivePreview.kt | 13 +++- .../kairo/reader/ui/theme/RsvpAccentColors.kt | 72 +++++++++++++++++++ .../kairo/reader/ui/rsvp/RsvpAccentCueTest.kt | 23 ++++++ .../reader/ui/theme/RsvpAccentColorsTest.kt | 54 ++++++++++++++ 6 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt create mode 100644 app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt create mode 100644 app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt index 49441e02..cbedb7cd 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration import com.kairo.reader.core.model.Token import com.kairo.reader.core.model.TokenType import kotlin.math.roundToInt @@ -51,7 +52,7 @@ internal fun buildOrpAnnotatedText( val safeEnd = highlightEndExclusive.coerceIn(safeStart, fullText.length) if (safeEnd > safeStart) { addStyle( - style = SpanStyle(color = highlightColor), + style = SpanStyle(color = highlightColor, textDecoration = TextDecoration.Underline), start = safeStart, end = safeEnd, ) @@ -60,12 +61,12 @@ internal fun buildOrpAnnotatedText( additionalHighlights.forEach { range -> val start = range.first.coerceIn(0, fullText.length) val end = (range.last + 1).coerceIn(start, fullText.length) - if (end > start) addStyle(SpanStyle(color = highlightColor), start, end) + if (end > start) addStyle(SpanStyle(color = highlightColor, textDecoration = TextDecoration.Underline), start, end) } if (pivotHighlightVisible && fullText.isNotEmpty()) { val safeIndex = pivotPosition.coerceIn(0, fullText.lastIndex) addStyle( - style = SpanStyle(color = pivotColor), + style = SpanStyle(color = pivotColor, textDecoration = TextDecoration.Underline), start = safeIndex, end = (safeIndex + 1).coerceAtMost(fullText.length), ) diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt index e12cd067..132a1b0a 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpScreenUi.kt @@ -42,6 +42,7 @@ import com.kairo.reader.ui.bionic.BionicReadingText import com.kairo.reader.ui.rememberWindowContainerMetrics import com.kairo.reader.ui.theme.composeFontFamily import com.kairo.reader.ui.theme.readingColor +import com.kairo.reader.ui.theme.rsvpAccentColors import com.kairo.reader.ui.tutorial.StartingTutorialOverlay import com.kairo.reader.ui.tutorial.StartingTutorialOverlayState import com.kairo.reader.ui.tutorial.StartingTutorialTargetIds @@ -255,11 +256,12 @@ internal fun rememberRsvpTextColors( val pivotLineAlpha = (PIVOT_LINE_ALPHA_BASE * clampedBrightness * guideBrightness) .coerceIn(PIVOT_LINE_ALPHA_MIN, PIVOT_LINE_ALPHA_MAX) + val accents = MaterialTheme.colorScheme.rsvpAccentColors() return OrpColors( - pivotColor = MaterialTheme.colorScheme.primary, + pivotColor = accents.pivot, pivotLineColor = MaterialTheme.colorScheme.onBackground.copy(alpha = pivotLineAlpha), textColor = readingColor(MaterialTheme.colorScheme.onBackground.copy(alpha = clampedBrightness)), - highlightColor = MaterialTheme.colorScheme.primary, + highlightColor = accents.wordPart, ) } diff --git a/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt index d15ef4b2..03384b93 100644 --- a/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt +++ b/app/src/main/java/com/kairo/reader/ui/settings/ThemeLivePreview.kt @@ -148,7 +148,16 @@ private fun ThemeRsvpPreview(preferences: UserPreferences, design: ThemeDesign) Column(Modifier.fillMaxWidth().heightIn(min = 112.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(stringResource(R.string.theme_preview_paused), style = MaterialTheme.typography.labelSmall) OrpAlignedText( - tokens = listOf(Token(sample, TokenType.WORD, orpIndex = (sample.length / 3).coerceAtMost(sample.lastIndex))), + tokens = listOf( + Token( + sample, + TokenType.WORD, + orpIndex = (sample.length / ORP_PREVIEW_PIVOT_DIVISOR).coerceAtMost(sample.lastIndex), + isSubwordChunk = true, + highlightStart = (sample.length / WORD_PART_PREVIEW_START_DIVISOR).coerceAtMost(sample.lastIndex), + highlightEndExclusive = sample.length, + ) + ), typography = OrpTypography( preferences.rsvpFontSizeSp, design.timedFont.composeFontFamily(), @@ -196,3 +205,5 @@ private fun ThemeInterfacePreview() { } private val CircleShapeForPreview = RoundedCornerShape(50) +private const val ORP_PREVIEW_PIVOT_DIVISOR = 3 +private const val WORD_PART_PREVIEW_START_DIVISOR = 2 diff --git a/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt b/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt new file mode 100644 index 00000000..91c67bce --- /dev/null +++ b/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt @@ -0,0 +1,72 @@ +package com.kairo.reader.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.graphics.Color +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +internal data class RsvpAccentColors(val pivot: Color, val wordPart: Color) + +/** Keep the two RSVP cues legible and distinct while following editable theme accents. */ +internal fun ColorScheme.rsvpAccentColors(): RsvpAccentColors { + val pivot = contrastingColor(primary, listOf(background)) + val themedWordPart = contrastingColor(tertiary, listOf(background)) + val wordPart = if (channelDistance(pivot, themedWordPart) >= MIN_ACCENT_DISTANCE) { + themedWordPart + } else { + val hue = colorHue(pivot) + val lightness = if (colorContrast(Color.White, background) > colorContrast(Color.Black, background)) { + DARK_BACKGROUND_WORD_PART_LIGHTNESS + } else { + LIGHT_BACKGROUND_WORD_PART_LIGHTNESS + } + val derived = DISTINCT_HUE_OFFSETS.map { offset -> + contrastingColor( + Color.hsl((hue + offset) % FULL_CIRCLE_DEGREES, WORD_PART_SATURATION, lightness), + listOf(background), + ) + } + val best = derived.maxBy { channelDistance(pivot, it) } + if (channelDistance(pivot, best) >= MIN_ACCENT_DISTANCE) { + best + } else { + val extremes = listOf(Color.Black, Color.White).filter { colorContrast(it, background) >= MIN_TEXT_CONTRAST } + (derived + extremes).maxBy { channelDistance(pivot, it) } + } + } + return RsvpAccentColors(pivot, wordPart) +} + +private fun channelDistance(first: Color, second: Color): Float = + max(abs(first.red - second.red), max(abs(first.green - second.green), abs(first.blue - second.blue))) + +private fun colorHue(color: Color): Float { + val high = max(color.red, max(color.green, color.blue)) + val low = min(color.red, min(color.green, color.blue)) + val delta = high - low + if (delta < NEUTRAL_CHROMA_THRESHOLD) return NEUTRAL_HUE_DEGREES + val sector = when (high) { + color.red -> (color.green - color.blue) / delta + color.green -> (color.blue - color.red) / delta + GREEN_HUE_SECTOR + else -> (color.red - color.green) / delta + BLUE_HUE_SECTOR + } + return (sector * HUE_SECTOR_DEGREES + FULL_CIRCLE_DEGREES) % FULL_CIRCLE_DEGREES +} + +private const val MIN_ACCENT_DISTANCE = 0.16f +private const val MIN_TEXT_CONTRAST = 4.5f +private const val WORD_PART_SATURATION = 0.9f +private const val LIGHT_BACKGROUND_WORD_PART_LIGHTNESS = 0.38f +private const val DARK_BACKGROUND_WORD_PART_LIGHTNESS = 0.72f +private const val NEUTRAL_CHROMA_THRESHOLD = 0.02f +private const val NEUTRAL_HUE_DEGREES = 40f +private const val GREEN_HUE_SECTOR = 2f +private const val BLUE_HUE_SECTOR = 4f +private const val HUE_SECTOR_DEGREES = 60f +private const val FULL_CIRCLE_DEGREES = 360f +private const val HUE_QUARTER_TURN = 90f +private const val HUE_WARM_TURN = 150f +private const val HUE_COOL_TURN = 210f +private const val HUE_THREE_QUARTER_TURN = 270f +private val DISTINCT_HUE_OFFSETS = listOf(HUE_QUARTER_TURN, HUE_WARM_TURN, HUE_COOL_TURN, HUE_THREE_QUARTER_TURN) diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt new file mode 100644 index 00000000..f3c4a3df --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt @@ -0,0 +1,23 @@ +package com.kairo.reader.ui.rsvp + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextDecoration +import org.junit.Assert.assertEquals +import org.junit.Test + +class RsvpAccentCueTest { + @Test + fun pivotAndWordPartHaveVisibleCuesBeyondColor() { + val text = buildOrpAnnotatedText( + fullText = "neuroplasticity", + pivotPosition = 2, + pivotColor = Color.Red, + highlightStart = 5, + highlightEndExclusive = 10, + highlightColor = Color.Blue, + ) + assertEquals(listOf(Color.Blue, Color.Red), text.spanStyles.map { it.item.color }) + assertEquals(listOf(TextDecoration.Underline, TextDecoration.Underline), text.spanStyles.map { it.item.textDecoration }) + assertEquals(listOf(5 to 10, 2 to 3), text.spanStyles.map { it.start to it.end }) + } +} diff --git a/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt b/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt new file mode 100644 index 00000000..98ec8277 --- /dev/null +++ b/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt @@ -0,0 +1,54 @@ +package com.kairo.reader.ui.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import com.kairo.reader.core.model.CustomTheme +import com.kairo.reader.core.model.ReaderTheme +import kotlin.math.abs +import kotlin.math.max +import kotlin.random.Random +import org.junit.Assert.assertTrue +import org.junit.Test + +class RsvpAccentColorsTest { + @Test + fun everyBuiltInThemeHasLegibleDistinctRsvpAccents() { + ReaderTheme.entries.filter { it != ReaderTheme.CUSTOM }.forEach { theme -> + assertAccents(theme.materialColorScheme()) + } + } + + @Test + fun customThemesStayLegibleWhenAccentsMatchOrHavePoorContrast() { + val random = Random(PALETTE_RANDOM_SEED) + repeat(CUSTOM_PALETTE_SAMPLES) { + val accent = random.nextInt() + assertAccents( + CustomTheme( + background = random.nextInt(), + primary = accent, + tertiary = if (it % MATCHED_ACCENT_INTERVAL == 0) accent else random.nextInt(), + ).materialColorScheme() + ) + } + } + + private fun assertAccents(scheme: androidx.compose.material3.ColorScheme) { + val (pivot, wordPart) = scheme.rsvpAccentColors() + val background = Color(scheme.background.toArgb()) + listOf(pivot, wordPart).forEach { color -> + assertTrue(colorContrast(Color(color.toArgb()), background) >= WCAG_AA_TEXT_CONTRAST - CONTRAST_TOLERANCE) + } + val distance = max(abs(pivot.red - wordPart.red), max(abs(pivot.green - wordPart.green), abs(pivot.blue - wordPart.blue))) + assertTrue("RSVP accent colors are too similar on $background: $pivot and $wordPart", distance >= MIN_VISIBLE_CHANNEL_DELTA) + } + + private companion object { + const val PALETTE_RANDOM_SEED = 417 + const val CUSTOM_PALETTE_SAMPLES = 100 + const val MATCHED_ACCENT_INTERVAL = 2 + const val WCAG_AA_TEXT_CONTRAST = 4.5f + const val CONTRAST_TOLERANCE = 0.0001f + const val MIN_VISIBLE_CHANNEL_DELTA = 0.05f + } +} From 39ee49455a6561e8144f2af7c712d417856a9e41 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Tue, 22 Sep 2026 22:02:15 +0100 Subject: [PATCH 14/16] chore(deps): update Coil to 3.6.3 Resolve the dependency freshness lint error and restore the full quality gate. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 09ea10d3..82151337 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ navigationCompose = "2.10.0" lifecycle = "2.11.0" room = "2.8.4" datastore = "1.2.1" -coil = "3.6.2" +coil = "3.6.3" jsoup = "1.23.2" pdfboxAndroid = "2.0.27.0" coreKtx = "1.19.0" From 705999a6addd1c6be633aea9fe0ca4d053c502ba Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Tue, 22 Sep 2026 22:27:42 +0100 Subject: [PATCH 15/16] chore(build): update Android Gradle Plugin to 9.4.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 82151337..32926b42 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.4.0" +agp = "9.4.1" kotlin = "2.4.20" compileSdk = "37" targetSdk = "37" From 2260ee416dcb06b0ede477d44a0baf6339ebba11 Mon Sep 17 00:00:00 2001 From: Edward Kemp Date: Tue, 22 Sep 2026 22:34:01 +0100 Subject: [PATCH 16/16] fix(rsvp): make pivot character distinct across themes --- .../rsvp/RsvpDifficultyHighlightDeviceTest.kt | 59 +++++++++++++++++++ .../com/kairo/reader/ui/rsvp/RsvpOrpText.kt | 2 +- .../kairo/reader/ui/theme/RsvpAccentColors.kt | 49 +++++++++++---- .../kairo/reader/ui/rsvp/RsvpAccentCueTest.kt | 7 ++- .../reader/ui/theme/RsvpAccentColorsTest.kt | 11 ++++ 5 files changed, 115 insertions(+), 13 deletions(-) diff --git a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt index 366a8ec8..4d3dc35f 100644 --- a/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt +++ b/app/src/androidTest/java/com/kairo/reader/ui/rsvp/RsvpDifficultyHighlightDeviceTest.kt @@ -1,7 +1,9 @@ package com.kairo.reader.ui.rsvp +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -17,6 +19,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.kairo.reader.TestActivity +import com.kairo.reader.core.model.ReaderTheme import com.kairo.reader.core.model.RsvpConfig import com.kairo.reader.core.model.RsvpProfile import com.kairo.reader.core.model.Token @@ -26,7 +29,11 @@ import com.kairo.reader.core.rsvp.ComprehensionRsvpEngine import com.kairo.reader.core.rsvp.RsvpGenerationOptions import com.kairo.reader.core.rsvp.RsvpLanguagePolicy import com.kairo.reader.ui.theme.KairoTheme +import com.kairo.reader.ui.theme.colorContrast +import com.kairo.reader.ui.theme.materialColorScheme +import com.kairo.reader.ui.theme.rsvpAccentColors import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -37,6 +44,48 @@ class RsvpDifficultyHighlightDeviceTest { @get:Rule val composeRule = createAndroidComposeRule() + @Test + fun darkThemesRenderAContrastingPivotLetterWithoutAHighlightBlock() { + var theme by mutableStateOf(ReaderTheme.DARK) + composeRule.setContent { + KairoTheme(readerTheme = theme) { + val scheme = MaterialTheme.colorScheme + val accents = scheme.rsvpAccentColors() + Box(Modifier.size(FOCUS_STAGE_WIDTH_DP.dp, FOCUS_STAGE_HEIGHT_DP.dp).background(scheme.background)) { + OrpAlignedText( + tokens = listOf(Token("reading", TokenType.WORD)), + typography = OrpTypography(FOCUS_FONT_SIZE_SP, FontFamily.Monospace, FontWeight.Normal), + colors = OrpColors(scheme.onBackground, accents.pivot, scheme.onBackground, accents.wordPart), + layout = OrpTextLayout( + horizontalBias = FOCUS_HORIZONTAL_BIAS, + lockPivot = true, + smoothTranslation = false, + preferWindowing = false, + simplifyPunctuation = false, + guideVisible = true, + pivotHighlightVisible = true, + guideThickness = FOCUS_GUIDE_THICKNESS, + ), + ) + } + } + } + + for (currentTheme in listOf(ReaderTheme.DARK, ReaderTheme.INK)) { + composeRule.runOnIdle { theme = currentTheme } + val scheme = currentTheme.materialColorScheme() + val accents = scheme.rsvpAccentColors() + val text = renderedLayout().layoutInput.text + val pivot = text.spanStyles.last() + assertEquals(PIVOT_CHARACTER_COUNT, pivot.end - pivot.start) + assertEquals(accents.pivot, pivot.item.color) + assertEquals(Color.Unspecified, pivot.item.background) + assertNull(pivot.item.fontWeight) + assertNull(pivot.item.textDecoration) + assertTrue(colorContrast(pivot.item.color, scheme.background) >= WCAG_AA_TEXT_CONTRAST) + } + } + @Test fun difficultWordsMoveLeftToRightWithoutMovingTheRenderedWord() { val source = listOf("the", "neuroplasticity", "changed").map { Token(it, TokenType.WORD) } @@ -166,4 +215,14 @@ class RsvpDifficultyHighlightDeviceTest { val text = layout.layoutInput.text return text.spanStyles.filter { it.item.color == Color.Blue }.joinToString("") { text.text.substring(it.start, it.end) } } + + private companion object { + const val FOCUS_STAGE_WIDTH_DP = 360 + const val FOCUS_STAGE_HEIGHT_DP = 180 + const val FOCUS_FONT_SIZE_SP = 28f + const val FOCUS_HORIZONTAL_BIAS = 0.5f + const val FOCUS_GUIDE_THICKNESS = 1f + const val PIVOT_CHARACTER_COUNT = 1 + const val WCAG_AA_TEXT_CONTRAST = 4.5f + } } diff --git a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt index cbedb7cd..64fa3653 100644 --- a/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt +++ b/app/src/main/java/com/kairo/reader/ui/rsvp/RsvpOrpText.kt @@ -66,7 +66,7 @@ internal fun buildOrpAnnotatedText( if (pivotHighlightVisible && fullText.isNotEmpty()) { val safeIndex = pivotPosition.coerceIn(0, fullText.lastIndex) addStyle( - style = SpanStyle(color = pivotColor, textDecoration = TextDecoration.Underline), + style = SpanStyle(color = pivotColor), start = safeIndex, end = (safeIndex + 1).coerceAtMost(fullText.length), ) diff --git a/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt b/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt index 91c67bce..8fe48819 100644 --- a/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt +++ b/app/src/main/java/com/kairo/reader/ui/theme/RsvpAccentColors.kt @@ -8,19 +8,29 @@ import kotlin.math.min internal data class RsvpAccentColors(val pivot: Color, val wordPart: Color) -/** Keep the two RSVP cues legible and distinct while following editable theme accents. */ +/** Tertiary is usually the palette's hue-separated accent; reserve primary for word parts. */ internal fun ColorScheme.rsvpAccentColors(): RsvpAccentColors { - val pivot = contrastingColor(primary, listOf(background)) - val themedWordPart = contrastingColor(tertiary, listOf(background)) + val themedPivot = contrastingColor(tertiary, listOf(background)) + val pivot = if (colorChroma(background) >= MIN_BACKGROUND_CHROMA && + hueDistance(background, themedPivot) < MIN_PIVOT_HUE_DISTANCE + ) { + contrastingColor( + Color.hsl( + (colorHue(background) + HUE_HALF_TURN) % FULL_CIRCLE_DEGREES, + PIVOT_SATURATION, + accentLightness(background), + ), + listOf(background), + ) + } else { + themedPivot + } + val themedWordPart = contrastingColor(primary, listOf(background)) val wordPart = if (channelDistance(pivot, themedWordPart) >= MIN_ACCENT_DISTANCE) { themedWordPart } else { val hue = colorHue(pivot) - val lightness = if (colorContrast(Color.White, background) > colorContrast(Color.Black, background)) { - DARK_BACKGROUND_WORD_PART_LIGHTNESS - } else { - LIGHT_BACKGROUND_WORD_PART_LIGHTNESS - } + val lightness = accentLightness(background) val derived = DISTINCT_HUE_OFFSETS.map { offset -> contrastingColor( Color.hsl((hue + offset) % FULL_CIRCLE_DEGREES, WORD_PART_SATURATION, lightness), @@ -41,6 +51,21 @@ internal fun ColorScheme.rsvpAccentColors(): RsvpAccentColors { private fun channelDistance(first: Color, second: Color): Float = max(abs(first.red - second.red), max(abs(first.green - second.green), abs(first.blue - second.blue))) +private fun accentLightness(background: Color): Float = + if (colorContrast(Color.White, background) > colorContrast(Color.Black, background)) { + DARK_BACKGROUND_ACCENT_LIGHTNESS + } else { + LIGHT_BACKGROUND_ACCENT_LIGHTNESS + } + +private fun colorChroma(color: Color): Float = + max(color.red, max(color.green, color.blue)) - min(color.red, min(color.green, color.blue)) + +private fun hueDistance(first: Color, second: Color): Float { + val distance = abs(colorHue(first) - colorHue(second)) + return min(distance, FULL_CIRCLE_DEGREES - distance) +} + private fun colorHue(color: Color): Float { val high = max(color.red, max(color.green, color.blue)) val low = min(color.red, min(color.green, color.blue)) @@ -55,16 +80,20 @@ private fun colorHue(color: Color): Float { } private const val MIN_ACCENT_DISTANCE = 0.16f +private const val MIN_BACKGROUND_CHROMA = 0.04f +private const val MIN_PIVOT_HUE_DISTANCE = 45f private const val MIN_TEXT_CONTRAST = 4.5f private const val WORD_PART_SATURATION = 0.9f -private const val LIGHT_BACKGROUND_WORD_PART_LIGHTNESS = 0.38f -private const val DARK_BACKGROUND_WORD_PART_LIGHTNESS = 0.72f +private const val PIVOT_SATURATION = 0.8f +private const val LIGHT_BACKGROUND_ACCENT_LIGHTNESS = 0.38f +private const val DARK_BACKGROUND_ACCENT_LIGHTNESS = 0.72f private const val NEUTRAL_CHROMA_THRESHOLD = 0.02f private const val NEUTRAL_HUE_DEGREES = 40f private const val GREEN_HUE_SECTOR = 2f private const val BLUE_HUE_SECTOR = 4f private const val HUE_SECTOR_DEGREES = 60f private const val FULL_CIRCLE_DEGREES = 360f +private const val HUE_HALF_TURN = 180f private const val HUE_QUARTER_TURN = 90f private const val HUE_WARM_TURN = 150f private const val HUE_COOL_TURN = 210f diff --git a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt index f3c4a3df..6de3b69d 100644 --- a/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/rsvp/RsvpAccentCueTest.kt @@ -3,11 +3,12 @@ package com.kairo.reader.ui.rsvp import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextDecoration import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test class RsvpAccentCueTest { @Test - fun pivotAndWordPartHaveVisibleCuesBeyondColor() { + fun pivotOnlyChangesCharacterColorWhileWordPartKeepsUnderline() { val text = buildOrpAnnotatedText( fullText = "neuroplasticity", pivotPosition = 2, @@ -17,7 +18,9 @@ class RsvpAccentCueTest { highlightColor = Color.Blue, ) assertEquals(listOf(Color.Blue, Color.Red), text.spanStyles.map { it.item.color }) - assertEquals(listOf(TextDecoration.Underline, TextDecoration.Underline), text.spanStyles.map { it.item.textDecoration }) + assertEquals(listOf(TextDecoration.Underline, null), text.spanStyles.map { it.item.textDecoration }) + assertNull(text.spanStyles.last().item.fontWeight) + assertEquals(Color.Unspecified, text.spanStyles.last().item.background) assertEquals(listOf(5 to 10, 2 to 3), text.spanStyles.map { it.start to it.end }) } } diff --git a/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt b/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt index 98ec8277..a72ad90d 100644 --- a/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt +++ b/app/src/test/java/com/kairo/reader/ui/theme/RsvpAccentColorsTest.kt @@ -18,6 +18,17 @@ class RsvpAccentColorsTest { } } + @Test + fun warmPageUsesACoolPivotMarkerEvenWithAWarmCustomTertiary() { + val scheme = CustomTheme( + background = Color(0xFFF2E6D2).toArgb(), + tertiary = Color(0xFF7A4B1F).toArgb(), + ).materialColorScheme() + val pivot = scheme.rsvpAccentColors().pivot + assertTrue("Pivot should stand apart from the warm page: $pivot", pivot.blue > pivot.red) + assertAccents(scheme) + } + @Test fun customThemesStayLegibleWhenAccentsMatchOrHavePoorContrast() { val random = Random(PALETTE_RANDOM_SEED)