Repository navigation
feat: generative detail screen, on-device NL engines, encryption at rest - #11
Merged
Merged
Conversation
…decaying overlay
The detail screen is now render(spec) too (ADR-0005 P2): a DetailSpec of typed
DetailBlocks (Hero/Title/Price/Description) produced by a DetailInferenceEngine and
rendered through a DetailRenderer registry walk — no hand-written screen. The SAME
decaying PreferenceOverlay that drives the list drives detail, so density and
price-visibility stay consistent across screens ("hide prices" on the list is honored
on detail too). List-only dims (layout, section order) don't apply.
- DetailSpec + DetailBlock (spec pkg) + merge(DetailSpec, overlay, now).
- DetailInferenceEngine seam + deterministic impl (canonical block order).
- ProductDetailViewModel: combine(baseline, overlay) -> merged spec; no state machine
(detail has no concurrent-append race). ProductOpened + dwell signals unchanged.
- Extract testable ProductDetailContent; 3 detail Roborazzi snapshots.
Verified: Android host tests + snapshots green; iOS compiles. Detail nav isn't
simctl-scriptable, so detail correctness is covered by host snapshots + unit tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…006) The phase-1 NL seam was designed against an assumed shape for the on-device model APIs. Reading the real ones showed it cannot carry a real engine. Android (ML Kit GenAI Prompt API): AICore's codes surface verbatim on GenAiException.ErrorCode, but BUSY (9) is documented as *either* transient quota *or* disallowed background use — opposite correct responses. Structured output has its own failure family (-104/-105/-106, where -106 is a truncated object) and policy refusals are a third family (4/15/11), neither of which was modelled. Inference is permitted only while the app is top foreground. Apple (read from the iOS 26 SDK swiftinterface/swiftdoc, Xcode 26.6): rateLimited is separate from concurrentRequests, and its documented cause is "your app is running in the background or the system is under load" — so iOS soft-limits background inference where Android hard-blocks it. Context window is 4,096 tokens shared with output and queryable at runtime. The framework carries two @objc annotations in total: it is Swift-only and unreachable from cinterop, and Swift cannot implement a Kotlin suspend function. Changes, all pure commonMain and unit-tested: - Parser-first ordering. The parser runs first; the model is consulted only on a miss or ambiguity. AICore's quota has no published numeric value, so the only safe design is to not spend it — and most real commands are literal vocabulary hits, so this also makes the common case instant. - Five-category ModelFailure (Unavailable / NotReady / Throttled(retryable) / Rejected / Cancelled) that each platform maps its native errors into. The model tier still never sees NlResolution, per phase 1.5. - Rule-table parser over tokens with clause-scoped negation, synonyms, an absolute > relative > bare priority order, and an explicit Ambiguous verdict. Fixes three real defects: "list" matched *listing* and *stylist*, "don't hide prices" hid prices, and no synonym a shopper might type was recognised. - validate() as the single bounded-vocabulary gate phase 1.5 promised — it rejects rather than repairs, so a model defect cannot be laundered into a layout the shopper never asked for. - NlContext, projected from the merged UiSpec rather than ViewPreference: relative requests ("switch layout") are unresolvable without current state, and ViewPreference only supplies the inference engines' defaults. - Serialised inference plus a resolution cache; the ViewModel cancels the previous command job. - Tri-state CapabilityProbe (NOT_READY must not cache as unavailable) and a ModelFactoryRegistry, which is how the Swift-side iOS engine will register. - prewarm() wired to ViewModel init, so the first command is not the one that pays model load and gets demoted for timing out. Verified: 118 Android host tests pass; 99/100 Kotlin/Native iOS tests pass. The one iOS failure (HomeStateMachineRaceTest.naive_canLoseBaseline) is pre-existing, reproduced on a clean tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-0006) Closes the two caveats left open by the previous commit: Tier A was unwired, and the prompt-prefix / context-budget design was deferred for want of a real engine to measure. Both engines now exist, and running the real model revised four decisions that reading the docs had settled wrongly. Shared (commonMain): - NlPrompt splits the prompt into a cacheable prefix (instruction + few-shot) and a per-request suffix (current state + request). Both platforms expose a real prefix-caching API, so the split is a cost decision, not a style one. - NlGoldenSet is the single source of truth for the parser tests, the model's few-shot examples and the future accuracy gate. It lives in commonMain rather than commonTest precisely so the prompt can read it; a golden set only tests could see would drift from what the model is shown. - NlPatchWire decodes what text-generating models actually emit: code fences, surrounding prose, nested objects, invented values. Unknown values are dropped rather than guessed, and an unknown section role drops the whole order because a partial reorder is a layout nobody asked for. - PromptBudget.fits declines a prompt that would leave no room for a reply, which is cheaper than paying for a truncated one. Measured on an M1 Pro against Apple's real on-device model: contextSize 4,096; prefix 476 tokens; schema 205; suffix 33-43. Nothing hardcodes a window size -- both platforms expose a tokenizer. Three defects only running it could reveal: - The "unchanged" sentinel was wrong. Optional @generable fields do work on iOS 26, and the sentinel made the model echo current state into every field: asking for "grid" came back as an instruction to set all four dimensions, and "play some jazz" came back with a full patch. Fixed with optional fields, a required isDisplayRequest decline flag (a forced schema otherwise gives the model no way to say "not my problem"), and withoutNoOps(context) in shared code -- which is correct on its own terms, since an override equal to the inferred value changes nothing visible while pinning a dimension for a whole TTL. It is not applied to parser output, which is exact. - The timeout was wrong by an order of magnitude. Warm, prewarmed, session- reused guided generation takes 4.2-19.6s, so the 3s budget would have demoted a working engine on nearly every request. Now 20s. - ModelFailure.Throttled gained retryAfterMs: GenAiException.getRetryDelay() exists, and the platform's own advice beats a fixed constant. iOS: FoundationModelsBridge (Swift, in iosApp) does guided generation via @generable and translates GenerationError into the shared taxonomy's codes. It has to live in Swift -- the framework is Swift-only and Swift cannot implement a Kotlin suspend function -- so the bridge is callback-shaped and iosMain adapts it back to suspend. All interpretation stays in Kotlin. Android: MlKitGenerativeBridge (in androidApp) over genai-prompt 1.0.0-beta4, sending the prompt via promptPrefix so the invariant half is not re-tokenised. Structured output is deliberately not used: ML Kit's typed path needs the genai-schema-compiler KSP processor this project has avoided by design, and it cannot be verified anywhere because no emulator ships AICore. Text + JSON keeps the decoding in commonMain where it is fully tested. Both engines live in their app module; shared keeps only mirrored constants and pure mapping functions, which is what lets the AICore error table be unit-tested with no ML Kit on the classpath. API 24-25 stays supported on the parser via tools:overrideLibrary plus a runtime guard, rather than raising the app minimum for everyone. Verified: 167 Android host tests; 141/142 Kotlin/Native iOS tests (the one failure, HomeStateMachineRaceTest.naive_canLoseBaseline, is pre-existing and reproduces on a clean tree). App launches on the iOS 26.5 simulator with the Swift bridge wired and prewarm running at init. On an API-34 emulator with no AICore, Tier A correctly reports unavailable and degrades to the parser; driven through the UI, "compact grid, hide prices" applied all three dimensions and "do not hide prices" restored prices -- the exact negation case the old substring parser got backwards. Not verified, and not verifiable here: Gemini Nano inference itself needs a Pixel 8+ / S24-class device. Its error mapping is unit-tested exhaustively instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs found by exercising the app on an API 37 emulator, neither reachable from the existing tests. ChatBox declared KeyboardActions(onDone = ...) but never set an imeAction, so the keyboard showed no action key and the handler could not fire: pressing enter silently did nothing and the Send button was the only way to submit. Now ImeAction.Send with a matching onSend. Both halves are required, which is what made the omission invisible. SqlDelightSignalLog.record launched one coroutine per call on a multi-threaded dispatcher, so rows were inserted concurrently and out of order. Observed on device: a preference_changed emitted before its nl_command was inserted after it, despite an earlier timestamp. Since two events in the same millisecond share a timestampMs -- which the sweep shows is the normal case for a command and its patch -- the autoincrement id is the only tiebreaker a reader has, and it is only meaningful if insert order is emission order. Writes now go through an unlimited channel drained by a single writer: record() still never blocks a click, nothing is dropped, and ordering is faithful. selectRecent orders by (timestampMs, id) so reads are deterministic too. An append-only log whose ordering cannot be trusted does not support the decision-log story it exists for, so this is a correctness fix rather than a tidy-up. Verified on an API 37 (Android 17) emulator, all 22 vocabulary cases driven through the UI and checked against the recorded patch in the signal log: absolutes, synonyms (tiles/rows), both negation forms including "no prices" where the rule owns the negation word, multi-dimension, section order, relative requests reading current state, absolute-beats-relative precedence, and all four out-of-vocabulary shapes including the "listing"/"stylist" substring regressions and the "grid and list" contradiction. 22/22 correct. Ordering fix re-verified on device afterwards. Also ran on a physical Galaxy A16 (API 36): ML Kit's genai classes load and the process starts clean with no AICore present, which exercises the Tier A degradation path on real hardware rather than an AOSP image. The device is PIN-locked so the UI could not be driven on it. 168 Android host tests; 141/142 Kotlin/Native iOS (the one failure is pre-existing); iOS app builds for the simulator. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wnload infra
Works through the improvement list from the last round, plus the two features
that list unblocked, plus Tier-B download infrastructure.
Parser vocabulary. Rules now combine word *groups* rather than fixed token
lists, so a rule reads "(hide OR conceal OR drop OR omit ...) AND (price OR cost
OR pricing OR amount ...)". Verbs and nouns multiply instead of being enumerated
pairwise, and widening one group widens every rule built from it. Several
collisions found while doing it: " with " as a clause separator broke "with
prices"; "display" in both the layout nouns and the show-verbs made "display
prices" ambiguous; "wide"/"up" as column nouns fired on ordinary phrases. All
narrowed, with the reasoning recorded where the group is defined.
Columns. A spec-driven grid column count, end to end: PreferencePatch,
PreferenceOverlay, the merge, the wire format, the prompt, and the renderer,
which now reads spec.columns instead of a hardcoded chunked(2). It is the first
continuous dimension, so it is also the first with a range to enforce — which
finally exercises the numeric branch of validate() that was described but unused.
Out-of-range values are rejected, not clamped; relative requests ("more
columns") coerce, because "more" at the maximum should sit still rather than
report a failure.
Reset. "reset" / "undo" / "revert" / "back to default" clears the overlay via a
new PreferenceOverlayRepository.clear(). This was in the plan's vocabulary and
missing from the table, which meant a shopper who made a mess had to wait out a
24-hour TTL. Reset is checked before dimensions, so "reset, then grid" is a
reset. Recorded as recognized-and-applied so the decision log shows why the
overrides vanished.
Clarification. ParseResult.Ambiguous now carries the dimension, and
NlResolution.NeedsClarification reaches the UI as a specific question ("Show
prices or hide them?") rather than a recital of the whole vocabulary at someone
who already named the thing they wanted. Only reached after the model also
declines, so it never pre-empts an engine that could have resolved it.
Progress and accessibility. A resolving state replaces Send with a spinner —
guided generation was measured at 4-20s, so the wait has to be visible. A polite
live region announces the regenerated layout, which is the announce-layout-
changed requirement a self-recomposing UI owes a screen-reader user. A hint no
longer sets isError: guidance is not a mistake.
Signal store. Pruning is amortised (every 50 writes) instead of running a
full-table DELETE per event; retention was always a bound, not an exact size, and
the test now pins that trade explicitly.
Bugs fixed:
- PatchPayload had no `columns` field, so a real device logged
preference_changed with an empty patch — silently lossy, exactly what the
persist-raw decision forbids. Caught on the emulator, not by a test; now
covered by one asserting every dimension survives the round trip.
- chatBoxKeyboard() now produces the ImeAction and its handler together, so
neither can be declared without the other. That pairing is what failed last
time. A full Compose interaction test needs a host-Activity harness this module
does not have, so the contract is asserted directly instead.
Model download (ADR 0004 phase 4, first half). ModelDownloader fetches a GGUF to
disk, shaped by what actually happens on a phone during a ~400 MB transfer:
resumable via Range against a .part file; free space checked up front, counting
only the remaining bytes on a resume; refused on a metered connection without
explicit per-call consent; verified by streaming SHA-256 and only then renamed,
so a crash can never leave a truncated file looking installed; a corrupt partial
is discarded rather than resumed forever; progress reports throughput and a
remaining estimate, with verification as its own visible state because hashing
400 MB is not instant. A server that ignores Range is detected and restarted
rather than concatenated onto — that one would have produced a silently corrupt
model.
The catalog entry is real, not a placeholder: Qwen's own repo ships only Q8_0
(639 MB), so this points at unsloth's Q4_K_M at 397 MB, with the size and the
sha256 read from the published artifact's Git-LFS oid.
Verified: 186 Android host tests, 155/156 Kotlin/Native iOS (the one failure is
pre-existing). The download path is tested against a fake file system and, in a
separate test, the real one. It was additionally verified against the real
Hugging Face CDN end to end — 1,153,380 bytes through a 302 redirect, progress
reported, and the streaming checksum matched the published LFS oid; that probe
was temporary and is not committed.
On an API 37 (Android 17) emulator, driven through the UI: "gallery" -> grid,
"3 columns" then "more columns" -> four columns rendered, "hide the cost" ->
prices gone, "price" -> the targeted clarification, and "reset" -> everything
back to the inferred baseline. The keyboard now shows a working Send action key.
Not included, and not a session's work: the llama.cpp GGUF inference engine that
would make a downloaded model usable. Downloading is complete and tested; running
is the separate deliverable. iOS platform storage/network conditions are also
still to write — only Android has them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… tests Finishes the three items the previous commit left open. Tier B (llama.cpp) is implemented and verified on device. No NDK build and no cinterop were needed: com.llamatik:library publishes llama.cpp for android, iosArm64 and iosSimulatorArm64, which makes the hand-rolled-JNI plan moot. GgufInference keeps the native call behind an interface, so the schema, decoding and failure classification are host-tested with no model file present, and TierBSupport reports the engine as NOT_READY (not UNAVAILABLE) until a verified model is on disk — the difference decides whether a picker offers an engine the user could actually have. Resolving Tier B never starts a download. Grammar-constrained decoding had to be abandoned. ADR 0004 specified GBNF, and the wrapper exposes generateJsonWithContext(..., jsonSchema) for exactly that. It crashes the process inside llama_grammar_accept_token — reproduced on an API 34 emulator, an API 37 emulator and a physical device, with two different schemas, the second using nothing but enum and type. That symbol is a llama.cpp API whose shape has since changed, so it reads as a version mismatch inside the published wrapper rather than anything about the schema. That is not merely a missing feature. A native SIGSEGV cannot be caught from Kotlin, so it is a failure the entire ModelFailure taxonomy is powerless against: it takes the app down. Avoiding the call is the only safe response. The shape is instead enforced the way the Android Tier-A text path already enforces it — schema in the prompt, tolerant decode, then validate — which is fully tested and cannot crash. Measured on an API 37 emulator with the catalogued Qwen3-0.6B Q4_K_M: download of 396,705,472 bytes (exactly the catalogued size) with the checksum verified and 408 progress updates; "make it a grid and hide the prices" -> GRID + prices hidden in 11.1s; "I want to see more items at once" -> GRID + 3 columns in 6.5s; "play some jazz" -> declined in 7.6s. Two things worth recording: that paraphrase is one Apple's on-device model got backwards, so Tier B is not uniformly the weaker engine; and this model echoes untouched dimensions exactly as Apple's does, which independently confirms withoutNoOps was a real requirement rather than a workaround for one vendor's quirk. Llamatik's AAR is ~52 MB because it ships llama.cpp, whisper.cpp and stable-diffusion.cpp for four ABIs. Restricting to arm64-v8a and x86_64 took the debug APK from 150 MB to 86 MB. A 32-bit-only device still installs and still works: a library that cannot load makes Tier B degrade to the parser rather than fail hard. An attempt to also exclude the unused whisper/stable-diffusion .so files did not take effect and was removed rather than left in place pretending to work — it is ~2 MB. iOS download platform. IosModelStorage uses Application Support (not Caches, which the OS may evict, and not Documents, which is user-visible and iCloud backed) and reports space via VolumeAvailableCapacityForImportantUsage, the figure Apple says to use for content an app genuinely needs. IosNetworkConditions reads NWPathMonitor, caching the latest path in atomics because the monitor is asynchronous while the interface is synchronous — and starting pessimistic, so no download can slip out on cellular during the window before the first callback. Compose interaction tests now exist, in :androidApp rather than :shared, because runComposeUiTest needs a host Activity registered in a manifest and a library has no business declaring one. A debug-only manifest supplies ComponentActivity, so it never reaches a release build. Six tests cover the IME action that shipped broken, the Send button, field clearing, blank rejection, the clarification hint, and the progress indicator replacing Send. ProductListContent became public to allow this. Verified: 217 shared Android host tests, 6 androidApp unit tests, 1 on-device instrumented test, 155/156 Kotlin/Native iOS (the one failure is pre-existing). The committed on-device test uses SmolLM2-135M (84 MB) so a run is ~30s rather than three minutes; the catalogued model's numbers above came from running that same test against it manually. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…R-0004 phase 2)
Closes the last gap: the Tier-B download infrastructure and engine were complete
and wired into the graph, but nothing in the app offered them.
All three surfaces sit over one EnginePicker and one EnginePickerViewModel. One
state holder rather than three, because the hard part is not layout but being
truthful about what each tier can do on this device, and three copies of that
judgement would drift.
Decisions:
- Unavailable tiers stay visible and disabled, with the reason. Hiding them
leaves the user unable to tell "this phone can't" from "this app doesn't", and
the one thing the picker must never do is let someone select a tier that
silently falls through to the parser.
- NOT_READY is selectable only for the downloadable tier, where the user can make
it ready. For Tier A it means the OS is still fetching assets, which selecting
cannot hurry.
- First run is derived, not stored. It is exactly "the user has never chosen",
which the existing engine_choice key already answers, so no second flag exists
to drift. Dismissing without choosing is therefore not recorded as consent: the
app keeps running on Auto and asks again next launch.
- The download size is on screen before anything starts. Consenting to "a model"
is not consenting to 397 MB.
- Deleting a model steps the selection back to Auto, since leaving
LocalDownloaded selected would point at a tier that can no longer do anything.
- Refusals are explained in terms of the next action — connect to Wi-Fi, free
this much space — rather than reported as a failure.
A bug only the device could show: pausing cancelled the download job, but a
cancelled flow simply stops emitting, so the last Downloading value survived
every later refresh and the row went on claiming "3 MB/s, 1 min left" over a
transfer that had stopped minutes earlier. cancelDownload now rewrites the state
to Paused, and refresh additionally treats a Downloading state with no live job
as stale whatever produced it — process death, a dropped connection. Covered by
a regression test.
Verified on an API 37 emulator: the first-run dialog appears with Tier A
correctly disabled ("Not available on this device") and Tier B offering "Qwen3
0.6B Instruct · 396 MB · Q4_K_M"; Download reaches "8 MB of 396 MB · 4 MB/s · 1
min left" with a live progress bar and a real .part file on disk; pause leaves
"122 MB of 396 MB downloaded" with Resume, surviving an app restart; and the same
picker is reachable from the chatbox's settings affordance and the inline
"Understanding: automatic — Change" row.
235 shared host tests, 16 androidApp unit tests (10 of them new picker
interaction tests), 155/156 Kotlin/Native iOS — the one failure is pre-existing.
iOS app builds for the simulator.
Note: SignalSectionRankerTest.coldStart_emitsOnlyAllProducts failed once during
this work and passed on three consecutive full re-runs. Not caused by these
changes, but it is a second flaky test alongside the iOS race demo and worth
looking at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ilistic Both flaky tests traced back to one cause: a test that hoped a thread race would happen, and burned enough CPU trying that it destabilised its neighbours. HomeStateMachineRaceTest.naive_canLoseBaseline asserted that a naive read-modify-write *does* lose an update, and tried to make that happen by firing 5,000 concurrent dispatches across 20 trials. That is probabilistic by construction on the JVM, and on Kotlin/Native it never occurred at all — Dispatchers.Default there never produced the interleaving, so the test failed on every single iOS run rather than occasionally. The hazard is now proven deterministically. NaiveStateMachine takes a betweenReadAndWrite hook — the exact gap where a lost update happens — and the test dispatches the competing event into it. No threads, no luck, identical result on every platform. The CAS side keeps a real concurrent storm, because "never loses it" is the invariant that matters and contention is the only honest way to check it; that direction cannot flake, since the invariant holds whether or not the interleaving occurs. The second flake follows from the first. SignalSectionRankerTest is fully deterministic — fixed clock, canned records, no timing — so it had no way to fail on its own. What it did have was a neighbour running 100,000 dispatches on Dispatchers.Default in the same JVM, which pins every core; a runTest that gets starved long enough trips its 60s timeout. The storm is now 500 dispatches, and the race test's total runtime dropped from minutes to 30ms. Verified: five consecutive clean full runs on the Android host target and three on iOS. 235 shared host tests and 156 Kotlin/Native iOS tests now pass with zero failures — the iOS suite is fully green for the first time, where it had been reporting 1 failure every run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ks 3, 5a, 6) Closes the four Phase-1 core gaps an audit of the tree found: no CI, no decision log, no module boundary enforcement, no flags seam. :core:flags is its own Gradle module, not a package in :shared. The point of the seam is gating the engine tiers and experimental sections from outside them, and a flag type that lives inside :shared can be read by the very code it is meant to gate. Flags are a closed enum so a typo is a compile error and the set is enumerable — which is what lets a debug surface list them without a registry to keep in sync. The flags gate real behaviour, verified: turning ON_DEVICE_MODELS off forces every tier to null so the resolver falls to the rule-table parser, which is the plan's stated reason for wanting the seam — showing the fallback live without a rebuild or uninstalling a model. DOWNLOADABLE_MODEL gates Tier B separately, because a ~400 MB download deserves its own switch. SIGNAL_RANKING off makes the ranker emit the cold-start shape, which is how you demonstrate the implicit half was doing work rather than asserting it. The decision log is Block 6's observability. The privacy thesis forbids an analytics backend, so "see it in prod" has to mean reconstruct it from the device. explainMerge derives the explanation from the same inputs as merge, so the two cannot disagree — an explanation computed from a different source would eventually lie, and a decision log that lies is worse than none. It records only which contributor won and which bounded-vocabulary value resulted: no product data, no user text. DECAYED_TO_INFERRED is its own source because a layout that changes with no user action is the case that most needs explaining, and logging it as plain "inferred" would leave it looking spontaneous. In-memory and bounded at 50, since a decision log that outlived the process would be a behavioural record on disk needing the same retention argument the signal store had to make. The boundary tests (Block 5a) read the source tree rather than a JVM classpath, because Konsist and ArchUnit only see the Android compilation and the rules that matter here are about commonMain and the platform source sets. Seven rules, one of which found something on its first run: the privacy rule flagged App.kt, which turned out to be a legitimate composition root handing the pooled HTTP client to Coil — the allowlist was incomplete, not the code. The rules now pin that only the catalog, the model downloader and the two composition roots may reach the network; that SQLDelight types stay in the signal package; that the domain layer has no framework imports; that platform engines never name NlResolution (ADR 0006); that vendor SDKs stay out of shared code; and that the .sq file exposes no UPDATE and only one bounded DELETE. CI runs two jobs. The JVM job is split from the iOS one so the fast feedback most commits need does not wait on a macOS runner queue, and it assembles the APK as well as running tests — a green suite over a module that no longer links is not a green build. The macOS job builds Kotlin/Native *and* the Xcode project, because a Gradle-only iOS job would never compile FoundationModelsBridge.swift, which is where the Tier-A error mapping lives. All five task paths were verified to resolve before committing. Snapshot correction: my earlier audit reported zero recorded snapshots. That was a bad grep — seven were committed and tracked. What was genuinely missing is coverage of the columns dimension added this week, so three cases are added (3 columns, 4 columns, and the compact-grid-no-prices combination a real command produces). Column count is the one dimension whose bug mode is purely visual — a wrong count still renders every product, just at an unusable width — so it is exactly what a snapshot catches and a unit test cannot. CI now passes -Proborazzi.test.verify=true, without which the snapshots run, pass and check nothing. Confirmed by corrupting a golden: 3 failures with it wrong, green when restored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…regeneration The plan calls a self-recomposing layout "an accessibility hazard" and asks for three things from Block 2 onward: stable semantics across recompositions, an announcement when the layout changes, and preserved focus. Only the announcement existed. Item keys included the section index, so any reorder changed every key — Compose destroyed and recreated every node rather than moving it, and focus went with them. Keys are now (role, product id), which survives a reorder, so a keyed item is moved rather than replaced. That is the mechanism focus preservation actually depends on; without it a FocusRequester would have nothing left to restore to. A FocusRequester on the collection restores focus into the list rather than to whatever happened to be next in traversal order. It re-requests only when the products change identity, not on every re-merge: the merge is time-dependent and runs constantly, and yanking the cursor back on a presentation-only change (grid to list) mid-read would be worse than doing nothing. Four tests assert the mechanisms a screen reader depends on, since a screen reader itself cannot be driven from a unit test: that a live region exists, that its description is derived from the spec rather than hardcoded (a fixed string would pass a weaker test and then lie forever), that section order reaches the announcement, and that products survive a reorder uniquely identifiable. The reorder test initially failed because the fixture put identical products in both sections, so every product matched twice — a fixture flaw, not a code bug. Sections now hold distinct products, as a real spec does: "Recommended" is a subset, not a duplicate. Also drops focusGroup, which does not exist in Compose Multiplatform's common surface — the requester alone achieves the same restore-into-the-list behaviour. 20 androidApp tests pass; snapshots re-verified unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The privacy differentiator had two halves and only one was implemented: nothing is uploaded (enforced by an architecture test), but the signal log was a plain SQLite file. Anyone with read access to app-private storage — a rooted device, an adb backup, a stolen unlocked phone — could open it and read the full behavioural history. "Encrypted at rest" appeared in the plan and in no code. Android uses SQLCipher with a two-level key, because SQLCipher needs the passphrase in the clear at open time and the Keystore never exports its keys: a 256-bit SecureRandom passphrase, wrapped by an AES-256-GCM Keystore key that never leaves secure hardware. The wrapped blob is written to a temp file and renamed, so a kill mid-write cannot leave a truncated key — indistinguishable from a corrupt one, and it would orphan the whole log. iOS uses the OS instead. SQLDelight's native driver links the system SQLite, which has no SQLCipher build, and Data Protection already provides the same guarantee against the same threat: a key derived from the passcode and hardware UID. So the platforms differ in mechanism and agree in guarantee, and IosOsProtectedDatabase is named distinctly from UnencryptedDatabase so the two situations stay distinguishable — one is encrypted by the OS, the other is not encrypted at all. Both platforms skip user authentication on the key, and ADR 0007 records that as a real weakening rather than taking it quietly: the ranker reads signals during launch, so requiring authentication would mean the app cannot render its own home screen until the user unlocks something. The ADR states the threat model first, because an unstated one is how security theatre gets shipped. In scope: offline access to storage. Explicitly out of scope: an attacker already executing as the app, against whom no client-side key can help, since the app must be able to decrypt its own store to function. Verified on an emulator, which matters because an untested crypto path is a claim rather than a feature. The key blob is exactly 60 bytes (12-byte IV + 32-byte key + 16-byte GCM tag). The database header is random bytes rather than "SQLite format 3"; sqlite3 reports "file is not a database"; a strings scan finds zero occurrences of the table name or any event type, all of which were plainly readable in this same store yesterday. Round-trip was verified across processes, which is the part that could silently break: a product opened in one process appeared in a Recommended section after a full force-stop and restart, so the log was written encrypted, decrypted by a new process, and ranked. Degradation is loud rather than silent. If SQLCipher's native library cannot load the app falls back to a plain driver and logs a warning, because the alternative is an app that cannot start — but a store that is quietly unencrypted while the app claims otherwise is worse than one that is openly plain. No migration, and none needed: the filename changed because an encrypted file cannot be opened by the plain driver, and the log is a bounded rebuildable cache, so starting fresh costs one cold-start default layout rather than user data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pters The plan calls AppFunctions "the fourth input channel onto the same Block-4 contract" and asks for one shared typed function surface with thin per-platform adapters: build once, N callers. This is that. PreferenceCommands in commonMain is the surface. Every setter routes through the same validate() the chatbox and the model tiers use, because the OS assistant is not our code and gets no more trust than a downloaded model — an assistant saying "twelve columns" is refused, not silently given four. 18 tests cover it, which matters because neither platform channel can be exercised without an OS assistant, so the shared half is the only part that can be proven. Typed setters only. No applyPreference(freeText) on either channel: the assistant has already done the natural-language work, so feeding its text back through our parser would strictly degrade the result and widen the cloud-disclosure surface for nothing (ADR 0006). It also means the channel needs no model, which is the only reason it can work on Android at all — AICore refuses inference outside the foreground, so a Tier-A-backed OS channel would fail by construction. OS commands get a 7-day TTL against the chatbox's 24 hours. Speaking to the system assistant is a more deliberate act than typing in a box, and having it evaporate on the chatbox's schedule would read as the app forgetting. ADR 0006 flagged this as its weakest default; it is settled here, and a test pins that the override still decays — a permanent one would break ADR 0005. Android: @appfunction functions whose KDoc is the schema, over androidx.appfunctions alpha10. Verified rather than assumed — KSP generates the inventory, invoker and id constants, and the KDoc really is extracted into the aggregated function registry the assistant reads. On an API 37 emulator the OS registers our PlatformAppFunctionService against the android.app.appfunctions.AppFunctionService action, guarded by BIND_APP_FUNCTION_SERVICE so only the system can bind it, and the framework's own IAppFunctionManager is present. The Application supplies the enclosing-class factory, without which the library would instantiate the functions with a no-arg constructor and the assistant would write to a different overlay than the app reads. iOS: App Intents in Swift over the same shared surface, using AppEnum parameters so Siri resolves phrasing against a closed set and an out-of-vocabulary value never reaches our code — the bounded-vocabulary principle enforced one layer earlier than the parser does it. openAppWhenRun is false, since reshaping the store without opening it is the entire point. Two AppShortcuts so the intents are reachable without the user building a shortcut first. Toolchain bumps were required, not optional: appfunctions alpha10 demands compileSdk 37 and AGP 9.1.0+, AGP 9.1.1 demands Gradle 9.3.1. The wrapper pins a distribution checksum, so that was updated too — against the SHA-256 published at services.gradle.org, which matched the downloaded artifact exactly, rather than trusting the file's own hash. Also adds the :core:flags module dependency wiring and PreferenceCommands to the DI graph, app-scoped because an assistant invokes it with no screen alive. 235+ shared host tests, 20 androidApp tests, 6 flags tests, 156 iOS tests, all green. Both apps build. What cannot be verified here: actually invoking either channel needs an OS assistant, which no emulator or simulator ships — the same wall as Gemini Nano. Registration is verified; execution is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…items) Two stretch items from the plan, plus a permission allowlist. Baseline profile. A profile is only worth generating if the startup path is expensive, and this app's genuinely is: cold start does not inflate a static screen, it opens an encrypted SQLCipher database, reads the signal log, runs the ranker, merges the spec and renders a layout computed at runtime. All of that is interpreted on the first frame without a profile. So the generator drives the *generative* path rather than just startActivityAndWait: it waits for real products (not a fixed sleep, which would be either flaky on a slow emulator or mostly idle on a fast one), then sends a chatbox command so the parser, validation, overlay write and re-merge are all recorded, then flings the list to capture the lazy-item path. Verified it captured what was intended rather than framework noise — 31,990 rules including 485 for the NL layer, 217 for SQLCipher's open path, 195 for the signal store, 73 for the parser and 23 for the ranker. The compiled profile is packaged into the release APK at assets/dexopt/baseline.prof, with profileinstaller to apply it; without that dependency the profile ships and is never used, which is the quiet way this feature ends up doing nothing. useConnectedDevices is set explicitly because the plugin otherwise refuses an emulator. An emulator is not a legitimate place to *measure* performance, but it is a legitimate place to record which methods a path touches — which is all a profile is. Stated in the build file rather than silently overridden. Proactive recomposition. The app offers a layout change instead of making one. That distinction is the feature: applying it unasked would both be the filter-bubble failure the plan warns about and move things under a user who did not ask for movement. Every nudge carries an exact undo — the order that was rendered, not a guess at a default — because a nudge you cannot walk back is an unannounced change with extra steps. The detector reads the inferred baseline against what is rendered rather than re-reading signals, so it cannot disagree with the ranker. Presentation dimensions are deliberately excluded: proposing a comfortable list to someone who asked for a compact grid is second-guessing an explicit instruction, not surfacing new information. A one-position shift is treated as arithmetic drift rather than a finding, since the ranker's scores move constantly as recency decays and nudging on every reshuffle would make the app chatty enough to ignore. Also fixes an unnecessary-safe-call warning left by the retryAfterMs change, and adds a .claude/settings.json allowlist for the repetitive read-only commands this work leans on (archive and class inspection, adb device/log reads, simctl list, awk, curl fetches) plus the named Gradle verification tasks. Deliberately no wildcard for ./gradlew, adb shell, xcrun simctl spawn, or python3 — each is arbitrary code execution. 284 shared host tests, 20 androidApp tests, 6 flags tests, 156 iOS tests, all green. Debug and release both assemble. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three rendered diagrams the README embeds: the before/after layout change, the two-contributor architecture, and the measured-latency chart.
The gap the write-up admitted publicly: the ranker consumed exactly one signal type while the app was already recording dwell time and throwing it away. Half the thesis was engineered and the other half was a scoring function written early and never revisited. Scoring moves out of the ranker into ProductScorer. The ranker's job is merchandising sections; deciding what matters is a different job, and a three-input formula is the part most likely to change, so it needs its own tests. Dwell is a multiplier on opens rather than an added term. Every dwell follows an open, so summing both counts one visit twice — and would rank "opened twice, bounced both times" above "opened once, read for a minute", which is backwards. A test pins that ordering directly. Raw duration is unbounded, so it saturates: min(duration / 30s, 1) mapped to a 1.0-2.0 multiplier. A thirty-second read is worth double a bounce, and a ten-minute detail screen left open in the background scores the same as a thirty-second read rather than dominating everything. The longest single dwell counts, not the sum, because many brief visits are not one engaged one. Time-of-day is a mild 1.0-1.15 nudge by circular distance between an event's hour and now's. Continuous rather than bucketed — buckets put a cliff edge at the boundary, so 11:59 and 12:01 would score very differently for no defensible reason. A test pins that three extra opens still beat a time-of-day match, so a weak signal cannot overpower a strong one. Dormant signal types stay excluded. AddedToCart and ProductImpression have no emitter, so scoring on them would be scoring on data that never arrives. The score carries its reasoning rather than being a bare Double — opens, longest dwell, the multiplier, and whether the dirty-data path was taken. "Recommended contains X" is otherwise unexplainable, which is exactly what the decision log exists to prevent. Exposed but deliberately not wired into the decision log yet; DecisionSource models the merge, not the ranker, and wiring it has its own decisions. Two defensive paths, both tested: a dwell with no matching open is still scored and flagged, since a killed process can produce one and dropping real signal is worse than counting it weakly; and an undecodable payload degrades to a neutral multiplier rather than taking the ranking down. All three original ranker tests pass unmodified, which was the point — widening the formula must not regress "recommends by recency-weighted frequency". 19 new scorer tests alongside. One test fixture bug found and fixed on the way: the fixed "now" was truncated to an hour boundary rather than midnight, so it landed on an arbitrary hour and the time-of-day assertion failed. The production code was correct. 303 shared host tests, 20 androidApp, 6 flags, 156 iOS — all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README was still the default KMP scaffold — Goals/Non-Goals plus JetBrains' getting-started text. It described none of the work. Leads with the thesis and a demo image, then the engineering: the merge function that makes decay emergent (and the pipeline snapshot-testable), the shared/platform split with its one leftover expect/actual, the three on-device tiers with measured latency, and why the rule table runs before the model. Includes the findings that only measurement produced — the ambiguous BUSY code, foreground-only inference killing a planned channel, a timeout wrong by 6x, the schema sentinel that made both vendors' models echo, and a SIGSEGV walking past the entire error taxonomy — plus a Status section that names the four things still unverified. Counts verified against the tree rather than recalled: 70 commonMain vs 11 platform files, one expect/actual pair, and per-suite test numbers with a note that commonTest overlaps across both shared targets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
The .part bytes already survived process death and already resumed via a Range header. What did not survive was any record that a transfer was outstanding, so the next launch rendered an idle row over a 300 MB partial and the user had to notice and re-tap. The missing fact is not progress, it is *why* the transfer stopped. A partial on disk looks identical whether the user deliberately paused it or the OS killed the app mid-download, and those two want opposite behaviour. ModelDownloadRecord carries exactly that one bit, plus the metered consent, since consent is per-attempt and a resume is a new attempt. ACTIVE resumes: a dropped connection and a killed process are nobody's decision. USER_PAUSED never does; resuming what someone deliberately stopped is a bug. The automatic resume is announced rather than silent and can be refused, and refusing writes USER_PAUSED so the decline outlives the process instead of lasting until the next launch. Refusing keeps the bytes — declining must not throw away progress. Disk wins over the record on every question it can answer: a record naming an installed model is stale, and one whose .part file is gone has nothing to resume. Both are cleared rather than allowed to drive a bogus offer. A .part with no record predates this feature and reads as USER_PAUSED, because no consent evidence exists and offering is the only defensible read. One automatic attempt per process, shared by both triggers, so a permanently failing URL cannot loop unattended over a 400 MB transfer. Not included: background execution. Continuing a transfer after the process dies needs WorkManager and a background URLSession, which is a different transfer implementation per platform and would fork the shared Ktor downloader. That belongs with its own ADR. deleteModel() and cancelDownload() are now asynchronous, because clearing the record is a persisted write. The delete test awaits the resulting state rather than advanceUntilIdle(), which does not cover work the scheduler never sees — the same reason awaitInstalled() already existed. Storage is the shared DataStore alongside the engine choice, so nothing platform-specific was needed on either side. Back-compatible per ADR 0003: absent keys mean "nothing outstanding", and an unrecognised intent reads as USER_PAUSED, failing toward asking rather than toward spending. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
Adds the Tier-B download design — resumable .part transfers, up-front space and metered checks, hash-then-atomic-move — and the part worth reading: what a .part file cannot tell you. A partial looks identical whether the user paused it or the OS killed the app, so the persisted record holds intent rather than progress, and the two intents get opposite treatment. Test count corrected to the measured 332 on the shared JVM target, and background transfer named in Known gaps rather than left implied. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
The LinkedIn series and its assets are personal write-up material, not part of the codebase, and the video alone was 300 KB of binary the repo had no reason to carry. They stay on disk and stay usable — they are just no longer tracked. The three diagrams the README embeds are the deliberate exception: the rendered README has to work on GitHub, so those stay tracked and the ignore rule negates them explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
The study guide moves out of the repo, so linking to it from the README would leave seven dead links for anyone browsing on GitHub. The ADR index carries the same reasoning and stays, with a pointer to the two that matter most for the on-device work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
Same call as the write-up material: it is reading notes about the codebase rather than part of it. Stays on disk and stays usable, just untracked. The README's Documentation section was removed in the previous commit so nothing links into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
itstanany
force-pushed
the
feat/v0.11.0-generative-detail
branch
from
July 31, 2026 18:15
026725b to
d19fc21
Compare
Ranking over opens, dwell and time-of-day shipped but the Complete list still read as though only the contract and engine work were done. An omission rather than an error, but the status section is the part a reviewer trusts, so it has to be exhaustive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
… app as a POC Two things. **The test.** The OS-assistant channel was registered but never executed, because the caller is normally an assistant and no emulator ships one. It turns out AppFunctionManager.executeAppFunction needs the privileged EXECUTE_APP_FUNCTIONS permission only for *other* packages — an app may invoke its own — so the dispatch path is reachable from an instrumented test after all. Eight cases: one per function plus two refusal paths. Parameters are built from metadata the runtime itself reports, so passing would prove KSP extracted the KDoc schema and AppSearch indexed it. Each asserts the overlay actually changed, not just that a response came back — a function returning "Done." and writing nowhere would pass a response-only check. It currently FAILS on the API 37 emulator, and is committed failing rather than weakened. All eight stop at the same line: the functions are never discoverable via searchAppFunctions, despite getInstance() reporting the feature supported, both platform services being present, and KSP emitting all six ids. Polls for 30s, so not a plain indexing race. Whether that is an emulator limitation or a wrong query shape is unresolved; worth retrying on a physical device. Not in CI — no emulator on the runners. Also fixes TierBOnDeviceTest, which still called androidModelDownloader with the pre-record-store signature. androidTest is not built by the regular check tasks, so that break survived the download-persistence work. **The framing.** The README led as though this were a store. It is a proof-of-concept for on-device generative UI, and saying so up front makes every scope decision in it legible rather than looking like omissions. The gap list moves out of the repo; the README now states only that Phase 2 is hardening this into an actual e-commerce app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
storeWrittenBeforeThisFeature_loadsCleanly failed on CI with IllegalStateException at FileStorage.kt:52 — DataStore refusing a second instance over a file the first had not released. The test opens a store, closes it, then reopens the same file, because reading back through the same instance is served from cache and proves nothing about what reached disk. close() cancelled the scope but did not wait for it, and cancel() returns before teardown finishes. On a fast local machine that window closes on its own; on CI it does not. Now joins the job. The race was mine, not the production code's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes the plan's Phase 1. 18 commits, 164 files.
What lands
The contract. A generative detail screen driven by the same
UiSpecas home, sharing one decayingPreferenceOverlay. Decay is emergent — an override carriesappliedAtMs+ttlMsandmerge(baseline, overlay, nowMs)asks whether it is still live. No timer, no sweep, no expiry job, and the whole pipeline stays a pure function.Natural language, on-device, three tiers. A parser-first resolver escalating to Gemini Nano / Apple Foundation Models (Tier A) and a downloaded Qwen3-0.6B via llama.cpp (Tier B). Parser first is deliberate: AICore's per-app inference quota and daily battery quota are both unpublished, and you cannot design against a number that does not exist. Vendor errors collapse into a five-category
ModelFailuretaxonomy where onlyretryablediffers by platform.Four input channels, one typed surface. Implicit signals, the in-app chatbox, Android AppFunctions and iOS App Intents all reach the same contract. The OS channels deliberately use typed setters and no model, because Android hard-blocks background inference.
Encryption at rest (ADR-0007). SQLCipher with a Keystore-wrapped passphrase on Android; Data Protection on iOS, since SQLDelight's native driver has no SQLCipher build and the OS gives the same guarantee against the same threat. The threat model states what is out of scope rather than implying total coverage.
Ranking widened. Opens alone became opens + dwell + time-of-day. Dwell is a multiplier rather than an addend — every dwell follows an open, so summing counts one visit twice.
Plus: engine picker across three surfaces, accessibility (focus preservation + stable identity across regeneration), a flags module with a CI gate, a baseline profile, and the study docs / ADRs 0006–0007 / README rewrite.
Findings that changed the code
Documentation alone would have shipped these broken:
BUSY = 9is documented as either "out of quota" or "background use not allowed" — two states, opposite responses, one code. Resolved using the app's own foreground state.BACKGROUND_USE_BLOCKED = 30). This killed a planned Nano-backed assistant channel, since an assistant invokes you with no screen alive."unchanged"schema sentinel made Apple's model restate current state in every field, so "play some jazz" returned a full layout patch. Qwen3-0.6B echoed identically — not a vendor quirk.llama_grammar_accept_tokenon three devices. A SIGSEGV cannot be caught from Kotlin, so the taxonomy is powerless and the mitigation is avoidance.Verification
:sharedruns 303 tests on the JVM target and 209 on Kotlin/Native (commonTestoverlaps both), plus 20 Compose interaction tests, 6 in:core:flags, 10 Roborazzi snapshots and 7 architecture rules that read the source tree — including the privacy thesis as an executable test.Encryption verified end to end rather than asserted: random DB header,
sqlite3reports "file is not a database", zero plaintext markers understrings, and a product opened in one process reappeared in Recommended after a full restart.Known gaps
dumpsys; execution needs a real assistant.🤖 Generated with Claude Code
https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq