From 13fdbc3c787482dad6912dd9d8f3d3334d8dc782 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:37:09 +0530 Subject: [PATCH 1/6] anomaly: estimate correlation from rows with tonight's kept features, not all four --- lib/src/onehz/wellness/anomaly.dart | 16 +++++++++++----- test/onehz/wellness_test.dart | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) 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/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', From 97528ea2f3f321ccdba81b92c7800c0c52b6c8a8 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:37:09 +0530 Subject: [PATCH 2/6] session cost: next morning must be the next calendar day, baseline by calendar span --- lib/src/onehz/human/session_cost.dart | 16 ++++++++++------ test/onehz/an_training_human_test.dart | 24 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) 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/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( From e806f25cc711a7f26b4e59057c851cc25857a81b Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:37:09 +0530 Subject: [PATCH 3/6] journal tags: per-tag lag so behaviour tags match the night after --- lib/src/onehz/human/coaching.dart | 36 ++++++++++++++++++++++++++++++- test/onehz/coaching_test.dart | 29 ++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/lib/src/onehz/human/coaching.dart b/lib/src/onehz/human/coaching.dart index d99b46b..4b40d29 100644 --- a/lib/src/onehz/human/coaching.dart +++ b/lib/src/onehz/human/coaching.dart @@ -282,6 +282,24 @@ 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, +}; + /// Per-tag effect of a journal entry on each outcome series. /// /// [outcomes] values must be POSITIONALLY ALIGNED to [dates] (same length); a @@ -308,10 +326,17 @@ 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 lag ≠ 0 and D − lag has no journal row +/// the day is dropped — we don't know whether the tag applied. 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 +389,19 @@ 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 Set? tags; + if (lag == 0) { + tags = tagByDate[dates[i]]; + } else { + final src = shiftDayLabel(dates[i], -lag); + tags = src == null ? null : tagByDate[src]; + if (tags == null) continue; // no row that day: unknown, not untagged + } + final hasTag = tags?.contains(tag) == true; (hasTag ? tagged : untagged).add(v); vals.add(v); inGroup.add(hasTag); diff --git a/test/onehz/coaching_test.dart b/test/onehz/coaching_test.dart index 04fa328..d23d6d4 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,29 @@ 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('empty journal yields no correlations', () { final out = journalCorrelations( journal: const [], @@ -451,6 +475,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 +497,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 +546,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', { From c9a05b1fc6a3ccf6f33582a069ad07ae05d304a2 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:52:21 +0530 Subject: [PATCH 4/6] journal tags: a day with no journal row is unknown for lag-0 tags too, so callers can pass dates past the journal --- lib/src/onehz/human/coaching.dart | 22 +++++++++++----------- test/onehz/coaching_test.dart | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/lib/src/onehz/human/coaching.dart b/lib/src/onehz/human/coaching.dart index 4b40d29..471caa5 100644 --- a/lib/src/onehz/human/coaching.dart +++ b/lib/src/onehz/human/coaching.dart @@ -330,8 +330,13 @@ const Map journalTagLagDays = { /// 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 lag ≠ 0 and D − lag has no journal row -/// the day is dropped — we don't know whether the tag applied. +/// 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. +/// +/// 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, @@ -393,15 +398,10 @@ List journalCorrelations({ for (var i = 0; i < dates.length; i++) { final v = entry.value[i]; if (v == null) continue; - final Set? tags; - if (lag == 0) { - tags = tagByDate[dates[i]]; - } else { - final src = shiftDayLabel(dates[i], -lag); - tags = src == null ? null : tagByDate[src]; - if (tags == null) continue; // no row that day: unknown, not untagged - } - final hasTag = tags?.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/test/onehz/coaching_test.dart b/test/onehz/coaching_test.dart index d23d6d4..8e7072e 100644 --- a/test/onehz/coaching_test.dart +++ b/test/onehz/coaching_test.dart @@ -429,6 +429,35 @@ void main() { 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('empty journal yields no correlations', () { final out = journalCorrelations( journal: const [], From dcaaae00949a4d7c0025b80a46b4c7f003fc5298 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:10:36 +0530 Subject: [PATCH 5/6] journal tags: journalTagDayForNight so a cause picked for last night is stored where the lag pairs it back --- lib/src/onehz/human/coaching.dart | 12 ++++++++++- test/onehz/coaching_test.dart | 33 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/src/onehz/human/coaching.dart b/lib/src/onehz/human/coaching.dart index 471caa5..00815d1 100644 --- a/lib/src/onehz/human/coaching.dart +++ b/lib/src/onehz/human/coaching.dart @@ -300,6 +300,14 @@ const Map journalTagLagDays = { '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 @@ -331,7 +339,9 @@ const Map journalTagLagDays = { /// 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. +/// dropped — we don't know whether the tag applied. 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 diff --git a/test/onehz/coaching_test.dart b/test/onehz/coaching_test.dart index 8e7072e..da0ca98 100644 --- a/test/onehz/coaching_test.dart +++ b/test/onehz/coaching_test.dart @@ -458,6 +458,39 @@ void main() { 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 [], From f364e2ddfd5e44c84a5ed87b93b0266068c37cc0 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sun, 4 Oct 2026 02:17:10 +0530 Subject: [PATCH 6/6] journal tags: note lagged tags need yyyy-mm-dd dates --- lib/src/onehz/human/coaching.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/onehz/human/coaching.dart b/lib/src/onehz/human/coaching.dart index 00815d1..4db4216 100644 --- a/lib/src/onehz/human/coaching.dart +++ b/lib/src/onehz/human/coaching.dart @@ -339,7 +339,9 @@ String? journalTagDayForNight(String nightDay, String tag) => /// 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. A writer that tags a night +/// 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. ///