diff --git a/CHANGELOG.md b/CHANGELOG.md index a670bba..7b32ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Docs/METHODOLOGY.md b/Docs/METHODOLOGY.md index 71d4b9a..d3776f8 100644 --- a/Docs/METHODOLOGY.md +++ b/Docs/METHODOLOGY.md @@ -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: @@ -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 diff --git a/README.md b/README.md index e2fe5a1..ecbddb3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Sources/SuperAgeCore/FitnessAgeCalculator.swift b/Sources/SuperAgeCore/FitnessAgeCalculator.swift index e317a55..23aca53 100644 --- a/Sources/SuperAgeCore/FitnessAgeCalculator.swift +++ b/Sources/SuperAgeCore/FitnessAgeCalculator.swift @@ -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 diff --git a/Sources/SuperAgeCore/FitnessAgeMobilityContext.swift b/Sources/SuperAgeCore/FitnessAgeMobilityContext.swift new file mode 100644 index 0000000..8929723 --- /dev/null +++ b/Sources/SuperAgeCore/FitnessAgeMobilityContext.swift @@ -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 { + 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 = [ + "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 = [ + "stand_hours" + ] +} diff --git a/Sources/SuperAgeCore/FitnessAgeProfile.swift b/Sources/SuperAgeCore/FitnessAgeProfile.swift index ae44aae..c6d7904 100644 --- a/Sources/SuperAgeCore/FitnessAgeProfile.swift +++ b/Sources/SuperAgeCore/FitnessAgeProfile.swift @@ -15,22 +15,36 @@ public struct FitnessAgeProfile: Codable, Equatable, Sendable { public var focusDomains: Set public var disabledMetricIds: Set + /// 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 { + disabledMetricIds.union(mobilityContext.inapplicableMetricIds) + } + public init( chronologicalAge: Int, biologicalSex: FitnessAgeBiologicalSex, excludedDomains: Set = [], focusDomains: Set = [], - disabledMetricIds: Set = [] + disabledMetricIds: Set = [], + 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 { @@ -39,6 +53,7 @@ public struct FitnessAgeProfile: Codable, Equatable, Sendable { case excludedDomains case focusDomains case disabledMetricIds + case mobilityContext } public init(from decoder: Decoder) throws { @@ -48,5 +63,9 @@ public struct FitnessAgeProfile: Codable, Equatable, Sendable { excludedDomains = try container.decodeIfPresent(Set.self, forKey: .excludedDomains) ?? [] focusDomains = try container.decodeIfPresent(Set.self, forKey: .focusDomains) ?? [] disabledMetricIds = try container.decodeIfPresent(Set.self, forKey: .disabledMetricIds) ?? [] + mobilityContext = try container.decodeIfPresent( + FitnessAgeMobilityContext.self, + forKey: .mobilityContext + ) ?? .ambulatory } } diff --git a/Tests/SuperAgeCoreTests/FitnessAgeMobilityContextTests.swift b/Tests/SuperAgeCoreTests/FitnessAgeMobilityContextTests.swift new file mode 100644 index 0000000..964ecdf --- /dev/null +++ b/Tests/SuperAgeCoreTests/FitnessAgeMobilityContextTests.swift @@ -0,0 +1,180 @@ +import Testing +import Foundation +import SuperAgeCore + +@Suite("FitnessAge mobility context") +struct FitnessAgeMobilityContextTests { + private static func metrics() -> FitnessAgeMetrics { + FitnessAgeMetrics( + restingHeartRate: 58, + vo2Max: 45, + heartRateVariability: 50, + respiratoryRate: 15, + walkingHeartRateAverage: 100, + stepCount: 10_000, + activeEnergy: 600, + exerciseTime: 45, + flightsClimbed: 10, + sixMinuteWalkTestDistance: 480, + standHours: 12, + stairAscentSpeed: 0.8, + stairDescentSpeed: 0.85, + sleepHours: 7.5, + bodyMassIndex: 23.5, + walkingSteadiness: 0.9, + walkingAsymmetry: 1.5, + walkingDoubleSupport: 22, + isSmoker: false, + timeInDaylight: 60 + ) + } + + private static func profile( + _ context: FitnessAgeMobilityContext + ) -> FitnessAgeProfile { + FitnessAgeProfile( + chronologicalAge: 42, + biologicalSex: .male, + mobilityContext: context + ) + } + + @Test("ambulatory is the default and leaves results unchanged") + func ambulatoryIsDefaultAndDoesNotChangeResults() { + let implicitProfile = FitnessAgeProfile(chronologicalAge: 42, biologicalSex: .male) + #expect(implicitProfile.mobilityContext == .ambulatory) + #expect(implicitProfile.effectiveDisabledMetricIds.isEmpty) + + let implicit = FitnessAgeCalculator().calculate( + FitnessAgeInput(profile: implicitProfile, metrics: Self.metrics()) + ) + let explicit = FitnessAgeCalculator().calculate( + FitnessAgeInput(profile: Self.profile(.ambulatory), metrics: Self.metrics()) + ) + + #expect(implicit == explicit) + } + + @Test("assisted ambulation removes step and gait derived instruments but keeps standing") + func assistedAmbulationRemovesStepAndGaitDerivedInstruments() { + let removed = FitnessAgeMobilityContext.assistedAmbulation.inapplicableMetricIds + + #expect(removed == [ + "steps", + "six_minute_walk_distance", + "flights_climbed", + "stair_ascent_speed", + "stair_descent_speed", + "walking_heart_rate_average", + "walking_steadiness", + "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("cane use is not an applicability boundary") + func caneUseIsNotAnApplicabilityBoundary() { + // Wrist step counting during cane use is reported at roughly two percent mean + // error, which is noise, not inapplicability. Cane users are `ambulatory`. + #expect(FitnessAgeMobilityContext.ambulatory.inapplicableMetricIds.isEmpty) + } + + @Test("non ambulatory keeps every domain scorable on applicable instruments") + func nonAmbulatoryKeepsEveryDomainScorable() { + let result = FitnessAgeCalculator().calculate( + FitnessAgeInput(profile: Self.profile(.nonAmbulatory), metrics: Self.metrics()) + ) + + // Activity survives on active energy and exercise time; lifestyle on smoking + // status and time in daylight. No domain collapses because of the context alone. + #expect(result.domainScores[.cardiovascular] != nil) + #expect(result.domainScores[.activity] != nil) + #expect(result.domainScores[.recovery] != nil) + #expect(result.domainScores[.bodyComposition] != nil) + #expect(result.domainScores[.lifestyle] != nil) + } + + @Test("inapplicable instruments are removed rather than scored as zero") + func inapplicableInstrumentsAreRemovedRatherThanScoredAsZero() { + let profile = Self.profile(.nonAmbulatory) + + // Same person, same applicable observations: one input simply omits the metrics + // the context cannot observe. Removing an instrument must be identical to never + // having supplied it, otherwise absence would be read as a low result. + var applicableOnly = Self.metrics() + applicableOnly.stepCount = nil + applicableOnly.flightsClimbed = nil + applicableOnly.sixMinuteWalkTestDistance = nil + applicableOnly.standHours = nil + applicableOnly.stairAscentSpeed = nil + applicableOnly.stairDescentSpeed = nil + applicableOnly.walkingHeartRateAverage = nil + applicableOnly.walkingSteadiness = nil + applicableOnly.walkingAsymmetry = nil + applicableOnly.walkingDoubleSupport = nil + + let contextDriven = FitnessAgeCalculator().calculate( + FitnessAgeInput(profile: profile, metrics: Self.metrics()) + ) + let omitted = FitnessAgeCalculator().calculate( + FitnessAgeInput( + profile: FitnessAgeProfile(chronologicalAge: 42, biologicalSex: .male), + metrics: applicableOnly + ) + ) + + #expect(abs(contextDriven.overallScore - omitted.overallScore) < 0.0001) + #expect(abs(contextDriven.fitnessAge - omitted.fitnessAge) < 0.0001) + } + + @Test("context composes with host disabled metric ids") + func contextComposesWithHostDisabledMetricIds() { + let profile = FitnessAgeProfile( + chronologicalAge: 42, + biologicalSex: .male, + disabledMetricIds: ["vo2max"], + mobilityContext: .nonAmbulatory + ) + + #expect(profile.effectiveDisabledMetricIds.contains("vo2max")) + #expect(profile.effectiveDisabledMetricIds.contains("steps")) + #expect(profile.disabledMetricIds == ["vo2max"]) + } + + @Test("profile decoding defaults mobility context to ambulatory") + func profileDecodingDefaultsMobilityContextToAmbulatory() throws { + let legacy = Data(#"{"chronologicalAge":42,"biologicalSex":"male"}"#.utf8) + let decoded = try JSONDecoder().decode(FitnessAgeProfile.self, from: legacy) + + #expect(decoded.mobilityContext == .ambulatory) + + let explicit = Data( + #"{"chronologicalAge":42,"biologicalSex":"male","mobilityContext":"nonAmbulatory"}"#.utf8 + ) + let decodedExplicit = try JSONDecoder().decode(FitnessAgeProfile.self, from: explicit) + + #expect(decodedExplicit.mobilityContext == .nonAmbulatory) + } + + @Test("profile round trips through Codable") + func profileRoundTripsThroughCodable() throws { + let profile = Self.profile(.assistedAmbulation) + let data = try JSONEncoder().encode(profile) + let decoded = try JSONDecoder().decode(FitnessAgeProfile.self, from: data) + + #expect(decoded == profile) + } +}