From ea6edad9f589e1a3832bc9fe04e22de7c8f48e56 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 26 Sep 2026 11:46:12 +0100 Subject: [PATCH] Let the keyboard alone decide a sentence start Two rules decided sentence starts independently: the keyboard after a sentence mark it typed, and the worker after any of several marks it saw in its buffer, or at the bare start of that buffer, which is often mid-line after a reset. Each could fire without the other. The keyboard now sends its pending automatic capital with each query and the worker uses only that, so a suggestion never disagrees with the keyboard's own Shift, and a person who cancelled the capital is respected. The worker's own sentence-start guess is removed. Closes #910 Co-Authored-By: Claude Fable 5.1 --- docs/word-prediction.md | 2 +- src-tauri/src/prediction/context.rs | 3 - src-tauri/src/prediction/mod.rs | 4 +- src-tauri/src/prediction/worker.rs | 146 +++++++++++++++++++--------- src-tauri/src/scan_keyboard.rs | 5 + 5 files changed, 107 insertions(+), 53 deletions(-) diff --git a/docs/word-prediction.md b/docs/word-prediction.md index 5e5e0431..90609926 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -14,7 +14,7 @@ One Word prediction setting controls an offline SmolLM2-135M int8 ONNX model. Th For a first typed prefix, a fixed local context keeps the model from favoring website names at the start of a document; it adds no user text. The model loads in the worker while keyboard input remains available. Suggestions are blank until loading finishes. A passive badge beside the scan prompt distinguishes loading, a ready keyboard awaiting typed context, no matching suggestions, available suggestions, paused activity tracking, and prediction failure. It never shows typed text and is not a scan target. If loading or inference fails, or a call exceeds 1.5 seconds, the keyboard continues accepting input and offers **Retry predictions** in its toolbar. Retry restarts only the prediction worker, clears its private text context, and leaves the keyboard open; type a new prefix afterward. A 400 ms search budget bounds candidate exploration. A clipped buffer with no complete earlier word yields no suggestions. -Candidates contain ASCII letters and apostrophes and must have sufficient model probability. There is no vocabulary filter: any word the model finds likely can be suggested, including swearing, because the person typing chose it. Single-letter candidates are limited to “a” and “I”. The pronoun I and its contractions, such as I’m and I’ll, are completed with a capital I; letters already typed stay as typed. Up to five suggestions are shown: the first, third and fifth are the most likely single words, and the second and fourth are the two most likely two-word phrases that begin with one of the top three words, ranked by the probability of the pair. When fewer phrases are found within an extra 200 ms, single words fill the remaining slots, and the other way round. Accepting a phrase inserts the rest of its first word, a space, the second word and a trailing space. Ordinary words use lowercase. Names keep the model's capital: mixed case anywhere, such as WhatsApp, or an initial capital mid-sentence that the model clearly prefers, such as London or Monday, which it does for proper nouns and occasionally a rare word. At a sentence start the model capitalises every word, so the lowercase form wins there and the sentence rule decides. Casing is applied when the keyboard displays a candidate, and the label shows exactly what will be inserted. Caps, or Shift locked, uppercases the whole completion, and together they cancel as they do on typed letters. Before any letter of the word is typed, Shift once changes only the first letter, the way it would change the next typed letter, and a sentence start capitalises it without any modifier. Shift once under Caps gives a lowercase first letter there, as it would on a typed letter. After typed letters, a pending Shift once is left for the next letter and does not touch the completion. +Candidates contain ASCII letters and apostrophes and must have sufficient model probability. There is no vocabulary filter: any word the model finds likely can be suggested, including swearing, because the person typing chose it. Single-letter candidates are limited to “a” and “I”. The pronoun I and its contractions, such as I’m and I’ll, are completed with a capital I; letters already typed stay as typed. Up to five suggestions are shown: the first, third and fifth are the most likely single words, and the second and fourth are the two most likely two-word phrases that begin with one of the top three words, ranked by the probability of the pair. When fewer phrases are found within an extra 200 ms, single words fill the remaining slots, and the other way round. Accepting a phrase inserts the rest of its first word, a space, the second word and a trailing space. Ordinary words use lowercase. Names keep the model's capital: mixed case anywhere, such as WhatsApp, or an initial capital mid-sentence that the model clearly prefers, such as London or Monday, which it does for proper nouns and occasionally a rare word. At a sentence start the model capitalises every word, so the lowercase form wins there and the sentence rule decides. Casing is applied when the keyboard displays a candidate, and the label shows exactly what will be inserted. Caps, or Shift locked, uppercases the whole completion, and together they cancel as they do on typed letters. Before any letter of the word is typed, Shift once changes only the first letter, the way it would change the next typed letter, and a sentence start capitalises it without any modifier. The keyboard alone decides what a sentence start is: a full stop, exclamation or question mark it typed, not yet followed by a letter and not cancelled by pressing Shift. Punctuation the worker merely sees in its buffer, or the bare start of that buffer after a reset, does not capitalise anything, so a suggestion never disagrees with the keyboard's own automatic Shift. Shift once under Caps gives a lowercase first letter there, as it would on a typed letter. After typed letters, a pending Shift once is left for the next letter and does not touch the completion. The model files are too large to commit. `npm run prediction-model` downloads them from a pinned upstream revision and verifies their SHA-256. The Tauri dev and build commands and CI run it automatically. Run it once before `cargo test` or `cargo clippy` in a fresh checkout. diff --git a/src-tauri/src/prediction/context.rs b/src-tauri/src/prediction/context.rs index e7b968e7..cc9a00c2 100644 --- a/src-tauri/src/prediction/context.rs +++ b/src-tauri/src/prediction/context.rs @@ -2,7 +2,6 @@ use unicode_segmentation::UnicodeSegmentation; pub struct Context { pub prefix: String, - pub sentence_start: bool, /// Buffered text before `prefix`, without a clipped leading fragment. pub before: String, /// Clipping left no complete earlier word, so an empty `before` is not @@ -40,7 +39,6 @@ fn extract_words(text: &str, clipped: bool) -> Context { String::new() }; Context { - sentence_start: tokens.is_empty() && (!clipped || boundary.is_some()), prefix, before: String::new(), partial: false, @@ -53,7 +51,6 @@ mod tests { fn unicode_boundaries_and_clipped_words() { let c = extract("Discard this. I can’t find cafe\u{301}", false); assert_eq!(c.prefix, "cafe\u{301}"); - assert!(extract("Done! ", false).sentence_start); assert_eq!( extract("Hi. I would like wa", false).before, "Hi. I would like " diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index 4e906891..42f1a02a 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -148,7 +148,7 @@ struct Service { reset: bool, accept: Option<(u64, usize)>, accepting: bool, - case: Option<(worker::Shift, bool, bool)>, + case: Option<(worker::Shift, bool, bool, bool)>, tracking: bool, } #[derive(Clone, Copy)] @@ -414,6 +414,7 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign keyboard.prediction_shift(), keyboard.caps, keyboard.modifiers[1..].iter().any(|m| *m != Modifier::Off), + keyboard.prediction_sentence_start(), ); if s.case != Some(case) { s.case = Some(case); @@ -534,6 +535,7 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign revision: s.edit_revision, shift: keyboard.prediction_shift(), caps: keyboard.caps, + sentence_start: keyboard.prediction_sentence_start(), } }; let now = Instant::now(); diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 85672da9..9b386f2a 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -45,6 +45,10 @@ pub enum Request { revision: u64, shift: Shift, caps: bool, + /// The keyboard's pending automatic capital, the one place that decides + /// a sentence start: it saw the punctuation, and whether the person + /// cancelled the capital since. + sentence_start: bool, }, Accept { generation: u64, @@ -111,7 +115,7 @@ pub struct Engine { batch: Option, suffixes: Vec, token: u64, - case: (Shift, bool), + case: (Shift, bool, bool), } impl Engine { pub fn new(database: Database, tracked: bool) -> Self { @@ -131,7 +135,7 @@ impl Engine { batch: None, suffixes: Vec::new(), token: 0, - case: (Shift::Off, false), + case: (Shift::Off, false, false), } } fn clear(&mut self) { @@ -174,6 +178,7 @@ impl Engine { revision: u64, shift: Shift, caps: bool, + sentence_start: bool, ) -> Option { if revision < self.revision || (revision == self.revision && !edits.is_empty()) { self.clear(); @@ -217,7 +222,7 @@ impl Engine { } self.status = self.database.status(); if self.snapshot.as_ref() == Some(&self.buffer) - && self.case == (shift, caps) + && self.case == (shift, caps, sentence_start) && self.status != Status::Loading { return self.batch.clone(); @@ -240,7 +245,7 @@ impl Engine { let suffix = cased( &word[offset..], ctx.prefix.is_empty(), - ctx.sentence_start, + sentence_start, shift, caps, ); @@ -251,7 +256,7 @@ impl Engine { self.clear(); return None; } - self.case = (shift, caps); + self.case = (shift, caps, sentence_start); self.snapshot = Some(self.buffer.clone()); self.batch = Some(Batch { token: self.token, @@ -279,8 +284,9 @@ impl Engine { revision, shift, caps, + sentence_start, } => { - let batch = self.query(edits, revision, shift, caps); + let batch = self.query(edits, revision, shift, caps, sentence_start); self.status = self.database.status(); let tracking = self .target @@ -436,26 +442,38 @@ mod tests { ); assert_eq!(interleave(w(&[]), w(&["a b"])), w(&["a b"])); let mut e = engine(); - let b = e.query(vec![append("wa")], 1, Shift::Off, false).unwrap(); + let b = e + .query(vec![append("wa")], 1, Shift::Off, false, false) + .unwrap(); assert_eq!(b.words, w(&["water", "water is", "waffle", "walk"])); assert_eq!(e.accept(b.token, 1).unwrap(), "ter is "); - let upper = e.query(vec![], 1, Shift::Off, true).unwrap(); + let upper = e.query(vec![], 1, Shift::Off, true, false).unwrap(); assert_eq!(upper.words[1], "waTER IS"); } #[test] fn first_letter_and_completion_chain_use_only_buffer() { let mut e = engine(); - let first = e.query(vec![append("w")], 1, Shift::Off, false).unwrap(); + let first = e + .query(vec![append("w")], 1, Shift::Off, false, false) + .unwrap(); assert!(first.words.iter().any(|w| w == "water")); - let b = e.query(vec![append("a")], 2, Shift::Off, false).unwrap(); + let b = e + .query(vec![append("a")], 2, Shift::Off, false, false) + .unwrap(); assert_eq!(b.words[0], "water"); let suffix = e.accept(b.token, 0).unwrap(); assert_eq!(suffix, "ter "); assert!(e.accept(b.token, 0).is_none()); - e.query(vec![append(&suffix)], 3, Shift::Off, false); + e.query(vec![append(&suffix)], 3, Shift::Off, false, false); assert_eq!(e.buffer, "water "); - e.query(vec![edit(Edit::Reset), append("wa")], 4, Shift::Off, false) - .unwrap(); + e.query( + vec![edit(Edit::Reset), append("wa")], + 4, + Shift::Off, + false, + false, + ) + .unwrap(); assert_eq!(e.buffer, "wa"); } #[test] @@ -464,26 +482,28 @@ mod tests { // A Switchify edit at 10 and external activity at 11 both precede // observer startup at 12. Only typing after startup is trustworthy. e.last_activity = || 12; - e.query(vec![append("wa")], 1, Shift::Off, false); + e.query(vec![append("wa")], 1, Shift::Off, false, false); assert!(e.buffer.is_empty()); let mut fresh = append("w"); fresh.time = 13; - assert!(e.query(vec![fresh], 2, Shift::Off, false).is_some()); + assert!(e.query(vec![fresh], 2, Shift::Off, false, false).is_some()); assert_eq!(e.buffer, "w"); } #[test] fn queued_edits_are_scoped_to_window_and_external_activity() { let mut e = engine(); - let b = e.query(vec![append("wa")], 1, Shift::Off, false).unwrap(); + let b = e + .query(vec![append("wa")], 1, Shift::Off, false, false) + .unwrap(); e.observe = || (1, true); e.last_activity = || 11; assert!(e.accept(b.token, 0).is_none()); let mut fresh = append("he"); fresh.time = 12; - e.query(vec![append("ter"), fresh], 2, Shift::Off, false); + e.query(vec![append("ter"), fresh], 2, Shift::Off, false, false); assert_eq!(e.buffer, "he"); e.foreground = || Ok(2); - e.query(vec![append("wa")], 3, Shift::Off, false); + e.query(vec![append("wa")], 3, Shift::Off, false, false); assert!(e.buffer.is_empty()); } #[test] @@ -498,22 +518,31 @@ mod tests { 1, Shift::Off, false, + false, ); assert_eq!(e.buffer, "wa"); for rev in 2..10 { - e.query(vec![append(&" word".repeat(90))], rev, Shift::Off, false); + e.query( + vec![append(&" word".repeat(90))], + rev, + Shift::Off, + false, + false, + ); } assert!(e.buffer.chars().count() <= 512); assert!(e.clipped); - e.query(vec![edit(Edit::Reset)], 10, Shift::Off, false); + e.query(vec![edit(Edit::Reset)], 10, Shift::Off, false, false); assert!(e.buffer.is_empty()); - e.query(vec![append("wa"); 513], 11, Shift::Off, false); + e.query(vec![append("wa"); 513], 11, Shift::Off, false, false); assert!(e.buffer.is_empty()); } #[test] fn stale_revisions_health_and_foreground_races_reject_acceptance() { let mut e = engine(); - let b = e.query(vec![append("wa")], 1, Shift::Off, false).unwrap(); + let b = e + .query(vec![append("wa")], 1, Shift::Off, false, false) + .unwrap(); assert!(matches!( e.respond(Request::Accept { generation: 0, @@ -524,93 +553,113 @@ mod tests { Response::Insert { text: None, .. } )); assert!(e.buffer.is_empty()); - e.query(vec![append("wa")], 2, Shift::Off, false); - assert!(e.query(vec![append("wa")], 2, Shift::Off, false).is_none()); - let b = e.query(vec![append("wa")], 3, Shift::Off, false).unwrap(); + e.query(vec![append("wa")], 2, Shift::Off, false, false); + assert!(e + .query(vec![append("wa")], 2, Shift::Off, false, false) + .is_none()); + let b = e + .query(vec![append("wa")], 3, Shift::Off, false, false) + .unwrap(); e.observe = || (0, false); assert!(e.accept(b.token, 0).is_none()); - assert!(e.query(vec![append("wa")], 4, Shift::Off, false).is_none()); + assert!(e + .query(vec![append("wa")], 4, Shift::Off, false, false) + .is_none()); } #[test] fn casing_and_unchanged_context_keep_choice_identity() { let mut e = engine(); - let b = e.query(vec![append("Wa")], 1, Shift::Off, false).unwrap(); + let b = e + .query(vec![append("Wa")], 1, Shift::Off, false, false) + .unwrap(); assert_eq!(b.words[0], "Water"); assert_eq!( - e.query(vec![], 1, Shift::Off, false).unwrap().token, + e.query(vec![], 1, Shift::Off, false, false).unwrap().token, b.token ); - let upper = e.query(vec![], 1, Shift::Off, true).unwrap(); + let upper = e.query(vec![], 1, Shift::Off, true, false).unwrap(); assert_eq!(upper.words[0], "WaTER"); assert_ne!(upper.token, b.token); } #[test] - fn sentence_start_prediction_uses_title_case_without_manual_shift() { + fn the_keyboard_alone_decides_a_sentence_start() { let mut e = engine(); - let suggestions = e - .query(vec![append("Done! ")], 1, Shift::Off, false) + // Punctuation in the buffer is not enough: the keyboard may have seen + // the person cancel the capital, or the buffer may have started + // mid-line after a reset. + let buffer_only = e + .query(vec![append("Done! ")], 1, Shift::Off, false, false) .unwrap(); + assert_eq!(buffer_only.words[0], "water"); + let suggestions = e.query(vec![], 1, Shift::Off, false, true).unwrap(); assert_eq!(suggestions.words[0], "Water"); - let locked = e.query(vec![], 1, Shift::Locked, false).unwrap(); + let locked = e.query(vec![], 1, Shift::Locked, false, true).unwrap(); assert_eq!(locked.words[0], "WATER"); - let once = e.query(vec![], 1, Shift::Once, false).unwrap(); + let once = e.query(vec![], 1, Shift::Once, false, true).unwrap(); assert_eq!(once.words[0], "Water"); // Modifiers still mirror typing at a sentence start: Caps with Shift // locked cancel to lowercase letters, and Shift once under Caps // lowers only the first one, exactly as the keys would type them. assert_eq!( - e.query(vec![], 1, Shift::Off, true).unwrap().words[0], + e.query(vec![], 1, Shift::Off, true, true).unwrap().words[0], "WATER" ); assert_eq!( - e.query(vec![], 1, Shift::Locked, true).unwrap().words[0], + e.query(vec![], 1, Shift::Locked, true, true).unwrap().words[0], "Water" ); assert_eq!( - e.query(vec![], 1, Shift::Once, true).unwrap().words[0], + e.query(vec![], 1, Shift::Once, true, true).unwrap().words[0], "wATER" ); } #[test] fn shift_once_changes_only_the_first_letter_and_only_before_typing() { let mut e = engine(); - e.query(vec![append("Send ")], 1, Shift::Off, false) + e.query(vec![append("Send ")], 1, Shift::Off, false, false) .unwrap(); assert_eq!( - e.query(vec![], 1, Shift::Off, false).unwrap().words[0], + e.query(vec![], 1, Shift::Off, false, false).unwrap().words[0], "water" ); assert_eq!( - e.query(vec![], 1, Shift::Once, false).unwrap().words[0], + e.query(vec![], 1, Shift::Once, false, false).unwrap().words[0], "Water" ); assert_eq!( - e.query(vec![], 1, Shift::Locked, false).unwrap().words[0], + e.query(vec![], 1, Shift::Locked, false, false) + .unwrap() + .words[0], "WATER" ); assert_eq!( - e.query(vec![], 1, Shift::Off, true).unwrap().words[0], + e.query(vec![], 1, Shift::Off, true, false).unwrap().words[0], "WATER" ); assert_eq!( - e.query(vec![], 1, Shift::Locked, true).unwrap().words[0], + e.query(vec![], 1, Shift::Locked, true, false) + .unwrap() + .words[0], "water" ); assert_eq!( - e.query(vec![], 1, Shift::Once, true).unwrap().words[0], + e.query(vec![], 1, Shift::Once, true, false).unwrap().words[0], "wATER" ); assert_eq!( - e.query(vec![], 1, Shift::Off, false).unwrap().words[4], + e.query(vec![], 1, Shift::Off, false, false).unwrap().words[4], "WhatsApp" ); - e.query(vec![append("wa")], 2, Shift::Off, false).unwrap(); + e.query(vec![append("wa")], 2, Shift::Off, false, false) + .unwrap(); assert_eq!( - e.query(vec![], 2, Shift::Once, false).unwrap().words[0], + e.query(vec![], 2, Shift::Once, false, false).unwrap().words[0], "water" ); assert_eq!( - e.query(vec![], 2, Shift::Locked, false).unwrap().words[0], + e.query(vec![], 2, Shift::Locked, false, false) + .unwrap() + .words[0], "waTER" ); } @@ -623,6 +672,7 @@ mod tests { edits: vec![append(&"x".repeat(LIMIT))], shift: Shift::Off, caps: false, + sentence_start: false, }; assert!(send(&mut Vec::new(), &request).is_err()); } diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 65f1dc72..6c041d71 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -472,6 +472,11 @@ impl Keyboard { Modifier::Locked => Shift::Locked, } } + /// Whether the next word starts a sentence, as the keyboard knows it: a + /// sentence mark it typed, not yet followed by a letter or cancelled. + pub fn prediction_sentence_start(&self) -> bool { + self.capitalize_next + } fn clear_auto_capital(&mut self) { if self.auto_shift { self.modifiers[0] = Modifier::Off;