Technical Documentation: a-ghorbani/pocketpal-ai
βΉοΈ Provenance: Multi-Tier Ingestion Β· CodeWiki Reference
Overview (`README.md`)
Why PocketPal AI?
Most AI apps are a thin window onto someone else's server β every message you type gets shipped off, logged, and analyzed somewhere you can't see. PocketPal flips that around: the AI lives on your phone, and your conversations never leave it.
- π Private by default β every prompt, response, and document stays on your device. Nothing is uploaded or stored on external servers.
- βοΈ Works offline β download a model once and it just works, with no connection and no account. On a plane, on a trail, anywhere.
- π± Runs on hardware you already own β real language models, voices, and tools, tuned to make the most of your phone's CPU, GPU, and NPU.
- π Free and open source β no subscription, no "pro" tier to unlock the AI. MIT-licensed and built in the open.
Privacy note: The only data that ever leaves your device is what you explicitly choose to share β benchmark results (if you opt into the leaderboard) and feedback you submit through the app.
Contents
- Features
- Get the app
- How it works
- Using the app
- For developers
- Contributing
- Roadmap
- Community & support
- License
Features
- π§ On-device chat β run GGUF language models (Gemma, Qwen, Phi, Llama, and more) fully offline.
- π£οΈ Text-to-speech β give your assistant a voice with on-device neural TTS (Kokoro and other engines), no cloud calls.
- π Pals β create personalized assistants with their own model, system prompt, and personality (Assistant and Roleplay types).
- ποΈ PalsHub β discover and install community Pals, including premium ones via in-app purchase.
- π οΈ Talents & tools β let capable Pals call built-in tools (calculator, date/time, rich HTML rendering) inside a tool-use loop.
- π₯ Hugging Face integration β search and download GGUF models, including gated ones, directly from the HF Hub with your access token.
- π Benchmarking β measure tokens/sec and memory, and optionally compare on the AI Phone Leaderboard.
- β‘ Hardware acceleration β CPU, GPU (Metal on iOS, OpenCL/Adreno on Android), and NPU (Qualcomm Hexagon) inference paths, with graceful fallback.
- π Localized β available in 11 languages, on phones and tablets, including full iPad support.
Get the app
| Platform | |
|---|---|
| iOS / iPadOS | |
| Android |
Three steps to your first chat:
- Install PocketPal from the App Store or Google Play.
- Download a model β tap the menu (β°) β Models, pick one that fits your phone, and download (or add one from Hugging Face).
- Load it and start chatting β that's it, you're running AI fully offline.
How it works
You don't need to know any of this to use PocketPal β but if you're curious how a phone runs real AI offline, here's the short version.
PocketPal is a four-layer stack, from the silicon up to the chat UI. Each layer has one job, and the dependency direction is strictly top-down β the JS app talks to native bridges, bridges talk to inference engines, engines target hardware backends.
| Layer | What runs here |
|---|---|
| UI & Tool Use | The React Native app (UI via React Native Paper, state via MobX, chat history in WatermelonDB). The AgentRunner drives each chat turn β streaming tokens, dispatching Talents (tools) when the model calls them, and feeding results back for follow-up reasoning. Pals are configurable personas; PalsHub is the in-app marketplace for sharing and buying them. |
| Bridging | Native modules that connect JavaScript to the engines. llama.rn bridges LLM inference over JSI; react-native-speech and onnxruntime-react-native bridge text-to-speech. |
| Engine | The inference engines. llama.cpp runs language models in the quantized GGUF format. ONNX Runtime runs TTS voice models in the ONNX format. |
| Hardware | Where the math actually happens. PocketPal targets CPU (universal fallback), GPU (Metal on iOS, OpenCL on Qualcomm Adreno for Android), and NPU (Qualcomm Hexagon) β falling back gracefully and offloading partial layers when a full backend isn't available. |
Using the app
π₯ Download & load a model
- Open the app and tap the Menu (β°), then go to Models.
- Pick a model from the list and tap Download, or tap + to add one from Hugging Face or local storage.
- From Hugging Face, search GGUF models and choose a quantization that fits your device's memory and storage β download now or bookmark for later.
- After downloading, tap Load (or use the chevron icon left of the chat input to load right from the chat screen).
π¬ Chat
- Make sure a model is loaded.
- Open the Chat page and start talking.
- The screen stays awake during inference and deactivates when idle.
- Copy a full response with the copy icon, or long-press a paragraph to copy just that.
- Edit any of your messages with a long-press β the AI regenerates from your change. Hit retry for a fresh answer, optionally with a different model.
π Pals & PalsHub
Create personalized assistants:
- Assistant Pal β pick a default model, set a system prompt (write it yourself or have the app generate one), and customize the chat input color.
- Roleplay Pal β everything above, plus location, the AI's role, and other contextual parameters.
Switch personas with the Pal picker on the chat page. Browse PalsHub in-app to discover community Pals, including premium ones via in-app purchase.
Creating a cocktail-recipe assistant
π Benchmark your device
- Open the Benchmark page.
- Run performance tests to compare speed and efficiency across models.
- Review tokens/sec and memory usage.
- Optionally share your results to the AI Phone Leaderboard.
π Set up a Hugging Face token (for gated models)
- Create an access token in your Hugging Face account (docs).
- In PocketPal, go to Settings β Set Token, paste it, and save.
π Send feedback
Go to App Info β "Sharing your thoughts", type your feedback β feature requests, suggestions, anything β and submit.
For developers
PocketPal is a standard React Native app. If you can build a React Native project, you can build PocketPal.
Prerequisites
- Node.js β version is pinned in
.nvmrc(currently22.21.0); runnvm useto match it. Older Node will fail theenginescheck. - Yarn 1 (Classic) β
packageManageris pinned to[email protected]. - Xcode + CocoaPods, and Ruby + Bundler (for iOS / Fastlane tooling).
- Android Studio + Android SDK/NDK.
See the React Native environment setup for platform details.
Clone, install & run
git clone https://github.com/a-ghorbani/pocketpal-ai
cd pocketpal-ai
nvm use # match the pinned Node version
yarn install # install JS dependencies
(cd ios && pod install) # iOS only
yarn start # Metro bundler
yarn ios # build + run on iOS simulator
yarn android # build + run on Android emulatorCore on-device chat works without any backend keys; only PalsHub/auth features need additional configuration.
Native-change rule: if you change
package.json, a native module,ios/,android/, the Podfile, orbuild.gradle, re-runpod installand rebuild both platforms β a JS reload won't pick up native changes.
Quality gates
yarn lint # ESLint
yarn typecheck # tsc --noEmit
yarn test # Jest
yarn l10n:validate # validate locale JSON (placeholders, integrity)
yarn test:perf # render-performance tests (Reassure)
yarn test:perf:compare [base] # render counts of HEAD vs. base (default: merge-base with origin/main)Run yarn lint && yarn typecheck && yarn test before opening a PR. Pull requests also run the render-count comparison; see render-performance tests. Commits are validated by Commitlint (Conventional Commits) via a Husky hook.
Repository layout
src/
βββ screens/ # Chat, Models, Pals, Benchmark, Settings, About, β¦
βββ components/ # Reusable UI
βββ store/ # MobX stores (Model, ChatSession, Pal, TTS, HF, Benchmark, β¦)
βββ services/
β βββ agent/ # AgentRunner β the chat / tool loop
β βββ talents/ # Tool engines + registries
β βββ tts/ # TTS engines (kokoro, kitten, supertonic, system)
β βββ palshub/ # PalsHub marketplace integration
β βββ downloads/ # Model download manager
βββ database/ # WatermelonDB schema, models, migrations
βββ repositories/ # Data-access layer over the DB
βββ locales/ # i18n JSON + lazy loader (index.ts is the registry)
βββ hooks/ api/ theme/ utils/ config/ specs/Tech stack
Versions are pinned in package.json; the highlights:
| Area | Choice |
|---|---|
| Framework | React Native 0.82.1, React 19.1.1 (New Architecture) |
| Language | TypeScript 5.0.4 |
| UI | React Native Paper 5.14.5, React Navigation |
| State | MobX 6 (mobx, mobx-react, mobx-persist-store) |
| Persistence | WatermelonDB (chat history), AsyncStorage (settings), Keychain (secrets) |
| LLM | llama.rn 0.13.0-rc.7 β llama.cpp b11385 Β· GGUF |
| TTS | react-native-speech 2.3.1 + onnxruntime-react-native 1.23.2 Β· ONNX |
| Tooling | Yarn 1 (Classic), ESLint, Prettier, Jest, Husky + Commitlint |
Extending PocketPal
A Talent is a tool the model can call mid-conversation. Engines are registered in a TalentRegistry, exposed to the model as tool schemas; the AgentRunner detects a call, runs the engine, and returns the result for the next turn.
| Talent | Engine | Does |
|---|---|---|
calculate |
CalculateEngine |
Arithmetic / expression evaluation |
datetime |
DatetimeEngine |
Current date / time |
render_html |
RenderHtmlEngine |
Renders model-produced HTML in chat |
Good first contributions:
- A new Talent β implement a
TalentEngineand register it insrc/services/talents/. - A new TTS engine β add it under
src/services/tts/engines/. - A new locale β add a JSON file in
src/locales/(or translate on Weblate).
Contributing
Contributions are welcome β bug reports, fixes, features, translations, and docs all help.
- Fork and branch:
git checkout -b feature/your-feature-name - Make your changes; run on a device/emulator (
yarn ios/yarn android). Re-runpod install+ rebuild if you touched native code. - Gate locally:
yarn lint && yarn typecheck && yarn test - Commit with Conventional Commits:
git commit -m "feat: add new talent" - Push and open a pull request.
Please read the Contributing Guidelines and Code of Conduct first. Want to translate PocketPal into your language? Join us on Weblate.
Roadmap
- Tool use expansion β grow the Talents catalog and deepen the agentic loop so Pals can do more, fully on-device.
Have an idea or found a bug? Open an issue or start a discussion.
Community & support
- π¬ Questions & ideas β GitHub Discussions
- π Bugs & requests β GitHub Issues
- π Website β pocketpal.dev
- β€οΈ Support development β PocketPal is free and ad-free; sponsoring helps keep it that way.
License
Licensed under the MIT License.
Acknowledgements
PocketPal AI stands on the shoulders of the open-source community, including:
- llama.cpp β efficient on-device LLM inference.
- llama.rn β llama.cpp bindings for React Native.
- react-native-speech β React Native TTS bridge powering on-device voices.
- ONNX Runtime β cross-platform inference engine powering on-device TTS.
- React Native, MobX, React Native Paper, React Navigation, WatermelonDB, and many other open-source libraries that make this project possible.
Overview (`e2e/README.md`)
PocketPal E2E Tests
End-to-end tests using Appium + WebDriverIO for local devices and AWS Device Farm.
Setup
cd e2e
yarn installTest Specs
| Spec | What it tests | Duration |
|---|---|---|
quick-smoke |
Full user journey: navigate to Models β search HuggingFace β download SmolLM2-135M β load model β chat β verify inference completes | ~50-70s/device |
load-stress |
Download model, run multiple load/unload cycles with inference between each. Catches crash-on-reload bugs | ~5-10 min/device |
thinking |
Loads Qwen3-0.6B (thinking model), verifies thinking toggle, thinking bubble appears, toggle off suppresses it | ~3-5 min/device |
diagnostic |
Dumps Appium page source XML at each screen. For debugging selectors, not a real test | ~10s |
benchmark-matrix |
Iterates {models} Γ {quants} Γ {backends} on Android, writes canonical JSON report per run. iOS runs through yarn bench:ios instead. Measurement infrastructure, not an automated gate. |
~25-45 min |
Benchmark Matrix Runner
Drives the in-app BenchmarkRunnerScreen via deep link (pocketpal://e2e/benchmark) β no WDIO required for ad-hoc runs. Three tiers gated by BENCH_TIER:
| Tier | Models Γ quants Γ backends | Cells | Runtime | When |
|---|---|---|---|---|
smoke (default) |
3 Γ 3 Γ 2 | 18 | ~10β15 min | Regression gate |
focused |
6 Γ 6 Γ 2 | ~60 | ~30β45 min | Investigation |
full |
11 Γ 8 Γ 2 | ~165 | ~3 hr/device | Default-tier recalibration |
Model + quant rosters live in fixtures/benchmark-models.ts (single source: BENCHMARK_FULL_MODELS; smaller tiers derived as id filters).
Android one-shot run (assumes E2E-flavor APK is already installed on the device):
# 1. Generate config + adb push to device
BENCH_TIER=smoke yarn build:bench-config --push # default device
BENCH_TIER=full yarn build:bench-config --push <udid> # specific device
# 2. Cold-launch the runner via deep link
adb shell am start -a android.intent.action.VIEW \
-d 'pocketpal://e2e/benchmark' -p com.pocketpalai.e2e
# 3. Tap "Run benchmark matrix" on the screen (or via adb input tap)
# 4. Wait for `bench-runner-screen-status` content-desc == "complete"
# 5. Pull the report
adb pull /sdcard/Android/data/com.pocketpalai.e2e/files/benchmark-report-*.jsonFilters narrow the chosen tier (cannot widen it):
BENCH_TIER=full BENCH_MODELS=qwen3.5-2b BENCH_QUANTS=q4_0,q6_k yarn build:bench-configHeavy models (Phi-3.5, Phi-4-mini, Gemma-4-E2B) are last in the full tier; if the OS ANR-kills the app on a heavy CPU bench, partial-row data from earlier cells survives in the JSON report.
iOS (physical iPhone)
yarn bench:ios drives the same matrix with xcrun devicectl only (no Appium). It pushes bench-config.json into the app's Documents, sends the deep link to the already-running app (a link on cold launch is dropped), polls Documents for the new benchmark-report-*.json until the runner writes its terminal outcome, then pulls, stamps and pass-gates the report.
--appreplaces the App Store PocketPal. iOS has no separate E2E bundle id, so installing the E2E build overai.pocketpalwipes that app's data. Without--appthe driver uses whatever is installed, which must be an E2E build.
# 1. Build the E2E IPA (development signing) -> ios/build/PocketPal.ipa
yarn ios:build:ipa
# 2. Preview: writes the config, prints the cell count and the devicectl plan, touches no device
BENCH_TIER=smoke yarn bench:ios --device <udid> --dry-run
# 3. Install and run (udid from `xcrun devicectl list devices`)
BENCH_TIER=smoke yarn bench:ios --device <udid> --app ../ios/build/PocketPal.ipaKeep the iPhone unlocked and on power, and do not lock it or switch apps during a run: a suspended app stops writing rows and the run ends failed:timeout. The runner holds the screen awake itself, so Auto-Lock need not be changed. Reports land in e2e/debug-output/benchmarks/ (--out to change). Extra env: BENCH_MAX_WAIT_MIN (default 60, enough for smoke and focused at the tier-table runtimes; full takes ~3 h on Android and is not yet timed on iOS, so run it with BENCH_MAX_WAIT_MIN=240), BENCH_IOS_SETTLE_S (15), BENCH_IOS_START_TIMEOUT_S (120), and E2E_DEVICE_NAME / E2E_PLATFORM_VERSION / E2E_DEVICE_SOC to override the stamped metadata. See yarn bench:ios --help. A timeout names its cause: with no new row for 15 min it blames a lock or backgrounding, and with rows still arriving it reports rows seen against expected and asks for a larger BENCH_MAX_WAIT_MIN.
Baselines are per platform: merge-bench-reports.ts refuses to mix Android and iOS reports, and benchmark-compare.ts exits 2 on a cross-platform pair.
Baselines (`baselines/benchmark/.json`)
Committed reference data the benchmark-compare.ts regression checker runs against. One file per device class. Refresh by re-running the full tier on that device, then merging the captured reports:
npx ts-node scripts/merge-bench-reports.ts \
--input '/path/to/benchmark-report-*.json' \
--out baselines/benchmark/<device-slug>.json \
--device 'POCO X9 Pro Myron' \
--soc 'Snapdragon 8 Elite Gen 5 / Adreno 840' \
--commit "$(git rev-parse --short HEAD)" \
--llama-rn-version "$(node -p 'require(\"../package.json\").dependencies[\"llama.rn\"]')" \
--drop-models lfm2-1.2bThe merger dedupes across multiple raw reports (latest run per model_id Γ quant Γ backend wins), drops stale model ids, sorts deterministically, and strips the debug-only log_signals.raw_matches so baseline diffs stay focused on perf deltas. Memory-profile baselines live in the sibling baselines/memory/ directory.
Local Testing
Prerequisites
- Xcode configured (for iOS)
- Android SDK configured (for Android)
- Build the app first (see below)
Build
# iOS simulator
yarn ios:build:e2e
# iOS real device (IPA, requires code signing)
yarn ios:build:ipa
# Android E2E APK (required β prod APK has no automation bridge)
yarn android:build:e2e
# Installs as com.pocketpalai.e2e and coexists side-by-side with the
# prod install (com.pocketpalai). E2E_BUILD=true is set automatically
# so the automation bridge (src/__automation__/) ships in this APK.Flavor. E2E targets the e2e flavor (com.pocketpalai.e2e, debuggable),
which ships the automation bridge. The prod flavor has no bridge β specs
will silently fail there.
Firebase. android/app/google-services.json (gitignored) must contain
client entries for both com.pocketpalai and com.pocketpalai.e2e. If the
build fails with a google-services plugin error, the .e2e client entry is
missing from your local copy.
Unified E2E Runner
All local test execution goes through a single yarn e2e command:
# Simple smoke test on default device
yarn e2e:ios --spec quick-smoke
yarn e2e:android --spec quick-smoke
# Test each model in isolation (one WDIO process per model)
yarn e2e:ios --each-model
yarn e2e:ios --each-model --models smollm2-135m,qwen3-0.6b
# Crash reproduction (load-stress on a specific model)
yarn e2e --platform ios --spec load-stress --models gemma-2-2b
# Multi-device pipeline (iterate across devices from devices.json)
yarn e2e:ios --each-device
yarn e2e:ios --devices virtual-only --skip-build
# Run on whatever real devices are currently plugged in
yarn e2e:android --devices connected --skip-build
# Full matrix: every model x every device
yarn e2e:ios --each-device --each-model
# Include crash-repro models in the pool
yarn e2e:ios --each-model --all-models
# Dry run (preview what would execute)
yarn e2e --platform both --each-device --each-model --dry-run
# List available models
yarn e2e --list-modelsFlags
| Flag | Values | Default | Description |
|---|---|---|---|
--platform |
ios, android, both |
(required) | Which platform(s) to test |
--spec |
quick-smoke, load-stress, diagnostic, language, all |
quick-smoke |
Which test spec to run |
--models |
comma-separated model IDs | (all) | Specific model(s) to test |
--each-model |
(flag) | off | Iterate spec once per model (isolated process) |
--all-models |
(flag) | off | Include crash-repro models in the pool |
--devices |
all, virtual-only, real-only, connected, or comma-separated IDs |
all |
Device filter (implies --each-device) |
--each-device |
(flag) | off | Iterate across devices from devices.json |
--mode |
local, device-farm |
local |
Execution mode (switches wdio config) |
--skip-build |
(flag) | builds by default | Skip app builds, reuse existing |
--dry-run |
(flag) | off | Print what would run without executing |
--report-dir |
path | auto-timestamped | Override report output directory |
--list-models |
(flag) | off | List all available models and exit |
Direct WDIO Commands
For ad-hoc runs where you need to pass WDIO-specific flags, invoke WDIO directly:
npx wdio run wdio.ios.local.conf.ts --spec specs/quick-smoke.spec.ts
npx wdio run wdio.android.local.conf.ts --spec specs/load-stress.spec.tsEnvironment Variables (WDIO Configs)
Both wdio.ios.local.conf.ts and wdio.android.local.conf.ts accept these env vars with backward-compatible defaults:
| Env Var | iOS Default | Android Default | Purpose |
|---|---|---|---|
E2E_DEVICE_NAME |
iPhone 17 Pro |
emulator-5554 |
Device/simulator name |
E2E_PLATFORM_VERSION |
26.0 |
16 |
OS version |
E2E_DEVICE_UDID |
(none) | (none) | Device UDID (required for real devices) |
E2E_APP_PATH |
../ios/build/.../PocketPal.app |
../android/.../app-e2e-releaseE2e.apk |
Path to built app |
E2E_APPIUM_PORT |
4723 |
4723 |
Appium server port |
E2E_XCODE_ORG_ID |
(none) | N/A | Apple Team ID (required for real iOS devices) |
E2E_XCODE_SIGNING_ID |
Apple Development |
N/A | Code signing identity for WDA |
Multi-Device Setup
To use --each-device, set up a device inventory:
Copy the template:
bashcp devices.template.json devices.jsonEdit
devices.jsonwith your actual devices (simulators, emulators, USB-connected real devices). Seedevices.template.jsonfor the format.Finding device UDIDs:
bash# iOS xcrun xctrace list devices # Android adb devices -ldevices.jsonis gitignored β each machine has its own.
Reports
Each run creates a timestamped directory under e2e/reports/:
e2e/reports/2026-02-13T16-14-12-758/
summary.json # Overall results + per-run breakdown
junit-results.xml # Merged JUnit XML (for CI integration)
iphone-17-pro-sim/ # Per-device subdirectory (when --each-device)
smollm2-135m/ # Per-model subdirectory (when --each-model)
junit-smollm2-135m.xml
screenshots/AWS Device Farm Testing
Prerequisites
- AWS Account with Device Farm access
- Create a Device Farm project
- Set environment variables or GitHub Secrets:
AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEYAWS_DEVICE_FARM_PROJECT_ARN
Run via GitHub Actions
- Go to Actions β "E2E Tests (AWS Device Farm)"
- Click "Run workflow"
- Select platform (android, ios, or both)
Run manually
yarn e2e:aws --platform android --app path/to/app.apkProject Structure
/* Detailed source-code truncated for AI context efficiency. */Writing Tests
Selectors
Use testID and accessibilityLabel for reliable cross-platform selectors:
import {Selectors} from '../helpers/selectors';
// By testID
await $(Selectors.byTestId('send-button')).click();
// By text (exact match)
await $(Selectors.byText('Models')).click();
// By partial text
await $(Selectors.byPartialText('Download')).click();
// By accessibility label
await $(Selectors.byAccessibilityLabel('Chat input')).click();Page Objects
Use page objects for common interactions:
import {ChatPage, DrawerPage, ModelsPage} from '../pages';
await ChatPage.openDrawer();
await DrawerPage.navigateToModels();
await ModelsPage.openHuggingFaceSearch();Cost Estimation (AWS Device Farm)
| Usage | Approximate Cost |
|---|---|
| 10 min test run, 1 device | ~$1.70 |
| 10 min test run, 2 devices (iOS+Android) | ~$3.40 |
| 30 runs/month, 2 devices | ~$100/month |
Pricing: $0.17 per device minute
Benchmark Matrix
The benchmark-matrix spec is measurement infrastructure, not an automated gate. It iterates {models} Γ {quants} Γ {backends} on Android, drives the in-app Benchmark screen for each cell, and writes a canonical JSON report to e2e/debug-output/benchmarks/benchmark-<device_slug>-<commit>.json. The JSON is incremental: a mid-matrix crash preserves completed rows.
This spec is Android-only; iOS uses yarn bench:ios (see Benchmark Matrix Runner). The matrix is 2 models Γ 8 quants Γ 2 backends = 32 runs at full scale; env-var filters reduce this.
Usage
# Full matrix on the currently connected Android device (~25-45 min)
yarn e2e --platform android --spec benchmark-matrix --skip-build
# Single cell (smoke)
BENCH_MODELS=qwen3-1.7b BENCH_QUANTS=q4_0 BENCH_BACKENDS=cpu \
yarn e2e --platform android --spec benchmark-matrix --skip-build
# Preseeded mode (see "Preseed workflow" below)
MODELS_PRESEEDED=1 yarn e2e --platform android --spec benchmark-matrix --skip-buildEnvironment variables
| Var | Values | Description |
|---|---|---|
BENCH_MODELS |
comma-separated model ids (lowercase) | e.g. qwen3-1.7b,gemma-3-1b |
BENCH_QUANTS |
comma-separated rung labels | e.g. q4_0,q6_k; full set: iq1_s,q2_k,q3_k_m,q4_0,q4_k_m,q5_k_m,q6_k,q8_0 |
BENCH_BACKENDS |
comma-separated tiers | cpu, gpu |
MODELS_PRESEEDED |
1 to enable |
Skip downloads; use already-pushed GGUFs on device |
E2E_DEVICE_SOC |
free-form string | Recorded in the JSON soc field; not used to drive tests |
JSON schema
Top-level:
{
"version": "1.0",
"device": "SM-S948U",
"soc": "Snapdragon 8 Elite Gen 2", // or null
"commit": "abc1234",
"llama_rn_version": "0.12.0-rc.8",
"platform": "android",
"os_version": "16",
"timestamp": "2026-04-21Tβ¦",
"preseeded": false,
"runs": [ /* BenchmarkRun[] */ ]
}Per-run (BenchmarkRun):
{
"model_id": "qwen3-1.7b",
"quant": "q4_0", // canonical lowercase rung label
"requested_backend": "cpu", // "cpu" | "gpu"
"effective_backend": "cpu", // see below
"pp_avg": 123.4, // tokens/s, nullable
"tg_avg": 18.2, // tokens/s, nullable
"wall_ms": 24571,
"peak_memory_mb": 812.3, // nullable
"log_signals": { // structured β see src/__automation__/logSignals.ts
"opencl_init": true,
"opencl_device_name": "QUALCOMM Adreno(TM) 840",
"adreno_gen": "A8X",
"large_buffer_enabled": true,
"large_buffer_unsupported": false,
"offloaded_layers": 29,
"total_layers": 29,
"raw_matches": [ /* up to 200 matched native-log lines, debug only */ ]
},
"init_settings": { /* modelStore.contextInitParams snapshot */ },
"status": "ok", // "ok" | "skipped" | "failed"
"reason": "β¦", // set on skipped
"error": "β¦", // set on failed (first 500 chars)
"timestamp": "2026-04-21Tβ¦"
}Interpreting `effective_backend`
Derived from the structured log_signals payload, not regex on raw text:
| Value | Meaning |
|---|---|
cpu |
No OpenCL init observed β pure CPU path. |
opencl |
OpenCL initialised, all layers offloaded to GPU, no large-buffer regression. |
cpu+opencl-partial |
OpenCL initialised but some layers ran on CPU, or large_buffer_unsupported triggered a fallback. |
metal |
Weights on a Metal (MTL*) buffer with every layer offloaded (iOS). |
cpu+metal-partial |
Metal buffer present but fewer layers offloaded than the model has. |
unknown |
OpenCL initialised but layer counts absent β investigate log_signals.raw_matches. |
A row where requested_backend=gpu but effective_backend=cpu is the canonical "silent CPU fallback" we want to catch. The comparison script flags this as a regression even when pp_avg / tg_avg numbers look fine.
Preseed workflow (E2E flavor required)
Preseeded mode skips all HuggingFace downloads and loads GGUFs that have already been pushed to the device's app-private storage. This is the fast path once you've downloaded each rung once.
Precondition: the app must be the E2E flavor. The prod release
APK is non-debuggable, so adb shell run-as com.pocketpalai andadb push into /data/data/com.pocketpalai/files/β¦ will not work.
The e2e flavor + releaseE2e buildType is debuggable by design
(same Hermes/release optimizer as prod, just with debuggable=true
flipped on). Build and install it:
yarn android:build:e2e
adb install -r android/app/build/outputs/apk/e2e/releaseE2e/app-e2e-releaseE2e.apkOn-device path (matches ModelStore.getModelFullPath, with the E2E
flavor's applicationId):
/data/data/com.pocketpalai.e2e/files/models/hf/<author>/<repo>/<filename>.ggufPush each GGUF once:
adb shell run-as com.pocketpalai.e2e mkdir -p \
files/models/hf/bartowski/Qwen_Qwen3-1.7B-GGUF
# copy via /data/local/tmp to avoid run-as's stdin limitations:
adb push Qwen_Qwen3-1.7B-Q4_0.gguf /data/local/tmp/
adb shell run-as com.pocketpalai.e2e sh -c \
'cat /data/local/tmp/Qwen_Qwen3-1.7B-Q4_0.gguf > \
files/models/hf/bartowski/Qwen_Qwen3-1.7B-GGUF/Qwen_Qwen3-1.7B-Q4_0.gguf'Then run with MODELS_PRESEEDED=1. The spec fails fast (before touching the matrix loop) with a per-file adb push template if anything is missing β no silent download fallback.
Comparing two reports
npx tsx e2e/scripts/benchmark-compare.ts \
path/to/baseline.json path/to/current.jsonFlags rows where either pp_avg or tg_avg delta exceeds |delta%| > 15 (override with --pct N). Additional flags, all independent of numeric deltas:
effective_backend:-><cur>β silent backend fallback (e.g. requested GPU but ran on CPU).status_regression(<status>)β baseline row wasok, current row flipped tofailed/skipped.pp_null_regression/tg_null_regressionβ both rows claimstatus:'ok'but the current numeric metric isnullwhile the baseline was numeric (catches partial native failures the screen didn't reject as failed).- Top-level
bench_protocol_mismatchβ the persistedbenchblock (pp/tg/pl/nr) differs between reports. Comparison is skipped (pass:false) and the CLI exits 2 because pp/tg numbers are not comparable across protocols. Reports that omit thebenchblock (e.g. legacy baselines pre-v1.1) skip the check with a stderr warning.
pass is true only when no row is flagged AND missing_in_current is empty AND no bench_protocol_mismatch. Exit codes: 0 pass, 1 regression, 2 input/protocol error.
Known limitations (v1)
yarn bench:iosdrives physical iPhones only:devicectlcannot target a simulator.- On an iOS simulator the in-app runner reports GPU cells as
cpu, so they failbackend-mismatch. yarn bench:ios --appreplaces the App Store build and wipes its data.- Hexagon NPU tier excluded (Android only; no iOS equivalent).
- Preseed requires the E2E-flavor APK (see above).
- Static IQ1_S rung is substituted with IQ2_M for Qwen3 1.7B and Gemma 3 1B β neither is published at IQ1_S by bartowski or lmstudio-community. The canonical rung label in the JSON remains
iq1_sso reports are comparable when IQ1_S eventually ships. - LFM2 1.2B slot 3 is deferred: no publisher has a complete 8-quant set.
Overview (`src/__automation__/README.md`)
`src/__automation__/` β E2E Automation Bridge
Single home for all code that exists ONLY to support E2E automation
(Appium/WebDriverIO specs). Every file in this folder MUST be
dead-code-eliminated from the production bundle.
Build-time contract
The folder is gated behind the __E2E__ global, inlined at build time bybabel-plugin-transform-define:
E2E_BUILD=truein the build env β__E2E__ = trueβ everything here ships.- Everything else (default dev + prod) β
__E2E__ = falseβ Metro/Hermes
strips the entire gate body (and, transitively, the imports it reaches).
The Android e2e flavor (applicationId com.pocketpalai.e2e,
buildType releaseE2e) is the production-like build that setsE2E_BUILD=true. The default prod flavor (com.pocketpalai) leavesE2E_BUILD unset.
Two independent guardrails enforce the contract:
- ESLint
no-restricted-importsβ in.eslintrc.js, files outside
this folder may not import fromsrc/__automation__/...EXCEPT for
the allow-listed entry points (App.tsx,src/hooks/useDeepLinking.ts). - CI bundle-grep β in
.github/workflows/ci.yml, thebuild-android
job extractsassets/index.android.bundlefrom the prod APK and fails
the build if any of the automation marker strings
(AUTOMATION_BRIDGE,memory-snapshot-label,memory-snapshot-result,BENCH_RUN_MATRIX,bench-runner-screen-status,IAP_FAKE_STORE,IAP_API_BASE_OVERRIDE) appear. The ground
truth for DCE.
Note on the static-import question. Some readers worry that
App.tsx
statically importing from./src/__automation__/index.ts(e.g. for<AutomationBridge />and the deep-link Drawer screen) leaks the entire
automation module graph into the prod bundle, since ES imports enter the
Metro graph regardless of whether the JSX render sites are reachable.The DCE contract here does NOT rest on import-site purity. It rests on the
bundle-grep gate in.github/workflows/ci.yml(theVerify prod APK has no automation-bridge codestep at lines 202-233).
That step extractsassets/index.android.bundlefrom the prod APK and
fails the build if any of the registered marker strings
(AUTOMATION_BRIDGE,memory-snapshot-label,memory-snapshot-result,BENCH_RUN_MATRIX,bench-runner-screen-status,IAP_FAKE_STORE,IAP_API_BASE_OVERRIDE) appear. Hermes'
constant-folding + tree-shaking on__E2E__ === falseis what actually
removes the code; the grep is the empirical contract that proves it for
every prod build.When adding a new automation surface, register a new marker (a literal
string referenced from inside the runtime code, never just JSDoc β Hermes
can DCE pure-comment literals) and add it to the markers list inci.yml.
Then trust the gate, not the import site.
Adding a new adapter
- Create
src/__automation__/adapters/FooAdapter.tsxβ a functional
component that renders a hidden, accessibility-tree-friendly surface
(seeMemoryAdapter.tsxas reference). Hidden adapters are appropriate
when the spec needs to instrument the regular UX flow at lifecycle
moments (memory profiling is the canonical case). - Render the adapter inside
AutomationBridge.tsx:tsx<> <MemoryAdapter /> <FooAdapter /> </> - Add a test at
adapters/__tests__/FooAdapter.test.tsxthat covers the
command protocol and the testID contract. - Update the CI bundle-grep markers in
.github/workflows/ci.ymlif
your adapter introduces new protocol strings.
For tasks that are synthetic harnesses (don't need to instrument the
real UX flow), prefer adding a dedicated screen under screens/ β
mounted as an __E2E__-gated Drawer.Screen in App.tsx and reachable
by deep link only. See BenchmarkRunnerScreen.tsx as reference.
Current adapters
| Adapter | Purpose | Commands |
|---|---|---|
MemoryAdapter |
Memory profile snapshots for the memory-profile spec |
snap::<label>, clear::snapshots, read::snapshots |
IapAdapter |
Scripts the in-app purchase FakeStore on Android for the iap-* specs |
api::, products::=|..., next::<outcome>, approve_pending, decline_pending, refund::, entitle::, unfinished::, unavailable, reset, read |
Screens
| Screen | Purpose | Activation |
|---|---|---|
BenchmarkRunnerScreen |
Drives the benchmark matrix in-app for the benchmark-matrix spec |
Deep link pocketpal://e2e/benchmark (registered in android/app/src/e2e/AndroidManifest.xml); manual button tap to start |
Deep-link dispatcher
src/__automation__/deepLink.ts exports dispatchAutomationDeepLink,
used by src/hooks/useDeepLinking.ts inside a __E2E__ gate. Today it
handles two hosts:
memoryβpocketpal://memory?cmd=snap::<label>etc. (memory-profile spec)iapβpocketpal://iap?cmd=<verb>::<arg>scripts theFakeStoreon iOS
(the Android specs useIapAdapter, since the Android Linking listener
only routes the benchmark URL).e2e/benchmarkβ navigates toBenchmarkRunnerScreen(benchmark-matrix spec).
On Android, the cold-launch path also lives inuseDeepLinking.ts
itself (a__E2E__-gatedLinking.getInitialURL()effect) since RN's
Android side has no equivalent of the iOSRCTOpenURLNotification
listener wired throughDeepLinkService.
Add new deep-link hosts here when they're E2E-only.
Marker
AutomationBridge.tsx contains the literal string AUTOMATION_BRIDGE
in its JSDoc so the CI grep has something to match against. If you rename
the component, update the grep markers in .github/workflows/ci.yml.
Overview (`e2e/baselines/benchmark/README.md`)
Benchmark baselines
Captured at bench: {pp:256, tg:64, pl:1, nr:3, inter_cell_settle_ms:30000} with the per-backend rules and mallopt(M_PURGE_ALL) purge APK applied. See COVERAGE.md for per-device cell counts, recovery story, and operational quirks.
Getting Started (`docs/getting_started.md`)
Getting Started
Note: Part of this guide is outdated. It will be updated soon.
Installation
iPhone users can grab it here: PocketPal AI on the App Store
Android users can get it from here: PocketPal AI on Google Play
Note: This is a personal project, so I am working on it in my spare time. It might have bugs and issues, and obviously, I have not tested it on all devices. If you encounter any issues, open an issue, or even better, contribute to the project!
Available Models
PocketPal AI comes pre-configured with some popular SLMs:
- Danube 2 and 3
- Phi
- Gemma 2
- Qwen
Modells need to be downloaded before use. You can download and use these models directly from the app and load any other GGUF models you like!
Using PocketPal AI
Downloading a Model
- Tap the burger menu
- Navigate to the βModelsβ page
- Choose your desired model and hit download
Loading a Model
After downloading, tap Load to bring the model into memory. Now youβre ready to chat!
Tips
On iOS devices, Appleβs GPU API (Metal) is activated by default. If you experience any hiccups, try deactivating it.
iOS Metal
Auto Offload/Load
To keep the device running smoothly, PocketPal AI can automatically manage memory usage:
- Enable βAuto Offload/Loadβ on the model page (by default it is)
- The app will offload the model when in the background
- Itβll reload when you return (give it a few seconds for larger models)
Advanced Settings
Click the chevron icon to access advanced LLM settings like:
- Temperature
- BOS token
- Chat template options
- etc.
Finally, Letβs Chat!
Once your model is loaded, head to the βChatβ page and start conversing with the loaded model!
The generation performance metric is also displayed. If interested, watch the chat bubble for real-time performance metrics: Tokens per second and Milliseconds per token.
Copying Text
Important Note: As of now, I havenβt found an easy way to select and copy text from the generated responses while preserving the text formatting, particularly Markdown support.
In the meantime, here are the current options for copying text:
- Paragraph-level copying: Long-press on a specific paragraph to copy its content.
- Full response copying: Use the copy icon at the bottom of the text bubble to copy the entire AI-generated response.
I know these options might not be ideal, and this is one of my frustrations with using other apps. The difficulty of copying portions of text used to be a particularly annoying aspect of chat apps like ChatGPT and others.
Developers: PocketPal AI is built using React Native. Finding an easy solution that balances text selection with preserved formatting (especially Markdown support) has been tricky for me. If you have experience in this area, Iβd love to hear from you!
Feedback Welcome!
If you have suggestions for new models or features, please let us know by creating an issue.
Happy exploring! ππ±β¨
Render Perf Tests (`docs/render-perf-tests.md`)
Render-performance tests
Render-performance tests run a scripted interaction against a component in Jest and record how many times React commits and how long those commits take. They use Reassure. The render count is deterministic, so CI fails a pull request that raises it. Durations are reported for information only.
What is measured
- Render count: the number of React commits under the measured tree in one run, mount included. It counts commits, not component renders: one state change that re-renders twenty components is one commit.
- Duration: the summed render time of those commits, in JS only.
- Jest runs with the usual mocks (
@gorhom/bottom-sheet, Reanimated, native modules), so native work, animations and gestures are not measured.
Commands
yarn test:perf # run every *.perf-test.tsx, write .reassure/current.perf
yarn test:perf --baseline # same, written as .reassure/baseline.perf
yarn test:perf:compare [base] # measure base, then HEAD, then apply the render-count gateyarn testnever runs perf tests.yarn test:perf:compareneeds a working tree without tracked changes (untracked files are ignored and measured on both sides). It checks outbase(default: the merge-base withorigin/main), measures it, checks your branch out again and measures it. Whenyarn.lockorpatches/differ from the base, it wipes and reinstallsnode_modulesfor the base and again for your branch.- On pull requests, the CI
perfjob runs the same command against the base branch. Its result is in the job summary, and therender-perfartifact holds.reassure/.
Reading the result
| Result | Job |
|---|---|
| A scenario's render count went up | fails |
| A scenario's render count differs between its runs | fails |
| Two scenarios share a name (when the base has no perf tests) | fails |
| The base was measured but no comparison was produced | fails |
| A perf test failed on the base or on your branch | fails (run-failed), see the job log |
Reassure's output format changed, or a scenario measured on both sides is reported as added or removed (unrecognised-output) |
fails: the gate fails closed rather than read an unknown format or skip a comparison |
| Render count went down, or stayed the same | passes |
| Duration changed with an equal render count | passes (informational) |
| Scenario added, removed or renamed | passes (not compared) |
| The base has no perf tests yet | passes, lists your counts |
The summary starts with the gate's heading and verdict; on failure, a link to this section follows. A failed run (run-failed) uses the same heading and verdict line, followed by its cause. When the pull request changes perf tests or their Jest setup (*.perf-test.tsx, jest/, __mocks__/, jest.config.js, babel.config.js), a note says that count changes may come from the tests themselves; the verdict is unchanged. Reassure's own report is folded at the end, under "Reassure report (durations are informational)".
Durations come from shared CI machines and move between runs, so even identical code can show a "significant" duration change. Only the render count gates.
An intended increase. The job goes red. Say in the pull request why the count rises; once the reviewer accepts it and it merges, the new count is the base for later pull requests.
Adding a scenario
Put the file next to the component's unit tests: <Component>/__tests__/<Component>.perf-test.tsx. Use measureScenario from jest/perf.ts:
import {fireEvent} from '../../../../jest/test-utils';
import {flushTimers, measureScenario} from '../../../../jest/perf';
it('expands the advanced settings', async () => {
await measureScenario(<SettingsScreen />, {
wrapperOptions: {
withSafeArea: true,
withNavigation: true,
withBottomSheetProvider: true,
},
scenario: async screen => {
await flushTimers();
expect(screen.queryByTestId('batch-size-slider')).toBeNull();
fireEvent.press(screen.getByTestId('advanced-settings-accordion'));
await flushTimers();
expect(screen.getByTestId('batch-size-slider')).toBeTruthy();
},
});
});- Drive the app's own path. Change the state the app changes (an owner component's state, a store observable, or an event) without remounting.
- One
actper driven write. Useawait act(() => runInAction(...))for each store write;fireEventalready wraps one. Several writes inside oneactbatch into a single commit. - Advance timers explicitly, in every scenario, sheets included.
measureScenarioruns on fake timers (with the realperformanceclock, so durations stay real). Callawait flushTimers()after the mount and after each step: it advances the clock 100 ms, 1 ms peract, so a timer-driven commit, such as Paper's deferredTextInputplaceholder or a list's batched cell update, always lands at the same point and as its own commit (two timers due in the same millisecond still share one commit). The mount-only measurement does the same flush, so a scenario that only waits does not count as an interaction. - Every run starts from the same state. Each scenario runs several times, re-mounting the same element. Reset anything it changes outside the tree in
beforeEach, in place:messages.replace(fresh), or settingfiltersback to[]on the same observable. A new object assigned inbeforeEachnever reaches the re-mounted element. - Assert the start and the end of the interaction in every run, for example "collapsed" before and "expanded content present" after.
- Type queries can't find
observercomponents (they render asmemo); assert on what they render instead. measureScenariouses at least 3 runs, so an unstable count shows up, and it fails a scenario that adds no commits over the mount alone.- The test's full name is the scenario's identity. Renaming a test reports the old name as removed and the new one as added, and neither is compared.
Limits
- Each step is followed by a 100 ms pause (
flushTimers), as if the user paused between key presses or tokens arrived slowly. Timers due within that pause commit inside the scenario; faster input in the app can batch some of them. The fake clock keeps running across flushes, so a timer due more than 100 ms after a step fires during a later step's flush, or not at all if no step follows. - Scenarios render as iOS (the Jest preset's default platform); Android-only branches are not measured.
- A change to a perf test or its Jest setup can move the counts by itself. The summary notes such pull requests, but cannot tell which change moved a count.
- Passing a new object to a memoised child on every render adds no commit, only work inside the existing one. The render count does not change; at most the duration does.
- Under the
@gorhom/bottom-sheetmock,presentandclosedo nothing and sheet content always renders. The sheet scenarios therefore count only the owner's visibility handling, not content mount, animation or gestures.
Current scenarios
| File | Interaction |
|---|---|
src/components/Sheet/__tests__/Sheet.perf-test.tsx |
open and close a Sheet from its owner's state |
src/components/ChatPalModelPickerSheet/__tests__/ChatPalModelPickerSheet.perf-test.tsx |
open and close the pal/model picker from the chat input's Select Pal button; the count includes the chat view's own toggle |
src/components/ChatView/__tests__/ChatView.typing.perf-test.tsx |
type ten characters into the chat input |
src/components/ChatView/__tests__/ChatView.streaming.perf-test.tsx |
stream twenty tokens into the newest assistant turn, written the way ChatSessionStore writes them; the store's 30 ms streaming throttle is upstream of the view and not exercised |
src/screens/ModelsScreen/__tests__/ModelsScreen.perf-test.tsx |
filter the model list to downloaded models |
src/screens/SettingsScreen/__tests__/SettingsScreen.perf-test.tsx |
expand the advanced settings |
--- METRICS ---
- Files Extracted: 6
- Estimated Token Budget: ~13625 tokens
- Canonical Reference: https://codewiki.google/github.com/a-ghorbani/pocketpal-ai