. The app's only HTTP path.
—3-lens verdict in deterministic Kotlin, no model call. ≥2-of-3 → CONSENSUS; one lens only → CANDIDATE; disagreement → AMBIGUOUS; Skeptical conflict over agreement → CONSENSUS_WITH_CONFLICT.
3. Engineering paper trail. is preserved in GitHub.
4. Test discipline. 1,200+ JVM @Test methods across 110+ files; 12 instrumented *SmokeTest.kt runs on the Galaxy S24 Ultra; gates ktlint / detekt / secret-scan / actionlint pre-commit and the full build + test pre-push; CI adds Sonar, Kover, CodeQL, commitlint, and verifyNoTelemetry.
How the lenses differ 🪞
Three lens prompts define HOW to read; five surface specs define WHAT to extract. The composer joins them at runtime, the worker iterates, the resolver decides. The architecture lives in the text below.
Literal ():
## Lens: Inferential
Apply a charitable reading. Go beyond explicit words to what the text most plausibly means for this person's cognitive and behavioral state.
Rules:
- Read for pattern and meaning, not just surface vocabulary. What is this person experiencing?
- Decision loops: when the user describes returning to the same choice with new framing and no resolution, capture it as a tag.
- Avoidance sequences: when the user approaches a task and retreats, or states an intention then does something else, tag both the avoidance and the specific task.
- User-coined idioms carry their meaning: tag the user's own phrasing verbatim and let it stand for the state it names.
Inference limits:
- Do not infer causes or motivations.
- Do not infer emotional states the user did not name.
- Retrieved history can corroborate inferences but cannot supply content that isn't anchored in the current entry.
Skeptical ():
## Surface: State
Captures the user's cognitive and energy state.
- The state word the user uses for their physical or cognitive condition (drained, crashed, foggy, flat, wired). Use the user's exact word, not clinical paraphrase. It must describe the person, not the event — discard manner qualifiers and effects.
- A before/after transition between two distinct states.
What goes in the schema:
- Append the state word to `tags` as a short lowercase kebab-case token. Single root word only — never a clause. Omit when the entry names no such condition.
Backed by (73% meaningful divergence on-device against a ≥50% bar).
How I Used Gemma 4
Gemma 4 E4B does the heavy lifting. EmbeddingGemma 300M is the tone-word clustering helper that earns its 200 MB when the user's vocabulary drifts. They do not share a job, because that is how you avoid building soup with a logo on it.
Why E4B 🧭
E4B is the path I validated end-to-end: native audio in, local structured extraction, and enough quality for the 3-lens resolver to be worth the wait. The 31B Dense and 26B MoE are the wrong hardware story for a phone; the real choice was E2B vs E4B.
Requirement |
E2B |
E4B |
|---|
Native audio in (no SpeechRecognizer) |
✅ |
✅ |
Foreground answer fast enough that the app still feels usable |
✅ (lighter, faster) |
✅ |
Structured background extraction quality floor under 3-lens load |
E4B was the validated path; E2B traded down quality/headroom for size/speed |
Holds, but the prompt stack was already trimmed once to land it |
E2B is lighter and probably wins on raw foreground latency. The reason it did not get its own bake-off is that the E4B run was already tight: the 3-lens prompt stack only landed after I scaled the guidance back once, and the product still needed native audio, structured extraction, and enough reasoning headroom for the resolver to matter. A smaller model would have meant another prompt cut against a quality floor that was already the hard part. Cold-start cost is uglier than I'd like, but I chose the path that survived the on-device receipts.
A cloud-class model would have made the latency story nicer and taken the user's voice entry somewhere the entire product says it will not go. E4B keeps the sensitive part on the phone, with no outbound path from the app process during normal use.
Native audio, no SpeechRecognizer 🛰️
The foreground call is the only one the user waits on directly. Audio goes in via LiteRtLmEngine.streamMessageContents; transcription and the persona follow-up come back together as a single streaming {transcription, follow_up} response—so the user waits once instead of through two consecutive spinners while the model gets philosophical in a broom closet. I tried splitting it in two on-device; didn't help. Back together it stays.
Behind the foreground sits the rest of the inference work: 3 background lens calls per entry (Literal / Inferential / Skeptical, sequential per ADR-008's single-session ceiling), 1 background pattern analysis pass every 3 completed entries, and a best-effort Gemma wording call when a temporal-relative pattern lands. All background, all queued, all invisible to the user.
The follow-up is single-turn by design in v1. Cross-entry intelligence lives in pattern detection, deterministic prior-entry candidates, tone-word clustering, and stored evidence—exactly where it can be audited instead of hand-waved.
Three lenses, one resolver 🪞
Once the entry is saved, the background pass runs three independent Gemma reads over the same transcript:
- Literal
- Inferential
- Skeptical
Each pass extracts across five surfaces:
- Behavior
- State
- Vocabulary
- Commitment
- Recurrence
Recurrence is the one surface the model doesn't decide alone—the app builds a deterministic candidate from prior entries first, then asks the model to judge whether the current entry actually repeats the candidate or just happens to land at the same clock time. The model never emits a pattern ID; the app owns that mapping. The Skeptical lens still adds unsupported-recurrence flags when the user signals "again" with no corroborating history.
The resolver (see §Code) compares the three reads before anything is committed, and surfaces conflict as conflict instead of guessing with better typography.
Step |
Purpose |
Example |
|---|
User entry |
Input |
"Crashed at noon. Fine before — wired even. Then gone." |
Literal |
Surface words only. |
Tags: crashed, noon, wired Vocabulary: crashed
|
Inferential |
Adds the pattern read. |
Tags: crashed, noon, wired, post-noon-crash, energy-flip Vocabulary: depleted. |
Skeptical |
Flags inconsistencies. |
Tags: crashed, noon, wired Vocabulary: crashed Flag: vocabulary-contradiction
|
Resolver |
Reconcile differences. |
Vocabulary lands CONSENSUS_WITH_CONFLICT on crashed. Literal and Skeptical agree, but Skeptical's vocabulary-contradiction flag elevates the verdict above plain CONSENSUS. |
The multi-lens approach only earns its keep if the lenses actually produce different reads. Three identical responses would have been useless and three times the wait.
So I built a test for that. The bar: at least 50% of test entries showing meaningful field-level divergence between the three reads. The : 12/12 entries succeed, 3/3 lenses parse on first attempt, zero retries, AUDIT dropped 8/12 → 4/12, and six distinct archetypes are in play (up from near-total audit). Lens disagreement is real—wired-third-night resolves AUDIT on lens votes tunnel-exit/audit/audit; tuesday-stalled resolves AFTERMATH on aftermath/aftermath/audit—which is exactly the disagreement the convergence math was built to resolve. Mean latency landed ~38s per entry (thermal on a back-to-back GPU session; the same path ran 21.2s cold on 2026-05-23).
I was wrong about being wrong 🪨
What's next 🎟️
v1 ships narrow on purpose. Two deferrals carry the headline weight.
Tighten the archetype language — moving template_label off the deterministic TemplateLabeler to a model-emitted, majority-resolved pick landed in v1. The latest STT-H run parsed 12/12 entries with zero retries, dropped AUDIT from 8/12 to 4/12, and surfaced six distinct archetypes. The next pass is prompt polish for borderline entries, not fixing a broken picker.
Agentic tool-calling — letting E4B call into the pattern-detection layer as functions (resolver-as-tool-call instead of deterministic Kotlin). External benchmarks land local function-calling around 75% reliability; the shipped path parses 12/12 lens calls on first attempt with deterministic Kotlin doing the convergence math. Not a swap until the tool-calling floor rises.
What helped 🪙
Planning ran through Claude Cowork and Codex Chat—messy thinking before any of it became a story.
In the codebase: Claude Code as primary, Codex as the secondary and reviewer, GitHub Copilot keeping things tidy on the way to merge. CI in GitHub Actions ran CodeQL and the verifyNoTelemetry privacy gate on every PR. Sonar ran the whole way (always free).
For the Android knowledge I didn't have, I sourced existing skills where they existed and wrote new ones where they didn't. The Lefthook pre-push gate enforced 1,200+ tests on every push—slowed things down, caught a ton of errors before they made it into the codebase. A trade I'd make again.
ADRs kept up with my thinking over time. Stories kept the build on schedule—mostly...
--
Closing 🎬
I still don't know why I do half the things I do. With Vestige I just don't get to pretend I haven't done them.
Your brain drops things. Vestige does not.
SOCIAL SHARE CARD GENERATOR