The Tuva encounter grouper runs on claims only. Clinical encounters (ADT feeds, EHR extracts) are never grouped: they get standardized and then stacked into the same table, core.encounter, next to the claims-derived encounters. The two sources are not matched and not deduplicated. One hospital stay present in both feeds means two rows.
The Scenario
If you load both claims and clinical data into Tuva, the obvious assumption is that core.encounter gives you one row per visit, regardless of where the visit came from. The Core Data Model is explicitly designed to unify claims and clinical data into a single longitudinal record, so the assumption is reasonable.
It’s also wrong for encounters. I wanted a definitive answer to two questions:
- Does the encounter grouper use only claims?
- If I feed clinical encounters from ADT, are they grouped too?
The docs describe the grouper in detail but don’t spell out what happens on the clinical side. So I read the code on main (commit 9cf2d1f7).
What the Code Does
There are two independent lanes that only meet at the very end.
1. Claims lane: the grouper
The code lives in models/claims_preprocessing/encounters/.
Inputs:
normalized__medical_claim- the service category grouper output
normalized__eligibility, used to scope patients bydata_source- provider and terminology lookups: DRG, revenue center, place of service, bill type
Each encounter starts from an anchor service: for example, an institutional acute inpatient claim, or a professional office E&M visit. Related claim lines then attach to that anchor using rules specific to each care setting. Inpatient stays use date/NPI continuity (overlapping claims with the same facility NPI, person_id and data_source); most outpatient and office encounters are formed from person_id + data_source + date. The Encounters chapter documents these rules in detail.
There are 26 encounter types, one model each: acute inpatient, ED, office visit, ASC, outpatient radiology, lab, DME, SNF, home health, dialysis, and so on. Lines that can’t attach to any anchor land in orphaned_claim.
Outputs:
- a claim-line → encounter crosswalk
core__stg_claims_encounter, withencounter_source_type = 'claim'
These rows carry fields that only make sense for claims: paid_amount, allowed_amount, claim_count, inst_claim_count, prof_claim_count, plus service flags like ed_flag and lab_flag.
2. Clinical lane: standardization only
The connector maps your source data into input_layer__encounter (the Input Layer contract). Then normalized__encounter:
- takes
encounter_typeas-is from the source - fills
encounter_groupfrom theterminology__encounter_typelookup - normalizes admit source, admit type, discharge disposition, diagnosis codes and DRG codes against terminology tables
- sets
encounter_source_type = 'clinical'
That’s it. Nothing is merged, split or re-grouped. One source encounter produces one row. If your source emits several records for the same stay, collapsing them is the connector’s job, not Tuva’s.
Extension columns (custom columns you pass through the Input Layer) only flow on clinical rows. On claims rows they’re null.
3. Where the lanes meet
core__encounter is controlled by two feature flags:
claims_enabled | clinical_enabled | core__encounter |
|---|---|---|
| true | true | smart_union of both lanes |
| true | false | claims lane only |
| false | true | clinical lane only |
A union. No join, no match, no dedup.
What This Means in Practice
Check what you actually have:
select
encounter_source_type,
data_source,
count(*) as encounters
from core.encounter
group by 1, 2
order by 1, 2;
Filter by encounter_source_type (or source_model / data_source) any time you compute utilization. Counting acute inpatient admits across both lanes double-counts every stay you see in ADT and claims.
If you need one row per real-world visit, build it downstream. A starting point:
-- sketch: candidate matches between clinical and claims inpatient encounters
-- adapt column names to your Tuva version and warehouse
select
cl.encounter_id as clinical_encounter_id,
cm.encounter_id as claim_encounter_id,
cl.person_id
from core.encounter cl
join core.encounter cm
on cl.person_id = cm.person_id
and cl.facility_id = cm.facility_id
and cl.encounter_start_date <= cm.encounter_end_date
and cm.encounter_start_date <= cl.encounter_end_date
where cl.encounter_source_type = 'clinical'
and cm.encounter_source_type = 'claim'
and cl.encounter_type = 'acute inpatient'
and cm.encounter_type = 'acute inpatient';
Things that will bite you here:
- Dates don’t mean the same thing. An ADT admit timestamp is not a claim’s
admission_date, and neither is the billing statement from/through date. Expect off-by-one days and observation stays that later convert to inpatient. - Facility identity differs. ADT feeds often carry an internal facility code; claims carry a billing NPI. You need a crosswalk.
- Claims-only fields are empty on clinical rows.
paid_amount, claim counts and service flags are null or meaningless whenencounter_source_type = 'clinical'. Don’t aggregate spend across both lanes. - Encounter types aren’t guaranteed to align. Clinical
encounter_typecomes straight from your source; claims types come from the grouper. Tuva’s own Encounters chapter already warns that EHR encounters and claims-derived encounters can’t be assumed to map one-to-one.
Conclusion
The encounter grouper is a claims-only algorithm; clinical encounters bypass it entirely. core.encounter is a union of two lanes, not a reconciled visit table, so the same stay in ADT and claims shows up as two rows. Filter on encounter_source_type every time, and treat ADT-to-claims reconciliation as a downstream job where dates are the hard part.