Skip to content
Get Started

② Standardize

Health Indicators

② Standardize: the offline resolver, the concept graph, the 300-member indicator registry, unit conversion at ingest, and how resolver coverage is measured.

An indicator is the name of a measured quantity — heartRates, bloodGlucoses, dailyTotalSteps. It is the key every reading is stored under and the only handle a question has on the data, which makes it the one place where a Garmin watch, an Oura ring and a scanned lab report have to agree. This page is about how that agreement is arranged, and about what happens when it cannot be: a report row named 空腹血糖 in one file and Fasting Glucose (FPG) in another is still the same measurement, and no registry is ever going to contain both spellings.

Two mechanisms answer those two cases, and they are worth keeping apart from the start.

Two mechanisms: the registry and the resolver

Section titled “Two mechanisms: the registry and the resolver”

An indicator name is only queryable if every spelling of the same test ends up with the same identity. LDL cholesterol, 低密度脂蛋白胆固醇 and LDL-C all name one measurement, and no fixed list can enumerate every way it is written. The engine handles the two cases separately:

MechanismCovers
The registrynames the engine defines itself: device streams, derived aggregates
The resolvernames written elsewhere: a row on a report, a foreign-language printout

The resolver can be used on its own: after pip install mirobody, resolve("血红蛋白").loinc needs no database, key or network. See The Engine as a Library.

The terminology resources that ship with the package, distributed through Git LFS:

ResourceSize
Concept-graph nodes (440,961 of them carry cross-vocabulary edges)745,620
Cross-vocabulary edges (LOINC ↔ SNOMED CT ↔ RxNorm)22,044,110
Sibling groups (covering 595,746 nodes)199,959
Multilingual aliases (中文 22,578 · 日本語 16,809 · ru · es · fr · ko · de)49,253
UCUM unit families~310
Registry indicators300, in 13 categories

The bundles are row-aligned; a loader aborts rather than half-aligning if the row counts disagree.

If an indicator name does not resolve, or resolves to the wrong analyte, an issue or a pull request is welcome — refining this vocabulary is where outside contributions help most, and one alias mapping plus one test case is a complete contribution. See Contributing.

mirobody/test_engine_coverage.py scores the offline resolver against the panels a physical routinely orders: lipid, CBC, metabolic, liver, thyroid, hormones, tumour markers, urinalysis and vitals, written the way a report prints them, in English, 中文 and 日本語.

Terminal window
pytest mirobody/test_engine_coverage.py -s
# offline resolver coverage: 116/116 = 100%

Scoring is on clinical correctness rather than resolution rate:

  • Resolving 血红蛋白 to the code for HbA1c counts as a failure, with no partial credit.
  • 血圧 is a panel name rather than a single observation, so the correct result is nothing at all, not one of its components.

StandardIndicator is an enum of 300 members. Each carries an IndicatorInfo declaring its category, standard unit, data type, English and Chinese names, and the aggregations available to it. The 300 split 167 summary / 111 series / 22 mixed across 13 categories (vital signs, body composition, activity, metabolic, sleep, performance, medical, device-specific, nutrition, lifestyle, health, mental, reproductive).

Three conventions matter when using them:

  • Names are lowerCamelCase and usually plural (heartRates, bloodGlucoses), because they name a stream of readings rather than one reading. Derived aggregates follow daily{Method}{Indicator}, so dailyAvgHeartRates is derived and heartRates is raw.
  • An unrecognised name passes through unchanged. normalize_indicator_name() only canonicalises case; it does not reject unknown names, which is how a free-text indicator reaches the write path and is then handled by the resolver.
  • Every registry indicator has a target unit. Asking for the standard unit of an unregistered indicator raises ValueError, because a conversion with no target is an error rather than something to fall back from.

A value is converted once, on write, so every reader downstream can assume it is in the indicator’s standard unit. Conversion tries indicator-specific rules, then a general conversion table, then nothing at all.

The behaviour of that last step is the one to know: when no rule applies, the value is stored with the unit it arrived in rather than being relabelled. A reading therefore cannot go silently wrong — a mg/dL value never sits in a row claiming mmol/L, and the caller sees the unconverted unit and can act on it.

Some conversions are clinical estimates rather than unit arithmetic (PaO2SpO2%, for instance), and some cannot be generalised because the molar masses differ — the mg/dLmmol/L factor for glucose is not the one for cholesterol. Both exist as indicator-specific rules.

A second, unrelated unit layer also exists, and it does not convert values. It parses a free-text unit string into canonical UCUM and reports the matching LOINC PROPERTY family, which answers a different question: a document says “毫摩尔每升” — what unit is that?

normalize_unit("MG/DL")
"mg/dL"
normalize_unit("毫摩尔每升")
"mmol/L"
parse_value_unit("90次每分钟")
ParsedQuantity("", 90.0, "/min")

Units in the same family convert into one another; converting across families is a category error. This layer is exposed through the normalize_unit tool — see Built-in Tools.

What does exist is a coding map: the coding table is loaded into memory at startup, and the write path answers “which fhir_id does this name have” from that cache with no database call.

Two config keys gate it. FHIR_TABLE_AUTO_R must be "true" for the mapping to load at all — otherwise the fhir_id column simply stays empty. FHIR_TABLE_AUTO_W enables auto-registration, in which case a name that was not in the table is registered by a later aggregation pass. Mirobody’s own indicators are registered under the vocabulary name THETA, alongside the standard ones.

The file intake path produces indicator names nobody registered: whatever a lab report called that row, in whatever language it was printed in. Forcing them into the registry at write time would mean guessing, and a wrong guess is unrecoverable — so they are stored verbatim and resolved at read time instead.

The worker keeps assigning codes to those names, cheapest and most confident judgement first: a deterministic registry match, then a name whose already-mapped rows all point at one code, and only then a ≥99% historical majority. A genuinely ambiguous name — “pain”, spread across body sites — never reaches 99% and is deliberately left unmapped; those are found by vector search instead, which is why every unmapped name gets an embedding.

Embeddings are 1024-dimensional either way: gemini requests output_dimensionality: 1024, qwen uses text-embedding-v4 with dimensions: 1024. One key, EMBEDDING_PROVIDER, chooses between them, and the column name follows the provider — which is why switching providers means re-embedding rather than flipping a flag.

A search runs in four steps:

  1. Embed. The keywords, plus their concatenation when there is more than one — so “MCHC” and “Mean Corpuscular Hemoglobin Concentration” contribute both separately and together.
  2. Vector recall. One recall over coded concepts and one over this user’s free-text indicators. Both are scoped to the user — an indicator they have no readings for cannot come back.
  3. Graph expansion. One step out along the concept graph: across vocabularies (SNOMED CT ↔ LOINC ↔ RxNorm) and to near neighbours within one (LOINC codes sharing a component, drugs in the same ATC subgroup). A query that hits one spelling of a concept therefore also finds the user’s rows filed under a related code.
  4. Merge and threshold. Coded and free-text hits are merged, free-text hits filtered at a floor of 0.6 or the weakest coded score — whichever is higher — then sorted by score.

The concept graph and the indexes are build artifacts rather than source; they are tracked with Git LFS. Two things matter when deploying:

  • The offline resolver’s vocabulary ships with the package. It is small, which is why resolve() needs nothing external.
  • The embedding index does not ship (~1.4 GB). Container deployments mount it and point FHIR_INDICATORS_DIR at that directory. Without it, semantic search falls back to pgvector in the database and the lexical resolver is unaffected.
EndpointReturns
GET /api/v1/pulse/theta/indicatorsThe indicators available to a caller.
GET /api/v1/health-indicatorsA user’s own readings, with the drawer’s source-file link; POST /api/v1/health-indicators/reading edits or deletes one, owner only.
GET /api/v1/manage/pulse/indicatorsThe full registry grouped by category.
GET /api/v1/manage/pulse/unitsThe standard-unit set and the conversion table.
GET /api/v1/manage/pulse/indicators-and-unitsBoth in one response.
GET /api/v1/manage/pulse/user-indicatorsWhich indicators one user actually has readings for.
GET /api/v1/manage/pulse/std-indicators/status · POST …/triggerThe standard-indicator sync state, and a manual kick.

The /api/v1/manage/* entries are guarded by a management key, not by a user’s bearer token — they are operator endpoints, not part of the app-facing surface.

Inside a chat turn, none of these are what the agent uses — it gets the single query_health_indicators tool, described in Built-in Tools. That tool asks for both the abbreviation and the expansion (["MCHC", "Mean Corpuscular Hemoglobin Concentration"]), because an abbreviation alone embeds poorly against a corpus written in full names. And when nothing matches it returns the user’s catalog rather than an empty list, so the model picks from what exists instead of re-guessing keywords.

The source for this step lives in mirobody/indicator/.