Skip to content

Fitness Age: gate on the persisted history the card reads, not the in-pass rows - #140

Merged
ryanbr merged 2 commits into
mainfrom
fix/fitness-age-gate-reads-persisted-history
Jul 9, 2026
Merged

ryanbr merged 2 commits into
mainfrom
fix/fitness-age-gate-reads-persisted-history

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 9, 2026

Copy link
Copy Markdown
Owner

The bug

Reported from the field: the Fitness Age readiness card shows every input satisfied — "Resting heart rate: 7 of last 7 nights", age/sex Set, activity 7 of 7 — yet the card reads "No Data" and never produces a value. Meanwhile Vitality / Body Age computes fine (54 / 48). Age and sex confirmed set in the profile, so it's not a coverage or profile gap.

Root cause

The engine gated Fitness Age on fa7 = dailies — this recompute pass's freshly scored nights. A recompute only re‑scores a night whose raw HR still lives in the DB (if (hr.size < MIN_HR_SAMPLES) continue), but the computed daily rows (and their resting‑HR) persist after the raw streams age out. The readiness card, by contrast, counts the persisted / merged daily history.

So the two disagree: the card counts the full persisted week ("7 of 7"), the engine's dailies sees fewer than 4 resting‑HR nights → assessReadiness returns NOT_READY → Fitness Age is never written. Vitality survives the same sparse fa7 because VitalityEngine needs only 3 of any input (sleep / HRV / steps), which is exactly why Body Age shows but Fitness Age doesn't.

Fix

Gate + compute Fitness Age from the same merged last‑7 the card and dashboard read (repo.daysMerged on Android, repo.dailyMetrics(fromDay:toDay:) union on iOS), kept separate from fa7 so Vitality (which already computes) is untouched.

Monotonic‑safe: the merged view can only yield ≥ the resting‑HR nights dailies had, using valid values — so it can never make Fitness Age compute less often. Worst case it's a no‑op; best case it computes.

Also adds a strap‑log line (Android) recording the resting‑HR‑night count the engine actually sees for the gate, so any remaining divergence from the card's "N of last 7" is provable from an export.

Honest caveat / validation

The recompute deletes+reinserts computed rows mid‑pass, so there's a chance the merged read reflects the same narrowed window and the fix is a no‑op for this exact case. That's what the diagnostic is for. To validate: after this ships and a recompute runs, does Fitness Age populate? If not, the strap‑log fitnessAge gate … rhrNights=N line pinpoints whether the persisted store itself is being pruned (a deeper follow‑up).

Verification

  • Android :app:compileFullDebugKotlin passes locally.
  • iOS reviewed by hand (Charts/Compression can't build on WSL): repo.dailyMetrics(fromDay:toDay:) is async (non‑throwing), in scope with oldestDay/newestDay; fa7/faRHRs retained for Vitality; removed faActiveStrains/faMeanActiveStrain have zero remaining refs.

ryanbr added 2 commits July 9, 2026 19:11
…-pass rows

User report: readiness card shows every input satisfied ("Resting heart
rate: 7 of last 7 nights", age/sex Set), yet Fitness Age stays "No Data"
while Vitality/Body Age computes. Root cause: the engine gated Fitness
Age on `fa7 = dailies` -- THIS recompute pass's freshly scored nights
(a recompute only re-scores nights whose raw HR still lives in the DB) --
while the readiness card counts the persisted/merged daily history. So
the two disagree: the card promises "ready", the engine sees <4 RHR
nights and never computes. Vitality survives because it needs only 3 of
ANY input (sleep/HRV/steps), so a sparse pass still clears its bar.

Fix: gate + compute Fitness Age from the SAME merged last-7 the card and
dashboard read (repo.daysMerged / repo.dailyMetrics union), kept separate
from `fa7` so Vitality is untouched. Monotonic-safe: the merged view can
only yield >= the RHR nights `dailies` had, using valid values, so it can
never make Fitness Age compute less often.

Also adds a strap-log line (Android) recording the RHR-night count the
engine actually sees for the gate, so a divergence from the card's "N of
last 7" is provable from an export.

Both platforms (Android IntelligenceEngine.kt + iOS IntelligenceEngine.swift).
…scores

Follow-up after "original noop worked" + the readiness card showing 7/7:
the root cause is the engine gating on computed-only `dailies`, while the
user's resting HR comes (partly) from IMPORTED daily aggregates (Apple
Health / Health Connect) that only the MERGED view carries. The first cut
read the merged view AFTER the recompute's own delete/evict, so it could
still see the narrowed window (a potential no-op) and the Android read was
unbounded.

Now snapshot the persisted/merged history BEFORE the rewrite and gate on
its UNION with this pass's fresh scores (by day, fresh wins): an RHR night
counts whether it survives in the store, was just scored, or came from an
import. Windowed to the recompute range (fixes the unbounded-read nit).
Vitality stays on `dailies` (already computes) , untouched.

Android + iOS.
@ryanbr

ryanbr commented Jul 9, 2026

Copy link
Copy Markdown
Owner Author

Update — root cause refined + hardened (after "original noop worked").

The Fitness Age block is byte-identical to upstream NoopApp/noop, so the code isn't inherently broken. With the readiness card showing 7/7 nights, the real cause is: the engine gates on computed-only dailies, but the user's resting HR comes (partly) from imported daily aggregates (Apple Health / Health Connect) that only the merged view carries — so the card counts them and the engine doesn't.

Two downsides from the first cut are fixed here:

  • No-op risk: the merged read now happens before the recompute's own delete/evict (was after), and the gate is the union of pre-rewrite persisted history ∪ this pass's fresh scores (by day) — so an RHR night counts whether it survives in the store, was just scored, or came from an import.
  • Unbounded Android read: now windowed to the recompute range.

Vitality stays on dailies (already computes) — untouched. Android :app:compileFullDebugKotlin passes; iOS hand-reviewed.

@ryanbr
ryanbr merged commit 9671ea6 into main Jul 9, 2026
tanarchytan pushed a commit to tanarchytan/noop that referenced this pull request Jul 9, 2026
The weekly Fitness Age is only written by the intelligence recompute
(15-min backstop, gated by an HR-fingerprint watermark), so a ready user
could wait a while to see it. Add a small refresh affordance on the
not-ready readiness card that recomputes Fitness Age NOW from the
PERSISTED merged daily history , no raw-HR rescoring , so it returns fast,
works offline, and reads exactly what the readiness card shows (it also
sidesteps the recompute's own delete-timing, so it lands the value even
where the auto-pass might not).

Shared gate: extracted the ryanbr#140 gate + compute into IntelligenceEngine.
fitnessAgeRows(...), used by BOTH the recompute pass and the new
recomputeFitnessAgeOnly(...) so the two can never drift.

Android: AppViewModel.refreshFitnessAgeNow + a Refresh IconButton (spinner
while running) on FitnessReadinessCard, wired on the Health hub
(FitnessAgeSection) and the Today-card tap-through (VitalDetailScreen),
re-reading the value on completion + a Toast.

iOS: engine parity (fitnessAgeRows / recomputeFitnessAgeOnly), a refresh
button on ReadinessChecklistCard (HealthView) and the fitness_age empty
state (MetricDetailView), via the app-wide IntelligenceEngine env object.

Stacked on ryanbr#139 (readiness surfaces) + ryanbr#140 (gate fix), which this
includes.
pipiche38 pushed a commit to pipiche38/noop that referenced this pull request Jul 9, 2026
…ot enough history"

The Today card scores Fitness Age from one weekly reading (the ryanbr#140 fix), but tapping
through hit the detail view's chart gate (points.size >= 2) and fell to the generic
"Not enough history yet" note -- a contradiction: a value on the card, "no data" on tap.
Every user in their first week after the fix sees this.

Fitness Age is WEEKLY; only the TREND needs a second point. Android now shows the single
reading's value + an honest "trend fills in once a second weekly reading lands" line,
instead of the no-data dead end. Brings Android to parity with iOS, which already renders
the value hero at a single point (its else-branch shows the value for size >= 1).
@ryanbr
ryanbr deleted the fix/fitness-age-gate-reads-persisted-history branch July 14, 2026 10:49
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
The weekly Fitness Age is only written by the intelligence recompute
(15-min backstop, gated by an HR-fingerprint watermark), so a ready user
could wait a while to see it. Add a small refresh affordance on the
not-ready readiness card that recomputes Fitness Age NOW from the
PERSISTED merged daily history , no raw-HR rescoring , so it returns fast,
works offline, and reads exactly what the readiness card shows (it also
sidesteps the recompute's own delete-timing, so it lands the value even
where the auto-pass might not).

Shared gate: extracted the ryanbr#140 gate + compute into IntelligenceEngine.
fitnessAgeRows(...), used by BOTH the recompute pass and the new
recomputeFitnessAgeOnly(...) so the two can never drift.

Android: AppViewModel.refreshFitnessAgeNow + a Refresh IconButton (spinner
while running) on FitnessReadinessCard, wired on the Health hub
(FitnessAgeSection) and the Today-card tap-through (VitalDetailScreen),
re-reading the value on completion + a Toast.

iOS: engine parity (fitnessAgeRows / recomputeFitnessAgeOnly), a refresh
button on ReadinessChecklistCard (HealthView) and the fitness_age empty
state (MetricDetailView), via the app-wide IntelligenceEngine env object.

Stacked on ryanbr#139 (readiness surfaces) + ryanbr#140 (gate fix), which this
includes.
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ot enough history"

The Today card scores Fitness Age from one weekly reading (the ryanbr#140 fix), but tapping
through hit the detail view's chart gate (points.size >= 2) and fell to the generic
"Not enough history yet" note -- a contradiction: a value on the card, "no data" on tap.
Every user in their first week after the fix sees this.

Fitness Age is WEEKLY; only the TREND needs a second point. Android now shows the single
reading's value + an honest "trend fills in once a second weekly reading lands" line,
instead of the no-data dead end. Brings Android to parity with iOS, which already renders
the value hero at a single point (its else-branch shows the value for size >= 1).
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