本页暂无完整中文版。以下先提供中文导读,随后是英文原文。
Status: provisional. The vocabulary, the arithmetic and the reference storage are here and pinned by 74 golden tests. The reference application is the first consumer; a second production consumer will find things to move, and the shapes below may change in a minor release until one exists. Nothing derived is stored, so a change here is a change to code, not a migration.A medication is not a reading, and every design decision on this page follows from that. A reading has a value, a unit and an instant. A plan has a schedule (a tuple of dosing instructions), a lifecycle (active → stopped → resumed as a NEW course) and a code system of its own. Storing “takes metformin 500 mg twice a day” as a reading forces a choice between losing the schedule and inventing a value, and makes every aggregation query step over rows that are not measurements.
The model
concept_key is what gets indexed, joined and logged
repr.
normalize_name is deterministic and convergent only
NFKC folding (so full-width 500mg and ㎎ become ASCII), case folding,
whitespace dropped. No alias or brand→generic mapping: that is an
open-ended asset behind the Terminology port, and this framework ships none.
Dose units and forms
A dose is an amount in a UCUM unit. Countable forms are UCUM annotations, andres/dose_forms.tsv is the whole table — sixteen forms, with the aliases each
one accepts:
Two notes on the table:
{pill}and{tablet}are different annotations. They are not merged, because a “pill” in a person’s words may be a capsule and the framework does not decide which.dose_unit_familyputs both incount, so they compare as amounts without being claimed to be the same object.{dose}and{application}carry no amount at all. They are accepted so a schedule can be recorded, and they never enter arithmetic — a “dose” is not a quantity a total can be taken of.
The instruction grammar
parse_dose_instruction(text) -> Schedule | None. Small and closed: one dose,
one frequency, optional clock times.
No partial parses. Half a regimen presented as a whole one is worse than no
regimen: the caller keeps the original text either way, and a
None is visible.
FHIR’s Dosage.text goes through the same grammar when the structured fields
are empty, which R4B expects and pharmacy systems commonly send.
The state tables
A plan’s stored status vs its effective status
Stored:active, stopped, entered_in_error. Everything else is derived by
effective_status(plan, today) — with the subject’s local date, never
date.today() on a server.
entered_in_error is FHIR’s “recorded in error”: terminal and retroactive. A
consumer whose “cancel” is reversible maps it to stopped with its own flag.
A dose’s state is derived from the clock
Stored:taken, skipped. That is the entire set.
The deadline is the end of the slot’s local day in the slot’s own zone — the
zone the person was in when the dose was due — or
grace minutes after the
instant.
Why nothing derived is stored: a stored missed is a lie the moment the
person opens the app and marks the dose taken.
Importing a FHIR MedicationStatement
The plan id is never the resource’s own
id. A FHIR resource id is unique
within the server that issued it; a plan id is unique across every person a
consumer stores. Two people importing documents from the same clinic would
collide and one medication list would overwrite another. plan_id_for(subject, record, concept_key) keeps the idempotency that made the raw id tempting and
scopes it to the subject.
Dose identity is not an instant
DoseSlot.key is (plan_id, local_date, slot). utc_ms is DERIVED at
projection time.
A person flies from Shanghai to London. Their 08:00 dose is still their 08:00
dose; only the instant changed. Keying on the instant would make the same dose
two different doses, and a re-projection after a zone change would churn every
row.
Daylight saving. A slot at 02:30 on a spring-forward day has no instant.
gap="shift_forward" moves it to the first instant that exists;
gap="skip" leaves utc_ms=None and slot_state answers unschedulable.
Neither is a default — the caller says which, because a blood-pressure tablet
and a contraceptive want different answers.
Adherence
Nonewhen nothing has elapsed — an as-needed plan, or a window that has not started. Not0, which reads as “took nothing”.extracounts taken doses with no slot;extra_skippedcounts skips with no slot, which answer nothing and are reported separately.unschedulableslots are excluded from every other count.- Roll-ups across plans add counts; they never average percentages.
The tool: query_medications
Medications have their own tool, with five parameters — view, keywords,
start, end, member — because their grammar shares nothing with a
reading’s resolution and aggregate (see answers.md).
kernel.meds — plan_rows, log_rows,
history_rows over the two store ports — so a consumer with its own storage
renders the same table; agent/tools/medications_service.py is the thin
binding.
plan carries the note “a plan is what the person intends to take; it is not a
record of doses taken”, because a model that is not told this reports a
medication list as evidence of what was swallowed. And a dose missing from
log is not evidence it was not taken.
Storage (the reference application)
schema/31_medications.sql: th_medication_plan, th_medication_course,
th_dose_event, th_override.
The schedule column carries structure only — times, counts, weekdays, dose
amounts. The person’s own words live in the encrypted columns.
collect/meds/store.py implements MedicationStore and DoseLogStore and asks
mirobody.kernel.meds every question that has an answer there, rather than
re-deriving one in SQL.