From 47e6973a88ad2ac5ad288873a67279409b2a6e03 Mon Sep 17 00:00:00 2001 From: Stefano Novelli Date: Mon, 10 Aug 2026 12:08:29 +0200 Subject: [PATCH] fix: collapse mobility context to one applicability boundary Two corrections to 0.2.0, both found by checking the behavior against Apple Watch rather than reasoning from the metric names. `stand_hours` is no longer removed by any context. In wheelchair mode Apple Watch turns the Stand ring into a Roll ring, counting hours that contain at least a minute of movement against the same daily goal, so hourly movement stays observable. The 0.2.0 table was wrong to drop it. With that fixed, `assistedAmbulation` and `nonAmbulatory` removed exactly the same instruments, so the distinction described no distinct behavior. They are replaced by a single `assistedMobility` case covering weight-bearing walking aids and wheeled mobility, which is where the evidence actually draws the line: step and gait detection fails for both, while cane use stays within reported measurement noise and remains `ambulatory`. Callers on 0.2.0 map both removed cases to `assistedMobility`. Encoded profiles carrying a removed raw value decode to `ambulatory` through the existing decodeIfPresent default. Behavior for `ambulatory` is unchanged and golden fixtures are unchanged. Signed-off-by: Stefano Novelli --- CHANGELOG.md | 9 +++++ Docs/METHODOLOGY.md | 33 ++++++++-------- README.md | 4 +- .../FitnessAgeMobilityContext.swift | 30 +++++--------- .../FitnessAgeMobilityContextTests.swift | 39 ++++++++----------- 5 files changed, 53 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b32ef8..cb39198 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Algorithm changes must note affected metrics, formulas, weights, confidence logi ## Unreleased +## 0.3.0 - 2026-08-10 + +### Changed + +- Replaced the three-value mobility context with two values: `ambulatory` and `assistedMobility`. `assistedAmbulation` and `nonAmbulatory` are removed. Weight-bearing walking aids and wheeled mobility share one applicability boundary, because step and gait detection fails for both, so a separate case described no separate behavior. +- `stand_hours` is no longer removed by any context. On Apple Watch, wheelchair mode turns the Stand ring into a Roll ring that counts hours containing at least a minute of movement against the same daily goal, so the instrument remains observable. The 0.2.0 applicability table was wrong on this point. + +Callers on 0.2.0 must map `assistedAmbulation` and `nonAmbulatory` to `assistedMobility`. Encoded profiles carrying a removed raw value decode to `ambulatory` through the existing `decodeIfPresent` default. Behavior for `ambulatory` is unchanged and golden fixtures are unchanged. + ## 0.2.0 - 2026-08-10 ### Added diff --git a/Docs/METHODOLOGY.md b/Docs/METHODOLOGY.md index d3776f8..fd2a0d8 100644 --- a/Docs/METHODOLOGY.md +++ b/Docs/METHODOLOGY.md @@ -89,37 +89,36 @@ This is a measurement-applicability input. It is not a diagnosis, not a clinical | Context | Meaning | | --- | --- | | `ambulatory` | Walks unaided, or with a cane. Every movement instrument applies. This is the default. | -| `assistedAmbulation` | Walks with a device that provides weight-bearing support, such as a walker or crutches. | -| `nonAmbulatory` | Does not ambulate; movement is wheeled. | +| `assistedMobility` | Uses a mobility aid that bears weight, such as a walker or crutches, or uses wheeled mobility. | Metric applicability by context: -| Metric ID | `ambulatory` | `assistedAmbulation` | `nonAmbulatory` | -| --- | :---: | :---: | :---: | -| `steps` | applies | not observable | not observable | -| `six_minute_walk_distance` | applies | not observable | not observable | -| `flights_climbed` | applies | not observable | not observable | -| `stair_ascent_speed` | applies | not observable | not observable | -| `stair_descent_speed` | applies | not observable | not observable | -| `walking_heart_rate_average` | applies | not observable | not observable | -| `walking_steadiness` | applies | not observable | not observable | -| `walking_asymmetry` | applies | not observable | not observable | -| `double_support` | applies | not observable | not observable | -| `stand_hours` | applies | applies | not observable | +| Metric ID | `ambulatory` | `assistedMobility` | +| --- | :---: | :---: | +| `steps` | applies | not observable | +| `six_minute_walk_distance` | applies | not observable | +| `flights_climbed` | applies | not observable | +| `stair_ascent_speed` | applies | not observable | +| `stair_descent_speed` | applies | not observable | +| `walking_heart_rate_average` | applies | not observable | +| `walking_steadiness` | applies | not observable | +| `walking_asymmetry` | applies | not observable | +| `double_support` | applies | not observable | +| `stand_hours` | applies | applies | Metrics that are not observable for the declared context are unioned with `disabledMetricIds` before scoring, so they follow exactly the documented removal path of a host-disabled metric: the field is cleared, the domain divides by the observed local weights that remain, and data quality averages over observed components only. Removing an inapplicable instrument is therefore identical to never having supplied it. The lifestyle metrics `movement_regularity`, `activity_consistency`, and `sedentary_time` are derived from `stepCount`, so they drop out with it and require no separate mapping. -No domain collapses from the context alone. Under `nonAmbulatory`, activity remains scorable through `activeEnergy` and `exerciseTime`, and lifestyle remains scorable through `isSmoker` and `timeInDaylight`. +No domain collapses from the context alone. Under `assistedMobility`, activity remains scorable through `activeEnergy` and `exerciseTime`, and lifestyle remains scorable through `isSmoker` and `timeInDaylight`. ### Where the Boundaries Come From - Step counting is described in measurement research as applicable only to ambulatory populations, with detection degrading further under slow or irregular gait (Suzuki et al., 2025). -- Weight-bearing walking aids break step detection rather than merely adding noise. Wrist and hip devices have been reported as showing poor validity in patients using gait aids, recording near-zero step counts during walker use, with agreement recovering only once the aid was no longer used (Kooner et al., 2024). Wrist step-count error during walker use has been reported at 31.2 percent (Jaworski et al., 2025). +- Weight-bearing walking aids break step detection rather than merely adding noise. Wrist and hip devices have been reported as showing poor validity in patients using gait aids, recording near-zero step counts during walker use, with agreement recovering only once the aid was no longer used (Kooner et al., 2024). Wrist step-count error during walker use has been reported at 31.2 percent (Jaworski et al., 2025). Wheeled mobility produces no steps at all, so both patterns share one context. - Cane use is on the other side of that boundary. Wrist step counting during cane use has been reported at roughly two percent mean error, which is measurement noise, not inapplicability, so cane users are `ambulatory` (Jaworski et al., 2025). - Gait-quality instruments derive from the same step and gait characterization, so they follow step counting rather than forming a separate tier. -- Standing is observable with a walker or crutches and not with wheeled mobility, which is the only instrument difference between the two non-default contexts. +- Hourly movement remains observable in every context, so `stand_hours` is never removed. On Apple Watch, wheelchair mode turns the Stand ring into a Roll ring that counts hours containing at least a minute of movement, against the same daily goal. - Activity targets themselves are not context-specific. The WHO 2020 guidelines added recommendations for people living with chronic conditions or disability, so `activeEnergy` and `exerciseTime` remain the applicable activity instruments rather than being replaced by different targets (Bull et al., 2020). ### Deliberately Out of Scope diff --git a/README.md b/README.md index ecbddb3..73bacf1 100644 --- a/README.md +++ b/README.md @@ -77,11 +77,11 @@ Many Apple Health-derived metrics are produced by step and gait detection. When let profile = FitnessAgeProfile( chronologicalAge: 42, biologicalSex: .male, - mobilityContext: .nonAmbulatory + mobilityContext: .assistedMobility ) ``` -`ambulatory` is the default and removes nothing; it covers unaided walking and cane use. `assistedAmbulation` covers walkers and crutches and removes step-derived and gait-derived instruments while keeping standing. `nonAmbulatory` also removes standing. Domains renormalize over the instruments that remain, so no threshold, curve, or weight changes. +`ambulatory` is the default and removes nothing; it covers unaided walking and cane use. `assistedMobility` covers walkers, crutches and wheeled mobility, and removes the step-derived and gait-derived instruments. Hourly movement is kept in both: on Apple Watch the Stand ring becomes a Roll ring in wheelchair mode. Domains renormalize over the instruments that remain, so no threshold, curve, or weight changes. This is a measurement-applicability input, not a diagnosis or clinical classification. Hosts are responsible for how the value is collected and for the consent and privacy obligations that attach to it. See [Docs/METHODOLOGY.md](Docs/METHODOLOGY.md) for the per-context applicability table and source anchors. diff --git a/Sources/SuperAgeCore/FitnessAgeMobilityContext.swift b/Sources/SuperAgeCore/FitnessAgeMobilityContext.swift index 8929723..0a6e7f0 100644 --- a/Sources/SuperAgeCore/FitnessAgeMobilityContext.swift +++ b/Sources/SuperAgeCore/FitnessAgeMobilityContext.swift @@ -1,9 +1,9 @@ import Foundation -/// Declares which movement instruments can be observed for the person being scored. +/// Declares whether step and gait detection can produce valid values for the person being scored. /// /// This is a measurement-applicability input, not a clinical classification and not a -/// diagnosis. Several Apple Health-derived metrics are produced by step and gait detection. +/// diagnosis. Many Apple Health-derived metrics are produced by step and gait detection. /// When that detection cannot run, the host does not receive a low value, it receives a /// value that does not describe the person: wearables have been reported as recording zero /// steps for someone walking with a walker. Without this declaration the calculator cannot @@ -20,20 +20,17 @@ public enum FitnessAgeMobilityContext: String, Codable, Sendable, CaseIterable { /// noise rather than an applicability boundary. case ambulatory - /// Walks with a device that provides weight-bearing support, such as a walker or crutches. + /// Uses a mobility aid that bears weight, such as a walker or crutches, or uses wheeled mobility. /// - /// Step and gait detection is unreliable for this pattern, so step-derived and - /// gait-derived instruments do not apply. Standing is still observable. - case assistedAmbulation - - /// Does not ambulate; movement is wheeled. - /// - /// No step-derived, gait-derived, or standing instrument applies. - case nonAmbulatory + /// Step and gait detection does not produce valid values for these movement patterns, so + /// step-derived and gait-derived instruments do not apply. Hourly movement is still + /// observable: on Apple Watch the Stand ring becomes a Roll ring in wheelchair mode and + /// counts hours containing at least a minute of movement, so `stand_hours` is retained. + case assistedMobility } public extension FitnessAgeMobilityContext { - /// Metric IDs whose observation depends on a movement pattern this context does not produce. + /// Metric IDs whose observation depends on step or gait detection this context does not produce. /// /// These IDs are unioned with `FitnessAgeProfile.disabledMetricIds` before scoring, so /// they follow exactly the same documented removal path as a host-disabled metric. @@ -41,10 +38,8 @@ public extension FitnessAgeMobilityContext { switch self { case .ambulatory: return [] - case .assistedAmbulation: + case .assistedMobility: return Self.stepAndGaitDerivedMetricIds - case .nonAmbulatory: - return Self.stepAndGaitDerivedMetricIds.union(Self.uprightPostureMetricIds) } } @@ -60,9 +55,4 @@ public extension FitnessAgeMobilityContext { "walking_asymmetry", "double_support" ] - - /// Instruments that require standing. - static let uprightPostureMetricIds: Set = [ - "stand_hours" - ] } diff --git a/Tests/SuperAgeCoreTests/FitnessAgeMobilityContextTests.swift b/Tests/SuperAgeCoreTests/FitnessAgeMobilityContextTests.swift index 964ecdf..8efbc7b 100644 --- a/Tests/SuperAgeCoreTests/FitnessAgeMobilityContextTests.swift +++ b/Tests/SuperAgeCoreTests/FitnessAgeMobilityContextTests.swift @@ -55,9 +55,9 @@ struct FitnessAgeMobilityContextTests { #expect(implicit == explicit) } - @Test("assisted ambulation removes step and gait derived instruments but keeps standing") - func assistedAmbulationRemovesStepAndGaitDerivedInstruments() { - let removed = FitnessAgeMobilityContext.assistedAmbulation.inapplicableMetricIds + @Test("assisted mobility removes step and gait derived instruments") + func assistedMobilityRemovesStepAndGaitDerivedInstruments() { + let removed = FitnessAgeMobilityContext.assistedMobility.inapplicableMetricIds #expect(removed == [ "steps", @@ -70,19 +70,13 @@ struct FitnessAgeMobilityContextTests { "walking_asymmetry", "double_support" ]) - // Standing remains observable with a walker or crutches. - #expect(!removed.contains("stand_hours")) } - @Test("non ambulatory additionally removes standing instruments") - func nonAmbulatoryAdditionallyRemovesStandingInstruments() { - let removed = FitnessAgeMobilityContext.nonAmbulatory.inapplicableMetricIds - - #expect(removed.isSuperset(of: FitnessAgeMobilityContext.assistedAmbulation.inapplicableMetricIds)) - #expect(removed.contains("stand_hours")) - #expect(removed.subtracting( - FitnessAgeMobilityContext.assistedAmbulation.inapplicableMetricIds - ) == ["stand_hours"]) + @Test("hourly movement stays observable under assisted mobility") + func hourlyMovementStaysObservableUnderAssistedMobility() { + // Apple Watch turns the Stand ring into a Roll ring in wheelchair mode and counts + // hours containing at least a minute of movement, so the instrument still applies. + #expect(!FitnessAgeMobilityContext.assistedMobility.inapplicableMetricIds.contains("stand_hours")) } @Test("cane use is not an applicability boundary") @@ -92,10 +86,10 @@ struct FitnessAgeMobilityContextTests { #expect(FitnessAgeMobilityContext.ambulatory.inapplicableMetricIds.isEmpty) } - @Test("non ambulatory keeps every domain scorable on applicable instruments") - func nonAmbulatoryKeepsEveryDomainScorable() { + @Test("assisted mobility keeps every domain scorable on applicable instruments") + func assistedMobilityKeepsEveryDomainScorable() { let result = FitnessAgeCalculator().calculate( - FitnessAgeInput(profile: Self.profile(.nonAmbulatory), metrics: Self.metrics()) + FitnessAgeInput(profile: Self.profile(.assistedMobility), metrics: Self.metrics()) ) // Activity survives on active energy and exercise time; lifestyle on smoking @@ -109,7 +103,7 @@ struct FitnessAgeMobilityContextTests { @Test("inapplicable instruments are removed rather than scored as zero") func inapplicableInstrumentsAreRemovedRatherThanScoredAsZero() { - let profile = Self.profile(.nonAmbulatory) + let profile = Self.profile(.assistedMobility) // Same person, same applicable observations: one input simply omits the metrics // the context cannot observe. Removing an instrument must be identical to never @@ -118,7 +112,6 @@ struct FitnessAgeMobilityContextTests { applicableOnly.stepCount = nil applicableOnly.flightsClimbed = nil applicableOnly.sixMinuteWalkTestDistance = nil - applicableOnly.standHours = nil applicableOnly.stairAscentSpeed = nil applicableOnly.stairDescentSpeed = nil applicableOnly.walkingHeartRateAverage = nil @@ -146,7 +139,7 @@ struct FitnessAgeMobilityContextTests { chronologicalAge: 42, biologicalSex: .male, disabledMetricIds: ["vo2max"], - mobilityContext: .nonAmbulatory + mobilityContext: .assistedMobility ) #expect(profile.effectiveDisabledMetricIds.contains("vo2max")) @@ -162,16 +155,16 @@ struct FitnessAgeMobilityContextTests { #expect(decoded.mobilityContext == .ambulatory) let explicit = Data( - #"{"chronologicalAge":42,"biologicalSex":"male","mobilityContext":"nonAmbulatory"}"#.utf8 + #"{"chronologicalAge":42,"biologicalSex":"male","mobilityContext":"assistedMobility"}"#.utf8 ) let decodedExplicit = try JSONDecoder().decode(FitnessAgeProfile.self, from: explicit) - #expect(decodedExplicit.mobilityContext == .nonAmbulatory) + #expect(decodedExplicit.mobilityContext == .assistedMobility) } @Test("profile round trips through Codable") func profileRoundTripsThroughCodable() throws { - let profile = Self.profile(.assistedAmbulation) + let profile = Self.profile(.assistedMobility) let data = try JSONEncoder().encode(profile) let decoded = try JSONDecoder().decode(FitnessAgeProfile.self, from: data)