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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ jobs:
- run: npm ci
- name: Fetch prediction model
run: npm run prediction-model
- name: Diagnose prepared ARM worker
if: runner.os == 'macOS'
env:
SWITCHIFY_BENCHMARK_MODEL: src-tauri/resources/prediction-model/english.sqlite
SWITCHIFY_BENCHMARK_WORKER: src-tauri/binaries/switchify-smol-worker-aarch64-apple-darwin
run: python3 scripts/probe-neural-worker.py
- run: cargo fmt --manifest-path src-tauri/Cargo.toml --check
- run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml
Expand Down Expand Up @@ -141,6 +147,7 @@ jobs:
run: |
model=$(node scripts/check-packaged-prediction.mjs src-tauri/target/release/bundle/macos)
echo "SWITCHIFY_BENCHMARK_MODEL=$model" >> "$GITHUB_ENV"
echo "SWITCHIFY_BENCHMARK_WORKER=$(node scripts/check-packaged-prediction.mjs src-tauri/target/release/bundle/macos --worker)" >> "$GITHUB_ENV"
- name: Verify Windows packaged prediction resources
if: runner.os == 'Windows'
shell: pwsh
Expand All @@ -152,13 +159,29 @@ jobs:
$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
$worker = node scripts/check-packaged-prediction.mjs $unpacked --worker
if ($LASTEXITCODE -ne 0) { throw 'Packaged workers failed verification.' }
"SWITCHIFY_BENCHMARK_WORKER=$worker" >> $env:GITHUB_ENV
- name: Measure packaged prediction without desktop input
env:
SWITCHIFY_BENCHMARK_REPORT: prediction-benchmark.json
run: cargo test --release --lib --locked --manifest-path src-tauri/Cargo.toml bundled_prediction_benchmark -- --ignored --nocapture --test-threads=1
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.13'
- name: Measure packaged neural integration without desktop input
run: |
python -m pip install psutil==7.0.0
python scripts/measure-neural.py --build --output src-tauri/neural-benchmark.json
- name: Diagnose packaged worker after benchmark failure
if: failure() && env.SWITCHIFY_BENCHMARK_WORKER != ''
run: python scripts/probe-neural-worker.py
- name: Upload prediction measurements
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: prediction-benchmark-${{ runner.os }}
path: src-tauri/prediction-benchmark.json
path: |
src-tauri/prediction-benchmark.json
src-tauri/neural-benchmark.json
if-no-files-found: error
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ tools/prediction-data/target/
src-tauri/resources/prediction-model/

src-tauri/prediction-benchmark.json

.cache/
src-tauri/resources/prediction-neural/
src-tauri/neural-benchmark.json
39 changes: 39 additions & 0 deletions docs/neural-prediction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Default neural-assisted prediction

Word prediction uses the existing statistical model immediately, then refines its eight-word shortlist with SmolLM2-135M Q8. This runs whenever word prediction is enabled. Existing disabled preferences remain disabled; the compatibility-only enhanced setting remains inert.

Only the isolated prediction worker holds Switchify's tracked typing context. This does not read arbitrary text from focused fields or learn personal text. The neural worker starts on the first prediction, loads once and uses four threads. Windows selects AVX2 only after checking AVX2, FMA and F16C; macOS uses the portable ARM worker. The display keeps its current words selectable while their row is scanned, then applies the queued refinement after leaving the row. Accepting a suggestion uses its exact batch token, never an index into a replacement list.

Neural startup, crashes and the 2 second reply deadline leave statistical predictions available. There is no automatic neural retry loop; reopening the keyboard or explicitly retrying prediction starts a fresh session. Context invalidation cancels refinement. Windows job containment and macOS process groups cover the prediction process tree. Keyboard closure kills the context-bearing worker; its bounded spare loads only the statistical model until the next prediction.

## Assets and verification

`npm run prediction-model` prepares both sources at build time. It verifies the companion release archive and selected files, fetches pinned upstream sources, runs the converter from the pinned release commit, and checks the converted model hash. Verified inputs are cached in `.cache/prediction-neural`. Corrupt conversion caches fail explicitly. The installed application has no model download path.

Tauri bundles the workers as external binaries so platform signing covers them. The neural resources include the 143,041,952-byte Q8 model, tokenizer, source provenance, Apache model license and worker notices. `scripts/check-packaged-prediction.mjs` checks extracted resources and unsigned worker hashes; signed release checks additionally verify platform signatures. No prediction text or scores are logged.

## Validation and limits

Fake-input tests cover stable scanning, accepting the displayed batch after refinement, token retirement, generation/revision mismatch and missing neural assets. Existing tests cover Unicode/casing, context races, insertion safety, failures and cleanup. CI verifies installer contents and runs 1,000 warmed queries using the production engine and actual neural child, with synthetic input and activity adapters. Timing includes 20 ms refinement polling but excludes the outer desktop pipe and rendering.

Build the integration fixture with `cargo test --release --lib --locked --manifest-path src-tauri/Cargo.toml neural_integration_benchmark --no-run`. Run its reported test executable through `python scripts/measure-neural.py --test-binary TEST_EXECUTABLE --model INSTALLED_ENGLISH_SQLITE --worker INSTALLED_PORTABLE_WORKER --output RESULTS_JSON`. The optional measurement script requires psutil and samples process-tree RSS every 20 ms. No keyboard or pointer input is generated.

Default activation is a product choice, not a new quality qualification. The upstream frozen comparison has two development quality regressions, portable Windows latency misses its target, and synthetic fixtures do not prove unseen-user accuracy. Platform measurements and remaining manual validation are recorded in the PR. Signed macOS manual testing must use `npm run macos:run`; an unsigned CI build cannot establish Accessibility permission behavior.

## Windows reference measurement

The extracted unsigned Windows installer completed 1,000 warmed integration queries with 1,000 neural refinements and no failures. Immediate p95 was 9.23 ms; refinement median was 89.71 ms, p95 91.65 ms and maximum 143.71 ms. Sampled process-tree peak RSS was 693,190,656 bytes, about 661 MiB. The installer was 154,901,216 bytes, about 148 MiB. See `neural-windows-results.json` for machine-readable results and measurement scope.

This uses five repeated synthetic contexts and measures latency, not accuracy. An earlier diagnostic run concurrent with packaging completed 561 refinements before a worker failure left the remaining 439 queries on statistical fallback; the simultaneous build also hit an executable file lock. The final measurement ran after packaging finished and used extracted installer assets. Those reference measurements used the original 500 ms deadline and unchanged retry policy.

macOS package and synthetic inference validation run in CI. Signed macOS Accessibility testing and signed Windows installation remain manual release checks; they were not performed on this Windows development host. This PR does not publish a release or change the RC version.

## CI sessions and deadline failures

The macOS ARM CI runner completed 359 measured refinements before a 500 ms companion timeout disabled refinement for the session. Its successful samples had median 141.91 ms and p95 272.95 ms. The remaining 641 queries returned statistics only. A separate direct worker diagnostic loaded and ranked successfully, so this was not a missing or incompatible asset. This failure remains evidence of latency variability on the shared runner.

The benchmark now requires 1,000 successful warmed refinements across at most five simulated keyboard sessions. On failure it records the reason, destroys the context-bearing engine, and explicitly simulates reopening the keyboard with a new engine and 20 fresh warmup queries. Reports retain every failure, including warmup failures, and each session's status and successful count. Successful-sample latency excludes timed-out queries; failure counts must be read alongside it. That benchmark change affected only the test scenario. The subsequent authorized timeout increase raises the companion reply deadline to 2 seconds; a user action is still required to restart after failure. CI also samples the process tree's memory use with the measurement helper.

## Reply timeout update

The companion inference and reset reply deadline is now 2 seconds, increased from 500 ms after repeated macOS CI timeouts. Startup remains bounded at 30 seconds, statistical suggestions remain immediate, and late or stale results remain rejected. Both Rust libraries are pinned to `171210ee89e3944d1c606d50ad779a5dfb1651b8`, the timeout fix in switchify-prediction PR #22. Model conversion and worker assets retain their verified v0.2.0 release identities because the deadline is enforced by the parent library and the worker protocol is unchanged. The earlier 500 ms measurements above remain historical evidence, not measurements of the increased deadline.
29 changes: 29 additions & 0 deletions docs/neural-windows-results.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"arch": "x86_64",
"failures": 0,
"immediate": {
"max_ms": 13.4205,
"median_ms": 8.3329,
"p95_ms": 9.2327,
"samples": 1000
},
"os": "windows",
"production_qualified": false,
"queries": 1000,
"refinement": {
"max_ms": 143.70759999999999,
"median_ms": 89.7075,
"p95_ms": 91.6524,
"samples": 1000
},
"scope": "Production Engine and model adapter, real neural child IPC, 20ms polling, fake input/activity; excludes outer desktop pipe and rendering",
"process_tree_peak_rss_bytes": 693190656,
"memory_note": "20ms sampled sum of test process and child RSS; shared pages may be counted twice and short peaks missed.",
"reference_machine": "Windows 11, AMD Ryzen AI 9 HX 370, four inference threads",
"prediction_revision": "99a3ef03cb54500006887a998547fded4ad39e0e",
"integration_source_head": "a6c85a4ad30e17a6dbad80b38f6565f171a236f5",
"model_id": "smollm2-135m-q8-v1",
"installer_bytes": 154901216,
"signed": false,
"fixture_note": "Five synthetic contexts repeated; latency regression check, not representative accuracy evaluation."
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"prediction-model": "node scripts/fetch-prediction-model.mjs",
"prediction-model": "node scripts/fetch-prediction-model.mjs && node scripts/fetch-prediction-neural.mjs",
"test": "vitest run && node --test scripts/*.node-test.mjs",
"lint": "tsc --noEmit",
"macos:setup-signing": "./scripts/setup-macos-dev-signing.sh",
Expand Down
24 changes: 24 additions & 0 deletions scripts/Verify-WindowsUiAccessPackage.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ $installerScript = Get-Content -LiteralPath $generatedInstaller -Raw
foreach ($expected in @(
'!define INSTALLMODE "perMachine"',
'switchify-pc-startup.exe',
'switchify-smol-worker.exe',
'switchify-smol-worker-avx2.exe',
'model.gguf',
'installer-hooks.nsh'
)) {
if (-not $installerScript.Contains($expected)) {
Expand All @@ -67,3 +70,24 @@ if ($configuration.bundle.windows.nsis.installMode -ne 'perMachine') {
}

Write-Output "Verified Windows UIAccess package: $installer"

# Inspect installed bytes: Tauri signs external binaries after the build copy.
$archiveTool = (Get-Command 7z -ErrorAction Stop).Source
$unpacked = Join-Path ([IO.Path]::GetTempPath()) "switchify-package-$([guid]::NewGuid().ToString('N'))"
New-Item -ItemType Directory -Path $unpacked | Out-Null
try {
& $archiveTool x $installer "-o$unpacked" -y | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Could not extract the installer for verification.' }
& node (Join-Path $PSScriptRoot 'check-packaged-prediction.mjs') $unpacked --signed
if ($LASTEXITCODE -ne 0) { throw 'Installed prediction resources failed verification.' }
foreach ($name in @('switchify-smol-worker.exe', 'switchify-smol-worker-avx2.exe')) {
$workers = @(Get-ChildItem -LiteralPath $unpacked -Recurse -File -Filter $name)
if ($workers.Count -ne 1) { throw "Expected one installed $name" }
Assert-Signature $workers[0].FullName
}
} finally {
$resolved = [IO.Path]::GetFullPath($unpacked)
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
if (-not $resolved.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) { throw 'Invalid verification directory' }
Remove-Item -LiteralPath $resolved -Recurse -Force
}
31 changes: 30 additions & 1 deletion scripts/check-packaged-prediction.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// 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 { createHash } from 'node:crypto';
import { bundlePins } from './fetch-prediction-neural.mjs';
import { verified } from './fetch-prediction-model.mjs';
const root = resolve(process.argv[2]);
const files = await readdir(root, { recursive: true });
Expand All @@ -9,4 +11,31 @@ if (models.length !== 1) throw new Error(`Expected one packaged prediction datab
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);
const neural = join(dirname(dirname(database)), 'prediction-neural');
if (!(await verified(neural, { files: bundlePins }))) throw new Error('Packaged neural model failed verification');
const pin = JSON.parse(await readFile(new URL('./prediction-neural.json', import.meta.url), 'utf8'));
if (JSON.stringify(JSON.parse(await readFile(join(neural, 'model-bundle.json'), 'utf8'))) !== JSON.stringify(pin.bundle)) throw new Error('Wrong packaged neural policy');
const build = JSON.parse(await readFile(join(neural, 'worker-notices/BUILD.json'), 'utf8'));
if (build.commit !== pin.revision || build.version !== '0.2.0') throw new Error('Wrong worker provenance');
const workerPin = pin.workers[build.target];
if (!workerPin) throw new Error('Unsupported packaged worker');
const windows = build.target.includes('windows');
let portable;
for (const [name, expected] of Object.entries(workerPin.files)) {
const file = name.split('/').at(-1);
if (!file.startsWith('switchify-smol-worker')) continue;
if (file.includes('avx2') && !windows) continue;
const matches = files.filter(p => p.split(/[\\/]/).at(-1) === file);
if (matches.length !== 1) throw new Error(`Expected one packaged ${file}`);
const path = join(root, matches[0]);
const bytes = await readFile(path);
// Platform release verification checks signatures after signing changes bytes.
if (!process.argv.includes('--signed') && (bytes.length !== expected.size || createHash('sha256').update(bytes).digest('hex') !== expected.sha256)) throw new Error('Packaged worker checksum mismatch');
if (!file.includes('avx2')) portable = path;
}
for (const [name, expected] of Object.entries(workerPin.files)) {
const file = name.split('/').at(-1);
if (file.startsWith('switchify-smol-worker')) continue;
if (!(await verified(join(neural, 'worker-notices'), { files: { [file]: expected } }))) throw new Error('Packaged worker notice mismatch');
}
console.log(process.argv.includes('--worker') ? portable : database);
Loading
Loading