diff --git a/lib/src/onehz/human/coaching.dart b/lib/src/onehz/human/coaching.dart index d99b46b..4db4216 100644 --- a/lib/src/onehz/human/coaching.dart +++ b/lib/src/onehz/human/coaching.dart @@ -282,6 +282,32 @@ class JournalTagCorrelation { const JournalTagCorrelation(this.tag, this.effects); } +/// PER-TAG outcome lag, in days — the tag twin of [journalFieldLagDays]. +/// Behaviour during the day lands on the night that follows (+1); retrospective +/// tags describe the night already over (0). Unlisted tags keep lag 0. +const Map journalTagLagDays = { + 'caffeine': 1, + 'alcohol': 1, + 'late meal': 1, + 'screens late': 1, + 'sauna': 1, + 'cold plunge': 1, + 'workout': 1, + 'social': 1, + 'rest day': 1, + 'stress': 0, + 'poor sleep': 0, + 'sick': 0, +}; + +/// The journal day to store [tag] on when it is given as the cause of the +/// night ending the morning of [nightDay] (the wake day, the outcome's own +/// label). A lag-1 tag goes on the evening before, so [journalCorrelations] +/// pairs it back with that night; writing it on [nightDay] pairs it with the +/// night after. Null when [nightDay] is not a date. +String? journalTagDayForNight(String nightDay, String tag) => + shiftDayLabel(nightDay, -(journalTagLagDays[tag] ?? 0)); + /// Per-tag effect of a journal entry on each outcome series. /// /// [outcomes] values must be POSITIONALLY ALIGNED to [dates] (same length); a @@ -308,10 +334,26 @@ class JournalTagCorrelation { /// /// When both sides are exactly constant (pooled SD = 0) d is undefined and we /// require [minNForZeroSpread] observations per side before the floor passes. +/// +/// PER-TAG LAG ([tagLagDays], same reasoning as [journalFieldLagDays]): an +/// outcome on day D is split by the tags logged on D − lag. A behaviour tag +/// logged on D (alcohol, late meal) lands on the night ending the morning of +/// D+1, so its outcome is D+1's. When D − lag has no journal row the day is +/// dropped — we don't know whether the tag applied. [dates] must be +/// `YYYY-MM-DD` for any tag with a non-zero lag; a label that can't be shifted +/// drops the day the same way. A writer that tags a night +/// after the fact (a cause picked on the wake day) must store each tag on +/// [journalTagDayForNight], not on the wake day. +/// +/// So [dates] must reach past the journal: pass every journal date AND the day +/// after it (outcomes aligned to that union), or a lag-1 tag only counts when +/// the day after it was journaled too, and someone who journals only on the +/// nights they drink gets nothing at all. [journal] stays the real rows. List journalCorrelations({ required List journal, required List dates, required Map> outcomes, + Map tagLagDays = journalTagLagDays, double minEffectPct = 3.0, double minCohensD = 0.5, int minNForZeroSpread = 3, @@ -364,10 +406,14 @@ List journalCorrelations({ final untagged = []; final vals = []; final inGroup = []; + final lag = tagLagDays[tag] ?? 0; for (var i = 0; i < dates.length; i++) { final v = entry.value[i]; if (v == null) continue; - final hasTag = tagByDate[dates[i]]?.contains(tag) == true; + final src = lag == 0 ? dates[i] : shiftDayLabel(dates[i], -lag); + final tags = src == null ? null : tagByDate[src]; + if (tags == null) continue; // no row that day: unknown, not untagged + final hasTag = tags.contains(tag); (hasTag ? tagged : untagged).add(v); vals.add(v); inGroup.add(hasTag); diff --git a/lib/src/onehz/human/session_cost.dart b/lib/src/onehz/human/session_cost.dart index 28ff8a2..2638518 100644 --- a/lib/src/onehz/human/session_cost.dart +++ b/lib/src/onehz/human/session_cost.dart @@ -71,10 +71,11 @@ const int sessionCostMinSessions = 10; /// Next-morning effect of each session type, one row per type. /// -/// POSITIONAL ALIGNMENT, contiguous daily series, oldest first: [dates], -/// [values] and [coverage] are index-aligned, and index i+1 is the morning -/// AFTER index i. The caller passes the day series it already has; nothing here -/// parses a date, so a local day label never has to survive a timezone. +/// Daily series, oldest first: [dates], [values] and [coverage] are +/// index-aligned. The series may skip days (an underived day has no row), so +/// the morning after index i only counts when index i+1 is the NEXT calendar +/// day, and the baseline window is [baselineDays] calendar days, not rows — +/// see [calendarDays]. Labels are compared as plain dates, no timezone. /// /// [sessionTypesByDate] maps a day label to every session that started that /// day. A day with more than one entry is dropped, not split. @@ -103,12 +104,14 @@ Metric> sessionMorningEffects({ } final deltasByType = >{}; + final day = calendarDays(dates); final mdcsByType = >{}; for (var i = 0; i + 1 < dates.length; i++) { final types = sessionTypesByDate[dates[i]]; if (types == null || types.length != 1) continue; // none, or ambiguous final morning = i + 1; + if (day[morning] != day[i] + 1) continue; // a gap: not the next morning final v = values[morning]; if (v == null) continue; if (coverage != null) { @@ -117,9 +120,10 @@ Metric> sessionMorningEffects({ } // Baseline from the days BEFORE the morning, excluding the morning itself. // Including it would drag the baseline toward the very value under test. - final from = morning - baselineDays < 0 ? 0 : morning - baselineDays; final window = [ - for (var k = from; k < morning; k++) + for (var k = morning - 1; + k >= 0 && day[morning] - day[k] <= baselineDays; + k--) if (values[k] != null) values[k]! ]; if (window.length < minBaseline) continue; diff --git a/lib/src/onehz/wellness/anomaly.dart b/lib/src/onehz/wellness/anomaly.dart index 02896a1..b659c53 100644 --- a/lib/src/onehz/wellness/anomaly.dart +++ b/lib/src/onehz/wellness/anomaly.dart @@ -117,17 +117,16 @@ List multivariateAnomaly( final cur = _orient(feats[i]); // Build per-feature baseline columns (valid only) from the trailing window. final cols = List.generate(4, (_) => []); - // Aligned rows (all 4 features present) for covariance off-diagonals. - final rows = >[]; + // Oriented baseline rows, filtered to tonight's kept features below for + // the covariance off-diagonals. + final baseRows = >[]; for (var j = i - 1; j >= 0; j--) { if (day[i] - day[j] > baselineDays) break; final o = _orient(feats[j]); for (var f = 0; f < 4; f++) { if (o[f] != null) cols[f].add(o[f]!); } - if (o.every((v) => v != null)) { - rows.add([for (final v in o) v!]); - } + baseRows.add(o); } // Which features are available BOTH tonight and with enough baseline? final idx = []; @@ -198,6 +197,13 @@ List multivariateAnomaly( ]; // Robust correlation matrix from aligned rows (standardized), regularized. + // A row is aligned when every KEPT feature is present; requiring all four + // let one sparse column (temp, resp) force the identity, so a single + // autonomic shift (RHR up + HRV down) was counted twice. + final rows = [ + for (final o in baseRows) + if (keep.every((f) => o[f] != null)) [for (final v in o) v ?? 0.0] + ]; final cov = _robustCorr(rows, keep, center, scale, ridge); final inv = _invert(cov); double d2; diff --git a/test/onehz/an_training_human_test.dart b/test/onehz/an_training_human_test.dart index 77e2603..6a58fa3 100644 --- a/test/onehz/an_training_human_test.dart +++ b/test/onehz/an_training_human_test.dart @@ -215,6 +215,30 @@ void main() { expect(m.present, isFalse); }); + test('the next ROW after a wear gap is not the next morning', () { + // Every session day is followed by two unworn days with no row, then an + // elevated morning. None of those mornings followed the session. + final all = _dates(120); + const noise = [0.0, 1, -1, 2, -2, 1, -1, 0, 2, -2]; + final dates = []; + final values = []; + final sessions = >{}; + for (var d = 0; d < 120; d++) { + final c = d % 7; // 0..3 worn, 4..5 unworn, 6 = elevated morning + if (c == 4 || c == 5) continue; + dates.add(all[d]); + values.add(50.0 + noise[d % 10] + (c == 6 ? 10 : 0)); + if (c == 3) sessions[all[d]] = ['football']; + } + final m = sessionMorningEffects( + dates: dates, + values: values, + metric: 'rhr', + sessionTypesByDate: sessions, + ); + expect(m.present, isFalse); + }); + test('refuses under the minimum n rather than showing a small one', () { final dates = _dates(60); final m = sessionMorningEffects( diff --git a/test/onehz/coaching_test.dart b/test/onehz/coaching_test.dart index 04fa328..da0ca98 100644 --- a/test/onehz/coaching_test.dart +++ b/test/onehz/coaching_test.dart @@ -372,7 +372,8 @@ void main() { 'recovery': [40, 41, 42, 43, 44, 80, 81, 82, 83, 84], }; final out = journalCorrelations( - journal: journal, dates: dates, outcomes: outcomes); + journal: journal, dates: dates, outcomes: outcomes, + tagLagDays: const {}); final eff = out.firstWhere((c) => c.tag == 'alcohol').effects.single; expect(eff.insufficient, isFalse); expect(eff.meaningful, isTrue); @@ -405,6 +406,91 @@ void main() { expect(eff.insufficient, isTrue); }); + test('a behaviour tag is matched to the NEXT morning, not the same day', () { + // Alcohol logged on day D shows up in the night ending the morning of + // D+1. Lag 0 compared it against a night that was already over. + String label(int i) => + DateTime.utc(2026, 3, 1 + i).toIso8601String().substring(0, 10); + final dates = [for (var i = 0; i < 20; i++) label(i)]; + final out = journalCorrelations( + journal: [ + for (var i = 0; i < 20; i++) + JournalDay(label(i), {if (i % 4 == 0) 'alcohol'}), + ], + dates: dates, + outcomes: { + 'recovery': [for (var i = 0; i < 20; i++) i % 4 == 1 ? 40.0 : 80.0], + }, + ); + final eff = out.firstWhere((c) => c.tag == 'alcohol').effects.single; + expect(eff.nTagged, 5); + expect(eff.nUntagged, 14, reason: 'day 0 has no prior row: dropped'); + expect(eff.delta, closeTo(-40, 1e-9)); + expect(eff.meaningful, isTrue); + }); + + test('a sparse journaler still gets lagged tags when dates reach the day after', () { + // Journals every other day: alcohol on 0, 4, 8..., stress on 2 and 6. + // dates = journal days + the day after each, so the morning after a + // drink is in range even though nobody journaled it. + String label(int i) => + DateTime.utc(2026, 3, 1 + i).toIso8601String().substring(0, 10); + final out = journalCorrelations( + journal: [ + for (var i = 0; i < 20; i += 2) + JournalDay(label(i), { + if (i % 4 == 0) 'alcohol', + if (i == 2 || i == 6) 'stress', + }), + ], + dates: [for (var i = 0; i < 20; i++) label(i)], + outcomes: { + 'recovery': [for (var i = 0; i < 20; i++) i % 4 == 1 ? 40.0 : 80.0], + }, + ); + final alc = out.firstWhere((c) => c.tag == 'alcohol').effects.single; + expect(alc.nTagged, 5); + expect(alc.nUntagged, 5); + expect(alc.delta, closeTo(-40, 1e-9)); + // Lag 0: a day nobody journaled is unknown, not a stress-free day. + final st = out.firstWhere((c) => c.tag == 'stress').effects.single; + expect(st.nTagged, 2); + expect(st.nUntagged, 8); + }); + + test('a cause tagged against a night lands on that night\'s outcome', () { + // "What was behind last night?" is answered on the wake day. Stored as + // is, a lag-1 tag pairs with the NEXT night and the rough one is left + // untagged. Stored via journalTagDayForNight it pairs with itself. + String label(int i) => + DateTime.utc(2026, 3, 1 + i).toIso8601String().substring(0, 10); + final rough = {for (var i = 3; i < 20; i += 4) label(i)}; + final byDay = >{ + for (var i = 0; i < 20; i++) label(i): {}, + }; + for (final night in rough) { + for (final t in ['alcohol', 'stress']) { + byDay[journalTagDayForNight(night, t)]!.add(t); + } + } + final out = journalCorrelations( + journal: [for (final e in byDay.entries) JournalDay(e.key, e.value)], + dates: [for (var i = 0; i < 20; i++) label(i)], + outcomes: { + 'recovery': [ + for (var i = 0; i < 20; i++) rough.contains(label(i)) ? 40.0 : 80.0 + ], + }, + ); + for (final t in ['alcohol', 'stress']) { + final eff = out.firstWhere((c) => c.tag == t).effects.single; + expect(eff.nTagged, 5, reason: t); + expect(eff.delta, closeTo(-40, 1e-9), reason: t); + } + expect(journalTagDayForNight('2026-10-03', 'alcohol'), '2026-10-02'); + expect(journalTagDayForNight('2026-10-03', 'stress'), '2026-10-03'); + }); + test('empty journal yields no correlations', () { final out = journalCorrelations( journal: const [], @@ -451,6 +537,7 @@ void main() { test('a large, well-separated effect is still meaningful', () { final out = journalCorrelations( + tagLagDays: const {}, // stats test, no alignment journal: [ for (var i = 0; i < 5; i++) JournalDay('d$i', const {'alcohol'}), for (var i = 5; i < 10; i++) JournalDay('d$i', const {}), @@ -472,6 +559,7 @@ void main() { // ----------------------------------------------------------------------- test('2 vs 2 cannot be meaningful however cleanly it separates', () { final out = journalCorrelations( + tagLagDays: const {}, // stats test, no alignment journal: const [ JournalDay('d0', {'alcohol'}), JournalDay('d1', {'alcohol'}), @@ -520,6 +608,7 @@ void main() { // are tested too, so the real one has to survive the correction. final dates = [for (var i = 0; i < 12; i++) 'd$i']; final out = journalCorrelations( + tagLagDays: const {}, // stats test, no alignment journal: [ for (var i = 0; i < 12; i++) JournalDay('d$i', { diff --git a/test/onehz/wellness_test.dart b/test/onehz/wellness_test.dart index 8304b56..8887f74 100644 --- a/test/onehz/wellness_test.dart +++ b/test/onehz/wellness_test.dart @@ -763,6 +763,34 @@ void main() { // REGRESSION: degenerate (zero-dispersion) baseline columns must be dropped, // not floored to an epsilon scale. // ------------------------------------------------------------------------- + group('multivariateAnomaly — correlation alignment (regression)', () { + test('a sparse temp column does not force the identity correlation', () { + // RHR and HRV move together (one autonomic axis). Tonight has only + // those two. PRE-FIX baseline rows needed all four features, so with + // temp/resp missing the correlation fell back to the identity and the + // shared shift was counted twice. + List series({required bool withTempResp}) { + final f = []; + for (var i = 0; i < 28; i++) { + final rhr = 58.0 + (i % 5 - 2) * 2; + f.add(AnomalyFeatures( + rhr: rhr, + hrv: 50 - (rhr - 58) * 1.5 + (i % 3 - 1) * 0.5, + temp: withTempResp ? 2000.0 + (i % 4) * 3 : null, + resp: withTempResp ? 14.0 + (i % 7) * 0.2 : null)); + } + f.add(const AnomalyFeatures(rhr: 66, hrv: 38)); + return f; + } + + final dates = [for (var i = 0; i < 29; i++) 'd$i']; + final full = multivariateAnomaly(dates, series(withTempResp: true)); + final sparse = multivariateAnomaly(dates, series(withTempResp: false)); + expect(full[28].mahalanobis, isNotNull); + expect(sparse[28].mahalanobis, closeTo(full[28].mahalanobis!, 1e-9)); + }); + }); + group('multivariateAnomaly — degenerate baseline (regression)', () { test( 'an exactly-constant baseline column is DROPPED, never floored to 1e-6',