Commit 679bb84
feat(assistant): Gemini Nano backend on Android, and an on-device planning benchmark (#306)
* fix(assistant): size llama.cpp threads from cpufreq, not core count refs #270
The thread count assumed every big.LITTLE arm64 phone pairs its performance
cores with exactly four efficiency cores, and derived the count as hw-4.
Tensor G5 (Pixel 10) has two: 2x2.25 + 5x3.05 + 1x3.78 GHz. So a Pixel 10 ran
four threads where six were available, and llama-bench (Qwen3-4B Q4_K_M, fa=1)
measured what that cost: pp512 19.90 t/s at four threads against 26.22 at six,
regressing again at seven and eight as soon as a little core joins.
Count the performance cores instead, from each core's cpufreq ceiling, keeping
those within 75% of the fastest. That gap holds across every current arm64
layout, and it reproduces the previously measured optimum on Tensor G3 (5) as
well as the new one on Tensor G5 (6). An unreadable cpufreq falls back to the
old count-only guess, and the [4, 6] clamp is unchanged.
Decode is unaffected: it measures 6.2-6.7 t/s at every thread count on Pixel
10 because it is bandwidth-bound, so this is a prefill fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(developer-guide): note iOS CI builds are possible, just not set up
Chapter 14 said iOS builds locally through Xcode because of signing and
App Store submission, which reads as a platform limitation. It is not:
certificates, provisioning profiles, and submission all automate on a
macOS runner. The workflow just has not been written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(assistant): Gemini Nano backend via AICore on Android refs #270
The Android counterpart of the Apple Foundation Models backend: the system
model, reached through AICore with the ML Kit GenAI Prompt API.
It is a trimmed NativeLlmProvider rather than a port of
AppleIntelligenceProvider, which is the opposite of what the symmetry
suggests. Foundation Models takes a GenerationSchema built per turn, so that
provider constrains the reply to the turn contract and removes the answer
branch until a tool result exists. ML Kit's structured output is compile-time
Kotlin codegen with no union type, so no per-tool schema can be built at
runtime and no branch can be removed. This backend runs the same
prompt-plus-parser, self-repair loop llama.cpp does and leans on the
code-level grounding checks in agent.ts instead.
Two capability facts came off a Pixel 10 and contradict the documentation,
so both are probed at runtime. getTokenLimit() reports 8192 on nano-v3 where
the docs say under 4000, and isSystemPromptAvailable() reports false, which
means a SystemInstruction is accepted and ignored: passing one would have
silently dropped the tool catalog and the install-specific facts. The system
text is folded into the prompt when that probe says false.
Unlike Apple's, this bridge needs a download surface, because AICore fetches
the weights on request rather than shipping them with the OS. A supported
phone that has not downloaded them reports notReady, which becomes a download
row in Settings rather than a dead end, and the hook re-probes when it
finishes so the backend becomes selectable without a restart. Two AICore
rejections get their own messages instead of the generic engine failure:
inference is refused unless the app is in the foreground, and each app is
metered per day, and "try again" is wrong advice for both.
ML Kit declares minSdk 26 against the app's 24, so the manifest overrides the
library rather than raising the app floor and dropping Android 7 users for a
backend needing Android 14 anyway; the plugin refuses every call below API 26
so no ML Kit class loads where the runtime could not verify it.
Measured on device end to end: a data question produced a tool call, the
following turn answered from the tool result, and a greeting answered without
calling a tool, at roughly 1.4 to 1.8 seconds per turn against the llama.cpp
path's 6.6 tokens per second on the same phone. That is three spot checks,
not a score: prompt-eval has not been run against this backend, and
agents/project/llm-models.md records it as unmeasured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(assistant): score the Android system model with the shared on-device eval refs #270
The prompt-eval harness reaches a backend over HTTP, and neither system model
has an HTTP surface, so the eval row added for Apple Foundation Models was the
only way that backend ever got a score. Gemini Nano needs the same, and needed
almost no new code to get it: runFmTimeEval already took any AssistantProvider,
so the Apple-only row became a parameterized one rather than a second copy.
The row takes a provider factory, the backend id and a model label. The factory
rather than an instance because each provider caches the context window it
learned per instance, and a re-run should re-learn it. The backend id is stamped
on the logged report because the point of running this on two backends is
comparing the two reports afterwards, which a report that does not say which
backend produced it cannot support.
The first run on a Pixel 10 scored 27/52 and the number is worthless: 32 of the
failures were BACKGROUND_USE_BLOCKED, because the screen had dozed and AICore
counts a locked screen as background. Whole case classes read as total failures
while the log showed no answer had been produced at all. The playbook now says
how to spot that before trusting an on-device number, since the failure looks
exactly like a model that cannot do weekdays.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(assistant): model downloads and evals outlive the screen that started them refs #270
The WebLLM and llama.cpp downloads already reported through the backgroundTasks
store, so they survived their settings row unmounting and kept showing progress
in the app-level drawer. The two surfaces added since did not: the Gemini Nano
download and both system-model eval runs held progress in component state, and
the eval row aborted its run in an unmount effect. Leaving Settings mid-run threw
the measurement away.
On Android it did worse than that. The abort called the plugin's cancelChat,
which cancels the ML Kit future, and genai-prompt 1.0.0-beta4 is compiled against
kotlinx-coroutines 1.7.3, where Job.cancel$default lives in Job$DefaultImpls. The
app resolves coroutines 1.10.2, which dropped that class, so ML Kit's
cancellation path throws NoSuchMethodError on its own thread pool, uncaught, and
takes the process down. cancelChat now abandons the call rather than cancelling
it: the JS side already turns an abort into an AbortError on its own and the
result is discarded, so the only cost is the second or so the model had left to
run, which is not worth crashing to avoid.
Both surfaces now report through backgroundTasks like the older downloads, so a
run shows in the drawer, keeps counting while the user is elsewhere, and is still
there when they come back. Neither task carries a cancelFn, because cancelling is
the crash above and AICore owns the download with no cancel of its own.
Verified on a Pixel 10: an eval at 27/52 in the row read 52% in the drawer from
another screen, then 31/52 in the row on return, with the app alive throughout.
That also produced the first trustworthy score for this backend, 48/52,
reproduced twice; the earlier 27/52 was 32 BACKGROUND_USE_BLOCKED rejections from
a screen that had dozed, not wrong answers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(assistant): drop llama.cpp from Android and rank backends per platform refs #270
Android's llama.cpp integration is gone. It never had a GPU path there, so it
decoded at 6.6 tok/s while the phone's own system model answered a whole turn in
about a second and a half, and it charged 76MB of native libraries plus a 2.5GB
model download to be the slower option. Android's on-device backend is Gemini
Nano; iOS keeps llama.cpp, where Metal makes it the better of its two on-device
choices. Deleted: the JNI engine, the CPU-topology helper and its check, the
CMake build, NativeLlmPlugin, and the NDK wiring in build.gradle. The shared JS
provider, the plugin wrapper and the Swift engine stay, because iOS still uses
every one of them.
Removing the abiFilters block restores what the app packaged before llama.cpp
arrived, since that commit is what added it: the APK is no longer arm64-only, so
32-bit ARM devices it had silently dropped are back in range.
The accuracy hint was one sentence shown on every platform, ranking backends that
platform might not have. It is now three, chosen by platform and naming the model
each tier runs rather than just the runtime, because "on-device" does not tell
anyone what is answering them:
iOS Ollama (qwen3:8b) > Qwen3 4B Instruct on Metal > Apple Intelligence
Android Ollama (qwen3:8b) > Gemini Nano
web Ollama (qwen3:8b) > the selected WebGPU model
The chat window's system-model note had the same problem in a worse form: it sent
everyone to llama.cpp, which on Android is now advice that cannot be taken. Both
the current backend and the suggested one are interpolated, so iOS still points at
the on-device model and Android points at the user's own Ollama server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(assistant): score the tool contract on-device, for both system models refs #270
The time eval measured what a backend does with "last tuesday night". It never
touched the thing the backend ranking actually claims to rank: given a question
and the tool catalog, does it pick the right tool with the right arguments. That
was scored only by prompt-eval.mts, which talks HTTP and so reaches Ollama alone,
so neither Apple Foundation Models nor Gemini Nano had ever been measured on it.
The cases move out of prompt-eval.mts into contract-eval-cases.ts and both
harnesses import them, for the reason time-eval-cases.ts already exists: a second
copy of an eval drifts from the first and then measures a bug the app does not
have. prompt-eval.mts keeps its scoring; only the list moved.
runContractEval drives provider.chat with the production buildSystemPrompt, so
each backend is scored through its real path rather than a reimplementation of
one. It passes a recording runTool, which is load-bearing: Apple owns its tool
loop and only reveals its calls by executing them, so without the callback the
run would score Apple's fallback path instead of the one production uses, and
score it as making no tool calls at all. Backends that return toolCalls ignore
the callback, and both sources are read.
The settings row now runs both stages under one task and one progress bar, and
the report line carries each stage's breakdown alongside the combined score.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(assistant): tell AICore's rate limit apart from the plugin's busy guard refs #270
The first contract eval on a Pixel 10 reported 0/14 with every case reading
"a chat is already running". Nothing was: AICore meters requests over a short
window, and the contract stage started the moment the time stage's 52 calls
finished, so the platform rejected all fourteen. The plugin mapped ML Kit's
ErrorCode.BUSY onto the same CHAT_BUSY code as its own concurrency guard, which
made a rate limit indistinguishable from a real conflict and a paced run
indistinguishable from a model that got everything wrong.
BUSY is RATE_LIMITED now, with its own message, and the provider tags the error
it throws so a batch caller can act on it. The contract eval backs off twice and
retries rather than scoring a rejection as a wrong answer, and reports how many
retries a run needed, so a score that was paced by the platform says so.
With that in place the eval produced the first tool-contract number any system
model has had: Gemini Nano 12/14, after 11 rate-limited retries. Both failures
are the same fault, answering a data question without calling a tool.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(assistant): measure answer quality on-device, the fault the other stages miss refs #270
The on-device eval scored two of the HTTP harness's six stages: can a backend read
"last tuesday night", and does it fetch the right thing. Neither asks whether the
answer it then writes is true, which is the class of failure this assistant has
actually shipped and the one a system model is most likely to commit. A backend can
pick the right tool with the right arguments and still tell the user there were
fifteen events when the result carried five.
Every check is a fault seen live: counts invented rather than read, monitor names
invented, the data denied while rows sat in the result, the raw tool payload handed
back as prose, and rows conjured over an empty result. grounding.ts catches two of
them at runtime; nothing measured how often they happen.
The cases and their fixtures move out of prompt-eval.mts into answer-eval-cases.ts
and both harnesses import them, as the tool and time cases already do. The runner
stages the question, the model's own tool call and the tool's result into the
history, so the only thing left to produce is prose, and passes no runTool: a
backend with a native tool loop has nothing left to call.
A full on-device run is now 70 cases across three stages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(assistant): make the on-device benchmark measure planning, not prose refs #270
What the assistant has to get right is reading a request and deciding what to do
about it: which tool, over which window, with which labels or tags, or that no
lookup is needed at all. The benchmark now measures that and only that.
The tool-plan cases go from 16 to 36. New coverage: rolling versus calendar
windows (count_events cannot express "yesterday", list_events cannot express "the
past hour"), clock ranges, ordinals, open-ended spans, a named camera, tags, the
group and monitor surfaces, and the object vocabulary that must appear on "were
there any cars" and must NOT appear on a plain summary.
The eight no-tool cases are now SCORED rather than skipped. Recognising that
"thanks", "who won the world cup", "how do I add a camera" and "arm the backyard
camera" need no lookup is half the judgement, and skipping them measured only the
half the model finds easier. Triage intercepts most of them in production, which
is a safety net in front of that judgement, not a reason to leave it unmeasured.
The answer-quality stage added earlier is removed with its cases and tests. It
scored prose written over a canned result, which is a different axis from
planning; keeping it in the run would have diluted the number this benchmark is
for. One revert brings it back if the prose axis is wanted separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(assistant): give apple the full prompt on the turn that plans refs #270
Apple Foundation Models scored 21/38 on the tool-planning benchmark against
Gemini Nano's 33/38, and its failures were not malformed output: the constrained
decoder produced a valid call every time. They were the wrong calls. Four chose
count_events for a calendar window, six defaulted to when "yesterday" whatever
was asked, and four went looking through events to answer questions about the
world cup and the capital of France.
Those are the faults a trimmed prompt predicts. The rolling-versus-calendar
distinction lives past the first sentence of two tool descriptions, and the rule
about copying the person's own time words lives in the body of the shared prompt.
Both were removed to fit the window.
The window forces that trim only when a tool result has to fit beside the prompt.
The untrimmed prompt is ~2766 tokens against 3072 usable, so on the turn that is
still deciding what to fetch there is room for it, and only after a result comes
back is there not. The prompt now follows that: whole shared prompt and whole tool
descriptions while planning, trimmed once a successful result is in the thread.
The same predicate already decided whether the schema offers an answer branch, so
it is one notion of "grounded yet" rather than two.
On the native tool-loop path the full descriptions go into toolsJson, where the
framework already injects them, rather than into a catalog in the prompt. A
catalog there would be the second, drifting copy the trimmed design was right to
avoid.
A tool-less turn keeps the trimmed prompt: it has nothing to choose between, and
the full one would teach tool conduct to a turn with no tools.
Unmeasured as yet. The benchmark that produced 21/38 is unchanged, so the re-run
is a clean before/after, and Gemini Nano's 33/38 stands as the baseline because
nothing shared moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(assistant): narrow the apple planning prompt to the tool descriptions refs #270
Handing Apple the whole shared system prompt on the planning turn was measured and
rejected: planning moved 21/38 to 23/38, inside this model's own run-to-run spread,
while failures appeared that had not been there before. Arguments leaked between
cases (a German window on two English questions), objectType filled with junk
("NO", a tag name), and the no-tool cases got worse rather than better. That is a
model pushed nearer its context limit, not one that was short of instructions.
What did work in that run is kept: the four failures that chose count_events for a
calendar window went to one, because the rolling-versus-calendar distinction lives
past the first sentence of those two descriptions. So the descriptions grow on the
planning turn and the system prompt stays trimmed to its dynamic facts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* revert(assistant): apple's planning gap is not the trimmed prompt refs #270
Reverts 8ec4210 and d864597.
Apple Foundation Models plans 21/38 on the tool-planning benchmark against Gemini
Nano's 33/38, so the trimmed prompt was the obvious suspect: the sentences it
removes are exactly the ones that distinguish count_events from list_events and
that tell the model to copy the person's own time words.
It was measured and it was wrong. Handing Apple the whole shared system prompt
plus whole tool descriptions on the planning turn moved it to 23/38, two cases on
a 38-case set and inside this model's own run-to-run spread, while introducing
failures that had not been there: a German window on two English questions, and
objectType filled with "NO" and with a tag name. That is a model pushed nearer
its context limit, not one that was short of instructions. The narrower variant,
whole descriptions with the system prompt still trimmed, never got a clean run.
Neither is worth the complexity of a second prompt shape and a mode switch for a
move that small, so both go back out. The playbook keeps what was learned,
including the one real effect: the extra text took count_events chosen for a
calendar window from four failures to one.
Also records the measured comparison and the screen-lock trap that voids an
on-device run on both platforms.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(assistant): say which backend to pick, per platform, with the numbers refs #270
The per-platform ranking was in one block near the top and nowhere else, so a
reader who arrived at the Apple Intelligence or Gemini Nano section from the table
of contents got no steer at all. Each of those sections now opens its closing with
what to pick and why, and the accuracy chapter carries the two system models it
never covered.
The numbers are the ones measured on the phones: Gemini Nano planned the right
lookup for 33 of 38 questions and knew when no lookup was needed 8 times out of 8;
Apple Intelligence managed 21 and 4. The second column is called out, because
searching a person's cameras to answer "who won the world cup in 2018" is the
failure they would actually notice.
Two accuracy claims are marked as not directly comparable rather than left to
imply a head-to-head that was never run: the Ollama table was measured on the
fourteen-question suite as it stood in July, and the system models on the
thirty-eight-question one it has since grown into.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>1 parent 8e0f640 commit 679bb84
44 files changed
Lines changed: 2733 additions & 1254 deletions
File tree
- agents/project
- app
- android/app
- src/main
- cpp
- java/com/zoneminder/zmNinjaNG
- scripts
- src
- components
- assistant
- __tests__
- settings
- __tests__
- hooks
- __tests__
- lib
- assistant
- __tests__
- providers
- __tests__
- locales
- de
- en
- es
- fr
- zh
- plugins/gemini-nano
- docs
- developer-guide
- user-guide
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
11 | | - | |
12 | | - | |
13 | | - | |
14 | | - | |
15 | | - | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
16 | 51 | | |
17 | 52 | | |
18 | 53 | | |
| |||
48 | 83 | | |
49 | 84 | | |
50 | 85 | | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
51 | 115 | | |
52 | 116 | | |
53 | 117 | | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
54 | 130 | | |
55 | 131 | | |
56 | 132 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
3 | 3 | | |
4 | 4 | | |
5 | 5 | | |
6 | | - | |
7 | 6 | | |
8 | 7 | | |
9 | 8 | | |
| |||
27 | 26 | | |
28 | 27 | | |
29 | 28 | | |
30 | | - | |
31 | | - | |
32 | | - | |
33 | | - | |
34 | | - | |
35 | | - | |
36 | | - | |
37 | | - | |
38 | | - | |
39 | | - | |
40 | | - | |
41 | | - | |
42 | | - | |
43 | 29 | | |
44 | 30 | | |
45 | 31 | | |
46 | 32 | | |
47 | 33 | | |
48 | 34 | | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | 35 | | |
60 | 36 | | |
61 | 37 | | |
| |||
71 | 47 | | |
72 | 48 | | |
73 | 49 | | |
74 | | - | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | | - | |
79 | | - | |
80 | 50 | | |
81 | 51 | | |
82 | 52 | | |
| |||
99 | 69 | | |
100 | 70 | | |
101 | 71 | | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
102 | 75 | | |
103 | 76 | | |
104 | 77 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
3 | 11 | | |
4 | 12 | | |
5 | 13 | | |
| |||
This file was deleted.
0 commit comments