Skip to content

analytics: reduce a skin-temp window to one scale before anything aggregates it - #1708

Merged
ryanbr merged 2 commits into
mainfrom
skintemp-explore-kind
Aug 28, 2026
Merged

ryanbr merged 2 commits into
mainfrom
skintemp-explore-kind

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Addresses #1705, reported by @pipiche38 with the file and line references already done — I verified every citation against current main before writing this, and they were accurate.

What was wrong

skin_temp is deliberately bimodal: a WHOOP CSV import writes absolute wrist °C into the same field the computed pipeline fills with a signed deviation from the personal baseline.

Formatting survives that, because the per-value formatter re-derives the kind for each number — which is precisely why nothing looks broken. Aggregates do not. A min, max, mean, or window-over-window delta drawn across both kinds is arithmetic on two scales, and the result is plausible rather than obviously wrong.

The fix

The guard already existed and the Explorer paths had never adopted it. VitalSignsSummary / HealthVitalsLogic pick one kind and filter to it before touching a sparkline, and SkinTempDisplay already models the kind on both platforms with tests.

So this adds one decision beside it — dominantKind, the kind a mixed window reduces to, which is the newest entry's, matching what those two already do — rather than a fourth inline copy of the >= 20 check.

Applied at the one place each platform merges, not at each screen:

where what it merges
Swift Repository.exploreSeries the three layers collapsing into [String: Double]
Kotlin WhoopRepository.resolvedSeries imported ∪ computed identities, resolved first-wins

That covers every consumer at once, including two the report did not have to name: the Explorer correlation scan, and Compare's min–max chart normalisation on Android. I confirmed both sides emit ascending-by-day, which dominantKind depends on.

Android was exposed too

The report flagged this as untraced. It is affected, by a different route: Android's importer emits no metricSeries skin_temp row and there is no exploreSeries twin — but sourceCandidates unions the import identity with the computed -noop one, so first-wins can resolve older days to absolute and recent days to deviation. CompareScreen then takes values.minOrNull()/maxOrNull() unfiltered and normalises the chart across them.

Why reduce rather than convert

The report's option (a) — convert the import to a deviation at write time — fixes every downstream reader including unaudited ones, and I did consider it. Against it: it rewrites stored data irreversibly, needs a baseline that a fresh import-only install does not have (the reporter's own objection), and puts both platforms' baseline derivation under the byte-identical stored-data contract. Filtering keeps the newest reading intact and drops only older entries of the other kind.

Tests

The regression guard the report asked for, built from its own numbers — the 313-row 32.39…36.64 import against computed deviations inside ±0.3. It asserts the filtered window is single-scale, and pins the defect itself: unfiltered, that mean lands at 15.43 and is labelled as a deviation, because it falls below 20.

Verified

4,725 Android tests, 0 failures with --rerun-tasks --no-build-cache; SkinTempDisplayTest 10/10. The Swift decision compiled standalone and agreed with the Kotlin.

Both twins live in package/analytics code, so test (StrandAnalytics) and build-and-test cover them without app-build — which was the point of putting the filter in the repository merge rather than in MetricExplorerView.swift, as the report's own Verification section noted.

ryanbr added 2 commits August 28, 2026 21:21
…regates it

Reported by @pipiche38 in #1705, with the file and line references already done.

skin_temp is deliberately bimodal: a WHOOP CSV import writes ABSOLUTE wrist temperature into the
same field the computed pipeline fills with a signed deviation from the personal baseline. Formatting
survives that, because the per-value formatter re-derives the kind for each number, which is exactly
why nothing looks broken. Aggregates do not. A min, max, mean or window-over-window delta drawn
across both kinds is arithmetic on two different scales, and the result is plausible rather than
obviously wrong: on the reported account a handful of ~34 C readings lift a should-be-near-zero
deviation average past a full degree, and the mean is then itself below 20, so it gets labelled with
the deviation unit.

The guard for this already existed and both Explorer paths had simply never adopted it.
VitalSignsSummary and HealthVitalsLogic pick one kind and filter their series to it before touching a
sparkline; SkinTempDisplay already models the kind, on both platforms, with tests. So this adds one
decision beside it — dominantKind, the kind a mixed window reduces to, which is the newest entry's,
matching what those two already do — rather than a fourth inline copy of the >= 20 check.

Applied at the ONE place each platform merges the layers, not at each screen: Swift in
Repository.exploreSeries where the three layers collapse, Kotlin in WhoopRepository.resolvedSeries
where the imported and computed identities are resolved first-wins. That covers every consumer at
once, including two the report did not have to name: the Explorer correlation scan, and Compare's
min-max chart normalisation on Android.

Reducing rather than converting is deliberate. Converting the import to a deviation at write time
would fix every downstream reader too, but it rewrites stored data irreversibly, needs a baseline a
fresh import-only install does not have, and puts both platforms' baseline derivation under the
byte-identical stored-data contract. Filtering keeps the newest reading intact and drops only older
entries of the other kind.

The regression guard is the one the report asked for, built from its own numbers — the 313-row
32.39..36.64 import against computed deviations inside +/-0.3. It asserts the filtered window is
single-scale and pins the defect itself: unfiltered, that mean lands at 15.43 and is labelled as a
deviation because it falls below 20.

Verified: 4,725 Android tests, 0 failures with --rerun-tasks --no-build-cache; the Swift decision
compiled standalone and agreed with the Kotlin. Both twins live in package/analytics code, so default
CI covers them without app-build.
… paths

Re-review of the reduction. It was conditional on a check that happens to always pass.

exploreSeries opens with `guard source == "my-whoop" else { return await series(...) }`, and the
filter sat only on the other return path, so the delegating one still returned a mixed window. It is
unreachable today because MetricCatalog gives the only skin_temp descriptor the my-whoop source — but
that makes the guard correct only because of a fact in a different file, which is how the original bug
happened. Both paths now go through one helper, so "exploreSeries never returns a mixed skin_temp
window" holds by construction rather than by coincidence.

Verified rather than assumed while writing the helper's doc: series() really is ascending by day on
both its branches (ORDER BY day ASC in MetricSeriesStore, and unionMetricSeries sorts), which is what
dominantKind relies on; and exploreAllSeries does delegate per descriptor to exploreSeries, so the
correlation scan inherits this too.

Repository.swift is app-target, so the helper was compile-checked standalone first — including the
labeled-tuple key path, which is the one bit of syntax here worth not assuming.

Kotlin untouched: resolvedSeries has no analogous bypass, so both platforms now hold the same
unconditional invariant.
@ryanbr
ryanbr merged commit 51204d7 into main Aug 28, 2026
17 checks passed
@ryanbr
ryanbr deleted the skintemp-explore-kind branch August 28, 2026 09:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant