Repository navigation
analytics: reduce a skin-temp window to one scale before anything aggregates it - #1708
Merged
Merged
Conversation
…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.
5 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_tempis 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/HealthVitalsLogicpick one kind and filter to it before touching a sparkline, andSkinTempDisplayalready 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>= 20check.Applied at the one place each platform merges, not at each screen:
Repository.exploreSeries[String: Double]WhoopRepository.resolvedSeriesThat 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
dominantKinddepends on.Android was exposed too
The report flagged this as untraced. It is affected, by a different route: Android's importer emits no
metricSeriesskin_temprow and there is noexploreSeriestwin — butsourceCandidatesunions the import identity with the computed-noopone, so first-wins can resolve older days to absolute and recent days to deviation.CompareScreenthen takesvalues.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;SkinTempDisplayTest10/10. The Swift decision compiled standalone and agreed with the Kotlin.Both twins live in package/analytics code, so
test (StrandAnalytics)andbuild-and-testcover them without app-build — which was the point of putting the filter in the repository merge rather than inMetricExplorerView.swift, as the report's own Verification section noted.