Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ jobs:
cargo audit --file src-tauri/Cargo.lock
cargo audit --file src-tauri/startup-launcher/Cargo.lock
cargo audit --file tools/updater-signature-verifier/Cargo.lock
cargo audit --file tools/prediction-data/Cargo.lock

frontend:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -118,10 +117,6 @@ jobs:
- run: cargo fmt --manifest-path src-tauri/Cargo.toml --check
- run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
- run: cargo fmt --manifest-path tools/prediction-data/Cargo.toml --check
- run: cargo clippy --locked --manifest-path tools/prediction-data/Cargo.toml --all-targets -- -D warnings
- run: cargo test --locked --manifest-path tools/prediction-data/Cargo.toml
- run: cargo run --release --locked --manifest-path tools/prediction-data/Cargo.toml -- --check src-tauri/resources/WordData2017051601.db src-tauri/resources/word-predictions.lookup
- run: cargo fmt --manifest-path tools/updater-signature-verifier/Cargo.toml --check
- run: cargo clippy --locked --manifest-path tools/updater-signature-verifier/Cargo.toml --all-targets -- -D warnings
- run: cargo test --locked --manifest-path tools/updater-signature-verifier/Cargo.toml
Expand Down
29 changes: 6 additions & 23 deletions docs/word-prediction.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,42 +8,25 @@ The buffer holds at most 512 characters, retaining complete Unicode graphemes at

A passive observer records only an activity counter and timestamp, never external text. Prediction is unavailable if this observer cannot start or loses access. Edits made before the observer is ready are discarded because intervening activity cannot be verified. Each queued edit is scoped to its foreground target and the time before injection, so edits preceding an observed external change are discarded. Changes within an application that produce no observed input cannot be detected without inspecting its fields.

The bundled English lookup database works offline. Ranking backs off from the longest available three-word context to shorter contexts, then to word frequency. Prefix matching is case-insensitive. Duplicate words are removed. The data is fixed; typing is never used to train or update it.
## On-device model

## Enhanced word prediction
One Word prediction setting controls an offline SmolLM2-135M int8 ONNX model. The saved `enhancedWordPrediction` field is retained for compatibility but does not choose an engine. The model spells candidates from subword pieces and reads only the last 256 characters of the temporary buffer, beginning at a word boundary. It never learns from typing.

Enhanced word prediction is off by default. When it is on, a small language model (SmolLM2-135M, int8 ONNX, run with ONNX Runtime on the CPU) spells suggestions from subword pieces, so it can suggest words the lookup lacks, such as WhatsApp. It sees only the buffer described above: at most its last 256 characters, starting at a word boundary, including earlier sentences still in the buffer. It runs offline in the prediction worker, never learns, and nothing it reads is logged or stored.
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 one second, 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.

- **Loading and fallback:** the model loads in the background when the worker starts, taking about 0.7 s. Until it is ready, and whenever it fails, the lookup answers as before. Turning the setting on or off restarts the worker.
- **Filters:**
- words contain only ASCII letters and apostrophes;
- a word at the start of the text must be capitalised, which rules out subword fragments;
- single letters must be in the lookup;
- blocklisted words are never suggested;
- words the lookup does not know must reach a minimum probability.
- **Spelling:** words the lookup knows take its usual spelling. Unknown words keep the model's spelling, except that a capital coming only from sentence position is removed.
- **Merging:** lookup suggestions fill any remaining positions.
- **Time limits:** the model stops exploring new words after 400 ms. If one call ever takes longer than 1 s, the worker uses the lookup for the rest of its life, so replies stay well inside the two-second deadline.
- **Clipped buffers:** when clipping leaves no complete earlier word, the lookup answers.
- **Cost:** predictions take roughly 40–80 ms on a recent laptop, and the worker needs about 300–450 MB more memory.
Candidates contain ASCII letters and apostrophes, must have sufficient model probability, and cannot appear in the blocklist. Single-letter candidates are limited to “a” and “I”. Up to five distinct suggestions are shown. Ordinary words use lowercase; mixed-case names retain model casing. Shift, Caps and sentence capitalization are applied when the keyboard displays a candidate.

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.

## Worker process

A separate process owns the buffer, activity observer and database. Private bounded inherited pipes carry successful edits and results to native rendering. Text and suggestions are not sent to the React UI, diagnostic history or telemetry. One request is outstanding at a time with a two-second deadline. Timeout stops predictions until the keyboard is reopened; ordinary keyboard operation remains available. Closing the keyboard, ending scanning, or exiting kills and reaps the worker.
A separate process owns the buffer, activity observer and model. Private bounded inherited pipes carry successful edits and results to native rendering. Text and suggestions are not sent to the React UI, diagnostic history or telemetry. One request is outstanding at a time with a two-second deadline. Timeout stops predictions until the keyboard is reopened; ordinary keyboard operation remains available. Closing the keyboard, ending scanning, or exiting kills and reaps the worker.

Before accepting a suggestion, the worker checks its token, edit revision, foreground identity and external activity. The main process checks its generation and foreground again before injection. Verification and native insertion cannot be atomic across applications; the target can still change in that short interval.

## Validation

Automated tests use fixture databases, fake foreground/activity sources, fake input adapters and sleeping subprocesses. They never inject desktop input. They cover first-letter completion, Unicode Backspace, bounded context, stale replies, foreground/external changes, failure cleanup and switch action compatibility. The database benchmark prints counts and timings only:

```powershell
cargo test --manifest-path src-tauri/Cargo.toml bundled_database_benchmark -- --ignored --nocapture
```

`cargo test` also runs the language model against the fetched files with synthetic text. For release-build suggestions and timings:
Automated tests use fake predictors, foreground/activity sources, input adapters and sleeping subprocesses. They never inject desktop input. They cover first-letter completion, Unicode Backspace, bounded context, stale replies, foreground/external changes, failure cleanup and switch action compatibility. `cargo test` also runs the language model against the fetched files with synthetic text. For release-build suggestions and timings:

```powershell
cargo test --release --manifest-path src-tauri/Cargo.toml bundled_model_suggests_current_words -- --nocapture
Expand Down
2 changes: 1 addition & 1 deletion scripts/fetch-prediction-model.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Downloads the enhanced word prediction model into src-tauri/resources/prediction-model.
// Downloads the word prediction model into src-tauri/resources/prediction-model.
// The model is too large to commit, so every file is pinned to one upstream
// revision and verified by SHA-256. Verified files are not downloaded again.
import { createHash } from "node:crypto";
Expand Down
88 changes: 0 additions & 88 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,3 @@ winreg = "0.55"

[patch.crates-io]
corebluetooth-rs = { path = "../vendor/corebluetooth-rs" }

[dev-dependencies]
rusqlite = { version = "0.40.2", features = ["bundled"] }
29 changes: 3 additions & 26 deletions src-tauri/resources/PROVENANCE.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,4 @@
# English prediction database

`WordData2017051601.db` was recovered from the user's private `enaboapps/sayit-ios` history, path `Assets/Databases/WordData2017051601.db`, commit `95bda9265758e50787c5da2779b16f36b53c319d`, dated May 20, 2017. Recovery took place September 18, 2026. The user authorized bundling this database with Switchify.

- Size: 102,690,816 bytes.
- SHA-256: `dedd65d263bde8315e7e5ed7d2c8e04f17c33598a68506c9d70661a6d1f57318`.
- SQLite integrity check during recovery: `ok`.
- Rows: WORDS 165,420; BIGRAMS 976,804; TRIGRAMS 996,846; QUADGRAMS 953,630.

This is the unchanged original SQLite file. The later Realm archive is not used. It is retained for conversion and parity tests, not shipped in the app. The converter ignores personal frequency columns. No captured text or user vocabulary is written into this resource.

## Packaged dedicated lookup

`word-predictions.lookup` is generated from the unchanged database. It contains frequency-ranked words and n-grams, a sorted prefix index, a versioned header and a SHA-256 payload checksum. The runtime validates counts, bounds, references and ordering before serving predictions. The app packages only this lookup and does not write to it.

Regenerate with Rust 1.97.1 from the repository root:

```sh
cargo run --release --locked --manifest-path tools/prediction-data/Cargo.toml -- src-tauri/resources/WordData2017051601.db src-tauri/resources/word-predictions.lookup
```

Use `-- --check` before the two paths to regenerate into a temporary file and verify byte-for-byte reproducibility. CI checks the artifact on Mac and Windows. The original SQLite implementation is retained only in tests to verify identical ordered suggestions.

# Enhanced prediction model
# Word prediction model

`prediction-model/` is not committed. `npm run prediction-model` (`scripts/fetch-prediction-model.mjs`) downloads it from [`onnx-community/SmolLM2-135M-ONNX`](https://huggingface.co/onnx-community/SmolLM2-135M-ONNX) at revision `d0ae6834f1df45e0e95b5fdae95e536f9ca7cd3f` and verifies each file's size and SHA-256 before use:

Expand All @@ -33,8 +10,8 @@ It is an int8 ONNX conversion of [HuggingFaceTB/SmolLM2-135M](https://huggingfac

# Prediction blocklist

`prediction-blocklist.txt` is the unchanged English list from [LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words](https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words) (`en` at commit `4638b970cb8d9d82789564fcba1f4a1eb508ff1a`, 403 lines, SHA-256 `af851ecef1d5f212caba17339b12ac39cc2fef7d78c74876f67237644fcee8bd`). It is licensed CC BY 4.0 by its contributors. Enhanced prediction never suggests a listed word.
`prediction-blocklist.txt` is the unchanged English list from [LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words](https://github.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words) (`en` at commit `4638b970cb8d9d82789564fcba1f4a1eb508ff1a`, 403 lines, SHA-256 `af851ecef1d5f212caba17339b12ac39cc2fef7d78c74876f67237644fcee8bd`). It is licensed CC BY 4.0 by its contributors. Word prediction never suggests a listed word.

# ONNX Runtime

Enhanced prediction statically links ONNX Runtime 1.28.0 through the `ort` crate (`=2.0.0-rc.13`, MIT/Apache-2.0). At build time, `ort-sys` downloads the prebuilt archive for the target from `cdn.pyke.io` and verifies it against a SHA-256 embedded in the crate. So every build, including signed releases, depends on that download. Only the CPU provider is used, and ONNX Runtime telemetry is disabled when the worker starts it.
Word prediction statically links ONNX Runtime 1.28.0 through the `ort` crate (`=2.0.0-rc.13`, MIT/Apache-2.0). At build time, `ort-sys` downloads the prebuilt archive for the target from `cdn.pyke.io` and verifies it against a SHA-256 embedded in the crate. So every build, including signed releases, depends on that download. Only the CPU provider is used, and ONNX Runtime telemetry is disabled when the worker starts it.
Binary file removed src-tauri/resources/WordData2017051601.db
Binary file not shown.
Binary file removed src-tauri/resources/word-predictions.lookup
Binary file not shown.
6 changes: 2 additions & 4 deletions src-tauri/src/point_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ pub struct Config {
#[serde(deserialize_with = "crate::scan_preferences::deserialize_preferences")]
pub scan_preferences: crate::scan_preferences::Preferences,
pub word_prediction: bool,
/// Uses the on-device language model for word prediction. Off by default
/// because it needs several hundred megabytes of memory.
/// Retained for saved-settings compatibility; the model is always used
/// when `word_prediction` is enabled.
pub enhanced_word_prediction: bool,
pub keyboard_wait_after_typing: bool,
/// Moves the keyboard or mouse panel away while the pointer is over it.
Expand Down Expand Up @@ -113,7 +113,6 @@ impl Config {
keyboard_scan: self.resolved(crate::scan_preferences::Area::Keyboard),
mouse_scan: self.resolved(crate::scan_preferences::Area::Mouse),
word_prediction: self.word_prediction,
enhanced_word_prediction: self.enhanced_word_prediction,
keyboard_wait_after_typing: self.keyboard_wait_after_typing,
panel_avoids_pointer: self.panel_avoids_pointer,
scanner_color: self.resolved(crate::scan_preferences::Area::Point).color,
Expand All @@ -140,7 +139,6 @@ pub struct PointSettings {
pub keyboard_scan: crate::scan_preferences::Resolved,
pub mouse_scan: crate::scan_preferences::Resolved,
pub word_prediction: bool,
pub enhanced_word_prediction: bool,
pub keyboard_wait_after_typing: bool,
pub panel_avoids_pointer: bool,
pub scanner_color: crate::scanning::ScannerColor,
Expand Down
15 changes: 7 additions & 8 deletions src-tauri/src/point_scan_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ impl Adapter for PointScan {
Request::Prediction { token, index } => {
return crate::prediction::select(token, index).map(|()| None)
}
Request::PredictionRetry => {
crate::prediction::retry();
return Ok(None);
}
Request::Keyboard(stroke) => {
let scope = crate::prediction::InputScope::capture();
let result = crate::scan_executor::activate(request);
Expand Down Expand Up @@ -268,6 +272,7 @@ impl Adapter for PointScan {
request,
Request::Keyboard(_)
| Request::Prediction { .. }
| Request::PredictionRetry
| Request::MouseMove { .. }
| Request::MouseMoveAbsolute { .. }
| Request::MouseScroll { .. }
Expand All @@ -282,14 +287,7 @@ impl Adapter for PointScan {
crate::prediction::start_keyboard_activity(captured_keys);
}
let enabled = technique.prediction_enabled();
let enhanced = technique.prediction_enhanced();
crate::prediction::poll(
app,
technique.prediction_keyboard(),
enabled,
enhanced,
captured_keys,
);
crate::prediction::poll(app, technique.prediction_keyboard(), enabled, captured_keys);
}
fn cleanup(_app: &AppHandle) -> Result<(), String> {
crate::prediction::stop();
Expand Down Expand Up @@ -425,6 +423,7 @@ mod tests {
token: 1,
index: 0
}));
assert!(PointScan::preserve_visuals(&Request::PredictionRetry));
assert!(PointScan::preserve_visuals(&Request::MouseScroll { dy: 5 }));
assert!(!PointScan::preserve_visuals(
&crate::point_workflow::default_click((10, 20))
Expand Down
Loading
Loading