diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7883ed8..f31bede 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index 61ef08e..0a06ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/neural-prediction.md b/docs/neural-prediction.md new file mode 100644 index 0000000..c8d4596 --- /dev/null +++ b/docs/neural-prediction.md @@ -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. diff --git a/docs/neural-windows-results.json b/docs/neural-windows-results.json new file mode 100644 index 0000000..d9670bd --- /dev/null +++ b/docs/neural-windows-results.json @@ -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." +} diff --git a/package.json b/package.json index 60d70df..b4436c8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/Verify-WindowsUiAccessPackage.ps1 b/scripts/Verify-WindowsUiAccessPackage.ps1 index 20bec72..663d5d2 100644 --- a/scripts/Verify-WindowsUiAccessPackage.ps1 +++ b/scripts/Verify-WindowsUiAccessPackage.ps1 @@ -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)) { @@ -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 +} diff --git a/scripts/check-packaged-prediction.mjs b/scripts/check-packaged-prediction.mjs index fb9cc7f..984d141 100644 --- a/scripts/check-packaged-prediction.mjs +++ b/scripts/check-packaged-prediction.mjs @@ -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 }); @@ -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); diff --git a/scripts/fetch-prediction-neural.mjs b/scripts/fetch-prediction-neural.mjs new file mode 100644 index 0000000..eda6440 --- /dev/null +++ b/scripts/fetch-prediction-neural.mjs @@ -0,0 +1,100 @@ +// Build-time only. Installed applications never fetch models or workers. +import { createHash } from 'node:crypto'; +import { readFile, writeFile, mkdir, copyFile, chmod } from 'node:fs/promises'; +import { join, resolve, basename } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { acquire, verified } from './fetch-prediction-model.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const manifest = JSON.parse(await readFile(new URL('./prediction-neural.json', import.meta.url), 'utf8')); +const hash = bytes => createHash('sha256').update(bytes).digest('hex'); +export const bundlePins = Object.fromEntries(Object.entries(manifest.bundle.files).map(([name, pin]) => + [name, { size: pin.bytes, sha256: pin.sha256 }])); + +function run(command, args, cwd) { + const result = spawnSync(command, args, { cwd, stdio: 'inherit', windowsHide: true }); + if (result.error || result.status !== 0) throw new Error(`Neural asset preparation failed: ${command}`); +} + +export async function pinnedFile(path, pin, url, fetcher = fetch) { + try { + const bytes = await readFile(path); + if (bytes.length === pin.bytes && hash(bytes) === pin.sha256) return; + } catch { /* Missing cache entry. */ } + const response = await fetcher(url, { signal: AbortSignal.timeout(300000) }); + if (!response.ok || !response.body) throw new Error('Neural source download failed'); + const chunks = []; + let size = 0; + for await (const chunk of response.body) { + size += chunk.length; + if (size > pin.bytes) throw new Error('Neural source exceeds pinned size'); + chunks.push(chunk); + } + const bytes = Buffer.concat(chunks); + if (size !== pin.bytes || hash(bytes) !== pin.sha256) throw new Error('Neural source checksum mismatch'); + await writeFile(path, bytes); +} + +export async function prepare() { + const target = process.env.TAURI_ENV_TARGET_TRIPLE ?? + (process.platform === 'win32' ? 'x86_64-pc-windows-msvc' : + process.platform === 'darwin' ? `${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-apple-darwin` : 'x86_64-unknown-linux-gnu'); + const workerPin = manifest.workers[target]; + if (!workerPin) throw new Error(`Unsupported neural target: ${target}`); + const cache = join(root, '.cache/prediction-neural'); + const bundle = join(root, 'src-tauri/resources/prediction-neural'); + const workers = join(cache, target); + const binaries = join(root, 'src-tauri/binaries'); + await mkdir(bundle, { recursive: true }); + await mkdir(binaries, { recursive: true }); + await acquire(workers, workerPin); + for (const name of Object.keys(workerPin.files)) { + const file = basename(name); + if (file.startsWith('switchify-smol-worker')) { + // Only Windows uses the optional accelerated worker. + if (file.includes('avx2') && process.platform !== 'win32') continue; + const ext = file.endsWith('.exe') ? '.exe' : ''; + const stem = ext ? file.slice(0, -4) : file; + const dest = join(binaries, `${stem}-${target}${ext}`); + await copyFile(join(workers, name), dest); + await chmod(dest, 0o755); + } else { + await mkdir(join(bundle, 'worker-notices'), { recursive: true }); + await copyFile(join(workers, name), join(bundle, 'worker-notices', file)); + } + } + if (!(await verified(bundle, { files: bundlePins }))) { + const source = join(cache, 'source'); + await mkdir(source, { recursive: true }); + for (const [name, pin] of Object.entries(manifest.sources.files)) { + await pinnedFile(join(source, name), pin, pin.url); + } + const upstream = join(cache, 'converter'); + await mkdir(upstream, { recursive: true }); + run('git', ['init', '--quiet'], upstream); + run('git', ['fetch', '--quiet', '--depth=1', 'https://github.com/switchifyapp/switchify-prediction', manifest.revision], upstream); + run('git', ['checkout', '--quiet', '--detach', 'FETCH_HEAD'], upstream); + // Reject local converter modifications rather than executing them. + run('git', ['diff', '--exit-code', 'HEAD', '--'], upstream); + const model = join(cache, 'model.gguf'); + if (!(await verified(cache, { files: { 'model.gguf': bundlePins['model.gguf'] } }))) { + // The converter refuses overwrites; a corrupt cache is reported explicitly. + run('cargo', ['run', '--locked', '--release', '--manifest-path', join(upstream, 'neural/Cargo.toml'), + '-p', 'switchify-smol-worker', '--bin', 'quantize', '--target-dir', join(cache, 'target'), '--', source, model], root); + } + if (!(await verified(cache, { files: { 'model.gguf': bundlePins['model.gguf'] } }))) throw new Error('Converted model checksum mismatch'); + await copyFile(model, join(bundle, 'model.gguf')); + for (const [dest, from] of [['config.json','config.json'], ['tokenizer.json','tokenizer.json'], ['MODEL_CARD.md','README.md']]) { + await copyFile(join(source, from), join(bundle, dest)); + } + await copyFile(join(upstream, 'neural/MODEL_LICENSE.txt'), join(bundle, 'MODEL_LICENSE.txt')); + await copyFile(join(upstream, 'neural/worker/src/bin/quantize.rs'), join(bundle, 'quantize.rs')); + } + await writeFile(join(bundle, 'model-bundle.json'), JSON.stringify(manifest.bundle, null, 2) + '\n'); + await writeFile(join(bundle, 'source-manifest.json'), JSON.stringify(manifest.sources, null, 2) + '\n'); + if (!(await verified(bundle, { files: bundlePins }))) throw new Error('Neural bundle verification failed'); + console.log(`Verified offline neural model and ${target} workers`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await prepare(); diff --git a/scripts/fetch-prediction-neural.node-test.mjs b/scripts/fetch-prediction-neural.node-test.mjs new file mode 100644 index 0000000..3cfc101 --- /dev/null +++ b/scripts/fetch-prediction-neural.node-test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { pinnedFile, bundlePins } from './fetch-prediction-neural.mjs'; + +test('neural sources reject corrupt/oversized input and retain the previous cache', async () => { + const dir = await mkdtemp(join(tmpdir(), 'neural-source-')); + try { + const path = join(dir, 'source'); + const good = Buffer.from('good'); + const pin = { bytes: 4, sha256: createHash('sha256').update(good).digest('hex') }; + await writeFile(path, 'old'); + for (const bad of ['evil', '', 'toolong']) { + await assert.rejects(pinnedFile(path, pin, 'https://fixture.invalid', async () => new Response(bad))); + assert.equal(await readFile(path, 'utf8'), 'old'); + } + await pinnedFile(path, pin, 'https://fixture.invalid', async () => new Response(good)); + await pinnedFile(path, pin, 'https://fixture.invalid', () => { throw new Error('cache should avoid download'); }); + assert.equal(await readFile(path, 'utf8'), 'good'); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('installed model pins retain the approved Q8 identity and license', () => { + assert.equal(bundlePins['model.gguf'].size, 143041952); + assert.equal(bundlePins['model.gguf'].sha256, '8d75e9b96c4b64e8a1180224cbaefbbc7744f21ca2e0be2319a429f2c589d342'); + assert.ok(bundlePins['MODEL_LICENSE.txt']); + assert.ok(bundlePins['MODEL_CARD.md']); +}); diff --git a/scripts/measure-neural.py b/scripts/measure-neural.py new file mode 100644 index 0000000..7df6dad --- /dev/null +++ b/scripts/measure-neural.py @@ -0,0 +1,77 @@ +"""Measure the ignored Rust integration fixture and its process-tree RSS. + +Requires psutil. Uses synthetic context and fake activity/input adapters only. +""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import tempfile +import time + +import psutil + +parser = argparse.ArgumentParser(description=__doc__) +source = parser.add_mutually_exclusive_group(required=True) +source.add_argument('--test-binary', type=Path) +source.add_argument('--build', action='store_true', help='Build and locate the release integration fixture') +parser.add_argument('--model', type=Path, default=os.environ.get('SWITCHIFY_BENCHMARK_MODEL')) +parser.add_argument('--worker', type=Path, default=os.environ.get('SWITCHIFY_BENCHMARK_WORKER')) +parser.add_argument('--output', type=Path, required=True) +args = parser.parse_args() +if args.model is None or args.worker is None: + parser.error('Explicit model and worker paths or benchmark environment variables are required') +if args.build: + built = subprocess.run(['cargo', 'test', '--release', '--lib', '--locked', + '--manifest-path', 'src-tauri/Cargo.toml', + 'neural_integration_benchmark', '--no-run', '--message-format=json'], + check=True, capture_output=True, text=True) + artifacts = [json.loads(line) for line in built.stdout.splitlines()] + binaries = [item['executable'] for item in artifacts + if item.get('reason') == 'compiler-artifact' + and item.get('target', {}).get('name') == 'switchify_pc_lib' + and item.get('executable')] + if len(binaries) != 1: + raise RuntimeError('Could not identify the integration test executable') + args.test_binary = Path(binaries[0]) +with tempfile.TemporaryDirectory() as temporary: + report_path = Path(temporary) / 'report.json' + env = dict(os.environ, SWITCHIFY_BENCHMARK_MODEL=str(args.model.resolve()), + SWITCHIFY_BENCHMARK_WORKER=str(args.worker.resolve()), + SWITCHIFY_NEURAL_REPORT=str(report_path)) + with (Path(temporary) / 'output.txt').open('w') as output: + process = subprocess.Popen([str(args.test_binary.resolve()), 'neural_integration_benchmark', + '--ignored', '--nocapture', '--test-threads=1'], env=env, + stdout=output, stderr=subprocess.STDOUT) + tracked = psutil.Process(process.pid) + peak = 0 + start = time.monotonic() + while process.poll() is None: + try: + rss = 0 + for child in [tracked, *tracked.children(recursive=True)]: + try: + rss += child.memory_info().rss + except (psutil.NoSuchProcess, psutil.AccessDenied): + pass + peak = max(peak, rss) + except psutil.NoSuchProcess: + pass + if time.monotonic() - start > 1200: + for child in tracked.children(recursive=True): + child.kill() + process.kill() + process.wait() + raise RuntimeError('Integration benchmark exceeded 20 minutes') + time.sleep(0.02) + if report_path.is_file(): + report = json.loads(report_path.read_bytes()) + report['process_tree_peak_rss_bytes'] = peak + report['memory_note'] = '20ms sampled sum of test process and child RSS; shared pages may be counted twice and short peaks missed.' + args.output.write_bytes((json.dumps(report, indent=2) + '\n').encode()) + print(json.dumps(report)) + if process.returncode: + raise RuntimeError((Path(temporary) / 'output.txt').read_text()) + if not report_path.is_file(): + raise RuntimeError('Integration fixture did not produce a measurement report') diff --git a/scripts/prediction-neural.json b/scripts/prediction-neural.json new file mode 100644 index 0000000..c03be70 --- /dev/null +++ b/scripts/prediction-neural.json @@ -0,0 +1,294 @@ +{ + "revision": "99a3ef03cb54500006887a998547fded4ad39e0e", + "bundle": { + "format_version": 1, + "model_id": "smollm2-135m-q8-v1", + "source": "HuggingFaceTB/SmolLM2-135M", + "revision": "93efa2f097d58c2a74874c7e644dbc9b0cee75a2", + "conversion": "Candle 0.11.0 Q8_0, F32 norms, adjacent-pair RoPE, tied embeddings; neural/worker/src/bin/quantize.rs", + "policy": { + "context_tokens": 64, + "shortlist": 8, + "max_results": 5, + "scoring": "whole-word log probability plus boundary probability, sequential" + }, + "files": { + "model.gguf": { + "bytes": 143041952, + "sha256": "8d75e9b96c4b64e8a1180224cbaefbbc7744f21ca2e0be2319a429f2c589d342" + }, + "config.json": { + "bytes": 704, + "sha256": "1d556eab73b69c7f11f64c557a2f9c6f440bd4c6b89bb2584a6b498c92603843" + }, + "tokenizer.json": { + "bytes": 2104556, + "sha256": "9ca9acddb6525a194ec8ac7a87f24fbba7232a9a15ffa1af0c1224fcd888e47c" + }, + "MODEL_CARD.md": { + "bytes": 6340, + "sha256": "d1ba68cae64a89b6b434b11526e6e2271ee5ffd2c914ec35ed515f9d84c6085c" + }, + "MODEL_LICENSE.txt": { + "bytes": 11358, + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + } + } + }, + "sources": { + "model": "HuggingFaceTB/SmolLM2-135M", + "revision": "93efa2f097d58c2a74874c7e644dbc9b0cee75a2", + "files": { + "config.json": { + "url": "https://huggingface.co/HuggingFaceTB/SmolLM2-135M/resolve/93efa2f097d58c2a74874c7e644dbc9b0cee75a2/config.json", + "sha256": "1d556eab73b69c7f11f64c557a2f9c6f440bd4c6b89bb2584a6b498c92603843", + "bytes": 704 + }, + "tokenizer.json": { + "url": "https://huggingface.co/HuggingFaceTB/SmolLM2-135M/resolve/93efa2f097d58c2a74874c7e644dbc9b0cee75a2/tokenizer.json", + "sha256": "9ca9acddb6525a194ec8ac7a87f24fbba7232a9a15ffa1af0c1224fcd888e47c", + "bytes": 2104556 + }, + "model.safetensors": { + "url": "https://huggingface.co/HuggingFaceTB/SmolLM2-135M/resolve/93efa2f097d58c2a74874c7e644dbc9b0cee75a2/model.safetensors", + "sha256": "80521b40281d6ce74e35c9282c22539e75aa0ac8578892b2a59955ef78d55da1", + "bytes": 269060552 + }, + "README.md": { + "url": "https://huggingface.co/HuggingFaceTB/SmolLM2-135M/resolve/93efa2f097d58c2a74874c7e644dbc9b0cee75a2/README.md", + "sha256": "d1ba68cae64a89b6b434b11526e6e2271ee5ffd2c914ec35ed515f9d84c6085c", + "bytes": 6340 + } + } + }, + "workers": { + "aarch64-apple-darwin": { + "url": "https://github.com/switchifyapp/switchify-prediction/releases/download/v0.2.0/switchify-prediction-neural-0.2.0-aarch64-apple-darwin.zip", + "archive_sha256": "af2c2fe55e9681522bb37478d71256f9cdb5b436945eb2034eaf1a25d90b2c36", + "files": { + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/QUALIFICATION.md": { + "size": 7086, + "sha256": "f5d13a7406728515112cab4da093ee19642f68abbb9f30c8c8f511ae68ec8eb3" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/QUALIFICATION.json": { + "size": 473, + "sha256": "55b6e9429ecb290fd194682406773a6f07102861529d5e1c0e51a6d2d4fc8c7e" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/LICENSE": { + "size": 1079, + "sha256": "1e5643d5431cc5b2d04534dee7b3f716b24fc3b82493a1659534cbfa4fc8732c" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/Cargo.lock": { + "size": 46211, + "sha256": "e31dd348e4db5cce55e43fa6730302678f1cff85ae833a67fe0e7a405f64954a" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/THIRD_PARTY_NOTICES.md": { + "size": 1545871, + "sha256": "6bc01a7be7b3aaa59d2c04acbbdea91b20699f0f20d556debc5e674eb0d3600f" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/README.md": { + "size": 7136, + "sha256": "9db3b6561aeb463ec0988ec25200d3b32318daf96c1f11ebe39551bcb667be63" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/switchify-smol-worker": { + "size": 7059808, + "sha256": "6e1107badf39c66102fc5d3370254976218d19ca5a673ce6203ab1c047f9a7bc" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/SHA256SUMS": { + "size": 998, + "sha256": "917dfee26b1eb70e4aadca383d26e6a0b5f0561f9552ad639fc5682fd6066e70" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/RUST_LIBRARY_COPYRIGHT.html": { + "size": 279302, + "sha256": "0a65bb747c49c7bb816cbc7188319bd6e4e8d08091c1190b8a3c0971c47968ed" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/verify_bundle.py": { + "size": 1282, + "sha256": "69c124d037d9e25018f690c10b861c4388c01a8cd8eb3ed438d17136196c1194" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/BUILD.json": { + "size": 568, + "sha256": "0c927cae38c96e4c9067008ad70d66a894f7bb5b43fff931483284ec336549c3" + }, + "switchify-prediction-neural-0.2.0-aarch64-apple-darwin/SECURITY.md": { + "size": 1973, + "sha256": "d79bf51500d3927bc47fad3e61b987be10764c7003b82df0f9d1f752f72a5182" + } + } + }, + "x86_64-apple-darwin": { + "url": "https://github.com/switchifyapp/switchify-prediction/releases/download/v0.2.0/switchify-prediction-neural-0.2.0-x86_64-apple-darwin.zip", + "archive_sha256": "abe05e5edf77ea94db06a60bb9a8dd6ca30632657f9ea3fa3fb147104d74e7b5", + "files": { + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/QUALIFICATION.md": { + "size": 7086, + "sha256": "f5d13a7406728515112cab4da093ee19642f68abbb9f30c8c8f511ae68ec8eb3" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/QUALIFICATION.json": { + "size": 473, + "sha256": "55b6e9429ecb290fd194682406773a6f07102861529d5e1c0e51a6d2d4fc8c7e" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/LICENSE": { + "size": 1079, + "sha256": "1e5643d5431cc5b2d04534dee7b3f716b24fc3b82493a1659534cbfa4fc8732c" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/Cargo.lock": { + "size": 46211, + "sha256": "e31dd348e4db5cce55e43fa6730302678f1cff85ae833a67fe0e7a405f64954a" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/THIRD_PARTY_NOTICES.md": { + "size": 1545871, + "sha256": "6bc01a7be7b3aaa59d2c04acbbdea91b20699f0f20d556debc5e674eb0d3600f" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/README.md": { + "size": 7136, + "sha256": "9db3b6561aeb463ec0988ec25200d3b32318daf96c1f11ebe39551bcb667be63" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/switchify-smol-worker": { + "size": 7635864, + "sha256": "27e6eb09456826f5a127b3d935e20af29a90a203f91fa73cc5b68445298aa2a6" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/switchify-smol-worker-avx2": { + "size": 7786832, + "sha256": "e5e6fefff242e27603ca1b2f2885c52d10938111958c1c674266e02f8a01c3d5" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/SHA256SUMS": { + "size": 1091, + "sha256": "d3dbdd7df98ff6202cc55b3860eba3fb5e1f5c4a4c75d0fe1186f96d05141ebd" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/RUST_LIBRARY_COPYRIGHT.html": { + "size": 279302, + "sha256": "0a65bb747c49c7bb816cbc7188319bd6e4e8d08091c1190b8a3c0971c47968ed" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/verify_bundle.py": { + "size": 1282, + "sha256": "69c124d037d9e25018f690c10b861c4388c01a8cd8eb3ed438d17136196c1194" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/BUILD.json": { + "size": 605, + "sha256": "17c25a610f304162d4b8063261d74e2cfdc986bec92ca41598301f751ae70edd" + }, + "switchify-prediction-neural-0.2.0-x86_64-apple-darwin/SECURITY.md": { + "size": 1973, + "sha256": "d79bf51500d3927bc47fad3e61b987be10764c7003b82df0f9d1f752f72a5182" + } + } + }, + "x86_64-pc-windows-msvc": { + "url": "https://github.com/switchifyapp/switchify-prediction/releases/download/v0.2.0/switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc.zip", + "archive_sha256": "48fb3f678fcf5788c305df40ecd3e3b1485382f186700aff6fd0ec1d9edd0a91", + "files": { + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/BUILD.json": { + "size": 627, + "sha256": "1358158df92945aeb66516326441e1b62066e9a901f243c899d2f02a2df87f0b" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/Cargo.lock": { + "size": 46211, + "sha256": "e31dd348e4db5cce55e43fa6730302678f1cff85ae833a67fe0e7a405f64954a" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/LICENSE": { + "size": 1100, + "sha256": "38a190016c5d23b6acf37beda35bdef634a3572da76bb7c6ebfec84763648357" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/QUALIFICATION.json": { + "size": 473, + "sha256": "55b6e9429ecb290fd194682406773a6f07102861529d5e1c0e51a6d2d4fc8c7e" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/QUALIFICATION.md": { + "size": 7147, + "sha256": "934945362415a5672a5c05887553911778f2032508b5efcb9a755c5e551548dc" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/README.md": { + "size": 7136, + "sha256": "9db3b6561aeb463ec0988ec25200d3b32318daf96c1f11ebe39551bcb667be63" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/RUST_LIBRARY_COPYRIGHT.html": { + "size": 279302, + "sha256": "0a65bb747c49c7bb816cbc7188319bd6e4e8d08091c1190b8a3c0971c47968ed" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/SECURITY.md": { + "size": 1973, + "sha256": "d79bf51500d3927bc47fad3e61b987be10764c7003b82df0f9d1f752f72a5182" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/SHA256SUMS": { + "size": 1116, + "sha256": "eb5b63c21f825c8a5078e07bf3578bcf3b145b60babf18f0583a850a00383b2f" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/switchify-smol-worker-avx2.exe": { + "size": 8453632, + "sha256": "eeecd019b9add31d9da9c1ae598f99df4351cabf54b86b62342c1fd06facf7b1" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/switchify-smol-worker.exe": { + "size": 8256512, + "sha256": "e0f334e7489cddff0d5978e43dcff3d79eef08c3d4f3f81b0814e816a556f524" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/THIRD_PARTY_NOTICES.md": { + "size": 1552635, + "sha256": "448c7f55a9ef918d5cf67f57cfb141c62f7bc6d819cc168fc4ad20f73b34c5f0" + }, + "switchify-prediction-neural-0.2.0-x86_64-pc-windows-msvc/verify_bundle.py": { + "size": 1315, + "sha256": "96b47d695b104c61ca65de856108d8850a6da7c00220ae72dd91b8e73cb8f6f2" + } + } + }, + "x86_64-unknown-linux-gnu": { + "url": "https://github.com/switchifyapp/switchify-prediction/releases/download/v0.2.0/switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu.zip", + "archive_sha256": "ce3350ebd76110474f783c5b0eda352b6a2011dbc5f00f0385cb9d149f1e968e", + "files": { + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/BUILD.json": { + "size": 632, + "sha256": "ae22d56dc215edec0a011b3a9f23116dc18bafdb2cab12823269ee26f5358d46" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/RUST_LIBRARY_COPYRIGHT.html": { + "size": 279302, + "sha256": "0a65bb747c49c7bb816cbc7188319bd6e4e8d08091c1190b8a3c0971c47968ed" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/switchify-smol-worker": { + "size": 9876080, + "sha256": "fb87190f587775fca62976f6f884c2ef1267a71c95921313a747ac9d667e0448" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/SHA256SUMS": { + "size": 1091, + "sha256": "c1ba9e8c96e6b7a1fe435e7488492ed45d8747411e208d345e17a585e1757768" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/LICENSE": { + "size": 1079, + "sha256": "1e5643d5431cc5b2d04534dee7b3f716b24fc3b82493a1659534cbfa4fc8732c" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/switchify-smol-worker-avx2": { + "size": 9966768, + "sha256": "7750438778be11849f11f732255104e75ad996319922c237e9f563d9ec9d15d0" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/SECURITY.md": { + "size": 1973, + "sha256": "d79bf51500d3927bc47fad3e61b987be10764c7003b82df0f9d1f752f72a5182" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/verify_bundle.py": { + "size": 1282, + "sha256": "69c124d037d9e25018f690c10b861c4388c01a8cd8eb3ed438d17136196c1194" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/Cargo.lock": { + "size": 46211, + "sha256": "e31dd348e4db5cce55e43fa6730302678f1cff85ae833a67fe0e7a405f64954a" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/README.md": { + "size": 7136, + "sha256": "9db3b6561aeb463ec0988ec25200d3b32318daf96c1f11ebe39551bcb667be63" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/THIRD_PARTY_NOTICES.md": { + "size": 1535176, + "sha256": "93cad9c9282edec579015f6d822901ab48596ff21e9f74bb3f07dc2ae8b7314c" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/QUALIFICATION.json": { + "size": 473, + "sha256": "55b6e9429ecb290fd194682406773a6f07102861529d5e1c0e51a6d2d4fc8c7e" + }, + "switchify-prediction-neural-0.2.0-x86_64-unknown-linux-gnu/QUALIFICATION.md": { + "size": 7086, + "sha256": "f5d13a7406728515112cab4da093ee19642f68abbb9f30c8c8f511ae68ec8eb3" + } + } + } + } +} diff --git a/scripts/probe-neural-worker.py b/scripts/probe-neural-worker.py new file mode 100644 index 0000000..6ddcee8 --- /dev/null +++ b/scripts/probe-neural-worker.py @@ -0,0 +1,74 @@ +"""Text-free packaged-worker diagnostic with a fixed synthetic query. + +The five-second diagnostic bound measures slow workers without changing the +application's 500 ms inference deadline. No desktop input is injected. +""" +import json +import os +from pathlib import Path +import queue +import struct +import subprocess +import threading +import time + + +def read_frame(stream, replies): + try: + header = stream.read(4) + if len(header) != 4: + raise ValueError() + length = struct.unpack(' Option { + match &self.state { + State::Ready(model) => model.neural_status(), + _ => None, + } + } pub fn open(model: &Path) -> Self { let model = model.to_path_buf(); let (tx, rx) = mpsc::sync_channel(1); @@ -61,6 +68,21 @@ impl Database { State::Unavailable => Status::Unavailable, } } + pub fn poll(&mut self) -> Option { + if let State::Ready(model) = &mut self.state { + model.poll() + } else { + None + } + } + pub fn pending(&self) -> bool { + matches!(&self.state, State::Ready(model) if model.pending()) + } + pub fn reset(&mut self) { + if let State::Ready(model) = &mut self.state { + model.reset(); + } + } pub fn predict(&mut self, context: &Context) -> (Status, Prediction) { let status = self.status(); if status != Status::Ready || context.partial { @@ -96,6 +118,14 @@ impl Database { ) } + #[cfg(test)] + pub fn with_predictor(model: Box) -> Self { + Self { + state: State::Ready(model), + slow_call: SLOW_CALL, + slow_calls: 0, + } + } #[cfg(test)] pub fn fixture() -> Self { Self { diff --git a/src-tauri/src/prediction/mod.rs b/src-tauri/src/prediction/mod.rs index 129353e..1131e87 100644 --- a/src-tauri/src/prediction/mod.rs +++ b/src-tauri/src/prediction/mod.rs @@ -84,6 +84,11 @@ impl Client { use std::os::windows::process::CommandExt; command.creation_flags(0x08000000); } + #[cfg(target_os = "macos")] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } let mut child = command.spawn().map_err(|_| ())?; #[cfg(target_os = "windows")] let job = match Job::contain(&child) { @@ -116,6 +121,10 @@ impl Client { } impl Client { fn terminate(&mut self) { + #[cfg(target_os = "macos")] + unsafe { + libc::kill(-(self.child.id() as i32), libc::SIGKILL); + } let _ = self.child.kill(); let _ = self.child.wait(); // Drain the bounded channel before joining, including a final EOF reply. @@ -200,6 +209,7 @@ struct Service { ignored: Vec, failed: bool, generation: u64, + refinement_pending: bool, outstanding: Option, last: Option, edit: Vec, @@ -708,8 +718,10 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign batch, revision, tracking, + pending, status, } => { + s.refinement_pending = pending && generation == s.generation; s.received_suggestions( keyboard, generation, revision, batch, tracking, status, ); @@ -785,14 +797,15 @@ pub fn poll(app: &AppHandle, keyboard: Option<&mut Keyboard>, enabled: bool, ign index, } } else { - if s.last - .is_some_and(|t| t.elapsed() < Duration::from_millis(250)) - { + if s.last.is_some_and(|t| { + t.elapsed() < Duration::from_millis(if s.refinement_pending { 20 } else { 250 }) + }) { return; } Request::Query { generation: s.generation, edits: s.take_edits(), + displayed: keyboard.displayed_prediction(), revision: s.edit_revision, shift: keyboard.prediction_shift(), caps: keyboard.caps, @@ -968,6 +981,7 @@ mod tests { assert!(!keyboard.error); // Whatever the replacement answers, a second miss soon after fails. let batch = worker::Batch { + refined: false, token: 1, words: vec!["water".into()], }; diff --git a/src-tauri/src/prediction/model.rs b/src-tauri/src/prediction/model.rs index 19a9ba6..ccbee6c 100644 --- a/src-tauri/src/prediction/model.rs +++ b/src-tauri/src/prediction/model.rs @@ -11,10 +11,27 @@ pub struct Prediction { } pub trait Predict: Send { + #[cfg(test)] + fn neural_status(&self) -> Option { + None + } fn predict(&mut self, before: &str, prefix: &str) -> Result; + fn poll(&mut self) -> Option { + None + } + fn pending(&self) -> bool { + false + } + fn reset(&mut self) {} +} + +pub struct Model { + predictor: Predictor, + neural: Option, + request: Option, + config: Option, } -pub struct Model(Predictor); impl Model { pub fn open(path: &Path) -> Result { let mut file = File::open(path).map_err(|_| ())?; @@ -30,7 +47,55 @@ impl Model { if format!("{:x}", hash.finalize()) != DATABASE_SHA256 { return Err(()); } - Predictor::open(path, None).map(Self).map_err(|_| ()) + let predictor = Predictor::open(path, None).map_err(|_| ())?; + let config = (|| { + let bundle = path.parent()?.parent()?.join("prediction-neural"); + let executable = std::env::current_exe().ok()?; + #[cfg(test)] + let executable = std::env::var_os("SWITCHIFY_BENCHMARK_WORKER") + .map(std::path::PathBuf::from) + .unwrap_or(executable); + let worker = |name: &str| { + let name = format!("{name}{}", std::env::consts::EXE_SUFFIX); + let bundled = executable.parent()?.join(&name); + if bundled.is_file() { + return Some(bundled); + } + if cfg!(debug_assertions) { + let target = if cfg!(target_os = "windows") { + "x86_64-pc-windows-msvc" + } else if cfg!(target_arch = "aarch64") { + "aarch64-apple-darwin" + } else { + "x86_64-apple-darwin" + }; + return Some(Path::new(env!("CARGO_MANIFEST_DIR")).join("binaries").join( + format!( + "{}-{target}{}", + name.trim_end_matches(std::env::consts::EXE_SUFFIX), + std::env::consts::EXE_SUFFIX + ), + )); + } + None + }; + Some(switchify_prediction_neural::Config { + bundle, + portable_worker: worker("switchify-smol-worker")?, + accelerated_worker: if cfg!(target_os = "windows") { + worker("switchify-smol-worker-avx2").filter(|p| p.is_file()) + } else { + None + }, + threads: 4, + }) + })(); + Ok(Self { + predictor, + neural: None, + config, + request: None, + }) } } @@ -41,24 +106,67 @@ fn display_word(word: String) -> String { } } impl Predict for Model { + #[cfg(test)] + fn neural_status(&self) -> Option { + self.neural.as_ref().map(|n| n.status()) + } fn predict(&mut self, before: &str, prefix: &str) -> Result { + // Child inference starts only after the parent has contained this worker + // and sent a query, never during speculative statistical loading. + if let Some(config) = self.config.take() { + self.neural = switchify_prediction_neural::Refiner::new(config).ok(); + } + let options = Options { + limit: 5, + min_chars: 0, + unigram_only: false, + }; + if let Some(neural) = &mut self.neural { + if let Ok(immediate) = neural.submit(&self.predictor, before, prefix, options, 0) { + self.request = immediate + .refinement_requested + .then_some(immediate.request_id); + return Ok(Prediction { + words: immediate.words.into_iter().map(display_word).collect(), + }); + } + } + self.request = None; Ok(Prediction { words: self - .0 - .predict( - before, - prefix, - Options { - limit: 5, - min_chars: 0, - unigram_only: false, - }, - ) + .predictor + .predict(before, prefix, options) .into_iter() .map(|s| display_word(s.word)) .collect(), }) } + fn poll(&mut self) -> Option { + let result = self.neural.as_mut()?.poll()?; + if self.request != Some(result.request_id) { + return None; + } + self.request = None; + Some(Prediction { + words: result.words.into_iter().map(display_word).collect(), + }) + } + fn pending(&self) -> bool { + self.request.is_some() + && self.neural.as_ref().is_some_and(|n| { + matches!( + n.status(), + switchify_prediction_neural::Status::Ready + | switchify_prediction_neural::Status::Loading + ) + }) + } + fn reset(&mut self) { + self.request = None; + if let Some(n) = &mut self.neural { + n.reset(); + } + } } #[cfg(test)] @@ -87,8 +195,22 @@ mod tests { 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()); + let mut model = Model { + predictor: Predictor::open(&path, None).unwrap(), + neural: None, + config: None, + request: None, + }; + model.config = Some(switchify_prediction_neural::Config { + bundle: dir.0.join("missing"), + portable_worker: dir.0.join("missing-worker"), + accelerated_worker: None, + threads: 4, + }); assert_eq!(model.predict("I need ", "h").unwrap().words[0], "help"); + assert!(!model.pending()); + assert!(model.config.is_none()); + assert!(model.poll().is_none()); assert_eq!(model.predict("I drink ", "").unwrap().words[0], "water"); assert!(model.predict("", "zyzzy").unwrap().words.is_empty()); assert_eq!( diff --git a/src-tauri/src/prediction/worker.rs b/src-tauri/src/prediction/worker.rs index d667a23..4ed7f32 100644 --- a/src-tauri/src/prediction/worker.rs +++ b/src-tauri/src/prediction/worker.rs @@ -42,6 +42,7 @@ pub enum Request { Query { generation: u64, edits: Vec, + displayed: Option, revision: u64, shift: Shift, caps: bool, @@ -60,6 +61,7 @@ pub enum Request { #[derive(Clone, Serialize, Deserialize)] pub struct Batch { pub token: u64, + pub refined: bool, pub words: Vec, } #[derive(Serialize, Deserialize)] @@ -69,6 +71,7 @@ pub enum Response { batch: Option, revision: u64, tracking: bool, + pending: bool, status: Status, }, Insert { @@ -120,6 +123,8 @@ pub struct Engine { inserts: Vec<(usize, String)>, token: u64, case: (Shift, bool, bool), + displayed: Option<(Batch, Vec<(usize, String)>)>, + generation: u64, } impl Engine { pub fn new(database: Database, tracked: bool) -> Self { @@ -140,9 +145,13 @@ impl Engine { inserts: Vec::new(), token: 0, case: (Shift::Off, false, false), + displayed: None, + generation: 0, } } fn clear(&mut self) { + self.database.reset(); + self.displayed = None; self.buffer.clear(); self.clipped = false; self.snapshot = None; @@ -220,21 +229,26 @@ impl Engine { return None; } if self.buffer.is_empty() { - self.batch = None; - self.snapshot = None; + self.clear(); return None; } self.status = self.database.status(); - if self.snapshot.as_ref() == Some(&self.buffer) + let same = self.snapshot.as_ref() == Some(&self.buffer) && self.case == (shift, caps, sentence_start) - && self.status != Status::Loading - { - return self.batch.clone(); - } + && self.status != Status::Loading; let ctx = context::extract(&self.buffer, self.clipped); - let (status, prediction) = self.database.predict(&ctx); - let words = prediction.words; - self.status = status; + let (words, refined) = if same { + match self.database.poll() { + Some(prediction) => (prediction.words, true), + None => return self.batch.clone(), + } + } else { + self.database.reset(); + self.displayed = None; + let (status, prediction) = self.database.predict(&ctx); + self.status = status; + (prediction.words, false) + }; self.token = self.token.wrapping_add(1); self.inserts.clear(); let mut labels = Vec::new(); @@ -283,20 +297,32 @@ impl Engine { self.snapshot = (self.status == Status::Ready).then(|| self.buffer.clone()); self.batch = Some(Batch { token: self.token, + refined, words: labels, }); self.batch.clone() } fn accept(&mut self, token: u64, index: usize) -> Option<(usize, String)> { - if self.batch.as_ref()?.token != token || index >= self.inserts.len() { - return None; - } if !self.stable(self.target?, self.activity) { self.clear(); return None; } - let insert = self.inserts[index].clone(); + let inserts = if self.batch.as_ref().is_some_and(|b| b.token == token) { + &self.inserts + } else if let Some((batch, inserts)) = &self.displayed { + if batch.token != token { + return None; + } + inserts + } else { + return None; + }; + let insert = inserts.get(index)?.clone(); + self.database.reset(); self.batch = None; + self.displayed = None; + self.inserts.clear(); + self.snapshot = None; Some(insert) } pub fn respond(&mut self, request: Request) -> Response { @@ -304,11 +330,27 @@ impl Engine { Request::Query { generation, edits, + displayed, revision, shift, caps, sentence_start, } => { + if self.generation != generation { + self.database.reset(); + self.displayed = None; + self.snapshot = None; + } + self.generation = generation; + if let Some(batch) = self.batch.as_ref().filter(|b| Some(b.token) == displayed) { + self.displayed = Some((batch.clone(), self.inserts.clone())); + } else if self + .displayed + .as_ref() + .is_none_or(|(b, _)| Some(b.token) != displayed) + { + self.displayed = None; + } let batch = self.query(edits, revision, shift, caps, sentence_start); self.status = self.database.status(); let tracking = self @@ -319,6 +361,7 @@ impl Engine { batch, revision: self.revision, tracking, + pending: self.database.pending(), status: self.status, } } @@ -328,7 +371,7 @@ impl Engine { revision, index, } => { - let insert = if revision == self.revision { + let insert = if revision == self.revision && generation == self.generation { self.accept(token, index) } else { self.clear(); @@ -461,6 +504,12 @@ pub fn run_from_args() -> bool { std::thread::spawn(move || loop { std::thread::sleep(std::time::Duration::from_millis(250)); if unsafe { libc::getppid() } != parent { + // The parent creates a dedicated group for this process tree. + unsafe { + if libc::getpgrp() == libc::getpid() { + libc::kill(0, libc::SIGKILL); + } + } std::process::exit(0); } }); @@ -508,6 +557,7 @@ mod tests { send( &mut frame, &Request::Query { + displayed: None, generation: 0, edits, revision, @@ -519,6 +569,192 @@ mod tests { .unwrap(); frame } + struct Deferred { + pending: bool, + } + impl super::super::model::Predict for Deferred { + fn predict(&mut self, _: &str, _: &str) -> Result { + self.pending = true; + Ok(super::super::model::Prediction { + words: vec!["water".into(), "walk".into()], + }) + } + fn poll(&mut self) -> Option { + std::mem::take(&mut self.pending).then(|| super::super::model::Prediction { + words: vec!["walk".into(), "water".into()], + }) + } + fn pending(&self) -> bool { + self.pending + } + fn reset(&mut self) { + self.pending = false; + } + } + fn request(edits: Vec, revision: u64, displayed: Option) -> Request { + Request::Query { + generation: revision, + edits, + revision, + displayed, + shift: Shift::Off, + caps: false, + sentence_start: false, + } + } + fn response_batch(response: Response) -> Batch { + match response { + Response::Suggestions { batch: Some(b), .. } => b, + _ => panic!("missing batch"), + } + } + #[test] + #[ignore = "real offline model measurement; no desktop input"] + fn neural_integration_benchmark() { + use std::time::{Duration, Instant}; + let path = std::env::var_os("SWITCHIFY_BENCHMARK_MODEL") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources/prediction-model/english.sqlite") + }); + let mut immediate = Vec::new(); + let mut refined = Vec::new(); + let mut failures = 0; + let mut session_reports = Vec::new(); + // A timeout deliberately disables refinement for the keyboard session. + // Simulate at most five explicit reopenings to collect 1,000 successes, + // reporting every failed attempt, including failures during warmup. + for session in 0..5 { + let mut e = engine(); + e.database = Database::open(&path); + let cold = Instant::now(); + while e.database.status() == Status::Loading { + assert!(cold.elapsed() < Duration::from_secs(30)); + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(e.database.status(), Status::Ready); + let prior_successes = refined.len(); + let mut warmup_completed = 0; + let mut failed_during_warmup = false; + for i in 0..1020 { + let text = [ + "please send the ", + "I need he", + "can you ", + "I want to ", + "thank you for ", + ][i % 5]; + let revision = (i + 1) as u64; + let start = Instant::now(); + let first = response_batch(e.respond(request( + vec![edit(Edit::Reset), append(text)], + revision, + None, + ))); + let first_ms = start.elapsed().as_secs_f64() * 1000.; + let mut success = false; + while e.database.pending() { + assert!(start.elapsed() < Duration::from_secs(35)); + std::thread::sleep(Duration::from_millis(20)); + let next = + response_batch(e.respond(request(vec![], revision, Some(first.token)))); + if next.refined { + success = true; + break; + } + } + if i >= 20 { + immediate.push(first_ms); + if success { + refined.push(start.elapsed().as_secs_f64() * 1000.); + } + } else if success { + warmup_completed += 1; + } + if !success { + failures += 1; + failed_during_warmup = i < 20; + break; + } + if refined.len() == 1000 { + break; + } + } + session_reports.push(serde_json::json!({ + "session": session + 1, "warmup_completed": warmup_completed, + "failed_during_warmup": failed_during_warmup, + "successful_refinements": refined.len() - prior_successes, + "neural_status": e.database.neural_status() + })); + // Drop the whole context-bearing model before opening a new session. + drop(e); + if refined.len() == 1000 { + break; + } + } + immediate.sort_by(f64::total_cmp); + refined.sort_by(f64::total_cmp); + let stats = |values: &[f64]| { + serde_json::json!({ + "samples":values.len(), "median_ms":values.get(values.len()/2), + "p95_ms":values.get((values.len()*95/100).min(values.len().saturating_sub(1))), + "max_ms":values.last() + }) + }; + let report = serde_json::json!({"os":std::env::consts::OS,"arch":std::env::consts::ARCH, + "queries":immediate.len(),"failures_including_warmup":failures,"immediate":stats(&immediate),"refinement":stats(&refined), + "sessions":session_reports,"maximum_sessions":5, + "retry_policy":"Benchmark simulates explicit keyboard reopenings after failure; production never retries automatically", + "scope":"Production Engine and model adapter, real neural child IPC, 20ms polling, fake input/activity; excludes outer desktop pipe and rendering", + "production_qualified":false}); + println!("{report}"); + if let Some(path) = std::env::var_os("SWITCHIFY_NEURAL_REPORT") { + std::fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap(); + } + assert_eq!( + refined.len(), + 1000, + "must collect 1,000 successful warmed refinements within five keyboard sessions" + ); + } + #[test] + fn refinement_preserves_the_displayed_acceptance_and_retires_it_on_ack() { + for accept_old in [true, false] { + let mut e = engine(); + e.database = Database::with_predictor(Box::new(Deferred { pending: false })); + let immediate = response_batch(e.respond(request(vec![append("wa")], 1, None))); + assert!(!immediate.refined); + let refined = response_batch(e.respond(request(vec![], 1, Some(immediate.token)))); + assert!(refined.refined); + assert_eq!(refined.words, vec!["walk", "water"]); + if accept_old { + assert_eq!(e.accept(immediate.token, 0), Some((0, "ter ".into()))); + assert!(e.accept(refined.token, 0).is_none()); + } else { + e.respond(request(vec![], 1, Some(refined.token))); + assert!(e.accept(immediate.token, 0).is_none()); + assert_eq!(e.accept(refined.token, 0), Some((0, "lk ".into()))); + } + } + } + #[test] + fn context_change_and_wrong_generation_reject_prior_batches() { + let mut e = engine(); + e.database = Database::with_predictor(Box::new(Deferred { pending: false })); + let old = response_batch(e.respond(request(vec![append("wa")], 1, None))); + e.respond(request(vec![append("l")], 2, Some(old.token))); + assert!(e.accept(old.token, 0).is_none()); + let current = e.batch.as_ref().unwrap().token; + let result = e.respond(Request::Accept { + generation: 1, + token: current, + revision: 2, + index: 0, + }); + assert!(matches!(result, Response::Insert { text: None, .. })); + assert!(!e.database.pending()); + } #[test] fn the_observer_starts_once_on_the_first_request() { use std::cell::Cell; @@ -1034,6 +1270,7 @@ mod tests { fn private_frames_are_bounded() { assert!(receive::(&mut &b"bad!"[..]).is_err()); let request = Request::Query { + displayed: None, generation: 0, revision: 1, edits: vec![append(&"x".repeat(LIMIT))], diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index a84257e..21766e6 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -407,6 +407,9 @@ impl Keyboard { && !self.positioning && self.scan.position(&self.rows).0 == 0 } + pub(crate) fn displayed_prediction(&self) -> Option { + self.predictions.as_ref().map(|b| b.token) + } pub fn predictions(&mut self, batch: Option, failed: bool) { let retry_changed = self.prediction_failed != failed; self.prediction_failed = failed; @@ -435,7 +438,9 @@ impl Keyboard { } if self.prediction_row_active() { if self.predictions.as_ref().map(|b| b.token) != batch.as_ref().map(|b| b.token) { - self.predictions = None; + if !batch.as_ref().is_some_and(|b| b.refined) { + self.predictions = None; + } self.queued_predictions = batch; } } else { @@ -1032,6 +1037,34 @@ impl Keyboard { mod tests { use super::*; + #[test] + fn refinement_keeps_visible_words_and_selection_stable() { + let mut k = Keyboard::new(false); + k.enable_predictions(true); + k.predictions( + Some(crate::prediction::worker::Batch { + token: 1, + refined: false, + words: vec!["water".into(), "walk".into()], + }), + false, + ); + k.restart(); + assert!(k.prediction_row_active()); + let position = k.scan.position(&k.rows); + k.predictions( + Some(crate::prediction::worker::Batch { + token: 2, + refined: true, + words: vec!["walk".into(), "water".into()], + }), + false, + ); + assert_eq!(k.displayed_prediction(), Some(1)); + assert_eq!(k.predictions.as_ref().unwrap().words[0], "water"); + assert_eq!(k.queued_predictions.as_ref().unwrap().token, 2); + assert_eq!(k.scan.position(&k.rows), position); + } #[test] fn prediction_badge_explains_empty_slots_without_replacing_scan_prompt() { let mut keyboard = Keyboard::new(false); @@ -1056,6 +1089,7 @@ mod tests { assert_eq!(badge(&keyboard), "Type for suggestions"); keyboard.predictions( Some(crate::prediction::worker::Batch { + refined: false, token: 1, words: vec![], }), @@ -1064,6 +1098,7 @@ mod tests { assert_eq!(badge(&keyboard), "No suggestions"); keyboard.predictions( Some(crate::prediction::worker::Batch { + refined: false, token: 2, words: vec!["water".into()], }), @@ -1393,6 +1428,7 @@ mod tests { // Suggestions arriving do not move the highlight. k.predictions( Some(crate::prediction::worker::Batch { + refined: false, token: 1, words: vec!["hello".into()], }), @@ -1418,6 +1454,7 @@ mod tests { // So does a suggestion, whose row is replaced. k.predictions( Some(crate::prediction::worker::Batch { + refined: false, token: 2, words: vec!["hello".into()], }), @@ -1488,6 +1525,7 @@ mod tests { k.enable_predictions(true); k.predictions( Some(crate::prediction::worker::Batch { + refined: false, token: 1, words: vec!["hello".into()], }), @@ -1501,6 +1539,7 @@ mod tests { for token in 2..5 { k.predictions( Some(crate::prediction::worker::Batch { + refined: false, token, words: vec!["world".into()], }), @@ -1574,6 +1613,7 @@ mod tests { keyboard.advance(490, 500); assert_eq!(keyboard.scan.nav.index(), 1); let batch = crate::prediction::worker::Batch { + refined: false, token: 1, words: vec!["hello".into()], }; @@ -1603,6 +1643,7 @@ mod tests { let mut baseline = Keyboard::configured(false, options); let mut polled = Keyboard::configured(false, options); let batch = crate::prediction::worker::Batch { + refined: false, token: 7, words: vec!["water".into(), "walk".into()], }; @@ -1642,6 +1683,7 @@ mod tests { let mut k = Keyboard::new(false); k.enable_predictions(true); let batch = crate::prediction::worker::Batch { + refined: false, token: 7, words: vec!["water".into(), "walk".into()], }; @@ -1666,6 +1708,7 @@ mod tests { assert_eq!(k.scan.nav.index(), 1); k.predictions( Some(crate::prediction::worker::Batch { + refined: false, token: 7, words: vec!["water".into(), "walk".into()], }), @@ -1973,6 +2016,7 @@ mod tests { keyboard.enable_predictions(true); keyboard.predictions( Some(crate::prediction::worker::Batch { + refined: false, token: 9, words: vec!["hello".into()], }), @@ -2299,6 +2343,7 @@ mod tests { k.advance(490, 500); k.predictions( Some(crate::prediction::worker::Batch { + refined: false, token: 1, words: vec!["hello".into(), "world".into()], }), @@ -2340,6 +2385,7 @@ mod tests { k.enable_predictions(true); k.predictions( Some(crate::prediction::worker::Batch { + refined: false, token: 1, words: vec!["hello".into(), "world".into()], }), @@ -2357,6 +2403,7 @@ mod tests { k.advance(490, 500); let position = k.scan.nav.index(); let next = replacement.then(|| crate::prediction::worker::Batch { + refined: false, token: 2, words: vec!["new".into()], }); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3a7424b..371937c 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -42,8 +42,10 @@ "resources/prediction-model/aac-source-manifest.json", "resources/prediction-model/ATTRIBUTION.md", "resources/prediction-model/LICENSE", - "resources/prediction-model/corpus-notices/*" + "resources/prediction-model/corpus-notices/*", + "resources/prediction-neural/**/*" ], + "externalBin": ["binaries/switchify-smol-worker"], "active": true, "targets": "all", "icon": [ diff --git a/src-tauri/tauri.windows-uiaccess.conf.json b/src-tauri/tauri.windows-uiaccess.conf.json index e57558b..062d807 100644 --- a/src-tauri/tauri.windows-uiaccess.conf.json +++ b/src-tauri/tauri.windows-uiaccess.conf.json @@ -1,6 +1,6 @@ { "bundle": { - "externalBin": ["binaries/switchify-pc-startup"], + "externalBin": ["binaries/switchify-pc-startup", "binaries/switchify-smol-worker", "binaries/switchify-smol-worker-avx2"], "windows": { "signCommand": { "cmd": "powershell.exe", diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json new file mode 100644 index 0000000..0b525fa --- /dev/null +++ b/src-tauri/tauri.windows.conf.json @@ -0,0 +1,5 @@ +{ + "bundle": { + "externalBin": ["binaries/switchify-smol-worker", "binaries/switchify-smol-worker-avx2"] + } +} diff --git a/src/App.test.tsx b/src/App.test.tsx index 4fb9b1c..56152c8 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -487,20 +487,22 @@ describe("Switchify PC shell", () => { stateHandler = handler; return () => undefined; }); + const markShown = vi.spyOn(api, "markSetupShown"); render(); await screen.findByRole("dialog", { name: "Pairing requests" }); expect(screen.queryByRole("dialog", { name: "Input access" })).not.toBeInTheDocument(); expect(screen.getByLabelText("Verification code for Galaxy")).toHaveTextContent("063781"); + // Let the auto-open state response settle first, so it cannot land after the cancellation and restore the pairing. + await waitFor(() => expect(markShown).toHaveBeenCalled()); + await act(() => markShown.mock.results[0].value); - act(() => stateHandler?.({ - ...structuredClone(browserState), - pendingPairings: [], - lastActivity: { kind: "info", message: "Pairing request cancelled." }, - })); + browserState.pendingPairings = []; + browserState.lastActivity = { kind: "info", message: "Pairing request cancelled." }; + act(() => stateHandler?.(structuredClone(browserState))); - await waitFor(() => expect(screen.queryByLabelText("Verification code for Galaxy")).not.toBeInTheDocument()); expect(await screen.findByRole("dialog", { name: "Input access" })).toBeInTheDocument(); + expect(screen.queryByLabelText("Verification code for Galaxy")).not.toBeInTheDocument(); }); it("creates a profile and records a desired key", async () => {