Skip to content

Engine Result fields are computed and never read — a sweep, plus 'time in high stress' and a CircadianEngine domain mismatch #982

Open

Description

@vishk23

Auditing NOOP for values that are computed but never reach a user or a score, one pattern kept recurring: an engine's Result struct grows a field, the field is computed with real logic and covered by a test, and nothing ever reads it. The test passes, so it looks wired.

Below: a mechanical sweep, then the two findings I'd act on first, then a suggestion that would catch the next one automatically.

The sweep

For every public let / public var stored property declared in a production Swift file, count .name dereferences in every other production file. 176 come back with zero.

Most are noise — SwiftUI protocol members (animatableData, alignment), init-only parameters, generated FFI (Strand/CloudSync/Generated/), and fields legitimately consumed inside their own file (e.g. NutritionCsvImport.caloriesIn is read at NutritionCsvImport.swift:84 to build metric points — flagged by the sweep, not actually dead). The signal is the subset on engine Result types, where external consumption is the entire point. Those I checked by hand:

Type Fields with no reader anywhere Note
AnalyticsEngine.DayScores chargeConfidence, effortConfidence, restConfidence (AnalyticsEngine.swift:87-89) all three computed for real (:783, stored :797) — a whole confidence tier the UI never shows
DaytimeStress.Result highStressMinutes (:204), hrOnlyFallback (:210), activityMaskedHours (:215) see below
SleepStager tibS (:2150), solS (:2153), remLatencyS (:2154), wasoS (:2155), deepPct/remPct/lightPct (:2161-2163) the standard PSG summary set, computed and dropped
CorrelationEngine intercept (:38) zero readers repo-wide, tests included
Baselines.Deviation ratio (:89), inNormalRange (:91) only .z and .delta are consumed
FitnessAgeEngine chronoAge, deltaYears, bandYears, lowerConfidence (:255-258)
VitalityEngine deltaYears, bandYears, chronoAge, factorsUsed, contributions (:66-74) HealthView recomputes contributions independently rather than reading this
DailyStressEngine daysBelowBand (:89), rhrBpmAboveLongTerm (:83), usedRHR (:114)
HRZones belowZone1 (:78), maxHR (:49)
WorkoutDetector zoneTimePct (:49), avgHRRPct (:51), hrmaxSource (:55), caloriesKJ (:57)
SleepStageTotals asleepSeconds (:227)
ResonanceEngine.PaceScore cleanBeats (:82), scoredCycles (:84)
RhythmScreener turningPointRate (:152), agreedAcrossSources (:161), NightRhythmSummary.steady/occasional/variedWindows (:198-204) these appear only in a SwiftUI preview fixture (RhythmView.swift:548-554)

Additive fields are where output goes to die: adding one to an existing struct never breaks a caller, so nothing forces the question "who reads this".

The two I'd act on

1. "Time in high stress" is computed on every render and thrown away

DaytimeStress.swift:529 — let highStressMinutes = scored.filter { $0.1 >= highBandFloor }.count * (bucketSeconds / 60) — real logic, real thresholding, passed into Result at :533, read by nothing. Seven test references, zero production ones.

This is a figure Oura shows directly and users compare against. It already exists, correctly computed, on a screen that's already built. Cheapest user-visible win in the audit — it's a label, not a feature.

2. CircadianEngine is fed heart rate where it documents motion, and the arrhythmia gate is a relative test

ActivityBin.activity is documented as motion volume. The production caller passes mean HR in bpm. The readability gate is relative amplitude — amplitude / |mesor| < 0.10 → .unreadable.

Those two facts don't compose. With motion the mesor sits near zero, so relative amplitude is large and the gate is generous. With HR you get a ~65 bpm DC offset against an ~8–12 bpm circadian swing, landing around 0.12–0.18 — just above a 0.10 floor, and below it for anyone with a flat HR profile. A low-resting-HR user (mine currently runs 41–46 with a very flat curve) sits closest to the edge, so the people the gate is most likely to silence are the fittest ones, which is backwards.

Nothing is currently broken — it clears the floor today. But it's structurally the same shape as the skin-temp baseline bug (a range check meeting a different domain than it was tuned for), and that one ended up permanently unreachable rather than merely marginal. Worth either passing motion as documented, or re-tuning the gate for the HR domain and updating the doc comment.

A suggestion that would catch the next one

Three engines currently assume ~1 Hz banked HR and would silently degrade to nothing if the cadence ever coarsened:

  • ScoreConfidence.solidEffortReadings = 3600 → .solid Effort becomes unreachable
  • DaytimeStress.minHourHRSamples = 300 → the intraday stress timeline goes empty
  • HeartRateRecovery.maximumContinuousGapSeconds = 10 → HRR returns nil on every workout

I measured it rather than assuming: on my WHOOP 5/MG, hourly hrSample counts run 3548–3599 per 3600-second hour across Jul 29–30, and ~84,500–86,400 per day for Jul 22–28 against a 1 Hz ceiling of 86,400. So the assumption holds on this hardware and all three thresholds are satisfiable — this is not a live bug.

But it's unpinned. One test asserting the observed per-source cadence would convert three independent silent-nothing failures into one loud one, and it's a better deliverable than three separate defensive fixes. Worth doing before the next firmware or the next device family changes the cadence out from under them.

Happy to send PRs for the highStressMinutes surface and the cadence test if either is wanted. The CircadianEngine domain question I'd rather ask than patch, since "pass motion" and "re-tune the gate" are meaningfully different answers.

Environment: WHOOP 5/MG, iOS. Cadence measured on my own cloud mirror; file:line against a fork of main at af44563c.

Activity

  1. ryanbr commented on Jul 31, 2026

    @ryanbr
    Owner

    Worked through this against the tree rather than reading it. The central thesis holds, the CircadianEngine finding is correct and is the best thing in here — but finding #1 does not exist in this repo, and that changes what to do first.

    Finding #2 is real, in all three parts

    Confirmed each independently:

    • ActivityBin.activity is documented as "the motion volume in that bin (e.g. StepsEstimateEngine.dayMotionIntensity per hour)" — CircadianEngine.swift:42-43.
    • The production caller accumulates sums[hour] += b.bpm and passes sums[h] / counts[h] — mean heart rate — as activity (AppModel.swift:1624, 1628).
    • The gate is relative: minRelativeAmplitude = 0.10, applied as fit.amplitude / abs(fit.mesor) (:30, :161).

    Your arithmetic follows. A ~65 bpm mesor against an ~8–12 bpm swing lands ~0.12–0.18 against a 0.10 floor, and the flatter the curve the closer to the edge — so the gate is most likely to silence the fittest users. That it clears today is luck of the domain, not design.

    Answering your question, since you asked rather than patched: I would keep HR and re-tune, not switch to motion. WHOOP 4.0 motion is documented as too sparse to even stage sleep (#345), so dayMotionIntensity is the weaker signal here, and HR is a legitimate circadian marker in its own right. But a relative gate is the wrong shape for a signal with a large DC offset — the honest fix is an absolute floor in bpm (an amplitude below ~N bpm is arrhythmic regardless of mesor), plus correcting that doc comment so the next reader is not misled the same way.

    Finding #1 is not in this tree

    highStressMinutes returns zero matches repo-wide — no production reference, no test reference. DaytimeStress.swift is 295 lines, so the cited :529 and :533 cannot exist, and its Result carries only hours, sustainedHigh, sustainedRun, dayMean, peak and the computed scored. Same for two others in that row: hrOnlyFallback and activityMaskedHours are absent, as is daysBelowBand from the DailyStressEngine row.

    Most of the sweep does check out — chargeConfidence is exactly at AnalyticsEngine.swift:87 as stated, and turningPointRate, cleanBeats, belowZone1, hrmaxSource and the rest all exist. So this is not a wholesale problem, but four named fields and one set of line numbers came from somewhere other than main. Worth checking whether that sweep ran against a fork or a working branch — I would rather ask than guess, because the rest of the report is careful enough that it stands out.

    The core thesis holds, and there is a better "cheapest win" available

    I traced chargeConfidence end to end: declaration :87, init parameter :113, init assignment :127, computed for real :770, passed into the struct :784, and one test assertion. Zero production readers, exactly as you describe. Same shape for effortConfidence and restConfidence.

    So a whole confidence tier is computed every day and shown to nobody — and unlike highStressMinutes, it is actually here. That is the label-not-a-feature win, and it is arguably a better one: it tells users when a score is still calibrating, which is a recurring source of confusion in the issue tracker.

    The cadence test

    Also verified: solidEffortReadings = 3600 (its own comment says "~1 h at 1 Hz"), minHourHRSamples = 300, maximumContinuousGapSeconds = 10. Three independent silent-nothing failures resting on an unpinned assumption, and your 3548–3599 per hour measurement is the kind of evidence that should be in a test rather than an issue. Agreed it is the better deliverable — one loud failure instead of three quiet ones.

    Suggested order

    1. CircadianEngine — re-tune to an absolute bpm floor and fix the doc comment.
    2. Cadence test — pins all three thresholds at once.
    3. Surface the confidence tier — the real cheapest user-visible win.

    Happy to take the PRs for 1 and 2 whenever you want to send them.

  2. ryanbr commented on Jul 31, 2026

    @ryanbr
    Owner

    Ran the sweep against main myself so this is usable as a backlog. #993 has landed the CircadianEngine half (doc corrected, gate cost pinned on both platforms).

    Your core claim holds, and it is bigger than the headline suggested. These 22 all have zero production readers on main — declaration, init, computation, sometimes a test assertion, and nothing else:

    chargeConfidence effortConfidence restConfidence · tibS solS remLatencyS wasoS deepPct remPct lightPct · belowZone1 zoneTimePct avgHRRPct hrmaxSource caloriesKJ · turningPointRate agreedAcrossSources · cleanBeats scoredCycles · asleepSeconds inNormalRange factorsUsed lowerConfidence

    Three rows do not survive checking, though:

    • chronoAge has 6 readers. It is consumed, not dead.
    • intercept is misattributed. It is declared in PpgHr.swift, not CorrelationEngine.swift, and has a reader.
    • bandYears, deltaYears, cleanBeats and ratio each have one reader.

    And six named fields are not on main at all — highStressMinutes, hrOnlyFallback, activityMaskedHours, daysBelowBand, rhrBpmAboveLongTerm, usedRHR. They are added by #988, which confirms the sweep ran against that branch. Worth re-running against main before anyone works the list, and worth knowing that finding #1 — the "cheapest user-visible win" — describes a field #988 is itself introducing unread, which is the very pattern this issue is about.

    Method caveat, since I am asking you to trust my numbers after questioning yours: I counted .field references outside the declaring file, excluding tests and previews. That over-counts common words (ratio, intercept, chronoAge can match unrelated types) and would miss a field consumed by destructuring. Treat the 22 as high-confidence and the low-count rows as "check by hand".

    On the two items still open

    The cadence test is yours — you offered it and you have the per-source measurements. Verified the three constants exist exactly as stated (solidEffortReadings = 3600, whose own comment says "~1 h at 1 Hz"; minHourHRSamples = 300; maximumContinuousGapSeconds = 10). Please do send it.

    The confidence tier is the better "cheapest win" now that highStressMinutes turns out not to exist here — it is real on main, it is a label rather than a feature, and it answers a recurring question in the tracker ("why is my score still blank / is this calibrating?"). But where it goes and what it says is a product call rather than a mechanical fix, so I'd want to settle that before landing anything.

    One thing I would push back on: deleting the other 22 is churn, not a fix. They cost nothing at runtime, and several are the obvious inputs to features that do not exist yet (the PSG summary set is a sleep-detail screen waiting to happen). The value in your sweep is as a menu of things already computed and free to surface, which is a much better framing than dead-code removal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions