Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 16 additions & 17 deletions Docs/METHODOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
30 changes: 10 additions & 20 deletions Sources/SuperAgeCore/FitnessAgeMobilityContext.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,31 +20,26 @@ 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.
var inapplicableMetricIds: Set<String> {
switch self {
case .ambulatory:
return []
case .assistedAmbulation:
case .assistedMobility:
return Self.stepAndGaitDerivedMetricIds
case .nonAmbulatory:
return Self.stepAndGaitDerivedMetricIds.union(Self.uprightPostureMetricIds)
}
}

Expand All @@ -60,9 +55,4 @@ public extension FitnessAgeMobilityContext {
"walking_asymmetry",
"double_support"
]

/// Instruments that require standing.
static let uprightPostureMetricIds: Set<String> = [
"stand_hours"
]
}
39 changes: 16 additions & 23 deletions Tests/SuperAgeCoreTests/FitnessAgeMobilityContextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -146,7 +139,7 @@ struct FitnessAgeMobilityContextTests {
chronologicalAge: 42,
biologicalSex: .male,
disabledMetricIds: ["vo2max"],
mobilityContext: .nonAmbulatory
mobilityContext: .assistedMobility
)

#expect(profile.effectiveDisabledMetricIds.contains("vo2max"))
Expand All @@ -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)

Expand Down