Skip to content

Wave 0 scoring: wire computed-but-disconnected methodology into Rest + readiness#456

Open
vishk23 wants to merge 4 commits into
ryanbr:mainfrom
vishk23:upstream-pr/scoring-wave0
Open

Wave 0 scoring: wire computed-but-disconnected methodology into Rest + readiness#456
vishk23 wants to merge 4 commits into
ryanbr:mainfrom
vishk23:upstream-pr/scoring-wave0

Conversation

@vishk23

@vishk23 vishk23 commented Jul 14, 2026

Copy link
Copy Markdown

Wave 0 scoring: wire methodology that's computed but wasn't reaching the score

Four small, additive scoring fixes where the rigorous input already existed but wasn't feeding the score. Each is one source file + tests, degrades honestly on thin history, and leaves cold-start behavior unchanged. Opening for review + validation — if any of these read wrong on your own data, that's exactly the feedback I'm after.

SL1 · real sleep regularity into Rest

IntelligenceEngine threaded habitualMidsleepSec into AnalyticsEngine.analyzeDay but never sleepConsistency — so Rest's consistency term ran on the neutral-0.5 default (a flat +5 for everyone, every night), while VitalityEngine.sleepConsistency was computed and used only by the vitality result. Now threaded through, computed once from the trailing 28-night history off the same session read as the midsleep learner (midsleep result byte-identical).

T1 · population-anchored, age-floored sleep need

Rest's duration term ran against a fixed 8h. New Rest.personalizedNeedHours(nightlyHours:age:): the upper quartile of the user's unrestricted nights, floored at the age-appropriate population target (8h adult / 9h <18) so a chronic under-sleeper's need can only adjust up for genuine long sleepers, never collapse toward their own deficit. Uses TST (span×efficiency), not in-bed span.

RD1 · log-domain readiness HRV z (narrow)

ReadinessEngine z-scored HRV in raw ms; RMSSD is right-skewed, so a symmetric z over-weights the long upper tail. Now lnRMSSD (Plews/Altini; matches this app's own HRVReadiness), for the HRV signal only — RHR/resp are ~normal and stay linear. Deliberately narrow: the full HRVReadiness SWC-tier engine stays behind its experimental flag until validated on real data.

RD-confidence · baseline-density confidence on the readiness read

Readiness scored off as few as 7 baseline nights with no confidence shown, while Charge already carries a ScoreConfidence. Readiness now carries one (calibrating / building / solid) from HRV baseline density. Analytics enabler — surfacing it on the Today card is a follow-up.

Validation

  • Unit + archetype tests for each: RestNeedTests, RestWiringImpactTests, RD1LogDomainTests, RDConfidenceTests.
  • SL1/T1 additionally validated against ~90 days of real Oura + WHOOP history — which caught an in-bed-span-vs-TST bug (need over-counted ~0.85h; fixed to feed TST).
  • Full StrandAnalytics suite green; macOS build green.

Notes

  • Kotlin/Android twin included and gradle-tested: RestScorer.personalizedNeedHours/populationNeedFloorHours, log-domain readiness HRV z-score, and ScoreConfidence.readiness, with RestNeedTest/RestWiringImpactTest/RD1LogDomainTest/RDConfidenceTest transcribing the Swift inputs + expected values (byte-identical parity). ./gradlew :app:testFullDebugUnitTest green — 13 new tests, existing analytics suites unchanged.
  • All changes additive; nothing changes for callers that don't have the history yet.

vishk23 added 3 commits July 14, 2026 10:36
…into Rest (Wave 0 · SL1/T1)

Rest was scored on a flat neutral-0.5 consistency and a fixed 8h need: despite the
"caller supplies it from history" contract, IntelligenceEngine never threaded either
into AnalyticsEngine.analyzeDay (it only threaded habitualMidsleepSec).

- T1: new AnalyticsEngine.Rest.personalizedNeedHours(nightlyHours:age:) — upper-quartile of
  the user's unrestricted nights, floored at the age-appropriate population TARGET (8h adult /
  9h <18) so a chronic under-sleeper's need can only adjust UP for genuine long sleepers, never
  collapse toward their own deficit. Cold-start returns the population default.
- SL1: thread real VitalityEngine.sleepConsistency + that personal need into the per-day
  analyzeDay call, computed once from the trailing per-night durations (recent 28 nights for
  regularity, full history for the need quantile). Extends computeHabitualMidsleep's existing
  read to also yield nightly durations — one read, midsleep result byte-identical.
- The nightly durations are TST (span x efficiency), NOT in-bed span: the need/regularity
  estimate must match the tstSeconds Rest scores against. Real-data validation on VK's noop-cloud
  history showed in-bed span over-counts need ~0.85h (his correct need ~8.0h TST).

Both degrade honestly on thin history (nil consistency -> neutral; need -> default).

Validation: RestWiringImpactTests archetype table + real-data replay on ~90d of VK's nights
(need 8.0h TST, consistency 0.57); RestNeedTests; full StrandAnalytics suite green; macOS build green.
…narrow)

ReadinessEngine scored its HRV signal as a raw-ms z-score, but RMSSD is right-skewed, so a
symmetric z over-weights the long upper tail and misstates tail rarity. lnRMSSD is closer to
normal (Plews/Altini; the app's own HRVReadiness already works in the log domain). Add a
logDomain path to the shared zSignal builder, used ONLY by HRV — RHR/resp are ~normal and stay
linear. Evidence stays in ms, but the reported baseline is the geometric mean, not an arithmetic
mean a few big-recovery nights inflate.

Deliberately NARROW: domain-correctness only. The full HRVReadiness SWC-tier engine stays behind
its experimental flag until validated on real data (its header notes n=1; roadmap anti-#194).

Tests: RD1LogDomainTests pins the geometric-mean baseline; ReadinessEngineTests unchanged; suite green.
…Wave 0 · RD-confidence)

ReadinessEngine scored off as few as 7 baseline nights with no confidence shown, while Charge
already carries a ScoreConfidence tier. Add ScoreConfidence.readiness(hasRead:baselineNights:
fullWindow:) + a `confidence` field on Readiness, from the HRV baseline density (the primary
readiness driver): calibrating (no read), building (7-29 of the 30-night window), solid (full
window). Analytics enabler; surfacing it on the Today card is a separate design pass.

Tests: RDConfidenceTests (tiers + engine building/solid/calibrating); ReadinessEngineTests
unchanged; full suite green; macOS Strand build green.
Mirror the StrandAnalytics Wave 0 changes on Android so Swift and Kotlin
produce byte-identical numbers:

- RestScorer: personalizedNeedHours(nightlyHours, age) + populationNeedFloorHours,
  minNeedNights=7, maxNeedHours=9.5 (population-anchored, age-floored, upper-quartile
  sleep need; chronic under-sleeper's need never drifts below the population target).
- ReadinessEngine: HRV z-score in the LOG domain (lnRMSSD); baseline shown is the
  geometric mean. Readiness now carries a ScoreConfidence from HRV baseline density.
- ScoreConfidence.readiness(hasRead, baselineNights, fullWindow).

Tests: RestNeedTest, RDConfidenceTest, RD1LogDomainTest, RestWiringImpactTest
(same inputs + expected values as the Swift tests). testFullDebugUnitTest green,
13 new tests; existing analytics suites unchanged.
@ryanbr

ryanbr commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Reviewed. This is in much better shape than the branch stacked on it — Kotlin twins are here, all six package suites are green, and the real-data validation caught a genuine bug (in-bed span vs TST, need over-counting ~0.85 h). The "computed but never reaching the score" framing is the right way to find this class of defect.

Verified as safe

  • Genuinely additive. sleepNeedHours: Double = Rest.defaultNeedHours and sleepConsistency: Double? = nil are both defaulted, so the ~35 other analyzeDay call sites (all tests) are untouched. Only the one production caller changes.
  • The rename is complete. computeHabitualMidsleepcomputeHabitualSleep leaves no code references behind — the two remaining hits are comments (below).
  • I trial-merged onto current main. Clean, no conflicts, and the merged analyzeDay call site correctly carries your two new arguments alongside everything main has added since.

Three things before merge

1. SL1 and T1 are wired on Swift only, so Rest will diverge between platforms.

Your notes list the Kotlin capabilities ported — RestScorer.personalizedNeedHours / populationNeedFloorHours, the log-domain z, ScoreConfidence.readiness — and that is all accurate. But the wiring is the Swift half only:

  • Strand/Data/IntelligenceEngine.swift computes sleepConsistency + sleepNeedHours and threads them into analyzeDay.
  • android/…/analytics/IntelligenceEngine.kt is not in this PR, and its analyzeDay call at line 550 passes habitualMidsleepSec but neither new argument — so Android keeps sleepConsistency = null (neutral) and sleepNeedHours = null (→ 8 h default).

Worth noting the sleepConsistency = VitalityEngine.sleepConsistency(vNights) at IntelligenceEngine.kt:1060 is not this wiring — it is the VitalityEngine.Inputs construction, i.e. exactly the "computed and used only by vitality" state your SL1 note describes for Swift.

So post-merge, the same wearer on the same night gets a different Rest score on iOS than on Android. RD1 and RD-confidence are fine — those live inside ReadinessEngine/ScoreConfidence, which you did port, so they need no external wiring. It is specifically the two Rest changes. Threading the same two values through the Kotlin IntelligenceEngine should be a small addition given everything underneath it already exists.

2. The green CI is 11 days and 236 commits stale.

The passing run is from 14 July against a tree 236 commits behind current main. MERGEABLE/CLEAN only means no textual conflicts. Of your 15 files, exactly one has moved on main since the merge base — Strand/Data/IntelligenceEngine.swift, by four commits (#714 skip-diagnostic, #510 workout backfill, #550/#595 R-R de-dup, and an iOS build fix) — and that is the one file carrying your integration point. My trial merge came out coherent, but that is inspection rather than a build, so a rebase and re-run is worth it before landing.

3. This changes scores for existing users, and the PR does not say so.

SL1 replaces "a flat +5 for everyone, every night" with real consistency, and T1 replaces a fixed 8 h with a personalised need. Both are corrections rather than tuning, but the visible effect is that Rest moves for everyone, and any re-scored history moves with it. That deserves a line in the release notes so it reads as an intended fix rather than a regression — this is exactly the kind of change users notice and report.

Minor

The rename left two stale doc references to computeHabitualMidsleep: Strand/Data/Repository.swift:1051 and Strand/Data/IntelligenceEngine.swift:1777. The second is inside the docblock this PR rewrites, and it now reads "Mirrors the Android computeHabitualMidsleep" — which is still literally true, since Kotlin keeps the old name, but the Swift and Kotlin function names have now diverged. Either rename the Kotlin one too or say plainly in the comment that the names differ, so the next reader does not go looking for a computeHabitualSleep on Android.

@ryanbr

ryanbr commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Stack-level note

Reviewing #456 and #469 back to back turned up one systemic thing that neither review says on its own, since there is no tracking issue carrying the pair.

Both PRs leave Android's scoring behind, one layer apart.

Landing both as they stand means iOS and Android disagree on Rest and Readiness — two headline scores — for the same wearer on the same night. RD1 and RD-confidence are fine; those live inside engines that were ported.

Suggested order:

  1. Wave 0 scoring: wire computed-but-disconnected methodology into Rest + readiness #456 + the Kotlin IntelligenceEngine wiring, rebased onto current main, CI re-run — its green run is 236 commits old.
  2. Rebase Readiness: fold HRV/RHR through the spine in a window-fold mode (stacked on #456) #469 onto the landed Wave 0 scoring: wire computed-but-disconnected methodology into Rest + readiness #456, so its diff narrows to the readiness commit as intended.
  3. Readiness: fold HRV/RHR through the spine in a window-fold mode (stacked on #456) #469 + its Kotlin twin (Baselines.update/foldHistory reject flag, the ReadinessEngine fold, RD2SpineTest.kt).

The Swift work in both is the hard part and it is done — the analysis, the candidate replay, and the reasoning for not reusing foldHistory naively are all genuinely good. What is missing is mechanical by comparison, and it is the part the parity contract cares about.

One more thing worth flagging for whoever lands these: both change scores for existing users. #456's SL1 replaces a flat +5-for-everyone consistency term and T1 replaces a fixed 8 h need; #469 changes the readiness HRV baseline. All corrections rather than tuning, but Rest and Readiness will visibly move, and re-scored history moves with them. Worth a release-note line so it reads as an intended fix.

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.

2 participants