Skip to content
48 changes: 47 additions & 1 deletion lib/src/onehz/human/coaching.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, int> journalTagLagDays = {
'caffeine': 1,
'alcohol': 1,
'late meal': 1,
Comment thread
abdulsaheel marked this conversation as resolved.
'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
Expand All @@ -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<JournalTagCorrelation> journalCorrelations({
required List<JournalDay> journal,
required List<String> dates,
required Map<String, List<double?>> outcomes,
Map<String, int> tagLagDays = journalTagLagDays,
double minEffectPct = 3.0,
double minCohensD = 0.5,
int minNForZeroSpread = 3,
Expand Down Expand Up @@ -364,10 +406,14 @@ List<JournalTagCorrelation> journalCorrelations({
final untagged = <double>[];
final vals = <double>[];
final inGroup = <bool>[];
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
Comment thread
abdulsaheel marked this conversation as resolved.
final hasTag = tags.contains(tag);
(hasTag ? tagged : untagged).add(v);
vals.add(v);
inGroup.add(hasTag);
Expand Down
16 changes: 10 additions & 6 deletions lib/src/onehz/human/session_cost.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -103,12 +104,14 @@ Metric<List<SessionMorningEffect>> sessionMorningEffects({
}

final deltasByType = <String, List<double>>{};
final day = calendarDays(dates);
Comment thread
abdulsaheel marked this conversation as resolved.
final mdcsByType = <String, List<double>>{};

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) {
Expand All @@ -117,9 +120,10 @@ Metric<List<SessionMorningEffect>> 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;
Expand Down
16 changes: 11 additions & 5 deletions lib/src/onehz/wellness/anomaly.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,16 @@ List<AnomalyDay> multivariateAnomaly(
final cur = _orient(feats[i]);
// Build per-feature baseline columns (valid only) from the trailing window.
final cols = List.generate(4, (_) => <double>[]);
// Aligned rows (all 4 features present) for covariance off-diagonals.
final rows = <List<double>>[];
// Oriented baseline rows, filtered to tonight's kept features below for
// the covariance off-diagonals.
final baseRows = <List<double?>>[];
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 = <int>[];
Expand Down Expand Up @@ -198,6 +197,13 @@ List<AnomalyDay> 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;
Expand Down
24 changes: 24 additions & 0 deletions test/onehz/an_training_human_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <String>[];
final values = <double?>[];
final sessions = <String, List<String>>{};
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(
Expand Down
91 changes: 90 additions & 1 deletion test/onehz/coaching_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 = <String, Set<String>>{
for (var i = 0; i < 20; i++) label(i): <String>{},
};
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 [],
Expand Down Expand Up @@ -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 {}),
Expand All @@ -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'}),
Expand Down Expand Up @@ -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', {
Expand Down
28 changes: 28 additions & 0 deletions test/onehz/wellness_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnomalyFeatures> series({required bool withTempResp}) {
final f = <AnomalyFeatures>[];
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',
Expand Down
Loading