Skip to content

Resilience (1/2): per-person recovery-time engine (Experimental) - #15

Merged
ayiskakov merged 16 commits into
mainfrom
feat/resilience-engine
Sep 23, 2026
Merged

ayiskakov merged 16 commits into
mainfrom
feat/resilience-engine

Conversation

@ayiskakov

@ayiskakov ayiskakov commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

PR 1 of 2 for the Resilience plan (docs/superpowers/plans/2026-09-23-resilience.md). This PR adds the engine only. The Experimental UI is stacked on top of it.

What changes

ResilienceEngine (Packages/StrandAnalytics, pure) is a per-person version of the recovery time in Pyrkov et al., Nat Commun 2021. It is Experimental, and no score reads it.

  • Signals. ln(steps) is the published signal. Resting HR and ln(RMSSD) are labelled NOOP extensions. A day with no reading counts as missing, never as zero. Step totals under 100 count as unworn.
  • Window. The trailing 180 days. An estimate needs at least 90 observed days; before that the engine returns a collecting countdown.
  • Removing known structure. The per-weekday means and one linear trend are removed together, as an exact joint least-squares fit.
  • Fit. C(k) = A·exp(−k/τ) for k = 1…28, weighted by the number of pairs at each lag. A free amplitude A absorbs the day-level noise. Below A = 0.1 the series is read as having no measurable memory: τ sits at the floor and the interval spans the whole range.
  • Bias correction and interval. The plan said to use a parametric bootstrap. In a prototype, the plain basic-bootstrap interval covered the true τ in only about half of the seeds at τ ≥ 14, because the bias depends strongly on τ. The bootstrap is therefore inverted:
    • Simulate at each τ on a 16-point log grid, over the same observed days and through the same pipeline, with a seeded SplitMix64.
    • The estimate is the τ whose median fitted τ̂ equals the measured τ̂.
    • The interval is every τ whose central 90 % of fitted τ̂ covers the measured τ̂.
  • σ is the SD of the de-structured series (Pyrkov's second hallmark).
  • Knocks. A knock is a day at least 2 robust SDs from a rolling baseline. The baseline is the median of the prior 28 days, frozen when the knock starts. The day after the peak must also be more than 1σ from the baseline, so a single outlier day is not a knock. For each knock the engine gives the path back, the observed number of days back to baseline, and a fitted half-life. The half-life is shown only when the fitted curve explains at least half of the path and its decay constant is not at a search bound. Without that check, a flat fit stuck at the ceiling showed a 41.6-day half-life next to a return observed in 2 days.

Verification

This follows the AGENTS.md rule for signals derived from raw data: the engine must recover several injected values, not match one.

  • Oracle. oracles/resilience.json comes from the -O twin Tools/oracle-twins/resilience_twin.swift, which runs 480 analyses:
    • 100 seeds each at τ = 7, 14, 28 and 56 days, with a weekday pattern, a 30 % nugget and 20 % missing days;
    • 20 seeds each of white noise, a ×6 trend, and a weekly-only pattern.
  • Coverage of the 90 % interval: 86, 88, 89 and 93 of 100 seeds. The median estimates are 7.8, 15.0, 38.0 and 60.0 days, so the estimate tracks the injected value.
  • Controls: none of them reads as memory. The interval always reaches the 1-day floor, and 19 of 20 runs of each control sit exactly at the floor. The white-noise intervals stay open to more than 60 days.
  • Intervals are wide. The median high/low ratio is about 30. That is what one person's half-year can support, and it is the reason the UI will always show the interval.
  • Live re-runs. ResilienceOracleTests reads the coverage from the pinned file. It also re-runs the first seed of every group, plus one fully pinned run (the ACF and the knocks, from a series stored in the file), and requires a match to 1e-9. That shows the file came from this engine. A debug build cannot afford all 480 runs: each estimate simulates about 2,000 series.
  • Unit tests (ResilienceEngineTests) cover:
    • exact removal of the weekday pattern and the trend;
    • ACF pair counting around gaps;
    • exact-exponential fits at three values of τ;
    • the collecting countdown;
    • missing days versus zero days;
    • the 180-day window;
    • determinism, and the SplitMix64 reference sequence;
    • knocks with injected half-lives of 1.5, 3 and 6 days (recovered as 1.7, 3.6 and 5.1);
    • a negative knock, a knock still underway, a lone outlier that is not a knock, and a path that no single decay describes (this test fails without the half-life check).
  • swift test in Packages/StrandAnalytics: 2,129 tests, 0 failures. The Resilience tests take about 15 s in a debug build.
  • doc_comment_lint is clean.

No app-target, BLE, schema or UI-copy changes.

Fits A·exp(−k/τ) to the autocorrelation of a signal's daily fluctuations
(weekday pattern and linear trend removed jointly) over a trailing
180-day window, and corrects the fit's bias and sets its 90 % interval by
inverting a seeded parametric bootstrap. Also finds knocks (excursions
from a rolling median baseline) and times their return. Experimental;
nothing reads it into another score.
…acle

A 480-run -O study over OU series with known tau (7/14/28/56 days, weekly
structure, nugget, 20 % missing) plus white-noise, trend and weekly-only
controls. The test reads coverage from the pinned file and re-runs a
spread of its cases live.
So app-side tests can build a readout without running the engine.
…s its path

A flat return fit pinned at the 60-day search ceiling printed a 41.6-day
half-life beside a return observed in two days. The half-life now needs
the fitted curve to explain at least half of the path and a decay
constant off the search bounds; the pinned oracle loses exactly that one
ceiling half-life.
…n reads

Reads repo.dailyMetrics once, runs ResilienceEngine off the main actor for
steps (ending yesterday), resting HR and HRV, and computes the rolling
trend on demand with the headline result as its newest point.
…method sheet

Hero with the recovery time and its 90 % range on a log band beside the
cohort ticks, three signal chips (steps published, RHR and HRV labelled
as extensions), and a detail screen with the measured autocorrelation and
its fit, the last three knocks, the 12-month trend with its range, and
the day-to-day swing. Shown only when the Settings toggle is on.
ayiskakov and others added 5 commits September 23, 2026 15:19
Resilience (2/2): Experimental section in the Healthspan tab
…coverage docs

Knock was the one Resilience result type left without a public init, so
the app layer could read knocks but not build them. daysToBaseline counts
calendar days since the peak, not observed days. The header's coverage
range (85-92 %) excluded the pinned tau = 56 result (93) and named the
wrong test file.
The detail sheet loaded its trend once, on appear. A refresh while the sheet was open handed down a
new readout, but the trend kept ending on the old headline, so the chart's last point and the hero
could disagree. Key the load on the readout, which is now Equatable, and clear the stale trend first.
Day indices come from the local calendar date, but the knock and trend dates were built at noon UTC,
which is already the next day from UTC+12 onwards: a knock peaking on 5 Mar read 6 Mar in New Zealand.
Build the date at local noon on the day's calendar date, and pin it across zones from UTC-12 to UTC+14.
The line said nothing moved the signal more than 2 SD for two days running, but a knock also needs the
next reading after its peak to fall within 3 days and still sit at least 1 SD out. A gap in wear could
reject two consecutive days past 2 SD while the screen claimed there were none. Update all locales.
@ayiskakov
ayiskakov merged commit f86c17d into main Sep 23, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant