From 7928e603857adc0139816df9358d1eddfa08c158 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 20 Sep 2026 16:08:02 +0100 Subject: [PATCH] Keep native-context predictions stable without fallback tracking Separate fallback tracking availability from native context caching and stop unchanged polls from resetting prediction batches. Cover scan timing, pass limits, selected choices and stale-context rejection. --- src-tauri/src/prediction/mod.rs | 19 +++-- src-tauri/src/prediction/worker.rs | 107 ++++++++++++++++++++++++++++- src-tauri/src/scan_keyboard.rs | 71 +++++++++++++++++++ 3 files changed, 189 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index d8836f52..6792afb1 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -152,6 +152,18 @@ struct Service { tracking: bool, } impl Service { + fn suggestions( + &mut self, + keyboard: &mut Keyboard, + batch: Option, + tracking: bool, + ) { + self.tracking = tracking; + if !tracking { + self.edit = None; + } + keyboard.predictions(batch, false); + } fn fail(&mut self, keyboard: &mut Keyboard) { self.client = None; self.failed = true; @@ -270,12 +282,7 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign batch, tracking, } if generation == s.generation => { - s.tracking = tracking; - if !tracking { - s.edit = None; - s.reset = true; - } - keyboard.predictions(batch, false); + s.suggestions(keyboard, batch, tracking); } Response::Insert { generation, text } => { s.accepting = false; diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 6cd4a909..f7380c28 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -139,9 +139,16 @@ impl Engine { .as_ref() .is_some_and(|old| self.adapter.same(old, &target).unwrap_or(false)); let uninterrupted = epoch == self.activity; - if !same || !uninterrupted || reset || !healthy { + if !same || !uninterrupted || reset { self.clear(); } + if !healthy { + self.fallback.clear(); + self.boundary = false; + if self.snapshot.as_ref().is_some_and(|raw| raw.position == -1) { + self.clear(); + } + } self.activity = epoch; if same && uninterrupted && !reset && self.tracked && healthy { if let Some(text) = edit { @@ -369,6 +376,7 @@ mod tests { target: usize, protected: bool, unsupported: bool, + editable: bool, reads: usize, change_on_read: bool, } @@ -385,6 +393,7 @@ mod tests { target: 1, protected: false, unsupported: false, + editable: true, reads: 0, change_on_read: false, } @@ -399,7 +408,7 @@ mod tests { Ok(self.protected) } fn editable(&mut self, _: &usize) -> bool { - true + self.editable } fn same(&mut self, a: &usize, b: &usize) -> Result { Ok(a == b) @@ -423,6 +432,100 @@ mod tests { e } #[test] + fn native_predictions_survive_polls_without_fallback_tracking() { + for unavailable in 0..3 { + let mut e = engine(); + match unavailable { + 0 => e.tracked = false, + 1 => e.adapter.editable = false, + _ => e.observe = || (0, false), + } + let mut service = crate::prediction::Service::default(); + let mut keyboard = crate::scan_keyboard::Keyboard::new(false); + keyboard.enable_predictions(true); + let mut original = None; + for _ in 0..40 { + let response = e.respond(Request::Query { + generation: 0, + edit: service.edit.take(), + reset: std::mem::take(&mut service.reset), + shift: false, + caps: false, + }); + let Response::Suggestions { + batch, tracking, .. + } = response + else { + panic!("expected suggestions"); + }; + assert!(!tracking); + let batch = batch.unwrap(); + let expected = original.get_or_insert_with(|| batch.clone()); + assert_eq!(batch.token, expected.token); + assert_eq!(batch.words, expected.words); + service.suggestions(&mut keyboard, Some(batch), tracking); + keyboard.advance(250, 1000); + } + let token = original.unwrap().token; + assert_eq!(e.accept(token, 0), Some("ter ".into())); + assert!(e.accept(token, 0).is_none()); + } + } + #[test] + fn genuine_context_changes_invalidate_identical_native_candidates() { + for change in 0..4 { + let mut e = engine(); + e.adapter.editable = false; + let original = e.query(None, false, false, false).unwrap(); + match change { + 0 => e.adapter.target += 1, + 1 => e.adapter.raw.position += 1, + 2 => e.activity = u64::MAX, + _ => {} + } + let replacement = e.query(None, change == 3, false, false).unwrap(); + assert_eq!(replacement.words, original.words); + assert_ne!(replacement.token, original.token); + assert!(e.accept(original.token, 0).is_none()); + assert_eq!(e.accept(replacement.token, 0), Some("ter ".into())); + } + } + #[test] + fn native_edits_selection_and_casing_refresh_without_tracking() { + let mut e = engine(); + e.tracked = false; + let original = e.query(None, false, false, false).unwrap(); + e.adapter.raw.before.push('t'); + let typed = e.query(None, false, false, false).unwrap(); + assert_ne!(typed.token, original.token); + assert!(e.accept(original.token, 0).is_none()); + e.adapter.raw.before.pop(); + let deleted = e.query(None, false, false, false).unwrap(); + assert_ne!(deleted.token, typed.token); + let shifted = e.query(None, false, true, false).unwrap(); + assert_ne!(shifted.token, deleted.token); + assert!(e.accept(deleted.token, 0).is_none()); + e.adapter.raw.has_selection = true; + assert!(e.query(None, false, false, false).is_none()); + assert!(e.accept(shifted.token, 0).is_none()); + e.adapter.raw.has_selection = false; + let restored = e.query(None, false, false, false).unwrap(); + e.adapter.protected = true; + assert!(e.query(None, false, false, false).is_none()); + assert!(e.accept(restored.token, 0).is_none()); + } + #[test] + fn losing_tracking_does_not_erase_a_pending_context_reset() { + let mut service = crate::prediction::Service { + reset: true, + edit: Some("queued".into()), + ..Default::default() + }; + service.suggestions(&mut crate::scan_keyboard::Keyboard::new(false), None, false); + assert!(service.reset); + assert!(service.edit.is_none()); + } + #[test] fn only_suffix_and_space_are_returned_once() { let mut e = engine(); let b = e.query(None, false, false, false).unwrap(); diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 922a0e3a..b07aa40d 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -619,6 +619,77 @@ mod tests { assert_eq!(keyboard.scan.nav.index(), 1); } + #[test] + fn unchanged_predictions_preserve_navigation_timing_and_pass_limits() { + use crate::scan_preferences::{Direction, Pattern, Resolved}; + for direction in [Direction::Forward, Direction::Reverse] { + for pattern in [Pattern::Grouped, Pattern::Linear] { + for automatic in [false, true] { + let options = Resolved { + direction, + pattern, + pass_limit: 2, + ..Default::default() + }; + let mut baseline = Keyboard::configured(false, options); + let mut polled = Keyboard::configured(false, options); + let batch = crate::prediction::worker::Batch { + token: 7, + words: vec!["water".into(), "walk".into()], + }; + for k in [&mut baseline, &mut polled] { + k.enable_predictions(true); + k.predictions(Some(batch.clone()), false); + k.restart(); + } + let ticks = 12 * (polled.rows.iter().map(Vec::len).sum::() + 1); + for tick in 0..ticks { + polled.predictions(Some(batch.clone()), false); + if automatic { + baseline.advance(250, 1000); + polled.advance(250, 1000); + } else if tick % 4 == 3 { + baseline.handle(Action::Next); + polled.handle(Action::Next); + } + assert_eq!( + polled.scan.position(&polled.rows), + baseline.scan.position(&baseline.rows) + ); + assert_eq!(polled.suspended(), baseline.suspended()); + assert_eq!(polled.disabled(), baseline.disabled()); + assert_eq!(polled.predictions.as_ref().unwrap().token, 7); + assert!(polled.queued_predictions.is_none()); + } + if automatic { + assert!(polled.suspended()); + } + } + } + } + } + #[test] + fn unchanged_predictions_preserve_selected_key_and_execute_once() { + let mut k = Keyboard::new(false); + k.enable_predictions(true); + let batch = crate::prediction::worker::Batch { + token: 7, + words: vec!["water".into(), "walk".into()], + }; + k.predictions(Some(batch.clone()), false); + k.handle(Action::Select); + k.advance(750, 1000); + k.predictions(Some(batch.clone()), false); + assert_eq!(k.scan.position(&k.rows), (0, Some(0))); + k.advance(250, 1000); + assert_eq!(k.scan.position(&k.rows), (0, Some(1))); + k.predictions(Some(batch), false); + assert_eq!( + k.handle(Action::Select), + Some(Output::Prediction { token: 7, index: 1 }) + ); + assert!(k.handle(Action::Select).is_none()); + } #[test] fn predictions_skip_empty_slots_and_defer_acceptance() { let mut k = Keyboard::new(false);