diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index e5c3fd99..f2d122e4 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; @@ -736,7 +738,39 @@ import 'substrate.dart'; // before citing a sibling-package change here — a bump whose stated cause is // not in the pinned code is how a fix was believed shipped for three releases // while the pin never carried it. -const int kAlgoVersion = 64; +// +// v65: 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 = 65; // Fold idempotency, the minimum-nights warm-up, and legacy-payload handling // all live in SleepProfilePolicy (pure, unit-tested) — see @@ -1127,6 +1161,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 @@ -4041,7 +4092,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 e0aab192..2f48b5ff 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 0e9c7d16..3239753d 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 00000000..e243524e --- /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 aae96286..5e35bd83 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -4648,6 +4648,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 65500613..d23211cf 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/pubspec.lock b/pubspec.lock index 14663caf..95184090 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -964,8 +964,8 @@ packages: dependency: "direct main" description: path: "." - ref: e047c5920ea2e0def28f028d0fc990c1fe64cd6b - resolved-ref: e047c5920ea2e0def28f028d0fc990c1fe64cd6b + ref: cef6fe4d11c4b4a15ae626350304e882882405e1 + resolved-ref: cef6fe4d11c4b4a15ae626350304e882882405e1 url: "https://github.com/OpenStrap/analytics.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 662fe4c1..c5223b8e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -98,7 +98,7 @@ dependencies: # Verified against the SHA: # `git show e047c59:lib/src/onehz/workout/calories.dart | # grep defaultMergeGapCapS` - ref: e047c5920ea2e0def28f028d0fc990c1fe64cd6b + ref: cef6fe4d11c4b4a15ae626350304e882882405e1 # BLE — flutter_blue_plus is the maintained cross-platform GATT client. flutter_blue_plus: ^1.36.8 diff --git a/test/coach_strain_target_wiring_test.dart b/test/coach_strain_target_wiring_test.dart new file mode 100644 index 00000000..2c286ad3 --- /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 00000000..329fbfb6 --- /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)); + }); + }); +}