Rosetta — Complete Architecture
Rosetta answers one question, millions of times: are these two clinical records measuring the same thing? Get it right and a patient’s history forms coherent trends across labs, years, and languages. Get it wrong and the chart silently breaks. This document explains how Rosetta gets it right — and why the architecture looks the way it does.
Contents
Section titled “Contents”- The problem Rosetta solves
- The core insight: identity is not a name
- Where Rosetta sits in the platform
- The original design & what we learned
- The multi-domain architecture (v1)
- Component deep-dives
- The three guarantees
- Caching
- The corrections facility
- What is NOT in Rosetta
- Rollout plan
- Glossary
01The problem Rosetta solves
Section titled “01The problem Rosetta solves”N1 shows each clinician a longitudinal view of a patient’s clinical data over time. Two hemoglobin measurements taken a year apart, from two different labs, in two different languages, must appear on the same chart so the trend is visible. The same holds for every biomarker, diagnosis, medication, allergy panel, cognitive assessment, and genetic variant.
For that to work, the platform has to answer one question, millions of times: are these two clinical records measuring the same thing? Trivial when both come from the same lab with the same name. Hard the moment a patient uploads records from many labs, across years, in many languages:
- “TSH” / “Thyroid Stimulating Hormone” / “Thyrotropin” / “甲状腺刺激ホルモン” — same test, must merge.
- “Hemoglobin” (g/dL) / “Haemoglobin” (g/L) / “HGB” / “血红蛋白” — same test, must merge.
- “Free Cortisone 1st Morning” vs “2nd Morning” — different tests (timepoint-specific references), must NOT merge.
- “MCH” (pg) vs “MCHC” (g/dL) — different quantities, same word stem, must NOT merge.
- “Systolic BP (Recovery)” vs “(Exercise Stage 2)” vs plain — three distinct stress-test readings, must NOT merge.
Why this matters clinicallyIf a patient's CA 19-9 surveillance trend is split across two "canonical" rows because an LLM emitted two display strings on two days, the clinician sees only half the trend. For a tumour marker whose entire value is trend detection, the chart is **silently broken**.02The core insight: identity is not a name
Section titled “02The core insight: identity is not a name”The system before Rosetta deduplicated records by display-name string equality. It asked an LLM to produce a canonical_name, then merged records whose names matched as strings. This can never work, for one structural reason:
The load-bearing ideaAn LLM is a **generator, not a classifier**. Asked to name TSH, it emits "TSH", "Thyrotropin", "Thyroid Stimulating Hormone", "Thyrotropin (TSH)" across calls — all correct, all different. No prompt engineering removes this; stylistic variation is intrinsic. So if the dedup key is a non-deterministic string, every variation creates a duplicate. One staging patient carried **23 distinct clusters of duplicate canonicals**.The fix: stop deduplicating on the display string. Identity must be a stable, deterministic, structured key; the display name becomes a derived property of that key. Rosetta decomposes each entity into axes borrowed from established ontologies (LOINC for labs):
test_name: "Thyrotropin" | unit: mIU/L | specimen: Serum
component = "thyrotropin" ← what is measuredproperty = "MCnc" ← kind of quantitysystem = "ser/plas" ← biological systemmethod = "" ← assay method (if identity-defining)time_aspect = "" ← timing constraint (if any)qualifier = "" ← variant info (if any)
group_key = "thyrotropin|mcnc|ser/plas|||" ← THE IDENTITY (stable)canonical_name = "Thyrotropin (TSH)" ← DERIVED displayEvery record decomposing to the same group_key is the same concept and belongs on the same chart — regardless of the original string or the chosen display. This is the whole idea. Everything else is engineering to make that decomposition fast, deterministic, and correct across every kind of clinical data.
03Where Rosetta sits in the platform
Section titled “03Where Rosetta sits in the platform”Rosetta is a library (n1r-rosetta), not a service. The dependency arrow always points into Rosetta; it never depends on N1 application code.
n1r-rosetta
Section titled “n1r-rosetta”Decomposes one (name, unit, specimen) tuple into a BiomarkerDefinition. Stateless except for the cache. Plain Python.
rosetta-grouper-v1
Section titled “rosetta-grouper-v1”Fetches a patient’s records, resolves each via Rosetta, groups by group_key, persists identities + FKs.
Platform DB
Section titled “Platform DB”Stores identities in dedicated tables; each record FK-links to its identity.
Rosetta knows nothing about patients, auth, HTTP, or the database. It is a pure function from clinical text to BiomarkerDefinition, with a cache.
04The original design (biochem) & what we learned
Section titled “04The original design (biochem) & what we learned”The first production Rosetta handled one domain: biochemistry. It works, and it taught three lessons that shaped the multi-domain rewrite.
What worked
Section titled “What worked”- Structured LOINC-axis decomposition — identity-as-key eliminated the duplicate-canonical problem at the root.
- Schema-constrained output gives shape guarantees.
- A validator stack (dimensional analysis, plausibility, timing) catches a real class of LLM errors before the DB.
What we learned the hard way
Section titled “What we learned the hard way”Lesson 1 — One giant prompt is fragileThe biochem prompt grew to ~500 lines. We measured the cost: patching it to fix gamma-glutamyl transferase spelling **broke triglycerides** (13/21 → 1/21 correct). Adding a rule reshuffles the model's attention across the whole prompt. Large prompts don't compose.
Lesson 2 — The eval was independent but not comprehensivegemini-3.5-flash vs production gemini-3.1-pro-preview agreed within 0.5% on 1,351 synthetic inputs — but only **55%** on a real patient's 8,387 biomarkers. The disagreements clustered in shapes the synthetic set never had (stress-test stages, allergen panels). Real patient data has shapes curated tests miss.
Lesson 3 — "First-write-wins" stabilization is a smellTo suppress display-name jitter, biochem added two extra cache layers that locked in whichever display string was written first. That made the name a patient saw depend on *who resolved it first* — an arbitrary race. The right fix: make the display name a pure function of the axes, so there is nothing to stabilize.05The multi-domain architecture (v1)
Section titled “05The multi-domain architecture (v1)”The vision: Rosetta becomes the identity engine for all clinical data. A lightweight classifier routes each input to a domain-specific pipeline. Each domain has its own small prompt, identity shape, validators, and cache namespace.
v1 implements two domains end-to-end: biochem (migrated) and allergy (greenfield). The other five are recognised by the classifier but route to biochem fallback until their decomposers ship — so the classifier returns its true verdict from day one, while consumers never hit an unimplemented-domain exception.
BiochemLOINC · 6 axesLive (migrated)Allergyallergen·reaction·severity·qualv1 (new)DiagnosisICD-10FutureMedicationRxNormFutureProcedureCPTFutureCognitiveinstrument-keyedFutureGeneticsHGVS · ClinVarFuture
Why route at allRouting is what makes each prompt small. The allergy prompt knows nothing about LOINC; the biochem prompt knows nothing about reaction types. **Lesson 1 dissolves** — a fix in the allergy prompt cannot break biochem because they are different prompts. The 77 catastrophic decomposition failures we saw on one patient (spice-IgG panels, drug allergies, wheal readings) were all biochem being asked to handle non-biochem inputs.
Still a lean libraryRouting adds exactly two things: a classifier (one cheap cached LLM call) and a dispatch table (a dict lookup). No framework, no orchestration engine, no agent runtime. The resolver shrinks from ~1,300 lines to ~250 lines of router; each domain is a self-contained ~500-line module. **Plain Python, start to finish.**06Component deep-dives
Section titled “06Component deep-dives”6.1 · The classifier
Section titled “6.1 · The classifier”- Model:
gemini-3.1-flash-lite— classification is tiny and well-bounded; it doesn’t need a frontier model. Cheap and fast on the hot path. - Output: JSON-schema
{domain, confidence}where both are enums. The model physically cannot emit a domain outside the set. - Multilingual by design: ≥6 non-English examples in the prompt (French allergy, Mandarin diagnosis, Japanese medication, Korean procedure, Spanish cognitive, German biochem).
- Soft-fail: any error returns
{biochem, low}and is not cached — a transient outage never burns a permanent miss. - Disambiguation is abstract, not enumerative: five short rules (“X IgE → allergy even when X looks like a vitamin”), never a list of substances.
6.2 · The resolver / dispatcher
Section titled “6.2 · The resolver / dispatcher”The Resolver is a router, not a worker. It owns the OpenAI client, the shared cache, the classifier, the corrections registry, and the {domain → decomposer} table. Every cache write, LLM call, validator, and correction lives inside the decomposers.
classify(...)→{domain, confidence}- If confidence is low or the domain has no decomposer yet → route to biochem (logged at INFO).
- Dispatch to
domains[domain].resolve_groupable(...). - Stamp the chosen
domainonto the result.
The public API (resolve, resolve_groupable, resolve_batch, resolve_batch_groupable) is unchanged — rosetta-grouper-v1 keeps working without modification.
6.3 · A domain decomposer
Section titled “6.3 · A domain decomposer”Each domain is a self-contained module: identity.py (frozen dataclass, axes + derived properties), prompt.py (small prompt + enum schema), decomposer.py (LLM call + assembly + cache + corrections), format.py (the pure display-name function), validators.py (domain checks). Domains share only a duck-typed surface (.group_key, .canonical_name, .domain) — never a common base class, so divergent domains never pollute each other’s shape.
07The three guarantees
Section titled “07The three guarantees”The rewrite makes three promises the original design could not.
Closed-vocabulary fields cannot drift
Section titled “Closed-vocabulary fields cannot drift”Every finite-value field is an enum in the JSON schema — the model physically cannot emit an out-of-set value. We removed the “propose a new label” affordance that let taxonomy fields drift run-to-run.
Same input → same identity, forever
Section titled “Same input → same identity, forever”For a given prompt version + correction set + model, the same tuple deterministically produces the same identity. Temperature 0; the cache makes the first resolution permanent until a version bump.
Same identity → same display name, on every machine, forever
Section titled “Same identity → same display name, on every machine, forever”canonical_name is a pure function of the axes — derived, never LLM-emitted, never cached as a stabilized string. The structural fix for “first-write-wins”: there is no first writer to win. The 1,544 “same identity, different display” rows on one patient drop to zero.
On display-stylization tablesA small `_DISPLAY_OVERRIDES` per domain handles stylization (e.g. "Hemoglobin A1c (HbA1c)") — but this is **stylization, not equivalence**: one identity → one prettier string, never many surface forms → one canonical. Every entry carries a one-line comment explaining why the mechanical builder falls short.08Caching
Section titled “08Caching”The original three-layer cache collapses to one layer per namespace:
NamespaceKey shapeWriteBump when<key> is {normalized_unit}||{normalized_specimen}||{test_name}. Each domain owns its version integer (biochem starts at v13, bumped from v12 to signal the identity-shape change; allergy at v1).
Why the extra layers are goneThe canonicalizer and group-key-index layers existed only to stabilize LLM stochasticity. With enum schemas (Guarantee 1) and pure-function display names (Guarantee 3), there is nothing stochastic left to stabilize. Plain `SET` is correct because every write for a key produces identical bytes. The cache also **fails open**: a Redis hiccup is a miss, never a crash; if Redis is down at startup, fall back to in-memory.09The corrections facility
Section titled “09The corrections facility”Even with small prompts, the LLM will have specific documented misfires. Corrections patch them without touching prompts — sidestepping Lesson 1’s cross-coupling entirely. A correction is a tested YAML file under rosetta/domains/<domain>/corrections/:
id: BIOCHEM-0002description: “GGT: LOINC canonical includes a space.” domain: biochem match: component: “gamma glutamyltransferase” # the LLM’s wrong output correct: component: “gamma glutamyl transferase” # the patch test_cases:
- input: {test_name: “GGT”, unit: “U/L”, specimen: “Serum”}
expected_axes: {component: "gamma glutamyl transferase"}provenance: source: “patient-9141cb0a, 11/11 Liver misses” affected_rows_observed: 11 retire_when:
- “default GGT spacing lands in the biochem prompt”
Why this is NOT a hidden synonym tableA correction maps **one documented wrong LLM output → one corrected output**, with evidence and a regression test. It encodes a *known model failure*, not general medical knowledge. It applies as a pure post-processing transform — it does not change the prompt, so the GGT correction literally cannot affect triglycerides. And it has a **retirement path** (`retire_when`): a quarterly audit disables each correction and checks whether anything regresses.The facility is deliberately constrained: transforms come from a hard whitelist (literal replacement, axis-template, extract_parens) — no eval, no arbitrary code in data files. Two corrections that match the same identity and touch the same axis raise CorrectionAmbiguityError at startup. Every YAML’s test_cases are auto-discovered by pytest and run offline.
10What is deliberately NOT in Rosetta
Section titled “10What is deliberately NOT in Rosetta”Firm architectural boundaries, not omissions.
No hardcoded medical-equivalence tables
Section titled “No hardcoded medical-equivalence tables”Code never claims “peanuts” == “groundnut”. The LLM does medical reasoning; code does mechanical invariants. A correction patching one misfire is allowed; a synonym dictionary is not.
No agent framework
Section titled “No agent framework”No agno, no orchestration engine, no agent runtime. Rosetta is a stateless library on a hot path of thousands of resolutions per patient; per-call object overhead would be a real regression. Resolution is one-shot structured extraction — not agent-shaped.
No external vocabulary (yet)
Section titled “No external vocabulary (yet)”v1 accepts a cache-covered determinism gap on free-form fields. A future v2 may snap them to RxNorm/UMLS codes loaded as data (never code) — explicitly deferred.
No LLM-emitted display string
Section titled “No LLM-emitted display string”Ever. canonical_name is always derived. And no dependency on N1 application code — the arrow points into Rosetta, always.
11Rollout plan
Section titled “11Rollout plan”The multi-domain work ships as a sequence of self-contained PRs on branch multi-domain-rosetta.
PRScopeShips valueEach PR is independently shippable. Future domains (diagnosis, medication, procedure, cognitive, genetics) follow the same template — one PR each. Diagnosis (ICD-10) and genetics (ClinVar/HGVS) are hardest because their vocabularies are huge; those are the candidates for a data-backed lookup when their time comes.
12Glossary
Section titled “12Glossary”🧬 Rosetta - Complete Architecture
Target architecture for n1r-rosetta multi-domain routing v1 — biochem in production, routing & allergy in active development on branch multi-domain-rosetta.
Supersedes the pre-Rosetta alias-lookup + embedding pipeline — see Biomarker Grouping Pipeline for historical context.
Confidential — N1 internal only.
