diff --git a/.gitignore b/.gitignore index fe01b834..1c629f93 100644 --- a/.gitignore +++ b/.gitignore @@ -71,7 +71,7 @@ pubspec_overrides.yaml # Firebase configurations android/app/google-services.json ios/Runner/GoogleService-Info.plist -lib/firebase_options.dart + firebase.json # one-off local debug scratch files - had a handful of these lying around diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 6f075541..3212d6f9 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -193,7 +193,22 @@ import 'substrate.dart'; // `_localNextDayLabelToSec` asks DateTime for the actual start of the next // day. Bump so recent days recompute onto the corrected readiness baseline; // only matters for history on the rare day that crossed a DST transition. -const int kAlgoVersion = 38; +// v39: night-tail sleep runs shorter than the 60-min standalone floor are no +// longer dropped when they continue the overnight chain (advanced_stager +// detectSleep) — a pre-dawn arousal that split off a <60-min tail was +// truncating the sleep-window offset at the arousal. Bump so affected days +// recompute the corrected (later) offset and downstream sleep/readiness metrics. +// v42: PERSONALIZED, self-improving cardio stager. (1) REM feature upgrades in +// cardioStager — LF/HF from the RR Lomb–Scargle spectrum + R(k)=mean|ΔIHR|, +// OR-combined with the RMSSD drop and gated by atonia + an HR floor (recovers +// under-called REM), plus a 3-epoch median flicker filter. (2) A rolling +// per-user sleep profile (baselines key `sleep_user_profile`) EWMA-folded after +// each finalized night and blended (bounded ≤0.5, growing with nights, 0 at +// cold start) with tonight's per-night-local baselines — so staging gets better +// over time while per-night-local always leads. Deep stays a low-confidence +// NREM sub-split (deep_low_confidence). The profile self-seeds across this +// re-derivation sweep; no explicit migration. Bump so every day re-stages. +const int kAlgoVersion = 42; /// Raw is kept this many days past derivation, then pruned (derived stays). const int rawRetentionDays = 3; @@ -815,19 +830,73 @@ class DerivationEngine { dayId: dayId, stats: stats, ); - final candidate = prepareSleepSessionCandidate( - searchSub, - targetDay: dayId, - override: override, - ); - if (override == null) { - await LocalDb.putSleepSessionCandidate( - dayId: dayId, - algoVersion: kAlgoVersion, - payloadJson: jsonEncode(candidate.toJson()), + // PERSONALIZED STAGER (v42): arm the sleeper's rolling profile before + // staging, and record this night's observed baselines for the fold below. + // `cardioStager` (inside segmentSleep) reads the ambient profile and blends + // it — bounded ≤0.5 — with tonight's per-night-local baselines. Runs in the + // main isolate here, so the ambient globals are in-scope for the call. + await _loadSleepUserProfile(); + ana.cardioRecordObservations = true; + ana.resetCardioObservations(); + // `finally` guarantees the recording flag is cleared even if staging or + // persistence throws — otherwise it leaks into the next day's derivation + // (this isolate is sequential, so no lock is needed, only the reset). + try { + final candidate = prepareSleepSessionCandidate( + searchSub, + targetDay: dayId, + override: override, ); + if (override == null) { + await LocalDb.putSleepSessionCandidate( + dayId: dayId, + algoVersion: kAlgoVersion, + payloadJson: jsonEncode(candidate.toJson()), + ); + // Fold the MAIN sleep (most epochs) of a freshly-staged night into the + // rolling profile. Skipped for overrides and cached reuse (this branch + // is the fresh-stage path). EWMA self-seeds across the v42 + // re-derivation sweep, so no explicit migration is needed. + await _foldSleepUserProfile(); + } else { + ana.resetCardioObservations(); + } + return candidate; + } finally { + ana.cardioRecordObservations = false; + } + } + + /// Load the persisted per-user sleep profile into the analytics ambient slot + /// (`baselines` key `sleep_user_profile`). Absent/corrupt ⇒ cold start (null). + Future _loadSleepUserProfile() async { + ana.cardioUserProfile = null; + final row = await LocalDb.baseline('sleep_user_profile'); + final raw = row?['payload_json']; + if (raw is String && raw.isNotEmpty) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + ana.cardioUserProfile = + ana.SleepUserProfile.fromJson(decoded.cast()); + } + } catch (_) { + // Cold start on unreadable payload. + } } - return candidate; + } + + /// EWMA-fold the just-staged main sleep into the per-user profile and persist. + Future _foldSleepUserProfile() async { + final obs = ana.takeCardioObservations(); + if (obs.isEmpty) return; + obs.sort((a, b) => b.epochs.compareTo(a.epochs)); + final main = obs.first; + if (main.epochs < 120) return; // require ≥60 min — not a nap + final base = ana.cardioUserProfile ?? const ana.SleepUserProfile(); + final updated = base.fold(main); + await LocalDb.putBaseline( + 'sleep_user_profile', jsonEncode(updated.toJson())); } Future _loadSubstrateRange( diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 00000000..195b11d3 --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,68 @@ +// File generated by FlutterFire CLI (Dummy version). +// ignore_for_file: type=lint +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for web - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for macos - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.windows: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for windows - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'dummy_api_key_for_android', + appId: '1:000000000000:android:0000000000000000000000', + messagingSenderId: '000000000000', + projectId: 'dummy-project', + storageBucket: 'dummy-project.appspot.com', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'dummy_api_key_for_ios', + appId: '1:000000000000:ios:0000000000000000000000', + messagingSenderId: '000000000000', + projectId: 'dummy-project', + storageBucket: 'dummy-project.appspot.com', + iosBundleId: 'wtf.openstrap.dummy', + ); +}