Skip to content

fix: round the imported Apple step total on Apple, matching Android - #1540

Merged
ryanbr merged 1 commit into
mainfrom
fix/apple-steps-rounding-parity
Aug 22, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/apple-steps-rounding-parity

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Follow-up to #1535. Found while reviewing that PR's Apple import mapping — not introduced by it.

The divergence

The same Apple Health export stored a different step count on each platform:

steps: d.steps.map { Int($0) }                      // Apple  — truncates
steps = d.steps?.let { Math.round(it).toInt() }     // Android — rounds

A fractional daily total is reachable rather than theoretical: AppleHealthAggregator accumulates per source (stepsBySource[…] += v) before the winning source's figure is taken, and nothing constrains a source to whole counts. On such a day Apple stored a total one lower than Android from the same file — stored-data divergence on an imported column, which is the class the parity contract exists to catch.

Why rounding is the side to converge on

This is an oversight, not a policy. Every other count and rate converted on the same rows already rounds — avgHr, maxHr, walkingHr on the AppleDaily row and restingHr on the DailyMetric row all use Int($0.rounded()). steps was the only field on the row that didn't, and Android rounds throughout. So Apple was inconsistent with itself before it was inconsistent with Android.

On the extraction

Both sites now go through one AppleHealthImport.stepsInt. I extracted it because the conversion lived inside a file-IO function and had no test seam at all — the same shape #1535 used for dailyMetricRow, so the test exercises the production conversion rather than a copy of it.

Swift's .rounded() is half-away-from-zero; Kotlin's Math.round is half-up. They differ only for negatives, which a step count cannot be — the test pins that agreement rather than assuming it, since it is the one input class where the two languages' rules could part company.

Scope

  • No Android change. It already rounds; this brings Apple to it.
  • No backfill. This corrects future imports. Re-importing an affected export now stores the value both platforms agree on.
  • The daily total's derivation was already identical and I checked rather than assumed: both platforms take max of stepsBySource (Morning recap: dedup on the reported night, not the calendar day (fixes #567 midnight re-fire) #589), both accumulate with +=. Only the final Double→Int step differed.
  • I swept for other truncating conversions — map { Int($0) } now appears nowhere in the Swift tree, so these two were the only ones.

Verification

  • app-build 32568897168 green on both legs, Test Strand at 1,191 tests, 0 failures — 1,188 before, so all three new tests ran rather than being silently skipped.
  • Doc lint clean.

One thing stated plainly: I did not revert-and-rerun to prove the tests fail against the old behaviour, as I have on recent PRs. Here it is arithmetic rather than logic — Int(10432.6) is 10432 and the test demands 10433 — so a CI cycle would confirm something already certain from the expression.

…1535 follow-up)

Found reviewing #1535. The same Apple Health export produced a different stored
step count on each platform:

  Apple    steps: d.steps.map { Int($0) }              truncates
  Android  steps = d.steps?.let { Math.round(it).toInt() }   rounds

A fractional daily total is reachable: the aggregator accumulates per source
(`stepsBySource[...] += v`) before taking the winning source's figure, and
nothing constrains a source to whole counts. On such a day Apple stored a total
one lower than Android for the same import -- stored-data divergence on an
imported column.

Rounding is the side to converge on, and this is an oversight rather than a
policy: every other count and rate converted on the SAME rows already rounds
(avgHr, maxHr, walkingHr, restingHr all use `Int($0.rounded())`), and Android
rounds throughout. `steps` was the only field on the row that did not.

Both conversion sites now route through one `stepsInt` helper. Extracted because
the mapping lived inside a file-IO function and had no test seam at all -- the
same shape #1535 used for `dailyMetricRow`, so a test exercises the production
conversion instead of a copy of it.

Swift's `.rounded()` is half-away-from-zero and Kotlin's `Math.round` is half-up.
They differ only for negatives, which a step count cannot be, and the test pins
that agreement rather than assuming it.

No Android change: it already rounds. No backfill -- this corrects future
imports, and re-importing an affected export now stores the same value both
platforms would.

Verified: doc lint clean; app-build for the app-target Swift -- see the PR.
@ryanbr
ryanbr merged commit 9b43879 into main Aug 22, 2026
4 checks passed
@ryanbr
ryanbr deleted the fix/apple-steps-rounding-parity branch August 22, 2026 11:04
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