Health Dashboard, 3. deo: moji nosivi uređaji lagali su prećutkivanjem
Treći deo mog build log-a za Health Dashboard: kako se tri uređaja tokom iste noći ne slažu oko toga šta su izmerili, zašto je kolona sleep_core godinama bila laž i koja pravila odlučuju kom izvoru sistem veruje svake noći.
Posle 2. dela ingestion sloj izgleda čisto. Server prima payload-e, nativni iOS klijent radi uz Health Auto Export, šema zna za san po segmentima, dashboard prikazuje pet faza sna. Deluje kao da je gotovo. Nije.
Sledeća klasa problema postala je vidljiva tek pošto su podaci postali tehnički korektni. Svake noći imam tri uređaja koja mogu nešto da kažu o mom snu: Apple Watch na zglobu, RingConn1 na prstu i iPhone pored kreveta, koji koristi Apple-ovu procenu Sleep Schedule. Svaki zna nešto što ostali ne znaju. I nijedan se ne slaže sa ostalima oko toga šta je tačno izmerio.
Ovaj deo je o reconciliation-u: malom skupu pravila koja odlučuju kom izvoru sistem veruje svake noći, i o tome zašto je kolona sa sasvim razumnim imenom godinama lagala pre nego što je iko to primetio.
Tri izvora ukratko
Svaki uređaj odgovara na malo drugačije pitanje i koristi malo drugačiji rečnik:
| Uređaj | Šta javlja o snu | Šta ne javlja |
|---|---|---|
| Apple Watch S7+ | faze po segmentima: deep, REM, core, awake | ništa ako sat nije nošen; propušta naps kraće od ~15 minuta |
| RingConn | ukupno vreme sna + nekoliko summary polja | nema razlaganja po fazama; šalje dnevni summary u ponoć |
| iPhone (Sleep Schedule) | procenjene bedtime / wake-time prozore | nema stvarne sensor data; samo inference po ekranu i pokretu |
Sa pulsom je slična priča. Apple Watch i RingConn mere HR tokom dana, ali različitom učestalošću i uz različit post-processing. HRV2 je najgori slučaj: Apple ga meri događajno — Breathe sesije, posle treninga, povremeno noću; RingConn ga računa kontinuirano tokom sna po sopstvenoj RMSSD3 metodologiji. To nije isti broj sa greškom merenja. To su različiti fiziološki agregati.
Problem nije u tome što je neki uređaj „pogrešan“. Problem je u tome što baza ima kolone sleep_core, sleep_total, hrv_avg, a te kolone tiho mešaju podatke uređaja koji pod istom rečju misle različite stvari.
Kolona koja je lagala
Pre v2.3, mapiranje sna iz iOS-a u health-sync pretvaralo je tri HealthKit vrednosti u jedno wire name:
case .asleepCore, .asleepUnspecified, .asleep: return "sleep_core"
Izgleda bezazleno. Na Apple Watch-u sa tracking-om faza to šalje stvarne minute core sleep-a — Apple-ovu heuristiku za lagani non-REM san. Na RingConn-u to šalje grubi span „samo spavao“ bez ikakvih informacija o fazama. Na starijim firmware verzijama Apple Watch-a koje još nisu znale faze — isto. Sva tri slučaja završavala su u istoj koloni sleep_core.
Stacked chart faza na dashboard-u prikazivao ih je sve kao „Core“. Daily aggregator ih je sabirao u daily_scores.sleep_core_hours. Readiness score, o kom je bio 1. deo, koristio je taj agregat kao ulaz za sleep efficiency. Za izvore bez tracking-a faza to je proizvodilo rečenicu tipa „imao si 7,5 sati core sleep-a“ — tvrdnju koja ne odgovara nijednom stvarnom merenju. Izvor je rekao samo asleep. Sistem je sam izmislio fazu.
To u praksi znači „lagati prećutkivanjem“. Podaci su tehnički bili prisutni i tehnički su odgovarali onome što je izmereno. Laž je bila u naslovu kolone: sleep_core je imala dva značenja u zavisnosti od uređaja koji je upisao red, a ni šema ni grafikon nisu mogli da kažu koje značenje je trenutno u igri.
Popravka je bila mala strukturno, ali velika metodološki: uvesti petu metriku faze sna, sleep_unspecified, i zahtevati od iOS klijenta da pregleda HealthKit sesiju pre slanja. Mapiranje je postalo:
case .asleepCore: return "sleep_core" // stages present, real measurement
case .asleepUnspecified, .asleep: return "sleep_unspecified" // coarse "just asleep", no stages
Posle rollout-a — 2. deo opisuje wire contract i migraciju — RingConn-only i iPhone-only noći prestale su da ulaze u sleep_core i počele su da ulaze u sleep_unspecified. Na dashboard-u se pojavila peta traka faza između REM i awake sa tooltip-om „Nema podele po fazama za ovaj izvor“. Sada čitalac vidi kada je noć izmerena po fazama, a kada je samo zabeležena po vremenu.
Petoslojni grafikon faza posle rollout-a _sleep_unspecified_. Sivo-plava traka između REM i awake je kategorija „nema podele po fazama za ovaj izvor“, koja je ranije ćutke završavala u _core_.
Pet paralelnih SQL kopija koje su se razišle
Kada dva izvora mogu da opišu istu noć, serveru treba pravilo: kome verovati za svaku metriku. Prva verzija tog pravila bila je SQL fragment ugrađen direktno u query koji je sastavljao daily_scores. Drugi callsite ga je kopirao. Treći je kopirao drugi. Do PR #10 postojalo je pet paralelnih kopija SQL-a za sleep cross-validation, svaka pored svog upita.
Počele su isto, a tokom sledećeg meseca su se razišle. Svaki PR koji je dirao sleep edge case popravljao je jednu kopiju. Ostale četiri čuvale su staru logiku. Ista konkretna noć mogla je da prođe kroz različita pravila u zavisnosti od toga odakle je došao read path: daily aggregate, chart, briefing payload, per-source breakdown, export.
Samo reconciliation pravilo nije komplikovano: preferirati Apple Watch u odnosu na RingConn, uzeti izvor sa granularnijim fazama, ali zahtevati minimalni sleep total pre nego što se poveruje single-source izboru. Tačno pravilo, u obliku koji je preživeo posle deduplikacije u jedinstveni helper sleepCrossValidationPickExpr:
Ako su dva izvora prijavila san za istu noć i njihovi totals se razlikuju više od 1,4×, uzeti minimum — duža vrednost verovatno sadrži duplo brojanje. Ako toga nema, preferirati Apple Watch kada njegov total prelazi 1 sat — to filtrira slučajne polusatne blips; inače se vratiti na RingConn.
Pravilo „uzeti minimum pri razilaženju“ zvuči naopako dok ne vidiš šta hvata. RingConn ponekad u ponoć pošalje 0,x-časovni daily summary „asleep“ — stub sinhronizacije, ne merenje. Bez praga od 1 sata, taj stub plus 7-časovna noć sa Apple Watch-a zadovoljili bi uslov MAX > MIN × 1.4, i dashboard bi ponosno prikazao 0,x sati kao noćni san. Prag od 1 sata pojavio se pošto je baš takav bug otišao u prod.
Pravilo sada ima tri callsite-a:
sleepCrossValidationPickExpr(valCol): value twin, koristi se u read paths (metricDataDayFromHourly,metricDataRaw,briefing.go::fetch).sleepCrossValidationPickSourceExpr(table, valCol): source twin, koristi se uupsertDailyForDate, da izabere JEDAN izvor za noć i da svih petsleep_*faza dođe sa istog uređaja.buildDailySleepBlock: multi-day backfill verzija, inlined, jer oblikGROUP BYne odgovara flat-table pretpostavci helper-a.
Pragovi — 1,0h floor i 1,4× divergence — i konstante prioriteta izvora dokumentovani su kao load-bearing u komentaru unutar buildDailySleepBlock. Svaka promena mora da stigne na sva tri mesta. Deduplikacija je bila popravka. Rani drift bio je lekcija.
Atomski izbor izvora
Izbor izvora je neophodan, ali nije dovoljan. Kada izabereš izvor za noć, moraš da prihvatiš ceo njegov stage breakdown. Inače dobiješ red u kom je sleep_deep sa Apple Watch-a, a sleep_core sa RingConn-a, i zbir ne odgovara ničemu što se stvarno desilo.
upsertDailyForDate to enforcing-uje preko gate-a sleep_picked_complete: izabrani izvor mora da da svih pet sleep_* metrika ili, za coarse izvore, sleep_total + sleep_unspecified. U suprotnom red ostaje u prethodnom stanju. Ili atomski update, ili ništa.
Gate ima drugu granu za coarse-only izvore. Bez nje bi multi-source noć, gde MIN-pick padne na uređaj koji javlja samo sleep_unspecified, završila kao NULL upisi, a prethodna noć sa fazama preživela bi kao „istina“: pogrešna, ali nevidljiva. Obe grane se održavaju sinhrono između upsertDailyForDate i buildDailySleepBlock.
Postoji poznat slučaj gde je ovo ponašanje namerno konzervativno. Ako neki budući izvor ikada pošalje samo sleep_total — bez faza i bez sleep_unspecified — gate neće proći, a prethodni red daily_scores će ostati. Doprinos novog izvora biće tiho odbačen za tu noć. To je namerno: prihvatanje single-metric pick-a omogućilo bi krivom third-party importer-u da obriše stvarnu staged night tako što upiše jedan sleep_total. Nativno importovani podaci ne mogu da upadnu u taj ćošak: iOS klijent v2.3 uvek uparuje sleep_total sa sleep_unspecified za coarse izvore, a Apple Health XML importer mapira i AsleepUnspecified i bare Asleep u sleep_unspecified. Ako budući izvor natera sistem na ovaj case, treba popraviti upstream emitter, a ne olabaviti gate.
Tri sloja kvaliteta oko svega ovoga
Source picking odgovara na pitanje „kom uređaju verovati“. Druga polovina reconciliation-a je „šta raditi sa vrednostima koje nijedan uređaj nije trebalo da izmeri“. Za to postoji pipeline validacije kvaliteta od tri sloja, koji rade nezavisno od toga koji je izvor proizveo red:
Layer 1 (ingest): filterImpossible drops out-of-range values
Layer 2 (schema): quality column flags 'ok' / 'impossible' / 'suspect'
Layer 3 (daily): z-score sweep marks autonomic outliers as 'suspect'
Layer 1 — strogo odbacivanje nemogućeg na ingest-u. internal/health/quality.go definiše fiziološke opsege po metrikama: HRV 4–300 ms, RHR4 28–150 bpm, SpO25 70–100%, temperatura zgloba u okviru ±5°C od baseline-a i tako dalje. internal/handler/health.go::filterImpossible odbacuje out-of-range tačke pre insert-a i piše rate-limited log. Metrike bez podešenog opsega prolaze bez validacije. To je najjeftiniji gate, koji hvata najglasnije sensor failures — one koje bi inače slomile grafikon.
Layer 2 — kolona quality u metric_points. TEXT NOT NULL DEFAULT 'ok'. Vrednosti: ok, impossible, suspect. Partial index pokriva hot path WHERE quality='ok'. Baseline reads filtriraju se po ok, da anomalije ne bi otrovale rolling baseline od kog zavisi sama anomaly detection. ScoreVersion je podignut kada je taj filter povezan, jer je stari keširani daily_scores bio izračunat po drugačijim pravilima poverenja.
Layer 3 — meki z-score6 sweep. internal/storage/quality_audit.go::MarkSuspectPoints računa z-score za svaku metriku u odnosu na 30-dnevni rolling personal baseline. Vrednosti iznad 3σ dobijaju flag suspect. Kritično je što učestvuju samo autonomic metrics: HRV, RHR, SpO2, respiratory rate, wrist temperature, VO27, body mass. Behavioural metrics — koraci, kalorije, exercise minutes — eksplicitno su isključene. Njihova bimodalna rest/active distribucija učinila bi z-score bučnim i označavala svaki weekend hike kao anomaliju.
Layer 3 sweep pokreće se svake noći po tenant-u u 03:00 local. U trenutnoj production table to je 15 redova od 4,7 miliona, označenih kao suspect tokom cele istorije projekta. Primeri: respiratory_rate=34.5 za 2026-05-02, heart_rate_variability=186.22012 za 2026-05-10. Svaka takva tačka dovoljno je uverljiva da je ne treba brisati naslepo, i dovoljno neobična da njome ne treba hraniti baseline bez provere.
Kratak spisak pravila koja su preživela
Nekoliko reconciliation rules pojavilo se tokom iteracija, i upravo ona sada nose težinu u trenutnom sistemu. Svako postoji zato što je njegovo uklanjanje proizvodilo konkretan bug:
- Držati
sleep_unspecifiedodvojeno od staged sleep. Bez toga se vraća početna laž iz nekoliko prethodnih odeljaka. - Preferirati Apple Watch u odnosu na iPhone i druge izvore kada su oba prisutna i fiziologija to podržava. Bez toga midnight summary stub od RingConn-a pobeđuje u multi-source noći.
- Deduplikovati chart views drugačije od daily scoring-a. Grafikoni mogu da prikazuju overlapping ranges iz više izvora; daily score mora da izabere jedan da bi izbegao double-counting.
- Sačuvati prethodni red
daily_scoresumesto da malformed single-metric source obriše staged night. To je all-or-nothing gate iznad. - Pokretati soft z-score sweep samo nad autonomic metrics. Uključivanje behavioural metrics napravilo je „weekend warrior“ anomaly cluster, koji je označavao polovinu korisnikovih planinarenja.
- Filtrirati baseline reads po
quality='ok'. Inačeimpossiblevrednost, označena u Layer 1, i dalje može da uđe u baseline koji Layer 3 koristi za detekciju anomalija.
Tako izgleda cela priča o „laganju prećutkivanjem“. Nijedno pojedinačno pravilo ne izgleda naročito pametno. Pametan deo — do kog se došlo kroz nekoliko regresija — jeste to da reconciliation mora biti eksplicitan i na jednom mestu. Pet paralelnih SQL kopija ne ostaju usklađene. Kolona u šemi sa dva značenja ne ostaje poštena. Z-score sweep koji ne razlikuje autonomic metrics od behavioural metrics tiho sakrije jedan signal pod drugim.
Prava lekcija
Potrošački health podaci nisu dataset. To su pregovori između uređaja koji se ne slažu oko toga šta su izmerili. Nekad se ne slažu zato što imaju različite senzore. Nekad zato što je firmware bio drugačiji u trenutku merenja. Nekad zato što je jedan uređaj bio na punjaču, a drugi izmislio daily summary u ponoć. Zadatak sistema nije da „merge-uje“ izvore. Zadatak sistema je da odluči kome veruje svake noći, po eksplicitnom pravilu, i prizna kada to pravilo ne daje odgovor.
Prethodni članak bio je o poverenju u kolone: imena metrika moraju da se poklapaju sa onim što storage layer pod njima podrazumeva. Ovaj je o poverenju u izvor iza svake kolone svake noći. Isti podaci, tri uređaja, jedno pravilo po metrici i kolona kvaliteta koja označava sve ostalo.
Sledeći članak nastavlja od onoga što postaje moguće kada reconciliation radi stabilno. Kada svaka noć ima jedan izvor i eksplicitnu nepodobnost za dane koje nije moguće uskladiti, konačno se može postaviti pitanje: da li je ova ocena predvidiva? Ili smo samo opisivali juče i zvali to sutra?
Pametni prsten (Kina, od 2023.), koji neprekidno meri puls, HRV, nivo kiseonika u krvi, temperaturu kože i faze sna. Jeftiniji je i štedljiviji od Apple Watch-a, ali koristi sopstvenu metodologiju merenja koja se ne poklapa uvek sa Apple-ovom. Izvozi podatke kroz Apple HealthKit, zbog čega na kraju završava u istom pipeline-u sa Apple Watch-om. ↩︎
Varijabilnost srčanog ritma (HRV). Fluktuacija vremenskih intervala između uzastopnih otkucaja srca u milisekundama. Kontraintuitivno, veća varijabilnost je bolja: stabilan rad srca poput metronoma ukazuje na dominaciju simpatičkog nervnog sistema (stres, bolest, umor), dok srce koje slobodno menja tempo ukazuje na parasimpatički oporavak. ↩︎
Root Mean Square of Successive Differences. Vremenska metrika HRV-a: uzimaju se uzastopni R-R intervali (vreme između otkucaja), računaju se njihove razlike, kvadriraju se, usrednjavaju i iz njih se izvlači kvadratni koren. Metod je stabilan na kratkim snimcima (dovoljno je 5 minuta) i predstavlja standard u potrošačkoj nosivoj tehnologiji. RingConn koristi RMSSD, dok Apple Watch podrazumevano prikazuje bliski SDNN. ↩︎
Puls u mirovanju (RHR). Broj otkucaja srca izmeren tokom odmora, idealno u poslednjim satima noćnog sna. Postepeni rast RHR-a tokom nekoliko dana je klasičan signal bolesti ili preopterećenja. ↩︎
Zasićenje krvi kiseonikom (SpO2) — procenat hemoglobina koji prenosi kiseonik, izmeren optički kroz kožu. Normalne vrednosti u mirovanju se kreću u opsegu od 95–100%. ↩︎
Broj standardnih devijacija za koliko neka vrednost odstupa od proseka. Z-score 2 znači „dva puta dalje od proseka nego što je uobičajeno dnevno odstupanje“. Prag od 3σ u Sloju 3 odgovara vrednostima koje su dalje od proseka nego 99,7% istorijskih merenja. ↩︎
Maksimalna brzina potrošnje kiseonika tokom vrhunca opterećenja. Apple Watch je procenjuje posredno na osnovu kretanja pulsa tokom hodanja i trčanja, a ne direktnim merenjem. ↩︎