From 90bde1d507d08d783e07b2ea966bff06c01e0db7 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 19 Sep 2026 15:56:48 +0100 Subject: [PATCH 1/4] spike: use bounded native prediction providers with database fallback --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 4 +- src-tauri/src/lib.rs | 6 + src-tauri/src/prediction/mod.rs | 1 + src-tauri/src/prediction/provider.rs | 414 +++++++++++++++++++++++++++ src-tauri/src/prediction/worker.rs | 82 ++++-- 6 files changed, 489 insertions(+), 19 deletions(-) create mode 100644 src-tauri/src/prediction/provider.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6a379340..1a5bf7ae 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4101,6 +4101,7 @@ dependencies = [ "unicode-segmentation", "uuid", "windows 0.62.2", + "windows-collections 0.3.2", "windows-sys 0.61.2", "winreg 0.55.0", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index de474cd6..f22c5648 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,13 +43,14 @@ core-graphics = { version = "0.25", features = ["highsierra"] } libc = "0.2" objc2 = "0.6.4" objc2-quartz-core = { version = "0.3.2", default-features = false, features = ["CALayer", "objc2-core-foundation", "objc2-core-graphics"] } -objc2-app-kit = { version = "0.3.2", features = ["NSBitmapImageRep", "NSColor", "NSControl", "NSEvent", "NSGraphics", "NSImage", "NSImageRep", "NSImageView", "NSPanel", "NSTextField", "NSFont", "NSResponder", "NSScreen", "NSView", "NSWindow", "NSWorkspace", "NSRunningApplication", "objc2-core-foundation"] } +objc2-app-kit = { version = "0.3.2", features = ["NSBitmapImageRep", "NSColor", "NSControl", "NSEvent", "NSGraphics", "NSImage", "NSImageRep", "NSImageView", "NSPanel", "NSTextField", "NSFont", "NSResponder", "NSScreen", "NSView", "NSWindow", "NSWorkspace", "NSRunningApplication", "NSSpellChecker", "objc2-core-foundation"] } objc2-core-graphics = { version = "0.3.2", default-features = false, features = ["CGEventSource", "CGEventTypes"] } objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSGeometry", "NSHost", "NSNotification", "NSObject", "NSOperation", "NSString", "NSThread", "NSURL", "block2"] } [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Security", "Win32_System_JobObjects", "Win32_System_SystemInformation", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] } windows = { version = "0.62.2", features = [ + "Data_Text", "Devices_Bluetooth", "Devices_Bluetooth_GenericAttributeProfile", "Devices_Radios", @@ -74,6 +75,7 @@ windows = { version = "0.62.2", features = [ "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging" ] } +windows-collections = "0.3.2" winreg = "0.55" [patch.crates-io] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 89770a53..6146fe71 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1362,6 +1362,12 @@ fn point_scan_prepare(app: &AppHandle) -> Result<(), String> { } pub fn run() { + if prediction::provider::probe_from_args() { + return; + } + if prediction::provider::run_from_args() { + return; + } if prediction::worker::run_from_args() { return; } diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index d8836f52..b07d219a 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -3,6 +3,7 @@ mod context; mod database; #[cfg(target_os = "macos")] mod macos; +pub mod provider; #[cfg(target_os = "windows")] mod windows; pub mod worker; diff --git a/src-tauri/src/prediction/provider.rs b/src-tauri/src/prediction/provider.rs new file mode 100644 index 00000000..3bc7c756 --- /dev/null +++ b/src-tauri/src/prediction/provider.rs @@ -0,0 +1,414 @@ +use super::{context::Context, database::Database, worker}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + process::{Child, Command, Stdio}, + sync::mpsc, + time::Duration, +}; +use unicode_segmentation::UnicodeSegmentation; + +pub const ARG: &str = "--switchify-native-prediction-worker"; +const BUDGET: Duration = Duration::from_millis(600); + +pub trait Provider { + fn predict(&mut self, context: &Context, before: &str) -> Result, ()>; +} +impl Provider for Database { + fn predict(&mut self, context: &Context, _: &str) -> Result, ()> { + Database::predict(self, context) + } +} +pub struct WithFallback { + pub native: N, + pub fallback: F, +} +impl Provider for WithFallback { + fn predict(&mut self, context: &Context, before: &str) -> Result, ()> { + let words = usable( + self.native.predict(context, before).unwrap_or_default(), + &context.prefix, + ); + if !words.is_empty() { + return Ok(words); + } + self.fallback + .predict(context, before) + .map(|words| usable(words, &context.prefix)) + } +} +pub fn usable(words: Vec, prefix: &str) -> Vec { + let mut seen = HashSet::new(); + words + .into_iter() + .take(64) + .filter_map(|word| { + if word.contains(['\n', '\r']) { + return None; + } + let word = word.trim(); + let offset = word + .char_indices() + .nth(prefix.chars().count()) + .map_or(word.len(), |(i, _)| i); + let folded = word.to_lowercase(); + (!word.is_empty() + && word.chars().count() <= 48 + && word.unicode_words().next() == Some(word) + && word[..offset].to_lowercase() == prefix.to_lowercase() + && seen.insert(folded)) + .then(|| word.to_owned()) + }) + .take(5) + .collect() +} +#[derive(Serialize, Deserialize)] +struct Input { + before: String, + prefix: String, + words: Vec, +} +impl Input { + fn valid(&self) -> bool { + self.before.chars().count() <= 512 + && self.prefix.chars().count() <= 512 + && self.before.ends_with(&self.prefix) + && self.words.len() <= 3 + && self.words.iter().all(|w| w.chars().count() <= 512) + } +} +struct OwnedChild(Child); +impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +pub struct Native; +impl Provider for Native { + fn predict(&mut self, context: &Context, before: &str) -> Result, ()> { + let input = Input { + before: before.to_owned(), + prefix: context.prefix.clone(), + words: context.words.clone(), + }; + let mut command = Command::new(std::env::current_exe().map_err(|_| ())?); + command.arg(ARG); + run(command, input, BUDGET) + } +} +fn run(mut command: Command, input: Input, budget: Duration) -> Result, ()> { + if !input.valid() { + return Err(()); + } + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let mut child = OwnedChild(command.spawn().map_err(|_| ())?); + #[cfg(target_os = "windows")] + let _job = super::Job::contain(&child.0)?; + let mut input_pipe = child.0.stdin.take().ok_or(())?; + let mut output = child.0.stdout.take().ok_or(())?; + let (sender, receiver) = mpsc::sync_channel(1); + let reader = std::thread::spawn(move || { + let result = worker::send(&mut input_pipe, &input) + .and_then(|()| worker::receive::>(&mut output)); + let _ = sender.send(result); + }); + let result = receiver + .recv_timeout(budget) + .map_err(|_| ()) + .and_then(|r| r); + drop(child); + let _ = reader.join(); + result +} +pub fn probe_from_args() -> bool { + if std::env::args().nth(1).as_deref() != Some("--switchify-native-prediction-probe") { + return false; + } + let _ = (|| -> Result<(), ()> { + let path = std::env::args_os().nth(2).ok_or(())?; + let database = Database::open(std::path::Path::new(&path))?; + let mode = std::env::args() + .nth(3) + .unwrap_or_else(|| "unspecified".into()); + let mut native = Native; + for repetition in 0..5 { + for before in [ + "hel", + "wat", + "I want some wa", + "Thank you ", + "How are ", + "I would like ", + ] { + let context = super::context::extract(before, false); + let start = std::time::Instant::now(); + let result = native.predict(&context, before); + let native_ms = start.elapsed().as_secs_f64() * 1000.0; + let error = result.is_err(); + let words = usable(result.unwrap_or_default(), &context.prefix); + let start = std::time::Instant::now(); + let fallback = database.predict(&context)?; + let database_ms = start.elapsed().as_secs_f64() * 1000.0; + println!( + "{}", + serde_json::json!({"mode":mode,"repetition":repetition,"text":before,"native":words,"database":fallback,"native_error":error,"fallback_used":words.is_empty(),"native_ms":native_ms,"database_ms":database_ms}) + ); + } + } + Ok(()) + })(); + true +} + +pub fn run_from_args() -> bool { + if std::env::args_os().nth(1).as_deref() != Some(std::ffi::OsStr::new(ARG)) { + return false; + } + worker::watch_parent(); + let _ = (|| -> Result<(), ()> { + let input: Input = worker::receive(&mut std::io::stdin().lock())?; + if !input.valid() { + return Err(()); + } + let words = platform(&input)?; + worker::send(&mut std::io::stdout().lock(), &words) + })(); + true +} +#[cfg(target_os = "macos")] +fn platform(input: &Input) -> Result, ()> { + use objc2::MainThreadMarker; + use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSSpellChecker}; + use objc2_foundation::{NSRange, NSString}; + let marker = MainThreadMarker::new().ok_or(())?; + NSApplication::sharedApplication(marker) + .setActivationPolicy(NSApplicationActivationPolicy::Prohibited); + let checker = NSSpellChecker::sharedSpellChecker(); + let available = checker.availableLanguages(); + let language = if available.iter().any(|s| s.to_string() == "en_GB") { + "en_GB" + } else { + "en" + }; + let text = NSString::from_str(&input.before); + let prefix_length = input.prefix.encode_utf16().count(); + let range = NSRange::new( + input.before.encode_utf16().count() - prefix_length, + prefix_length, + ); + let tag = NSSpellChecker::uniqueSpellDocumentTag(); + let result = checker.completionsForPartialWordRange_inString_language_inSpellDocumentWithTag( + range, + &text, + Some(&NSString::from_str(language)), + tag, + ); + let words = result + .map(|words| words.iter().take(64).map(|s| s.to_string()).collect()) + .unwrap_or_default(); + checker.closeSpellDocumentWithTag(tag); + Ok(usable(words, &input.prefix)) +} +#[cfg(target_os = "windows")] +fn platform(input: &Input) -> Result, ()> { + use windows::{ + core::HSTRING, + Data::Text::{TextPredictionGenerator, TextPredictionOptions}, + Win32::System::WinRT::{RoInitialize, RO_INIT_MULTITHREADED}, + }; + use windows_collections::IIterable; + unsafe { + RoInitialize(RO_INIT_MULTITHREADED).map_err(|_| ())?; + } + locales(&["en-GB", "en-US"], &input.prefix, |language| { + let generator = + TextPredictionGenerator::Create(&HSTRING::from(language)).map_err(|_| ())?; + let prior: IIterable = input + .words + .iter() + .map(HSTRING::from) + .collect::>() + .into(); + let result = if input.prefix.is_empty() { + generator.GetNextWordCandidatesAsync(10, &prior) + } else { + generator.GetCandidatesWithParametersAsync( + &HSTRING::from(&input.prefix), + 10, + TextPredictionOptions::Predictions, + &prior, + ) + } + .and_then(|op| op.join()) + .map_err(|_| ())?; + (0..result.Size().map_err(|_| ())?.min(64)) + .map(|i| result.GetAt(i).map(|s| s.to_string()).map_err(|_| ())) + .collect() + }) +} +#[cfg(any(target_os = "windows", test))] +fn locales( + mut languages: &[&str], + prefix: &str, + mut predict: impl FnMut(&str) -> Result, ()>, +) -> Result, ()> { + while let Some((language, rest)) = languages.split_first() { + let words = usable(predict(language).unwrap_or_default(), prefix); + if !words.is_empty() { + return Ok(words); + } + languages = rest; + } + Ok(Vec::new()) +} +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn platform(_: &Input) -> Result, ()> { + Err(()) +} + +#[cfg(test)] +mod tests { + use super::*; + struct Fake { + result: Result, ()>, + calls: usize, + } + impl Provider for Fake { + fn predict(&mut self, _: &Context, _: &str) -> Result, ()> { + self.calls += 1; + self.result.clone() + } + } + fn fake(words: &[&str]) -> Fake { + Fake { + result: Ok(words.iter().map(|s| s.to_string()).collect()), + calls: 0, + } + } + #[test] + fn native_ranking_filtering_and_fallback() { + let mut provider = WithFallback { + native: fake(&["water", "walk", "water", "wrong", "wa\nter", "water melon"]), + fallback: fake(&["want"]), + }; + let context = super::super::context::extract("I want wa", false); + assert_eq!( + provider.predict(&context, "I want wa").unwrap(), + ["water", "walk"] + ); + assert_eq!(provider.fallback.calls, 0); + provider.native.result = Err(()); + assert_eq!(provider.predict(&context, "I want wa").unwrap(), ["want"]); + assert_eq!(provider.fallback.calls, 1); + provider.native = fake(&["", "\n", "wrong", " "]); + assert_eq!(provider.predict(&context, "I want wa").unwrap(), ["want"]); + provider.fallback.result = Err(()); + assert!(provider.predict(&context, "I want wa").is_err()); + } + #[test] + fn candidate_bounds_and_unicode_prefixes() { + assert_eq!( + usable( + vec![ + " Café ".into(), + "CAFE".into(), + "café".into(), + "café\n".into() + ], + "Caf" + ), + ["Café", "CAFE"] + ); + assert!(usable(vec!["a".repeat(49), "two words".into(), "a\0".into()], "").is_empty()); + assert_eq!( + usable( + vec![ + "for".into(), + "so".into(), + "again".into(), + "and".into(), + "very".into(), + "much".into() + ], + "" + ) + .len(), + 5 + ); + assert_eq!(usable(vec!["can't".into()], "can"), ["can't"]); + } + #[test] + fn retry_us_only_when_uk_is_unusable() { + let mut tried = Vec::new(); + let words = locales(&["en-GB", "en-US"], "wa", |lang| { + tried.push(lang.to_owned()); + Ok(if lang == "en-GB" { + vec!["wrong".into()] + } else { + vec!["water".into()] + }) + }) + .unwrap(); + assert_eq!(tried, ["en-GB", "en-US"]); + assert_eq!(words, ["water"]); + let mut count = 0; + assert_eq!( + locales(&["en-GB", "en-US"], "", |_| { + count += 1; + Ok(vec!["for".into()]) + }) + .unwrap(), + ["for"] + ); + assert_eq!(count, 1); + } + #[test] + fn hung_native_process_is_stopped_before_fallback_deadline() { + #[cfg(target_os = "windows")] + let command = { + let mut c = Command::new("powershell.exe"); + c.args(["-NoProfile", "-Command", "Start-Sleep -Seconds 10"]); + c + }; + #[cfg(not(target_os = "windows"))] + let command = { + let mut c = Command::new("/bin/sleep"); + c.arg("10"); + c + }; + let began = std::time::Instant::now(); + let result = run( + command, + Input { + before: "wa".into(), + prefix: "wa".into(), + words: Vec::new(), + }, + Duration::from_millis(100), + ); + assert!(result.is_err()); + assert!(began.elapsed() < Duration::from_secs(2)); + } + #[test] + fn invalid_context_never_starts_a_native_helper() { + let result = run( + Command::new("nonexistent-probe"), + Input { + before: "hello".into(), + prefix: "wrong".into(), + words: Vec::new(), + }, + BUDGET, + ); + assert!(result.is_err()); + } +} diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 6cd4a909..21f7e415 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -2,6 +2,7 @@ use super::{ activity, context::{self, Adapter, RawContext, Status}, database::Database, + provider::{Native, Provider, WithFallback}, }; use serde::{Deserialize, Serialize}; use std::{ @@ -66,9 +67,9 @@ pub fn receive(reader: &mut impl Read) -> Result reader.read_exact(&mut bytes).map_err(|_| ())?; serde_json::from_slice(&bytes).map_err(|_| ()) } -pub struct Engine { +pub struct Engine { adapter: A, - database: Database, + provider: P, target: Option, snapshot: Option, fallback: String, @@ -81,11 +82,11 @@ pub struct Engine { token: u64, case: (bool, bool), } -impl Engine { - pub fn new(adapter: A, database: Database, tracked: bool) -> Self { +impl Engine { + pub fn new(adapter: A, provider: P, tracked: bool) -> Self { Self { adapter, - database, + provider, tracked, target: None, snapshot: None, @@ -191,18 +192,22 @@ impl Engine { self.clear(); return None; } - self.target = Some(target); if self.snapshot.as_ref() == Some(&raw) && self.case == (shift, caps) { return self.batch.clone(); } let ctx = context::extract(&raw.before, raw.clipped_start); - let words = match self.database.predict(&ctx) { + let words = match self.provider.predict(&ctx, &raw.before) { Ok(w) => w, Err(_) => { self.clear(); return None; } }; + if !self.stable(&target, epoch) { + self.clear(); + return None; + } + self.target = Some(target); self.token = self.token.wrapping_add(1); self.suffixes.clear(); let mut labels = Vec::new(); @@ -316,6 +321,19 @@ impl Engine { } } } +pub(super) fn watch_parent() { + #[cfg(target_os = "macos")] + { + let parent = unsafe { libc::getppid() }; + std::thread::spawn(move || loop { + std::thread::sleep(std::time::Duration::from_millis(250)); + if unsafe { libc::getppid() } != parent { + std::process::exit(0); + } + }); + } +} + pub fn run_from_args() -> bool { let args: Vec<_> = std::env::args_os().collect(); if args.get(1).is_none_or(|s| s != ARG) { @@ -330,16 +348,7 @@ pub fn run_from_args() -> bool { if ignored.len() > 129 { return Err(()); } - #[cfg(target_os = "macos")] - { - let parent = unsafe { libc::getppid() }; - std::thread::spawn(move || loop { - std::thread::sleep(std::time::Duration::from_millis(250)); - if unsafe { libc::getppid() } != parent { - std::process::exit(0); - } - }); - } + watch_parent(); let tracked = activity::start(ignored); #[cfg(target_os = "windows")] let adapter = super::windows::WindowsAdapter::new().map_err(|_| ())?; @@ -348,7 +357,14 @@ pub fn run_from_args() -> bool { #[cfg(any(target_os = "windows", target_os = "macos"))] { let database = Database::open(&path)?; - let mut engine = Engine::new(adapter, database, tracked); + let mut engine = Engine::new( + adapter, + WithFallback { + native: Native, + fallback: database, + }, + tracked, + ); let mut input = std::io::stdin().lock(); let mut output = std::io::stdout().lock(); while let Ok(request) = receive::(&mut input) { @@ -422,6 +438,36 @@ mod tests { e.activity = 0; e } + #[test] + fn native_provider_obeys_context_gates_caching_and_exactly_once_acceptance() { + struct Counting(std::rc::Rc>); + impl Provider for Counting { + fn predict(&mut self, _: &context::Context, _: &str) -> Result, ()> { + self.0.set(self.0.get() + 1); + Ok(vec!["water".into()]) + } + } + let calls = std::rc::Rc::new(std::cell::Cell::new(0)); + let mut e = Engine::new(Fake::new(), Counting(calls.clone()), true); + e.observe = || (0, true); + e.activity = 0; + let first = e.query(None, false, false, false).unwrap(); + let again = e.query(None, false, false, false).unwrap(); + assert_eq!(first.token, again.token); + assert_eq!(calls.get(), 1); + assert_eq!(e.accept(first.token, 0), Some("ter ".into())); + assert!(e.accept(first.token, 0).is_none()); + e.adapter.protected = true; + assert!(e.query(None, false, false, false).is_none()); + e.adapter.protected = false; + e.adapter.raw.has_selection = true; + assert!(e.query(None, false, false, false).is_none()); + e.adapter.raw.has_selection = false; + e.adapter.raw.after = "x".into(); + assert!(e.query(None, false, false, false).is_none()); + assert_eq!(calls.get(), 1); + } + #[test] fn only_suffix_and_space_are_returned_once() { let mut e = engine(); From eb6d288fa3abc9a6fc693ceadbf24d2c71b226db Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 19 Sep 2026 16:15:02 +0100 Subject: [PATCH 2/4] docs: record native prediction spike measurements and qualification gaps --- docs/native-prediction-spike/README.md | 84 +++++++++++++++++++ .../native-prediction-spike/mac-offline.jsonl | 30 +++++++ docs/native-prediction-spike/mac-online.jsonl | 30 +++++++ .../windows-offline.jsonl | 30 +++++++ .../windows-online.jsonl | 30 +++++++ 5 files changed, 204 insertions(+) create mode 100644 docs/native-prediction-spike/README.md create mode 100644 docs/native-prediction-spike/mac-offline.jsonl create mode 100644 docs/native-prediction-spike/mac-online.jsonl create mode 100644 docs/native-prediction-spike/windows-offline.jsonl create mode 100644 docs/native-prediction-spike/windows-online.jsonl diff --git a/docs/native-prediction-spike/README.md b/docs/native-prediction-spike/README.md new file mode 100644 index 00000000..c45737a9 --- /dev/null +++ b/docs/native-prediction-spike/README.md @@ -0,0 +1,84 @@ +# Native prediction spike (#797 / draft #798) + +## Recommendation + +Keep both providers experimental. Mac is the stronger candidate for a later production trial: this VM produced relevant UK-English completions and next words with modest process overhead. Windows also returned usable results after the locale retry/filtering path, but needs broader language-pack and quality coverage. Neither is qualified for the shipping default: switch-driven insertion and suggestion-row timing have not been validated interactively in this run, and the six-sentence corpus is too small to establish quality or reliability. + +The implementation is stacked on PR #796, `codex/word-prediction-795`, at `e5ee2a598b062640d311ae8e6c45de2729127b7d`. The VM binaries and raw results below were built from implementation commit `90bde1d507d08d783e07b2ea966bff06c01e0db7`. No schema, BLE, settings UI, or provider selector changes are included. Native-first is the default only in this experimental branch when the existing word-prediction setting is enabled. + +## Measurements + +2026-09-19, ARM64 VMs: macOS 26.6.2 (25G83), Windows 11 Pro 10.0.26200. Each mode ran six fixed synthetic contexts five times. Each native request starts the same executable in native-helper mode, measures IPC and process startup as well as the native API, then measures the database on the same context. These are warm-machine debug-build measurements, not cold-boot or release benchmarks. Each query uses a fresh helper; identical-context caching is deliberately bypassed by the comparison command. + +| Platform / mode | Queries | Empty usable results | Native errors | DB fallback | Native median / p95 / max (ms) | DB median / p95 (ms) | +|---|---:|---:|---:|---:|---|---| +| mac / online | 30 | 0 | 0 | 0 | 52.6 / 61.0 / 93.1 | 0.38 / 18.16 | +| mac / offline | 30 | 0 | 0 | 0 | 54.2 / 59.6 / 60.1 | 0.56 / 18.22 | +| windows / online | 30 | 0 | 0 | 0 | 148.2 / 151.4 / 157.8 | 0.68 / 21.92 | +| windows / offline | 30 | 0 | 0 | 0 | 148.5 / 167.9 / 178.5 | 0.65 / 24.19 | + +Raw, synthetic-only measurements: [Mac online](mac-online.jsonl), [Mac offline](mac-offline.jsonl), [Windows online](windows-online.jsonl), [Windows offline](windows-offline.jsonl). `fallback_used` means the filtered native list was empty/error and would choose the database in the shared provider; the comparison additionally queries the database even when native succeeds. No real editor content is recorded. + +| Synthetic context | Mac native, first three | Windows native, first three | Database, first three | +|---|---|---|---| +| `hel` | hello, help, helps | hello, help, helping | held, help, helped | +| `wat` | watch, watching, water | watching, watch, water | water, waters, watch | +| `I want some wa` | water, warm, way | water, way, want | way, ways, water | +| `Thank you ` | for, so, again | for, so, have | very, for, so | +| `How are ` | you, your, we | you, u, things | you, we, they | +| `I would like ` | to, a, that | to, that, it | to, you, the | + +The native results offer useful contextual next words in this small sample. The database remains much faster for simple prefixes. These examples do not establish an accuracy score: there is no labelled intended-word corpus, and colloquial/capitalized native candidates may be inappropriate for some users. Locale-specific standalone exploration before the integrated run found empty UK lists on this Windows installation and usable US lists; the committed integrated measurements do not log which locale supplied each result. + +For the offline runs, each VM's Parallels `net0` was disconnected and an external HTTPS request to `example.com` failed with curl error 6 (DNS resolution). All 60 offline native queries still returned usable results. Both adapters were reconnected afterward. Local VM-to-host transfer remained reachable. This verifies operation during the tested external-connectivity failure; it is not a packet-capture audit or a guarantee about every OS service's networking behaviour. + +## Reproduction + +Build this branch with Node 24 and Rust 1.97.1. Do not run the application or input tests on the user's signed local Mac installation. Copy a built app into a separate VM test directory. + +Mac, from a normal logged-in VM Terminal (the helper needs a native application session): + +```sh +app='/path/to/test/Switchify PC.app' +"$app/Contents/MacOS/switchify-pc" \ + --switchify-native-prediction-probe \ + "$app/Contents/Resources/resources/WordData2017051601.db" \ + online > mac-online.jsonl +``` + +Windows ARM64, from the normal interactive user's PowerShell after `npm run tauri -- build --debug --no-bundle --target aarch64-pc-windows-msvc`: + +```powershell +& .\src-tauri\target\aarch64-pc-windows-msvc\debug\switchify-pc.exe ` + --switchify-native-prediction-probe ` + .\src-tauri\resources\WordData2017051601.db online | + Set-Content windows-online.jsonl +``` + +Adjust the executable path if `CARGO_TARGET_DIR` is set. Supply the existing database explicitly. Require exactly 30 JSON rows; an empty/truncated file is a failed run. Disconnect only the test VM network, verify external connectivity fails, repeat with `offline`, and restore networking in a `finally`/cleanup step. This probe does not type, extract foreground text, change permissions, or enable scanning. Its explicit synthetic output is separate from the production worker, which does not log text. + +The debug application resolves its prediction database from the build-time source directory. For the Mac VM app-launch attempt, the bundled database was mirrored to that path inside the guest. The standalone comparison does not depend on that workaround because it accepts an explicit database path. + +## Implementation and automated validation + +`Provider` separates native generation from the existing database and shared context/insertion engine. Native work runs in a disposable helper process with a 600 ms budget inside the existing two-second prediction-worker deadline. Windows contains children in a Job; Mac helpers watch the parent. Cancellation still terminates the outer worker and invalidates pending batches. Native failure, timeout and empty filtered lists use the database. Unsafe/protected, selected, stale-focus and invalid contexts remain rejected by the shared engine before any provider request; the target and input generation are checked again after generation. + +Mac supplies the surrounding text and UTF-16 prefix range to NSSpellChecker, selecting an available `en_GB` dictionary or `en`. A zero-length range at a boundary returned next words in this VM. Apple's API documentation describes partial-word completion, so the observed zero-length behaviour should be requalified across supported macOS versions. [Apple completion API](https://developer.apple.com/documentation/appkit/nsspellchecker/completions(forpartialwordrange:in:language:inspelldocumentwithtag:)) + +Windows explicitly selects `TextPredictionOptions::Predictions`, tries UK then US after unusable/empty/error UK results, and uses `GetNextWordCandidatesAsync` at a boundary. Previous words are passed to the candidate API. [Microsoft candidate API](https://learn.microsoft.com/en-us/uwp/api/windows.data.text.textpredictiongenerator.getcandidatesasync?view=winrt-26100) + +Filtering preserves rank, removes duplicate/empty/multiline/incompatible words, bounds results, and only accepts words compatible with suffix-only insertion. The shared cache preserves unchanged batch tokens, so identical results do not request a new suggestion layout or restart scan timing. + +Fake-provider/input tests cover rank, filtering, native-first behaviour, database failure/fallback, locale retry, invalid context, identical-result caching, exactly-once suffix-and-space insertion, and killing a hanging helper before the overall worker deadline. Existing prerequisite tests cover shared context validation, cancellation, focus/input-generation changes, worker timeout, protected text, and scanning behaviour. These tests are not a substitute for native interactive qualification. + +Required local frontend lint/tests/build and Mac Rust format/Clippy/tests passed (449 unit tests, seven integration tests; one manual benchmark ignored). Windows ARM64 Clippy and Rust tests passed; the native overlay smoke test was excluded from the headless SYSTEM-session test runner. The Windows Tauri debug build and Mac development-signed app build succeeded. The signed local Mac app was not stopped or replaced. + +## Interactive qualification gap and restoration + +Both spike apps launched in the VMs and displayed Ready/input access ready using temporary manual-scan bindings. A disposable TextEdit file accepted ordinary `x` input. Automated Space attempts did not produce a visible scan, so suggestion selection, repeated switch characters, focus/selection changes, protected-field suppression, and stable live suggestion-row timing remain **unqualified** in this run. In Windows, the computer-control path did not reliably deliver even unrelated typing or clicks to the disposable Notepad document, including after Switchify was stopped. No conclusion that this is a provider or capture regression can be drawn from these attempts. Do not mark the interactive gate passed. + +Before a production decision, repeat the full planned switch-driven matrix with reliable VM input: complete a prefix, insert next words, check capitalization/trailing spaces and repeated letters; retain editor focus; change selection/focus and enter protected fields; observe stable row timing; repeat offline. Include cold starts, slower systems, other supported OS versions and Windows language-pack configurations. Keep database fallback and explicit empty/error handling. + +The two settings files were backed up and restored byte-for-byte in each VM. Windows's prior #796 executable was restarted, without overwriting its binary. Mac had no Switchify process before testing; the spike was stopped and the VM returned to its prior suspended state. Network connections were restored, no new Accessibility grants or security-policy changes were made, and the Mac disposable editor windows were closed. The Windows disposable Notepad tab may remain open because UI input was unavailable; existing user tabs were preserved. No native keyboard visibility was changed. + +Keep the PR draft until these findings and the outstanding interactive gates are reviewed. Retarget to main only after #796 merges. Merge/release and any shipping-default change need separate instructions. diff --git a/docs/native-prediction-spike/mac-offline.jsonl b/docs/native-prediction-spike/mac-offline.jsonl new file mode 100644 index 00000000..48232caa --- /dev/null +++ b/docs/native-prediction-spike/mac-offline.jsonl @@ -0,0 +1,30 @@ +{"database":["held","help","helped","helping","helps"],"database_ms":0.049375,"fallback_used":false,"mode":"offline","native":["hello","help","helps","helpful","helping"],"native_error":false,"native_ms":58.880792,"repetition":0,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.072791,"fallback_used":false,"mode":"offline","native":["watch","watching","water","watched","waterproof"],"native_error":false,"native_ms":57.932708,"repetition":0,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":20.09325,"fallback_used":false,"mode":"offline","native":["water","warm","way","ways","watch"],"native_error":false,"native_ms":57.53425,"repetition":0,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.167208,"fallback_used":false,"mode":"offline","native":["for","so","again","and","very"],"native_error":false,"native_ms":52.230458,"repetition":0,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.960583,"fallback_used":false,"mode":"offline","native":["you","your","we","the","things"],"native_error":false,"native_ms":53.007,"repetition":0,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.4394579999999997,"fallback_used":false,"mode":"offline","native":["to","a","that","you","it"],"native_error":false,"native_ms":51.871709,"repetition":0,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.045083000000000005,"fallback_used":false,"mode":"offline","native":["hello","help","helps","helpful","helping"],"native_error":false,"native_ms":59.634541,"repetition":1,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.07179200000000001,"fallback_used":false,"mode":"offline","native":["watch","watching","water","watched","waterproof"],"native_error":false,"native_ms":60.103291000000006,"repetition":1,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":18.009166999999998,"fallback_used":false,"mode":"offline","native":["water","warm","way","ways","watch"],"native_error":false,"native_ms":56.851082999999996,"repetition":1,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.160834,"fallback_used":false,"mode":"offline","native":["for","so","again","and","very"],"native_error":false,"native_ms":56.3715,"repetition":1,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":1.240875,"fallback_used":false,"mode":"offline","native":["you","your","we","the","things"],"native_error":false,"native_ms":54.513332999999996,"repetition":1,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.5274579999999998,"fallback_used":false,"mode":"offline","native":["to","a","that","you","it"],"native_error":false,"native_ms":53.403375000000004,"repetition":1,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.047959,"fallback_used":false,"mode":"offline","native":["hello","help","helps","helpful","helping"],"native_error":false,"native_ms":55.740167,"repetition":2,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.070041,"fallback_used":false,"mode":"offline","native":["watch","watching","water","watched","waterproof"],"native_error":false,"native_ms":57.646625,"repetition":2,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":18.217375,"fallback_used":false,"mode":"offline","native":["water","warm","way","ways","watch"],"native_error":false,"native_ms":52.203667,"repetition":2,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.132167,"fallback_used":false,"mode":"offline","native":["for","so","again","and","very"],"native_error":false,"native_ms":53.359375,"repetition":2,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":1.099792,"fallback_used":false,"mode":"offline","native":["you","your","we","the","things"],"native_error":false,"native_ms":53.166917,"repetition":2,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.7145,"fallback_used":false,"mode":"offline","native":["to","a","that","you","it"],"native_error":false,"native_ms":52.643541,"repetition":2,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.045084,"fallback_used":false,"mode":"offline","native":["hello","help","helps","helpful","helping"],"native_error":false,"native_ms":53.201792,"repetition":3,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.070625,"fallback_used":false,"mode":"offline","native":["watch","watching","water","watched","waterproof"],"native_error":false,"native_ms":55.797833,"repetition":3,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":17.824666999999998,"fallback_used":false,"mode":"offline","native":["water","warm","way","ways","watch"],"native_error":false,"native_ms":54.152583,"repetition":3,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.157667,"fallback_used":false,"mode":"offline","native":["for","so","again","and","very"],"native_error":false,"native_ms":50.773625,"repetition":3,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":1.025709,"fallback_used":false,"mode":"offline","native":["you","your","we","the","things"],"native_error":false,"native_ms":50.329708000000004,"repetition":3,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.4642090000000003,"fallback_used":false,"mode":"offline","native":["to","a","that","you","it"],"native_error":false,"native_ms":53.637,"repetition":3,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.045584,"fallback_used":false,"mode":"offline","native":["hello","help","helps","helpful","helping"],"native_error":false,"native_ms":52.963334,"repetition":4,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.063167,"fallback_used":false,"mode":"offline","native":["watch","watching","water","watched","waterproof"],"native_error":false,"native_ms":54.666084,"repetition":4,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":17.368125000000003,"fallback_used":false,"mode":"offline","native":["water","warm","way","ways","watch"],"native_error":false,"native_ms":50.702749999999995,"repetition":4,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.157333,"fallback_used":false,"mode":"offline","native":["for","so","again","and","very"],"native_error":false,"native_ms":54.322833,"repetition":4,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":1.160917,"fallback_used":false,"mode":"offline","native":["you","your","we","the","things"],"native_error":false,"native_ms":54.287375,"repetition":4,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":4.1035,"fallback_used":false,"mode":"offline","native":["to","a","that","you","it"],"native_error":false,"native_ms":54.56125,"repetition":4,"text":"I would like "} diff --git a/docs/native-prediction-spike/mac-online.jsonl b/docs/native-prediction-spike/mac-online.jsonl new file mode 100644 index 00000000..5bf65a1e --- /dev/null +++ b/docs/native-prediction-spike/mac-online.jsonl @@ -0,0 +1,30 @@ +{"database":["held","help","helped","helping","helps"],"database_ms":0.0515,"fallback_used":false,"mode":"online","native":["hello","help","helps","helpful","helping"],"native_error":false,"native_ms":93.059792,"repetition":0,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.059833000000000004,"fallback_used":false,"mode":"online","native":["watch","watching","water","watched","waterproof"],"native_error":false,"native_ms":61.039917,"repetition":0,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":19.259708,"fallback_used":false,"mode":"online","native":["water","warm","way","ways","watch"],"native_error":false,"native_ms":54.646542,"repetition":0,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.23550000000000001,"fallback_used":false,"mode":"online","native":["for","so","again","and","very"],"native_error":false,"native_ms":54.368333,"repetition":0,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.600458,"fallback_used":false,"mode":"online","native":["you","your","we","the","things"],"native_error":false,"native_ms":57.427167000000004,"repetition":0,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.652041,"fallback_used":false,"mode":"online","native":["to","a","that","you","it"],"native_error":false,"native_ms":59.700250000000004,"repetition":0,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.044375,"fallback_used":false,"mode":"online","native":["hello","help","helps","helpful","helping"],"native_error":false,"native_ms":55.108708,"repetition":1,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.074958,"fallback_used":false,"mode":"online","native":["watch","watching","water","watched","waterproof"],"native_error":false,"native_ms":55.186458,"repetition":1,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":17.9115,"fallback_used":false,"mode":"online","native":["water","warm","way","ways","watch"],"native_error":false,"native_ms":51.615458000000004,"repetition":1,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.146708,"fallback_used":false,"mode":"online","native":["for","so","again","and","very"],"native_error":false,"native_ms":51.766708,"repetition":1,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.516584,"fallback_used":false,"mode":"online","native":["you","your","we","the","things"],"native_error":false,"native_ms":51.238125000000004,"repetition":1,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.65475,"fallback_used":false,"mode":"online","native":["to","a","that","you","it"],"native_error":false,"native_ms":50.238792,"repetition":1,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.0485,"fallback_used":false,"mode":"online","native":["hello","help","helps","helpful","helping"],"native_error":false,"native_ms":55.464708,"repetition":2,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.059459,"fallback_used":false,"mode":"online","native":["watch","watching","water","watched","waterproof"],"native_error":false,"native_ms":52.091,"repetition":2,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":18.162000000000003,"fallback_used":false,"mode":"online","native":["water","warm","way","ways","watch"],"native_error":false,"native_ms":53.118167,"repetition":2,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.150834,"fallback_used":false,"mode":"online","native":["for","so","again","and","very"],"native_error":false,"native_ms":50.726542,"repetition":2,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":1.082625,"fallback_used":false,"mode":"online","native":["you","your","we","the","things"],"native_error":false,"native_ms":50.499583,"repetition":2,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.669042,"fallback_used":false,"mode":"online","native":["to","a","that","you","it"],"native_error":false,"native_ms":50.8055,"repetition":2,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.043125,"fallback_used":false,"mode":"online","native":["hello","help","helps","helpful","helping"],"native_error":false,"native_ms":53.009375,"repetition":3,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.07566700000000001,"fallback_used":false,"mode":"online","native":["watch","watching","water","watched","waterproof"],"native_error":false,"native_ms":53.146208,"repetition":3,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":18.122417,"fallback_used":false,"mode":"online","native":["water","warm","way","ways","watch"],"native_error":false,"native_ms":52.030209,"repetition":3,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.150375,"fallback_used":false,"mode":"online","native":["for","so","again","and","very"],"native_error":false,"native_ms":52.603292,"repetition":3,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":1.016917,"fallback_used":false,"mode":"online","native":["you","your","we","the","things"],"native_error":false,"native_ms":52.622958999999994,"repetition":3,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":4.326207999999999,"fallback_used":false,"mode":"online","native":["to","a","that","you","it"],"native_error":false,"native_ms":50.9865,"repetition":3,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.052541,"fallback_used":false,"mode":"online","native":["hello","help","helps","helpful","helping"],"native_error":false,"native_ms":52.532250000000005,"repetition":4,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.08762500000000001,"fallback_used":false,"mode":"online","native":["watch","watching","water","watched","waterproof"],"native_error":false,"native_ms":53.0715,"repetition":4,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":17.895625,"fallback_used":false,"mode":"online","native":["water","warm","way","ways","watch"],"native_error":false,"native_ms":52.443749999999994,"repetition":4,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.120333,"fallback_used":false,"mode":"online","native":["for","so","again","and","very"],"native_error":false,"native_ms":50.574041,"repetition":4,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":1.108416,"fallback_used":false,"mode":"online","native":["you","your","we","the","things"],"native_error":false,"native_ms":50.494209,"repetition":4,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":4.129166,"fallback_used":false,"mode":"online","native":["to","a","that","you","it"],"native_error":false,"native_ms":52.265541,"repetition":4,"text":"I would like "} diff --git a/docs/native-prediction-spike/windows-offline.jsonl b/docs/native-prediction-spike/windows-offline.jsonl new file mode 100644 index 00000000..db0c2e27 --- /dev/null +++ b/docs/native-prediction-spike/windows-offline.jsonl @@ -0,0 +1,30 @@ +{"database":["held","help","helped","helping","helps"],"database_ms":0.075542,"fallback_used":false,"mode":"offline","native":["hello","help","helping","Hella"],"native_error":false,"native_ms":145.58458399999998,"repetition":0,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.10175000000000001,"fallback_used":false,"mode":"offline","native":["watching","watch","water","watcha","watchin"],"native_error":false,"native_ms":149.073584,"repetition":0,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":24.187667,"fallback_used":false,"mode":"offline","native":["water","way","want","was","wanna"],"native_error":false,"native_ms":133.941375,"repetition":0,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.40462499999999996,"fallback_used":false,"mode":"offline","native":["for","so","have","want","get"],"native_error":false,"native_ms":144.126333,"repetition":0,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.901375,"fallback_used":false,"mode":"offline","native":["you","u","things","we","going"],"native_error":false,"native_ms":149.865083,"repetition":0,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":4.820958999999999,"fallback_used":false,"mode":"offline","native":["to","that","it","a","I"],"native_error":false,"native_ms":147.737792,"repetition":0,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.055708999999999995,"fallback_used":false,"mode":"offline","native":["hello","help","helping","Hella"],"native_error":false,"native_ms":144.50329200000002,"repetition":1,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.096208,"fallback_used":false,"mode":"offline","native":["watching","watch","water","watcha","watchin"],"native_error":false,"native_ms":149.028916,"repetition":1,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":26.163541,"fallback_used":false,"mode":"offline","native":["water","way","want","was","wanna"],"native_error":false,"native_ms":150.37175,"repetition":1,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.27325,"fallback_used":false,"mode":"offline","native":["for","so","have","want","get"],"native_error":false,"native_ms":142.17725000000002,"repetition":1,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.769667,"fallback_used":false,"mode":"offline","native":["you","u","things","we","going"],"native_error":false,"native_ms":149.858833,"repetition":1,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.388125,"fallback_used":false,"mode":"offline","native":["to","that","it","a","I"],"native_error":false,"native_ms":146.224417,"repetition":1,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.06579099999999999,"fallback_used":false,"mode":"offline","native":["hello","help","helping","Hella"],"native_error":false,"native_ms":146.433125,"repetition":2,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.095666,"fallback_used":false,"mode":"offline","native":["watching","watch","water","watcha","watchin"],"native_error":false,"native_ms":149.72054100000003,"repetition":2,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":22.146292000000003,"fallback_used":false,"mode":"offline","native":["water","way","want","was","wanna"],"native_error":false,"native_ms":152.071333,"repetition":2,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.27712499999999995,"fallback_used":false,"mode":"offline","native":["for","so","have","want","get"],"native_error":false,"native_ms":144.7405,"repetition":2,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.966583,"fallback_used":false,"mode":"offline","native":["you","u","things","we","going"],"native_error":false,"native_ms":149.51108299999999,"repetition":2,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.445292,"fallback_used":false,"mode":"offline","native":["to","that","it","a","I"],"native_error":false,"native_ms":147.74895800000002,"repetition":2,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.06975,"fallback_used":false,"mode":"offline","native":["hello","help","helping","Hella"],"native_error":false,"native_ms":145.665334,"repetition":3,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.08762500000000001,"fallback_used":false,"mode":"offline","native":["watching","watch","water","watcha","watchin"],"native_error":false,"native_ms":150.11729100000002,"repetition":3,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":23.022791,"fallback_used":false,"mode":"offline","native":["water","way","want","was","wanna"],"native_error":false,"native_ms":167.926125,"repetition":3,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.534375,"fallback_used":false,"mode":"offline","native":["for","so","have","want","get"],"native_error":false,"native_ms":149.76825,"repetition":3,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.794375,"fallback_used":false,"mode":"offline","native":["you","u","things","we","going"],"native_error":false,"native_ms":178.542958,"repetition":3,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.907042,"fallback_used":false,"mode":"offline","native":["to","that","it","a","I"],"native_error":false,"native_ms":147.05058300000002,"repetition":3,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.057917,"fallback_used":false,"mode":"offline","native":["hello","help","helping","Hella"],"native_error":false,"native_ms":145.324334,"repetition":4,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.087833,"fallback_used":false,"mode":"offline","native":["watching","watch","water","watcha","watchin"],"native_error":false,"native_ms":150.235625,"repetition":4,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":21.212917,"fallback_used":false,"mode":"offline","native":["water","way","want","was","wanna"],"native_error":false,"native_ms":150.313166,"repetition":4,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.356458,"fallback_used":false,"mode":"offline","native":["for","so","have","want","get"],"native_error":false,"native_ms":145.984083,"repetition":4,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.7601669999999999,"fallback_used":false,"mode":"offline","native":["you","u","things","we","going"],"native_error":false,"native_ms":149.995625,"repetition":4,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.9692089999999998,"fallback_used":false,"mode":"offline","native":["to","that","it","a","I"],"native_error":false,"native_ms":148.00775,"repetition":4,"text":"I would like "} diff --git a/docs/native-prediction-spike/windows-online.jsonl b/docs/native-prediction-spike/windows-online.jsonl new file mode 100644 index 00000000..eb2ad293 --- /dev/null +++ b/docs/native-prediction-spike/windows-online.jsonl @@ -0,0 +1,30 @@ +{"database":["held","help","helped","helping","helps"],"database_ms":0.082333,"fallback_used":false,"mode":"online","native":["hello","help","helping","Hella"],"native_error":false,"native_ms":157.762709,"repetition":0,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.088625,"fallback_used":false,"mode":"online","native":["watching","watch","water","watcha","watchin"],"native_error":false,"native_ms":148.74491700000002,"repetition":0,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":42.053584,"fallback_used":false,"mode":"online","native":["water","way","want","was","wanna"],"native_error":false,"native_ms":151.371916,"repetition":0,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":1.397375,"fallback_used":false,"mode":"online","native":["for","so","have","want","get"],"native_error":false,"native_ms":142.75929200000002,"repetition":0,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":3.0638330000000003,"fallback_used":false,"mode":"online","native":["you","u","things","we","going"],"native_error":false,"native_ms":150.47275,"repetition":0,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":5.957959,"fallback_used":false,"mode":"online","native":["to","that","it","a","I"],"native_error":false,"native_ms":143.217125,"repetition":0,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.06695899999999999,"fallback_used":false,"mode":"online","native":["hello","help","helping","Hella"],"native_error":false,"native_ms":142.513542,"repetition":1,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.09125,"fallback_used":false,"mode":"online","native":["watching","watch","water","watcha","watchin"],"native_error":false,"native_ms":149.527167,"repetition":1,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":21.892667,"fallback_used":false,"mode":"online","native":["water","way","want","was","wanna"],"native_error":false,"native_ms":133.448042,"repetition":1,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.24537499999999998,"fallback_used":false,"mode":"online","native":["for","so","have","want","get"],"native_error":false,"native_ms":148.13458400000002,"repetition":1,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.710625,"fallback_used":false,"mode":"online","native":["you","u","things","we","going"],"native_error":false,"native_ms":147.382583,"repetition":1,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.6259170000000003,"fallback_used":false,"mode":"online","native":["to","that","it","a","I"],"native_error":false,"native_ms":149.048875,"repetition":1,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.06870899999999999,"fallback_used":false,"mode":"online","native":["hello","help","helping","Hella"],"native_error":false,"native_ms":145.953416,"repetition":2,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.10483300000000001,"fallback_used":false,"mode":"online","native":["watching","watch","water","watcha","watchin"],"native_error":false,"native_ms":149.428083,"repetition":2,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":21.917167000000003,"fallback_used":false,"mode":"online","native":["water","way","want","was","wanna"],"native_error":false,"native_ms":150.694084,"repetition":2,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.27549999999999997,"fallback_used":false,"mode":"online","native":["for","so","have","want","get"],"native_error":false,"native_ms":146.190584,"repetition":2,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.682167,"fallback_used":false,"mode":"online","native":["you","u","things","we","going"],"native_error":false,"native_ms":149.023291,"repetition":2,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.532417,"fallback_used":false,"mode":"online","native":["to","that","it","a","I"],"native_error":false,"native_ms":148.258917,"repetition":2,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.064125,"fallback_used":false,"mode":"online","native":["hello","help","helping","Hella"],"native_error":false,"native_ms":145.648041,"repetition":3,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.08804100000000001,"fallback_used":false,"mode":"online","native":["watching","watch","water","watcha","watchin"],"native_error":false,"native_ms":133.221833,"repetition":3,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":21.682667,"fallback_used":false,"mode":"online","native":["water","way","want","was","wanna"],"native_error":false,"native_ms":133.017792,"repetition":3,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.314209,"fallback_used":false,"mode":"online","native":["for","so","have","want","get"],"native_error":false,"native_ms":146.581833,"repetition":3,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.8362499999999999,"fallback_used":false,"mode":"online","native":["you","u","things","we","going"],"native_error":false,"native_ms":148.98458300000001,"repetition":3,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":3.6142499999999997,"fallback_used":false,"mode":"online","native":["to","that","it","a","I"],"native_error":false,"native_ms":147.58474999999999,"repetition":3,"text":"I would like "} +{"database":["held","help","helped","helping","helps"],"database_ms":0.067167,"fallback_used":false,"mode":"online","native":["hello","help","helping","Hella"],"native_error":false,"native_ms":146.772833,"repetition":4,"text":"hel"} +{"database":["water","waters","watch","watching","watched"],"database_ms":0.106083,"fallback_used":false,"mode":"online","native":["watching","watch","water","watcha","watchin"],"native_error":false,"native_ms":148.948416,"repetition":4,"text":"wat"} +{"database":["way","ways","water","want","warm"],"database_ms":20.903000000000002,"fallback_used":false,"mode":"online","native":["water","way","want","was","wanna"],"native_error":false,"native_ms":151.444208,"repetition":4,"text":"I want some wa"} +{"database":["very","for","so","both","all"],"database_ms":0.246667,"fallback_used":false,"mode":"online","native":["for","so","have","want","get"],"native_error":false,"native_ms":145.91416600000002,"repetition":4,"text":"Thank you "} +{"database":["you","we","they","the","things"],"database_ms":0.681917,"fallback_used":false,"mode":"online","native":["you","u","things","we","going"],"native_error":false,"native_ms":149.500584,"repetition":4,"text":"How are "} +{"database":["to","you","the","for","a"],"database_ms":4.319292,"fallback_used":false,"mode":"online","native":["to","that","it","a","I"],"native_error":false,"native_ms":148.71241700000002,"repetition":4,"text":"I would like "} From 108b1d019178b11ef6e56287250d96de089dccd6 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 19 Sep 2026 16:24:52 +0100 Subject: [PATCH 3/4] docs: record Mac switch validation and empty prediction row blocker --- docs/native-prediction-spike/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/native-prediction-spike/README.md b/docs/native-prediction-spike/README.md index c45737a9..e4bd6d32 100644 --- a/docs/native-prediction-spike/README.md +++ b/docs/native-prediction-spike/README.md @@ -75,7 +75,7 @@ Required local frontend lint/tests/build and Mac Rust format/Clippy/tests passed ## Interactive qualification gap and restoration -Both spike apps launched in the VMs and displayed Ready/input access ready using temporary manual-scan bindings. A disposable TextEdit file accepted ordinary `x` input. Automated Space attempts did not produce a visible scan, so suggestion selection, repeated switch characters, focus/selection changes, protected-field suppression, and stable live suggestion-row timing remain **unqualified** in this run. In Windows, the computer-control path did not reliably deliver even unrelated typing or clicks to the disposable Notepad document, including after Switchify was stopped. No conclusion that this is a provider or capture regression can be drawn from these attempts. Do not mark the interactive gate passed. +Both spike apps launched in the VMs and displayed Ready/input access ready using temporary manual-scan bindings. Initial Mac Space attempts were inconclusive because manual scan lines started at the display edge. A follow-up using J for Select, K for Next and L for Previous successfully opened the action menu and keyboard, scanned to h and inserted it once (`hel` became `helh`), inserted a Space after deleting that h, and reached and activated Close keyboard using switches. TextEdit retained focus for those insertions. However, the suggestion row remained empty for both `hel` and the word boundary, despite the standalone native helper returning useful candidates. This is an unresolved integrated context/worker/provider-path blocker; its origin in #796 or this spike has not been isolated. Suggestion acceptance, repeated switch characters, focus/selection/protected-field cases and stable live suggestion-row timing remain **unqualified**. In Windows, the computer-control path did not reliably deliver even unrelated typing or clicks to the disposable Notepad document, including after Switchify was stopped. Do not mark either platform's complete interactive prediction gate passed. Before a production decision, repeat the full planned switch-driven matrix with reliable VM input: complete a prefix, insert next words, check capitalization/trailing spaces and repeated letters; retain editor focus; change selection/focus and enter protected fields; observe stable row timing; repeat offline. Include cold starts, slower systems, other supported OS versions and Windows language-pack configurations. Keep database fallback and explicit empty/error handling. From 97f45136a1ad9d9569ba38ae51297d01f1f6b128 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sat, 19 Sep 2026 16:37:13 +0100 Subject: [PATCH 4/4] fix: stop Mac prediction helpers already orphaned at startup --- docs/native-prediction-spike/README.md | 6 ++--- src-tauri/src/prediction/worker.rs | 36 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/docs/native-prediction-spike/README.md b/docs/native-prediction-spike/README.md index e4bd6d32..2e7fd31d 100644 --- a/docs/native-prediction-spike/README.md +++ b/docs/native-prediction-spike/README.md @@ -4,7 +4,7 @@ Keep both providers experimental. Mac is the stronger candidate for a later production trial: this VM produced relevant UK-English completions and next words with modest process overhead. Windows also returned usable results after the locale retry/filtering path, but needs broader language-pack and quality coverage. Neither is qualified for the shipping default: switch-driven insertion and suggestion-row timing have not been validated interactively in this run, and the six-sentence corpus is too small to establish quality or reliability. -The implementation is stacked on PR #796, `codex/word-prediction-795`, at `e5ee2a598b062640d311ae8e6c45de2729127b7d`. The VM binaries and raw results below were built from implementation commit `90bde1d507d08d783e07b2ea966bff06c01e0db7`. No schema, BLE, settings UI, or provider selector changes are included. Native-first is the default only in this experimental branch when the existing word-prediction setting is enabled. +The implementation is stacked on PR #796, `codex/word-prediction-795`, at `e5ee2a598b062640d311ae8e6c45de2729127b7d`. The VM binaries and raw results below were built from implementation commit `90bde1d507d08d783e07b2ea966bff06c01e0db7`. A later cleanup guard and subprocess test handle a Mac helper orphaned before its watcher starts; the measurements predate that guard. No schema, BLE, settings UI, or provider selector changes are included. Native-first is the default only in this experimental branch when the existing word-prediction setting is enabled. ## Measurements @@ -61,7 +61,7 @@ The debug application resolves its prediction database from the build-time sourc ## Implementation and automated validation -`Provider` separates native generation from the existing database and shared context/insertion engine. Native work runs in a disposable helper process with a 600 ms budget inside the existing two-second prediction-worker deadline. Windows contains children in a Job; Mac helpers watch the parent. Cancellation still terminates the outer worker and invalidates pending batches. Native failure, timeout and empty filtered lists use the database. Unsafe/protected, selected, stale-focus and invalid contexts remain rejected by the shared engine before any provider request; the target and input generation are checked again after generation. +`Provider` separates native generation from the existing database and shared context/insertion engine. Native work runs in a disposable helper process with a 600 ms budget inside the existing two-second prediction-worker deadline. Windows contains children in a Job; Mac helpers watch the parent and exit immediately if already orphaned at startup. A subprocess regression test verifies this startup race without desktop input. Cancellation still terminates the outer worker and invalidates pending batches. Native failure, timeout and empty filtered lists use the database. Unsafe/protected, selected, stale-focus and invalid contexts remain rejected by the shared engine before any provider request; the target and input generation are checked again after generation. Mac supplies the surrounding text and UTF-16 prefix range to NSSpellChecker, selecting an available `en_GB` dictionary or `en`. A zero-length range at a boundary returned next words in this VM. Apple's API documentation describes partial-word completion, so the observed zero-length behaviour should be requalified across supported macOS versions. [Apple completion API](https://developer.apple.com/documentation/appkit/nsspellchecker/completions(forpartialwordrange:in:language:inspelldocumentwithtag:)) @@ -71,7 +71,7 @@ Filtering preserves rank, removes duplicate/empty/multiline/incompatible words, Fake-provider/input tests cover rank, filtering, native-first behaviour, database failure/fallback, locale retry, invalid context, identical-result caching, exactly-once suffix-and-space insertion, and killing a hanging helper before the overall worker deadline. Existing prerequisite tests cover shared context validation, cancellation, focus/input-generation changes, worker timeout, protected text, and scanning behaviour. These tests are not a substitute for native interactive qualification. -Required local frontend lint/tests/build and Mac Rust format/Clippy/tests passed (449 unit tests, seven integration tests; one manual benchmark ignored). Windows ARM64 Clippy and Rust tests passed; the native overlay smoke test was excluded from the headless SYSTEM-session test runner. The Windows Tauri debug build and Mac development-signed app build succeeded. The signed local Mac app was not stopped or replaced. +Required local frontend lint/tests/build and Mac Rust format/Clippy/tests passed (450 unit tests, seven integration tests; one manual benchmark ignored). Windows ARM64 Clippy and Rust tests passed; the native overlay smoke test was excluded from the headless SYSTEM-session test runner. The Windows Tauri debug build and Mac development-signed app build succeeded. The signed local Mac app was not stopped or replaced. ## Interactive qualification gap and restoration diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 21f7e415..5b1d3914 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -325,6 +325,9 @@ pub(super) fn watch_parent() { #[cfg(target_os = "macos")] { let parent = unsafe { libc::getppid() }; + if parent <= 1 { + std::process::exit(0); + } std::thread::spawn(move || loop { std::thread::sleep(std::time::Duration::from_millis(250)); if unsafe { libc::getppid() } != parent { @@ -380,6 +383,39 @@ pub fn run_from_args() -> bool { #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "macos")] + #[test] + fn orphaned_worker_exits_at_startup() { + const FLAG: &str = "SWITCHIFY_PREDICTION_ORPHAN_TEST"; + if std::env::var_os(FLAG).is_some() { + for _ in 0..100 { + if unsafe { libc::getppid() } <= 1 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(unsafe { libc::getppid() }, 1); + println!("orphan-started"); + std::io::stdout().flush().unwrap(); + watch_parent(); + println!("orphan-survived"); + return; + } + let output = std::process::Command::new("/bin/sh") + .args([ + "-c", + "\"$1\" --exact prediction::worker::tests::orphaned_worker_exits_at_startup --nocapture &", + "orphan-probe", + ]) + .arg(std::env::current_exe().unwrap()) + .env(FLAG, "1") + .output() + .unwrap(); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(output.status.success()); + assert!(stdout.contains("orphan-started"), "{stdout}"); + assert!(!stdout.contains("orphan-survived"), "{stdout}"); + } struct Fake { raw: RawContext, target: usize,