Skip to content

perf(rescore): stop a post-sync pass re-scoring nights and cycles whose inputs did not change - #2293

Open
Iskrata wants to merge 6 commits into
ryanbr:mainfrom
Iskrata:fix/rescore-habits-from-finished-nights
Open

Iskrata wants to merge 6 commits into
ryanbr:mainfrom
Iskrata:fix/rescore-habits-from-finished-nights

Conversation

@Iskrata

@Iskrata Iskrata commented Sep 17, 2026

Copy link
Copy Markdown

What this PR does

Two changes stop the post-sync re-score from repeating work whose inputs did not change. Most of the cost of a backgrounded pass came from these two places.

1. Sleep habits are learned from finished nights only. sleepNeedHours, sleepConsistency and habitualMidsleepSec are all in the day-cache config signature, and computeHabitualSleep read them from every banked session up to now. That includes tonight's session, which every sync re-banks while it is still growing. So each sync through a morning moved the consistency and midsleep, the signature changed, and the whole 21-night cache was dropped (configDropped(sleepNeedHours+sleepConsistency+habitualMidsleep)). computeHabitualSleep now takes finishedBefore: and the pass passes its local midnight. A night joins the learned history the day after it ends, when the window rolls anyway.

2. A closed sleep-to-sleep cycle keeps its Effort and calories. In .sleepOnset mode (the default), DayCycleIntelligenceIntegration.compute re-read each cycle's full day of 1 Hz heart rate from every owner on every pass. It then re-scored Effort and calories for all 21 cycles, although only the open cycle gains samples. Step counts already had a per-cycle cache. Effort and calories now have one too, keyed on the index-only hrFingerprint (count and newest timestamp) per owner, the same witness the day cache uses, plus resting HR, HRmax, method and profile.

Why

A phone log showed backgrounded passes of 8 813 s and 2 345 s running back to back, with scores arriving hours late. A backgrounded app only gets short execution windows, so a pass that costs seconds of CPU in the foreground takes hours of wall-clock time in the background.

I measured this by replaying analyzeRecent on a Mac against a copy of that phone's database (21 nights, Debug build, so compare the numbers with each other rather than with a phone):

Pass Before After
Cold (first pass of a process) 171 s 166 s
Again, nothing changed 96 s (reused=0/21, configDropped(sleepConsistency+habitualMidsleep)) 2.9 s (reused=21/21)
One more minute of heart rate today 16 s 7.9 s

In the last row, the score2 phase went from 5.9 s to 0.3 s. The time before the scoring loop was nearly all DayCycleIntelligenceIntegration.compute (6–12 s, measured with temporary timers). The detected nights, stages, and the resulting sleep sessions are unchanged by either commit.

Behaviour change

This is not only a speed-up. Learning from finished nights moves three user-visible numbers: sleepNeedHours, sleepConsistency and habitualMidsleepSec will differ slightly from what the same database produced before, for everyone, on upgrade. The most recently finished night is held out of the learned history for up to about a day, until the window rolls past it. The reasoning is that a night still being slept is not a habit yet, and learning from a session every sync re-banks while it grows is what produced the churn. Anyone picking up the open anchor half of #2350 should know habitualMidsleepSec moved underneath it.

The cycle load key names the profile's fields explicitly (UserProfile.cacheKey on both platforms, a test per field) rather than relying on String(describing:) / toString, whose format is not a contract.

Type of change

  • Bug fix (performance / battery)
  • Behaviour change (learned sleep habits, see above)

How it was tested

  • New StrandTests/HabitualSleepFinishedNightsTests: tonight's session, synced first to 04:00 and then to 09:30, does not change the learned midsleep or nightly hours.
  • The replay above, before and after, on the same database copy.
  • doc_comment_lint.py is clean, and parity_ratchet.py --base upstream/main --offline reports 0 errors.

Android. The Kotlin twin is in this PR:

  • IntelligenceEngine.computeHabitualSleep(finishedBefore) learns from finished nights only, through finishedSessions.
  • PhysiologicalStepCycleEngine keeps a per-cycle Effort and calories cache keyed by loadCacheKey, with the index-only WhoopRepository.hrUnionFingerprint as the HR witness.
  • Both platforms keep deciding when tonight's night joins the learned habits the same way.
  • Built and tested locally: the new RescoreUnchangedInputsTest covers both changes. The full suite has 6287 tests with 2 failures, both in RecoveryDriversTest (a half-tie rounding assertion), and they fail identically on unmodified main on this Mac.
  • The four Kotlin functions are recorded as platform_specific in parity_dispositions.json, because their Swift twins sit in the app layer (Strand/Data/), outside the governed roots.
  • parity_ratchet.py --offline reports 0 errors and the parity-governance suite passes under Python 3.12, as CI runs it.

Checklist

  • No new build warnings introduced
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

@ryanbr

ryanbr commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Thanks @Iskrata, this is careful work: a measured problem, a named cost, both
platforms in one change, and tests on each side. I checked the substance rather
than taking the write-up on trust, so here is what I verified and the one thing
standing in the way.

What holds up

The habitual-sleep change is parity-correct. Both platforms filter on
endTs < finishedBefore with finishedBefore = nowLocalMidnight. Swift applies it
inline on the deduped list, Kotlin routes it through finishedSessions as a test
seam. Same predicate, same cutoff, so the two cannot learn from different nights.

The load cache key is complete. The owner set is encoded in the witness
prefixes rather than left out, and resting HR, HRmax, effort method and profile are
all in the key. The witnesses really are index-only on both sides:
hrFingerprint is COUNT(*), COALESCE(MAX(ts), 0) and hrUnionFingerprint is the
countHrInWindow / maxHrTsInWindow pair, so a closed cycle costs two aggregates
instead of a day of rows.

The profile interpolation is value-based, which was my main worry. Swift
UserProfile is a public struct with no custom description and Kotlin's is a
data class, so both render their fields. An identity-based description would have
been stable within a process and therefore served stale calories after a mid-session
profile edit, with the cache looking like it worked.

The empty-witness path is consistent. When hrEndInclusive < onset the witness
is empty and the recompute reads nothing either, so both arrive at nil rather than
one of them caching an answer the other would not have produced.

One code note

The key's coverage of the profile rests on the DEFAULT description of both types.
If UserProfile ever gains a custom description or toString that omits a field,
the key silently stops witnessing it and the cache serves stale Effort and calories,
with nothing failing. Worth either a comment pinning that dependency where the key
is built, or keying on the fields explicitly so the compiler notices when one is
added. Not a change I would hold the PR for, but it is the part most likely to rot
quietly.

One thing worth naming

This is filed as performance, and it is, but learning only from finished nights also
moves user-visible numbers: sleepNeedHours, sleepConsistency and
habitualMidsleepSec will differ from what the same database produced before. The
reasoning is sound, a night still being slept is not a habit yet, and it is the right
call. It is a behaviour decision as well as a speed-up though, so it should be taken
as one rather than arrive as a side effect.

What is blocking it, which is not your doing

This branch is based on 8ea500a12, before #2348 landed. Your parity_twin_map.json
moves functions from 4453 to 4456, which is exactly right for the three functions
you add. Current main derives 4454, because #2348 added a Swift helper in a governed
root and the derived map was never refreshed. So on a rebase the derived count is
4457 against your checked-in 4456, and parity-governance reports a mismatch of one
function you never touched.

That is the failure mode the workflow header calls out, and the scheduled run on main
went red on it earlier today, so it is main that needs repairing rather than anything
here. I will sort that out first. Once main carries a correct authority again, a
rebase plus one more --refresh-derived on your side should bring this green, and I
will confirm the numbers here when it does.

Nothing above needs action from you yet beyond the profile-key note, which is your
call. The measurements, the replay method and the twin coverage are all exactly what
makes a change like this reviewable.

ryanbr added a commit that referenced this pull request Sep 20, 2026


The scheduled parity-governance run on main went red this morning on
test_checked_metadata_is_compact_v3_and_expands_losslessly: the checked-in
authority no longer reproduced from the current sources. The cause was #2348,
which added a Swift helper inside a governed root without a re-derive. Nothing
went red at the time because the workflow deliberately excludes product source
from its path filters, so the merge that caused the drift is never the one that
reports it. The schedule exists for exactly that, and it did its job.

The numbers move by that one function: functions 4453 to 4454, unpaired_functions
4107 to 4108. The baseline file is untouched and the refresh reports the same 300
known findings, so no debt changed hands.

--refresh-derived alone refuses here, because the base's stored manifest cannot be
reproduced at all, and --repair-stale-base cannot help either: repair is for a base
whose governed state matches and whose metadata drifted, which is not this. So this
is --refresh-derived --migrate-authority, which waives only the reproducibility of
the base manifest and no semantic debt. Every one-sided declaration still needs its
own issue-bound disposition, and the tool refuses to migrate onto anything but an
exactly derived current authority.

This was blocking an outside contributor: PR #2293 branched before #2348 and
carries a correct authority for its own three additions, so on a rebase it would
have reported a mismatch of one function it never touched. That is the failure the
workflow header warns about, and this clears it.

Verification: the parity-governance suite CI runs, 124 tests, OK.
@ryanbr

ryanbr commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Main is repaired, so this is unblocked. Reporting the numbers as promised.

0599d8a28 on main re-derives the authority: functions 4453 to 4454,
unpaired_functions 4107 to 4108, which is the single Swift helper #2348 added
inside a governed root without a re-derive. The baseline file is untouched and the
refresh reports the same 300 known findings, so no debt moved. The
parity-governance run on main is green again after being red on the 04:31
schedule.

The remedy was --refresh-derived --migrate-authority. For anyone who hits this
later: --repair-stale-base reads like the right flag from its description, but it
is for a base whose governed state matches and whose metadata drifted. Here the
base's stored manifest could not be reproduced at all, so there was no exact basis
to compare against. Migration waives only that reproducibility and no semantic
debt, and it refuses to run against anything but an exactly derived current
authority.

What that means for this branch: your 4453 to 4456 was correct for the three
functions you add, and the base underneath it has now moved by one. After a rebase
onto main the derived counts should be functions 4457 and unpaired_functions 4111,
so one more --refresh-derived should land you exactly there. If it comes out
anywhere else, say so rather than adjusting the file by hand, because that would
mean something other than the #2348 helper is in play.

The review above stands unchanged: the two changes are sound, the twins agree, and
the only open question is the profile-key fragility, which is yours to judge.

@ryanbr

ryanbr commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Reviewed at 5ef4775. Three things in here looked wrong on a first read and turned out to be fine, so let me record those first, because the names invite the wrong conclusion and the next reader will land in the same places.

  • hrUnionFingerprint is not a collapsed union. The name suggests one count across owners, which would be strictly weaker than Swift's per-owner loop: one source losing a row while another gains one leaves the total unchanged. It is not that. It iterates rawWhoopSourceIds and builds "$id=$count:$maxTs", the same per-owner shape Swift builds, and is named for the hrSamplesUnion it witnesses. The two platforms invalidate on the same events.
  • The windows match. Swift's hrEndInclusive = window.endExclusive - 1 against Kotlin's window.endExclusive - 1L.
  • "\(profile)" in the cache key is value-sensitive. My concern was that Profile is an @Published class, where interpolation gives a bare type name, so an edited age or weight would never invalidate the cache and a user changing their profile would keep stale calories forever. Different type: UserProfile is a struct, so reflection prints the stored values. Worth knowing the hazard exists one type away.

Also verified: Swift declines the cache when a fingerprint read failed (hasSuffix("=unread")), and the new cache.loads eviction mirrors the existing cache.cycles one so a cycle leaving the window cannot strand an entry. CI is 8 of 8 green.

1. This is a behaviour change, not only a performance one

computeHabitualSleep now filters endTs < nowLocalMidnight, so the most recently FINISHED night is held out of the learned history for up to about 24 hours. sleepConsistency, habitualMidsleepSec and sleepNeedHours will therefore shift slightly for everyone on upgrade.

I think the change is right. A night still being slept is not a habit, and learning from a session that every sync re-banks while it grows is what produced the churn in the first place. Your doc comments are honest about the mechanism. But the PR is typed "Bug fix (performance / battery)" and the body reads as a pure speedup, and this moves three user-visible numbers. Worth saying so in the description so it is on the record rather than discovered later. Note #2350's open anchor half touches habitualMidsleepSec as well, so whoever picks that up should know this moved underneath it.

2. Worth connecting to #2360

That is an open background-battery report on a 4.0, and the two costs there are distinct: the radio side, which is the history-offload protocol gap, and the app side. Your 8 813 s and 2 345 s passes are the app side, and 96 s down to 2.9 s on an unchanged re-pass is the most concrete battery improvement anyone has put up this week. The replay methodology, same database copy before and after, with the caveat that it is a Debug build so the numbers are comparable to each other and not to a phone, is exactly the right shape for this claim.

3. Minor

  • private static to static on computeHabitualSleep widens the surface for the test. Fine, worth one line saying that is why.
  • The key leans on String(describing:) of a struct. It works and it is value-sensitive, but the format is not a documented contract and it runs reflection per cycle per pass. UserProfile is Equatable; naming the fields explicitly would be sturdier and cheaper.

Housekeeping

Dirty, 32 behind, carrying a parity_twin_map.json stamp derived on the old base. Main's authority was migrated earlier today in f8ada93f6, so a rebase plus a plain --refresh-derived re-derives cleanly now, with no --migrate-authority. That would not have worked before today, so if you tried and saw a base-authority error, that was main and not you.

One pattern across your three open PRs: this, #2284 and #2285 all report the same two RecoveryDriversTest half-tie failures, each verified unrelated by stashing. Three reports is a pattern rather than three coincidences, and that test being environment-sensitive on your machine probably deserves its own issue so the next person does not have to re-derive that it is noise.

Approving after the rebase, with point 1 in the description.

…ies instead of re-reading its heart rate every pass
… and reuse a closed cycle's Effort and calories
…scription

String(describing:) of the struct was value-sensitive but its format is not a contract and it ran
reflection per cycle per pass. UserProfile.cacheKey names every stored field (bit patterns for
doubles) on both platforms, with a test per field.
@Iskrata
Iskrata force-pushed the fix/rescore-habits-from-finished-nights branch from 5ef4775 to 73a6004 Compare September 21, 2026 10:21
@Iskrata

Iskrata commented Sep 21, 2026

Copy link
Copy Markdown
Author

Rebased onto main (73a6004ba), and:

  1. Behaviour change. It's in the description now, as its own section and a second type box. It names sleepNeedHours, sleepConsistency and habitualMidsleepSec, says the most recent night is held out for up to a day, and flags Body clock dial: 'ideal window' is driven by daytime HR timing, not sleep #2350's anchor half.
  2. Profile key. It's keyed on the fields now: UserProfile.cacheKey on both platforms names every stored field (doubles by bit pattern, so no reflection), with a test per field on each side. The two types live in differently named files, so they're recorded as a cross-file twin pair in parity_dispositions.json.
  3. computeHabitualSleep visibility. Now has a one-line comment saying it's internal only so the test can drive finishedBefore.
  4. Background Battery consumption #2360. Noted. I'll leave the battery thread to you to connect.

On RecoveryDriversTest: it's reproducible on clean upstream/main here (Apple Silicon, JDK 17). Both failures assert an exact -0.5 at a hand-tuned HRV input, and this machine lands on -0.4999999999999929. Kotlin's exp/ln go to java.lang.Math, which may use CPU-specific intrinsics within 1 ulp; only StrictMath is bit-reproducible. So an input tuned to hit the tie on CI's x86 runner misses it on arm64. Glad to open a separate issue if you'd like one. I'd suggest having the test search for the tie input at runtime rather than hardcoding it.

Locally: UserProfileCacheKeyTests, HabitualSleepFinishedNightsTests and Kotlin RescoreUnchangedInputsTest (3) all pass.

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