Found while fixing #290 (PR #293) and deliberately left out of that PR, which only had room for the session card. Line numbers are against 855ddd7.
The day's zone bars are binned on 208 − 0.7·age, under a footnote that names whatever anchors trainingZones chose. The same persisted bundle carries zones from one zone set and zone_timeline / zone_source / zone_max_hr from a different one.
The two producers
The pure pipeline bins everything on the one set (lib/compute/onehz_pipeline.dart):
final zoneSet = trainingZones(…, observedCeilingBpm: d.observedHrCeilingBpm, …); // :664
hrZones = _wakeZoneMinutesFromSeries(wakeHr, zoneSet); // :747
zoneTimeline = _zoneTimeline(wakeHr, zoneSet); // :854
'zone_source': zoneSet?.source, // :1271
'zone_max_hr': …zoneSet.maxHr…, // :1272
The coordinator then recomputes zones alone, from the age estimate only (lib/compute/derivation_engine.dart):
final hrMax = estimatedMaxHr(profile.ageYears, daySub.deviceFamily); // :5800 — Tanaka
// "Zones are pure %HRmax bands — real as soon as HRmax is real."
zones = _wakeZoneMinutes(daySub, sleepOnsetSec, sleepOffsetSec, hrMax); // :5902
… final zoneSet = ana.HeartRateZones.zonesFromMaxHr(hrMax); // :5042
and overwrites the pipeline's bars in the merge:
bundle['zones'] = wake['zones']; // :5377
Nothing overwrites zone_source, zone_max_hr or zone_timeline, so the pipeline's values survive beside bars that no longer match them. That comment at :5901 is the tell — it predates TS-03/TS-04, when %HRmax off the age estimate was the only zone set.
What the user sees
lib/ui2/activity/day_strain.dart reads the bars from s['zones'] (:131 → :142 → :314) and the footnote from s['zone_source'] / s['zone_max_hr'] (:144-145). So for anyone with a measured ceiling the day screen draws Tanaka-binned bars under "Zone edges span the gap between your measured resting heart rate and the highest we have seen (196 bpm). Both measured on you."
Reachable exactly when trainingZones returns a non-tanaka set — i.e. an observed ceiling at or above the age line, which since #293 is the only way to earn observed/karvonen at all. Before #293 it was reachable for anyone with any observed ceiling, which is most users.
Also worth noting: zones and zone_timeline are two views of the same day, and they are binned by different sets. A day whose timeline is entirely Z1 can have its bar chart report Z2.
Why it hasn't been caught
test/hr_ceiling_zones_test.dart:292 asserts precisely this invariant:
test('the zone timeline is binned by the same set as the zone minutes', () {
final tl = (bundle(ceiling: 196)['series'] as Map)['zone_timeline'];
but bundle(…) is deriveDayBundle(…) — the pure pipeline in isolation. The coordinator's overwrite never runs, so the guard passes on a path where the bug cannot appear. No test drives _computeDayBlocks' zones.
Fix sketch
_DayBlocksInput carries neither the observed ceiling nor the resting-HR history, so the coordinator has no way to build the real set. Either:
- thread both anchors through
_DayBlocksInput and have _wakeZoneMinutes take a HeartRateZoneSet instead of a double hrMax — the anchors cross the isolate boundary as two plain values; or
- drop
bundle['zones'] = wake['zones'] and keep the pipeline's bars, if the second half's recompute has no other reason to own them (worth checking against the trimp carve-out immediately above it at :3500, where the halves' ownership is already split by key).
Either way it moves zones for every user with a measured ceiling, so it needs a kAlgoVersion bump, and the regression test has to go through the engine rather than deriveDayBundle.
Found while fixing #290 (PR #293) and deliberately left out of that PR, which only had room for the session card. Line numbers are against
855ddd7.The day's zone bars are binned on
208 − 0.7·age, under a footnote that names whatever anchorstrainingZoneschose. The same persisted bundle carrieszonesfrom one zone set andzone_timeline/zone_source/zone_max_hrfrom a different one.The two producers
The pure pipeline bins everything on the one set (
lib/compute/onehz_pipeline.dart):The coordinator then recomputes
zonesalone, from the age estimate only (lib/compute/derivation_engine.dart):and overwrites the pipeline's bars in the merge:
Nothing overwrites
zone_source,zone_max_hrorzone_timeline, so the pipeline's values survive beside bars that no longer match them. That comment at :5901 is the tell — it predates TS-03/TS-04, when %HRmax off the age estimate was the only zone set.What the user sees
lib/ui2/activity/day_strain.dartreads the bars froms['zones'](:131→:142→:314) and the footnote froms['zone_source']/s['zone_max_hr'](:144-145). So for anyone with a measured ceiling the day screen draws Tanaka-binned bars under "Zone edges span the gap between your measured resting heart rate and the highest we have seen (196 bpm). Both measured on you."Reachable exactly when
trainingZonesreturns a non-tanakaset — i.e. an observed ceiling at or above the age line, which since #293 is the only way to earnobserved/karvonenat all. Before #293 it was reachable for anyone with any observed ceiling, which is most users.Also worth noting:
zonesandzone_timelineare two views of the same day, and they are binned by different sets. A day whose timeline is entirely Z1 can have its bar chart report Z2.Why it hasn't been caught
test/hr_ceiling_zones_test.dart:292asserts precisely this invariant:but
bundle(…)isderiveDayBundle(…)— the pure pipeline in isolation. The coordinator's overwrite never runs, so the guard passes on a path where the bug cannot appear. No test drives_computeDayBlocks' zones.Fix sketch
_DayBlocksInputcarries neither the observed ceiling nor the resting-HR history, so the coordinator has no way to build the real set. Either:_DayBlocksInputand have_wakeZoneMinutestake aHeartRateZoneSetinstead of adouble hrMax— the anchors cross the isolate boundary as two plain values; orbundle['zones'] = wake['zones']and keep the pipeline's bars, if the second half's recompute has no other reason to own them (worth checking against thetrimpcarve-out immediately above it at:3500, where the halves' ownership is already split by key).Either way it moves
zonesfor every user with a measured ceiling, so it needs akAlgoVersionbump, and the regression test has to go through the engine rather thanderiveDayBundle.