From 96e2d2307699790115d049d924c1a4b3fdc0ac9e Mon Sep 17 00:00:00 2001 From: SATHVIK SVS Date: Wed, 12 Aug 2026 20:55:50 +0530 Subject: [PATCH] rescale strain to the load actually earned, and backfill what is stored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rides the analytics change that redefines the 0-21 headline as the TRIMP earned ABOVE a quiet-waking baseline rather than whole-waking-day TRIMP through a log base 1.5. On a real bundle an INACTIVE full-wear day scored 12.8 out of 21, because ~16 h of simply being awake accrues ~180 TRIMP and that curve is steepest near zero. 21 sat at TRIMP ~4987 — about 35 h at 80 % HRR — so the top of the scale was unreachable and a marathon read ~15.8. Both call sites now pass the wake window the TRIMP was accumulated over, since that is what sets the baseline: `perMin.length` in the engine and the pure pipeline, the window's own length for a manually logged session. Passing the OBSERVED length rather than an assumed full day is what stops a partial-wear day from being charged a whole day's overhead — a real bundle (2026-07-10) was worn 135 waking minutes. Sex is threaded through with it so the baseline is priced by the same Banister constant that scored the TRIMP. The intraday strain curve picks up Banister's 0.64/0.86 scale coefficient. It had been inlining `exp(b*hrr)` and dropping the coefficient entirely, so it accumulated a TRIMP 1.5625x the day's own — tolerable while it was only a shape, but not once the headline nets against a baseline priced with `banisterY`. Its baseline grows with the wake window already elapsed, so the curve stays flat through quiet waking and climbs only on real effort. BACKFILL. Raw 1 Hz substrate is pruned `rawRetentionDays` (3) behind the data edge, so history cannot be re-derived from raw — the engine keeps the old row and logs "no substrate (raw pruned)". It does not need raw: strain is a pure function of (TRIMP, wake minutes, sex), and metric_series already stores trimp, worn_min and tst_min for every derived day. On a real bundle `strain_curve` has 611 points against `worn_min - tst_min` = 611, so the reconstructed wake window is the one the pipeline fed the scorer. `strain_backfill.dart` rebuilds the headline from those, rewriting metric_series (trends, sparklines, v_daily, the coach's SQL surface, baselines) and writing a corrected day_result row at the new algo version — a version bump writes a NEW row, so the immutable-per-version invariant holds. It runs once, before the sweep, guarded by compute_freshness, and never fatally: a failed rescale must not take the derive cycle down. Days INSIDE the raw window are deliberately left alone for a real re-derive. Patching them would write a row at kAlgoVersion, and the derive gate matches algo_version exactly, so a partial patch would stand in for a full re-derivation. A day that cannot be rescaled — no stored TRIMP, or no wake window — is left exactly as it was and counted, because 0 is a number, not an absence. The stale `series.strain_curve` is DROPPED on rescaled days: it was built from per-sample HR that no longer exists, and its last point IS the old headline, so a curve ending at 12.79 under a headline of 9.03 contradicts itself. SEPARATE BUG, found while tracing the target the user actually sees. There were two strain targets with different key names and only one producer. crossDayPipeline emits `strain_coach` ({target_min,target_max,band}) into the insights map, which the Insights card reads. CoachData reads `coach.strain_target` ({value,low,high}) — and NOTHING wrote a `coach` key anywhere: `sub('coach')` in payloads.dart was the only occurrence of it in the tree. Today's plan chip, the Coach screen's target tile and the home-screen widget all rendered nothing, silently, while a test fixture "covered" the shape production never emitted. `coachToday()` bridges the two and getToday() emits it; the fixture's shape is now something the app actually produces. --- lib/compute/derivation_engine.dart | 63 ++++++- lib/compute/manual_session.dart | 9 +- lib/compute/onehz_pipeline.dart | 36 +++- lib/compute/strain_backfill.dart | 215 ++++++++++++++++++++++ lib/data/db.dart | 19 ++ lib/data/local_repository_impl.dart | 35 ++++ test/coach_strain_target_wiring_test.dart | 89 +++++++++ test/strain_rescale_backfill_test.dart | 207 +++++++++++++++++++++ 8 files changed, 662 insertions(+), 11 deletions(-) create mode 100644 lib/compute/strain_backfill.dart create mode 100644 test/coach_strain_target_wiring_test.dart create mode 100644 test/strain_rescale_backfill_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 03bfc53..f0a8eb2 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -25,6 +25,8 @@ import 'dart:io' show Platform; import 'dart:isolate'; import 'dart:math' as math; +import 'strain_backfill.dart' show backfillStrainScale; + import 'package:flutter/foundation.dart'; import 'nap_edits.dart'; import 'package:openstrap_analytics/onehz.dart' as ana; @@ -697,7 +699,39 @@ import 'substrate.dart'; // floor, and now bills through the same per-sample gate, resting floor and // gap cap as the re-score. Already-stored sessions are left alone — they are // not re-derived — so the change applies from this version forward. -const int kAlgoVersion = 62; +// +// v63: THE 0–21 HEADLINE STRAIN SCALE IS RECALIBRATED. +// +// `strainScore` was `min(21, ln(TRIMP+1)/ln(1.5))` over whole-waking-day +// Banister TRIMP. Two things were wrong with that, and they compounded: +// +// * Whole-day TRIMP counts every waking minute above resting, so ~16 h of +// ordinary living accrues ~180 TRIMP before any exercise. Log base 1.5 is +// steepest near zero, so that overhead alone bought ~13 of the 21 points: +// on a real bundle an INACTIVE full-wear day scored 12.8. +// * Each further point cost 1.5x the load, so 21 sat at TRIMP ~4987 — +// roughly 35 h at 80 % HRR. The top third of the scale was unreachable; +// a marathon read ~15.8. The whole usable range was about 8 to 16. +// +// Strain is now the load earned ABOVE a quiet-waking baseline (20 % of HRR, +// scaled by the wake window actually observed, so partial wear is not charged +// a full day's overhead), mapped by 21·ln(1+u·14)/ln(15) with u = net/400. +// Anchored on real days: inactive ~0, rest + a walk 2-4, a 45-min moderate +// run 8-11, a 90-min hard session 14-17, 5 h at 160 bpm 21. +// +// SAME BUMP: `strainTarget`'s recovery bands are rebased onto that +// distribution (they asked for "recover 4-8" on a scale whose floor was 13), +// and its fatigue/freshness tests are now ratios against CTL — they compared +// raw TRIMP in the hundreds against thresholds of 10 and 5, sized for the +// 0-21 scale, so they fired on ordinary week-to-week noise. The intraday +// `strain_curve` also picks up Banister's 0.64/0.86 scale coefficient, which +// it had been dropping entirely (it accumulated a TRIMP 1.5625x the day's). +// +// Days inside the raw-retention window re-derive from substrate on this bump. +// Older days have no raw to re-derive from, so `strain_backfill.dart` rebuilds +// their headline from the stored TRIMP + wake window instead — see that file +// for why that is exact and what it deliberately drops. +const int kAlgoVersion = 63; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling // all live in SleepProfilePolicy (pure, unit-tested) — see @@ -1088,6 +1122,23 @@ class DerivationEngine { }) async { if (_running) return 0; _running = true; + // ONE-SHOT: rescale stored strain onto the v63 scale. Days inside the raw + // window re-derive below from substrate; everything older has none, so its + // headline is rebuilt from the stored TRIMP + wake window instead. Runs + // before the sweep so the two never disagree mid-pass, and no-ops after the + // first successful pass (`compute_freshness`). Never fatal — a failed + // rescale must not take the derive cycle down with it. + try { + final rescaled = await backfillStrainScale( + female: workoutSex(profile.sex) == 'female', + ); + if (rescaled.didWork) { + _log('[derive] strain rescale: ${rescaled.bundleDays} day(s) rebuilt, ' + '${rescaled.skipped} skipped (no TRIMP or no wake window)'); + } + } catch (e) { + _log('[derive] strain rescale failed (kept old values): $e'); + } final startedAt = DateTime.now().millisecondsSinceEpoch; _diag ..['running'] = true @@ -3999,7 +4050,15 @@ class DerivationEngine { sex: _workoutSex(sex) == 'female' ? ana.Sex.female : ana.Sex.male, ); if (trimp.present && trimp.value != null) { - final score = ana.strainScoreMetric(trimp.value); + // `perMin` IS the wake window the TRIMP was accumulated over, so it + // sets the quiet-waking baseline that gets subtracted. Passing the + // observed length (not an assumed 24 h) is what stops a partial-wear + // day from being charged a full day's overhead. + final score = ana.strainScoreMetric( + trimp.value, + wakeMinutes: perMin.length.toDouble(), + female: _workoutSex(sex) == 'female', + ); if (score.present) strain = score.value; } } diff --git a/lib/compute/manual_session.dart b/lib/compute/manual_session.dart index e0aab19..2f48b5f 100644 --- a/lib/compute/manual_session.dart +++ b/lib/compute/manual_session.dart @@ -256,7 +256,14 @@ double? strainFromPerMinuteHr( sex: workoutSex(sex) == 'female' ? ana.Sex.female : ana.Sex.male, ); if (!trimp.present || trimp.value == null) return null; - final score = ana.strainScoreMetric(trimp.value); + // The window's own length is the baseline window: strain is the load earned + // ABOVE quiet waking, and the same sex constant has to price the baseline as + // priced the TRIMP or the subtraction is off by the male/female coefficient. + final score = ana.strainScoreMetric( + trimp.value, + wakeMinutes: perMinuteHr.length.toDouble(), + female: workoutSex(sex) == 'female', + ); return score.present ? score.value : null; } diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index 0e9c7d1..3239753 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -539,9 +539,15 @@ Map deriveDayBundle(Map inputJson) { } } - // HEADLINE STRAIN = 0–21 log-squash of raw TRIMP; raw TRIMP kept as a detail. + // HEADLINE STRAIN = 0–21 map of the TRIMP earned ABOVE the quiet-waking + // baseline; raw TRIMP kept as a detail. `perMin` is the wake window the TRIMP + // was accumulated over, so it sets the baseline that gets subtracted. final rawTrimp = trimp.present ? trimp.value : null; - final strainMetric = strainScoreMetric(rawTrimp); + final strainMetric = strainScoreMetric( + rawTrimp, + wakeMinutes: perMin.isEmpty ? null : perMin.length.toDouble(), + female: workoutSex(sex) == 'female', + ); // ── curve series for the UI ──────────────────────────────────────────────── final hrCurve = _downsampleHr(d.dayTsSec, d.dayHr); @@ -1094,19 +1100,33 @@ List> _strainCurve( sex == null) { return const []; } - // Banister's sex constant, via the shared normalisation — a profile stored - // as 'female' by the profile screen used to fall through to the male value - // here while scoring female everywhere else. - final b = workoutSex(sex) == 'female' ? 1.67 : 1.92; + // Banister's sex constants, via the ONE shared weighting factor. This used to + // inline `exp(b·hrr)` and drop the 0.64/0.86 scale coefficient entirely, so + // the curve accumulated a TRIMP 1.5625× the day's own — the curve and the + // headline were never on the same scale. It matters more now: the headline + // subtracts a baseline priced with `banisterY`, so a curve accumulating + // without it would be netted against an allowance from a different formula. + final female = workoutSex(sex) == 'female'; final reserve = maxHr - restingHr; var trimp = 0.0; + var wakeMin = 0.0; final out = >[]; for (final p in wakeHr) { var hrr = (p.hr - restingHr) / reserve; if (hrr < 0) hrr = 0; if (hrr > 1) hrr = 1; - trimp += hrr * math.exp(b * hrr); - out.add({'t': p.tsSec, 'v': _round(strainScore(trimp), 2)}); + trimp += hrr * StrainScorer.banisterY(hrr, female: female); + // The baseline grows with the wake window ALREADY elapsed, so the curve + // stays flat through quiet waking and climbs only on real effort — rather + // than charging a whole day's allowance against the first minute. + wakeMin += 1; + out.add({ + 't': p.tsSec, + 'v': _round( + strainScore(trimp, wakeMinutes: wakeMin, female: female), + 2, + ), + }); } return out; } diff --git a/lib/compute/strain_backfill.dart b/lib/compute/strain_backfill.dart new file mode 100644 index 0000000..e243524 --- /dev/null +++ b/lib/compute/strain_backfill.dart @@ -0,0 +1,215 @@ +// ONE-SHOT BACKFILL — stored strain onto the recalibrated 0–21 scale. +// +// The headline strain map changed: it used to be `min(21, ln(TRIMP+1)/ln(1.5))` +// over whole-waking-day TRIMP, which charged ~180 TRIMP of simply being awake +// as training load and put an INACTIVE full-wear day at ~13/21. It is now the +// load earned ABOVE a quiet-waking baseline that scales with the wake window. +// Every day derived before that change carries a number on the old scale, so +// trends, v_daily/coach SQL and the day-detail screen would show a step change +// at the fix date rather than a real one in the user's training. +// +// WHY NOT JUST RE-DERIVE: raw 1 Hz substrate is pruned `rawRetentionDays` (3) +// behind the DATA EDGE. For anything older there is no substrate — the engine +// logs "no substrate (raw pruned) — kept" and keeps the old row — so a +// kAlgoVersion bump alone can only ever fix the last few days. +// +// It does not need raw. Strain is a pure function of (TRIMP, wake minutes, +// sex), and `metric_series` already stores `trimp`, `worn_min` and `tst_min` +// for every derived day, so the headline can be rebuilt exactly from what is +// on disk. (`series.strain_curve` carries one point per wake minute, and on a +// real bundle its length equals `worn_min − tst_min` — the reconstruction of +// the wake window used here is the same one the pipeline fed the scorer.) + +import 'dart:convert'; + +import 'package:openstrap_analytics/onehz.dart' as ana; + +import '../data/db.dart'; +import 'derivation_engine.dart' show kAlgoVersion, rawRetentionDays; + +/// `compute_freshness` key marking the rescale as already applied. Bumped with +/// the algo version so a future rescale is a new one-shot rather than a no-op. +const String kStrainRescaleKey = 'strain_rescale_v63'; + +class StrainBackfillResult { + /// Days whose `metric_series` strain was rewritten (trends / v_daily). + final int seriesDays; + + /// Days that got a fresh `day_result` row at the current algo version. + final int bundleDays; + + /// Days left exactly as they were because they could not be rescaled. + final int skipped; + + const StrainBackfillResult({ + required this.seriesDays, + required this.bundleDays, + required this.skipped, + }); + + bool get didWork => seriesDays > 0 || bundleDays > 0; +} + +/// Rebuild one day's headline strain from its stored scalars. +/// +/// Returns null when the day cannot be rescaled — no TRIMP to rescale from, or +/// no wake window to price the baseline over. A day that cannot be rescaled is +/// LEFT ALONE: an un-rescalable day must not silently become 0, which is a +/// number, not an absence. +double? rescaledStrain({ + required double? trimp, + required double? wornMin, + required double? tstMin, + required bool female, +}) { + if (trimp == null || wornMin == null) return null; + final wake = wornMin - (tstMin ?? 0); + if (wake <= 0) return null; + return ana.strainScore(trimp, wakeMinutes: wake, female: female); +} + +/// Rescale every stored day that can no longer be re-derived from raw. +/// +/// [female] selects the Banister constant for the quiet-waking baseline; it has +/// to match the constant the stored TRIMP was scored with or the subtraction is +/// off by the male/female coefficient. Runs once — set [force] to re-run. +Future backfillStrainScale({ + required bool female, + bool force = false, +}) async { + const none = StrainBackfillResult(seriesDays: 0, bundleDays: 0, skipped: 0); + if (!force && await LocalDb.computeFreshness(kStrainRescaleKey) != null) { + return none; + } + + final strainRows = await LocalDb.metricSeries('strain'); + if (strainRows.isEmpty) { + await _markDone(); + return none; + } + + final trimpBy = await _byDate('trimp'); + final wornBy = await _byDate('worn_min'); + final tstBy = await _byDate('tst_min'); + + // The DATA EDGE is the newest day on disk, matching how the pruner measures + // retention (never the wall clock — a multi-day flash backfill received in + // one sync must not be treated as old). Days at or after the cutoff still + // have raw and are LEFT for a real re-derive: writing a patched row at + // kAlgoVersion here would satisfy the derive gate, which matches + // algo_version EXACTLY, and a partial patch would stand in for a full + // re-derivation of the day. + final days = [ + for (final r in strainRows) ?(r['date'] as String?), + ]..sort(); + final cutoff = _shiftDays(days.last, -rawRetentionDays); + + var seriesDays = 0; + var bundleDays = 0; + var skipped = 0; + + for (final day in days) { + if (day.compareTo(cutoff) >= 0) continue; + + final row = await LocalDb.dayResult(day); + // Already carries a row at the current version — rescaled on a prior pass. + if (row != null && + ((row['algo_version'] as num?)?.toInt() ?? 0) >= kAlgoVersion) { + continue; + } + + final next = rescaledStrain( + trimp: trimpBy[day], + wornMin: wornBy[day], + tstMin: tstBy[day], + female: female, + ); + if (next == null) { + skipped++; + continue; + } + + if (row == null) { + // A series row with no bundle behind it: still worth fixing the trend. + await LocalDb.putMetricSeriesValue(day, 'strain', next); + seriesDays++; + continue; + } + + final payload = _decode(row['payload_json']); + if (payload == null) { + skipped++; + continue; + } + final scalars = payload['scalars']; + if (scalars is! Map) { + skipped++; + continue; + } + scalars['strain'] = next; + + // The intraday curve is cumulative strain, one point per wake minute, built + // from per-sample HR that no longer exists — it cannot be rescaled, and its + // last point IS the old headline. A curve ending at 12.79 under a headline + // of 9.03 contradicts itself, so it is DROPPED rather than left to disagree. + final series = payload['series']; + if (series is Map) series.remove('strain_curve'); + + final partial = (row['partial'] as num?)?.toInt() == 1; + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode(payload), + windowJson: (row['window_json'] as String?) ?? '{}', + finalized: (row['finalized'] as num?)?.toInt() == 1, + skipped: (row['skipped'] as num?)?.toInt() == 1, + partial: partial, + rhr: (row['rhr'] as num?)?.toDouble(), + rmssd: (row['rmssd'] as num?)?.toDouble(), + readiness: (row['readiness'] as num?)?.toDouble(), + // `putDayResult` skips the series write for a partial row, so only count + // the trend as rewritten when it actually was. + series: {'strain': next}, + ); + bundleDays++; + if (!partial) seriesDays++; + } + + await _markDone(); + return StrainBackfillResult( + seriesDays: seriesDays, + bundleDays: bundleDays, + skipped: skipped, + ); +} + +Future _markDone() => + LocalDb.putComputeFreshness(kStrainRescaleKey, jsonEncode({'done': true})); + +Future> _byDate(String key) async { + final out = {}; + for (final r in await LocalDb.metricSeries(key)) { + final d = r['date'] as String?; + final v = (r['value'] as num?)?.toDouble(); + if (d != null && v != null) out[d] = v; + } + return out; +} + +Map? _decode(Object? json) { + if (json is! String) return null; + try { + final v = jsonDecode(json); + return v is Map ? v.cast() : null; + } catch (_) { + return null; + } +} + +/// Shift a 'YYYY-MM-DD' label by [days] calendar days. +String _shiftDays(String day, int days) { + final t = DateTime.parse(day).add(Duration(days: days)); + final mm = t.month.toString().padLeft(2, '0'); + final dd = t.day.toString().padLeft(2, '0'); + return '${t.year}-$mm-$dd'; +} diff --git a/lib/data/db.dart b/lib/data/db.dart index 2ca1326..4782b43 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -4391,6 +4391,25 @@ class LocalDb { ); + /// Write ONE (date, key) scalar into the canonical series store. + /// + /// The bulk path is [putDayResult]'s `series` map, which writes a whole day's + /// scalars alongside its bundle. This is for the case where a series row has + /// to be corrected on its own — a day whose bundle is gone but whose trend + /// point is still on screen (see `strain_backfill.dart`). + static Future putMetricSeriesValue( + String date, + String key, + double? value, + ) async { + final db = await instance; + await db.insert('metric_series', { + 'date': date, + 'key': key, + 'value': value, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + /// A long-format metric series (oldest first) for trends/sparklines. static Future>> metricSeries( String key, { diff --git a/lib/data/local_repository_impl.dart b/lib/data/local_repository_impl.dart index 6550061..d23211c 100644 --- a/lib/data/local_repository_impl.dart +++ b/lib/data/local_repository_impl.dart @@ -359,6 +359,9 @@ class LocalRepositoryImpl extends LocalRepository { // Cross-day rollup surfaced on Today (present only when computed). 'illness': cd?['illness'], 'anomaly': cd?['anomaly'], + // Today's strain target, in the shape CoachData reads. Absent until + // `strainTarget` has a recovery value, which the surfaces already handle. + 'coach': coachToday(cd), 'load': cd?['load'], 'readiness_breakdown': cd?['readiness_glassbox'], 'regularity': cd?['regularity'], @@ -3241,6 +3244,38 @@ class LocalRepositoryImpl extends LocalRepository { } } +/// The /today `coach` block, bridging the cross-day strain target onto the +/// shape [CoachData] reads. Pure + public so the seam is unit-testable. +/// +/// There were TWO strain targets and only one producer. `crossDayPipeline` +/// emits `strain_coach` as a Metric ({value: {target_min, target_max, band, +/// rationale}}), which the Insights card reads. [CoachData] — behind Today's +/// plan row, the Coach screen's target tile and the home-screen widget — reads +/// `coach.strain_target` ({value, low, high, rationale}), and NOTHING wrote a +/// `coach` key anywhere in the app, so those three surfaces silently rendered +/// nothing while a test fixture "covered" the shape production never emitted. +/// +/// `value` is the CENTRE of the aim band (what the Today chip shows). Returns +/// null when the target abstains — `strainTarget` has no recovery value yet, +/// and an absent target must not surface as a 0–0 aim band. +Map? coachToday(Map? crossDay) { + final metric = crossDay?['strain_coach']; + if (metric is! Map) return null; + final v = metric['value']; + if (v is! Map) return null; + final lo = (v['target_min'] as num?)?.toDouble(); + final hi = (v['target_max'] as num?)?.toDouble(); + if (lo == null || hi == null) return null; + return { + 'strain_target': { + 'value': (lo + hi) / 2, + 'low': lo, + 'high': hi, + 'rationale': (v['rationale'] ?? '').toString(), + }, + }; +} + /// The /today `stress` block from a day bundle — the pipeline's Baevsky block, /// verbatim, with NO fallback substitute when SI couldn't compute a score. /// (Previously mirrored getDayStress's `100 - readiness` fallback; removed for diff --git a/test/coach_strain_target_wiring_test.dart b/test/coach_strain_target_wiring_test.dart new file mode 100644 index 0000000..2c286ad --- /dev/null +++ b/test/coach_strain_target_wiring_test.dart @@ -0,0 +1,89 @@ +// The strain target reaches the Today row, the Coach screen and the home widget. +// +// There were TWO strain targets with different key names, and only one was ever +// produced. The cross-day pipeline writes `strain_coach` ({target_min, +// target_max, band, rationale}) into the insights map, which the Insights +// Strain Coach card reads. `CoachData` — the model behind Today's "Today's +// plan" chip, the Coach screen's target tile and the home-screen widget — reads +// `coach.strain_target` ({value, low, high, rationale}) instead, and NOTHING in +// the app ever wrote a `coach` key: `sub('coach')` in payloads.dart was the only +// occurrence of that key in the whole codebase. The three surfaces silently +// rendered nothing, and the test fixture that "covered" them supplied a shape +// production never emitted. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/local_repository_impl.dart'; +import 'package:openstrap_edge/models/payloads.dart'; + +/// A cross-day map shaped exactly like `crossDayPipeline` emits. +Map crossDayWith(Object? strainCoachValue) => { + 'strain_coach': { + 'value': strainCoachValue, + 'confidence': 0.6, + 'tier': 'ESTIMATE', + 'inputs_used': const ['recovery', 'load'], + }, +}; + +void main() { + group('coachToday — cross-day strain_coach → coach.strain_target', () { + test('maps the band onto the value/low/high shape CoachData reads', () { + final coach = coachToday( + crossDayWith({ + 'target_min': 9.0, + 'target_max': 14.0, + 'band': 'maintain', + 'rationale': 'Target shaped by recovery and recent load.', + }), + ); + + final t = coach?['strain_target'] as Map?; + expect(t, isNotNull); + expect(t!['low'], closeTo(9.0, 1e-9)); + expect(t['high'], closeTo(14.0, 1e-9)); + // The headline number is the centre of the aim band. + expect(t['value'], closeTo(11.5, 1e-9)); + expect(t['rationale'], 'Target shaped by recovery and recent load.'); + }); + + test('REGRESSION: the emitted shape actually reaches CoachData', () { + // The point of the fix: a row built the way getToday() builds it must + // survive TodayData.fromJson and come out the far side as a real target. + // Before, coach was absent, so TodayData.coach was null forever. + final row = { + 'daily': const {}, + 'sleep': const {}, + 'coach': coachToday( + crossDayWith({ + 'target_min': 13.0, + 'target_max': 18.0, + 'band': 'push', + 'rationale': 'Recovered well.', + }), + ), + }; + + final coach = TodayData.fromJson(row).coach; + expect(coach, isNotNull, reason: 'the coach key must be populated'); + + final tgt = coach!.strainTarget; + expect(tgt, isNotNull, reason: 'Today/Coach/widget read this'); + expect(tgt!.low, closeTo(13.0, 1e-9)); + expect(tgt.high, closeTo(18.0, 1e-9)); + expect(tgt.value, closeTo(15.5, 1e-9)); + }); + + test('an absent target produces no coach map rather than a fake one', () { + // `strainTarget` abstains until there is a recovery value today. An + // abstaining metric must not surface as a 0–0 aim band. + expect(coachToday(crossDayWith(null)), isNull); + expect(coachToday(const {}), isNull); + expect(coachToday(null), isNull); + }); + + test('a malformed band is dropped, not half-rendered', () { + expect(coachToday(crossDayWith({'band': 'maintain'})), isNull); + expect(coachToday(crossDayWith({'target_min': 9.0})), isNull); + }); + }); +} diff --git a/test/strain_rescale_backfill_test.dart b/test/strain_rescale_backfill_test.dart new file mode 100644 index 0000000..329fbfb --- /dev/null +++ b/test/strain_rescale_backfill_test.dart @@ -0,0 +1,207 @@ +// One-shot backfill of stored strain onto the recalibrated 0–21 scale. +// +// Raw 1 Hz substrate is pruned `rawRetentionDays` (3) behind the data edge, so +// history CANNOT be re-derived from raw — the engine logs "no substrate (raw +// pruned) — kept" and keeps the old row. It does not need raw: strain is a pure +// function of (TRIMP, wake minutes, sex), and `metric_series` already stores +// `trimp`, `worn_min` and `tst_min` for every derived day. This rebuilds the +// headline from those, so trends, v_daily/coach SQL and the day detail agree +// instead of showing a scale discontinuity at the fix date. + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:openstrap_edge/compute/derivation_engine.dart' show kAlgoVersion; +import 'package:openstrap_edge/compute/strain_backfill.dart'; +import 'package:openstrap_edge/data/db.dart'; + +/// Seed a derived day the way the engine would have before the rescale. +Future seedDay( + String day, { + required double? trimp, + required double? strain, + required double wornMin, + required double tstMin, + bool finalized = true, + int algoVersion = 62, +}) async { + await LocalDb.putDayResult( + dayId: day, + algoVersion: algoVersion, + payloadJson: jsonEncode({ + 'date': day, + 'scalars': { + 'trimp': trimp, + 'strain': strain, + 'worn_min': wornMin, + 'tst_min': tstMin, + }, + 'series': { + 'hr_curve': [ + {'t': 1, 'v': 70}, + ], + 'strain_curve': [ + {'t': 1, 'v': strain}, + ], + }, + }), + windowJson: '{}', + finalized: finalized, + series: { + 'trimp': trimp, + 'strain': strain, + 'worn_min': wornMin, + 'tst_min': tstMin, + }, + ); +} + +Future seriesValue(String key, String day) async { + final rows = await LocalDb.metricSeries(key); + for (final r in rows) { + if (r['date'] == day) return (r['value'] as num?)?.toDouble(); + } + return null; +} + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_strain_backfill_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + group('rescaledStrain — pure recompute from stored scalars', () { + test('rebuilds the headline from TRIMP and the wake window', () { + // Real bundle 2026-07-09: TRIMP 177.8, worn 827, TST 216 → wake 611. + // Old scale put this at 12.79; the rescale reads ~9.0. + final s = rescaledStrain( + trimp: 177.80394321843846, + wornMin: 827, + tstMin: 216, + female: false, + ); + expect(s, isNotNull); + expect(s!, closeTo(9.03, 0.05)); + }); + + test('a short-wear inactive day rescales to zero', () { + // Real bundle 2026-07-10: 23 steps, worn 486, TST 351 → wake 135. + expect( + rescaledStrain(trimp: 23.416868457643158, wornMin: 486, tstMin: 351, + female: false), + 0.0, + ); + }); + + test('abstains rather than guessing when an input is missing', () { + // No TRIMP → nothing to rescale from. Must leave the day alone, not zero it. + expect( + rescaledStrain(trimp: null, wornMin: 827, tstMin: 216, female: false), + isNull, + ); + expect( + rescaledStrain(trimp: 177.8, wornMin: null, tstMin: 216, female: false), + isNull, + ); + // Wear entirely inside sleep leaves no wake window to price. + expect( + rescaledStrain(trimp: 177.8, wornMin: 200, tstMin: 240, female: false), + isNull, + ); + }); + + test('sex changes the baseline, matching how the TRIMP was scored', () { + final male = rescaledStrain( + trimp: 300, wornMin: 900, tstMin: 0, female: false)!; + final female = rescaledStrain( + trimp: 300, wornMin: 900, tstMin: 0, female: true)!; + // The female quiet-waking allowance is larger (0.86·e^0.334 vs + // 0.64·e^0.384), so the same TRIMP nets less strain. + expect(female, lessThan(male)); + }); + }); + + group('backfillStrainScale — the stored history', () { + test('rescales a raw-pruned historical day in series AND bundle', () async { + await seedDay('2026-07-09', + trimp: 177.80394321843846, strain: 12.790964777435558, + wornMin: 827, tstMin: 216); + // Data edge, well inside the retention window — must be left for the + // engine to re-derive from raw rather than patched here. + await seedDay('2026-07-20', + trimp: 200, strain: 13.0, wornMin: 900, tstMin: 400, + finalized: false); + + final r = await backfillStrainScale(female: false); + expect(r.seriesDays, 1); + expect(r.bundleDays, 1); + + // The trend series now carries the rescaled value. + expect(await seriesValue('strain', '2026-07-09'), closeTo(9.03, 0.05)); + // …and so does the bundle the day-detail screen reads. + final row = await LocalDb.dayResult('2026-07-09'); + expect((row!['algo_version'] as num).toInt(), kAlgoVersion); + final scalars = (jsonDecode(row['payload_json'] as String) + as Map)['scalars'] as Map; + expect((scalars['strain'] as num).toDouble(), closeTo(9.03, 0.05)); + // TRIMP is the input, not the output — it must survive untouched. + expect((scalars['trimp'] as num).toDouble(), + closeTo(177.80394321843846, 1e-9)); + }); + + test('leaves days inside the raw-retention window for a real re-derive', + () async { + // Patching these would write a row AT kAlgoVersion, and the derive gate + // matches algo_version EXACTLY — the engine would then skip the day and + // a partial patch would stand in for a full re-derivation. + expect(await seriesValue('strain', '2026-07-20'), closeTo(13.0, 1e-9)); + final row = await LocalDb.dayResult('2026-07-20'); + expect((row!['algo_version'] as num).toInt(), 62); + }); + + test('drops the stale intraday curve rather than contradicting the headline', + () async { + // `series.strain_curve` is cumulative strain, one point per wake minute, + // and its last point IS the old headline (12.79 for 2026-07-09). It was + // built from per-sample HR that no longer exists, so it cannot be + // rescaled — and a curve ending at 12.79 under a headline of 9.03 is + // worse than no curve. The UI already renders a missing curve honestly. + final row = await LocalDb.dayResult('2026-07-09'); + final payload = jsonDecode(row!['payload_json'] as String) as Map; + final series = payload['series'] as Map?; + expect(series?['strain_curve'], isNull); + // Everything else in the block survives. + expect(series?['hr_curve'], isNotNull); + }); + + test('is idempotent — a second run rewrites nothing', () async { + final again = await backfillStrainScale(female: false); + expect(again.seriesDays, 0); + expect(again.bundleDays, 0); + expect(await seriesValue('strain', '2026-07-09'), closeTo(9.03, 0.05)); + }); + + test('a day with no stored TRIMP is skipped, not zeroed', () async { + await LocalDb.putComputeFreshness(kStrainRescaleKey, '{}'); + await seedDay('2026-06-01', + trimp: null, strain: 11.5, wornMin: 800, tstMin: 200); + + final r = await backfillStrainScale(female: false, force: true); + expect(r.skipped, greaterThanOrEqualTo(1)); + // Left exactly as it was — an un-rescalable day must not become 0. + expect(await seriesValue('strain', '2026-06-01'), closeTo(11.5, 1e-9)); + }); + }); +}