Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion lib/compute/accepted_naps.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,31 @@ import 'nap_edits.dart';
void composeAcceptedNaps(Map<String, dynamic> bundle, List<NapEdit> edits) {
final old = bundle['naps'] as Map?;
if (old == null && edits.isEmpty) return;
final accepted = applyNapEdits(const [], edits);
final sleep = bundle['sleep'];
final metric = sleep is Map ? sleep['window'] : null;
final window = metric is Map ? metric['value'] : null;
final onset = window is Map ? window['onset_ms'] : null;
final offset = window is Map ? window['offset_ms'] : null;
final hasMainWindow =
onset is num &&
offset is num &&
onset.isFinite &&
offset.isFinite &&
offset > onset;
// A corrected main window can absorb an older detector's nap snapshot.
// Suppress only fully contained snapshots, retaining the stored entry for
// a later correction. Partial overlaps keep their recorded duration: the
// snapshot has no stages to apportion sleep inside/outside the main window.
// Manual/confirmed decisions still win.
final accepted = applyNapEdits(const [], edits)
.where(
(nap) =>
nap['source'] != 'legacy' ||
!hasMainWindow ||
(nap['start'] as num) * 1000 < onset ||
(nap['end'] as num) * 1000 > offset,
)
.toList();
// Whether the detector judged the day, persisted so a later compose cannot
// turn an unjudged day into a measured zero once its logged naps are gone.
// Rows from before the flag: an unjudged day only carried a value when the
Expand Down
5 changes: 4 additions & 1 deletion lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1831,7 +1831,10 @@ import 'vendor_sleep.dart';
// wake, chronotype and social jetlag no longer move by the zone difference. A
// re-derive keeps the stored night's clock. Unchanged for a user who has not
// changed zone. Edge-only.
const int kAlgoVersion = 117;
// 117 → 118 (edge-only): a legacy nap snapshot fully inside the corrected
// main window is no longer counted on top of it; partial overlaps keep their
// recorded minutes, and a nap the user put back stays theirs.
const int kAlgoVersion = 118;
/// The sibling SHAs this version was derived against, asserted against
/// pubspec.yaml in test/db_serve_version_and_reads_test.dart.
///
Expand Down
9 changes: 8 additions & 1 deletion lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12631,10 +12631,17 @@ class LocalDb {
if (restoreSource != null) {
// Removing a rejection means "Put it back". Keep its original asleep
// minutes and confidence even after raw recordings have expired.
// This is now an explicit decision, even for a legacy snapshot: a
// later main-window correction must not suppress it as old detection.
restoreSource = 'confirmed';
final restored = Map<String, dynamic>.from(jsonDecode(
restorePayload ?? rows.single['payload_json'] as String) as Map);
restored['source'] = restoreSource;
restorePayload = jsonEncode(restored);
await tx.update('sleep_nap', {
'source': restoreSource,
'created_at': DateTime.now().millisecondsSinceEpoch ~/ 1000,
'payload_json': ?restorePayload,
'payload_json': restorePayload,
}, where: 'day_id = ? AND start_ts = ?', whereArgs: [dayId, startTs]);
} else {
await tx.delete('sleep_nap', where: 'day_id = ? AND start_ts = ?', whereArgs: [dayId, startTs]);
Expand Down
8 changes: 4 additions & 4 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -937,17 +937,17 @@ packages:
dependency: "direct main"
description:
path: "."
ref: 77fe373eb819fda235f80610e898a4bc20a7a249
resolved-ref: 77fe373eb819fda235f80610e898a4bc20a7a249
ref: "77fe373eb819fda235f80610e898a4bc20a7a249"
resolved-ref: "77fe373eb819fda235f80610e898a4bc20a7a249"
url: "https://github.com/OpenStrap/analytics.git"
source: git
version: "1.0.0"
openstrap_protocol:
dependency: "direct main"
description:
path: "."
ref: 89080bcfac7767b3a871fd3a7d9307f5f30dad26
resolved-ref: 89080bcfac7767b3a871fd3a7d9307f5f30dad26
ref: "89080bcfac7767b3a871fd3a7d9307f5f30dad26"
resolved-ref: "89080bcfac7767b3a871fd3a7d9307f5f30dad26"
url: "https://github.com/OpenStrap/protocol.git"
source: git
version: "1.0.0"
Expand Down
102 changes: 102 additions & 0 deletions test/accepted_naps_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:openstrap_edge/compute/accepted_naps.dart';
import 'package:openstrap_edge/compute/nap_edits.dart';

Map<String, dynamic> bundle({bool mainSleep = true}) => {
if (mainSleep)
'sleep': {
'window': {
'value': {'onset_ms': 0, 'offset_ms': 8 * 3600 * 1000},
},
},
'naps': {'value': <Map<String, dynamic>>[], 'assessment_complete': true},
'sleep_periods': {
'assessment_complete': true,
'periods': [
if (mainSleep) {'is_main': true, 'duration_min': 360},
],
},
};

NapEdit nap(int start, int end, {String source = 'legacy'}) => NapEdit(
kind: NapEditKind.added,
startSec: start,
endSec: end,
snapshot: source == 'manual' ? null : {'source': source, 'duration_min': 120},
);

void main() {
test('a legacy continuation is not also credited as a nap', () {
final day = bundle();
final edit = nap(6 * 3600, 8 * 3600);
composeAcceptedNaps(day, [edit]);

expect(day['naps']['value'], isEmpty);
expect(day['scalars']['nap_min'], 0);
expect(day['sleep_periods']['periods'], hasLength(1));
expect(day['sleep_periods']['total_asleep_min'], 360);
composeAcceptedNaps(day, [edit]);
expect(day['scalars']['nap_min'], 0);
expect(day['sleep_periods']['total_asleep_min'], 360);

// Keep the stored decision: a later correction that removes the main
// window can expose this original snapshot again.
final withoutMain = bundle(mainSleep: false);
composeAcceptedNaps(withoutMain, [edit]);
expect(withoutMain['naps']['value'], hasLength(1));
expect(withoutMain['scalars']['nap_min'], 120);
});

for (final bounds in [
(-3600, 3600),
(7 * 3600, 9 * 3600),
(-3600, 9 * 3600),
]) {
test(
'a partially overlapping legacy nap keeps its recorded duration: $bounds',
() {
final day = bundle();
final edit = nap(bounds.$1, bounds.$2);
composeAcceptedNaps(day, [edit]);
expect(day['naps']['value'], hasLength(1));
expect(day['scalars']['nap_min'], 120);
expect(day['sleep_periods']['total_asleep_min'], 480);
composeAcceptedNaps(day, [edit]);
expect(day['scalars']['nap_min'], 120);
expect(day['sleep_periods']['total_asleep_min'], 480);
},
);
}

test('legacy naps adjoining the main window stay separate', () {
final day = bundle();
composeAcceptedNaps(day, [nap(-7200, 0), nap(8 * 3600, 10 * 3600)]);
expect(day['naps']['value'], hasLength(2));
expect(day['scalars']['nap_min'], 240);
expect(day['sleep_periods']['total_asleep_min'], 600);
});

for (final window in [
<String, dynamic>{},
{'onset_ms': 1000, 'offset_ms': 0},
{'onset_ms': 'unknown', 'offset_ms': 8 * 3600 * 1000},
]) {
test('invalid main window preserves the legacy nap: $window', () {
final day = bundle();
day['sleep'] = {
'window': {'value': window},
};
composeAcceptedNaps(day, [nap(6 * 3600, 8 * 3600)]);
expect(day['naps']['value'], hasLength(1));
});
}

for (final source in ['manual', 'confirmed']) {
test('a corrected window preserves a $source nap decision', () {
final day = bundle();
composeAcceptedNaps(day, [nap(6 * 3600, 8 * 3600, source: source)]);
expect(day['naps']['value'], hasLength(1));
expect(day['scalars']['nap_min'], 120);
});
}
}
4 changes: 2 additions & 2 deletions test/activity_review_regression_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ void main() {
expect(restored['confidence'], 0.7);
expect(
restored['source'],
source == 'confirmed' ? 'confirmed' : 'legacy',
'confirmed',
);
expect(await store.pending(), isEmpty);
expect(await db.query('decoded_onehz'), isEmpty);
Expand Down Expand Up @@ -557,7 +557,7 @@ void main() {
(await LocalDb.dayResult(day))!['payload_json'] as String,
);
final restored = (bundle['naps']['value'] as List).single as Map;
expect(restored['source'], 'legacy');
expect(restored['source'], 'confirmed');
expect(restored['start'], start);
expect(bundle['scalars']['nap_min'], 30);
});
Expand Down
61 changes: 61 additions & 0 deletions test/activity_review_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@ void main() {
int? version,
List<dynamic> naps = const [],
String? source = 'band',
Map<String, num>? mainWindow,
}) async {
await LocalDb.putDayResult(
dayId: day,
algoVersion: version ?? kAlgoVersion,
payloadJson: jsonEncode({
if (mainWindow != null) 'sleep': {'window': {'value': mainWindow}},
'scalars': {'rhr': 53, 'readiness': 74, 'nap_min': 0},
'naps': {'value': naps, 'count': naps.length},
'sleep_periods': {
Expand Down Expand Up @@ -492,6 +494,65 @@ void main() {
expect(await store.pending(), isEmpty);
});

test('a corrected main window excludes a captured legacy nap on every write', () async {
await db.update('activity_review_meta', {'activated_at': now});
await dayResult(version: kAlgoVersion - 1, naps: [nap()], source: null);
await dayResult(mainWindow: {
'onset_ms': (start - 6 * 3600) * 1000,
'offset_ms': (start + 3600) * 1000,
});
Future<void> check() async {
final saved = jsonDecode((await LocalDb.dayResult(day))!['payload_json'] as String);
expect(saved['naps']['value'], isEmpty);
expect(saved['scalars']['nap_min'], 0);
expect(saved['sleep_periods']['total_asleep_min'], 420);
expect((await LocalDb.metricSeries('nap_min')).single['value'], 0);
expect((await LocalDb.napEdits(day)).single['source'], 'legacy');
}
await check();
// The durable review path has no raw input and must compose identically.
await ActivityStore.markDayChanged(db, day);
await LocalDb.applyPendingActivityReviews();
await check();
});

test('a partially overlapping captured nap survives persistence and review', () async {
await db.update('activity_review_meta', {'activated_at': now});
await dayResult(version: kAlgoVersion - 1, naps: [nap()], source: null);
await dayResult(mainWindow: {
'onset_ms': (start - 6 * 3600) * 1000,
'offset_ms': (start + 900) * 1000,
});
Future<void> check() async {
final saved = jsonDecode((await LocalDb.dayResult(day))!['payload_json'] as String);
expect(saved['naps']['value'], hasLength(1));
expect(saved['scalars']['nap_min'], 24);
expect(saved['sleep_periods']['total_asleep_min'], 444);
expect((await LocalDb.metricSeries('nap_min')).single['value'], 24);
expect((await LocalDb.napEdits(day)).single['source'], 'legacy');
}
await check();
await ActivityStore.markDayChanged(db, day);
await LocalDb.applyPendingActivityReviews();
await check();
});

test('putting a legacy nap back records an explicit confirmation', () async {
await db.update('activity_review_meta', {'activated_at': now});
await dayResult(version: kAlgoVersion - 1, naps: [nap()], source: null);
await dayResult(mainWindow: {
'onset_ms': (start - 6 * 3600) * 1000,
'offset_ms': (start + 3600) * 1000,
});
await LocalDb.putNapEdit(dayId: day, startTs: start, endTs: start + 1800, source: 'rejected');
await LocalDb.deleteNapEdit(day, start);
await LocalDb.applyPendingActivityReviews();
final saved = jsonDecode((await LocalDb.dayResult(day))!['payload_json'] as String);
expect((await LocalDb.napEdits(day)).single['source'], 'confirmed');
expect(saved['naps']['value'], hasLength(1));
expect(saved['scalars']['nap_min'], 24);
});

test('older refresh cannot clear a newer durable review job', () async {
final s = await detectNap();
await store.discard(s);
Expand Down
18 changes: 9 additions & 9 deletions test/fixtures/whoop_freeze/gen4_minus0400.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"day_result": [
{
"day_id": "2026-07-13",
"algo_version": 117,
"algo_version": 118,
"payload_json": {
"date": "2026-07-13",
"day_confidence": 0.6,
Expand Down Expand Up @@ -24902,7 +24902,7 @@
},
{
"day_id": "2026-07-14",
"algo_version": 117,
"algo_version": 118,
"payload_json": {
"date": "2026-07-14",
"day_confidence": 0.6,
Expand Down Expand Up @@ -49595,7 +49595,7 @@
},
{
"day_id": "2026-07-15",
"algo_version": 117,
"algo_version": 118,
"payload_json": {
"date": "2026-07-15",
"day_confidence": 0.6,
Expand Down Expand Up @@ -73355,23 +73355,23 @@
"metric_series_version": [
{
"date": "2026-07-13",
"algo_version": 117,
"algo_version": 118,
"source": "band",
"device_family": "gen4",
"coverage_devices": "",
"priority_hash": "accel1Hz=;hr1Hz=;ppgRedIr=;rrIntervals=;skinTempRaw="
},
{
"date": "2026-07-14",
"algo_version": 117,
"algo_version": 118,
"source": "band",
"device_family": "gen4",
"coverage_devices": "",
"priority_hash": "accel1Hz=;hr1Hz=;ppgRedIr=;rrIntervals=;skinTempRaw="
},
{
"date": "2026-07-15",
"algo_version": 117,
"algo_version": 118,
"source": "band",
"device_family": "gen4",
"coverage_devices": "",
Expand All @@ -73381,7 +73381,7 @@
"wake_day_features": [
{
"day_id": "2026-07-13",
"algo_version": 117,
"algo_version": 118,
"payload_json": {
"day_id": "2026-07-13",
"active_min": 90,
Expand Down Expand Up @@ -74589,7 +74589,7 @@
},
{
"day_id": "2026-07-14",
"algo_version": 117,
"algo_version": 118,
"payload_json": {
"day_id": "2026-07-14",
"active_min": 240,
Expand Down Expand Up @@ -75797,7 +75797,7 @@
},
{
"day_id": "2026-07-15",
"algo_version": 117,
"algo_version": 118,
"payload_json": {
"day_id": "2026-07-15",
"active_min": 40,
Expand Down
Loading
Loading