Skip to content

Steps-calibration prompt: leftover calibration state can activate the 4.0 pipeline on a 5.0 (iOS) #1523

Description

@ryanbr

Second of two parity gaps found reviewing #1521. The first (an unset strap model reading as a 4.0) is fixed there; this one is left alone deliberately, because unlike that one it is a design question rather than an oversight.

The divergence

TodayView.stepsPipelineActive reads:

(WhoopModel(rawValue: selectedWhoopModelRaw)?.deviceFamily == .whoop4 && hasDayData)
    || profile.stepsCalibrationCoefficient > 0
    || profile.stepsManualCoefficient > 0
    || profile.stepsCalibrationSampleDays > 0

The three calibration terms sit outside the family check, so any one of them activates the steps pipeline no matter which strap is selected. Android's stepsCalibrationPrompt returns null for anything that is not a 4.0 before it looks at calibration state at all, so it cannot reach the same place.

Why that state is reachable

The calibration pass in IntelligenceEngine is not family-gated. It builds its points from per-day motion volume (store.gravitySamples) crossed with Apple Health step counts, and on the not-yet-calibrated branch it writes stepsCalibrationSampleDays = have with the coefficient left at 0.

So a 5.0 owner partway through accruing usable days holds sampleDays > 0 with coefficient == 0. On a date where the strap reported no steps and Apple has none either, the tile is blank, needsCalibration is true, and stepsCalibrationCaption returns "Need N more days where your phone also counted steps" — a 4.0-only message, on a strap that reports steps natively. The gear accessory appears with it.

Caveat on reachability: this assumes a 5.0 writes gravity samples, which is what the motion volume is built from. I could not settle that from the code and have not checked it against a 5.0. If a 5.0 produces no gravity rows then sampleDays stays 0 and the state is unreachable in practice, and this can be closed as theoretical. That is the thing to check first.

The design question

The OR is deliberate — the reasoning in the doc comment is that calibration state is profile-global rather than per-strap, so both halves measure the same user. That holds for someone who moved from a 4.0 to a 5.0 and kept their coefficient. It does not hold for the case above, where the sample-day counter is just a partially-filled counter and not evidence the user wants a step estimate.

Options, roughly in order of how much they change:

  1. Move the calibration terms inside the family check on iOS, matching Android exactly. Simplest, and loses the migrated-4.0-owner case the OR was written for.
  2. Gate the calibration pass itself on the family, so a non-4.0 never accrues sample days. Fixes the cause rather than the symptom, and is the deeper change of the two.
  3. Drop only the sampleDays > 0 term, keeping the two coefficient terms. A coefficient is evidence of a real calibration; a sample-day count is not. Narrowest fix, and keeps the migrated owner working.

(3) looks closest to the original intent, but any of them is a behaviour change worth deciding on rather than folding into a performance PR.

Whichever way it goes, both platforms should end up agreeing, since today they cannot produce the same answer for the same user.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions