From 9d8c150c46103fc56bc9dfa4fcc69475c0408eb5 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 27 Sep 2026 13:18:50 +0100 Subject: [PATCH 1/3] Add a casing matrix test and a casing section in the docs One table-driven test covers every combination of typed prefix, Shift, Caps and sentence start for a word, plus the name cases that retype a prefix, keep a typed capital or continue in capitals. The expected label, deletion count and typed text are written out by hand, so the table documents the behaviour rather than restating the rule. The word prediction page gains a Casing section that sets out the three steps that decide case, the same table, and the bounds on retyping a prefix. Closes #913 Co-Authored-By: Claude Fable 5.1 --- docs/word-prediction.md | 42 ++++++++++++++- src-tauri/src/prediction/worker.rs | 86 ++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/docs/word-prediction.md b/docs/word-prediction.md index 8055199b..032b1cdb 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -14,7 +14,47 @@ 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. Accepting restores a capital the typed prefix lacks by retyping that prefix: the prefix is text Switchify typed itself in the current window with no outside activity since, it is never more than one word, and the executor refuses more than 32 deletions. A capital the person typed is kept, a word typed in capitals is never retyped, and when the case already matches nothing is deleted. The label always reads as the text will after acceptance. Any keyboard or mouse activity not made by Switchify between choosing the suggestion and typing it cancels the acceptance, so nothing else is deleted. Deleting and typing cannot be atomic: if typing fails after the deletion, the prefix is lost and prediction context resets. The deletion assumes one Backspace removes one typed character; an application that auto-pairs or autocorrects what was typed can break that, and Switchify cannot verify what an application accepted. 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. A typed prefix of two or more letters, all capitals, is completed in capitals whatever the modifiers now say. Otherwise 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. +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”. 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. + +## Casing + +The label on a suggestion always reads as the text will after it is accepted. Casing is decided in three steps. + +**1. The word itself.** Ordinary words are 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. The model does that for proper nouns and occasionally a rare word. At a sentence start the model capitalises every word, so the lowercase form wins there and step 3 decides. The pronoun I and its contractions, such as I’m and I’ll, always have a capital I. + +**2. The letters already typed.** A capital the person typed is kept. A typed prefix of two or more letters, all capitals, is a word being written in capitals, and is completed in capitals whatever the modifiers say. Where the word has a capital and a lowercase letter was typed, accepting restores the capital by retyping the prefix, described below. + +**3. The modifiers, for letters not yet typed.** Caps, or Shift locked, uppercases the rest of the word, 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 the first letter without any modifier. After typed letters, a pending Shift once is left for the next letter and does not touch the completion. + +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. At the true start of a document the keyboard has no such signal, so the first word is not capitalised automatically; Shift once capitalises it. + +For the word “water”: + +| Typed | Shift | Caps | Sentence start | Suggestion | +|---|---|---|---|---| +| nothing | off | off | no | water | +| nothing | off | off | yes | Water | +| nothing | once | off | either | Water | +| nothing | locked | off | either | WATER | +| nothing | off | on | either | WATER | +| nothing | once | on | either | wATER | +| nothing | locked | on | no | water | +| nothing | locked | on | yes | Water | +| wa | off or once | off | either | water | +| wa | locked | off | either | waTER | +| wa | off or once | on | either | waTER | +| wa | locked | on | either | water | +| Wa | off or once | off | either | Water | +| Wa | locked | off | either | WaTER | +| WA | any | any | either | WATER | + +### Retyping the prefix + +Accepting restores a capital the typed prefix lacks by backspacing that prefix and typing the whole word, as in “lon” for London, “i” for I’m or “wh” for WhatsApp. When the case already matches, which is the usual case, nothing is deleted and only the missing suffix is typed. + +The deletion is bounded. The prefix is text Switchify typed itself in the current window with no outside activity since. It is never more than one word, and the executor refuses more than 32 deletions. Any keyboard or mouse activity not made by Switchify between choosing the suggestion and typing it cancels the acceptance, so nothing else is deleted. + +Two things cannot be guaranteed. Deleting and typing cannot be atomic: if typing fails after the deletion, the prefix is lost and prediction context resets. The deletion also assumes one Backspace removes one typed character; an application that auto-pairs or autocorrects what was typed can break that, and Switchify cannot verify what an application accepted. 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/worker.rs b/src-tauri/src/prediction/worker.rs index b3bb6ec6..44afec45 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -825,6 +825,92 @@ mod tests { "waTER" ); } + /// Every combination of typed prefix, Shift, Caps and sentence start for + /// one word, with the expected label and insert written out by hand so + /// the table documents the behaviour rather than restating the rule. + #[test] + fn casing_matrix() { + use Shift::{Locked, Off, Once}; + const EITHER: [bool; 2] = [false, true]; + // (typed, shift, caps, sentence starts, label, deleted, typed text) + type Row = ( + &'static str, + Shift, + bool, + &'static [bool], + &'static str, + usize, + &'static str, + ); + let rows: &[Row] = &[ + // Nothing of the word typed yet: the whole word is cased. + ("", Off, false, &[false], "water", 0, "water "), + ("", Off, false, &[true], "Water", 0, "Water "), + ("", Once, false, &EITHER, "Water", 0, "Water "), + ("", Locked, false, &EITHER, "WATER", 0, "WATER "), + ("", Off, true, &EITHER, "WATER", 0, "WATER "), + ("", Once, true, &EITHER, "wATER", 0, "wATER "), + ("", Locked, true, &[false], "water", 0, "water "), + ("", Locked, true, &[true], "Water", 0, "Water "), + // Lowercase letters typed: modifiers case the rest only, and a + // pending Shift once is left for the next typed letter. + ("wa", Off, false, &EITHER, "water", 0, "ter "), + ("wa", Once, false, &EITHER, "water", 0, "ter "), + ("wa", Locked, false, &EITHER, "waTER", 0, "TER "), + ("wa", Off, true, &EITHER, "waTER", 0, "TER "), + ("wa", Once, true, &EITHER, "waTER", 0, "TER "), + ("wa", Locked, true, &EITHER, "water", 0, "ter "), + // A capital the person typed is kept. + ("Wa", Off, false, &EITHER, "Water", 0, "ter "), + ("Wa", Once, false, &EITHER, "Water", 0, "ter "), + ("Wa", Locked, false, &EITHER, "WaTER", 0, "TER "), + ("Wa", Off, true, &EITHER, "WaTER", 0, "TER "), + ("Wa", Locked, true, &EITHER, "Water", 0, "ter "), + // A word typed in capitals continues in capitals. + ("WA", Off, false, &EITHER, "WATER", 0, "TER "), + ("WA", Once, false, &EITHER, "WATER", 0, "TER "), + ("WA", Locked, false, &EITHER, "WATER", 0, "TER "), + ("WA", Off, true, &EITHER, "WATER", 0, "TER "), + ("WA", Once, true, &EITHER, "WATER", 0, "TER "), + ("WA", Locked, true, &EITHER, "WATER", 0, "TER "), + // A name restores its capital by retyping a lowercase prefix, + // keeps a typed capital, and is never retyped in capitals. + ("wh", Off, false, &EITHER, "WhatsApp", 2, "WhatsApp "), + ("wh", Once, false, &EITHER, "WhatsApp", 2, "WhatsApp "), + ("Wh", Off, false, &EITHER, "WhatsApp", 0, "atsApp "), + ("WH", Off, false, &EITHER, "WHATSAPP", 0, "ATSAPP "), + ]; + let mut e = engine(); + let mut revision = 0; + let mut typed = None; + for &(prefix, shift, caps, starts, label, deleted, text) in rows { + if typed != Some(prefix) { + typed = Some(prefix); + revision += 1; + // "Send " leaves a buffer whose current word is empty. + let buffer = format!("Send {prefix}"); + e.query( + vec![edit(Edit::Reset), append(&buffer)], + revision, + Off, + false, + false, + ); + } + for &sentence_start in starts { + let case = format!("{prefix:?} {shift:?} caps={caps} start={sentence_start}"); + let row = e + .query(vec![], revision, shift, caps, sentence_start) + .unwrap_or_else(|| panic!("no suggestions for {case}")); + assert_eq!(row.words[0], label, "label for {case}"); + assert_eq!( + e.accept(row.token, 0), + Some((deleted, text.to_owned())), + "insert for {case}" + ); + } + } + } #[test] fn private_frames_are_bounded() { assert!(receive::(&mut &b"bad!"[..]).is_err()); From f71a24e56f53a5dbc7da9f75def20360f75bf2ff Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 27 Sep 2026 13:22:54 +0100 Subject: [PATCH 2/3] Complete the casing table and restore two dropped statements The docs table gains the rows for a typed capital with Caps on, the retyping section says again that a typed capital is kept and a word in capitals is never retyped, and the wording about the model's capital after a sentence mark says what actually happens. The matrix gains the one missing row. Co-Authored-By: Claude Fable 5.1 --- docs/word-prediction.md | 8 +++++--- src-tauri/src/prediction/worker.rs | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/word-prediction.md b/docs/word-prediction.md index 032b1cdb..5e0ffde3 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -20,11 +20,11 @@ Candidates contain ASCII letters and apostrophes and must have sufficient model The label on a suggestion always reads as the text will after it is accepted. Casing is decided in three steps. -**1. The word itself.** Ordinary words are 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. The model does that for proper nouns and occasionally a rare word. At a sentence start the model capitalises every word, so the lowercase form wins there and step 3 decides. The pronoun I and its contractions, such as I’m and I’ll, always have a capital I. +**1. The word itself.** Ordinary words are 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. The model does that for proper nouns and occasionally a rare word. After a full stop, exclamation or question mark in the text the model read, the model capitalises every word, so its capital says nothing about the word and the lowercase form is used; step 3 then decides the first letter. The pronoun I and its contractions, such as I’m and I’ll, always have a capital I. **2. The letters already typed.** A capital the person typed is kept. A typed prefix of two or more letters, all capitals, is a word being written in capitals, and is completed in capitals whatever the modifiers say. Where the word has a capital and a lowercase letter was typed, accepting restores the capital by retyping the prefix, described below. -**3. The modifiers, for letters not yet typed.** Caps, or Shift locked, uppercases the rest of the word, 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 the first letter without any modifier. After typed letters, a pending Shift once is left for the next letter and does not touch the completion. +**3. The modifiers, for letters not yet typed.** Caps, or Shift locked, uppercases the rest of the word, 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, so under Caps it gives a lowercase first letter. A sentence start capitalises the first letter without any modifier. After typed letters, a pending Shift once is left for the next letter and does not touch the completion. 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. At the true start of a document the keyboard has no such signal, so the first word is not capitalised automatically; Shift once capitalises it. @@ -46,11 +46,13 @@ For the word “water”: | wa | locked | on | either | water | | Wa | off or once | off | either | Water | | Wa | locked | off | either | WaTER | +| Wa | off or once | on | either | WaTER | +| Wa | locked | on | either | Water | | WA | any | any | either | WATER | ### Retyping the prefix -Accepting restores a capital the typed prefix lacks by backspacing that prefix and typing the whole word, as in “lon” for London, “i” for I’m or “wh” for WhatsApp. When the case already matches, which is the usual case, nothing is deleted and only the missing suffix is typed. +Accepting restores a capital the typed prefix lacks by backspacing that prefix and typing the whole word, as in “lon” for London, “i” for I’m or “wh” for WhatsApp. When the case already matches, which is the usual case, nothing is deleted and only the missing suffix is typed. A capital the person typed is kept, and a word typed in capitals is never retyped. The deletion is bounded. The prefix is text Switchify typed itself in the current window with no outside activity since. It is never more than one word, and the executor refuses more than 32 deletions. Any keyboard or mouse activity not made by Switchify between choosing the suggestion and typing it cancels the acceptance, so nothing else is deleted. diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 44afec45..14ac462f 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -826,8 +826,9 @@ mod tests { ); } /// Every combination of typed prefix, Shift, Caps and sentence start for - /// one word, with the expected label and insert written out by hand so - /// the table documents the behaviour rather than restating the rule. + /// one word, then the name cases with Caps off. The expected label and + /// insert are written out by hand, so the table documents the behaviour + /// rather than restating the rule. #[test] fn casing_matrix() { use Shift::{Locked, Off, Once}; @@ -865,6 +866,7 @@ mod tests { ("Wa", Once, false, &EITHER, "Water", 0, "ter "), ("Wa", Locked, false, &EITHER, "WaTER", 0, "TER "), ("Wa", Off, true, &EITHER, "WaTER", 0, "TER "), + ("Wa", Once, true, &EITHER, "WaTER", 0, "TER "), ("Wa", Locked, true, &EITHER, "Water", 0, "ter "), // A word typed in capitals continues in capitals. ("WA", Off, false, &EITHER, "WATER", 0, "TER "), From 1a0d09a02c31ca7e20ab604f76e66cd2ed678f3c Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 27 Sep 2026 13:24:59 +0100 Subject: [PATCH 3/3] Say the model's capital is also ignored when it has no text Co-Authored-By: Claude Fable 5.1 --- docs/word-prediction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/word-prediction.md b/docs/word-prediction.md index 5e0ffde3..4cb680a8 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -20,7 +20,7 @@ Candidates contain ASCII letters and apostrophes and must have sufficient model The label on a suggestion always reads as the text will after it is accepted. Casing is decided in three steps. -**1. The word itself.** Ordinary words are 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. The model does that for proper nouns and occasionally a rare word. After a full stop, exclamation or question mark in the text the model read, the model capitalises every word, so its capital says nothing about the word and the lowercase form is used; step 3 then decides the first letter. The pronoun I and its contractions, such as I’m and I’ll, always have a capital I. +**1. The word itself.** Ordinary words are 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. The model does that for proper nouns and occasionally a rare word. After a full stop, exclamation or question mark in the text the model read, or when that text is empty, the model capitalises every word, so its capital says nothing about the word and the lowercase form is used; step 3 then decides the first letter. The pronoun I and its contractions, such as I’m and I’ll, always have a capital I. **2. The letters already typed.** A capital the person typed is kept. A typed prefix of two or more letters, all capitals, is a word being written in capitals, and is completed in capitals whatever the modifiers say. Where the word has a capital and a lowercase letter was typed, accepting restores the capital by retyping the prefix, described below.