A privacy-first, on-device health assistant for iOS. It turns fragmented patient-portal records into calm, source-cited reminders for medications, physical therapy, and diet.
Gemma 4 E2B runs on the device itself, so nothing has to be uploaded to answer a question — put the phone in airplane mode and it still works. Records live in an encrypted local vault; the only network access in the app is fetching the model's own weights.
The hardest requirement in DESIGN_DOC.md is not the FHIR import or the notifications — it is
this pair of rules:
Every reminder proposal must cite its source. If medication timing or food instructions are absent, the assistant should not invent clinical advice.
A language model cannot be trusted to honor those by instruction alone, so Remli does not ask it to:
┌──────────────────────────────────────┐
FHIR ─► │ DETERMINISTIC CORE (trusted) │ ─► ReminderProposal
│ normalizer → proposal engine │ every field traced to a citation
└──────────────────────────────────────┘
│ structured, already-decided facts
▼
┌──────────────────────────────────────┐
│ GEMMA (untrusted, cosmetic) │ ─► narration
│ phrases it warmly, answers "why?" │
└──────────────────────────────────────┘
│
▼ SafetyGuard — reject → deterministic template
Gemma never decides a dose, a frequency, or a food instruction. Those are computed from FHIR
fields, each carrying a SourceCitation with the verbatim source text. Gemma phrases the result
and may choose when a reminder fires — labelled as its own suggestion, never as the chart's.
Gemma on an iPhone 15, quoting the record verbatim and then saying what it does not say. On-device inference, a citation, and a refusal to invent a time — in one reply.
Full reasoning: ARCHITECTURE.md · Scope decisions: ROADMAP.md
| Reminders | Portal |
|---|---|
![]() |
![]() |
| Reminders that scheduled themselves, each traceable to its source. Gemma spaced the times around each other. | Where records come from, and what is honestly not connected. |
The amber dot is adherence status: reminders have gone unanswered. It means "worth a look", never "you missed three doses" — we know what was tapped and what was silence, and silence is ambiguous.
Note the aeroplane in the status bar — read, scheduled and delivered with no network.
The body quotes the record verbatim, clumsy phrasing and all — "Every four to six hours (qualifier value)" — because tidying it up is how "with meals" quietly disappears somewhere else.
Three actions, not five: past three or four, people stop reading them.
The same reminder, locked: "Remli — It's time for one of your reminders. Tap to see it." No medication, no clinic, not even the category.
A medication name implies a condition, and a lock screen is readable by anyone nearby, several times a day, for years. So the detail is gated rather than withheld: with previews set to When Unlocked — the Face ID default — it appears the instant the owner looks. A user who sets previews to Always will see the name; that is their setting, and the app says so on its preview screen.
Running on a physical iPhone 15 with Gemma 4 E2B on the GPU backend.
| Subsystem | State | How it was verified |
|---|---|---|
| Domain model + provenance | ✅ | Provenance is unforgeable by construction |
| FHIR decoding (R4 subset) | ✅ | Real Synthea bundles + authored portal export |
| Encrypted vault | ✅ | AES-GCM, Keychain ThisDeviceOnly, excluded from backup |
| Normalizer + proposal engine | ✅ | Executed against both bundles via a CLI harness |
| Nutrition (diet, allergies, meals) | ✅ | NutritionOrder + care-plan diet entries |
| Tiered activation | ✅ | Unambiguous reminders self-schedule; only conflicts wait |
| Gemma 4 E2B via LiteRT-LM | ✅ | Running on device, GPU backend, ~5 s engine load |
| SafetyGuard | ✅ | 29 adversarial cases, all passing |
| Gemma-chosen reminder times | ✅ | Schema-validated, deterministic fallback |
| Notifications | ✅ | Scheduled and test sends both delivering on device, with three actions |
| Adherence ledger + status light | ✅ | Green/amber/red in-app, no streak |
| Interruption budget | ✅ | One check-in per open, one per 3 days |
| SMART on FHIR (real MyChart) | ⬜ | V2 — see ROADMAP |
open ios/Remli.xcodeprojiOS 17+, Xcode 16+.
Build for arm64 only. LiteRT-LM's xcframework ships
ios-arm64andios-arm64-simulatorslices with no x86_64. A universal simulator build fails at link time.
| Environment | Import | Engine | Notifications | Gemma |
|---|---|---|---|---|
| iOS Simulator | real | real | real | scripted fallback |
| iPhone (A16+) | real | real | real | real Gemma 4 E2B |
LiteRT-LM is vendored as a local package in ios/ThirdParty/LiteRT-LM, whose manifest drops
upstream's macOS binary target so an iOS build does not fetch an unused 44.6 MB artifact.
The weights are not in the repo. Get gemma-4-E2B-it.litertlm (2.41 GB) from
litert-community/gemma-4-E2B-it-litert-lm.
GemmaModelManager looks in Application Support, then the app's Documents directory, then
downloads. To side-load it rather than wait on Wi-Fi:
xcrun devicectl device copy to --device <UDID> \
--domain-type appDataContainer --domain-identifier app.remli.Remli \
--source ~/RemliModels/gemma-4-E2B-it.litertlm \
--destination Documents/gemma-4-E2B-it.litertlmGemma 4 E2B uses Google's mixed 2/4/8-bit mobile quantization derived from QAT checkpoints: ~607 MB peak on CPU, ~1.45 GB on GPU. Inference cannot run in the Simulator (LiteRT-LM #2504) — the app detects this and labels its narration "Scripted" rather than passing templates off as model output.
No real patient data is in this repository, and none should be added.
synthea-glover.json— trimmed from MITRE Synthea. Fully synthetic.portal-export-demo.json— hand-authored, taggeddata-origin: authored-demo, which the app surfaces in the UI so demo data is never mistaken for a chart pull.
The authored bundle exists because Synthea's MedicationRequest.dosageInstruction carries only
timing.repeat — no instruction text, no food instruction, no time of day. That gap is not a
problem to work around; it is the product's central case:
"Your chart says this is taken once daily. It does not say what time of day."
Real Synthea data drives that path. The authored bundle adds verbatim dosage text, an ACM timing
code, PT activities with reps and equipment, a NutritionOrder with excluded foods, a food
allergy, and a deliberate cross-source food-instruction conflict.
Two Foundation-only harnesses compile the core outside the app and run it against real data:
- Proposal engine — executed against both bundles. Confirms as-needed medications produce no schedule, missing time-of-day is never invented, and a cross-source conflict cites both organizations without picking a winner.
- SafetyGuard — 29 adversarial cases. Rejects the design doc's own unsafe example, invented doses, times credited to the chart, and diagnosis language; accepts Remli proposing a time as its own idea.
The distinction SafetyGuard enforces is attribution, not vocabulary:
| "I've set this for 9:30 — your other two are at 8:00" | allowed |
| "Your chart says to take this at 9:30" | rejected |
| "I'll remind you at 7:30, which is when your chart says" | rejected |
| "I can remind you to take 850 mg" | rejected — doses are strict under every framing |
Per the design doc: no diagnosis, no treatment recommendation, no triage, no cloud PHI processing, no SMS/voice reminders. Photo capture is a UI affordance only.
Scope decisions made during the build:
- Voice input cut — outside the core loop, most failure modes in the app. Preserved in
ios/Deferred/; spoken read-back kept. - Tross declined (
ROADMAP.md) — its value is breadth, Remli's is trust. - Hydration reminders — almost never in a chart, so generating one would be inventing advice. Available as a user-created reminder, where the person is the source.
silencecheck-ins — built, off behind a flag. Weakest evidence, most consequential wording.
Each is recorded with full reasoning in DESIGN_DOC.md §Amendments.
Every bug found here has been silent and open: code after an earlier return, a string constant that could never match after normalization, a property no view read, a recorder never invoked, a policy documented as enforcement and never called. None throw, none log, and the app keeps reporting itself healthy.
Two examples: chat appeared to know exactly one medication for several builds, because grounding
consulted an arbitrary proposal before the name-matching beneath it. And Gemma loaded, reported "on
device", and returned scripted text — the KV cache was sized for the reply rather than the prompt,
and sendMessage returned null rather than an error naming the cause.
Reasoning from the code gave four wrong answers to the second. A diagnostic file — model state, rule identifiers, never prompts or record content — separated all four in one run.
Hunt for code that is never reached, not code that throws.
DESIGN_DOC.md product spec (source of truth for what Remli may say)
ARCHITECTURE.md how the safety properties are structurally enforced
ROADMAP.md data acquisition: V1, V2 MyChart, Tross decision
ios/Remli/
Core/Models/ domain types; provenance is a type, not a convention
Core/FHIR/ R4 decoding + HealthSourceConnector seam
Core/Normalize/ FHIR → domain, capturing citations
Core/Reminders/ deterministic proposal engine + scheduling
Core/Language/ Gemma, SafetyGuard, ScheduleProposer, diagnostics
Core/Adherence/ outcome ledger, pattern detection, interruption budget
Core/Vault/ AES-GCM encrypted store
Core/Privacy/ EgressPolicy — the only place network access is allowed
Features/ SwiftUI: Reminders, Portal, Chat
ios/ThirdParty/ vendored LiteRT-LM package
ios/Deferred/ preserved, intentionally outside the build target
web-prototype/ original static mockup, kept for reference




