From 7af47110e310614c717be9dbb8f7d0942d180f48 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 29 Sep 2026 18:32:17 +0100 Subject: [PATCH 1/4] Replace ONNX predictions with the verified Switchify English database --- .github/workflows/ci.yml | 28 + .gitignore | 2 + docs/word-prediction.md | 40 +- package-lock.json | 8 + package.json | 3 +- scripts/check-packaged-prediction.mjs | 12 + scripts/fetch-prediction-model.mjs | 116 ++- scripts/fetch-prediction-model.node-test.mjs | 61 ++ scripts/prediction-model.json | 52 + src-tauri/Cargo.lock | 721 ++++---------- src-tauri/Cargo.toml | 3 +- src-tauri/resources/PROVENANCE.md | 31 +- src-tauri/src/prediction/context.rs | 27 +- src-tauri/src/prediction/database.rs | 50 +- src-tauri/src/prediction/mod.rs | 7 +- src-tauri/src/prediction/model.rs | 983 ++++--------------- src-tauri/src/prediction/worker.rs | 111 ++- src-tauri/tauri.conf.json | 10 +- src/settings/ScannerPreferences.tsx | 2 +- 19 files changed, 780 insertions(+), 1487 deletions(-) create mode 100644 scripts/check-packaged-prediction.mjs create mode 100644 scripts/fetch-prediction-model.node-test.mjs create mode 100644 scripts/prediction-model.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74112b1e..eb6c06ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,3 +134,31 @@ jobs: run: | $env:SWITCHIFY_ALLOW_UNSIGNED_UIACCESS_PACKAGE = '1' pwsh ./scripts/Verify-WindowsUiAccessPackage.ps1 + + - name: Verify macOS packaged prediction resources + if: runner.os == 'macOS' + shell: bash + run: | + model=$(node scripts/check-packaged-prediction.mjs src-tauri/target/release/bundle/macos) + echo "SWITCHIFY_BENCHMARK_MODEL=$model" >> "$GITHUB_ENV" + - name: Verify Windows packaged prediction resources + if: runner.os == 'Windows' + shell: pwsh + run: | + $installer = Get-ChildItem src-tauri/target/release/bundle/nsis/*.exe | Select-Object -First 1 + $unpacked = Join-Path $env:RUNNER_TEMP 'prediction-package' + & 7z x $installer.FullName "-o$unpacked" -y | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Could not extract the installer.' } + $model = node scripts/check-packaged-prediction.mjs $unpacked + if ($LASTEXITCODE -ne 0) { throw 'Packaged prediction resources failed verification.' } + "SWITCHIFY_BENCHMARK_MODEL=$model" >> $env:GITHUB_ENV + - name: Measure packaged prediction without desktop input + env: + SWITCHIFY_BENCHMARK_REPORT: prediction-benchmark.json + run: cargo test --release --locked --manifest-path src-tauri/Cargo.toml bundled_prediction_benchmark -- --ignored --nocapture --test-threads=1 + - name: Upload prediction measurements + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: prediction-benchmark-${{ runner.os }} + path: src-tauri/prediction-benchmark.json + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 6182a6af..61ef08e3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ src-tauri/icons/ios/ src-tauri/tauri.release*.generated.json tools/prediction-data/target/ src-tauri/resources/prediction-model/ + +src-tauri/prediction-benchmark.json diff --git a/docs/word-prediction.md b/docs/word-prediction.md index bd340f0c..9477227b 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -1,6 +1,6 @@ # Word prediction -Word prediction is enabled by default in Scanning settings. Its five-position row appears only on the Letters page. Empty positions are skipped. Select the row and then a word using the existing switches. Acceptance appends the missing suffix and a space without selecting text or using the clipboard. It deletes nothing, with one bounded exception: when the word has a capital where a lowercase letter was typed, as in “lon” for London or “i” for I’m, it backspaces the typed prefix and types the whole word. Shift and Caps affect completion casing; Ctrl, Alt/Option and Windows/Command suppress suggestions. +Word prediction is enabled by default in Scanning settings. Its five-position row appears only on the Letters page. Empty positions are skipped. Select the row and then a word using the existing switches. Acceptance appends the missing suffix and a space without selecting text or using the clipboard. It deletes nothing, with one bounded exception: when the word has a capital where a lowercase letter was typed, as in “i” for I’m, it backspaces the typed prefix and types the whole word. Shift and Caps affect completion casing; Ctrl, Alt/Option and Windows/Command suppress suggestions. Predictions use only a temporary buffer of successful Switchify keyboard input, starting with the first letter. Existing text, pasted text and hardware keyboard typing are never read into it. Switchify does not inspect fields, selections, passwords or caret positions. Suggestions may therefore appear anywhere the keyboard is open, including password fields or applications without a text field. The buffer records successful input injection; it cannot verify what an application actually accepted. @@ -23,17 +23,19 @@ A passive observer records only an activity counter and timestamp, never externa ## On-device model -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. +One Word prediction setting controls the English `en-aac-oanc-v1` SQLite database through the pinned `switchify-prediction` Rust library. The saved `enhancedWordPrediction` field remains compatible but does not choose an engine. Only the isolated worker opens the read-only baseline; there is no personal database, saved learning, import, or runtime download. -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, which takes about a second. Suggestions are blank until loading finishes. Later keyboard opens normally skip this wait by using the spare worker described under Worker process. A passive badge beside the scan prompt distinguishes loading, a ready keyboard awaiting typed context, suggestions held until the next word, 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, the keyboard continues accepting input and offers **Retry predictions** in its toolbar. A call that exceeds 1.5 seconds is tolerated: its suggestions are still shown, and only three such calls in a row count as a failure, since one stall on a busy machine does not mean the model cannot keep up. A call at normal speed clears the count. A call so slow that its reply misses the two-second deadline is handled by replacing the worker, described under Worker process. Retry restarts only the prediction worker, clears its private text context, and leaves the keyboard open. Suggestions resume with the next word, or at once when no word was in progress. A 400 ms search budget bounds candidate exploration. A clipped buffer with no complete earlier word yields no suggestions. +The worker ranks up to five words using interpolated trigram/bigram/unigram probabilities (0.6/0.3/0.1, renormalized when context is unavailable). Suggestions begin with the first typed character and continue after a space as next-word suggestions. An untouched keyboard remains empty. The prefix and preceding text are supplied separately, and sentence punctuation resets language context. There are no generated phrases. Unknown prefixes yield no suggestions. The vocabulary can contain names, disfluencies and inappropriate words; it is English-only and is not a clinical recommendation system. -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. +Loading is asynchronous and typing remains available. The existing badge distinguishes loading, waiting for typed context, held suggestions, no match, available suggestions, paused activity tracking, and failure. **Retry predictions** replaces the worker without closing the keyboard. A clipped buffer without a complete earlier word yields no suggestions. Repeated calls exceeding 1.5 seconds mark prediction unavailable; a missed two-second reply deadline triggers the existing worker replacement policy. Errors never expose buffered text. + +The loader checks the pinned database SHA-256 before opening it. Missing, corrupt or incompatible resources fail closed. `npm run prediction-model` obtains the versioned upstream release, verifies its archive and individual file hashes, and installs only allowlisted resources after verification. Verified local files work without network access. Bundles carry the source manifests, attribution and separate corpus notices; see [model provenance](../src-tauri/resources/PROVENANCE.md). ## 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. 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. +**1. The word itself.** Database lookup words are normalized lowercase. The pronoun I and its standard contractions (I'm, I'll, I'd and I've) receive a capital I for display. Proper-name capitals are not guessed; use Shift or Caps to enter them. **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. @@ -65,13 +67,13 @@ For the word “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. A capital the person typed is kept, and a word typed in capitals is never retyped. +Accepting restores a capital the typed prefix lacks by backspacing that prefix and typing the whole word, as in “i” for I’m. 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. 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. +The database is not committed. `npm run prediction-model` downloads the pinned release bundle and verifies its 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 @@ -79,18 +81,18 @@ A separate process owns the buffer, activity observer and model. Private bounded ### Spare worker -Loading the model takes about a second, so a keyboard that had a working worker leaves a spare behind when it closes. The worker that held the typed text is killed and reaped first. A new process is then started, which loads the model and waits. It has received no request, so it holds no text, and it starts its activity observer only on its first request, so it observes nothing while it waits. The next keyboard open adopts it and suggestions are ready without a reload. +Loading remains lazy on first keyboard use, so a keyboard that had a working worker leaves a spare behind when it closes. The worker that held the typed text is killed and reaped first. A new process is then started, which loads the model and waits. It has received no request, so it holds no text, and it starts its activity observer only on its first request, so it observes nothing while it waits. The next keyboard open adopts it and suggestions are ready without a reload. -At most one spare exists, and only between keyboard opens. It is killed and reaped after two minutes without a keyboard open, whenever scanning ends or restarts, such as after saving settings, when Word prediction is turned off, on Retry predictions and on exit. A spare started for different switch keys, or one that has exited, is discarded and a new worker is started instead. A keyboard whose worker failed leaves no spare. In each of these cases, and on the first open of a scanning session, the next open loads the model again. A keyboard reopened within about a second adopts a spare that is still loading and shows the loading badge until it finishes. While it waits, the spare holds the loaded model in memory, roughly 250 MB. +At most one spare exists, and only between keyboard opens. It is killed and reaped after two minutes without a keyboard open, whenever scanning ends or restarts, such as after saving settings, when Word prediction is turned off, on Retry predictions and on exit. A spare started for different switch keys, or one that has exited, is discarded and a new worker is started instead. A keyboard whose worker failed leaves no spare. In each of these cases, and on the first open of a scanning session, the next open loads the model again. A keyboard reopened before loading completes adopts a spare that is still loading and shows the loading badge until it finishes. While it waits, the spare holds the loaded model in memory. CI records platform-specific load time and memory measurements rather than assuming a fixed allocation. 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 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: +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` exercises the SQLite adapter with synthetic corpora. Packaged model verification and isolated release-mode measurements run separately on macOS and Windows. For release-build suggestions and timings: ```powershell -cargo test --release --manifest-path src-tauri/Cargo.toml bundled_model_suggests_current_words -- --nocapture +cargo test --release --manifest-path src-tauri/Cargo.toml bundled_prediction_benchmark -- --ignored --nocapture --test-threads=1 ``` For native validation, use disposable synthetic text in Notepad and a browser on Windows, and TextEdit and a browser on macOS. Launch macOS with `npm run macos:run` to retain its signed Accessibility identity. @@ -103,3 +105,19 @@ For native validation, use disposable synthetic text in Notepad and a browser on 6. Close the keyboard, stop scanning, disconnect and exit. Verify input releases and the prediction worker exits. After closing the keyboard one spare worker remains; reopen after a few seconds and within two minutes, and verify the loading badge clears almost immediately, then verify the spare exits after two minutes idle and when scanning stops. Test observer failure separately; typing should remain usable without predictions. Compilation and fake-adapter tests do not establish native application compatibility. Record live results separately. + +### Database adapter measurement + +On the development Apple M2 Max (12 logical CPUs), an isolated release-mode +adapter test loaded the verified 29,802,496-byte database in 1,758 ms. Across +200 warmed queries, p50 was 0.254 ms and p95 was 5.812 ms. Process RSS rose from +10.0 MiB to 371.5 MiB; this includes the Rust test harness and retained allocator +memory, and is not a measurement of the whole desktop application. The SQLite +file size does not describe its in-memory n-gram representation. + +Keep first-use loading asynchronous and lazy: preloading at app startup would +allocate this memory even for people who never open the keyboard. The existing +bounded spare-worker policy handles subsequent opens. Native CI records the same +measurements against extracted Windows and macOS package resources; timing is +reported, not enforced as a flaky shared-runner threshold. See the integration +PR for each platform's report. diff --git a/package-lock.json b/package-lock.json index be00e9b0..220db61e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "fflate": "0.8.3", "jsdom": "^25.0.1", "tailwindcss": "^4.3.3", "typescript": "~5.8.3", @@ -2671,6 +2672,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", diff --git a/package.json b/package.json index 2a92ccd7..362e9053 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "prediction-model": "node scripts/fetch-prediction-model.mjs", - "test": "vitest run && node --test scripts/create-update-feed.node-test.mjs", + "test": "vitest run && node --test scripts/*.node-test.mjs", "lint": "tsc --noEmit", "macos:setup-signing": "./scripts/setup-macos-dev-signing.sh", "macos:run": "./scripts/run-macos-signed.sh", @@ -32,6 +32,7 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", + "fflate": "0.8.3", "jsdom": "^25.0.1", "tailwindcss": "^4.3.3", "typescript": "~5.8.3", diff --git a/scripts/check-packaged-prediction.mjs b/scripts/check-packaged-prediction.mjs new file mode 100644 index 00000000..fb9cc7f0 --- /dev/null +++ b/scripts/check-packaged-prediction.mjs @@ -0,0 +1,12 @@ +// Verify shipped model/notices independently of the source checkout resources. +import { readdir, readFile } from 'node:fs/promises'; +import { join, dirname, resolve } from 'node:path'; +import { verified } from './fetch-prediction-model.mjs'; +const root = resolve(process.argv[2]); +const files = await readdir(root, { recursive: true }); +const models = files.filter(name => name.endsWith('english.sqlite')); +if (models.length !== 1) throw new Error(`Expected one packaged prediction database, found ${models.length}`); +const database = join(root, models[0]); +const manifest = JSON.parse(await readFile(new URL('./prediction-model.json', import.meta.url),'utf8')); +if (!(await verified(dirname(database), manifest))) throw new Error('Packaged prediction files failed verification'); +console.log(database); diff --git a/scripts/fetch-prediction-model.mjs b/scripts/fetch-prediction-model.mjs index fac824c0..818d1f62 100644 --- a/scripts/fetch-prediction-model.mjs +++ b/scripts/fetch-prediction-model.mjs @@ -1,58 +1,80 @@ -// 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"; -import { createReadStream, createWriteStream } from "node:fs"; -import { mkdir, rename, rm, stat } from "node:fs/promises"; -import { dirname, join } from "node:path"; -import { Readable } from "node:stream"; -import { pipeline } from "node:stream/promises"; -import { fileURLToPath } from "node:url"; +// Build-time acquisition only. The installed app never downloads prediction data. +import { createHash } from 'node:crypto'; +import { readFile, writeFile, mkdir, mkdtemp, rename, rm, lstat } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { unzipSync } from 'fflate'; -const revision = "d0ae6834f1df45e0e95b5fdae95e536f9ca7cd3f"; -const base = `https://huggingface.co/onnx-community/SmolLM2-135M-ONNX/resolve/${revision}`; -const files = [ - { name: "model.onnx", source: "onnx/model_int8.onnx", size: 135658354, sha256: "50ba80511ce74634d232a043b6c37775cca756b826b49d0a4a8eff958c4bbcc9" }, - { name: "tokenizer.json", source: "tokenizer.json", size: 2053526, sha256: "139d2f4b4919b90953bdd3c0c40c94c9b23074799a766508dc3bf5eb8ab73351" }, - { name: "config.json", source: "config.json", size: 1035, sha256: "2c5f23fddabecdf9c47d0048f555899822ec87ec4a28169393840ac7e74192c4" }, -]; -const target = join(dirname(fileURLToPath(import.meta.url)), "..", "src-tauri", "resources", "prediction-model"); +const digest = bytes => createHash('sha256').update(bytes).digest('hex'); +const MAX_ARCHIVE = 64 * 1024 * 1024; -async function digest(path) { - const hash = createHash("sha256"); - await pipeline(createReadStream(path), hash); - return hash.digest("hex"); +export async function verified(directory, manifest) { + try { + for (const [name, expected] of Object.entries(manifest.files)) { + const path = join(directory, name); + const info = await lstat(path); + if (!info.isFile() || info.size !== expected.size || digest(await readFile(path)) !== expected.sha256) return false; + } + return true; + } catch { return false; } } -async function verified(path, file) { - try { - return (await stat(path)).size === file.size && (await digest(path)) === file.sha256; - } catch { - return false; +export function unpack(bytes, manifest) { + if (bytes.length > MAX_ARCHIVE || digest(bytes) !== manifest.archive_sha256) throw new Error('Prediction archive checksum mismatch'); + const seen = new Set(); + const entries = unzipSync(new Uint8Array(bytes), { filter: entry => { + const name = entry.name; + if (name.startsWith('/') || name.includes('\\') || name.includes(':') || name.split('/').some(p => p === '..' || p === '.') || seen.has(name)) throw new Error('Unsafe prediction archive path'); + seen.add(name); + if (!Object.hasOwn(manifest.files, name)) return false; + const expected = manifest.files[name]; + if (entry.originalSize !== expected.size) throw new Error('Prediction entry size mismatch'); + return true; + }}); + for (const [name, expected] of Object.entries(manifest.files)) { + if (!entries[name] || entries[name].length !== expected.size || digest(entries[name]) !== expected.sha256) throw new Error('Prediction file checksum mismatch'); } + return entries; } -async function download(file) { - const path = join(target, file.name); - if (await verified(path, file)) return false; - const pending = `${path}.pending`; - for (let attempt = 1; ; attempt++) { - try { - const response = await fetch(`${base}/${file.source}`); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - await pipeline(Readable.fromWeb(response.body), createWriteStream(pending)); - if (!(await verified(pending, file))) throw new Error("size or SHA-256 mismatch"); - await rename(pending, path); - return true; - } catch (error) { - await rm(pending, { force: true }); - if (attempt === 3) throw new Error(`Could not fetch ${file.name}: ${error.message}`); - await new Promise((resolve) => setTimeout(resolve, attempt * 2000)); - } +async function download(url, fetcher) { + const response = await fetcher(url, { signal: AbortSignal.timeout(120000) }); + if (!response.ok || !response.body) throw new Error(`Prediction download failed: HTTP ${response.status}`); + const chunks = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.length; + if (size > MAX_ARCHIVE) throw new Error('Prediction archive too large'); + chunks.push(chunk); } + return Buffer.concat(chunks); +} + +export async function acquire(target, manifest, fetcher = fetch) { + if (await verified(target, manifest)) return false; + // No destination writes until the complete archive and every selected file verify. + const entries = unpack(await download(manifest.url, fetcher), manifest); + await mkdir(dirname(target), { recursive: true }); + const staging = await mkdtemp(`${target}.pending-`); + const previous = `${staging}.previous`; + let moved = false; + try { + for (const [name, bytes] of Object.entries(entries)) { + await mkdir(dirname(join(staging, name)), { recursive: true }); + await writeFile(join(staging, name), bytes); + } + if (!(await verified(staging, manifest))) throw new Error('Prediction staging verification failed'); + try { await rename(target, previous); moved = true; } + catch (error) { if (error.code !== 'ENOENT') throw error; } + try { await rename(staging, target); } + catch (error) { if (moved) await rename(previous, target); throw error; } + await rm(previous, { recursive: true, force: true }); + return true; + } finally { await rm(staging, { recursive: true, force: true }); } } -await mkdir(target, { recursive: true }); -for (const file of files) { - if (await download(file)) console.log(`Fetched ${file.name}`); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const manifest = JSON.parse(await readFile(new URL('./prediction-model.json', import.meta.url), 'utf8')); + const target = fileURLToPath(new URL('../src-tauri/resources/prediction-model', import.meta.url)); + if (await acquire(target, manifest)) console.log(`Fetched and verified ${manifest.model}`); } diff --git a/scripts/fetch-prediction-model.node-test.mjs b/scripts/fetch-prediction-model.node-test.mjs new file mode 100644 index 00000000..5d9f1002 --- /dev/null +++ b/scripts/fetch-prediction-model.node-test.mjs @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, writeFile, rm, readdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { zipSync, strToU8 } from 'fflate'; +import { acquire, unpack, verified } from './fetch-prediction-model.mjs'; +const sha = b => createHash('sha256').update(b).digest('hex'); +function fixture(entries = { 'english.sqlite': strToU8('synthetic database'), 'corpus-notices/license': strToU8('test notice') }) { + const archive = zipSync(entries); + const files = Object.fromEntries(Object.entries(entries).filter(([n]) => !n.startsWith('../')).map(([n,b]) => [n, {sha256: sha(b), size:b.length}])); + return {archive, manifest:{url:'https://example.invalid/model', archive_sha256:sha(archive), files}}; +} +test('verified cache works offline and corrupt cache is replaced with verified files only', async () => { + const dir = await mkdtemp(join(tmpdir(), 'switchify-model-')); + const target = join(dir, 'model'); + const {archive,manifest} = fixture(); + let requests = 0; + const fetcher = async () => { requests++; return new Response(archive); }; + try { + assert.equal(await acquire(target, manifest, fetcher), true); + assert.equal(await verified(target, manifest), true); + assert.equal(await acquire(target, manifest, () => { throw Error('offline'); }), false); + assert.equal(requests, 1); + await writeFile(join(target,'english.sqlite'), 'corrupt'); + assert.equal(await acquire(target, manifest, fetcher), true); + assert.equal(await verified(target, manifest), true); + assert.deepEqual(await readdir(dir), ['model']); + } finally { await rm(dir, {recursive:true, force:true}); } +}); +test('corrupt and interrupted downloads do not replace existing resources or leave staging files', async () => { + const dir = await mkdtemp(join(tmpdir(), 'switchify-model-')); + const target = join(dir,'model'); + const {archive,manifest} = fixture(); + try { + await acquire(target,manifest,async()=>new Response(archive)); + await writeFile(join(target,'english.sqlite'), 'old cache'); + await assert.rejects(acquire(target,manifest,async()=>new Response('bad')), /checksum/); + await assert.rejects(acquire(target,manifest,async()=>new Response(new ReadableStream({start(c){c.enqueue(archive.subarray(0,8));c.error(Error('interrupted'));}}))), /interrupted/); + assert.equal(await readFile(join(target,'english.sqlite'),'utf8'),'old cache'); + assert.deepEqual(await readdir(dir),['model']); + } finally { await rm(dir,{recursive:true,force:true}); } +}); +test('archive paths, missing files, and per-file tampering fail closed', () => { + for (const path of ['../escape','/absolute','C:/absolute','folder\\escape','folder/../escape']) { + const {archive,manifest}=fixture({'english.sqlite':strToU8('db'),[path]:strToU8('bad')}); + assert.throws(()=>unpack(archive,manifest), /Unsafe/); + } + const {archive,manifest}=fixture(); + manifest.files['missing.txt']={size:1,sha256:sha(strToU8('x'))}; + assert.throws(()=>unpack(archive,manifest), /checksum/); + delete manifest.files['missing.txt']; + manifest.files['english.sqlite'].sha256='0'.repeat(64); + assert.throws(()=>unpack(archive,manifest), /checksum/); +}); +test('unlisted files are never extracted', () => { + const {archive,manifest}=fixture({'english.sqlite':strToU8('db'),'unused.txt':strToU8('extra')}); + delete manifest.files['unused.txt']; + assert.deepEqual(Object.keys(unpack(archive,manifest)),['english.sqlite']); +}); diff --git a/scripts/prediction-model.json b/scripts/prediction-model.json new file mode 100644 index 00000000..0888681c --- /dev/null +++ b/scripts/prediction-model.json @@ -0,0 +1,52 @@ +{ + "model": "en-aac-oanc-v1", + "revision": "c4b9d14ba8312179617ead4d2707e86ec825a2e6", + "url": "https://github.com/switchifyapp/switchify-prediction/releases/download/v0.1.0/switchify-english-en-aac-oanc-v1.zip", + "archive_sha256": "4537c70b44f553b31e618378a5cc500cd83ffa2dff940263ecabf88e18945b17", + "files": { + "english.sqlite": { + "sha256": "222253417d0a7a705823ffb7e599a3bcf5d5d3daf4a9d76161ac6b3e555aeaad", + "size": 29802496 + }, + "production-model.json": { + "sha256": "70be7dc26a372eef4e8731ef8a2ea4ef19fb9dc5d93803d14e65b02b232b7424", + "size": 12352 + }, + "source-manifest.json": { + "sha256": "5421600b9be1063599e8adc07ee14a4ff4bdbf91a07b6a9d4fcfc793e2aafd7d", + "size": 4624 + }, + "aac-source-manifest.json": { + "sha256": "4da7fef8208e9829881b8ae656d941a7db9bf22aab49a4a8e3d029576b847879", + "size": 3015 + }, + "ATTRIBUTION.md": { + "sha256": "3922703e6f3bb669ded05674f034d7f9ccfd7d359805d12439191496c7755266", + "size": 4188 + }, + "LICENSE": { + "sha256": "1e5643d5431cc5b2d04534dee7b3f716b24fc3b82493a1659534cbfa4fc8732c", + "size": 1079 + }, + "corpus-notices/aac-notice.html": { + "sha256": "d803db680180170cbdb8f6dafd8202ba29198ce9399f7f16b9ae802406e958d0", + "size": 7737 + }, + "corpus-notices/aac-readme.txt": { + "sha256": "556d1e4e136cf2f6b16296880df86301c260b669ea2eb4566ca164cdadc0f850", + "size": 2309 + }, + "corpus-notices/oanc-download.html": { + "sha256": "1a0e43a2434517dc58d8806b955b72ba3838ea47a70a77e7c767bdb38d185847", + "size": 6583 + }, + "corpus-notices/oanc-legacy-license.txt": { + "sha256": "b0dcd84d8980a7bbe5a9d82786b95376ef1c91bbd89c34969e34ef81cdc440a6", + "size": 12198 + }, + "corpus-notices/oanc-notice.html": { + "sha256": "d8d0e58baa168e99451586e4de0c211f801cb0a43238593a51426d98d9dea605", + "size": 5249 + } + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 606976c0..5a56c55f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -19,20 +19,6 @@ dependencies = [ "cpufeatures 0.2.17", ] -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "serde", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -66,6 +52,56 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -313,12 +349,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -331,12 +361,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - [[package]] name = "bit-set" version = "0.8.0" @@ -537,15 +561,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "castaway" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" -dependencies = [ - "rustversion", -] - [[package]] name = "cbc" version = "0.1.2" @@ -639,6 +654,46 @@ dependencies = [ "inout", ] +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + [[package]] name = "cmake" version = "0.1.58" @@ -648,6 +703,12 @@ dependencies = [ "cc", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "combine" version = "4.6.7" @@ -658,21 +719,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compact_str" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "serde", - "static_assertions", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -778,25 +824,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.13" @@ -861,38 +888,14 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", + "darling_core", + "darling_macro", ] [[package]] @@ -908,37 +911,17 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.119", -] - [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.119", ] -[[package]] -name = "dary_heap" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" -dependencies = [ - "serde", -] - [[package]] name = "dbus" version = "0.9.12" @@ -970,37 +953,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn 2.0.119", -] - [[package]] name = "derive_more" version = "2.1.1" @@ -1205,12 +1157,6 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - [[package]] name = "embed-resource" version = "3.0.11" @@ -1248,7 +1194,7 @@ dependencies = [ "foreign-types-shared", "libc", "log", - "nom 8.0.0", + "nom", "objc2", "objc2-app-kit", "objc2-foundation", @@ -1306,12 +1252,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "esaxx-rs" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" - [[package]] name = "event-listener" version = "5.4.2" @@ -1333,15 +1273,16 @@ dependencies = [ ] [[package]] -name = "fancy-regex" -version = "0.14.0" +name = "fallible-iterator" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" @@ -1844,11 +1785,32 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -1892,12 +1854,6 @@ dependencies = [ "digest", ] -[[package]] -name = "hmac-sha256" -version = "1.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" - [[package]] name = "html5ever" version = "0.38.0" @@ -2210,13 +2166,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] -name = "itertools" -version = "0.14.0" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" @@ -2454,6 +2407,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2487,28 +2451,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-rust2" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" - -[[package]] -name = "macro_rules_attribute" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" -dependencies = [ - "macro_rules_attribute-proc_macro", - "pastey", -] - -[[package]] -name = "macro_rules_attribute-proc_macro" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" - [[package]] name = "markup5ever" version = "0.38.0" @@ -2520,16 +2462,6 @@ dependencies = [ "web_atoms", ] -[[package]] -name = "matrixmultiply" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" -dependencies = [ - "autocfg", - "rawpointer", -] - [[package]] name = "memchr" version = "2.8.3" @@ -2560,12 +2492,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "minisign-verify" version = "0.2.5" @@ -2593,28 +2519,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "monostate" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" -dependencies = [ - "monostate-impl", - "serde", - "serde_core", -] - -[[package]] -name = "monostate-impl" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "moxcms" version = "0.8.1" @@ -2646,21 +2550,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ndarray" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" -dependencies = [ - "matrixmultiply", - "num-complex", - "num-integer", - "num-traits", - "portable-atomic", - "portable-atomic-util", - "rawpointer", -] - [[package]] name = "ndk" version = "0.9.0" @@ -2691,16 +2580,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nom" version = "8.0.0" @@ -3047,6 +2926,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -3069,30 +2954,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "ort" -version = "2.0.0-rc.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4336a1e2b38848325241c72889086886004e589b7c74f335e60a8e8db5138a0b" -dependencies = [ - "ndarray", - "ort-sys", - "smallvec", - "tracing", - "ureq", -] - -[[package]] -name = "ort-sys" -version = "2.0.0-rc.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf211e3776eea6aec988552fa118dd746d70e1b1e5e244058d1c98015f3e5872" -dependencies = [ - "hmac-sha256", - "lzma-rust2", - "ureq", -] - [[package]] name = "osakit" version = "0.3.1" @@ -3161,18 +3022,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" - [[package]] name = "percent-encoding" version = "2.3.2" @@ -3308,21 +3157,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -3515,20 +3349,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -3550,16 +3374,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -3569,15 +3383,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -3599,43 +3404,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rawpointer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-cond" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" -dependencies = [ - "either", - "itertools", - "rayon", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3770,6 +3538,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.19", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3805,7 +3598,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -3881,12 +3673,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "same-file" version = "1.0.6" @@ -4154,7 +3940,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling 0.23.0", + "darling", "proc-macro2", "quote", "syn 2.0.119", @@ -4268,17 +4054,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "socks" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" -dependencies = [ - "byteorder", - "libc", - "winapi", -] - [[package]] name = "softbuffer" version = "0.4.8" @@ -4328,15 +4103,15 @@ dependencies = [ ] [[package]] -name = "spm_precompiled" -version = "0.1.4" +name = "sqlite-wasm-rs" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ - "base64 0.13.1", - "nom 7.1.3", - "serde", - "unicode-segmentation", + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", ] [[package]] @@ -4345,12 +4120,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strict-num" version = "0.1.1" @@ -4424,19 +4193,18 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "objc2-quartz-core", - "ort", "rand 0.8.7", "reqwest", "serde", "serde_json", "sha2", + "switchify-prediction", "tauri", "tauri-build", "tauri-plugin-autostart", "tauri-plugin-single-instance", "tauri-plugin-updater", "tiny-skia", - "tokenizers", "tokio", "unicode-segmentation", "uuid", @@ -4445,6 +4213,22 @@ dependencies = [ "winreg 0.55.0", ] +[[package]] +name = "switchify-prediction" +version = "0.1.0" +source = "git+https://github.com/switchifyapp/switchify-prediction?rev=c4b9d14ba8312179617ead4d2707e86ec825a2e6#c4b9d14ba8312179617ead4d2707e86ec825a2e6" +dependencies = [ + "clap", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.19", + "unicode-normalization", + "unicode-segmentation", +] + [[package]] name = "syn" version = "1.0.109" @@ -5014,39 +4798,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tokenizers" -version = "0.22.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" -dependencies = [ - "ahash", - "aho-corasick", - "compact_str", - "dary_heap", - "derive_builder", - "esaxx-rs", - "fancy-regex", - "getrandom 0.3.4", - "itertools", - "log", - "macro_rules_attribute", - "monostate", - "paste", - "rand 0.9.5", - "rayon", - "rayon-cond", - "regex", - "regex-syntax", - "serde", - "serde_json", - "spm_precompiled", - "thiserror 2.0.19", - "unicode-normalization-alignments", - "unicode-segmentation", - "unicode_categories", -] - [[package]] name = "tokio" version = "1.53.1" @@ -5391,12 +5142,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-normalization-alignments" -version = "0.1.12" +name = "unicode-normalization" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ - "smallvec", + "tinyvec", ] [[package]] @@ -5405,47 +5156,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" -dependencies = [ - "base64 0.23.1", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "socks", - "ureq-proto", - "utf8-zero", - "webpki-roots", -] - -[[package]] -name = "ureq-proto" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" -dependencies = [ - "base64 0.23.1", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -5471,18 +5187,18 @@ dependencies = [ "url", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" @@ -5495,6 +5211,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -5714,15 +5436,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webview2-com" version = "0.38.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4296429b..b69d8b77 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,13 +14,13 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] +switchify-prediction = { git = "https://github.com/switchifyapp/switchify-prediction", rev = "c4b9d14ba8312179617ead4d2707e86ec825a2e6" } anyhow = "1" base64 = "0.22" directories = "6.0.0" enigo = "=0.6.1" hmac = "0.12" keyring = "4.1.6" -ort = { version = "=2.0.0-rc.13", default-features = false, features = ["std", "download-binaries", "tls-rustls", "api-27"] } rand = "0.8" unicode-segmentation = "1.12" reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] } @@ -32,7 +32,6 @@ tauri-plugin-autostart = "2.5.1" tauri-plugin-single-instance = "2.4.3" tauri-plugin-updater = "2.10.1" tiny-skia = "0.11.4" -tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] } tokio = { version = "1", features = ["macros", "rt", "sync", "time"] } uuid = { version = "1", features = ["v4", "serde"] } diff --git a/src-tauri/resources/PROVENANCE.md b/src-tauri/resources/PROVENANCE.md index e94a8437..e55676dc 100644 --- a/src-tauri/resources/PROVENANCE.md +++ b/src-tauri/resources/PROVENANCE.md @@ -1,13 +1,26 @@ -# Word prediction model +# English prediction database -`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: +Switchify PC uses `switchify-prediction` v0.1.0 at commit +`c4b9d14ba8312179617ead4d2707e86ec825a2e6` and model `en-aac-oanc-v1`. +The Rust dependency is pinned by commit and Cargo.lock. The baseline contains +21,674 words and 539,151 n-grams. Its SHA-256 is +`222253417d0a7a705823ffb7e599a3bcf5d5d3daf4a9d76161ac6b3e555aeaad`. -- `model.onnx`: upstream `onnx/model_int8.onnx`, 135,658,354 bytes, SHA-256 `50ba80511ce74634d232a043b6c37775cca756b826b49d0a4a8eff958c4bbcc9`. -- `tokenizer.json`: 2,053,526 bytes, SHA-256 `139d2f4b4919b90953bdd3c0c40c94c9b23074799a766508dc3bf5eb8ab73351`. -- `config.json`: 1,035 bytes, SHA-256 `2c5f23fddabecdf9c47d0048f555899822ec87ec4a28169393840ac7e74192c4`. +`npm run prediction-model` downloads the [versioned release](https://github.com/switchifyapp/switchify-prediction/releases/tag/v0.1.0) +and verifies the archive and each shipped resource against +`scripts/prediction-model.json`. No raw training files or personal data are bundled. +The installed app uses the database offline and read-only. No ONNX runtime, +model, tokenizer or third-party native-runtime download is used. -It is an int8 ONNX conversion of [HuggingFaceTB/SmolLM2-135M](https://huggingface.co/HuggingFaceTB/SmolLM2-135M), licensed Apache-2.0. The app bundles these files unchanged and only reads them. +New library code is MIT licensed; SQLite is public domain. Corpus licensing is +separate: WorldAlphabets identifies its English Tatoeba CC0 source; Taskmaster +and AAC are CC BY 4.0. OANC's current publisher grants unrestricted use and +redistribution, including commercial use; its historical XML-release notice has +different restrictions. Both notices remain included, and no independent legal +clearance is claimed. -# ONNX Runtime - -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. +The application bundles ATTRIBUTION.md, LICENSE, production-model.json, +source-manifest.json, aac-source-manifest.json and corpus-notices alongside the +database. Retain these when redistributing. The corpus includes crowd-imagined +AAC communication and older American speech; suggestions can be inappropriate +or incorrect. Model quality results are available in the upstream release. diff --git a/src-tauri/src/prediction/context.rs b/src-tauri/src/prediction/context.rs index cc9a00c2..c5e8d592 100644 --- a/src-tauri/src/prediction/context.rs +++ b/src-tauri/src/prediction/context.rs @@ -10,14 +10,16 @@ pub struct Context { } pub fn extract(text: &str, clipped: bool) -> Context { let context = extract_words(text, clipped); - let mut before = &text[..text.len() - context.prefix.len()]; - if clipped { + let boundary = text.rfind(['.', '!', '?', '\n', '\r', '。', '!', '?']); + let start = boundary.map_or(0, |i| i + text[i..].chars().next().unwrap().len_utf8()); + let mut before = &text[start..text.len() - context.prefix.len()]; + if clipped && start == 0 { before = before .find(char::is_whitespace) .map_or("", |i| &before[i..]); } Context { - partial: clipped && before.trim().is_empty(), + partial: clipped && start == 0 && before.trim().is_empty(), before: before.to_owned(), ..context } @@ -30,11 +32,13 @@ fn extract_words(text: &str, clipped: bool) -> Context { if clipped && start == 0 && tokens.first().is_some_and(|(i, _)| *i == 0) { tokens.remove(0); } - let prefix = if tokens - .last() - .is_some_and(|(i, w)| i + w.len() == tail.len()) - { - tokens.pop().unwrap().1.to_owned() + let prefix = if tokens.last().is_some_and(|(i, w)| { + i + w.len() == tail.len() + || (matches!(&tail[i + w.len()..], "'" | "’") + && w.chars().last().is_some_and(char::is_alphabetic)) + }) { + let (i, _) = tokens.pop().unwrap(); + tail[i..].to_owned() } else { String::new() }; @@ -53,12 +57,17 @@ mod tests { assert_eq!(c.prefix, "cafe\u{301}"); assert_eq!( extract("Hi. I would like wa", false).before, - "Hi. I would like " + " I would like " ); assert_eq!(extract("agment whole pa", true).before, " whole "); assert_eq!(extract("agment", true).before, ""); assert!(extract("agment", true).partial); assert!(!extract("agment whole pa", true).partial); assert!(!extract("wa", false).partial); + assert_eq!(extract("I think i’", false).prefix, "i’"); + assert_eq!(extract("I can'", false).prefix, "can'"); + assert_eq!(extract("Old context。I need h", false).before, "I need "); + assert!(!extract("agment. h", true).partial); + assert_eq!(extract("I can't", false).prefix, "can't"); } } diff --git a/src-tauri/src/prediction/database.rs b/src-tauri/src/prediction/database.rs index 985bfebd..8b1edd83 100644 --- a/src-tauri/src/prediction/database.rs +++ b/src-tauri/src/prediction/database.rs @@ -8,11 +8,7 @@ use std::{ time::{Duration, Instant}, }; -const SEARCH_BUDGET: Duration = Duration::from_millis(400); -/// Extra time after the word search for two-word phrases. -const PHRASE_BUDGET: Duration = Duration::from_millis(200); -/// Well under the parent's two-second reply deadline, with room for the -/// budgets above plus the beam step that may overrun each of them. +/// Repeated stalls mark the model unavailable before the parent reply deadline. const SLOW_CALL: Duration = Duration::from_millis(1500); /// Slow calls in a row that mark the model unavailable. One stall, such as /// a busy disk or a waking laptop, is not a model that cannot keep up. @@ -74,12 +70,7 @@ impl Database { unreachable!() }; let start = Instant::now(); - let result = model.predict( - &context.before, - &context.prefix, - start + SEARCH_BUDGET, - start + SEARCH_BUDGET + PHRASE_BUDGET, - ); + let result = model.predict(&context.before, &context.prefix); self.slow_calls = if start.elapsed() > self.slow_call { self.slow_calls + 1 } else { @@ -90,7 +81,6 @@ impl Database { return (Status::Unavailable, Prediction::default()); }; prediction.words.truncate(5); - prediction.phrases.truncate(2); (Status::Ready, prediction) } #[cfg(test)] @@ -107,20 +97,17 @@ impl Database { struct FakeModel; #[cfg(test)] impl Predict for FakeModel { - fn predict( - &mut self, - _before: &str, - prefix: &str, - _deadline: Instant, - _phrase_deadline: Instant, - ) -> Result { - let words: Vec = ["water", "waffle", "walk", "WhatsApp"] - .into_iter() - .filter(|w| w.to_lowercase().starts_with(&prefix.to_lowercase())) - .map(str::to_owned) - .collect(); - let phrases = words.iter().take(1).map(|w| format!("{w} is")).collect(); - Ok(Prediction { words, phrases }) + fn predict(&mut self, _before: &str, prefix: &str) -> Result { + let words: Vec = [ + "water", "waffle", "walk", "WhatsApp", "café", "can't", "I'm", + ] + .into_iter() + .filter(|w| { + switchify_prediction::normalize(w).starts_with(&switchify_prediction::normalize(prefix)) + }) + .map(str::to_owned) + .collect(); + Ok(Prediction { words }) } } @@ -129,17 +116,10 @@ mod tests { use super::*; struct Fake(Result, ()>, Duration); impl Predict for Fake { - fn predict( - &mut self, - _before: &str, - _prefix: &str, - _deadline: Instant, - _phrase_deadline: Instant, - ) -> Result { + fn predict(&mut self, _before: &str, _prefix: &str) -> Result { std::thread::sleep(self.1); self.0.clone().map(|w| Prediction { words: w.iter().map(|w| (*w).to_owned()).collect(), - phrases: w.iter().map(|w| format!("{w} too")).collect(), }) } } @@ -183,7 +163,7 @@ mod tests { Duration::ZERO, ))); let prediction = db.predict(&context()).1; - assert_eq!((prediction.words.len(), prediction.phrases.len()), (5, 2)); + assert_eq!(prediction.words.len(), 5); db.state = State::Ready(Box::new(Fake(Err(()), Duration::ZERO))); assert_eq!( db.predict(&context()), diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index a509a5f9..c4265472 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -616,18 +616,17 @@ fn may_delete(backspaces: usize, chosen: (u64, bool), now: (u64, bool)) -> bool backspaces == 0 || (chosen.1 && now == chosen) } -/// The model's config file; its directory is the model. A file rather than -/// the directory, so a half-copied bundle is not mistaken for a model. +/// Resolve the baseline file; the worker verifies its pinned identity before loading. fn resource(app: &AppHandle) -> Result { let bundled = app .path() .resolve( - "resources/prediction-model/config.json", + "resources/prediction-model/english.sqlite", tauri::path::BaseDirectory::Resource, ) .map_err(|_| ()); let development = cfg!(debug_assertions).then(|| { - Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/prediction-model/config.json") + Path::new(env!("CARGO_MANIFEST_DIR")).join("resources/prediction-model/english.sqlite") }); model_resource(bundled, development) } diff --git a/src-tauri/src/prediction/model.rs b/src-tauri/src/prediction/model.rs index b45acf80..19a9ba60 100644 --- a/src-tauri/src/prediction/model.rs +++ b/src-tauri/src/prediction/model.rs @@ -1,851 +1,210 @@ -//! On-device language model for word prediction. -//! -//! The model spells candidate words from subword pieces, constrained to the -//! typed prefix. -//! A word scores log P(pieces | text) + log P(a word boundary follows), with -//! case variants merged. Low probability words never appear. There is no -//! vocabulary filter: the person typing chose the word. Runs only inside -//! the prediction worker; text is never logged. -use ort::{ - session::{builder::GraphOptimizationLevel, Session, SessionInputValue}, - value::Tensor, -}; -use std::{ - borrow::Cow, - collections::{HashMap, HashSet}, - path::Path, - time::Instant, -}; -use tokenizers::Tokenizer; +//! Read-only English word prediction. Only the isolated worker holds context. +use sha2::{Digest, Sha256}; +use std::{fs::File, io::Read, path::Path}; +use switchify_prediction::{Options, Predictor}; -const BEAM: usize = 16; -const MAX_PIECES: usize = 5; -/// Pieces below this log-probability are never explored. -const PRUNE: f32 = -18.0; -/// Minimum log-probability for a candidate word. -const UNKNOWN_MIN: f32 = -16.0; -/// Most recent buffered characters the model reads. -const CONTEXT_CHARS: usize = 256; +const DATABASE_SHA256: &str = "222253417d0a7a705823ffb7e599a3bcf5d5d3daf4a9d76161ac6b3e555aeaad"; -pub trait Scorer { - /// Next-piece log-probabilities after `text`, which becomes the base for `extend`. - fn context(&mut self, text: &str) -> Result, ()>; - /// Next-piece log-probabilities after each continuation of the last context. - fn extend(&mut self, continuations: &[Vec]) -> Result>, ()>; -} - -pub struct Vocabulary { - pieces: Vec, - /// Pieces that can end a word: space-led, punctuation or special tokens. - boundary: Vec, - /// Pieces made only of ASCII letters and apostrophes. - continues: Vec, -} - -impl Vocabulary { - pub fn new(pieces: Vec) -> Self { - let wordy = |s: &str| { - !s.is_empty() - && s.chars() - .all(|c| c.is_ascii_alphabetic() || c == '\'' || c == '’') - }; - Self { - boundary: pieces - .iter() - .map(|p| { - !p.chars() - .next() - .is_some_and(|c| c.is_alphanumeric() || c == '\'' || c == '’') - }) - .collect(), - continues: pieces.iter().map(|p| wordy(p)).collect(), - pieces, - } - } -} - -fn normalize(word: &str) -> String { - word.to_lowercase().replace('’', "'") -} - -/// Words and phrases for one typed prefix, most probable first. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Prediction { pub words: Vec, - pub phrases: Vec, -} - -/// A word (or phrase) the beam finished: its probability mass over spellings, -/// how much of it spells the last word with a capital, the best spelling, -/// that spelling's pieces and their log-probability. -struct Finished { - mass: f32, - capital: f32, - best: f32, - spelling: String, - pieces: Vec, - logp: f32, -} - -type Beam = Vec<(f32, Vec, String)>; - -/// Words the beam keeps must be able to become `prefix`, or already be it. -fn compatible(prefix: &str, word: &str) -> bool { - let w = normalize(word); - w.starts_with(prefix) || prefix.starts_with(&w) -} - -/// The word `piece` begins, or `None` if it cannot begin one. At the very -/// start of text a capital marks a word start, since fragments are never -/// capitalised; elsewhere a word begins after a space. -fn word_start(piece: &str, start: bool) -> Option<&str> { - let word = if start { - piece - } else { - piece.strip_prefix(' ')? - }; - let first = word.chars().next()?; - let begins = if start { - first.is_uppercase() - } else { - first.is_ascii_alphabetic() - }; - (begins - && word - .chars() - .all(|c| c.is_ascii_alphabetic() || c == '\'' || c == '’')) - .then_some(word) -} - -/// Extends `beam` piece by piece, collecting entries that reach a word -/// boundary and match `prefix`, until `MAX_PIECES` or `deadline`. -fn grow( - scorer: &mut impl Scorer, - vocabulary: &Vocabulary, - mut beam: Beam, - prefix: &str, - deadline: Instant, -) -> Result, ()> { - let mut finished: HashMap = HashMap::new(); - let fifth = |finished: &HashMap| { - // Only entries that can survive the final filter may prune the beam. - let mut p: Vec = finished - .values() - .filter(|f| f.mass.ln() >= UNKNOWN_MIN) - .map(|f| f.mass) - .collect(); - p.sort_by(|a, b| b.total_cmp(a)); - p.get(4).map_or(f32::NEG_INFINITY, |p| p.ln()) - }; - for _ in 0..MAX_PIECES { - if Instant::now() >= deadline { - break; - } - beam.sort_by(|a, b| b.0.total_cmp(&a.0)); - let bound = fifth(&finished); - beam.retain(|b| b.0 > bound); - beam.truncate(BEAM); - if beam.is_empty() { - break; - } - let rows = scorer.extend(&beam.iter().map(|b| b.1.clone()).collect::>())?; - if rows.len() != beam.len() { - return Err(()); - } - let mut grown = Vec::new(); - for ((logp, pieces, word), row) in beam.iter().zip(&rows) { - if row.len() != vocabulary.pieces.len() { - return Err(()); - } - let key = normalize(word); - let plausible = key.chars().count() > 1 || key == "a" || key == "i"; - if plausible && key.starts_with(prefix) { - let boundary: f32 = row - .iter() - .zip(&vocabulary.boundary) - .filter(|(_, b)| **b) - .map(|(l, _)| l.exp()) - .sum(); - let score = logp + boundary.max(1e-12).ln(); - let capital = word - .rsplit(' ') - .next() - .and_then(|last| last.chars().next()) - .is_some_and(char::is_uppercase); - let entry = finished.entry(key).or_insert(Finished { - mass: 0.0, - capital: 0.0, - best: f32::NEG_INFINITY, - spelling: String::new(), - pieces: Vec::new(), - logp: f32::NEG_INFINITY, - }); - entry.mass += score.exp(); - if capital { - entry.capital += score.exp(); - } - if score > entry.best { - entry.best = score; - entry.spelling = word.clone(); - entry.pieces = pieces.clone(); - entry.logp = *logp; - } - } - for (id, piece) in vocabulary.pieces.iter().enumerate() { - if vocabulary.continues[id] && row[id] > PRUNE { - let longer = format!("{word}{piece}"); - if compatible(prefix, &longer) { - let mut pieces = pieces.clone(); - pieces.push(id as i64); - grown.push((logp + row[id], pieces, longer)); - } - } - } - } - beam = grown; - } - Ok(finished) -} - -/// The finished entries worth showing, most probable first. -fn ranked(finished: HashMap, limit: usize) -> Vec<(String, Finished)> { - let mut entries: Vec<(String, Finished)> = finished - .into_iter() - .filter(|(_, f)| f.mass.ln() >= UNKNOWN_MIN) - .collect(); - entries.sort_by(|a, b| b.1.mass.total_cmp(&a.1.mass).then_with(|| a.0.cmp(&b.0))); - entries.truncate(limit); - entries -} - -/// Capitalised spellings must carry this share of a word's probability for -/// the capital to be a name rather than a coin toss on a rare word. -const NAME_SHARE: f32 = 0.9; - -/// How a finished word is shown. All-caps tokens never force uppercase onto -/// a completion. A name keeps the model's capital: mixed case anywhere, or an -/// initial capital mid-sentence that the model clearly prefers, which it only -/// does for a proper noun. At a sentence start the model capitalises every -/// word, so the lowercase form wins there and the sentence rule decides. The -/// pronoun I and its contractions are always capital, and the typed -/// apostrophe style is kept. -fn display(key: &str, f: &Finished, typed: &str, sentence_start: bool) -> String { - let spelling = &f.spelling; - let all_upper = spelling.chars().all(|c| c.is_ascii_uppercase()); - let mixed = spelling.chars().skip(1).any(|c| c.is_ascii_uppercase()); - let name = spelling.chars().next().is_some_and(char::is_uppercase) - && !sentence_start - && f.capital >= NAME_SHARE * f.mass; - let word = if key == "i" || key.starts_with("i'") { - format!("I{}", &key[1..]) - } else if !all_upper && (mixed || name) { - spelling.replace('’', "'") - } else { - key.to_owned() - }; - if typed.contains('’') { - word.replace('\'', "’") - } else { - word - } -} - -/// Up to five words for `prefix` after `before`, most probable first. -/// Expansion stops at `deadline`, returning the words finished so far. -#[cfg(test)] -fn spell( - scorer: &mut impl Scorer, - vocabulary: &Vocabulary, - before: &str, - prefix: &str, - deadline: Instant, -) -> Result, ()> { - complete(scorer, vocabulary, before, prefix, deadline, deadline).map(|p| p.words) } -/// Words for `prefix` after `before`, plus up to `PHRASES` two-word phrases -/// that continue the likeliest of them, ranked by the probability of the -/// pair. Word search stops at `deadline` and phrase search at -/// `phrase_deadline`, so phrases never delay the words. -pub fn complete( - scorer: &mut impl Scorer, - vocabulary: &Vocabulary, - before: &str, - prefix: &str, - deadline: Instant, - phrase_deadline: Instant, -) -> Result { - let typed = prefix; - let prefix = normalize(prefix); - let text = before.trim_end(); - // A word after the last one needs a leading space. - let start = text.is_empty(); - if !start && text.len() == before.len() { - return Ok(Prediction::default()); - } - let next = scorer.context(text)?; - if next.len() != vocabulary.pieces.len() { - return Err(()); - } - let mut beam = Beam::new(); - for (id, piece) in vocabulary.pieces.iter().enumerate() { - if let Some(word) = word_start(piece, start) { - if next[id] > PRUNE && compatible(&prefix, word) { - beam.push((next[id], vec![id as i64], word.to_owned())); - } - } - } - let words = ranked(grow(scorer, vocabulary, beam, &prefix, deadline)?, 5); - let phrases = follow(scorer, vocabulary, &words, phrase_deadline)?; - let sentence_start = start || text.ends_with(['.', '!', '?']); - Ok(Prediction { - words: words - .iter() - .map(|(key, f)| display(key, f, typed, sentence_start)) - .collect(), - phrases: phrases - .into_iter() - .map(|(lead, key, f)| { - let second_key = key.split_once(' ').map_or("", |(_, s)| s); - let second = Finished { - spelling: f.spelling.split_once(' ').map_or("", |(_, s)| s).to_owned(), - ..f - }; - format!( - "{} {}", - display(&words[lead].0, &words[lead].1, typed, sentence_start), - display(second_key, &second, typed, false) - ) - }) - .collect(), - }) +pub trait Predict: Send { + fn predict(&mut self, before: &str, prefix: &str) -> Result; } -/// How many of the top words are followed, and how many phrases result. -const PHRASE_LEADS: usize = 3; -const PHRASES: usize = 2; - -/// The likeliest next words after the top `PHRASE_LEADS` words, as -/// `(lead index, "lead second" key, finished phrase)`. One beam spells every -/// lead's continuation at once from the same cached context, scored by the -/// probability of the second word given its lead so that no lead crowds the -/// others out. Pairs are then ranked by joint probability, preferring one -/// phrase per lead so the row shows different words. -fn follow( - scorer: &mut impl Scorer, - vocabulary: &Vocabulary, - words: &[(String, Finished)], - deadline: Instant, -) -> Result, ()> { - let leads: Vec<&(String, Finished)> = words.iter().take(PHRASE_LEADS).collect(); - if leads.is_empty() || Instant::now() >= deadline { - return Ok(Vec::new()); - } - let rows = scorer.extend( - &leads - .iter() - .map(|(_, f)| f.pieces.clone()) - .collect::>(), - )?; - if rows.len() != leads.len() { - return Err(()); - } - let mut beam = Beam::new(); - for ((_, lead), row) in leads.iter().zip(&rows) { - if row.len() != vocabulary.pieces.len() { - return Err(()); - } - for (id, piece) in vocabulary.pieces.iter().enumerate() { - if let Some(word) = word_start(piece, false) { - if row[id] > PRUNE { - let mut pieces = lead.pieces.clone(); - pieces.push(id as i64); - beam.push((row[id], pieces, format!("{} {word}", lead.spelling))); - } - } - } - } - let finished = grow(scorer, vocabulary, beam, "", deadline)?; - let mut candidates: Vec<(f32, usize, String, Finished)> = finished - .into_iter() - .filter_map(|(key, f)| { - let (first, second) = key.split_once(' ')?; - // The second word obeys the single-letter rule on its own. - if !(second.chars().count() > 1 || second == "a" || second == "i") { - return None; +pub struct Model(Predictor); +impl Model { + pub fn open(path: &Path) -> Result { + let mut file = File::open(path).map_err(|_| ())?; + let mut hash = Sha256::new(); + let mut buffer = [0; 65536]; + loop { + let n = file.read(&mut buffer).map_err(|_| ())?; + if n == 0 { + break; } - let lead = leads.iter().position(|(k, _)| k == first)?; - let joint = leads[lead].1.logp + f.mass.ln(); - (joint >= UNKNOWN_MIN).then_some((joint, lead, key, f)) - }) - .collect(); - candidates.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.2.cmp(&b.2))); - let mut phrases: Vec<(usize, String, Finished)> = Vec::new(); - let mut rest = Vec::new(); - for (_, lead, key, f) in candidates { - if phrases.len() < PHRASES && !phrases.iter().any(|(l, _, _)| *l == lead) { - phrases.push((lead, key, f)); - } else { - rest.push((lead, key, f)); + hash.update(&buffer[..n]); } - } - phrases.extend(rest.into_iter().take(PHRASES - phrases.len().min(PHRASES))); - Ok(phrases) -} - -struct Past { - keys: Vec>, - values: Vec>, - len: usize, -} - -pub struct Onnx { - session: Session, - tokenizer: Tokenizer, - layers: usize, - heads: usize, - dim: usize, - bos: i64, - context: Option<(String, Past, Vec)>, -} - -fn log_softmax(logits: &[f32]) -> Vec { - let max = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max); - let norm = max + logits.iter().map(|l| (l - max).exp()).sum::().ln(); - logits.iter().map(|l| l - norm).collect() -} - -impl Onnx { - fn open(dir: &Path) -> Result { - // Typed text must never leave the worker, so ONNX Runtime telemetry stays off. - ort::init().with_telemetry(false).commit(); - let threads = std::thread::available_parallelism().map_or(1, |n| n.get().min(4)); - let builder = Session::builder().map_err(|_| ())?; - let builder = builder - .with_optimization_level(GraphOptimizationLevel::Level3) - .map_err(|_| ())?; - let mut builder = builder.with_intra_threads(threads).map_err(|_| ())?; - let session = builder - .commit_from_file(dir.join("model.onnx")) - .map_err(|_| ())?; - let tokenizer = Tokenizer::from_file(dir.join("tokenizer.json")).map_err(|_| ())?; - let config: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(dir.join("config.json")).map_err(|_| ())?, - ) - .map_err(|_| ())?; - let number = |key: &str| config[key].as_u64().map(|n| n as usize).ok_or(()); - let layers = number("num_hidden_layers")?; - let names: HashSet<&str> = session.inputs().iter().map(|i| i.name()).collect(); - let required = ["input_ids", "attention_mask", "position_ids"]; - if !required.iter().all(|n| names.contains(n)) - || !(0..layers).all(|l| names.contains(format!("past_key_values.{l}.key").as_str())) - { + if format!("{:x}", hash.finalize()) != DATABASE_SHA256 { return Err(()); } - Ok(Self { - layers, - heads: number("num_key_value_heads")?, - dim: number("head_dim")?, - bos: number("bos_token_id")? as i64, - session, - tokenizer, - context: None, - }) - } - - /// Runs `batch` rows of `width` pieces after `past`; returns the last row's - /// log-probabilities for each batch item (at `ends`) and the new past. - fn run( - &mut self, - past: Option<&Past>, - ids: Vec, - batch: usize, - width: usize, - ends: &[usize], - ) -> Result<(Vec>, Option), ()> { - let before = past.map_or(0, |p| p.len); - let tensor = - |shape: [usize; 2], data: Vec| -> Result, ()> { - Ok(Tensor::from_array((shape, data)).map_err(|_| ())?.into()) - }; - let mut inputs: Vec<(Cow, SessionInputValue)> = vec![ - ("input_ids".into(), tensor([batch, width], ids)?), - ( - "attention_mask".into(), - tensor([batch, before + width], vec![1; batch * (before + width)])?, - ), - ( - "position_ids".into(), - tensor( - [batch, width], - (0..batch) - .flat_map(|_| (before..before + width).map(|p| p as i64)) - .collect(), - )?, - ), - ]; - for layer in 0..self.layers { - for (kind, cache) in [ - ("key", past.map(|p| &p.keys)), - ("value", past.map(|p| &p.values)), - ] { - let data: Vec = cache.map_or(Vec::new(), |c| { - c[layer] - .iter() - .copied() - .cycle() - .take(c[layer].len() * batch) - .collect() - }); - let value = Tensor::from_array(([batch, self.heads, before, self.dim], data)) - .map_err(|_| ())?; - inputs.push(( - format!("past_key_values.{layer}.{kind}").into(), - value.into(), - )); - } - } - let outputs = self.session.run(inputs).map_err(|_| ())?; - let (_, logits) = outputs["logits"] - .try_extract_tensor::() - .map_err(|_| ())?; - let size = logits.len() / (batch * width); - let rows = ends - .iter() - .enumerate() - .map(|(b, end)| log_softmax(&logits[(b * width + end) * size..][..size])) - .collect(); - let present = if batch == 1 { - let mut keys = Vec::new(); - let mut values = Vec::new(); - for layer in 0..self.layers { - for (kind, out) in [("key", &mut keys), ("value", &mut values)] { - let (_, data) = outputs[format!("present.{layer}.{kind}").as_str()] - .try_extract_tensor::() - .map_err(|_| ())?; - out.push(data.to_vec()); - } - } - Some(Past { - keys, - values, - len: before + width, - }) - } else { - None - }; - Ok((rows, present)) - } -} - -impl Scorer for Onnx { - fn context(&mut self, text: &str) -> Result, ()> { - if let Some((cached, _, next)) = &self.context { - if cached == text { - return Ok(next.clone()); - } - } - self.context = None; - let mut ids = vec![self.bos]; - let encoding = self.tokenizer.encode(text, false).map_err(|_| ())?; - ids.extend(encoding.get_ids().iter().map(|&i| i as i64)); - let width = ids.len(); - let (mut rows, past) = self.run(None, ids, 1, width, &[width - 1])?; - let next = rows.pop().ok_or(())?; - self.context = Some((text.to_owned(), past.ok_or(())?, next.clone())); - Ok(next) - } - fn extend(&mut self, continuations: &[Vec]) -> Result>, ()> { - let (text, past, next) = self.context.take().ok_or(())?; - let width = continuations.iter().map(Vec::len).max().ok_or(())?; - let ids = continuations - .iter() - .flat_map(|c| c.iter().copied().chain(std::iter::repeat(0)).take(width)) - .collect(); - let ends: Vec = continuations.iter().map(|c| c.len() - 1).collect(); - let result = self.run(Some(&past), ids, continuations.len(), width, &ends); - self.context = Some((text, past, next)); - result.map(|(rows, _)| rows) + Predictor::open(path, None).map(Self).map_err(|_| ()) } } -pub struct Model { - onnx: Onnx, - vocabulary: Vocabulary, -} - -impl Model { - pub fn open(dir: &Path) -> Result { - let onnx = Onnx::open(dir)?; - let size = onnx.tokenizer.get_vocab_size(true) as u32; - let pieces = (0..size) - .map(|id| onnx.tokenizer.decode(&[id], false).unwrap_or_default()) - .collect(); - Ok(Self { - onnx, - vocabulary: Vocabulary::new(pieces), - }) +fn display_word(word: String) -> String { + match word.as_str() { + "i" | "i'm" | "i'll" | "i'd" | "i've" => "I".to_owned() + &word[1..], + _ => word, } } - -/// Predictor behind `Database`, so tests can substitute a fake. -pub trait Predict: Send { - fn predict( - &mut self, - before: &str, - prefix: &str, - deadline: Instant, - phrase_deadline: Instant, - ) -> Result; -} - impl Predict for Model { - fn predict( - &mut self, - before: &str, - prefix: &str, - deadline: Instant, - phrase_deadline: Instant, - ) -> Result { - // The model's beginning-of-document distribution favors site names. - // A fixed, local context yields useful initial completions without - // reading any additional user text. - let context = match recent(before) { - Some("") if !prefix.is_empty() => "I ", - Some(text) => text, - None => return Ok(Prediction::default()), - }; - complete( - &mut self.onnx, - &self.vocabulary, - context, - prefix, - deadline, - phrase_deadline, - ) + fn predict(&mut self, before: &str, prefix: &str) -> Result { + Ok(Prediction { + words: self + .0 + .predict( + before, + prefix, + Options { + limit: 5, + min_chars: 0, + unigram_only: false, + }, + ) + .into_iter() + .map(|s| display_word(s.word)) + .collect(), + }) } } -/// At most the last `CONTEXT_CHARS` characters of `before`, starting at a -/// word boundary. This bounds the one model pass that has no deadline. -/// `None` when the kept text holds no complete word. -fn recent(before: &str) -> Option<&str> { - let Some((cut, _)) = before.char_indices().rev().nth(CONTEXT_CHARS - 1) else { - return Some(before); - }; - let tail = &before[cut..]; - let start = tail.find(char::is_whitespace)?; - let kept = tail[start..].trim_start(); - (!kept.is_empty()).then_some(kept) -} - #[cfg(test)] mod tests { use super::*; + use std::{fs, time::Instant}; - /// Pieces: 0 "", 1 " Wa", 2 " wa", 3 "ter", 4 "ffle", 5 " .", 6 " water", - /// 7 "Wa", 8 "zz", 9 " darn", 10 " x", 11 " is", 12 " i". After " water" - /// the next word is " is" or " i"; after "ffle" it is " is" less often. - /// Everything else after a word is a boundary. - struct Fake { - next: Vec, - contexts: usize, - } - fn pieces() -> Vec { - [ - "", " Wa", " wa", "ter", "ffle", " .", " water", "Wa", "zz", " darn", " x", " is", - " i", - ] - .map(String::from) - .to_vec() - } - fn dist(pairs: &[(usize, f32)]) -> Vec { - let mut d = vec![-30.0; 13]; - for &(i, p) in pairs { - d[i] = p.ln(); - } - d - } - impl Scorer for Fake { - fn context(&mut self, _text: &str) -> Result, ()> { - self.contexts += 1; - Ok(self.next.clone()) - } - fn extend(&mut self, continuations: &[Vec]) -> Result>, ()> { - Ok(continuations - .iter() - .map(|c| match c.last() { - Some(1) | Some(2) | Some(7) => dist(&[(3, 0.6), (4, 0.3), (5, 0.1)]), - Some(6) => dist(&[(11, 0.5), (12, 0.3), (5, 0.2)]), - Some(4) => dist(&[(11, 0.4), (5, 0.6)]), - _ => dist(&[(5, 1.0)]), - }) - .collect()) + struct Fixture(std::path::PathBuf); + impl Fixture { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("switchify-prediction-{}", uuid::Uuid::new_v4())); + fs::create_dir(&path).unwrap(); + Self(path) } } - fn vocabulary() -> Vocabulary { - Vocabulary::new(pieces()) - } - fn fake(pairs: &[(usize, f32)]) -> Fake { - Fake { - next: dist(pairs), - contexts: 0, + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); } } - - fn run(s: &mut Fake, before: &str, prefix: &str) -> Result, ()> { - let deadline = Instant::now() + std::time::Duration::from_secs(60); - spell(s, &vocabulary(), before, prefix, deadline) - } - - #[test] - fn spells_multi_piece_words_merges_case_and_applies_prefix() { - let mut s = fake(&[(1, 0.2), (2, 0.3), (6, 0.4), (9, 0.1)]); - // water = 0.4 + 0.5 * 0.6; waffle = 0.5 * 0.3; bare "wa" = 0.5 * 0.1. - let words = run(&mut s, "I would like ", "wa").unwrap(); - assert_eq!(words, ["water", "waffle", "wa"]); - assert_eq!(run(&mut s, "I would like ", "waf").unwrap(), ["waffle"]); - } - - #[test] - fn phrases_prefer_different_leads_capitalise_i_and_keep_their_deadline() { - let mut s = fake(&[(1, 0.2), (2, 0.3), (6, 0.4), (9, 0.1)]); - let far = Instant::now() + std::time::Duration::from_secs(60); - // "water i" (0.4 * 0.3) outranks "waffle is" (0.09 * 0.4), but the - // second slot prefers a phrase from a different lead. - let p = complete(&mut s, &vocabulary(), "I would like ", "wa", far, far).unwrap(); - assert_eq!(p.words, ["water", "waffle", "wa"]); - assert_eq!(p.phrases, ["water is", "waffle is"]); - let p = complete(&mut s, &vocabulary(), "I would like ", "wat", far, far).unwrap(); - assert_eq!(p.phrases, ["water is", "water I"]); - let p = complete( - &mut s, - &vocabulary(), - "I would like ", - "wa", - far, - Instant::now(), - ) - .unwrap(); - assert_eq!(p.words, ["water", "waffle", "wa"]); - assert!(p.phrases.is_empty()); - let p = complete(&mut s, &vocabulary(), "I would like ", "waf", far, far).unwrap(); - assert_eq!(p.phrases, ["waffle is"]); - } - - #[test] - fn single_letter_and_improbable_unknown_words_are_removed_but_nothing_is_censored() { - let mut s = fake(&[(9, 0.6), (10, 0.3), (6, 0.1)]); - assert_eq!(run(&mut s, "Say ", "").unwrap(), ["darn", "water"]); - let mut s = fake(&[(6, 1e-8)]); - assert!(run(&mut s, "Say ", "").unwrap().is_empty()); - } - - #[test] - fn capital_spellings_survive_mid_sentence_but_not_at_a_sentence_start() { - let mut s = fake(&[(1, 0.9)]); - let lowered = ["water", "waffle", "wa"]; - assert_eq!(run(&mut s, "Send ", "").unwrap(), ["Water", "Waffle", "Wa"]); - assert_eq!(run(&mut s, "Done. ", "").unwrap(), lowered); - // The capital must carry nine tenths of the word's mass: 0.8 against - // 0.1 is a share of 0.89 and stays lowercase; 0.85 against 0.05 is - // 0.94 and keeps the capital. - let mut s = fake(&[(1, 0.8), (2, 0.1)]); - assert_eq!(run(&mut s, "Send ", "").unwrap()[0], "water"); - let mut s = fake(&[(1, 0.85), (2, 0.05)]); - assert_eq!(run(&mut s, "Send ", "").unwrap()[0], "Water"); - } - - #[test] - fn text_start_needs_capital_and_attached_prefixes_fall_back() { - let mut s = fake(&[(7, 0.5), (8, 0.5)]); - assert_eq!(run(&mut s, "", "").unwrap(), ["water", "waffle", "wa"]); - assert!(run(&mut s, "hello-", "wa").unwrap().is_empty()); - assert_eq!(s.contexts, 1); - } - - #[test] - fn an_expired_deadline_stops_expansion_and_bad_vocabularies_fail() { - let mut s = fake(&[(6, 0.9)]); - let expired = spell(&mut s, &vocabulary(), "Say ", "", Instant::now()); - assert!(expired.unwrap().is_empty()); - s.next.pop(); - assert!(run(&mut s, "Say ", "").is_err()); - } - - #[test] - fn model_context_keeps_recent_whole_words_only() { - assert_eq!(recent("I would like "), Some("I would like ")); - let long = format!("{} tea please ", "x".repeat(300)); - assert_eq!(recent(&long), Some("tea please ")); - let words = "word ".repeat(100); - let kept = recent(&words).unwrap(); - assert!(kept.chars().count() < CONTEXT_CHARS && kept.starts_with("word")); - assert_eq!(recent(&"é".repeat(300)), None); - assert_eq!(recent(&format!("{} ", "x".repeat(300))), None); - } - - #[test] - fn i_and_its_contractions_are_always_capital() { - let finished = |spelling: &str| Finished { - mass: 1.0, - capital: 0.0, - best: 0.0, - spelling: spelling.to_owned(), - pieces: Vec::new(), - logp: 0.0, - }; - assert_eq!(display("i", &finished("i"), "", false), "I"); - assert_eq!(display("i'm", &finished("i'm"), "", true), "I'm"); - assert_eq!(display("i'll", &finished("i'll"), "i’", false), "I’ll"); - assert_eq!(display("is", &finished("is"), "", false), "is"); - } - #[test] - fn apostrophes_continue_words_rather_than_ending_them() { - let pieces = [" don", "’t", "'s", "’", " .", ","] - .map(String::from) - .to_vec(); - let v = Vocabulary::new(pieces); - assert_eq!(v.boundary, [true, false, false, false, true, true]); - assert_eq!(v.continues, [false, true, true, true, false, false]); + fn sqlite_adapter_ranks_context_and_preserves_read_only_baseline() { + let dir = Fixture::new(); + let path = dir.0.join("english.sqlite"); + switchify_prediction::build(&path, + "I need help. I need help. I need help. I drink water. I drink water. Café can't wait. I'm here. I am home. I am happy. I am healthy. I am hungry. I am hopeful. I am human.", "synthetic test").unwrap(); + let original = fs::read(&path).unwrap(); + let mut model = Model(Predictor::open(&path, None).unwrap()); + assert_eq!(model.predict("I need ", "h").unwrap().words[0], "help"); + assert_eq!(model.predict("I drink ", "").unwrap().words[0], "water"); + assert!(model.predict("", "zyzzy").unwrap().words.is_empty()); + assert_eq!( + model.predict("", "cafe\u{301}").unwrap().words, + vec!["café"] + ); + assert_eq!(model.predict("", "can’").unwrap().words, vec!["can't"]); + assert_eq!(model.predict("", "i’").unwrap().words, vec!["I'm"]); + assert_eq!(model.predict("", "h").unwrap().words.len(), 5); + assert_eq!( + model.predict("I need.", "h").unwrap(), + model.predict("", "h").unwrap() + ); + drop(model); + assert_eq!(fs::read(&path).unwrap(), original); + assert_eq!(fs::read_dir(&dir.0).unwrap().count(), 1); + // A structurally valid custom database is not the pinned production model. + assert!(Model::open(&path).is_err()); + fs::write(&path, "corrupt").unwrap(); + assert!(Model::open(&path).is_err()); + assert!(Model::open(&dir.0.join("missing")).is_err()); + } + + fn rss_bytes() -> u64 { + let pid = std::process::id().to_string(); + #[cfg(target_os = "windows")] + let output = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-Command", + &format!("(Get-Process -Id {pid}).WorkingSet64"), + ]) + .output() + .unwrap(); + #[cfg(not(target_os = "windows"))] + let output = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &pid]) + .output() + .unwrap(); + assert!(output.status.success()); + let value: u64 = String::from_utf8(output.stdout) + .unwrap() + .trim() + .parse() + .unwrap(); + if cfg!(target_os = "windows") { + value + } else { + value * 1024 + } } #[test] - fn bundled_model_suggests_current_words() { - let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("resources"); + #[ignore = "isolated measurement; run with --ignored --test-threads=1"] + fn bundled_prediction_benchmark() { + let path = std::env::var_os("SWITCHIFY_BENCHMARK_MODEL") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("resources/prediction-model/english.sqlite") + }); + let before = rss_bytes(); let start = Instant::now(); - let mut model = Model::open(&root.join("prediction-model")) - .expect("run `npm run prediction-model` to fetch the model"); - println!("model startup_ms={}", start.elapsed().as_millis()); - let mut times = Vec::new(); - for (before, prefix, expected) in [ - ("", "w", Some("want")), - ("I would like ", "wa", None), - ("I would like a cup of ", "", Some("tea")), - ("Please put the ", "ket", Some("kettle")), - ("Can you send a ", "wh", None), - ("I live in ", "lon", Some("London")), - ("See you on ", "mon", Some("Monday")), - ("Tomorrow ", "i'", Some("I'm")), - ("I want to ", "fa", None), - ] { - let t = Instant::now(); - let deadline = t + std::time::Duration::from_secs(60); - let p = model.predict(before, prefix, deadline, deadline).unwrap(); - times.push(t.elapsed().as_secs_f64() * 1000.0); - println!("{before:?}+{prefix:?} -> {:?} {:?}", p.words, p.phrases); - let words = &p.words; - assert!(words.iter().all(|w| normalize(w).starts_with(prefix))); - for phrase in &p.phrases { - let (first, second) = phrase.split_once(' ').expect("two words"); - assert!(words.iter().take(3).any(|w| w == first), "{phrase:?}"); - assert!(!second.is_empty() && !second.contains(' ')); - } - if let Some(expected) = expected { - assert!(words.iter().any(|w| w == expected), "{words:?}"); + let mut model = Model::open(&path).unwrap(); + let cold_ms = start.elapsed().as_secs_f64() * 1000.0; + let loaded = rss_bytes(); + let mut samples = Vec::new(); + for i in 0..220 { + let (context, prefix) = [ + ("I need ", "he"), + ("I want ", ""), + ("", "a"), + ("can you ", "h"), + ][i % 4]; + let start = Instant::now(); + let suggestions = model.predict(context, prefix).unwrap(); + assert!(!suggestions.words.is_empty()); + if i >= 20 { + samples.push(start.elapsed().as_secs_f64() * 1000.0); } } - println!("model predict_ms={times:.1?}"); + samples.sort_by(f64::total_cmp); + assert_eq!(model.predict("I need ", "he").unwrap().words[0], "help"); + #[cfg(target_os = "macos")] + let cpu = std::process::Command::new("sysctl") + .args(["-n", "machdep.cpu.brand_string"]) + .output() + .unwrap(); + #[cfg(target_os = "windows")] + let cpu = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-Command", + "(Get-CimInstance Win32_Processor).Name", + ]) + .output() + .unwrap(); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + let cpu = std::process::Command::new("uname") + .arg("-m") + .output() + .unwrap(); + assert!(cpu.status.success()); + let report = serde_json::json!({ + "os": std::env::consts::OS, "arch": std::env::consts::ARCH, + "logical_cpus": std::thread::available_parallelism().unwrap().get(), + "cpu": String::from_utf8_lossy(&cpu.stdout).trim(), + "cold_load_ms": cold_ms, "warm_p50_ms": samples[99], "warm_p95_ms": samples[189], + "rss_before_bytes": before, "rss_loaded_bytes": loaded, + "rss_increase_bytes": loaded.saturating_sub(before), + "memory_note": "Isolated Rust test process using the production adapter, including test harness; not total desktop RSS.", + "database_bytes": fs::metadata(path).unwrap().len(), "samples": samples.len(), + }); + println!("{report}"); + if let Some(path) = std::env::var_os("SWITCHIFY_BENCHMARK_REPORT") { + fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap(); + } } } diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 33e76f09..2a8af05a 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -233,19 +233,15 @@ impl Engine { } let ctx = context::extract(&self.buffer, self.clipped); let (status, prediction) = self.database.predict(&ctx); - let words = interleave(prediction.words, prediction.phrases); + let words = prediction.words; self.status = status; self.token = self.token.wrapping_add(1); self.inserts.clear(); let mut labels = Vec::new(); for word in words { - let offset = word - .char_indices() - .nth(ctx.prefix.chars().count()) - .map_or(word.len(), |(i, _)| i); - if word[..offset].to_lowercase() != ctx.prefix.to_lowercase() { + let Some(offset) = prefix_offset(&word, &ctx.prefix) else { continue; - } + }; let suffix = if shouting(&ctx.prefix) { word[offset..].to_uppercase() } else { @@ -352,15 +348,27 @@ impl Engine { /// "lon" for London or "i" for I'm. A capital the person typed is kept. /// `None` when nothing would change, which is the usual case and deletes /// nothing. +fn prefix_offset(word: &str, typed: &str) -> Option { + let normalized = switchify_prediction::normalize(typed); + if normalized.is_empty() { + return Some(0); + } + word.grapheme_indices(true) + .map(|(i, g)| i + g.len()) + .find(|&end| switchify_prediction::normalize(&word[..end]) == normalized) +} + fn restored(typed: &str, head: &str) -> Option { let merged: String = typed - .chars() - .zip(head.chars()) + .graphemes(true) + .zip(head.graphemes(true)) .map(|(t, w)| { - if t.is_lowercase() && w.is_uppercase() { - w + if t.chars().next().is_some_and(char::is_lowercase) + && w.chars().next().is_some_and(char::is_uppercase) + { + t.to_uppercase() } else { - t + t.to_owned() } }) .collect(); @@ -410,24 +418,6 @@ fn cased(suffix: &str, whole_word: bool, sentence_start: bool, shift: Shift, cap } } -/// The five suggestion slots: words in the first, third and fifth, phrases -/// in the second and fourth. When one kind runs short the other fills in. -pub fn interleave(words: Vec, phrases: Vec) -> Vec { - let (mut words, mut phrases) = (words.into_iter(), phrases.into_iter()); - let mut row = Vec::new(); - while row.len() < 5 { - let next = if row.len() % 2 == 1 { - phrases.next().or_else(|| words.next()) - } else { - words.next().or_else(|| phrases.next()) - }; - match next { - Some(label) => row.push(label), - None => break, - } - } - row -} /// Answers requests until the pipe closes. The observer starts on the first /// request, which means a keyboard is open: a spare worker waiting for one /// loads the model and observes nothing. Starting can take up to 500 ms of @@ -572,26 +562,49 @@ mod tests { assert!(!e.tracked); } #[test] - fn phrases_take_every_second_slot_and_accept_as_one_suffix() { - let w = |s: &[&str]| s.iter().map(|s| (*s).to_owned()).collect::>(); - assert_eq!( - interleave(w(&["a", "b", "c", "d", "e"]), w(&["a b", "c d"])), - w(&["a", "a b", "b", "c d", "c"]) - ); - assert_eq!(interleave(w(&["a", "b", "c"]), w(&[])), w(&["a", "b", "c"])); - assert_eq!( - interleave(w(&["a"]), w(&["a b", "a c"])), - w(&["a", "a b", "a c"]) - ); - assert_eq!(interleave(w(&[]), w(&["a b"])), w(&["a b"])); + fn normalized_prefixes_preserve_typed_graphemes_and_apostrophes() { + assert_eq!(prefix_offset("café", "cafe\u{301}"), Some("café".len())); + assert_eq!(prefix_offset("I'm", "i’"), Some(2)); + assert_eq!(prefix_offset("can't", "can’"), Some(4)); + assert_eq!(restored("i’", "I'").as_deref(), Some("I’")); + assert_eq!(restored("cafe\u{301}", "café"), None); + assert_eq!(prefix_offset("water", "wax"), None); + assert_eq!(prefix_offset("water", ""), Some(0)); + } + + #[test] + fn normalized_completions_insert_without_rewriting_typed_accents_or_apostrophes() { + for (typed, expected, deletion, insertion) in [ + ("cafe\u{301}", "cafe\u{301}", 0, " "), + ("can’", "can’t", 0, "t "), + ("i’", "I’m", 2, "I’m "), + ] { + let mut e = engine(); + let b = e + .query(vec![append(typed)], 1, Shift::Off, false, false) + .unwrap(); + assert_eq!(b.words, vec![expected]); + assert_eq!(e.accept(b.token, 0), Some((deletion, insertion.to_owned()))); + } + let mut e = engine(); + assert!(e.query(vec![], 1, Shift::Off, false, false).is_none()); + assert!(!e + .query(vec![append("I need ")], 2, Shift::Off, false, false) + .unwrap() + .words + .is_empty()); + } + + #[test] + fn five_slots_contain_ranked_words_only() { let mut e = engine(); let b = e .query(vec![append("wa")], 1, Shift::Off, false, false) .unwrap(); - assert_eq!(b.words, w(&["water", "water is", "waffle", "walk"])); - assert_eq!(e.accept(b.token, 1).unwrap(), (0, "ter is ".to_owned())); + assert_eq!(b.words, vec!["water", "waffle", "walk"]); + assert_eq!(e.accept(b.token, 1).unwrap(), (0, "ffle ".to_owned())); let upper = e.query(vec![], 1, Shift::Off, true, false).unwrap(); - assert_eq!(upper.words[1], "waTER IS"); + assert_eq!(upper.words[1], "waFFLE"); } #[test] fn first_letter_and_completion_chain_use_only_buffer() { @@ -739,7 +752,7 @@ mod tests { let b = e .query(vec![append("wh")], 1, Shift::Off, false, false) .unwrap(); - assert_eq!(b.words, w(&["WhatsApp", "WhatsApp is"])); + assert_eq!(b.words, w(&["WhatsApp"])); assert_eq!(e.accept(b.token, 0).unwrap(), (2, "WhatsApp ".to_owned())); // The edits the service queues afterwards leave the buffer reading // as the screen does. @@ -765,7 +778,7 @@ mod tests { false, ) .unwrap(); - assert_eq!(b.words, w(&["WhatsApp", "WhatsApp is"])); + assert_eq!(b.words, w(&["WhatsApp"])); assert_eq!(e.accept(b.token, 0).unwrap(), (0, "atsApp ".to_owned())); // A word in capitals is never retyped. let b = e @@ -799,7 +812,7 @@ mod tests { .unwrap(); let row = e.query(vec![], 1, Shift::Off, false, false).unwrap(); assert_eq!(row.words[0], "WATER"); - assert_eq!(row.words[1], "WATER IS"); + assert_eq!(row.words[1], "WAFFLE"); assert_eq!( e.query(vec![], 1, Shift::Locked, false, false) .unwrap() @@ -890,7 +903,7 @@ mod tests { "wATER" ); assert_eq!( - e.query(vec![], 1, Shift::Off, false, false).unwrap().words[4], + e.query(vec![], 1, Shift::Off, false, false).unwrap().words[3], "WhatsApp" ); e.query(vec![append("wa")], 2, Shift::Off, false, false) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2dacdd79..afd17667 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -36,9 +36,13 @@ }, "bundle": { "resources": [ - "resources/prediction-model/model.onnx", - "resources/prediction-model/tokenizer.json", - "resources/prediction-model/config.json" + "resources/prediction-model/english.sqlite", + "resources/prediction-model/production-model.json", + "resources/prediction-model/source-manifest.json", + "resources/prediction-model/aac-source-manifest.json", + "resources/prediction-model/ATTRIBUTION.md", + "resources/prediction-model/LICENSE", + "resources/prediction-model/corpus-notices/*" ], "active": true, "targets": "all", diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index fa058211..4135163d 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -160,7 +160,7 @@ export function ScannerPreferences({ controller }: { controller: ScanningControl } {area === 'keyboard' && update('wordPrediction', value)} /> -

Uses an on-device language model. The keyboard shows when suggestions are loading, unavailable, or have no match. If prediction fails, choose Retry predictions on the keyboard; typing still works. Your typing never leaves this computer and is not used for learning. Uses about 400 MB of extra memory while the keyboard is open.

+

Uses an offline English word database. Suggestions appear as you type and after a space. The keyboard shows when suggestions are loading, unavailable, or have no match. If prediction fails, choose Retry predictions on the keyboard; typing still works. Your typing never leaves this computer and is not used for learning.

} {field('automatic', 'Automatic scanning', locked => change('automatic', value)} />)} From 342b98a6e5b365c30517b60592264a74abd37b25 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 29 Sep 2026 18:57:44 +0100 Subject: [PATCH 2/4] Run packaged model benchmarks only in the library test harness --- .github/workflows/ci.yml | 2 +- docs/word-prediction.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb6c06ae..7883ed8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,7 +155,7 @@ jobs: - name: Measure packaged prediction without desktop input env: SWITCHIFY_BENCHMARK_REPORT: prediction-benchmark.json - run: cargo test --release --locked --manifest-path src-tauri/Cargo.toml bundled_prediction_benchmark -- --ignored --nocapture --test-threads=1 + run: cargo test --release --lib --locked --manifest-path src-tauri/Cargo.toml bundled_prediction_benchmark -- --ignored --nocapture --test-threads=1 - name: Upload prediction measurements uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/docs/word-prediction.md b/docs/word-prediction.md index 9477227b..439734e7 100644 --- a/docs/word-prediction.md +++ b/docs/word-prediction.md @@ -92,7 +92,7 @@ Before accepting a suggestion, the worker checks its token, edit revision, foreg 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` exercises the SQLite adapter with synthetic corpora. Packaged model verification and isolated release-mode measurements run separately on macOS and Windows. For release-build suggestions and timings: ```powershell -cargo test --release --manifest-path src-tauri/Cargo.toml bundled_prediction_benchmark -- --ignored --nocapture --test-threads=1 +cargo test --release --lib --manifest-path src-tauri/Cargo.toml bundled_prediction_benchmark -- --ignored --nocapture --test-threads=1 ``` For native validation, use disposable synthetic text in Notepad and a browser on Windows, and TextEdit and a browser on macOS. Launch macOS with `npm run macos:run` to retain its signed Accessibility identity. From d093c7a1852d9eff0b4dd735ab73d69c4bcddc1a Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 29 Sep 2026 19:00:44 +0100 Subject: [PATCH 3/4] Refresh buffered predictions when asynchronous loading completes --- src-tauri/src/prediction/database.rs | 13 +++++++++++++ src-tauri/src/prediction/worker.rs | 25 ++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/prediction/database.rs b/src-tauri/src/prediction/database.rs index 8b1edd83..bade48c6 100644 --- a/src-tauri/src/prediction/database.rs +++ b/src-tauri/src/prediction/database.rs @@ -83,6 +83,19 @@ impl Database { prediction.words.truncate(5); (Status::Ready, prediction) } + #[cfg(test)] + pub fn pending_fixture() -> (Self, mpsc::SyncSender>) { + let (tx, rx) = mpsc::sync_channel(1); + ( + Self { + state: State::Loading(rx), + slow_call: SLOW_CALL, + slow_calls: 0, + }, + tx, + ) + } + #[cfg(test)] pub fn fixture() -> Self { Self { diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index 2a8af05a..d667a234 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -278,7 +278,9 @@ impl Engine { return None; } self.case = (shift, caps, sentence_start); - self.snapshot = Some(self.buffer.clone()); + // Loading produces an empty batch, not a reusable prediction. Once the + // model becomes ready, predict the buffered text even without a new edit. + self.snapshot = (self.status == Status::Ready).then(|| self.buffer.clone()); self.batch = Some(Batch { token: self.token, words: labels, @@ -595,6 +597,27 @@ mod tests { .is_empty()); } + #[test] + fn text_typed_during_loading_is_predicted_when_ready_without_another_edit() { + let mut e = engine(); + let (loading, _pending) = Database::pending_fixture(); + e.database = loading; + let waiting = e + .query(vec![append("wa")], 1, Shift::Off, false, false) + .unwrap(); + assert!(waiting.words.is_empty()); + assert_eq!(e.status, Status::Loading); + e.database = Database::fixture(); + let ready = e.query(vec![], 1, Shift::Off, false, false).unwrap(); + assert_eq!(ready.words, vec!["water", "waffle", "walk"]); + assert_ne!(ready.token, waiting.token); + // Once ready, an unchanged query retains the scan choice identity. + assert_eq!( + e.query(vec![], 1, Shift::Off, false, false).unwrap().token, + ready.token + ); + } + #[test] fn five_slots_contain_ranked_words_only() { let mut e = engine(); From 955fd63e3e54881126392898d1b6724458c214e9 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 29 Sep 2026 20:40:21 +0100 Subject: [PATCH 4/4] fix: pass SQLite baseline file to prediction worker --- src-tauri/src/prediction/mod.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index c4265472..129353e9 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -681,9 +681,7 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign .collect(); ignored.sort_unstable(); ignored.dedup(); - let model = resource(app) - .ok() - .and_then(|path| path.parent().map(Path::to_owned)); + let model = resource(app).ok(); s.client = SPARE .with(|spare| adopt(&mut spare.borrow_mut(), &ignored, Instant::now())) .or_else(|| Client::start(model.as_deref()?, ignored.clone()).ok());