Tamo · system architecture

Tamo — A Longitudinal Health Digital Twin for Companion Animals

System architecture overview

Owner-reported feline health monitoring. The system converts unstructured owner speech — “he’s been squinting since the weekend, and he threw up twice yesterday” — into a structured, longitudinal clinical record; tracks symptom episodes across days; alerts when emergent; runs a nightly server-side reasoning round; and produces a veterinarian-facing report.

Status. Deployed and in continuous daily use for one animal as a single-user research prototype, including the nightly reasoning job in production.

Stack. React Native / Expo (TypeScript) client; PostgreSQL (Supabase) with row-level security; LLM access through a server-side gateway; nightly reasoning driven by pg_cron. ~40 tables, 65 schema migrations, ~150 pure-logic test harnesses with mutation testing.

Design principles

  1. The model judges; code routes.
    The recurring failure mode in LLM-backed record systems is asking the model to do bookkeeping — to decide simultaneously what was said, what it means, where it belongs, and whether it is still open. Here the model performs narrow, individually checkable judgments; a deterministic state machine owns routing, identity, idempotency, and lifecycle.
  2. Failure directions are chosen, not inherited.
    Every judgment is arranged so that its failure mode falls on the safe, over-reporting side: missing polarity reads as abnormal, never as silently normal; an unresolved time is stored as unresolved rather than guessed; a failed nightly round is retried rather than marked done.
  3. Expressed certainty must never exceed actual certainty.
    The report asserts only what was observed, distinguishes “not observed” from “negative”, and hides any section it cannot honestly populate — “not recorded” is never rendered as “normal”.
  4. Provenance is never destroyed.
    The owner’s original account is stored verbatim; every derived record cites the narratives it was compiled from. Structure extraction is additive — a later re-interpretation can be re-derived from testimony rather than from a lossy earlier parse.

1. The digital twin

The digital twin: ten normalized modules alongside a closed nineteen-category body-system vocabulary feeding the symptom entity.
The twin and its symptom vocabulary. Ten normalized modules; alongside them the closed 19-category body-system vocabulary that feeds the single symptom entity described in §2.
  • Stable record pointers. An application-assigned id (rec_NNNNNN) is what retrieval hands to the model, what the model cites back, and what cross-module references use — identity stays stable across turns, across days, and across the client/server boundary.
  • Relational spine, JSONB leaves. Typed columns for everything queried or joined; a GIN-indexed semantic-tag array for retrieval; JSONB for the open-ended per-category long tail. Schema evolution without migration, with every index-bearing predicate still in a real column.
  • Time modeled for lay speech. A record carries an absolute time where one exists, otherwise an age-anchor pair plus the owner’s own phrasing — a kitten-era event and a dated event live on one timeline without either being forced into the other’s representation.

2. Symptom model: two orthogonal axes

Course lifecycle: model-proposed silence and closure horizons are clamped by a minimum observation window and a chronic-never-auto-closes rule before a course moves from active to passive to closed.
Course lifecycle. Horizons are model-proposed but floor-clamped: an acute course cannot decay to closed merely by going unmentioned, and a chronic course may go quiet but never auto-closes.

The core entity holds both a single-day observation and a multi-day episode, distinguished by lifecycle state — a course is what a day becomes, not a different kind of object.

  • Type axis. 19 closed body-system categories. A category names a body system, not a verdict.
  • Polarity axis. Three independently sourced judgments — the owner’s explicit assertion, a pure function of the structured fields, and the extraction model’s own reading — resolved by a database-generated column: any-abnormal-wins, and all-null resolves to abnormal (a false alarm, never a missed finding). Source attribution survives, so post-hoc analysis can ask which channel mislabels.
  • Normal findings are first-class data. “His stool looked fine today” writes a normal row — including during an open episode, where a normal day is diagnostically meaningful. Tri-state booleans and an explicit “not observed” enum member throughout the field schemas are what make pertinent negatives representable at all: “I checked his gums, they’re pink” ≠ “I didn’t look”.
  • Episode layer. An open course carries a per-day trajectory (onset / worsening / improving / unchanged / normal) and an append-only per-statement log — because a merged field-wise view has no unit for “one statement”, and three sentences about one ear in one day would otherwise collapse into a superposition true at no single moment.
  • Lifecycle floors. Transitions (active → passive → closed, plus dormant) use model-proposed horizons clamped by floors the model cannot override: a minimum observation window, and a rule that a chronic course may go passive but never auto-closes.

3. Conversational pipeline: extraction ≠ routing

One conversational turn: a user message goes to an intent call, then concurrently to an extraction call that writes to the twin and a retrieval call that reads from it, then to a response call which may refill.
One turn. The intent call resolves first; extraction (which writes to the twin) and retrieval (which reads from it) run concurrently; only the response call sees both, and may refill if what it was handed proves insufficient.

An earlier single extraction call both decomposed the utterance and decided where each piece belonged. Under load it fragmented observations, dropped bare answers, and guessed at times. The current design gives the model only the first job. Every extracted entry lands in an append-only observation ledger, and code decides materialization:

  • Today → folds into the daily record.
  • Past → becomes a history record on the twin.
  • Ambiguous → triggers exactly one clarifying question; if still unanswered by the deadline it is materialized with an explicit null onset plus the owner’s original phrasing, for the nightly round to adjudicate. Dates are never fabricated to fill a slot.

Time is two independent axes — “is this happening today” versus “since when” — which is what allows the router to be deterministic. The burden of proof sits on the past side, so “he’s still sneezing, it started Tuesday” routes as an ordinary today-record carrying a three-day-old onset.

4. Three time scales

One day end to end: the daily record and chat pipeline feed a nightly round that organizes symptom courses and decides what to carry forward, which the next day's conversation opens from.
One day, end to end. The day’s records and conversation close into a nightly round that organizes symptom courses and decides what tomorrow should carry; the next morning Tamo opens the conversation from that agenda rather than from a blank slate.
loopperiodjob
turnseconds extract, route to ledger, retrieve, respond
round
server-side, per pet-local day
nightly merge yesterday’s rows into courses; emit a diagnosis frame (current + append-only log); adjudicate leftover ambiguous entries; run lifecycle transitions; seed tomorrow’s follow-up agenda
longitudinalweeks–months steady-state profile re-measurement, version-to-version drift, assessment staleness (§5)

The round is idempotent and catch-up-capable, guarded by a lease against concurrent execution; client-side execution exists only as a degraded fallback.

5. The steady-state profile, and staleness by construction

The normal-activity profile: a per-animal steady-state baseline collected roughly every three months, covering stool, food, water, activity, play and social approach.
The normal-activity profile. Each animal’s own steady state, re-measured on a roughly quarterly cadence — the previous version is archived so the two can be compared.

Each animal has a versioned normal-activity profile — its own steady state (stool frequency and form, intake, activity) — archived exactly once per re-measurement session, so two versions can be compared.

It is deliberately not a per-day comparator. Daily judgment runs on discrete rules with no “normal range” and no weighted deviation score; an earlier design that scored each day against a fitted normal was abandoned. Two scoped exceptions survive:

  • body-condition score compares against its own previous value;
  • the frequency axis reads two baseline fields for a relative call, falling back to absolute thresholds when the baseline is unavailable.

The profile’s real job is longitudinal: version-to-version drift — continuous fields at ≥30%, food ≥25%; ordinal fields by rank distance.

Drift feeds the persisted health assessment, which is keyed by a hash over its own inputs — and those inputs are restricted to the persistent portrait: banded age rather than exact, chronic background rather than active courses, long-term medication, the profile and its drift. Nothing transient enters, so the hash does not churn daily. There is no “refresh the assessment” code path at all:

re-measuring archives a version → drift becomes computable → the hash changes → the assessment reads as stale and offers regeneration, user-triggered rather than silently spending a model call.

Staleness is a derived property, not an event someone has to remember to fire.

6. Memory: three tiers with explicit expiry

Working-memory pool across rounds one, two and five: records held by hard bring-in and by semantic reasons; a record leaves the pool only after three consecutive false rounds or when it has neither a semantic reason nor a live parent record.
What stays in context, and why it leaves. Each retrieved record holds survival reasons rather than a timestamp. A semantic reason lapses when a round no longer plans to use the record; three consecutive lapses retire it. A structural reason lapses when its parent record leaves. Only when every reason is gone does the record drop out of the pool.
  • Working set. A bounded recent-turn window, plus retrieved records that hold survival reasons rather than timestamps — a semantic reason expires after 3 turns without renewed relevance, a structural reason when its parent leaves the set. An item is evicted only when all its reasons expire (cascade eviction), leaving a tombstone stub that keeps it cheaply re-retrievable and keeps the model aware something was set down, not never known.
  • Task memory. Open questions — the thing a health assistant most easily loses — live in per-topic drawers keyed by set-membership over general intent, not by verbatim strings (which rebuilt the container every turn and let questions silently evaporate). They are closed by question id at the earliest point the evidence exists, and aged mechanically to dormant: demoted without being deleted. The countervailing “interrogates the user” risk is handled by a hard deterministic cap on how many questions may reach the response prompt — the guard placed where it is mechanically checkable, not as a soft instruction.
  • Durable memory. The twin itself, plus a fact ledger of ruled-out and settled items injected into both chat and the nightly round to prevent re-asking. Nothing is carried forward in the context window; each turn re-assembles what it needs through a semantic catalog and content expander over the twin.

7. Engineering commitments

  • Pure core, IO shell. Every decision rule — merge, eviction, polarity, day-attribution, lifecycle, routing — is a pure function in a module with no I/O. That is what makes the ~150 harnesses and mutation testing possible.
  • Invariants live in the database. Generated columns, row-level security, cross-table triggers — rather than call-site discipline.
  • One source of truth per concept. “Which day does this belong to”, “what is today” — each exists once, imported by both the client and the server legs.