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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ Algorithm changes must note affected metrics, formulas, weights, confidence logi

## Unreleased

## 0.2.0 - 2026-08-10

### Added

- Added `FitnessAgeMobilityContext` and `FitnessAgeProfile.mobilityContext`, a measurement-applicability input that declares which movement instruments can be observed for a person. Metrics produced by step and gait detection are removed before scoring when the declared context cannot produce them, instead of reaching the calculator as a value that reads like inactivity.
- Added `FitnessAgeProfile.effectiveDisabledMetricIds`, exposing the host-disabled metric IDs unioned with the IDs the mobility context cannot observe.
- Documented mobility context, the per-context applicability table, where each boundary comes from, and what is deliberately out of scope, with source anchors for step-counting applicability, step detection under walking aids, walking-aid error magnitude, activity targets across mobility contexts, and wheeled-mobility measurement error.

No result drift for existing callers. `mobilityContext` defaults to `ambulatory`, which removes no metric; encoded profiles that omit the field decode to `ambulatory`. Golden fixtures are unchanged.

Affected metrics under a non-default context: `steps`, `six_minute_walk_distance`, `flights_climbed`, `stair_ascent_speed`, `stair_descent_speed`, `walking_heart_rate_average`, `walking_steadiness`, `walking_asymmetry`, `double_support`, and the step-derived lifestyle metrics `movement_regularity`, `activity_consistency`, and `sedentary_time`. `stand_hours` is additionally removed under `nonAmbulatory` only. No formula, curve, local weight, domain weight, or confidence rule changed. Removal reuses the existing disabled-metric path, so domains renormalize over the observed local weights that remain.

## 0.1.0 - 2026-06-06

### Added
Expand Down
57 changes: 57 additions & 0 deletions Docs/METHODOLOGY.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,58 @@ Sleep uses an explicit fallback to preserve deterministic continuity when a call

Disabled metric IDs are applied before scoring by clearing the mapped metric fields from the input copy. Excluded domains are applied after domain scoring and remove those domains from results, weighted aggregation, confidence domain completeness, and metric counts.

## Mobility Context and Instrument Applicability

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. Scoring that as inactivity is a measurement error, not a fitness result.

`FitnessAgeProfile.mobilityContext` lets a host declare which movement instruments can be observed, so the calculator scores only applicable ones.

This is a measurement-applicability input. It is not a diagnosis, not a clinical classification, and not a statement about health status. It changes no threshold, curve, weight, or score.

| 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. |

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 |

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`.

### 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).
- 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.
- 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

This mechanism removes instruments that cannot be observed. It does not adjust targets for conditions where the instrument works but the expected value is debated, such as fatigue-related or mental-health-related conditions: step count is measurable there, and changing its target without an evidence anchor would be a scoring change presented as an applicability change. Such an adjustment would require an Algorithm RFC and its own source anchors.

Energy-based instruments are not free of population-specific measurement error either: waist-worn accelerometry has been shown to underestimate energy expenditure in manual wheelchair users, and device placement changes the error materially (Nightingale et al., 2015). SuperAgeCore consumes host-normalized values and does not correct for sensor placement.

Hosts remain responsible for how this value is collected and for the privacy and consent obligations that attach to it.

## Domain Metric Weights

Within each domain, observed metric scores are weighted locally, then normalized by the total observed local weight:
Expand Down Expand Up @@ -181,6 +233,11 @@ uses package-specific interpolation to convert observations into a common
| Six-minute walk distance | Apple HealthKit six-minute walk distance behavior: https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifiersixminutewalktestdistance | Age bands and activity-domain weighting |
| Blood glucose | CDC and ADA fasting glucose thresholds: https://www.cdc.gov/diabetes/diabetes-testing/index.html and https://diabetes.org/about-diabetes/diagnosis | Score levels around normal, elevated, and high ranges |
| Sleep duration | National Sleep Foundation duration recommendations discussed in population research: https://pmc.ncbi.nlm.nih.gov/articles/PMC8201191/ | Midpoint scoring and duration deviation curve |
| Step counting applicability | Suzuki et al., JMIR Formative Research, 2025, stating that step counting applies only to ambulatory patients and degrades with irregular gait: https://pmc.ncbi.nlm.nih.gov/articles/PMC11999377/ | Mapping from mobility context to the set of unobservable metric IDs |
| Step detection under walking aids | Kooner et al., Journal of Orthopaedic Surgery and Research, 2024, reporting poor validity of wrist and hip activity monitors in patients using gait aids: https://pmc.ncbi.nlm.nih.gov/articles/PMC11247726/ | Placing walker and crutch use outside the ambulatory context |
| Walking aid error magnitude | Jaworski et al., International Journal of Environmental Research and Public Health, 2025, reporting 31.2 percent wrist step-count error with a walker and roughly two percent with a cane: https://pmc.ncbi.nlm.nih.gov/articles/PMC12294748/ | Keeping cane use inside the ambulatory context |
| Activity targets across mobility contexts | Bull et al., British Journal of Sports Medicine, 2020, WHO 2020 guidelines adding recommendations for people living with chronic conditions or disability: https://pubmed.ncbi.nlm.nih.gov/33239350/ | Keeping the same activity instruments and targets instead of substituting context-specific ones |
| Wheeled-mobility activity measurement error | Nightingale et al., PLoS One, 2015, on accelerometer placement and energy expenditure error in manual wheelchair users: https://pmc.ncbi.nlm.nih.gov/articles/PMC4425541/ | Documented limitation only; the package consumes host-normalized values |

## Scoring Curves

Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ let domainScores = result.domainScores

`result.fitnessAge` is intended for informational fitness and wellness experiences. It is not a diagnosis or clinical risk estimate.

## Mobility Context

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. Declare the applicable instruments so those metrics are removed rather than scored as inactivity:

```swift
let profile = FitnessAgeProfile(
chronologicalAge: 42,
biologicalSex: .male,
mobilityContext: .nonAmbulatory
)
```

`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.

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.

## Algorithm Modes

`FitnessAgeConfiguration.default` uses the `evidenceFirst` algorithm mode. This mode keeps confidence as an evidence-completeness field and maps the normalized score symmetrically around chronological age.
Expand Down
4 changes: 3 additions & 1 deletion Sources/SuperAgeCore/FitnessAgeCalculator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ public struct FitnessAgeCalculator: Sendable {
)
}

let filteredMetrics = input.metrics.filteringDisabledMetricIds(input.profile.disabledMetricIds)
let filteredMetrics = input.metrics.filteringDisabledMetricIds(
input.profile.effectiveDisabledMetricIds
)
let domainScores = FitnessAgeDomainScorer().domainScores(
metrics: filteredMetrics,
profile: input.profile
Expand Down
68 changes: 68 additions & 0 deletions Sources/SuperAgeCore/FitnessAgeMobilityContext.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Foundation

/// Declares which movement instruments can be observed 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.
/// 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
/// tell "the instrument did not apply" from "the person was inactive".
///
/// The context never changes a threshold, a curve, a weight, or a score. It only removes
/// metrics that cannot be observed, after which the existing domain normalization divides
/// by the observed local weights that remain.
public enum FitnessAgeMobilityContext: String, Codable, Sendable, CaseIterable {
/// Walks unaided, or with a cane.
///
/// Every movement instrument applies. Cane use belongs here: wrist step counting during
/// cane use has been reported at roughly two percent mean error, which is measurement
/// noise rather than an applicability boundary.
case ambulatory

/// Walks with a device that provides weight-bearing support, such as a walker or crutches.
///
/// 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
}

public extension FitnessAgeMobilityContext {
/// Metric IDs whose observation depends on a movement pattern 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:
return Self.stepAndGaitDerivedMetricIds
case .nonAmbulatory:
return Self.stepAndGaitDerivedMetricIds.union(Self.uprightPostureMetricIds)
}
}

/// Instruments produced by step counting or gait characterization.
static let stepAndGaitDerivedMetricIds: Set<String> = [
"steps",
"six_minute_walk_distance",
"flights_climbed",
"stair_ascent_speed",
"stair_descent_speed",
"walking_heart_rate_average",
"walking_steadiness",
"walking_asymmetry",
"double_support"
]

/// Instruments that require standing.
static let uprightPostureMetricIds: Set<String> = [
"stand_hours"
]
}
21 changes: 20 additions & 1 deletion Sources/SuperAgeCore/FitnessAgeProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,36 @@ public struct FitnessAgeProfile: Codable, Equatable, Sendable {
public var focusDomains: Set<FitnessAgeDomain>
public var disabledMetricIds: Set<String>

/// Which movement instruments can be observed for this person.
///
/// Defaults to `.ambulatory`, which applies every metric and leaves results identical
/// to callers that never set it.
public var mobilityContext: FitnessAgeMobilityContext

public var isValidForCalculation: Bool {
chronologicalAge > 0
}

/// Metric IDs removed before scoring: the host-disabled set plus the metrics that the
/// declared mobility context cannot observe.
public var effectiveDisabledMetricIds: Set<String> {
disabledMetricIds.union(mobilityContext.inapplicableMetricIds)
}

public init(
chronologicalAge: Int,
biologicalSex: FitnessAgeBiologicalSex,
excludedDomains: Set<FitnessAgeDomain> = [],
focusDomains: Set<FitnessAgeDomain> = [],
disabledMetricIds: Set<String> = []
disabledMetricIds: Set<String> = [],
mobilityContext: FitnessAgeMobilityContext = .ambulatory
) {
self.chronologicalAge = chronologicalAge
self.biologicalSex = biologicalSex
self.excludedDomains = excludedDomains
self.focusDomains = focusDomains
self.disabledMetricIds = disabledMetricIds
self.mobilityContext = mobilityContext
}

private enum CodingKeys: String, CodingKey {
Expand All @@ -39,6 +53,7 @@ public struct FitnessAgeProfile: Codable, Equatable, Sendable {
case excludedDomains
case focusDomains
case disabledMetricIds
case mobilityContext
}

public init(from decoder: Decoder) throws {
Expand All @@ -48,5 +63,9 @@ public struct FitnessAgeProfile: Codable, Equatable, Sendable {
excludedDomains = try container.decodeIfPresent(Set<FitnessAgeDomain>.self, forKey: .excludedDomains) ?? []
focusDomains = try container.decodeIfPresent(Set<FitnessAgeDomain>.self, forKey: .focusDomains) ?? []
disabledMetricIds = try container.decodeIfPresent(Set<String>.self, forKey: .disabledMetricIds) ?? []
mobilityContext = try container.decodeIfPresent(
FitnessAgeMobilityContext.self,
forKey: .mobilityContext
) ?? .ambulatory
}
}
Loading