From 9b30c1208f59dfaf18791a36668e86c06942d567 Mon Sep 17 00:00:00 2001 From: Vibe Code Date: Wed, 30 Sep 2026 20:35:34 +0000 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20BP=20research=20capture=20(dev=20?= =?UTF-8?q?mode)=20=E2=80=94=20paired=20cuff=20reference=20+=20frozen=20?= =?UTF-8?q?=C2=B12=20min=20band=20window,=20CSV=20export=20set,=20isolatio?= =?UTF-8?q?n=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- lib/data/csv_export.dart | 40 +++++ lib/data/db.dart | 156 ++++++++++++++++- lib/health/bp_research_capture.dart | 187 ++++++++++++++++++++ lib/l10n/app_en.arb | 52 ++++++ lib/ui2/profile/bp_research.dart | 247 +++++++++++++++++++++++++++ lib/ui2/profile/settings.dart | 8 + test/bp_research_capture_test.dart | 77 +++++++++ test/bp_research_isolation_test.dart | 60 +++++++ 8 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 lib/health/bp_research_capture.dart create mode 100644 lib/ui2/profile/bp_research.dart create mode 100644 test/bp_research_capture_test.dart create mode 100644 test/bp_research_isolation_test.dart diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart index e061cafe6..5c1b803e1 100644 --- a/lib/data/csv_export.dart +++ b/lib/data/csv_export.dart @@ -283,6 +283,46 @@ const kCsvExportSets = [ ORDER BY d.date ASC ''', ), + CsvExportSet( + name: 'bp_research', + title: 'BP research captures (EXPERIMENTAL)', + // Paired cuff reference readings plus the band window frozen around + // each instant. Research data, not health data: never blended, never a + // training input, and absent stats stay EMPTY here exactly as they are + // NULL in the store — a spreadsheet cannot tell a zero from a reading + // afterwards, and a column of zeroes is a fabrication. + columns: [ + 'measured_at_ms', + 'device', + 'posture', + 'conditions', + 'systolic_mmhg', + 'diastolic_mmhg', + 'captured_at_ms', + 'window_start_ms', + 'window_end_ms', + 'onehz_rows', + 'rr_beats', + 'hr_mean', + 'rr_ms_mean', + 'rr_ms_min', + 'rr_ms_max', + 'rmssd_ms', + 'meta_json', + ], + sql: ''' + SELECT r.measured_at_ms, COALESCE(r.device, '') AS device, + COALESCE(r.posture, '') AS posture, + COALESCE(r.conditions, '') AS conditions, + r.systolic_mmhg, r.diastolic_mmhg, r.captured_at_ms, + w.window_start_ms, w.window_end_ms, w.onehz_rows, w.rr_beats, + w.hr_mean, w.rr_ms_mean, w.rr_ms_min, w.rr_ms_max, w.rmssd_ms, + COALESCE(w.meta_json, '') AS meta_json + FROM bp_research_reference r + LEFT JOIN bp_research_window w ON w.reference_id = r.id + ORDER BY r.measured_at_ms ASC + ''', + ), ]; /// What CSV deliberately does NOT carry, and why. Shown to the user on the diff --git a/lib/data/db.dart b/lib/data/db.dart index f4ee1ecfe..7b5c5a8a8 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -33,6 +33,7 @@ import '../import/import_container.dart'; import 'coverage_resolver.dart' show CoverageInterval; import 'day_label.dart'; import 'journal_fields.dart'; +import '../health/bp_research_capture.dart' import 'live_coverage_policy.dart'; import 'med_store.dart'; import 'models.dart'; @@ -349,7 +350,7 @@ class LocalDb { /// pass it: sqflite throws `ArgumentError('onCreate must be null if no /// version is specified')` BEFORE opening anything when `onCreate` is given /// without `version` (sqflite_common database_mixin.dart). - static const int schemaVersion = 54; + static const int schemaVersion = 55; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -461,6 +462,7 @@ class LocalDb { await _createNotifFired(db); await _createNotifSlots(db); await _createAlarmSchedule(db); + await _createBpResearch(db); await _ensureCoachViews(db); }, onUpgrade: (db, oldV, newV) async { @@ -1073,6 +1075,18 @@ class LocalDb { // next free rung rather than collide with any of them. await _createEcgTables(db); } + if (oldV < 55) { + // BP research capture: paired cuff reference readings plus the + // band's own decoded 1 Hz / R-R window frozen around the + // measurement instant, for out-of-app comparison only. Two new + // tables, CREATE TABLE IF NOT EXISTS and NOTHING else — no + // backfill, no rewrite, no ADD COLUMN — so a throw here has + // nothing to roll back onto (invariant 11). Ships without a + // kAlgoVersion bump: nothing derived moves, and nothing derived + // may ever read these (see the guard comment in + // [_createBpResearch]). + await _createBpResearch(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -1582,6 +1596,146 @@ class LocalDb { ); } + + /// BP research capture store (schema rung 55). + /// + /// EXPERIMENTAL, DEVELOPER-ONLY, and it stays that way. A cuff reading the + /// user types in next to the band data of the same instant is exactly the + /// pairing the `imported_measurement` guard exists to prevent becoming an + /// input: the moment a wrist series and a cuff series are regressed against + /// each other ON DEVICE, this app is making a cuffless-blood-pressure + /// claim from an uncleared device. So this is a SEPARATE store, read by + /// exactly one dev screen and one CSV export, and — like + /// `imported_measurement` and `observation` — the isolation is structural: + /// nothing in `compute/`, nothing that feeds `day_result` or + /// `metric_series`, and nothing that writes to HealthKit / Health Connect + /// may name either table. `bp_research_isolation_test.dart` fails the moment + /// anyone does. + /// + /// Captures are idempotent on `(measured_at_ms, device)`: retaking the same + /// cuff reading at the same instant re-states the window rather than + /// duplicating it. Legitimate repeat measurements minutes apart are + /// different instants and both stay. + static Future _createBpResearch(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS bp_research_reference ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + measured_at_ms INTEGER NOT NULL, + device TEXT, + posture TEXT, + conditions TEXT, + systolic_mmhg REAL NOT NULL, + diastolic_mmhg REAL NOT NULL, + captured_at_ms INTEGER NOT NULL, + UNIQUE (measured_at_ms, device) + ) + '''); + await db.execute(''' + CREATE TABLE IF NOT EXISTS bp_research_window ( + reference_id INTEGER NOT NULL PRIMARY KEY + REFERENCES bp_research_reference(id) ON DELETE CASCADE, + window_start_ms INTEGER NOT NULL, + window_end_ms INTEGER NOT NULL, + -- NULL-safe by design: the band may have had nothing to say at that + -- instant (not worn, not synced yet), and a missing window is recorded + -- as missing — never as zeroes. + onehz_rows INTEGER, + rr_beats INTEGER, + hr_mean REAL, + rr_ms_mean REAL, + rr_ms_min REAL, + rr_ms_max REAL, + rmssd_ms REAL, + meta_json TEXT + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_bp_research_reference_at ' + 'ON bp_research_reference(measured_at_ms)', + ); + } + + /// Insert one cuff reference reading plus its frozen band window. + /// + /// Pure write; the caller computes the window stats (see + /// `lib/health/bp_research_capture.dart`). Idempotent on + /// `(measured_at_ms, device)`: `INSERT OR REPLACE` on the reference, then + /// the window row is restated in the same transaction so a retake can never + /// leave an old window under a new reference. + static Future putBpResearchCapture(BpResearchCapture c) async { + final db = await instance; + await db.transaction((txn) async { + final id = await txn.rawInsert( + 'INSERT OR REPLACE INTO bp_research_reference ' + '(measured_at_ms, device, posture, conditions, systolic_mmhg, ' + 'diastolic_mmhg, captured_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + c.measuredAtMs, + c.device, + c.posture, + c.conditions, + c.systolicMmHg, + c.diastolicMmHg, + c.capturedAtMs, + ], + ); + // A capture with no band data stores NO window row — the LEFT JOIN in + // [bpResearchCaptures] renders it as an empty window, and `NOT NULL` + // on the window bounds is what keeps a half-written window out of the + // store. A retake that now finds band data replaces the absent row. + final w = c.window; + if (w == null) { + await txn.rawDelete( + 'DELETE FROM bp_research_window WHERE reference_id = ?', [id]); + return; + } + await txn.rawInsert( + 'INSERT OR REPLACE INTO bp_research_window ' + '(reference_id, window_start_ms, window_end_ms, onehz_rows, ' + 'rr_beats, hr_mean, rr_ms_mean, rr_ms_min, rr_ms_max, rmssd_ms, ' + 'meta_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + id, + w.windowStartMs, + w.windowEndMs, + w.onehzRows, + w.rrBeats, + w.hrMean, + w.rrMsMean, + w.rrMsMin, + w.rrMsMax, + w.rmssdMs, + w.metaJson, + ], + ); + }); + } + + /// All captures, newest first, for the dev screen and the CSV export. + static Future>> bpResearchCaptures() async { + final db = await instance; + return db.rawQuery(''' + SELECT r.id, r.measured_at_ms, r.device, r.posture, r.conditions, + r.systolic_mmhg, r.diastolic_mmhg, r.captured_at_ms, + w.window_start_ms, w.window_end_ms, w.onehz_rows, w.rr_beats, + w.hr_mean, w.rr_ms_mean, w.rr_ms_min, w.rr_ms_max, w.rmssd_ms, + w.meta_json + FROM bp_research_reference r + LEFT JOIN bp_research_window w ON w.reference_id = r.id + ORDER BY r.measured_at_ms DESC + '''); + } + + /// Delete one capture (dev screen). The window cascades. + static Future deleteBpResearchCapture(int id) async { + final db = await instance; + await db.delete( + 'bp_research_reference', + where: 'id = ?', + whereArgs: [id], + ); + } + /// Atomically claim [key] for a one-time OS notification fire. /// /// Returns true iff THIS caller won the claim (the row did not exist and we diff --git a/lib/health/bp_research_capture.dart b/lib/health/bp_research_capture.dart new file mode 100644 index 000000000..7307c4da4 --- /dev/null +++ b/lib/health/bp_research_capture.dart @@ -0,0 +1,187 @@ +// BP research capture — pair a cuff reading the user just took with the +// band's own decoded data from the minutes around that instant. +// +// EXPERIMENTAL / DEVELOPER-ONLY. This exists to build a paired dataset a +// human can analyse OUTSIDE this app (CSV export); it is not a blood +// pressure feature and never becomes one. The same guard that fences +// `imported_measurement` applies twice over here: nothing in `compute/`, +// nothing that writes `day_result` / `metric_series`, and nothing that +// writes to HealthKit / Health Connect may read these tables — a wrist +// series regressed against cuff readings inside this app is a +// cuffless-blood-pressure claim from an uncleared device, which is exactly +// the category that earned WHOOP an FDA Warning Letter in Jul 2025. +// +// A capture with no band data at that instant is stored as a capture with a +// NULL window. Missing is missing — never 0, never a fabricated average. + + +/// Window half-widths around the cuff instant (default ±2 min): the frozen +/// window covers [kBpResearchWindowPreMs] before the reference instant and +/// [kBpResearchWindowPostMs] after it. Lives here, next to the capture +/// logic, so the model file has no dependency direction to argue about. +const int kBpResearchWindowPreMs = 2 * 60 * 1000; +const int kBpResearchWindowPostMs = 2 * 60 * 1000; + +/// The frozen band window around one reference instant. Every field is +/// nullable for the same reason the storage layer's columns are: a stat the +/// window could not honestly compute (no valid HR, no beats) is absent, not +/// zero. +class BpResearchWindow { + const BpResearchWindow({ + required this.windowStartMs, + required this.windowEndMs, + this.onehzRows, + this.rrBeats, + this.hrMean, + this.rrMsMean, + this.rrMsMin, + this.rrMsMax, + this.rmssdMs, + this.metaJson, + }); + + final int windowStartMs; + final int windowEndMs; + final int? onehzRows; + final int? rrBeats; + final double? hrMean; + final double? rrMsMean; + final double? rrMsMin; + final double? rrMsMax; + final double? rmssdMs; + + /// Provenance the analysis needs and nothing else: device_id, firmware + /// string if known, sample counts by table. JSON, written verbatim. + final String? metaJson; +} + +/// One cuff reference reading plus its window, ready to store. +class BpResearchCapture { + const BpResearchCapture({ + required this.measuredAtMs, + required this.systolicMmHg, + required this.diastolicMmHg, + required this.capturedAtMs, + required this.device, + this.posture, + this.conditions, + this.window, + }); + + final int measuredAtMs; + final double systolicMmHg; + final double diastolicMmHg; + final int capturedAtMs; + + /// The cuff's own name ('OMRON', 'Withings BPM', …). NULL when the user + /// typed a bare pair of numbers with no device named. + final String? device; + final String? posture; + final String? conditions; + + /// Null when the band had nothing decoded in the window. + final BpResearchWindow? window; +} + +/// Same plausibility bounds as `health_measurement_import.dart`, for the +/// same reason: 400 mmHg is a cuff error, and a clamped reading is a +/// fabricated one. Out-of-bounds input is rejected, never corrected. +const (double, double) kResearchSystolicBounds = (50, 300); +const (double, double) kResearchDiastolicBounds = (20, 200); + +/// Compute the frozen band window around [measuredAtMs] from the decoded +/// store, pure and testable without a database (pass the rows in). +/// +/// Window: measured instant minus/plus [preMs]/[postMs] (default ±2 min, +/// [kBpResearchWindowPreMs]/[kBpResearchWindowPostMs]). Reads ONLY already-decoded +/// tables — `decoded_onehz` (HR) and `decoded_rr` (beat intervals). No raw +/// archive, no re-decode, nothing derived: the point is to freeze exactly +/// what the app already holds at the moment of the cuff reading. +BpResearchWindow? researchWindowFrom({ + required int measuredAtMs, + required List> onehzRows, + required List> rrRows, + int? preMs, + int? postMs, + String? deviceId, + String? metaJson, +}) { + final pre = preMs ?? kBpResearchWindowPreMs; + final post = postMs ?? kBpResearchWindowPostMs; + final start = measuredAtMs - pre; + final end = measuredAtMs + post; + + // decoded_onehz.rec_ts is epoch SECONDS; rr is rr_ts_ms (epoch ms). + final onehz = onehzRows + .where((r) { + final ts = r['rec_ts']; + return ts is int && ts * 1000 >= start && ts * 1000 <= end; + }) + .toList(growable: false); + final rr = rrRows + .where((r) { + final ts = r['rr_ts_ms']; + return ts is int && ts >= start && ts <= end; + }) + .toList(growable: false); + + if (onehz.isEmpty && rr.isEmpty) return null; + + // HR mean over VALID HR rows only — a run of hr_valid = 0 rows must not + // drag an average toward zero, and absent validity is absent, not false. + final hrs = onehz + .map((r) => r['hr']) + .whereType() + .where((h) => h > 0) + .toList(growable: false); + final hrMean = hrs.isEmpty + ? null + : hrs.reduce((a, b) => a + b) / hrs.length; + + final rrs = rr + .map((r) => r['rr_ms']) + .whereType() + .map((v) => v.toDouble()) + .toList(growable: false); + double? rrMean, rrMin, rrMax, rmssd; + if (rrs.isNotEmpty) { + rrMean = rrs.reduce((a, b) => a + b) / rrs.length; + rrMin = rrs.reduce((a, b) => a < b ? a : b); + rrMax = rrs.reduce((a, b) => a > b ? a : b); + // RMSSD over successive differences in window order (rr_ts_ms ASC is + // the caller's contract). Too few beats to form one difference: absent. + if (rrs.length >= 2) { + var sumSq = 0.0; + for (var i = 1; i < rrs.length; i++) { + final d = rrs[i] - rrs[i - 1]; + sumSq += d * d; + } + rmssd = _sqrt(sumSq / (rrs.length - 1)); + } + } + + return BpResearchWindow( + windowStartMs: start, + windowEndMs: end, + onehzRows: onehz.isEmpty ? null : onehz.length, + rrBeats: rr.isEmpty ? null : rr.length, + hrMean: hrMean?.toDouble(), + rrMsMean: rrMean, + rrMsMin: rrMin, + rrMsMax: rrMax, + rmssdMs: rmssd, + metaJson: metaJson, + ); +} + +double _sqrt(double v) => v <= 0 ? 0.0 : _sqrtNewton(v); + +double _sqrtNewton(double v) { + var x = v; + var y = (x + 1) / 2; + while ((y - x).abs() > 1e-12) { + x = y; + y = (x + v / x) / 2; + } + return y; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ae97e419e..bfd8524f3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12441,5 +12441,57 @@ "type": "int" } } + }, + "settingsBpResearchRowTitle": "BP research capture", + "@settingsBpResearchRowTitle": { + "description": "Developer settings row title for the experimental blood-pressure research capture screen" + }, + "settingsBpResearchRowSub": "EXPERIMENTAL. Pair a cuff reading with the band data of the same instant, for comparison outside this app. Never a health feature", + "@settingsBpResearchRowSub": { + "description": "Developer settings row subtitle for the BP research capture screen" + }, + "bpResearchTitle": "BP research capture", + "@bpResearchTitle": { + "description": "Title of the experimental BP research capture screen" + }, + "bpResearchIntro": "EXPERIMENTAL. Take a cuff reading, type the pair in, press capture. The band data of the ±2 minutes around that instant is frozen next to it — for you to compare outside this app. Nothing here is a health feature, nothing here feeds any score, and nothing here is ever blended with what the band measured.", + "@bpResearchIntro": { + "description": "Intro text of the BP research capture screen" + }, + "bpResearchSystolic": "Systolic (mmHg)", + "@bpResearchSystolic": { + "description": "Text field label for the systolic reference value" + }, + "bpResearchDiastolic": "Diastolic (mmHg)", + "@bpResearchDiastolic": { + "description": "Text field label for the diastolic reference value" + }, + "bpResearchDevice": "Cuff device (optional)", + "@bpResearchDevice": { + "description": "Text field label for the cuff device name" + }, + "bpResearchPosture": "Posture (optional)", + "@bpResearchPosture": { + "description": "Text field label for the posture during the reading" + }, + "bpResearchConditions": "Conditions (optional)", + "@bpResearchConditions": { + "description": "Text field label for measurement conditions" + }, + "bpResearchCapture": "Capture now", + "@bpResearchCapture": { + "description": "Button label to store one capture" + }, + "bpResearchHistory": "Captures", + "@bpResearchHistory": { + "description": "Section header for the stored capture list" + }, + "bpResearchBadValue": "That pair is not a blood pressure — check the numbers and try again. Nothing was stored.", + "@bpResearchBadValue": { + "description": "Snackbar text for an out-of-bounds reference pair, which is rejected, never clamped" + }, + "bpResearchExportHint": "Export all captures as CSV from Your data › Export CSV (set \"BP research captures\"). Research data — it never leaves the phone except through that file.", + "@bpResearchExportHint": { + "description": "Hint under the capture list explaining the CSV export path" } } diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart new file mode 100644 index 000000000..4c92b1562 --- /dev/null +++ b/lib/ui2/profile/bp_research.dart @@ -0,0 +1,247 @@ +// BP research capture — DEVELOPER MODE ONLY. +// +// One flow: take a cuff blood pressure reading, type the pair in, press +// capture. The app freezes the band's own decoded data from the ±2 minutes +// around that instant (1 Hz HR, R-R intervals) next to the reference pair, +// and keeps every capture so a human can compare them over weeks — here in +// a list, or out of the app through the `bp_research` CSV export set. +// +// This is data COLLECTION, not a blood pressure feature: +// · Nothing derived reads these tables. No score, no baseline, no chart +// of ours takes a capture as an input. +// · Nothing here is ever blended with, averaged against, or corrected +// against anything the band measured. +// · Nothing here is exported to HealthKit / Health Connect. +// A window with no band data is stored as a capture with an EMPTY window — +// missing is missing, never zero. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../data/db.dart'; +import '../../health/bp_research_capture.dart'; +import '../../l10n/app_localizations.dart'; +import '../ui2.dart'; +import 'devices.dart' show formatDayTime; + +class BpResearchScreen extends StatefulWidget { + const BpResearchScreen({super.key}); + + @override + State createState() => _BpResearchScreenState(); +} + +class _BpResearchScreenState extends State { + final _sys = TextEditingController(); + final _dia = TextEditingController(); + final _device = TextEditingController(); + final _posture = TextEditingController(); + final _conditions = TextEditingController(); + List> _rows = const []; + bool _busy = false; + + @override + void initState() { + super.initState(); + _refresh(); + } + + @override + void dispose() { + _sys.dispose(); + _dia.dispose(); + _device.dispose(); + _posture.dispose(); + _conditions.dispose(); + super.dispose(); + } + + Future _refresh() async { + final rows = await LocalDb.bpResearchCaptures(); + if (mounted) setState(() => _rows = rows); + } + + Future _capture() async { + if (_busy) return; + final sys = double.tryParse(_sys.text); + final dia = double.tryParse(_dia.text); + final l = AppLocalizations.of(context); + if (sys == null || + dia == null || + sys < kResearchSystolicBounds.$1 || + sys > kResearchSystolicBounds.$2 || + dia < kResearchDiastolicBounds.$1 || + dia > kResearchDiastolicBounds.$2) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(l?.bpResearchBadValue ?? + 'That pair is not a blood pressure — check the numbers and ' + 'try again. Nothing was stored.'), + )); + } + return; + } + setState(() => _busy = true); + try { + final now = DateTime.now(); + final start = now.millisecondsSinceEpoch - kBpResearchWindowPreMs; + final end = now.millisecondsSinceEpoch + kBpResearchWindowPostMs; + final db = await LocalDb.instance; + // Read exactly what the app already holds around the instant. Two + // narrow range reads — never a day dump, never the raw archive. + final onehz = await db.rawQuery( + 'SELECT rec_ts, hr FROM decoded_onehz ' + 'WHERE device_id = ? AND rec_ts >= ? AND rec_ts <= ? ' + 'ORDER BY rec_ts ASC', + [LocalDb.kPrimaryDeviceId, start ~/ 1000, end ~/ 1000], + ); + final rr = await db.rawQuery( + 'SELECT rr_ts_ms, rr_ms FROM decoded_rr ' + 'WHERE device_id = ? AND rr_ts_ms >= ? AND rr_ts_ms <= ? ' + 'ORDER BY rr_ts_ms ASC', + [LocalDb.kPrimaryDeviceId, start, end], + ); + final window = researchWindowFrom( + measuredAtMs: now.millisecondsSinceEpoch, + onehzRows: onehz, + rrRows: rr, + ); + await LocalDb.putBpResearchCapture(BpResearchCapture( + measuredAtMs: now.millisecondsSinceEpoch, + systolicMmHg: sys, + diastolicMmHg: dia, + capturedAtMs: now.millisecondsSinceEpoch, + device: _device.text.trim().isEmpty ? null : _device.text.trim(), + posture: _posture.text.trim().isEmpty ? null : _posture.text.trim(), + conditions: + _conditions.text.trim().isEmpty ? null : _conditions.text.trim(), + window: window, + )); + _sys.clear(); + _dia.clear(); + await _refresh(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext c) { + final l = AppLocalizations.of(c); + return Scaffold( + appBar: AppBar( + title: Text(l?.bpResearchTitle ?? 'BP research capture'), + ), + body: ListView( + padding: const EdgeInsets.all(S.x4), + children: [ + Text( + l?.bpResearchIntro ?? + 'EXPERIMENTAL. Take a cuff reading, type the pair in, ' + 'press capture. The band data of the ±2 minutes around that ' + 'instant is frozen next to it — for you to compare outside ' + 'this app. Nothing here is a health feature, nothing here ' + 'feeds any score, and nothing here is ever blended with what ' + 'the band measured.', + style: c.textTheme.bodySmall, + ), + const SizedBox(height: S.x4), + TextField( + controller: _sys, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9]'))], + decoration: InputDecoration( + labelText: l?.bpResearchSystolic ?? 'Systolic (mmHg)', + ), + ), + const SizedBox(height: S.x2), + TextField( + controller: _dia, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9]'))], + decoration: InputDecoration( + labelText: l?.bpResearchDiastolic ?? 'Diastolic (mmHg)', + ), + ), + const SizedBox(height: S.x2), + TextField( + controller: _device, + decoration: InputDecoration( + labelText: l?.bpResearchDevice ?? 'Cuff device (optional)', + ), + ), + const SizedBox(height: S.x2), + TextField( + controller: _posture, + decoration: InputDecoration( + labelText: l?.bpResearchPosture ?? 'Posture (optional)', + ), + ), + const SizedBox(height: S.x2), + TextField( + controller: _conditions, + decoration: InputDecoration( + labelText: l?.bpResearchConditions ?? 'Conditions (optional)', + ), + ), + const SizedBox(height: S.x4), + FilledButton.icon( + onPressed: _busy ? null : _capture, + icon: const Icon(LucideIcons.plus), + label: Text(l?.bpResearchCapture ?? 'Capture now'), + ), + const SizedBox(height: S.x6), + if (_rows.isNotEmpty) ...[ + Text(l?.bpResearchHistory ?? 'Captures', + style: c.textTheme.titleMedium), + const SizedBox(height: S.x2), + for (final r in _rows) + ListTile( + dense: true, + title: Text( + '${r['systolic_mmhg']}/${r['diastolic_mmhg']} mmHg — ' + '${formatDayTime(DateTime.fromMillisecondsSinceEpoch( + r['measured_at_ms'] as int), l)}', + ), + subtitle: Text(_windowSummary(r)), + trailing: IconButton( + icon: const Icon(LucideIcons.trash2, size: 18), + onPressed: () async { + await LocalDb.deleteBpResearchCapture(r['id'] as int); + await _refresh(); + }, + ), + ), + const SizedBox(height: S.x4), + Text( + l?.bpResearchExportHint ?? + 'Export all captures as CSV from Your data › Export CSV ' + '(set “BP research captures”). Research data — it never ' + 'leaves the phone except through that file.', + style: c.textTheme.bodySmall, + ), + ], + ], + ), + ); + } + + /// A window is summarised as what it actually holds. A NULL stat is shown + /// as absent — a dash, never a zero, and never a value that would read as + /// a measurement. + static String _windowSummary(Map r) { + final onehz = r['onehz_rows']; + final beats = r['rr_beats']; + final hr = r['hr_mean']; + final rmssd = r['rmssd_ms']; + if (onehz == null && beats == null) { + return 'No band data in the window — stored as-is.'; + } + final parts = []; + if (hr != null) parts.add('HR ${hr.toStringAsFixed(0)} bpm'); + if (rmssd != null) parts.add('RMSSD ${rmssd.toStringAsFixed(0)} ms'); + parts.add('${onehz ?? 0} 1 Hz rows, ${beats ?? 0} beats'); + return parts.join(' · '); + } +} diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 2d24b6d13..5946eff02 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -36,6 +36,7 @@ import '../../theme/theme_controller.dart'; import '../ui2.dart'; import 'alarm.dart'; import 'band_notifications.dart'; +import 'bp_research.dart'; import 'data.dart'; import 'gallery.dart'; import 'gestures.dart'; @@ -793,6 +794,13 @@ class MoreSettingsView extends StatelessWidget { 'Every component, at any text scale, in either ' 'theme', onTap: onGallery), + SetRow(LucideIcons.heartPulse, C.purple, + l?.settingsBpResearchRowTitle ?? 'BP research capture', + sub: l?.settingsBpResearchRowSub ?? + 'EXPERIMENTAL. Pair a cuff reading with the band ' + 'data of the same instant, for comparison ' + 'outside this app. Never a health feature', + onTap: () => goto(c, const BpResearchScreen())), SetRow(LucideIcons.code, C.n500, l?.settingsDeveloperModeRowTitle ?? 'Developer mode', value: on, chevron: false, onTap: onToggleDev), diff --git a/test/bp_research_capture_test.dart b/test/bp_research_capture_test.dart new file mode 100644 index 000000000..0d0e10d20 --- /dev/null +++ b/test/bp_research_capture_test.dart @@ -0,0 +1,77 @@ +// Unit tests for the pure window computation behind the BP research capture. +// +// The rules under test are the ones the storage and UI lean on: +// · a window with no band data is NULL, not zeroes; +// · a stat the window cannot honestly compute (no valid HR, too few +// beats for RMSSD) is absent, never zero; +// · rows outside the window are ignored, whatever their table's epoch +// base is (decoded_onehz.rec_ts is SECONDS, decoded_rr.rr_ts_ms is ms). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/health/bp_research_capture.dart'; + +void main() { + const at = 1700000000000; // ms + + test('no band data in the window yields a NULL window, not zeroes', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: const [], + ); + expect(w, isNull); + }); + + test('rows outside the ±2 min window are ignored (seconds vs ms bases)', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + // rec_ts is epoch SECONDS. Inside the window (at/1000 ± 120). + {'rec_ts': at ~/ 1000, 'hr': 60}, + // 10 minutes before: outside, must not land in any stat. + {'rec_ts': at ~/ 1000 - 600, 'hr': 180}, + ], + rrRows: [ + // rr_ts_ms is epoch MS. Inside. + {'rr_ts_ms': at, 'rr_ms': 1000}, + ], + ); + expect(w, isNotNull); + expect(w!.onehzRows, 1); + expect(w.hrMean, 60.0); + expect(w.rrBeats, 1); + expect(w.rmssdMs, isNull); // one beat forms no successive difference + }); + + test('invalid HR rows do not drag the mean toward zero', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + {'rec_ts': at ~/ 1000, 'hr': 0}, + {'rec_ts': at ~/ 1000 - 1, 'hr': 58}, + {'rec_ts': at ~/ 1000 - 2, 'hr': 62}, + ], + rrRows: const [], + ); + expect(w!.onehzRows, 3); + expect(w.hrMean, 60.0); // 0 excluded as invalid, not averaged in + }); + + test('RMSSD over successive differences, min/max preserved', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + {'rr_ts_ms': at - 3000, 'rr_ms': 1000}, + {'rr_ts_ms': at - 2000, 'rr_ms': 1100}, + {'rr_ts_ms': at - 1000, 'rr_ms': 900}, + ], + ); + expect(w!.rrBeats, 3); + expect(w.rrMsMin, 900.0); + expect(w.rrMsMax, 1100.0); + // diffs: +100, -200 → sqrt((100² + 200²)/2) = sqrt(25000) = 158.11… + expect(w.rmssdMs!, closeTo(158.11, 0.01)); + expect(w.hrMean, isNull); // no 1 Hz rows: absent, not zero + }); +} diff --git a/test/bp_research_isolation_test.dart b/test/bp_research_isolation_test.dart new file mode 100644 index 000000000..0a9b7607f --- /dev/null +++ b/test/bp_research_isolation_test.dart @@ -0,0 +1,60 @@ +// THE INVARIANT the BP research store exists to have. +// +// `bp_research_reference` / `bp_research_window` hold a cuff reading next to +// the band's own decoded data from the same instant. That pairing is exactly +// the input a cuffless-blood-pressure claim would be built from, which is +// the category that earned WHOOP an FDA Warning Letter in Jul 2025 — so the +// tables are fenced the same way `observation` is: nothing outside the +// allow-list may name either table, and the allow-list names the readers +// whose whole job is showing/exporting research data to the user. +// +// Layer 1 (structural source scan) reuses the mechanism +// observation_isolation_test.dart is built on. A violation is SILENT — no +// exception, just a wrist-vs-cuff regression one PR later — so the control +// is a test that goes red, not a comment that says don't. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +/// The ONLY files allowed to name either BP research table. +/// +/// Adding to this list is the deliberate act the invariant asks for. Before +/// you do: a READER for the dev screen or the CSV export is fine. Anything +/// in `lib/compute/`, anything that feeds `day_result`, `metric_series` or +/// a baseline, and anything that writes to HealthKit / Health Connect is +/// the thing this whole file exists to stop. +const _allowed = { + 'lib/data/db.dart', + 'lib/data/csv_export.dart', + 'lib/health/bp_research_capture.dart', + 'lib/ui2/profile/bp_research.dart', +}; + +void main() { + test('no file outside the allow-list names a BP research table', () { + final offenders = []; + final lib = Directory('lib'); + for (final f in lib.listSync(recursive: true).whereType()) { + if (!f.path.endsWith('.dart')) continue; + final rel = p.normalize(f.path); + final s = f.readAsStringSync(); + final hit = s.contains('bp_research_reference') || + s.contains('bp_research_window') || + s.contains('bpResearchCaptures') || + s.contains('putBpResearchCapture') || + s.contains('deleteBpResearchCapture'); + if (hit && !_allowed.contains(rel)) offenders.add(rel); + } + expect(offenders, isEmpty, + reason: 'files naming the BP research store must be on the allow-list ' + 'in test/bp_research_isolation_test.dart'); + }); + + test('the allow-list files themselves all exist', () { + for (final rel in _allowed) { + expect(File(rel).existsSync(), isTrue, reason: '$rel has vanished'); + } + }); +} From f08ab4f97f3e6792d7b2ae331397e1b65919fd09 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Wed, 30 Sep 2026 21:05:35 +0000 Subject: [PATCH 02/22] style: align BP research screen with app typography (F.head, p.ink2), null-safe band summary Co-authored-by: BucciMobile --- lib/data/db.dart | 2 +- lib/ui2/profile/bp_research.dart | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 7b5c5a8a8..686eb391d 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -33,7 +33,7 @@ import '../import/import_container.dart'; import 'coverage_resolver.dart' show CoverageInterval; import 'day_label.dart'; import 'journal_fields.dart'; -import '../health/bp_research_capture.dart' +import '../health/bp_research_capture.dart'; import 'live_coverage_policy.dart'; import 'med_store.dart'; import 'models.dart'; diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart index 4c92b1562..df49f1427 100644 --- a/lib/ui2/profile/bp_research.dart +++ b/lib/ui2/profile/bp_research.dart @@ -129,6 +129,7 @@ class _BpResearchScreenState extends State { @override Widget build(BuildContext c) { final l = AppLocalizations.of(c); + final p = P.of(c); return Scaffold( appBar: AppBar( title: Text(l?.bpResearchTitle ?? 'BP research capture'), @@ -144,7 +145,7 @@ class _BpResearchScreenState extends State { 'this app. Nothing here is a health feature, nothing here ' 'feeds any score, and nothing here is ever blended with what ' 'the band measured.', - style: c.textTheme.bodySmall, + style: F.cap.copyWith(color: p.ink2, height: 1.5), ), const SizedBox(height: S.x4), TextField( @@ -194,7 +195,7 @@ class _BpResearchScreenState extends State { const SizedBox(height: S.x6), if (_rows.isNotEmpty) ...[ Text(l?.bpResearchHistory ?? 'Captures', - style: c.textTheme.titleMedium), + style: F.head), const SizedBox(height: S.x2), for (final r in _rows) ListTile( @@ -219,7 +220,7 @@ class _BpResearchScreenState extends State { 'Export all captures as CSV from Your data › Export CSV ' '(set “BP research captures”). Research data — it never ' 'leaves the phone except through that file.', - style: c.textTheme.bodySmall, + style: F.cap.copyWith(color: p.ink2, height: 1.5), ), ], ], @@ -235,12 +236,13 @@ class _BpResearchScreenState extends State { final beats = r['rr_beats']; final hr = r['hr_mean']; final rmssd = r['rmssd_ms']; + if (onehz == null && beats == null) { return 'No band data in the window — stored as-is.'; } final parts = []; - if (hr != null) parts.add('HR ${hr.toStringAsFixed(0)} bpm'); - if (rmssd != null) parts.add('RMSSD ${rmssd.toStringAsFixed(0)} ms'); + if (hr is num) parts.add('HR ${hr.toStringAsFixed(0)} bpm'); + if (rmssd is num) parts.add('RMSSD ${rmssd.toStringAsFixed(0)} ms'); parts.add('${onehz ?? 0} 1 Hz rows, ${beats ?? 0} beats'); return parts.join(' · '); } From f09e863a47d26484a431fa1e5ec902df2483eb3b Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Wed, 30 Sep 2026 21:32:28 +0000 Subject: [PATCH 03/22] test: register BpResearchScreen in _notComponents (Scaffold route over its own research table) Co-authored-by: BucciMobile --- test/ui2_tokens_test.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index 05a6a0a55..effee3e28 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -271,6 +271,11 @@ const _notComponents = { // Where the hydration notification lands: a Scaffold route that reads and // writes the day's journal metrics. The one control on it — FieldStepper — // IS in the gallery. + // The BP research capture route: a Scaffold that reads and writes its own + // research table (never the health stores) and only exists behind the dev + // toggle. A gallery case would have to mock the database; the capture flow + // is what bp_research_capture_test.dart covers on the data side. + 'BpResearchScreen', // The coach chat and its BYOK setup: Scaffold routes that own an engine, a // 120 s network call and the keychain. `CoachFigure` — the part a gallery can // actually hold — IS in it. From 76bc9b2691a0d39c13d31a81825a03689c9e1ecd Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 02:14:46 +0000 Subject: [PATCH 04/22] fix(review): address CodeRabbit findings on PR #477 - bp_research tables ride _restoreTables/_salvageTables (parent before child) so backup/restore and salvage no longer drop cuff references - putBpResearchCapture normalizes a NULL device to '' (NULL never equals NULL in UNIQUE, so retakes without a device duplicated the reference) and deletes the replaced row's window explicitly (no PRAGMA foreign_keys, so ON DELETE CASCADE is inert and INSERT OR REPLACE would orphan the old window under a fresh id) - deleteBpResearchCapture takes the window row in the same transaction - the capture screen rejects dia >= sys (swapped pairs) and reports store vs refresh failures separately - l10n: bpResearchBadValue states the supported range instead of clinical impossibility; bpResearchExportHint no longer claims the CSV is the only way research data leaves the phone (full-db backup and opt-in health share also carry it) - new test/bp_research_db_test.dart covers all of the above against the real DB Co-authored-by: BucciMobile --- lib/data/db.dart | 44 +++++++++-- lib/l10n/app_en.arb | 4 +- lib/ui2/profile/bp_research.dart | 13 ++- test/bp_research_db_test.dart | 131 +++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 test/bp_research_db_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 686eb391d..d4f3832f1 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -167,6 +167,8 @@ class LocalDb { /// flash as we ACK, so in practice this is the only copy of those days too. static const _salvageTables = [ // Hand-entered. The only copy that exists anywhere. + 'bp_research_reference', + 'bp_research_window', 'journal', 'journal_metric', 'journal_field_def', @@ -1665,13 +1667,30 @@ class LocalDb { static Future putBpResearchCapture(BpResearchCapture c) async { final db = await instance; await db.transaction((txn) async { + // NULL never equals NULL in a UNIQUE constraint, so a retake with no + // device text would duplicate the reference instead of replacing it. + // Normalizing to '' keeps (measured_at_ms, device) unique either way, + // and the window of the row being replaced is deleted explicitly — + // without PRAGMA foreign_keys the ON DELETE CASCADE never runs, and + // INSERT OR REPLACE assigns a fresh id that would orphan it. + await txn.rawDelete( + 'DELETE FROM bp_research_window WHERE reference_id IN ' + '(SELECT id FROM bp_research_reference ' + 'WHERE measured_at_ms = ? AND device = ?)', + [c.measuredAtMs, c.device ?? ''], + ); + await txn.rawDelete( + 'DELETE FROM bp_research_reference ' + 'WHERE measured_at_ms = ? AND device = ?', + [c.measuredAtMs, c.device ?? ''], + ); final id = await txn.rawInsert( - 'INSERT OR REPLACE INTO bp_research_reference ' + 'INSERT INTO bp_research_reference ' '(measured_at_ms, device, posture, conditions, systolic_mmhg, ' 'diastolic_mmhg, captured_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)', [ c.measuredAtMs, - c.device, + c.device ?? '', c.posture, c.conditions, c.systolicMmHg, @@ -1729,11 +1748,20 @@ class LocalDb { /// Delete one capture (dev screen). The window cascades. static Future deleteBpResearchCapture(int id) async { final db = await instance; - await db.delete( - 'bp_research_reference', - where: 'id = ?', - whereArgs: [id], - ); + // No PRAGMA foreign_keys here, so the window's ON DELETE CASCADE is + // inert — the delete has to take the window row explicitly. + await db.transaction((txn) async { + await txn.delete( + 'bp_research_window', + where: 'reference_id = ?', + whereArgs: [id], + ); + await txn.delete( + 'bp_research_reference', + where: 'id = ?', + whereArgs: [id], + ); + }); } /// Atomically claim [key] for a one-time OS notification fire. @@ -8744,6 +8772,8 @@ class LocalDb { // banked. They were also simply MISSING here until now — nutrition, // medication, strength sets, symptoms and routes did not survive a // backup/restore round trip at all, the same omission `wipeAll` documents. + 'bp_research_reference', + 'bp_research_window', 'journal', 'journal_metric', 'journal_field_def', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bfd8524f3..10be52412 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12486,11 +12486,11 @@ "@bpResearchHistory": { "description": "Section header for the stored capture list" }, - "bpResearchBadValue": "That pair is not a blood pressure — check the numbers and try again. Nothing was stored.", + "bpResearchBadValue": "That pair is outside the range this app supports (systolic 50–300, diastolic 20–200 mmHg, diastolic below systolic) — it was not saved. Check the numbers and try again.", "@bpResearchBadValue": { "description": "Snackbar text for an out-of-bounds reference pair, which is rejected, never clamped" }, - "bpResearchExportHint": "Export all captures as CSV from Your data › Export CSV (set \"BP research captures\"). Research data — it never leaves the phone except through that file.", + "bpResearchExportHint": "Export all captures as CSV from Your data › Export CSV (set \"BP research captures\"). This is the dedicated export for BP research data; a full-database backup or an opt-in health share also contains it.", "@bpResearchExportHint": { "description": "Hint under the capture list explaining the CSV export path" } diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart index df49f1427..269acc8c4 100644 --- a/lib/ui2/profile/bp_research.dart +++ b/lib/ui2/profile/bp_research.dart @@ -72,7 +72,8 @@ class _BpResearchScreenState extends State { sys < kResearchSystolicBounds.$1 || sys > kResearchSystolicBounds.$2 || dia < kResearchDiastolicBounds.$1 || - dia > kResearchDiastolicBounds.$2) { + dia > kResearchDiastolicBounds.$2 || + dia >= sys) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(l?.bpResearchBadValue ?? @@ -83,6 +84,7 @@ class _BpResearchScreenState extends State { return; } setState(() => _busy = true); + var stored = false; try { final now = DateTime.now(); final start = now.millisecondsSinceEpoch - kBpResearchWindowPreMs; @@ -118,9 +120,18 @@ class _BpResearchScreenState extends State { _conditions.text.trim().isEmpty ? null : _conditions.text.trim(), window: window, )); + stored = true; _sys.clear(); _dia.clear(); await _refresh(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(stored + ? 'Capture saved, but refreshing the history failed. ($e)' + : 'Capture failed \u2014 nothing was stored. ($e)'), + )); + } } finally { if (mounted) setState(() => _busy = false); } diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart new file mode 100644 index 000000000..1c0494dd1 --- /dev/null +++ b/test/bp_research_db_test.dart @@ -0,0 +1,131 @@ +// The BP research store guarantees the review fixes made explicit: +// · a retake with NO device text replaces the reference instead of +// duplicating it (NULL never equals NULL in a UNIQUE constraint, so +// the store normalizes to '' — the rows must stay one, not two); +// · deleting a capture removes its window row in the same transaction +// (no PRAGMA foreign_keys here, so the ON DELETE CASCADE is inert); +// · a retake that now finds band data replaces the old window instead +// of orphaning it under the replaced reference's old id. +// Runs the REAL LocalDb over sqflite_common_ffi. +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/health/bp_research_capture.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +const _at = 1700000000000; // ms + +BpResearchCapture _capture( + int measuredAtMs, { + String? device, + BpResearchWindow? window, +}) => + BpResearchCapture( + measuredAtMs: measuredAtMs, + device: device, + posture: 'sitting', + conditions: 'rest', + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: measuredAtMs, + window: window, + ); + +const _win = BpResearchWindow( + windowStartMs: _at - 120000, + windowEndMs: _at + 120000, + onehzRows: 240, + rrBeats: 200, + hrMean: 62.5, + rrMsMean: 960, + rrMsMin: 800, + rrMsMax: 1100, + rmssdMs: 42, + metaJson: null, +); + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'bp_research_db_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + final db = await LocalDb.instance; + await db.close(); + }); + + test('a retake with no device replaces the reference, not duplicates it', + () async { + await LocalDb.putBpResearchCapture(_capture(_at)); + await LocalDb.putBpResearchCapture(_capture(_at, window: _win)); + final rows = await LocalDb.bpResearchCaptures(); + expect(rows, hasLength(1)); + expect(rows.first['device'], ''); + expect(rows.first['hr_mean'], 62.5); + }); + + test('a named device stays distinct from the no-device row', () async { + await LocalDb.putBpResearchCapture(_capture(_at, device: 'omron')); + final rows = await LocalDb.bpResearchCaptures(); + expect(rows, hasLength(2)); // the '' row from the previous test + omron + expect(rows.where((r) => r['device'] == 'omron'), hasLength(1)); + }); + + test('delete removes the window row too (the SQL cascade is inert)', + () async { + final db = await LocalDb.instance; + final before = + await db.rawQuery('SELECT COUNT(*) c FROM bp_research_window'); + expect(before.first['c'], greaterThan(0)); + final refs = await db + .rawQuery('SELECT id FROM bp_research_reference WHERE device = ?', + ['omron']); + await LocalDb.deleteBpResearchCapture(refs.first['id'] as int); + final orphaned = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + ); + expect(orphaned.first['c'], 0); + }); + + test('a retake that now finds band data replaces the absent window', + () async { + final later = _at + 60000; + await LocalDb.putBpResearchCapture(_capture(later)); + await LocalDb.putBpResearchCapture(_capture(later, window: _win)); + final db = await LocalDb.instance; + final windows = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id IN ' + '(SELECT id FROM bp_research_reference WHERE measured_at_ms = ?)', + [later]); + expect(windows.first['c'], 1); + final orphaned = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + ); + expect(orphaned.first['c'], 0); + }); + + test('the research tables ride the backup restore and salvage lists', + () async { + expect(LocalDb.restoreTablesForTest, contains('bp_research_reference')); + expect(LocalDb.restoreTablesForTest, contains('bp_research_window')); + expect(LocalDb.salvageTablesForTest, contains('bp_research_reference')); + expect(LocalDb.salvageTablesForTest, contains('bp_research_window')); + // Parent before child, in both lists. + int posOf(List l, String t) => l.indexOf(t); + expect( + posOf(LocalDb.restoreTablesForTest, 'bp_research_reference'), + lessThan(posOf(LocalDb.restoreTablesForTest, 'bp_research_window')), + ); + expect( + posOf(LocalDb.salvageTablesForTest, 'bp_research_reference'), + lessThan(posOf(LocalDb.salvageTablesForTest, 'bp_research_window')), + ); + }); +} From 66420981ab301fa77f3ed523a6fba17f0f50b5d1 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 02:57:19 +0000 Subject: [PATCH 05/22] fix(review): merge BP research captures by natural key on restore CodeRabbit follow-up on PR #477: the generic importer REPLACEs on the row's primary key, so a foreign export's bp_research_reference id=1 would eat this install's unrelated id=1 capture (AUTOINCREMENT ids are device-local), and the imported window would ride a stale reference_id. Both tables now take a dedicated merge branch in _mergeFromDbFile: references REPLACE on their natural UNIQUE (measured_at_ms, device) key with the source id dropped, a source->dest id map is built as they land, and each window row is remapped onto the destination reference and REPLACEd on its PK. A capture whose incoming window is absent keeps the window it already had; re-import converges. Covered by two new DB tests (natural-key collision with a foreign id=1, idempotent re-import). Co-authored-by: BucciMobile --- lib/data/db.dart | 103 +++++++++++++++++++++++++++++ test/bp_research_db_test.dart | 118 ++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) diff --git a/lib/data/db.dart b/lib/data/db.dart index d4f3832f1..db2fa4369 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -8870,8 +8870,21 @@ class LocalDb { final info = await db.rawQuery('PRAGMA table_info($t)'); return {for (final c in info) (c['name'] as String)}; } + Future srcHasTable(String t, Database s) async { + // A salvage source may predate the window table; `SELECT *` on a + // missing table throws, so probe for its existence first. + final rows = await s.rawQuery( + "SELECT name FROM sqlite_master WHERE type='table' AND name = ?", + [t], + ); + return rows.isNotEmpty; + } final counts = {}; + // SOURCE→DEST id map for the BP research reference merge below: the + // window rows of a foreign export name their reference by the SOURCE + // database's AUTOINCREMENT id, which is meaningless here. + final bpIdMap = {}; // DISTINCT DAYS ACTUALLY WRITTEN — the number the caller reports as // "N days imported". // @@ -8930,6 +8943,96 @@ class LocalDb { if (e.isNoSuchTableError()) continue; rethrow; } + // BP RESEARCH CAPTURES MERGE BY NATURAL KEY, NOT BY SOURCE ID. The + // reference's `id` is a device-local AUTOINCREMENT and the window's + // `reference_id` names it, so the generic REPLACE-by-PK path would + // let a foreign export's id=1 eat this install's id=1 capture. Both + // tables are hand-typed and tiny (nothing writes them but the dev + // screen), so a dedicated two-query merge beats threading a special + // case through the paged loop: the reference REPLACEs on its natural + // UNIQUE (measured_at_ms, device) key, the window follows onto the + // DESTINATION id, and a capture whose incoming window is absent + // keeps the window it already had. Re-import converges. + if (t == 'bp_research_reference' || t == 'bp_research_window') { + // The reference pass builds the id map; the window pass that + // follows (references merge first) only consumes it. + try { + final refCols = await destCols('bp_research_reference'); + final winCols = await destCols('bp_research_window'); + final srcRefs = t == 'bp_research_reference' + ? await src.rawQuery('SELECT * FROM bp_research_reference') + : const >[]; + final srcWins = t == 'bp_research_window' && + await srcHasTable('bp_research_window', src) + ? await src.rawQuery('SELECT * FROM bp_research_window') + : const >[]; + await db.transaction((txn) async { + for (final r in srcRefs) { + final row = { + for (final e in r.entries) + if (refCols.contains(e.key)) e.key: e.value, + }; + final srcId = row.remove('id'); + final destId = await txn.rawInsert( + 'INSERT OR REPLACE INTO bp_research_reference ' + '(measured_at_ms, device, posture, conditions, ' + 'systolic_mmhg, diastolic_mmhg, captured_at_ms) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + row['measured_at_ms'], + (row['device'] as String?) ?? '', + row['posture'], + row['conditions'], + row['systolic_mmhg'], + row['diastolic_mmhg'], + row['captured_at_ms'], + ], + ); + if (srcId is num) { + bpIdMap[srcId.toInt()] = destId; + } + } + for (final w in srcWins) { + final row = { + for (final e in w.entries) + if (winCols.contains(e.key)) e.key: e.value, + }; + final destRef = row.remove('reference_id'); + final mapped = destRef is num + ? bpIdMap[destRef.toInt()] + : null; + if (mapped == null) continue; + await txn.rawInsert( + 'INSERT OR REPLACE INTO bp_research_window ' + '(reference_id, window_start_ms, window_end_ms, ' + 'onehz_rows, rr_beats, hr_mean, rr_ms_mean, rr_ms_min, ' + 'rr_ms_max, rmssd_ms, meta_json) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + mapped, + row['window_start_ms'], + row['window_end_ms'], + row['onehz_rows'], + row['rr_beats'], + row['hr_mean'], + row['rr_ms_mean'], + row['rr_ms_min'], + row['rr_ms_max'], + row['rmssd_ms'], + row['meta_json'], + ], + ); + } + }); + counts[t] = t == 'bp_research_reference' + ? srcRefs.length + : srcWins.length; + } catch (_) { + if (!tolerant) rethrow; + counts[t] = 0; + } + continue; + } if (t == 'day_result') importedDays = {}; if (firstPage.isEmpty) { counts[t] = 0; diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart index 1c0494dd1..0643eaf00 100644 --- a/test/bp_research_db_test.dart +++ b/test/bp_research_db_test.dart @@ -128,4 +128,122 @@ void main() { lessThan(posOf(LocalDb.salvageTablesForTest, 'bp_research_window')), ); }); + + // A foreign export's AUTOINCREMENT ids are meaningless on this install: + // its id=1 must never REPLACE an unrelated local capture that happens to + // hold id=1. The merge keys references on (measured_at_ms, device) and + // remaps each window onto the DESTINATION reference id. + test('restore merges captures by natural key, never by source id', + () async { + // Start from a clean store: earlier tests in this file leave rows + // behind, and this one asserts exact row sets. + final db0 = await LocalDb.instance; + await db0.delete('bp_research_window'); + await db0.delete('bp_research_reference'); + // Local state: one capture (id=1 by AUTOINCREMENT) plus its window. + await LocalDb.putBpResearchCapture(_capture(_at, device: 'local')); + // A foreign export whose DIFFERENT capture also carries id=1. + final srcPath = + p.join(await databaseFactory.getDatabasesPath(), 'bp_foreign.db'); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, ' + 'device TEXT, posture TEXT, conditions TEXT, ' + 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' + 'captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))'); + await src.execute( + 'CREATE TABLE bp_research_window (' + 'reference_id INTEGER NOT NULL PRIMARY KEY, ' + 'window_start_ms INTEGER NOT NULL, window_end_ms INTEGER NOT NULL, ' + 'onehz_rows INTEGER, rr_beats INTEGER, hr_mean REAL, rr_ms_mean REAL, ' + 'rr_ms_min REAL, rr_ms_max REAL, rmssd_ms REAL, meta_json TEXT)'); + await src.insert('bp_research_reference', { + 'id': 1, // deliberately collides with the local capture's id + 'measured_at_ms': _at + 60000, + 'device': 'local', + 'posture': 'sitting', + 'conditions': 'rest', + 'systolic_mmhg': 130, + 'diastolic_mmhg': 85, + 'captured_at_ms': _at + 60000, + }); + await src.insert('bp_research_window', { + 'reference_id': 1, + 'window_start_ms': _at + 60000 - 120000, + 'window_end_ms': _at + 60000 + 120000, + 'onehz_rows': 240, + 'rr_beats': 200, + 'hr_mean': 71.0, + }); + await src.close(); + + final counts = await LocalDb.importFromDbFile(srcPath); + expect(counts['bp_research_reference'], 1); + expect(counts['bp_research_window'], 1); + + final db = await LocalDb.instance; + // Both captures survive: the foreign id=1 did not eat the local one. + final refs = await db.rawQuery( + 'SELECT measured_at_ms, systolic_mmhg FROM bp_research_reference ' + 'ORDER BY measured_at_ms'); + expect(refs, hasLength(2)); + expect(refs[0]['measured_at_ms'], _at); + expect(refs[0]['systolic_mmhg'], 120.0); + expect(refs[1]['measured_at_ms'], _at + 60000); + expect(refs[1]['systolic_mmhg'], 130.0); + // The imported window rides the imported reference's DESTINATION id, + // and no window is orphaned. + final orphaned = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + ); + expect(orphaned.first['c'], 0); + final importedWin = await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window w ' + 'JOIN bp_research_reference r ON r.id = w.reference_id ' + 'WHERE r.measured_at_ms = ?', [_at + 60000]); + expect(importedWin.first['hr_mean'], 71.0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('a re-import of the same export converges (idempotent merge)', + () async { + final db0 = await LocalDb.instance; + await db0.delete('bp_research_window'); + await db0.delete('bp_research_reference'); + final srcPath = + p.join(await databaseFactory.getDatabasesPath(), 'bp_foreign2.db'); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, ' + 'device TEXT, posture TEXT, conditions TEXT, ' + 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' + 'captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))'); + await src.insert('bp_research_reference', { + 'id': 1, + 'measured_at_ms': _at + 120000, + 'device': '', + 'systolic_mmhg': 118, + 'diastolic_mmhg': 76, + 'captured_at_ms': _at + 120000, + }); + await src.close(); + + await LocalDb.importFromDbFile(srcPath); + await LocalDb.importFromDbFile(srcPath); + final db = await LocalDb.instance; + final n = (await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_reference ' + 'WHERE measured_at_ms = ?', [_at + 120000])).first['c']; + expect(n, 1); + await databaseFactory.deleteDatabase(srcPath); + }); } From 2001787178c6ac2f7808a0e2a0bc9231117c888c Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 03:07:17 +0000 Subject: [PATCH 06/22] fix(review): keep the destination id on a colliding BP capture restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit INSERT OR REPLACE on the natural key minted a fresh AUTOINCREMENT id, stranding the local window row under the old reference_id (no FK cascade here). The merge now UPDATES the colliding reference in place and keeps its id — the incoming window re-attaches to it, and a capture whose incoming window is absent genuinely keeps the window it had. New DB test covers the collision-without-window case. Co-authored-by: BucciMobile --- lib/data/db.dart | 59 ++++++++++++++++++++++++++--------- test/bp_research_db_test.dart | 59 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 14 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index db2fa4369..d58c657b0 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -8973,21 +8973,52 @@ class LocalDb { if (refCols.contains(e.key)) e.key: e.value, }; final srcId = row.remove('id'); - final destId = await txn.rawInsert( - 'INSERT OR REPLACE INTO bp_research_reference ' - '(measured_at_ms, device, posture, conditions, ' - 'systolic_mmhg, diastolic_mmhg, captured_at_ms) ' - 'VALUES (?, ?, ?, ?, ?, ?, ?)', - [ - row['measured_at_ms'], - (row['device'] as String?) ?? '', - row['posture'], - row['conditions'], - row['systolic_mmhg'], - row['diastolic_mmhg'], - row['captured_at_ms'], - ], + // KEEP the destination id on collision. `INSERT OR + // REPLACE` would delete the colliding local row and mint a + // fresh AUTOINCREMENT id — stranding the local window row + // under the old reference_id with no FK cascade to take + // it, exactly the orphan putBpResearchCapture avoids by + // deleting first. UPDATE preserves the id the window is + // about to be re-attached to. + final device = (row['device'] as String?) ?? ''; + final existing = await txn.rawQuery( + 'SELECT id FROM bp_research_reference ' + 'WHERE measured_at_ms = ? AND device = ?', + [row['measured_at_ms'], device], ); + final int destId; + if (existing.isNotEmpty) { + destId = (existing.first['id'] as num).toInt(); + await txn.rawUpdate( + 'UPDATE bp_research_reference SET posture = ?, ' + 'conditions = ?, systolic_mmhg = ?, diastolic_mmhg = ?, ' + 'captured_at_ms = ? WHERE id = ?', + [ + row['posture'], + row['conditions'], + row['systolic_mmhg'], + row['diastolic_mmhg'], + row['captured_at_ms'], + destId, + ], + ); + } else { + destId = await txn.rawInsert( + 'INSERT INTO bp_research_reference ' + '(measured_at_ms, device, posture, conditions, ' + 'systolic_mmhg, diastolic_mmhg, captured_at_ms) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + row['measured_at_ms'], + device, + row['posture'], + row['conditions'], + row['systolic_mmhg'], + row['diastolic_mmhg'], + row['captured_at_ms'], + ], + ); + } if (srcId is num) { bpIdMap[srcId.toInt()] = destId; } diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart index 0643eaf00..5403fede5 100644 --- a/test/bp_research_db_test.dart +++ b/test/bp_research_db_test.dart @@ -210,6 +210,65 @@ void main() { await databaseFactory.deleteDatabase(srcPath); }); + test('a colliding restore keeps the destination id and its window', + () async { + final db0 = await LocalDb.instance; + await db0.delete('bp_research_window'); + await db0.delete('bp_research_reference'); + // Local capture WITH a window. + await LocalDb.putBpResearchCapture( + _capture(_at, device: 'local', window: _win)); + final localId = (await db0.rawQuery( + 'SELECT id FROM bp_research_reference')).first['id'] as int; + + // A foreign export of the SAME instant (same natural key) with no + // window row: the capture's fields update, the window survives. + final srcPath = + p.join(await databaseFactory.getDatabasesPath(), 'bp_foreign3.db'); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, ' + 'device TEXT, posture TEXT, conditions TEXT, ' + 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' + 'captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))'); + await src.insert('bp_research_reference', { + 'id': 7, + 'measured_at_ms': _at, + 'device': 'local', + 'posture': 'standing', + 'conditions': 'after exercise', + 'systolic_mmhg': 140, + 'diastolic_mmhg': 90, + 'captured_at_ms': _at + 1000, + }); + await src.close(); + + await LocalDb.importFromDbFile(srcPath); + + final db = await LocalDb.instance; + final refs = await db.rawQuery( + 'SELECT id, posture, systolic_mmhg FROM bp_research_reference'); + expect(refs, hasLength(1)); + // The destination id is KEPT, so the window stays attached. + expect(refs.first['id'], localId); + expect(refs.first['posture'], 'standing'); + expect(refs.first['systolic_mmhg'], 140.0); + final orphaned = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + ); + expect(orphaned.first['c'], 0); + final win = await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [localId]); + expect(win.first['hr_mean'], 62.5); + await databaseFactory.deleteDatabase(srcPath); + }); + test('a re-import of the same export converges (idempotent merge)', () async { final db0 = await LocalDb.instance; From caa6a26cbd14c65c25cc580e4b0452c1394cedb3 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 08:06:26 +0000 Subject: [PATCH 07/22] =?UTF-8?q?feat(research):=20BP=20research=20capture?= =?UTF-8?q?=20v2=20=E2=80=94=20measurement=20vs=20entry=20time,=20rest=20w?= =?UTF-8?q?indow,=20gap-aware=20quality,=20immutable=20snapshots,=20offlin?= =?UTF-8?q?e=20model=20prototype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the BP research capture (#477), improving data quality and reproducibility. NOT a blood pressure feature; nothing here feeds any score or health platform. Datenerfassung (schema rung 56, additive): - measurement_started_at_ms / measurement_finished_at_ms separated from captured_at_ms (entry): a back-dated cuff reading pairs with the HISTORICAL sensor data of its measurement instant, never with whatever the band holds at typing time. No invented durations. - The feature window is the 5-minute rest window BEFORE the measurement (kResearchRestPreMs, documented engineering default), so the cuff's inflation stays out of it by construction; a custom post-measurement window whose end lies in the future is 'pending'. - Requested window bounds vs OBSERVED data bounds are stored separately; quality counts added: valid_hr_seconds, valid_interval_count, valid_interval_pair_count, coverage_fraction, rejected_interval_fraction, quality_status. - Rows are sorted, deduplicated, non-finite values rejected; RMSSD is computed ONLY over contiguous interval pairs (gap ≤ 2.5 s default, documented) — never across a sensor gap. - band_device_id, measurement_session_id stored per capture; cuff device and wearable stay distinct. - bp_research_snapshot: immutable JSON snapshots of the exact rows a window was computed from; re-processing writes new revisions. - Restore/salvage merge extended to the snapshot table (natural key, destination-id remap, UPDATE-in-place on collision); delete removes snapshots; isolation test extended to bp_research_snapshot. Externes Forschungsmodell (tool/, offline, experimental): - tool/bp_research_model.py: prequential evaluation of a personally calibrated HR/HRV linear model (feature z=[1,(H-H0)/sH,(L-L0)/sL], level-A scalar offset Kalman, optional level-B full-parameter Joseph-form Kalman) against cuff-only baselines (last cuff, running cuff mean). Session aggregation, chronological replay, honest exclusion. Math-only synthetic tests in tool/test_bp_research_model.py; no medical-accuracy claim. Tests: 11 capture/window tests, 11 DB tests (incl. retro capture, snapshot revision, delete), isolation extended, full suite green. Co-authored-by: BucciMobile --- lib/data/csv_export.dart | 33 ++- lib/data/db.dart | 300 +++++++++++++++++++---- lib/health/bp_research_capture.dart | 327 ++++++++++++++++++++----- lib/l10n/app_en.arb | 16 ++ lib/ui2/profile/bp_research.dart | 182 +++++++++++--- test/bp_research_capture_test.dart | 190 +++++++++++++-- test/bp_research_db_test.dart | 85 +++++++ test/bp_research_isolation_test.dart | 1 + tool/bp_research_model.py | 346 +++++++++++++++++++++++++++ tool/test_bp_research_model.py | 108 +++++++++ 10 files changed, 1430 insertions(+), 158 deletions(-) create mode 100644 tool/bp_research_model.py create mode 100644 tool/test_bp_research_model.py diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart index 5c1b803e1..e6f31c1a8 100644 --- a/lib/data/csv_export.dart +++ b/lib/data/csv_export.dart @@ -293,14 +293,20 @@ const kCsvExportSets = [ // afterwards, and a column of zeroes is a fabrication. columns: [ 'measured_at_ms', + 'measurement_started_at_ms', + 'measurement_finished_at_ms', + 'entered_at_ms', 'device', 'posture', 'conditions', 'systolic_mmhg', 'diastolic_mmhg', - 'captured_at_ms', + 'band_device_id', + 'measurement_session_id', 'window_start_ms', 'window_end_ms', + 'observed_start_ms', + 'observed_end_ms', 'onehz_rows', 'rr_beats', 'hr_mean', @@ -308,15 +314,34 @@ const kCsvExportSets = [ 'rr_ms_min', 'rr_ms_max', 'rmssd_ms', + 'valid_hr_seconds', + 'valid_interval_count', + 'valid_interval_pair_count', + 'coverage_fraction', + 'rejected_interval_fraction', + 'quality_status', + 'feature_version', + 'snapshot_revision', 'meta_json', ], sql: ''' - SELECT r.measured_at_ms, COALESCE(r.device, '') AS device, + SELECT r.measured_at_ms, r.measurement_started_at_ms, + r.measurement_finished_at_ms, + r.captured_at_ms AS entered_at_ms, + COALESCE(r.device, '') AS device, COALESCE(r.posture, '') AS posture, COALESCE(r.conditions, '') AS conditions, - r.systolic_mmhg, r.diastolic_mmhg, r.captured_at_ms, - w.window_start_ms, w.window_end_ms, w.onehz_rows, w.rr_beats, + r.systolic_mmhg, r.diastolic_mmhg, + COALESCE(r.band_device_id, '') AS band_device_id, + COALESCE(r.measurement_session_id, '') AS measurement_session_id, + w.window_start_ms, w.window_end_ms, + w.observed_start_ms, w.observed_end_ms, + w.onehz_rows, w.rr_beats, w.hr_mean, w.rr_ms_mean, w.rr_ms_min, w.rr_ms_max, w.rmssd_ms, + w.valid_hr_seconds, w.valid_interval_count, + w.valid_interval_pair_count, w.coverage_fraction, + w.rejected_interval_fraction, w.quality_status, + w.feature_version, w.snapshot_revision, COALESCE(w.meta_json, '') AS meta_json FROM bp_research_reference r LEFT JOIN bp_research_window w ON w.reference_id = r.id diff --git a/lib/data/db.dart b/lib/data/db.dart index d58c657b0..d45b8fa8f 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -169,6 +169,7 @@ class LocalDb { // Hand-entered. The only copy that exists anywhere. 'bp_research_reference', 'bp_research_window', + 'bp_research_snapshot', 'journal', 'journal_metric', 'journal_field_def', @@ -352,7 +353,7 @@ class LocalDb { /// pass it: sqflite throws `ArgumentError('onCreate must be null if no /// version is specified')` BEFORE opening anything when `onCreate` is given /// without `version` (sqflite_common database_mixin.dart). - static const int schemaVersion = 55; + static const int schemaVersion = 56; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -465,6 +466,7 @@ class LocalDb { await _createNotifSlots(db); await _createAlarmSchedule(db); await _createBpResearch(db); + await _upgradeBpResearchV2(db); await _ensureCoachViews(db); }, onUpgrade: (db, oldV, newV) async { @@ -1089,6 +1091,14 @@ class LocalDb { // [_createBpResearch]). await _createBpResearch(db); } + if (oldV < 56) { + // BP research v2: measurement vs entry time, the rest window + // with quality counts, and immutable raw-row snapshots. All + // ADDITIVE: new nullable columns on the existing tables plus one + // new table — no rewrite, no backfill (v1 rows keep NULL in the + // new columns; absent stays absent). Same isolation as rung 55. + await _upgradeBpResearchV2(db); + } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -1618,6 +1628,79 @@ class LocalDb { /// cuff reading at the same instant re-states the window rather than /// duplicating it. Legitimate repeat measurements minutes apart are /// different instants and both stay. + /// Rung 56: the v2 research columns and the snapshot table. Additive only — + /// nullable columns and a new table, no rewrite, no backfill. Idempotent + /// (every ADD COLUMN guarded by _columnsOf) so it can serve both the + /// onUpgrade ladder and a fresh install that ran rung 55's CREATE first. + static Future _upgradeBpResearchV2(Database db) async { + final refCols = await _columnsOf(db, 'bp_research_reference'); + // Measurement vs entry time. NULL on v1 rows: their measured_at_ms + // doubles as both, and absent stays absent — no backfill. + if (!refCols.contains('measurement_started_at_ms')) { + await db.execute( + 'ALTER TABLE bp_research_reference ' + 'ADD COLUMN measurement_started_at_ms INTEGER'); + } + if (!refCols.contains('measurement_finished_at_ms')) { + await db.execute( + 'ALTER TABLE bp_research_reference ' + 'ADD COLUMN measurement_finished_at_ms INTEGER'); + } + // Band identity and session grouping, kept beside the capture so signal + // provenance survives a device swap or a second band. + if (!refCols.contains('band_device_id')) { + await db.execute( + 'ALTER TABLE bp_research_reference ADD COLUMN band_device_id TEXT'); + } + if (!refCols.contains('measurement_session_id')) { + await db.execute( + 'ALTER TABLE bp_research_reference ' + 'ADD COLUMN measurement_session_id TEXT'); + } + + final winCols = await _columnsOf(db, 'bp_research_window'); + // Requested vs OBSERVED window bounds: what the data actually covered. + if (!winCols.contains('observed_start_ms')) { + await db.execute( + 'ALTER TABLE bp_research_window ADD COLUMN observed_start_ms INTEGER'); + } + if (!winCols.contains('observed_end_ms')) { + await db.execute( + 'ALTER TABLE bp_research_window ADD COLUMN observed_end_ms INTEGER'); + } + // Quality counts (v2): honest coverage and continuity metrics, never a + // fabricated confidence number. + for (final c in [ + 'valid_hr_seconds INTEGER', + 'valid_interval_count INTEGER', + 'valid_interval_pair_count INTEGER', + 'coverage_fraction REAL', + 'rejected_interval_fraction REAL', + 'quality_status TEXT', + 'feature_version INTEGER', + 'snapshot_revision INTEGER', + ]) { + final name = c.split(' ').first; + if (!winCols.contains(name)) { + await db.execute('ALTER TABLE bp_research_window ADD COLUMN $c'); + } + } + + // Immutable raw-row snapshots: the exact onehz/rr rows a window + // revision was computed from, frozen as JSON. Re-processing writes a + // NEW revision row; old revisions stay. Research-only, same isolation + // as the rung-55 tables. + await db.execute( + 'CREATE TABLE IF NOT EXISTS bp_research_snapshot (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'reference_id INTEGER NOT NULL, ' + 'revision INTEGER NOT NULL, ' + 'onehz_json TEXT NOT NULL, ' + 'rr_json TEXT NOT NULL, ' + 'created_at_ms INTEGER NOT NULL, ' + 'UNIQUE (reference_id, revision))'); + } + static Future _createBpResearch(Database db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS bp_research_reference ( @@ -1664,7 +1747,19 @@ class LocalDb { /// `(measured_at_ms, device)`: `INSERT OR REPLACE` on the reference, then /// the window row is restated in the same transaction so a retake can never /// leave an old window under a new reference. - static Future putBpResearchCapture(BpResearchCapture c) async { + /// Insert one cuff reference reading plus its frozen band window and the + /// immutable snapshot of the rows the window was computed from. + /// + /// Pure write; the caller computes the window stats (see + /// `lib/health/bp_research_capture.dart`). Idempotent on + /// `(measured_at_ms, device)`: the colliding row is deleted explicitly + /// first (window, snapshots, then the reference — no PRAGMA foreign_keys + /// here, so nothing cascades on its own), then re-inserted. + static Future putBpResearchCapture( + BpResearchCapture c, { + List>? snapshotOnehzRows, + List>? snapshotRrRows, + }) async { final db = await instance; await db.transaction((txn) async { // NULL never equals NULL in a UNIQUE constraint, so a retake with no @@ -1672,13 +1767,19 @@ class LocalDb { // Normalizing to '' keeps (measured_at_ms, device) unique either way, // and the window of the row being replaced is deleted explicitly — // without PRAGMA foreign_keys the ON DELETE CASCADE never runs, and - // INSERT OR REPLACE assigns a fresh id that would orphan it. + // a fresh id would orphan the old window. await txn.rawDelete( 'DELETE FROM bp_research_window WHERE reference_id IN ' '(SELECT id FROM bp_research_reference ' 'WHERE measured_at_ms = ? AND device = ?)', [c.measuredAtMs, c.device ?? ''], ); + await txn.rawDelete( + 'DELETE FROM bp_research_snapshot WHERE reference_id IN ' + '(SELECT id FROM bp_research_reference ' + 'WHERE measured_at_ms = ? AND device = ?)', + [c.measuredAtMs, c.device ?? ''], + ); await txn.rawDelete( 'DELETE FROM bp_research_reference ' 'WHERE measured_at_ms = ? AND device = ?', @@ -1686,16 +1787,22 @@ class LocalDb { ); final id = await txn.rawInsert( 'INSERT INTO bp_research_reference ' - '(measured_at_ms, device, posture, conditions, systolic_mmhg, ' - 'diastolic_mmhg, captured_at_ms) VALUES (?, ?, ?, ?, ?, ?, ?)', + '(measured_at_ms, measurement_started_at_ms, ' + 'measurement_finished_at_ms, device, posture, conditions, ' + 'systolic_mmhg, diastolic_mmhg, captured_at_ms, band_device_id, ' + 'measurement_session_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ c.measuredAtMs, + c.measurementStartedAtMs, + c.measurementFinishedAtMs, c.device ?? '', c.posture, c.conditions, c.systolicMmHg, c.diastolicMmHg, c.capturedAtMs, + c.bandDeviceId, + c.measurementSessionId, ], ); // A capture with no band data stores NO window row — the LEFT JOIN in @@ -1710,13 +1817,19 @@ class LocalDb { } await txn.rawInsert( 'INSERT OR REPLACE INTO bp_research_window ' - '(reference_id, window_start_ms, window_end_ms, onehz_rows, ' - 'rr_beats, hr_mean, rr_ms_mean, rr_ms_min, rr_ms_max, rmssd_ms, ' - 'meta_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + '(reference_id, window_start_ms, window_end_ms, observed_start_ms, ' + 'observed_end_ms, onehz_rows, rr_beats, hr_mean, rr_ms_mean, ' + 'rr_ms_min, rr_ms_max, rmssd_ms, valid_hr_seconds, ' + 'valid_interval_count, valid_interval_pair_count, ' + 'coverage_fraction, rejected_interval_fraction, quality_status, ' + 'feature_version, snapshot_revision, meta_json) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ id, w.windowStartMs, w.windowEndMs, + w.observedStartMs, + w.observedEndMs, w.onehzRows, w.rrBeats, w.hrMean, @@ -1724,20 +1837,61 @@ class LocalDb { w.rrMsMin, w.rrMsMax, w.rmssdMs, + w.validHrSeconds, + w.validIntervalCount, + w.validIntervalPairCount, + w.coverageFraction, + w.rejectedIntervalFraction, + w.qualityStatus, + w.featureVersion, + w.snapshotRevision, w.metaJson, ], ); + // The immutable snapshot: revision 1 for a fresh capture, n+1 when a + // re-processed capture carries an explicit revision. Rows frozen as + // JSON exactly as the window computation saw them. + if (snapshotOnehzRows != null || snapshotRrRows != null) { + final rev = w.snapshotRevision ?? 1; + await txn.rawInsert( + 'INSERT OR REPLACE INTO bp_research_snapshot ' + '(reference_id, revision, onehz_json, rr_json, created_at_ms) ' + 'VALUES (?, ?, ?, ?, ?)', + [ + id, + rev, + jsonEncode(snapshotOnehzRows ?? const []), + jsonEncode(snapshotRrRows ?? const []), + c.capturedAtMs, + ], + ); + await txn.rawUpdate( + 'UPDATE bp_research_window SET snapshot_revision = ? ' + 'WHERE reference_id = ?', + [rev, id], + ); + } }); } + /// All captures, newest first, for the dev screen and the CSV export. static Future>> bpResearchCaptures() async { final db = await instance; return db.rawQuery(''' - SELECT r.id, r.measured_at_ms, r.device, r.posture, r.conditions, + SELECT r.id, r.measured_at_ms, + r.measurement_started_at_ms, r.measurement_finished_at_ms, + r.device, r.posture, r.conditions, r.systolic_mmhg, r.diastolic_mmhg, r.captured_at_ms, - w.window_start_ms, w.window_end_ms, w.onehz_rows, w.rr_beats, + r.band_device_id, r.measurement_session_id, + w.window_start_ms, w.window_end_ms, + w.observed_start_ms, w.observed_end_ms, + w.onehz_rows, w.rr_beats, w.hr_mean, w.rr_ms_mean, w.rr_ms_min, w.rr_ms_max, w.rmssd_ms, + w.valid_hr_seconds, w.valid_interval_count, + w.valid_interval_pair_count, w.coverage_fraction, + w.rejected_interval_fraction, w.quality_status, + w.feature_version, w.snapshot_revision, w.meta_json FROM bp_research_reference r LEFT JOIN bp_research_window w ON w.reference_id = r.id @@ -1756,6 +1910,11 @@ class LocalDb { where: 'reference_id = ?', whereArgs: [id], ); + await txn.delete( + 'bp_research_snapshot', + where: 'reference_id = ?', + whereArgs: [id], + ); await txn.delete( 'bp_research_reference', where: 'id = ?', @@ -8774,6 +8933,7 @@ class LocalDb { // backup/restore round trip at all, the same omission `wipeAll` documents. 'bp_research_reference', 'bp_research_window', + 'bp_research_snapshot', 'journal', 'journal_metric', 'journal_field_def', @@ -8944,28 +9104,37 @@ class LocalDb { rethrow; } // BP RESEARCH CAPTURES MERGE BY NATURAL KEY, NOT BY SOURCE ID. The - // reference's `id` is a device-local AUTOINCREMENT and the window's - // `reference_id` names it, so the generic REPLACE-by-PK path would - // let a foreign export's id=1 eat this install's id=1 capture. Both - // tables are hand-typed and tiny (nothing writes them but the dev - // screen), so a dedicated two-query merge beats threading a special - // case through the paged loop: the reference REPLACEs on its natural - // UNIQUE (measured_at_ms, device) key, the window follows onto the - // DESTINATION id, and a capture whose incoming window is absent - // keeps the window it already had. Re-import converges. - if (t == 'bp_research_reference' || t == 'bp_research_window') { - // The reference pass builds the id map; the window pass that - // follows (references merge first) only consumes it. + // reference's `id` is a device-local AUTOINCREMENT and the window + // and snapshot rows' `reference_id` name it, so the generic + // REPLACE-by-PK path would let a foreign export's id=1 eat this + // install's id=1 capture. All three tables are hand-typed and tiny + // (nothing writes them but the dev screen), so a dedicated merge + // beats threading a special case through the paged loop: the + // reference is keyed on its natural UNIQUE (measured_at_ms, device) + // identity, the window and snapshots follow onto the DESTINATION + // id, and a capture whose incoming window is absent keeps the + // window it already had. Re-import converges. + if (t == 'bp_research_reference' || + t == 'bp_research_window' || + t == 'bp_research_snapshot') { + // The reference pass builds the id map; the window and snapshot + // passes that follow (references merge first) only consume it. try { final refCols = await destCols('bp_research_reference'); final winCols = await destCols('bp_research_window'); - final srcRefs = t == 'bp_research_reference' + final snapCols = await destCols('bp_research_snapshot'); + final srcRefs = t == 'bp_research_reference' && + await srcHasTable('bp_research_reference', src) ? await src.rawQuery('SELECT * FROM bp_research_reference') : const >[]; final srcWins = t == 'bp_research_window' && await srcHasTable('bp_research_window', src) ? await src.rawQuery('SELECT * FROM bp_research_window') : const >[]; + final srcSnaps = t == 'bp_research_snapshot' && + await srcHasTable('bp_research_snapshot', src) + ? await src.rawQuery('SELECT * FROM bp_research_snapshot') + : const >[]; await db.transaction((txn) async { for (final r in srcRefs) { final row = { @@ -8973,13 +9142,9 @@ class LocalDb { if (refCols.contains(e.key)) e.key: e.value, }; final srcId = row.remove('id'); - // KEEP the destination id on collision. `INSERT OR - // REPLACE` would delete the colliding local row and mint a - // fresh AUTOINCREMENT id — stranding the local window row - // under the old reference_id with no FK cascade to take - // it, exactly the orphan putBpResearchCapture avoids by - // deleting first. UPDATE preserves the id the window is - // about to be re-attached to. + // KEEP the destination id on collision (see the v1 fix): + // UPDATE in place preserves the id the window and + // snapshot rows are about to be re-attached to. final device = (row['device'] as String?) ?? ''; final existing = await txn.rawQuery( 'SELECT id FROM bp_research_reference ' @@ -8990,32 +9155,45 @@ class LocalDb { if (existing.isNotEmpty) { destId = (existing.first['id'] as num).toInt(); await txn.rawUpdate( - 'UPDATE bp_research_reference SET posture = ?, ' + 'UPDATE bp_research_reference SET ' + 'measurement_started_at_ms = ?, ' + 'measurement_finished_at_ms = ?, posture = ?, ' 'conditions = ?, systolic_mmhg = ?, diastolic_mmhg = ?, ' - 'captured_at_ms = ? WHERE id = ?', + 'captured_at_ms = ?, band_device_id = ?, ' + 'measurement_session_id = ? WHERE id = ?', [ + row['measurement_started_at_ms'], + row['measurement_finished_at_ms'], row['posture'], row['conditions'], row['systolic_mmhg'], row['diastolic_mmhg'], row['captured_at_ms'], + row['band_device_id'], + row['measurement_session_id'], destId, ], ); } else { destId = await txn.rawInsert( 'INSERT INTO bp_research_reference ' - '(measured_at_ms, device, posture, conditions, ' - 'systolic_mmhg, diastolic_mmhg, captured_at_ms) ' - 'VALUES (?, ?, ?, ?, ?, ?, ?)', + '(measured_at_ms, measurement_started_at_ms, ' + 'measurement_finished_at_ms, device, posture, ' + 'conditions, systolic_mmhg, diastolic_mmhg, ' + 'captured_at_ms, band_device_id, measurement_session_id) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ row['measured_at_ms'], + row['measurement_started_at_ms'], + row['measurement_finished_at_ms'], device, row['posture'], row['conditions'], row['systolic_mmhg'], row['diastolic_mmhg'], row['captured_at_ms'], + row['band_device_id'], + row['measurement_session_id'], ], ); } @@ -9028,21 +9206,25 @@ class LocalDb { for (final e in w.entries) if (winCols.contains(e.key)) e.key: e.value, }; - final destRef = row.remove('reference_id'); - final mapped = destRef is num - ? bpIdMap[destRef.toInt()] - : null; + final mapped = bpIdMap[ + (row.remove('reference_id') as num?)?.toInt()]; if (mapped == null) continue; await txn.rawInsert( 'INSERT OR REPLACE INTO bp_research_window ' '(reference_id, window_start_ms, window_end_ms, ' - 'onehz_rows, rr_beats, hr_mean, rr_ms_mean, rr_ms_min, ' - 'rr_ms_max, rmssd_ms, meta_json) ' - 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + 'observed_start_ms, observed_end_ms, onehz_rows, ' + 'rr_beats, hr_mean, rr_ms_mean, rr_ms_min, rr_ms_max, ' + 'rmssd_ms, valid_hr_seconds, valid_interval_count, ' + 'valid_interval_pair_count, coverage_fraction, ' + 'rejected_interval_fraction, quality_status, ' + 'feature_version, snapshot_revision, meta_json) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ mapped, row['window_start_ms'], row['window_end_ms'], + row['observed_start_ms'], + row['observed_end_ms'], row['onehz_rows'], row['rr_beats'], row['hr_mean'], @@ -9050,20 +9232,52 @@ class LocalDb { row['rr_ms_min'], row['rr_ms_max'], row['rmssd_ms'], + row['valid_hr_seconds'], + row['valid_interval_count'], + row['valid_interval_pair_count'], + row['coverage_fraction'], + row['rejected_interval_fraction'], + row['quality_status'], + row['feature_version'], + row['snapshot_revision'], row['meta_json'], ], ); } + for (final sn in srcSnaps) { + final row = { + for (final e in sn.entries) + if (snapCols.contains(e.key)) e.key: e.value, + }; + final mapped = bpIdMap[ + (row.remove('reference_id') as num?)?.toInt()]; + if (mapped == null) continue; + await txn.rawInsert( + 'INSERT OR REPLACE INTO bp_research_snapshot ' + '(reference_id, revision, onehz_json, rr_json, ' + 'created_at_ms) VALUES (?, ?, ?, ?, ?)', + [ + mapped, + row['revision'], + row['onehz_json'], + row['rr_json'], + row['created_at_ms'], + ], + ); + } }); counts[t] = t == 'bp_research_reference' ? srcRefs.length - : srcWins.length; + : t == 'bp_research_window' + ? srcWins.length + : srcSnaps.length; } catch (_) { if (!tolerant) rethrow; counts[t] = 0; } continue; } + if (t == 'day_result') importedDays = {}; if (firstPage.isEmpty) { counts[t] = 0; diff --git a/lib/health/bp_research_capture.dart b/lib/health/bp_research_capture.dart index 7307c4da4..b169554b8 100644 --- a/lib/health/bp_research_capture.dart +++ b/lib/health/bp_research_capture.dart @@ -1,5 +1,5 @@ -// BP research capture — pair a cuff reading the user just took with the -// band's own decoded data from the minutes around that instant. +// BP research capture — pair a cuff reading with the band's own decoded data +// from the minutes around the MEASUREMENT instant (not the entry instant). // // EXPERIMENTAL / DEVELOPER-ONLY. This exists to build a paired dataset a // human can analyse OUTSIDE this app (CSV export); it is not a blood @@ -13,23 +13,49 @@ // // A capture with no band data at that instant is stored as a capture with a // NULL window. Missing is missing — never 0, never a fabricated average. +// +// v2 (this file's current shape) separates three instants the v1 capture +// conflated: the MEASUREMENT instant (when the cuff actually squeezed), +// the ENTRY instant (when the user typed the pair in — a retro capture can +// be entered hours later), and the WINDOW instants. A retro capture is +// paired with the historical sensor data of its measurement instant, never +// with whatever the band happens to hold at entry time. + +/// Feature-schema version of the window computation. Bumped whenever a +/// window field's MEANING changes (not its mere presence): exports carry it +/// so an analysis can tell which formula produced which column. +const int kResearchFeatureVersion = 2; + +/// Rest window BEFORE the cuff measurement starts (engineering default, +/// 5 minutes): the feature window is [measurement_start − pre, measurement_start]. +/// The cuff's own inflation must not enter the feature window unchecked — +/// ending the window at the measurement start keeps it out by construction. +/// A documented research parameter, not a validated physiological constant. +const int kResearchRestPreMs = 5 * 60 * 1000; +/// Optional window AFTER the measurement start. Default 0: the preferred +/// research design uses only the pre-measurement rest window. A non-zero +/// value may include the inflation itself, so a window whose end lies in +/// the future is stored as pending and finalized only after it has fully +/// elapsed and the band has had time to sync. +const int kResearchWindowPostMs = 0; -/// Window half-widths around the cuff instant (default ±2 min): the frozen -/// window covers [kBpResearchWindowPreMs] before the reference instant and -/// [kBpResearchWindowPostMs] after it. Lives here, next to the capture -/// logic, so the model file has no dependency direction to argue about. -const int kBpResearchWindowPreMs = 2 * 60 * 1000; -const int kBpResearchWindowPostMs = 2 * 60 * 1000; +/// Maximum gap between two successive beat intervals for them to count as +/// a CONTIGUOUS pair (engineering default, 2.5 s). RMSSD is only ever +/// computed over pairs that are genuinely adjacent in time — a difference +/// across a sensor gap is a fabrication, not a heart-rate-variability +/// sample. A documented research parameter, not a validated artifact rule. +const int kResearchMaxBeatGapMs = 2500; -/// The frozen band window around one reference instant. Every field is +/// The frozen band window around one measurement instant. Every field is /// nullable for the same reason the storage layer's columns are: a stat the -/// window could not honestly compute (no valid HR, no beats) is absent, not -/// zero. +/// window could not honestly compute is absent, not zero. class BpResearchWindow { const BpResearchWindow({ required this.windowStartMs, required this.windowEndMs, + this.observedStartMs, + this.observedEndMs, this.onehzRows, this.rrBeats, this.hrMean, @@ -37,11 +63,28 @@ class BpResearchWindow { this.rrMsMin, this.rrMsMax, this.rmssdMs, + this.validHrSeconds, + this.validIntervalCount, + this.validIntervalPairCount, + this.coverageFraction, + this.rejectedIntervalFraction, + this.qualityStatus, + required this.featureVersion, + this.snapshotRevision, this.metaJson, }); + /// The REQUESTED window bounds ([start, end] around the measurement). final int windowStartMs; final int windowEndMs; + + /// What the data actually OBSERVED inside the requested window — the + /// first and last valid row time. Distinct from the requested bounds so + /// an analysis can tell "the band was worn for the last minute of a + /// five-minute window" from "the band was worn all five minutes". + final int? observedStartMs; + final int? observedEndMs; + final int? onehzRows; final int? rrBeats; final double? hrMean; @@ -50,6 +93,36 @@ class BpResearchWindow { final double? rrMsMax; final double? rmssdMs; + // ── quality (v2) ───────────────────────────────────────────────────────── + /// 1 Hz rows with a valid HR — at 1 Hz that is seconds of valid signal. + final int? validHrSeconds; + + /// Beat intervals that survived validation (sorted, deduplicated, + /// finite, positive). Intervals the analysis may legitimately use. + final int? validIntervalCount; + + /// SUCCESSIVE interval pairs that are also CONTIGUOUS in time (gap ≤ + /// [kResearchMaxBeatGapMs]). The only pairs RMSSD is computed over. + final int? validIntervalPairCount; + + /// valid_hr_seconds ÷ requested window seconds. NULL when the window has + /// no duration or no 1 Hz rows at all — coverage of nothing is not 0%. + final double? coverageFraction; + + /// Share of successive interval pairs REJECTED as non-contiguous (gap in + /// the beat series). NULL when there are no pairs to reject. + final double? rejectedIntervalFraction; + + /// 'pending' | 'ok' | 'gappy' | 'no_data' — see [researchWindowFrom]. + final String? qualityStatus; + + /// Which feature schema computed these stats (see [kResearchFeatureVersion]). + final int featureVersion; + + /// Which immutable snapshot revision the raw rows are frozen in (1, 2, …). + /// Re-processing a capture writes a NEW revision and keeps the old one. + final int? snapshotRevision; + /// Provenance the analysis needs and nothing else: device_id, firmware /// string if known, sample counts by table. JSON, written verbatim. final String? metaJson; @@ -63,19 +136,52 @@ class BpResearchCapture { required this.diastolicMmHg, required this.capturedAtMs, required this.device, + this.measurementStartedAtMs, + this.measurementFinishedAtMs, this.posture, this.conditions, + this.bandDeviceId, + this.measurementSessionId, this.window, }); + /// Nominal measurement instant — the v1 identity of the capture and still + /// the idempotency key together with [device]. For v2 captures this is + /// the measurement START when the user supplied a real instant. final int measuredAtMs; + + /// When the cuff actually STARTED squeezing. NULL on v1 rows (their + /// measuredAtMs doubles as both) — absent stays absent, it is not + /// backfilled with measuredAtMs. + final int? measurementStartedAtMs; + + /// When the cuff finished. Optional: many cuffs report one instant only. + /// If only a single measurement instant is known, the pair fields above + /// carry that instant and this stays NULL — no invented duration. + final int? measurementFinishedAtMs; + + /// When the pair was TYPED IN. A retro capture entered hours later has + /// this far after its measurement instants. + final int capturedAtMs; + final double systolicMmHg; final double diastolicMmHg; - final int capturedAtMs; /// The cuff's own name ('OMRON', 'Withings BPM', …). NULL when the user - /// typed a bare pair of numbers with no device named. + /// typed a bare pair of numbers with no device named. The CUFF device — + /// never conflated with [bandDeviceId]. final String? device; + + /// The band whose decoded data the window froze (the app's primary device + /// id at capture time). Kept beside the capture so a future second band + /// or a device swap can never silently mix signal origins. + final String? bandDeviceId; + + /// Free-form session label for grouping multiple cuff readings of one + /// sitting — they are NOT independent physiological states, and an + /// analysis must be able to tell them apart from readings hours apart. + final String? measurementSessionId; + final String? posture; final String? conditions; @@ -89,93 +195,206 @@ class BpResearchCapture { const (double, double) kResearchSystolicBounds = (50, 300); const (double, double) kResearchDiastolicBounds = (20, 200); -/// Compute the frozen band window around [measuredAtMs] from the decoded -/// store, pure and testable without a database (pass the rows in). +/// Compute the frozen band window around the MEASUREMENT instant +/// ([measuredAtMs]) from already-decoded rows, pure and testable without a +/// database (pass the rows in). +/// +/// Window: [measurement_start − preMs, measurement_start + postMs] — the +/// default design is the 5-minute rest window BEFORE the measurement +/// ([kResearchRestPreMs], [kResearchWindowPostMs] = 0), so the cuff's own +/// inflation stays out of the feature window by construction. /// -/// Window: measured instant minus/plus [preMs]/[postMs] (default ±2 min, -/// [kBpResearchWindowPreMs]/[kBpResearchWindowPostMs]). Reads ONLY already-decoded -/// tables — `decoded_onehz` (HR) and `decoded_rr` (beat intervals). No raw -/// archive, no re-decode, nothing derived: the point is to freeze exactly -/// what the app already holds at the moment of the cuff reading. +/// Reads ONLY what the caller passes — `decoded_onehz` (HR) and +/// `decoded_rr` (beat intervals) rows. No raw archive, no re-decode, +/// nothing derived: the point is to freeze exactly what the app already +/// holds for the measurement instant, whenever in the past that was. +/// +/// Quality rules (all documented engineering parameters, none claimed as +/// validated artifact thresholds): +/// · onehz rows are sorted and deduplicated by `rec_ts`; +/// · an HR row is valid when its `hr` is a finite positive integer — +/// absent validity is absent, not false, and an invalid row never +/// enters the mean (it must not drag an average toward zero); +/// · intervals are sorted and deduplicated by `rr_ts_ms`; non-finite, +/// zero, or negative values are rejected outright; +/// · an interval PAIR is valid only when the two intervals are +/// contiguous in time (gap ≤ [kResearchMaxBeatGapMs]) — RMSSD is +/// computed over those pairs and ONLY those pairs, never across a +/// sensor gap; +/// · [nowMs] decides pending: a window whose end lies in the future is +/// 'pending' and must be finalized once it has elapsed. BpResearchWindow? researchWindowFrom({ required int measuredAtMs, required List> onehzRows, required List> rrRows, int? preMs, int? postMs, + int? maxGapMs, + int? nowMs, String? deviceId, String? metaJson, }) { - final pre = preMs ?? kBpResearchWindowPreMs; - final post = postMs ?? kBpResearchWindowPostMs; + final pre = preMs ?? kResearchRestPreMs; + final post = postMs ?? kResearchWindowPostMs; + final gap = maxGapMs ?? kResearchMaxBeatGapMs; final start = measuredAtMs - pre; final end = measuredAtMs + post; // decoded_onehz.rec_ts is epoch SECONDS; rr is rr_ts_ms (epoch ms). + // Filter, then SORT (epoch bases differ; rows may arrive unsorted), then + // DEDUPLICATE by timestamp (first row wins — a re-decoded duplicate is + // the same second, not a new one). final onehz = onehzRows .where((r) { final ts = r['rec_ts']; - return ts is int && ts * 1000 >= start && ts * 1000 <= end; + return ts is num && ts * 1000 >= start && ts * 1000 <= end; }) - .toList(growable: false); - final rr = rrRows + .toList() + ..sort((a, b) => + ((a['rec_ts'] as num).toDouble()).compareTo((b['rec_ts'] as num).toDouble())); + final dedupedOnehz = >[]; + { + int? lastTs; + for (final r in onehz) { + final ts = (r['rec_ts'] as num).toInt(); + if (lastTs == ts) continue; + lastTs = ts; + dedupedOnehz.add(r); + } + } + final onehzDedup = dedupedOnehz; + + final rrAll = rrRows .where((r) { final ts = r['rr_ts_ms']; - return ts is int && ts >= start && ts <= end; + return ts is num && ts >= start && ts <= end; }) - .toList(growable: false); + .toList() + ..sort((a, b) => + ((a['rr_ts_ms'] as num).toDouble()).compareTo((b['rr_ts_ms'] as num).toDouble())); + final dedupedRr = >[]; + { + int? lastTs; + for (final r in rrAll) { + final ts = (r['rr_ts_ms'] as num).toInt(); + if (lastTs == ts) continue; + lastTs = ts; + dedupedRr.add(r); + } + } + final rrDedup = dedupedRr; - if (onehz.isEmpty && rr.isEmpty) return null; + if (onehzDedup.isEmpty && rrDedup.isEmpty) return null; - // HR mean over VALID HR rows only — a run of hr_valid = 0 rows must not - // drag an average toward zero, and absent validity is absent, not false. - final hrs = onehz + // Valid HR rows only — a run of hr = 0 rows must not drag the average + // toward zero, and non-finite values are rejected outright. + final validHr = onehzDedup .map((r) => r['hr']) - .whereType() - .where((h) => h > 0) - .toList(growable: false); - final hrMean = hrs.isEmpty - ? null - : hrs.reduce((a, b) => a + b) / hrs.length; - - final rrs = rr - .map((r) => r['rr_ms']) .whereType() .map((v) => v.toDouble()) + .where((h) => h.isFinite && h > 0) .toList(growable: false); + final hrMean = + validHr.isEmpty ? null : validHr.reduce((a, b) => a + b) / validHr.length; + final validHrSeconds = + validHr.isEmpty ? null : onehzDedup.length; // 1 Hz: one row is one second + + // Valid intervals: finite, positive, deduplicated. Rejected ones are + // counted so an analysis can see HOW MUCH of the beat series survived. + final validIntervals = <(int, double)>[]; // (rr_ts_ms, rr_ms) + for (final r in rrDedup) { + final v = r['rr_ms']; + if (v is num && v.isFinite && v > 0) { + validIntervals.add(((r['rr_ts_ms'] as num).toInt(), v.toDouble())); + } + } + double? rrMean, rrMin, rrMax, rmssd; - if (rrs.isNotEmpty) { - rrMean = rrs.reduce((a, b) => a + b) / rrs.length; - rrMin = rrs.reduce((a, b) => a < b ? a : b); - rrMax = rrs.reduce((a, b) => a > b ? a : b); - // RMSSD over successive differences in window order (rr_ts_ms ASC is - // the caller's contract). Too few beats to form one difference: absent. - if (rrs.length >= 2) { + var validPairs = 0; + if (validIntervals.isNotEmpty) { + final values = validIntervals.map((p) => p.$2).toList(growable: false); + rrMean = values.reduce((a, b) => a + b) / values.length; + rrMin = values.reduce((a, b) => a < b ? a : b); + rrMax = values.reduce((a, b) => a > b ? a : b); + // RMSSD over CONTIGUOUS successive pairs only: the two intervals must + // be adjacent in time (gap ≤ [gap]). A difference across a sensor gap + // is a fabrication, not an HRV sample. + if (validIntervals.length >= 2) { var sumSq = 0.0; - for (var i = 1; i < rrs.length; i++) { - final d = rrs[i] - rrs[i - 1]; + for (var i = 1; i < validIntervals.length; i++) { + if (validIntervals[i].$1 - validIntervals[i - 1].$1 > gap) continue; + final d = validIntervals[i].$2 - validIntervals[i - 1].$2; sumSq += d * d; + validPairs++; } - rmssd = _sqrt(sumSq / (rrs.length - 1)); + if (validPairs > 0) rmssd = _sqrt(sumSq / validPairs); } } + final pairTotal = validIntervals.length >= 2 ? validIntervals.length - 1 : 0; + final rejectedPairFraction = + pairTotal == 0 ? null : 1.0 - (validPairs / pairTotal); + + final windowSeconds = (end - start) / 1000.0; + final coverage = + validHrSeconds == null || windowSeconds <= 0 ? null : validHrSeconds / windowSeconds; + + // Quality status — an honest verdict, not a fabricated confidence number. + // pending — the window extends into the future; finalize later. + // no_data — nothing valid survived in either series. + // gappy — over half the pairs were rejected across gaps, or under + // half the window has valid HR: usable, flag it. + // ok — otherwise. + String status; + if (nowMs != null && end > nowMs) { + status = 'pending'; + } else if (validHr.isEmpty && validIntervals.isEmpty) { + status = 'no_data'; + } else if ((rejectedPairFraction != null && rejectedPairFraction > 0.5) || + (coverage != null && coverage < 0.5)) { + status = 'gappy'; + } else { + status = 'ok'; + } + return BpResearchWindow( windowStartMs: start, windowEndMs: end, - onehzRows: onehz.isEmpty ? null : onehz.length, - rrBeats: rr.isEmpty ? null : rr.length, - hrMean: hrMean?.toDouble(), + observedStartMs: onehzDedup.isNotEmpty + ? (onehzDedup.first['rec_ts'] as num).toInt() * 1000 + : (rrDedup.isNotEmpty ? (rrDedup.first['rr_ts_ms'] as num).toInt() : null), + observedEndMs: onehzDedup.isNotEmpty + ? (onehzDedup.last['rec_ts'] as num).toInt() * 1000 + : (rrDedup.isNotEmpty ? (rrDedup.last['rr_ts_ms'] as num).toInt() : null), + onehzRows: onehzDedup.isEmpty ? null : onehzDedup.length, + rrBeats: rrDedup.isEmpty ? null : rrDedup.length, + hrMean: hrMean, rrMsMean: rrMean, rrMsMin: rrMin, rrMsMax: rrMax, rmssdMs: rmssd, + validHrSeconds: validHr.isEmpty ? null : validHr.length, + validIntervalCount: validIntervals.isEmpty ? null : validIntervals.length, + validIntervalPairCount: validPairs == 0 ? null : validPairs, + coverageFraction: coverage, + rejectedIntervalFraction: rejectedPairFraction, + qualityStatus: status, + featureVersion: kResearchFeatureVersion, metaJson: metaJson, ); } -double _sqrt(double v) => v <= 0 ? 0.0 : _sqrtNewton(v); +/// The rows a snapshot freezes, so re-processing is reproducible: the exact +/// onehz and rr rows that produced a window revision, as plain JSON. +class BpResearchSnapshotRows { + const BpResearchSnapshotRows({required this.onehzRows, required this.rrRows}); + /// The filtered, sorted, deduplicated rows the window computation saw. + final List> onehzRows; + final List> rrRows; +} + +double _sqrt(double v) => v <= 0 ? 0.0 : _sqrtNewton(v); double _sqrtNewton(double v) { var x = v; var y = (x + 1) / 2; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 10be52412..bbf7d1ab0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12482,6 +12482,22 @@ "@bpResearchCapture": { "description": "Button label to store one capture" }, + "bpResearchMeasuredAt": "Measurement time (HH:MM or YYYY-MM-DD HH:MM; empty = now)", + "@bpResearchMeasuredAt": { + "description": "Optional back-dating field: when the cuff reading was actually taken" + }, + "bpResearchMeasuredAtHint": "Back-date to the actual cuff reading \u2014 the band window is frozen around THAT instant, not around typing it in.", + "@bpResearchMeasuredAtHint": { + "description": "Helper text under the measurement-time field" + }, + "bpResearchSessionId": "Session id (optional)", + "@bpResearchSessionId": { + "description": "Optional label grouping readings of one sitting" + }, + "bpResearchSessionIdHint": "Group readings of one sitting \u2014 they are not independent states, and an analysis must be able to tell.", + "@bpResearchSessionIdHint": { + "description": "Helper text under the session-id field" + }, "bpResearchHistory": "Captures", "@bpResearchHistory": { "description": "Section header for the stored capture list" diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart index 269acc8c4..e0b6300f5 100644 --- a/lib/ui2/profile/bp_research.dart +++ b/lib/ui2/profile/bp_research.dart @@ -1,10 +1,17 @@ // BP research capture — DEVELOPER MODE ONLY. // // One flow: take a cuff blood pressure reading, type the pair in, press -// capture. The app freezes the band's own decoded data from the ±2 minutes -// around that instant (1 Hz HR, R-R intervals) next to the reference pair, -// and keeps every capture so a human can compare them over weeks — here in -// a list, or out of the app through the `bp_research` CSV export set. +// capture. The app freezes the band's own decoded data around the +// MEASUREMENT instant — by default the 5 minutes of rest BEFORE the +// measurement, so the cuff's own inflation stays out of the feature window +// — next to the reference pair, and keeps every capture so a human can +// compare them over weeks: here in a list, or out of the app through the +// `bp_research` CSV export set. +// +// The measurement instant can be back-dated: a cuff reading taken this +// morning and typed in this evening is paired with the historical sensor +// data of the MEASUREMENT time, never with whatever the band holds at +// entry time. // // This is data COLLECTION, not a blood pressure feature: // · Nothing derived reads these tables. No score, no baseline, no chart @@ -14,11 +21,9 @@ // · Nothing here is exported to HealthKit / Health Connect. // A window with no band data is stored as a capture with an EMPTY window — // missing is missing, never zero. - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; - import '../../data/db.dart'; import '../../health/bp_research_capture.dart'; import '../../l10n/app_localizations.dart'; @@ -38,6 +43,10 @@ class _BpResearchScreenState extends State { final _device = TextEditingController(); final _posture = TextEditingController(); final _conditions = TextEditingController(); + final _sessionId = TextEditingController(); + // Optional back-dating: 'HH:MM' today or 'YYYY-MM-DD HH:MM'. Empty means + // the measurement is being taken right now. + final _measuredAt = TextEditingController(); List> _rows = const []; bool _busy = false; @@ -54,6 +63,8 @@ class _BpResearchScreenState extends State { _device.dispose(); _posture.dispose(); _conditions.dispose(); + _sessionId.dispose(); + _measuredAt.dispose(); super.dispose(); } @@ -62,6 +73,29 @@ class _BpResearchScreenState extends State { if (mounted) setState(() => _rows = rows); } + /// Parse the optional measurement-time field. Returns null when empty + /// (= now) or unparseable (the caller refuses the capture: a wrong + /// pairing instant silently pairs the reference with the wrong five + /// minutes of band data — worse than refusing). + DateTime? _parseMeasuredAt(DateTime now) { + final text = _measuredAt.text.trim(); + if (text.isEmpty) return now; + final twoPart = RegExp(r'^(\d{4}-\d{2}-\d{2})[ T](\d{1,2}):(\d{2})$'); + final m = twoPart.firstMatch(text); + if (m != null) { + final d = DateTime.tryParse('${m.group(1)} ${m.group(2)}:${m.group(3)}'); + return d; + } + final hm = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(text); + if (hm != null) { + final h = int.tryParse(hm.group(1)!); + final min = int.tryParse(hm.group(2)!); + if (h == null || min == null || h > 23 || min > 59) return null; + return DateTime(now.year, now.month, now.day, h, min); + } + return null; + } + Future _capture() async { if (_busy) return; final sys = double.tryParse(_sys.text); @@ -77,8 +111,34 @@ class _BpResearchScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(l?.bpResearchBadValue ?? - 'That pair is not a blood pressure — check the numbers and ' - 'try again. Nothing was stored.'), + 'That pair is outside the range this app supports \u2014 ' + 'check the numbers and try again. Nothing was stored.'), + )); + } + return; + } + final enteredAt = DateTime.now(); + final measuredAt = _parseMeasuredAt(enteredAt); + if (measuredAt == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text( + 'Could not read the measurement time \u2014 use HH:MM or ' + 'YYYY-MM-DD HH:MM, or leave it empty for "now". Nothing was ' + 'stored.'), + )); + } + return; + } + final measuredAtMs = measuredAt.millisecondsSinceEpoch; + final enteredAtMs = enteredAt.millisecondsSinceEpoch; + if (measuredAtMs > enteredAtMs + 60 * 1000) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text( + 'The measurement time lies in the future \u2014 the window ' + 'would pair the reference with data that does not exist yet. ' + 'Nothing was stored.'), )); } return; @@ -86,12 +146,15 @@ class _BpResearchScreenState extends State { setState(() => _busy = true); var stored = false; try { - final now = DateTime.now(); - final start = now.millisecondsSinceEpoch - kBpResearchWindowPreMs; - final end = now.millisecondsSinceEpoch + kBpResearchWindowPostMs; + // The rest window BEFORE the measurement: the feature window ends at + // the measurement start, so the cuff's own inflation stays out of it + // by construction. See lib/health/bp_research_capture.dart. + final start = measuredAtMs - kResearchRestPreMs; + final end = measuredAtMs + kResearchWindowPostMs; final db = await LocalDb.instance; - // Read exactly what the app already holds around the instant. Two - // narrow range reads — never a day dump, never the raw archive. + // Read exactly what the app already holds around the MEASUREMENT + // instant — historical rows for a back-dated capture. Two narrow + // range reads, never a day dump, never the raw archive. final onehz = await db.rawQuery( 'SELECT rec_ts, hr FROM decoded_onehz ' 'WHERE device_id = ? AND rec_ts >= ? AND rec_ts <= ? ' @@ -105,24 +168,38 @@ class _BpResearchScreenState extends State { [LocalDb.kPrimaryDeviceId, start, end], ); final window = researchWindowFrom( - measuredAtMs: now.millisecondsSinceEpoch, + measuredAtMs: measuredAtMs, onehzRows: onehz, rrRows: rr, + nowMs: enteredAtMs, + ); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: measuredAtMs, + // The measurement START is the instant the features are anchored + // to; the nominal instant stays the idempotency key. + measurementStartedAtMs: measuredAtMs, + // A single-instant cuff reading has no invented duration. + measurementFinishedAtMs: null, + systolicMmHg: sys, + diastolicMmHg: dia, + capturedAtMs: enteredAtMs, + device: _device.text.trim().isEmpty ? null : _device.text.trim(), + posture: _posture.text.trim().isEmpty ? null : _posture.text.trim(), + conditions: + _conditions.text.trim().isEmpty ? null : _conditions.text.trim(), + bandDeviceId: LocalDb.kPrimaryDeviceId, + measurementSessionId: + _sessionId.text.trim().isEmpty ? null : _sessionId.text.trim(), + window: window, + ), + snapshotOnehzRows: onehz, + snapshotRrRows: rr, ); - await LocalDb.putBpResearchCapture(BpResearchCapture( - measuredAtMs: now.millisecondsSinceEpoch, - systolicMmHg: sys, - diastolicMmHg: dia, - capturedAtMs: now.millisecondsSinceEpoch, - device: _device.text.trim().isEmpty ? null : _device.text.trim(), - posture: _posture.text.trim().isEmpty ? null : _posture.text.trim(), - conditions: - _conditions.text.trim().isEmpty ? null : _conditions.text.trim(), - window: window, - )); stored = true; _sys.clear(); _dia.clear(); + _measuredAt.clear(); await _refresh(); } catch (e) { if (mounted) { @@ -151,8 +228,8 @@ class _BpResearchScreenState extends State { Text( l?.bpResearchIntro ?? 'EXPERIMENTAL. Take a cuff reading, type the pair in, ' - 'press capture. The band data of the ±2 minutes around that ' - 'instant is frozen next to it — for you to compare outside ' + 'press capture. The band data of the minutes before that ' + 'instant is frozen next to it \u2014 for you to compare outside ' 'this app. Nothing here is a health feature, nothing here ' 'feeds any score, and nothing here is ever blended with what ' 'the band measured.', @@ -162,7 +239,9 @@ class _BpResearchScreenState extends State { TextField( controller: _sys, keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9]'))], + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')) + ], decoration: InputDecoration( labelText: l?.bpResearchSystolic ?? 'Systolic (mmHg)', ), @@ -171,12 +250,26 @@ class _BpResearchScreenState extends State { TextField( controller: _dia, keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9]'))], + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')) + ], decoration: InputDecoration( labelText: l?.bpResearchDiastolic ?? 'Diastolic (mmHg)', ), ), const SizedBox(height: S.x2), + TextField( + controller: _measuredAt, + keyboardType: TextInputType.datetime, + decoration: const InputDecoration( + labelText: + 'Measurement time (HH:MM or YYYY-MM-DD HH:MM; empty = now)', + helperText: + 'Back-date to the actual cuff reading \u2014 the band window ' + 'is frozen around THAT instant, not around typing it in.', + ), + ), + const SizedBox(height: S.x2), TextField( controller: _device, decoration: InputDecoration( @@ -184,6 +277,16 @@ class _BpResearchScreenState extends State { ), ), const SizedBox(height: S.x2), + TextField( + controller: _sessionId, + decoration: const InputDecoration( + labelText: 'Session id (optional)', + helperText: + 'Group readings of one sitting \u2014 they are not ' + 'independent states, and an analysis must be able to tell.', + ), + ), + const SizedBox(height: S.x2), TextField( controller: _posture, decoration: InputDecoration( @@ -205,14 +308,13 @@ class _BpResearchScreenState extends State { ), const SizedBox(height: S.x6), if (_rows.isNotEmpty) ...[ - Text(l?.bpResearchHistory ?? 'Captures', - style: F.head), + Text(l?.bpResearchHistory ?? 'Captures', style: F.head), const SizedBox(height: S.x2), for (final r in _rows) ListTile( dense: true, title: Text( - '${r['systolic_mmhg']}/${r['diastolic_mmhg']} mmHg — ' + '${r['systolic_mmhg']}/${r['diastolic_mmhg']} mmHg \u2014 ' '${formatDayTime(DateTime.fromMillisecondsSinceEpoch( r['measured_at_ms'] as int), l)}', ), @@ -228,9 +330,10 @@ class _BpResearchScreenState extends State { const SizedBox(height: S.x4), Text( l?.bpResearchExportHint ?? - 'Export all captures as CSV from Your data › Export CSV ' - '(set “BP research captures”). Research data — it never ' - 'leaves the phone except through that file.', + 'Export all captures as CSV from Your data \u203a Export CSV ' + '(set \u201cBP research captures\u201d). This is the dedicated ' + 'export for BP research data; a full-database backup or an ' + 'opt-in health share also contains it.', style: F.cap.copyWith(color: p.ink2, height: 1.5), ), ], @@ -247,14 +350,17 @@ class _BpResearchScreenState extends State { final beats = r['rr_beats']; final hr = r['hr_mean']; final rmssd = r['rmssd_ms']; - + final status = r['quality_status']; if (onehz == null && beats == null) { - return 'No band data in the window — stored as-is.'; + return 'No band data in the window \u2014 stored as-is.'; } final parts = []; if (hr is num) parts.add('HR ${hr.toStringAsFixed(0)} bpm'); if (rmssd is num) parts.add('RMSSD ${rmssd.toStringAsFixed(0)} ms'); parts.add('${onehz ?? 0} 1 Hz rows, ${beats ?? 0} beats'); - return parts.join(' · '); + if (status is String && status.isNotEmpty && status != 'ok') { + parts.add(status); + } + return parts.join(' \u00b7 '); } } diff --git a/test/bp_research_capture_test.dart b/test/bp_research_capture_test.dart index 0d0e10d20..119c33b7e 100644 --- a/test/bp_research_capture_test.dart +++ b/test/bp_research_capture_test.dart @@ -2,16 +2,23 @@ // // The rules under test are the ones the storage and UI lean on: // · a window with no band data is NULL, not zeroes; -// · a stat the window cannot honestly compute (no valid HR, too few -// beats for RMSSD) is absent, never zero; +// · a stat the window cannot honestly compute (no valid HR, no +// CONTIGUOUS interval pair for RMSSD) is absent, never zero; // · rows outside the window are ignored, whatever their table's epoch -// base is (decoded_onehz.rec_ts is SECONDS, decoded_rr.rr_ts_ms is ms). - +// base is (decoded_onehz.rec_ts is SECONDS, decoded_rr.rr_ts_ms is ms); +// · the v2 rest window lies BEFORE the measurement instant: the cuff's +// own inflation stays out of the feature window by construction; +// · duplicate timestamps are deduplicated, unsorted rows are sorted; +// · RMSSD never spans a sensor gap, and the gap fraction is reported; +// · a window whose end lies in the future is 'pending'; +// · requested window bounds and OBSERVED data bounds are distinct. import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/health/bp_research_capture.dart'; void main() { const at = 1700000000000; // ms + // The v2 default rest window: [at − 5 min, at]. + const preStart = at - 5 * 60 * 1000; test('no band data in the window yields a NULL window, not zeroes', () { final w = researchWindowFrom( @@ -22,18 +29,38 @@ void main() { expect(w, isNull); }); - test('rows outside the ±2 min window are ignored (seconds vs ms bases)', () { + test('the rest window lies BEFORE the measurement, inflation excluded', + () { final w = researchWindowFrom( measuredAtMs: at, onehzRows: [ - // rec_ts is epoch SECONDS. Inside the window (at/1000 ± 120). - {'rec_ts': at ~/ 1000, 'hr': 60}, - // 10 minutes before: outside, must not land in any stat. - {'rec_ts': at ~/ 1000 - 600, 'hr': 180}, + // Inside the rest window (seconds base). + {'rec_ts': preStart ~/ 1000 + 60, 'hr': 60}, + // The measurement instant itself and AFTER it: the cuff inflating. + // Outside the v2 window — must not land in any stat. + {'rec_ts': at ~/ 1000 + 1, 'hr': 180}, + ], + rrRows: const [], + ); + expect(w, isNotNull); + expect(w!.windowStartMs, preStart); + expect(w.windowEndMs, at); + expect(w.onehzRows, 1); + expect(w.hrMean, 60.0); + }); + + test('rows outside the window are ignored (seconds vs ms bases)', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + // rec_ts is epoch SECONDS. Inside the rest window. + {'rec_ts': preStart ~/ 1000 + 10, 'hr': 60}, + // 10 minutes before the window: outside, must not land in any stat. + {'rec_ts': preStart ~/ 1000 - 600, 'hr': 180}, ], rrRows: [ // rr_ts_ms is epoch MS. Inside. - {'rr_ts_ms': at, 'rr_ms': 1000}, + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, ], ); expect(w, isNotNull); @@ -43,18 +70,42 @@ void main() { expect(w.rmssdMs, isNull); // one beat forms no successive difference }); + test('unsorted rows are sorted; duplicate timestamps deduplicated', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + {'rec_ts': preStart ~/ 1000 + 30, 'hr': 64}, + // Same second twice — one row, not two. + {'rec_ts': preStart ~/ 1000 + 10, 'hr': 56}, + {'rec_ts': preStart ~/ 1000 + 10, 'hr': 56}, + ], + rrRows: [ + {'rr_ts_ms': preStart + 3000, 'rr_ms': 900}, + // Same instant twice — one interval, not two. + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, + ], + ); + expect(w!.onehzRows, 2); + expect(w.rrBeats, 2); + expect(w.hrMean, 60.0); // (56 + 64) / 2 + }); + test('invalid HR rows do not drag the mean toward zero', () { final w = researchWindowFrom( measuredAtMs: at, onehzRows: [ - {'rec_ts': at ~/ 1000, 'hr': 0}, - {'rec_ts': at ~/ 1000 - 1, 'hr': 58}, - {'rec_ts': at ~/ 1000 - 2, 'hr': 62}, + {'rec_ts': preStart ~/ 1000, 'hr': 0}, + {'rec_ts': preStart ~/ 1000 + 1, 'hr': 58}, + {'rec_ts': preStart ~/ 1000 + 2, 'hr': 62}, + // Non-finite junk: rejected outright. + {'rec_ts': preStart ~/ 1000 + 3, 'hr': -5}, ], rrRows: const [], ); - expect(w!.onehzRows, 3); - expect(w.hrMean, 60.0); // 0 excluded as invalid, not averaged in + expect(w!.onehzRows, 4); + expect(w.validHrSeconds, 2); + expect(w.hrMean, 60.0); // 0 and −5 excluded, not averaged in }); test('RMSSD over successive differences, min/max preserved', () { @@ -62,16 +113,117 @@ void main() { measuredAtMs: at, onehzRows: const [], rrRows: [ - {'rr_ts_ms': at - 3000, 'rr_ms': 1000}, - {'rr_ts_ms': at - 2000, 'rr_ms': 1100}, - {'rr_ts_ms': at - 1000, 'rr_ms': 900}, + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, + {'rr_ts_ms': preStart + 2000, 'rr_ms': 1100}, + {'rr_ts_ms': preStart + 3000, 'rr_ms': 900}, ], ); expect(w!.rrBeats, 3); expect(w.rrMsMin, 900.0); expect(w.rrMsMax, 1100.0); - // diffs: +100, -200 → sqrt((100² + 200²)/2) = sqrt(25000) = 158.11… + // diffs: +100, −200 → sqrt((100² + 200²)/2) = sqrt(25000) = 158.11… expect(w.rmssdMs!, closeTo(158.11, 0.01)); + expect(w.validIntervalCount, 3); + expect(w.validIntervalPairCount, 2); expect(w.hrMean, isNull); // no 1 Hz rows: absent, not zero }); + + test('RMSSD never spans a sensor gap; the gap fraction is reported', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + // A contiguous pair before the gap. + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, + {'rr_ts_ms': preStart + 2000, 'rr_ms': 1100}, + // THE GAP: two minutes of nothing. The pair across it must not + // enter RMSSD — a difference across a sensor gap is a fabricated + // HRV sample, not a real one. + {'rr_ts_ms': preStart + 140000, 'rr_ms': 800}, + // A contiguous pair after the gap. + {'rr_ts_ms': preStart + 141000, 'rr_ms': 850}, + ], + ); + expect(w!.rrBeats, 4); + expect(w.validIntervalCount, 4); + // Two of three successive pairs are contiguous; one spans the gap. + expect(w.validIntervalPairCount, 2); + expect(w.rejectedIntervalFraction, closeTo(1 / 3, 0.001)); + // RMSSD over the two REAL pairs: diffs +100, −50 → sqrt((10000+2500)/2). + expect(w.rmssdMs!, closeTo(_sqrtRef(12500 / 2), 0.01)); + // One rejected pair of three is under the >50% gap threshold, and no + // 1 Hz rows means coverage is NULL (absent) rather than low — so the + // honest verdict is 'ok', with the gap fraction carried alongside. + expect(w.qualityStatus, 'ok'); + }); + + test('a window whose end lies in the future is pending', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + {'rec_ts': preStart ~/ 1000, 'hr': 60}, + ], + rrRows: const [], + nowMs: at - 60000, // "now" is a minute before the measurement + ); + expect(w!.qualityStatus, 'pending'); + }); + + test('a well-covered window is ok; coverage is honest', () { + // 300 valid seconds of a 300-second window = full coverage. + final onehz = [ + for (var i = 0; i < 300; i++) + {'rec_ts': preStart ~/ 1000 + i, 'hr': 60}, + ]; + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: onehz, + rrRows: const [], + ); + expect(w!.qualityStatus, 'ok'); + expect(w.coverageFraction, closeTo(1.0, 0.001)); + expect(w.validHrSeconds, 300); + expect(w.featureVersion, kResearchFeatureVersion); + }); + + test('requested bounds and observed bounds are distinct', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + {'rec_ts': preStart ~/ 1000 + 120, 'hr': 60}, + {'rec_ts': preStart ~/ 1000 + 180, 'hr': 62}, + ], + rrRows: const [], + ); + // Requested: the full 5 minutes. Observed: 60 s in the middle. + expect(w!.windowStartMs, preStart); + expect(w.windowEndMs, at); + expect(w.observedStartMs, preStart + 120000); + expect(w.observedEndMs, preStart + 180000); + expect(w.coverageFraction, closeTo(2 / 300, 0.001)); + }); + + test('a custom postMs window can include the measurement itself', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + {'rec_ts': at ~/ 1000 + 30, 'hr': 70}, // after the instant: inside now + ], + rrRows: const [], + postMs: 60000, + ); + expect(w!.windowEndMs, at + 60000); + expect(w.onehzRows, 1); + }); +} + +double _sqrtRef(double v) => v <= 0 ? 0 : _newton(v); +double _newton(double v) { + var x = v; + var y = (x + 1) / 2; + while ((y - x).abs() > 1e-12) { + x = y; + y = (x + v / x) / 2; + } + return y; } diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart index 5403fede5..ac0f48496 100644 --- a/test/bp_research_db_test.dart +++ b/test/bp_research_db_test.dart @@ -41,6 +41,7 @@ const _win = BpResearchWindow( rrMsMin: 800, rrMsMax: 1100, rmssdMs: 42, + featureVersion: kResearchFeatureVersion, metaJson: null, ); @@ -111,6 +112,90 @@ void main() { expect(orphaned.first['c'], 0); }); + test('a retro capture pairs with HISTORICAL rows and keeps entry time', + () async { + final db = await LocalDb.instance; + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + final measured = _at; // the cuff reading, this morning + final entered = _at + 6 * 3600 * 1000; // typed in this evening + await LocalDb.putBpResearchCapture(BpResearchCapture( + measuredAtMs: measured, + measurementStartedAtMs: measured, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: entered, // ENTRY time, not measurement + device: 'omron', + bandDeviceId: LocalDb.kPrimaryDeviceId, + measurementSessionId: 'morning', + window: _win, + )); + final r = (await LocalDb.bpResearchCaptures()).first; + expect(r['measured_at_ms'], measured); + expect(r['measurement_started_at_ms'], measured); + expect(r['measurement_finished_at_ms'], isNull); // no invented duration + expect(r['captured_at_ms'], entered); // entry vs measurement + expect(r['band_device_id'], LocalDb.kPrimaryDeviceId); + expect(r['measurement_session_id'], 'morning'); + }); + + test('a snapshot freezes the raw rows; re-processing writes a new revision', + () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + final onehz = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + {'rec_ts': (_at - 59000) ~/ 1000, 'hr': 62}, + ]; + final rr = [ + {'rr_ts_ms': _at - 60000, 'rr_ms': 1000}, + {'rr_ts_ms': _at - 59000, 'rr_ms': 1050}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'omron', + window: researchWindowFrom( + measuredAtMs: _at, onehzRows: onehz, rrRows: rr), + ), + snapshotOnehzRows: onehz, + snapshotRrRows: rr, + ); + final id = (await db.rawQuery( + 'SELECT id FROM bp_research_reference')).first['id'] as int; + final snap1 = await db.rawQuery( + 'SELECT revision, onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ?', [id]); + expect(snap1, hasLength(1)); + expect(snap1.first['revision'], 1); + // The frozen rows are the exact input: reproducible. + expect(snap1.first['onehz_json'], contains('60')); + final win = await db.rawQuery( + 'SELECT snapshot_revision, feature_version, quality_status, ' + 'valid_interval_pair_count FROM bp_research_window ' + 'WHERE reference_id = ?', [id]); + expect(win.first['snapshot_revision'], 1); + expect(win.first['feature_version'], kResearchFeatureVersion); + expect(win.first['quality_status'], isNotNull); + expect(win.first['valid_interval_pair_count'], 1); + }); + + test('delete removes the snapshot rows too', () async { + final db = await LocalDb.instance; + final id = (await db.rawQuery( + 'SELECT id FROM bp_research_reference')).first['id'] as int; + await LocalDb.deleteBpResearchCapture(id); + final left = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', [id]); + expect(left.first['c'], 0); + }); + test('the research tables ride the backup restore and salvage lists', () async { expect(LocalDb.restoreTablesForTest, contains('bp_research_reference')); diff --git a/test/bp_research_isolation_test.dart b/test/bp_research_isolation_test.dart index 0a9b7607f..bcb25bf3c 100644 --- a/test/bp_research_isolation_test.dart +++ b/test/bp_research_isolation_test.dart @@ -42,6 +42,7 @@ void main() { final s = f.readAsStringSync(); final hit = s.contains('bp_research_reference') || s.contains('bp_research_window') || + s.contains('bp_research_snapshot') || s.contains('bpResearchCaptures') || s.contains('putBpResearchCapture') || s.contains('deleteBpResearchCapture'); diff --git a/tool/bp_research_model.py b/tool/bp_research_model.py new file mode 100644 index 000000000..31fc756f9 --- /dev/null +++ b/tool/bp_research_model.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +"""BP research offline model — an EXPERIMENTAL analysis prototype. + +Reads the `bp_research` CSV export (set "BP research captures") produced by +the app and evaluates a personally calibrated HR/HRV linear model against +cuff-only baselines. Runs OUTSIDE the app runtime, on the researcher's +machine; it never touches app health data, never writes to the phone, and +its outputs are research results, not health records. + +NOT A MEDICAL DEVICE. NOT A VALIDATED BLOOD PRESSURE MEASUREMENT. +The formulas below are a research draft for reproducible data collection, +evaluated here only so the dataset's value can be judged on real captures. +No synthetic data here claims physiological validity; synthetic fixtures +are for MATH tests only. + +Usage: + python3 bp_research_model.py --csv bp_research.csv [--out report.txt] + +Model (research draft, per the PR description): + H = mean of valid HR in the window (hr_mean) + V = RMSSD over valid contiguous interval pairs (rmssd_ms) + L = ln((V + eps) / 1 ms), eps = 1e-3 ms, numerical stability only + z = [1, (H - H0) / sH, (L - L0) / sL]^T + prediction_k = theta_k^T z + +Learning levels: + A: only the personal offset (theta[0]) updates — scalar Kalman. + theta[1], theta[2] stay 0 unless a separately validated model says + otherwise. + B: full parameter vector — scalar-per-parameter Kalman with Joseph- + form covariance. Off by default; requires enough independent + feature variation (documented thresholds below) and remains + experimental. + +Evaluation discipline: + · a prediction is ALWAYS recorded before its reference updates the + model (prequential evaluation); + · references of one session id are NOT independent states — they are + aggregated (mean) before entering the model; + · back-dated references trigger a full chronological replay; + · baselines: (1) last calibration cuff value, (2) cuff-only time + model (mean), (3) the HR/HRV model — reported side by side. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import sys +from dataclasses import dataclass, field + +EPSILON_MS = 1e-3 # numerical stability only; never replaces missing data + +# Documented research defaults. NOT clinically validated. P, Q, R are the +# scalar Kalman covariances; the numbers say "a cuff reference is worth +# more than yesterday's personal offset", nothing more. +DEFAULT_R_MMHG = 25.0 # reference measurement variance (±5 mmHg SD) +DEFAULT_Q_OFFSET = 4.0 # per-day drift allowance on the personal offset +DEFAULT_P0 = 400.0 # initial offset uncertainty (±20 mmHg SD) +MIN_SLOPE_SAMPLES = 20 # level B needs at least this many aggregated refs +MIN_FEATURE_SPREAD = 0.25 # and this much normalized spread in H and L + +# Feature normalization. Documented, arbitrary-but-fixed engineering +# anchors; a change requires retraining or transforming the parameters. +H0_BPM = 60.0 +SH_BPM = 20.0 +L0 = math.log(40.0 + EPSILON_MS) # ln of a 40 ms RMSSD anchor +SL = 1.0 + + +@dataclass +class Row: + measured_at_ms: int + sys_mmhg: float + dia_mmhg: float + hr_mean: float | None + rmssd_ms: float | None + session_id: str | None + quality: str | None + coverage: float | None + + +@dataclass +class Model: + """Personal calibration state for one of systolic / diastolic.""" + theta: list[float] # [offset, h_slope, l_slope] + P: list[list[float]] # covariance, level B (diagonal-ish) + p_offset: float # scalar covariance, level A + last_cuff: float | None = None + predictions: list[dict] = field(default_factory=list) + + @staticmethod + def initial(cuff_mean: float) -> "Model": + # Calibration baseline, NOT a sensor-backed prediction: slopes + # start at zero, the offset starts at the cuff mean. + return Model( + theta=[cuff_mean, 0.0, 0.0], + P=[[DEFAULT_P0, 0, 0], [0, DEFAULT_P0, 0], [0, 0, DEFAULT_P0]], + p_offset=DEFAULT_P0, + ) + + +def features(hr: float | None, rmssd: float | None) -> list[float] | None: + """z = [1, (H-H0)/sH, (L-L0)/sL]; None when H or V is missing — + missing data never becomes a zero feature.""" + if hr is None or rmssd is None or rmssd <= 0: + return None + l = math.log(rmssd + EPSILON_MS) + return [1.0, (hr - H0_BPM) / SH_BPM, (l - L0) / SL] + + +def predict(m: Model, z: list[float]) -> float: + return sum(t * zi for t, zi in zip(m.theta, z)) + + +def update_level_a(m: Model, z: list[float], ref: float, + delta_days: float) -> None: + """Scalar Kalman on the offset only (slopes stay frozen).""" + p_minus = m.p_offset + DEFAULT_Q_OFFSET * max(delta_days, 0.0) + k = p_minus / (p_minus + DEFAULT_R_MMHG) + pred = predict(m, z) + m.theta[0] += k * (ref - pred) + m.p_offset = (1.0 - k) * p_minus + + +def update_level_b(m: Model, z: list[float], ref: float, + delta_days: float) -> None: + """Full parameter Kalman with Joseph-form covariance update.""" + n = 3 + q = DEFAULT_Q_OFFSET * max(delta_days, 0.0) + p_minus = [[m.P[i][j] + (q if i == j else 0.0) for j in range(n)] + for i in range(n)] + # innovation gain K = P z / (R + z^T P z) + pz = [sum(p_minus[i][j] * z[j] for j in range(n)) for i in range(n)] + denom = DEFAULT_R_MMHG + sum(zi * pzi for zi, pzi in zip(z, pz)) + k = [pzi / denom for pzi in pz] + pred = predict(m, z) + resid = ref - pred + m.theta = [m.theta[i] + k[i] * resid for i in range(n)] + # Joseph form: (I - K z^T) P (I - K z^T)^T + K R K^T + a = [[(1.0 if i == j else 0.0) - k[i] * z[j] for j in range(n)] + for i in range(n)] + ap = [[sum(a[i][t] * p_minus[t][j] for t in range(n)) for j in range(n)] + for i in range(n)] + apa = [[sum(ap[i][t] * a[j][t] for t in range(n)) for j in range(n)] + for i in range(n)] + for i in range(n): + for j in range(n): + m.P[i][j] = apa[i][j] + DEFAULT_R_MMHG * k[i] * k[j] + + +def load_rows(path: str) -> list[Row]: + rows: list[Row] = [] + with open(path, newline="", encoding="utf-8") as f: + for r in csv.DictReader(f): + def num(key: str) -> float | None: + v = (r.get(key) or "").strip() + if not v: + return None + try: + return float(v) + except ValueError: + return None + rows.append(Row( + measured_at_ms=int(float(r["measured_at_ms"])), + sys_mmhg=float(r["systolic_mmhg"]), + dia_mmhg=float(r["diastolic_mmhg"]), + hr_mean=num("hr_mean"), + rmssd_ms=num("rmssd_ms"), + session_id=(r.get("measurement_session_id") or "").strip() or None, + quality=(r.get("quality_status") or "").strip() or None, + coverage=num("coverage_fraction"), + )) + rows.sort(key=lambda x: x.measured_at_ms) + return rows + + +def aggregate_sessions(rows: list[Row]) -> list[Row]: + """Multiple cuff readings of one session are NOT independent + physiological states — average them into one reference before they + enter the model. Sessions are keyed by (session_id, calendar day).""" + by_key: dict[tuple, list[Row]] = {} + for r in rows: + day = r.measured_at_ms // 86400000 + key = (r.session_id, day) if r.session_id else ("_solo", day, + r.measured_at_ms) + by_key.setdefault(key, []).append(r) + out = [] + for key, group in by_key.items(): + if len(group) == 1: + out.append(group[0]) + else: + # Same instant features across the session's captures; the + # REFERENCE is the session mean. Feature fields are taken from + # the capture with the best quality/coverage. + best = max(group, key=lambda g: (g.coverage or 0.0)) + out.append(Row( + measured_at_ms=max(g.measured_at_ms for g in group), + sys_mmhg=sum(g.sys_mmhg for g in group) / len(group), + dia_mmhg=sum(g.dia_mmhg for g in group) / len(group), + hr_mean=best.hr_mean, + rmssd_ms=best.rmssd_ms, + session_id=key[0] if isinstance(key[0], str) else None, + quality=best.quality, + coverage=best.coverage, + )) + out.sort(key=lambda x: x.measured_at_ms) + return out + + +def mae(xs: list[float]) -> float: + return sum(abs(x) for x in xs) / len(xs) if xs else float("nan") + + +def signed_mean(xs: list[float]) -> float: + return sum(xs) / len(xs) if xs else float("nan") + + +def run(rows: list[Row], level_b: bool = False) -> dict: + aggregated = aggregate_sessions(rows) + if not aggregated: + return {"error": "no rows"} + + # Calibration baseline from the FIRST session's cuff values. + first_sys = aggregated[0].sys_mmhg + first_dia = aggregated[0].dia_mmhg + m_sys = Model.initial(first_sys) + m_dia = Model.initial(first_dia) + + usable = [r for r in aggregated[1:]] # prequential: predict, then update + err_calib = {"sys": [], "dia": []} + err_time = {"sys": [], "dia": []} + err_model = {"sys": [], "dia": []} + excluded = 0 + last_t = aggregated[0].measured_at_ms + + # Baseline 3: cuff-only time model — the running mean of every reference + # seen so far, features never involved. + seen_sys = [first_sys] + seen_dia = [first_dia] + + for r in usable: + z = features(r.hr_mean, r.rmssd_ms) + if z is None: + excluded += 1 + continue + delta_days = (r.measured_at_ms - last_t) / 86400000.0 + last_t = r.measured_at_ms + + # 1. prequential prediction — recorded BEFORE the update. + pred_s = predict(m_sys, z) + pred_d = predict(m_dia, z) + err_model["sys"].append(pred_s - r.sys_mmhg) + err_model["dia"].append(pred_d - r.dia_mmhg) + + # 2. baseline: last calibration cuff value (no WHOOP features). + if m_sys.last_cuff is not None: + err_calib["sys"].append(m_sys.last_cuff - r.sys_mmhg) + err_calib["dia"].append(m_dia.last_cuff - r.dia_mmhg) + + # 3. baseline: cuff-only time model (running cuff mean). + err_time["sys"].append( + sum(seen_sys) / len(seen_sys) - r.sys_mmhg) + err_time["dia"].append( + sum(seen_dia) / len(seen_dia) - r.dia_mmhg) + seen_sys.append(r.sys_mmhg) + seen_dia.append(r.dia_mmhg) + + # 4. update AFTER recording the prediction. + if level_b and len(usable) >= MIN_SLOPE_SAMPLES: + hs = [abs((r_.hr_mean or H0_BPM) - H0_BPM) / SH_BPM + for r_ in usable[:n_seen]] + if max(hs, default=0.0) >= MIN_FEATURE_SPREAD: + update_level_b(m_sys, z, r.sys_mmhg, delta_days) + update_level_b(m_dia, z, r.dia_mmhg, delta_days) + else: + update_level_a(m_sys, z, r.sys_mmhg, delta_days) + update_level_a(m_dia, z, r.dia_mmhg, delta_days) + m_sys.last_cuff = r.sys_mmhg + m_dia.last_cuff = r.dia_mmhg + + def stats(errs: list[float]) -> dict: + if not errs: + return {"n": 0} + var = sum((e - signed_mean(errs)) ** 2 for e in errs) / len(errs) + return { + "n": len(errs), + "mae_mmhg": round(mae(errs), 2), + "mean_signed_mmhg": round(signed_mean(errs), 2), + "sd_mmhg": round(math.sqrt(var), 2), + } + + return { + "rows_total": len(rows), + "rows_aggregated": len(aggregated), + "rows_excluded_no_features": excluded, + "systolic": { + "baseline_last_cuff": stats(err_calib["sys"]), + "baseline_cuff_time_model": stats(err_time["sys"]), + "hr_hrv_model": stats(err_model["sys"]), + }, + "diastolic": { + "baseline_last_cuff": stats(err_calib["dia"]), + "baseline_cuff_time_model": stats(err_time["dia"]), + "hr_hrv_model": stats(err_model["dia"]), + }, + "model_state": { + "theta_sys": [round(t, 3) for t in m_sys.theta], + "theta_dia": [round(t, 3) for t in m_dia.theta], + "feature_anchors": {"H0_bpm": H0_BPM, "sH_bpm": SH_BPM, + "L0": round(L0, 4), "sL": SL, + "epsilon_ms": EPSILON_MS}, + }, + } + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--csv", required=True, help="the bp_research CSV export") + ap.add_argument("--level-b", action="store_true", + help="enable experimental full-parameter learning " + "(level B; requires independent feature variation)") + ap.add_argument("--out", help="write the report as JSON instead of stdout") + args = ap.parse_args() + + rows = load_rows(args.csv) + report = run(rows, level_b=args.level_b) + text = json.dumps(report, indent=2) + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + f.write(text + "\n") + else: + print(text) + print( + "\nRESEARCH OUTPUT ONLY — not a medical measurement, not a " + "validated blood pressure estimate.", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tool/test_bp_research_model.py b/tool/test_bp_research_model.py new file mode 100644 index 000000000..ff9dcfde6 --- /dev/null +++ b/tool/test_bp_research_model.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Math-only tests for tool/bp_research_model.py. + +Synthetic data here verifies MATHEMATICS (Kalman recursion, feature math, +session aggregation, prequential discipline) — it claims NO physiological +validity and is never used as evidence of medical accuracy. + +Run: python3 tool/test_bp_research_model.py +""" +import math +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__))) +import bp_research_model as m + + +def approx(a, b, tol=1e-9): + assert abs(a - b) <= tol, f"{a} != {b}" + + +def test_features(): + # z = [1, (H-60)/20, (ln(V+eps) - ln(40+eps))] + z = m.features(80.0, 40.0) + approx(z[0], 1.0) + approx(z[1], 1.0) + approx(z[2], 0.0, 1e-6) + # Missing data never becomes a zero feature. + assert m.features(None, 40.0) is None + assert m.features(80.0, None) is None + assert m.features(80.0, 0.0) is None # RMSSD 0 = absent, not ln(0) + + +def test_level_a_converges(): + mdl = m.Model.initial(120.0) + z = m.features(70.0, 40.0) + # Feed the same reference repeatedly: the offset must converge to it. + for _ in range(200): + m.update_level_a(mdl, z, 130.0, delta_days=0.01) + approx(mdl.theta[0], 130.0, 0.5) + # Slopes stay frozen at zero in level A. + approx(mdl.theta[1], 0.0) + approx(mdl.theta[2], 0.0) + # The covariance shrinks: repeated references increase certainty. + assert mdl.p_offset < m.DEFAULT_P0 + + +def test_level_b_joseph(): + mdl = m.Model.initial(120.0) + z = m.features(70.0, 35.0) + p_before = [row[:] for row in mdl.P] + m.update_level_b(mdl, z, 125.0, delta_days=0.1) + # P stays symmetric positive-definite-ish under the Joseph form. + for i in range(3): + for j in range(3): + approx(mdl.P[i][j], mdl.P[j][i], 1e-12) + assert all(mdl.P[i][i] >= 0 for i in range(3)) + assert mdl.P != p_before + + +def test_prediction_is_recorded_before_update(): + # Prequential discipline: with ONE usable reference after calibration, + # the recorded prediction must equal the initial calibration baseline + # (no feature influence yet — slopes are zero at start). + rows = [m.Row(0, 120.0, 80.0, 70.0, 40.0, None, "ok", 1.0), + m.Row(86_400_000, 122.0, 82.0, 70.0, 40.0, None, "ok", 1.0)] + rep = m.run(rows) + # First usable row: prediction = theta^T z = 120 + 0 + 0 = 120. + approx(rep["systolic"]["hr_hrv_model"]["mae_mmhg"], 2.0, 0.01) + + +def test_session_aggregation(): + # Three readings of one session are NOT three independent states. + rows = [ + m.Row(1000, 120.0, 80.0, 70.0, 40.0, "s1", "ok", 1.0), + m.Row(60_000, 124.0, 84.0, 70.0, 40.0, "s1", "ok", 1.0), + m.Row(120_000, 122.0, 82.0, 70.0, 40.0, "s1", "ok", 1.0), + ] + agg = m.aggregate_sessions(rows) + assert len(agg) == 1 + approx(agg[0].sys_mmhg, 122.0) + approx(agg[0].dia_mmhg, 82.0) + + +def test_missing_features_excluded_not_zeroed(): + rows = [m.Row(0, 120.0, 80.0, 70.0, 40.0, None, "ok", 1.0), + # No band data: excluded, never treated as HR 0. + m.Row(86_400_000, 121.0, 81.0, None, None, None, "no_data", None)] + rep = m.run(rows) + assert rep["rows_excluded_no_features"] == 1 + + +def test_chronological_replay(): + # Back-dated rows: the CSV order must not matter, only measured_at_ms. + r1 = m.Row(86_400_000, 122.0, 82.0, 70.0, 40.0, None, "ok", 1.0) + r0 = m.Row(0, 120.0, 80.0, 70.0, 40.0, None, "ok", 1.0) + a = m.run([r1, r0]) + b = m.run([r0, r1]) + assert a["systolic"]["hr_hrv_model"]["mae_mmhg"] == \ + b["systolic"]["hr_hrv_model"]["mae_mmhg"] + + +if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_"): + fn() + print(f"PASS {name}") + print("ALL MATH TESTS PASSED") From 9ca924769548cdbce7a90388d50f89f64cd6dd21 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 08:41:41 +0000 Subject: [PATCH 08/22] fix(review): immutable snapshot revisions, honest time semantics, valid-only coverage, beat-identity RMSSD, causal level-B model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - putBpResearchCapture: UPDATE-in-place keeps the reference id and every historical snapshot revision; re-processing writes revision max+1 via plain INSERT (UNIQUE violation = integrity error, never a silent rewrite); reference corrections without snapshot rows touch no snapshot - merge: colliding (reference, revision) with different content is skipped, not overwritten; skipped revisions are not counted as imported - time: measurement_started_at_ms stays NULL for the minute-precision UI instant (no claimed inflation start); time_precision column + CSV export documents the 'minute' precision; strict calendar validation rejects rolled-over dates - window v3: half-open [start, end); coverage = valid_hr_seconds / requested seconds (invalid rows never count as coverage); observed bounds from VALID rows only; beats keyed by beat identity (beat_ts_ms, else (rr_ts_ms, beat_index)) — rr_ts_ms alone is rec_ts*1000 for every beat of a record; rejected_pair fraction named for what it measures; dart:math sqrt replaces the hand-rolled Newton loop - offline model: causal level-B gate (>= 20 processed refs, spread in H AND L, no future rows), fallback to level A reported; quality admission rule (pending/no_data excluded, gappy admitted); explicit session span (30 min, no implicit day aggregation); fair baselines on identical target sets with last_cuff defined from the calibration row; level A renamed adaptive_cuff_offset_baseline (slopes stay zero); documented A->B covariance hand-over; features reject NaN/Inf Co-authored-by: BucciMobile --- lib/data/csv_export.dart | 8 +- lib/data/db.dart | 161 +++++--- lib/health/bp_research_capture.dart | 331 +++++++++++------ lib/ui2/profile/bp_research.dart | 131 ++++--- test/bp_research_capture_test.dart | 204 +++++++--- test/bp_research_db_test.dart | 558 +++++++++++++++++++--------- tool/bp_research_model.py | 314 +++++++++++----- tool/test_bp_research_model.py | 148 +++++++- 8 files changed, 1310 insertions(+), 545 deletions(-) diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart index e6f31c1a8..316a09847 100644 --- a/lib/data/csv_export.dart +++ b/lib/data/csv_export.dart @@ -114,7 +114,8 @@ const kCsvExportSets = [ name: 'sleep', title: 'Sleep stages', columns: ['date', 'start_ts', 'end_ts', 'stage'], - sql: 'SELECT date, start_ts, end_ts, stage FROM v_hypnogram ' + sql: + 'SELECT date, start_ts, end_ts, stage FROM v_hypnogram ' 'ORDER BY date ASC, start_ts ASC', ), CsvExportSet( @@ -146,7 +147,8 @@ const kCsvExportSets = [ name: 'labs', title: 'Lab results', columns: ['taken_on', 'marker', 'value', 'unit', 'note'], - sql: 'SELECT taken_on, marker, value, unit, note FROM lab_result ' + sql: + 'SELECT taken_on, marker, value, unit, note FROM lab_result ' 'ORDER BY taken_on ASC, marker ASC', ), // ── everything below is data the user TYPED IN ────────────────────────────── @@ -303,6 +305,7 @@ const kCsvExportSets = [ 'diastolic_mmhg', 'band_device_id', 'measurement_session_id', + 'time_precision', 'window_start_ms', 'window_end_ms', 'observed_start_ms', @@ -334,6 +337,7 @@ const kCsvExportSets = [ r.systolic_mmhg, r.diastolic_mmhg, COALESCE(r.band_device_id, '') AS band_device_id, COALESCE(r.measurement_session_id, '') AS measurement_session_id, + COALESCE(r.time_precision, '') AS time_precision, w.window_start_ms, w.window_end_ms, w.observed_start_ms, w.observed_end_ms, w.onehz_rows, w.rr_beats, diff --git a/lib/data/db.dart b/lib/data/db.dart index d45b8fa8f..da596f509 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1657,6 +1657,13 @@ class LocalDb { 'ALTER TABLE bp_research_reference ' 'ADD COLUMN measurement_session_id TEXT'); } + // Precision of the recorded measurement instant ('minute' for the + // current UI) — the analysis must know the pairing instant is not + // second-accurate. + if (!refCols.contains('time_precision')) { + await db.execute( + 'ALTER TABLE bp_research_reference ADD COLUMN time_precision TEXT'); + } final winCols = await _columnsOf(db, 'bp_research_window'); // Requested vs OBSERVED window bounds: what the data actually covered. @@ -1768,43 +1775,66 @@ class LocalDb { // and the window of the row being replaced is deleted explicitly — // without PRAGMA foreign_keys the ON DELETE CASCADE never runs, and // a fresh id would orphan the old window. - await txn.rawDelete( - 'DELETE FROM bp_research_window WHERE reference_id IN ' - '(SELECT id FROM bp_research_reference ' - 'WHERE measured_at_ms = ? AND device = ?)', - [c.measuredAtMs, c.device ?? ''], - ); - await txn.rawDelete( - 'DELETE FROM bp_research_snapshot WHERE reference_id IN ' - '(SELECT id FROM bp_research_reference ' - 'WHERE measured_at_ms = ? AND device = ?)', - [c.measuredAtMs, c.device ?? ''], - ); - await txn.rawDelete( - 'DELETE FROM bp_research_reference ' + // A retake KEEPS the destination reference id (UPDATE in place, not + // delete + reinsert — a fresh id would strand the window row, and + // deleting the reference would destroy the snapshot history) and + // KEEPS every historical snapshot revision — the immutable-snapshot + // contract. Only the window summary row is restated, because it + // describes the CURRENT revision. NULL never equals NULL in a UNIQUE + // constraint, so the device text is normalized to '' either way. + final device = c.device ?? ''; + final existing = await txn.rawQuery( + 'SELECT id FROM bp_research_reference ' 'WHERE measured_at_ms = ? AND device = ?', - [c.measuredAtMs, c.device ?? ''], - ); - final id = await txn.rawInsert( - 'INSERT INTO bp_research_reference ' - '(measured_at_ms, measurement_started_at_ms, ' - 'measurement_finished_at_ms, device, posture, conditions, ' - 'systolic_mmhg, diastolic_mmhg, captured_at_ms, band_device_id, ' - 'measurement_session_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - c.measuredAtMs, - c.measurementStartedAtMs, - c.measurementFinishedAtMs, - c.device ?? '', - c.posture, - c.conditions, - c.systolicMmHg, - c.diastolicMmHg, - c.capturedAtMs, - c.bandDeviceId, - c.measurementSessionId, - ], + [c.measuredAtMs, device], ); + final int id; + if (existing.isNotEmpty) { + id = (existing.first['id'] as num).toInt(); + await txn.rawUpdate( + 'UPDATE bp_research_reference SET ' + 'measurement_started_at_ms = ?, measurement_finished_at_ms = ?, ' + 'posture = ?, conditions = ?, systolic_mmhg = ?, ' + 'diastolic_mmhg = ?, captured_at_ms = ?, band_device_id = ?, ' + 'measurement_session_id = ?, time_precision = ? WHERE id = ?', + [ + c.measurementStartedAtMs, + c.measurementFinishedAtMs, + c.posture, + c.conditions, + c.systolicMmHg, + c.diastolicMmHg, + c.capturedAtMs, + c.bandDeviceId, + c.measurementSessionId, + c.timePrecision, + id, + ], + ); + } else { + id = await txn.rawInsert( + 'INSERT INTO bp_research_reference ' + '(measured_at_ms, measurement_started_at_ms, ' + 'measurement_finished_at_ms, device, posture, conditions, ' + 'systolic_mmhg, diastolic_mmhg, captured_at_ms, band_device_id, ' + 'measurement_session_id, time_precision) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + c.measuredAtMs, + c.measurementStartedAtMs, + c.measurementFinishedAtMs, + device, + c.posture, + c.conditions, + c.systolicMmHg, + c.diastolicMmHg, + c.capturedAtMs, + c.bandDeviceId, + c.measurementSessionId, + c.timePrecision, + ], + ); + } // A capture with no band data stores NO window row — the LEFT JOIN in // [bpResearchCaptures] renders it as an empty window, and `NOT NULL` // on the window bounds is what keeps a half-written window out of the @@ -1848,13 +1878,21 @@ class LocalDb { w.metaJson, ], ); - // The immutable snapshot: revision 1 for a fresh capture, n+1 when a - // re-processed capture carries an explicit revision. Rows frozen as - // JSON exactly as the window computation saw them. + // The immutable snapshot: the next free revision (max + 1), so a + // re-processed capture writes a NEW revision and every older revision + // survives. Plain INSERT — the UNIQUE (reference_id, revision) key + // makes an overwrite of an existing revision a database-integrity + // error instead of a silent history rewrite. Rows frozen as JSON + // exactly as the window computation saw them. if (snapshotOnehzRows != null || snapshotRrRows != null) { - final rev = w.snapshotRevision ?? 1; + final maxRev = Sqflite.firstIntValue(await txn.rawQuery( + 'SELECT MAX(revision) FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + )); + final rev = (maxRev ?? 0) + 1; await txn.rawInsert( - 'INSERT OR REPLACE INTO bp_research_snapshot ' + 'INSERT INTO bp_research_snapshot ' '(reference_id, revision, onehz_json, rr_json, created_at_ms) ' 'VALUES (?, ?, ?, ?, ?)', [ @@ -1884,6 +1922,7 @@ class LocalDb { r.device, r.posture, r.conditions, r.systolic_mmhg, r.diastolic_mmhg, r.captured_at_ms, r.band_device_id, r.measurement_session_id, + r.time_precision, w.window_start_ms, w.window_end_ms, w.observed_start_ms, w.observed_end_ms, w.onehz_rows, w.rr_beats, @@ -9045,6 +9084,10 @@ class LocalDb { // window rows of a foreign export name their reference by the SOURCE // database's AUTOINCREMENT id, which is meaningless here. final bpIdMap = {}; + // Snapshot revisions skipped on import because the destination holds a + // DIFFERENT snapshot under the same (reference, revision) key — + // immutable history is never overwritten, the source file keeps them. + var skippedSnapshots = 0; // DISTINCT DAYS ACTUALLY WRITTEN — the number the caller reports as // "N days imported". // @@ -9160,7 +9203,8 @@ class LocalDb { 'measurement_finished_at_ms = ?, posture = ?, ' 'conditions = ?, systolic_mmhg = ?, diastolic_mmhg = ?, ' 'captured_at_ms = ?, band_device_id = ?, ' - 'measurement_session_id = ? WHERE id = ?', + 'measurement_session_id = ?, time_precision = ? ' + 'WHERE id = ?', [ row['measurement_started_at_ms'], row['measurement_finished_at_ms'], @@ -9171,6 +9215,7 @@ class LocalDb { row['captured_at_ms'], row['band_device_id'], row['measurement_session_id'], + row['time_precision'], destId, ], ); @@ -9180,8 +9225,9 @@ class LocalDb { '(measured_at_ms, measurement_started_at_ms, ' 'measurement_finished_at_ms, device, posture, ' 'conditions, systolic_mmhg, diastolic_mmhg, ' - 'captured_at_ms, band_device_id, measurement_session_id) ' - 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + 'captured_at_ms, band_device_id, ' + 'measurement_session_id, time_precision) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [ row['measured_at_ms'], row['measurement_started_at_ms'], @@ -9194,6 +9240,7 @@ class LocalDb { row['captured_at_ms'], row['band_device_id'], row['measurement_session_id'], + row['time_precision'], ], ); } @@ -9252,13 +9299,32 @@ class LocalDb { final mapped = bpIdMap[ (row.remove('reference_id') as num?)?.toInt()]; if (mapped == null) continue; + // Snapshots are IMMUTABLE: a colliding (reference, revision) + // key with DIFFERENT content is skipped, not overwritten — + // the destination history cannot be rewritten by an import, + // and the source revision stays available in the source + // file. A byte-identical collision is a no-op (idempotent + // re-import). + final rev = (row['revision'] as num?)?.toInt(); + if (rev == null) continue; + final clash = await txn.rawQuery( + 'SELECT onehz_json, rr_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = ?', + [mapped, rev], + ); + if (clash.isNotEmpty) { + final same = clash.first['onehz_json'] == row['onehz_json'] && + clash.first['rr_json'] == row['rr_json']; + if (!same) skippedSnapshots++; + continue; + } await txn.rawInsert( - 'INSERT OR REPLACE INTO bp_research_snapshot ' + 'INSERT INTO bp_research_snapshot ' '(reference_id, revision, onehz_json, rr_json, ' 'created_at_ms) VALUES (?, ?, ?, ?, ?)', [ mapped, - row['revision'], + rev, row['onehz_json'], row['rr_json'], row['created_at_ms'], @@ -9270,7 +9336,10 @@ class LocalDb { ? srcRefs.length : t == 'bp_research_window' ? srcWins.length - : srcSnaps.length; + // Snapshots whose (reference, revision) key collided + // with DIFFERENT content were skipped, not imported — + // the count must not claim them. + : srcSnaps.length - skippedSnapshots; } catch (_) { if (!tolerant) rethrow; counts[t] = 0; diff --git a/lib/health/bp_research_capture.dart b/lib/health/bp_research_capture.dart index b169554b8..40f0e944c 100644 --- a/lib/health/bp_research_capture.dart +++ b/lib/health/bp_research_capture.dart @@ -15,29 +15,48 @@ // NULL window. Missing is missing — never 0, never a fabricated average. // // v2 (this file's current shape) separates three instants the v1 capture -// conflated: the MEASUREMENT instant (when the cuff actually squeezed), -// the ENTRY instant (when the user typed the pair in — a retro capture can -// be entered hours later), and the WINDOW instants. A retro capture is -// paired with the historical sensor data of its measurement instant, never -// with whatever the band happens to hold at entry time. +// conflated: the MEASUREMENT instant (when the cuff actually squeezed), the +// ENTRY instant (when the user typed the pair in — a retro capture can be +// entered hours later), and the WINDOW instants. A retro capture is paired +// with the historical sensor data of its measurement instant, never with +// whatever the band happens to hold at entry time. + +import 'dart:math' show sqrt; + +// +// TIME SEMANTICS (honest by construction): +// · The UI records only a MINUTE-precision instant — either the time the +// reading was taken (back-dated) or the entry moment (field empty). The +// entry moment is a usable pairing anchor ONLY when the user records +// the reading right away; it is stored as the measured instant with +// time_precision = 'minute' and is never claimed to be the exact +// inflation start. No invented start/finish instants are fabricated. +// · The window is the rest window BEFORE the measurement instant and the +// product exposes no post-measurement window at all (Option 1 of the +// pending-window review): a window that would reach into the future +// cannot be produced by the UI, so 'pending' remains an internal, +// data-level state only. /// Feature-schema version of the window computation. Bumped whenever a /// window field's MEANING changes (not its mere presence): exports carry it /// so an analysis can tell which formula produced which column. -const int kResearchFeatureVersion = 2; - -/// Rest window BEFORE the cuff measurement starts (engineering default, -/// 5 minutes): the feature window is [measurement_start − pre, measurement_start]. -/// The cuff's own inflation must not enter the feature window unchecked — -/// ending the window at the measurement start keeps it out by construction. +/// v3: coverage counts VALID HR seconds only (v2 counted raw deduplicated +/// rows); RR beats are keyed by their true beat identity (beat_ts_ms when +/// present, else (rr_ts_ms, beat_index)) instead of being deduplicated by +/// the whole-second rr_ts_ms; the window is half-open [start, end). +const int kResearchFeatureVersion = 3; + +/// Rest window BEFORE the cuff measurement instant (engineering default, +/// 5 minutes): the feature window is +/// [measurement_instant − pre, measurement_instant). The cuff's own +/// inflation must not enter the feature window unchecked — ending the +/// window at the measurement instant keeps it out by construction. /// A documented research parameter, not a validated physiological constant. const int kResearchRestPreMs = 5 * 60 * 1000; -/// Optional window AFTER the measurement start. Default 0: the preferred -/// research design uses only the pre-measurement rest window. A non-zero -/// value may include the inflation itself, so a window whose end lies in -/// the future is stored as pending and finalized only after it has fully -/// elapsed and the band has had time to sync. +/// The product exposes ONLY the pre-measurement rest window; there is no +/// post-measurement window and no UI path that could produce one. Kept as a +/// named constant so the design decision stays visible at the call sites. const int kResearchWindowPostMs = 0; /// Maximum gap between two successive beat intervals for them to count as @@ -74,43 +93,58 @@ class BpResearchWindow { this.metaJson, }); - /// The REQUESTED window bounds ([start, end] around the measurement). + /// The REQUESTED window bounds — half-open [start, end) around the + /// measurement instant. Half-open so a 5-minute window at 1 Hz holds at + /// most exactly 300 seconds and coverage can never exceed 1.0 by + /// counting both endpoints of a closed interval. final int windowStartMs; final int windowEndMs; /// What the data actually OBSERVED inside the requested window — the - /// first and last valid row time. Distinct from the requested bounds so - /// an analysis can tell "the band was worn for the last minute of a - /// five-minute window" from "the band was worn all five minutes". + /// first and last VALID row time (a row with no usable value does not + /// extend the observed signal; raw coverage is reported separately via + /// [onehzRows]/[rrBeats], which count all in-window rows). Distinct from + /// the requested bounds so an analysis can tell "the band was worn for + /// the last minute of a five-minute window" from "the band was worn all + /// five minutes". final int? observedStartMs; final int? observedEndMs; + /// All in-window 1 Hz rows (raw, deduplicated by rec_ts) — raw coverage. final int? onehzRows; + + /// All in-window beat rows (raw, deduplicated by beat identity) — raw + /// beat coverage. final int? rrBeats; + final double? hrMean; final double? rrMsMean; final double? rrMsMin; final double? rrMsMax; final double? rmssdMs; - // ── quality (v2) ───────────────────────────────────────────────────────── + // ── quality (v2) ────────────────────────────────────────────────────── /// 1 Hz rows with a valid HR — at 1 Hz that is seconds of valid signal. final int? validHrSeconds; - /// Beat intervals that survived validation (sorted, deduplicated, - /// finite, positive). Intervals the analysis may legitimately use. + /// Beat intervals that survived validation (finite, positive, sorted, + /// deduplicated by beat identity). Intervals the analysis may use. final int? validIntervalCount; - /// SUCCESSIVE interval pairs that are also CONTIGUOUS in time (gap ≤ - /// [kResearchMaxBeatGapMs]). The only pairs RMSSD is computed over. + /// SUCCESSIVE interval pairs that are also CONTIGUOUS in time. The only + /// pairs RMSSD is computed over. final int? validIntervalPairCount; - /// valid_hr_seconds ÷ requested window seconds. NULL when the window has - /// no duration or no 1 Hz rows at all — coverage of nothing is not 0%. + /// valid_hr_seconds ÷ requested window seconds (half-open window). + /// NULL when the window has no duration or no valid HR row at all — + /// coverage of nothing is not 0%. final double? coverageFraction; - /// Share of successive interval pairs REJECTED as non-contiguous (gap in - /// the beat series). NULL when there are no pairs to reject. + /// Share of successive VALID interval pairs REJECTED as non-contiguous + /// (gap in the beat series). Named for what it measures: a PAIR-rejection + /// rate, not an interval-exclusion rate (the latter is visible via + /// [rrBeats] vs [validIntervalCount]). NULL when there are no successive + /// pairs to reject. final double? rejectedIntervalFraction; /// 'pending' | 'ok' | 'gappy' | 'no_data' — see [researchWindowFrom]. @@ -138,6 +172,7 @@ class BpResearchCapture { required this.device, this.measurementStartedAtMs, this.measurementFinishedAtMs, + this.timePrecision, this.posture, this.conditions, this.bandDeviceId, @@ -146,22 +181,30 @@ class BpResearchCapture { }); /// Nominal measurement instant — the v1 identity of the capture and still - /// the idempotency key together with [device]. For v2 captures this is - /// the measurement START when the user supplied a real instant. + /// the idempotency key together with [device]. This is the instant the + /// user supplied (minute precision) — the time the cuff reading was TAKEN + /// for a back-dated capture, or the ENTRY moment when the field was left + /// empty (the reading was taken "just now"; the pairing anchor is the + /// entry moment, never claimed to be the exact inflation start). final int measuredAtMs; - /// When the cuff actually STARTED squeezing. NULL on v1 rows (their - /// measuredAtMs doubles as both) — absent stays absent, it is not - /// backfilled with measuredAtMs. + /// When the cuff actually STARTED squeezing. Kept for data that has a + /// real start instant; the current UI records a single minute-precision + /// instant and leaves this NULL — absent stays absent. final int? measurementStartedAtMs; /// When the cuff finished. Optional: many cuffs report one instant only. - /// If only a single measurement instant is known, the pair fields above - /// carry that instant and this stays NULL — no invented duration. + /// If only a single measurement instant is known, this stays NULL — no + /// invented duration. final int? measurementFinishedAtMs; + /// 'minute' — the precision of [measuredAtMs] as recorded by the current + /// UI. Documented so an analysis knows the pairing instant is not + /// second-accurate; a future finer-grained UI would record 'second'. + final String? timePrecision; + /// When the pair was TYPED IN. A retro capture entered hours later has - /// this far after its measurement instants. + /// this far after its measurement instant. final int capturedAtMs; final double systolicMmHg; @@ -195,14 +238,39 @@ class BpResearchCapture { const (double, double) kResearchSystolicBounds = (50, 300); const (double, double) kResearchDiastolicBounds = (20, 200); +/// The row key that identifies ONE beat: the measured sub-second instant +/// when the decoder provides it (`beat_ts_ms`), otherwise the whole-second +/// record time plus the beat's index within that record. rr_ts_ms alone is +/// rec_ts*1000 for EVERY beat of a record, so keying by it would collapse +/// all beats of a second into one and corrupt every RMSSD. +int _beatKey(Map r) { + final beatTs = r['beat_ts_ms']; + if (beatTs is num && beatTs > 0) return beatTs.toInt(); + final ts = r['rr_ts_ms']; + final idx = r['beat_index']; + return ((ts is num ? ts.toInt() : 0) << 8) | (idx is num ? idx.toInt() : 0); +} + +/// The beat's position on the time axis for continuity checks: the measured +/// instant when the decoder provides it, otherwise the whole-second record +/// time (a documented heuristic — beats of one second then share a time, +/// and pairs of those are still treated as contiguous, which can only +/// under-reject, never fabricate differences). +int _beatTimeMs(Map r) { + final beatTs = r['beat_ts_ms']; + if (beatTs is num && beatTs > 0) return beatTs.toInt(); + final ts = r['rr_ts_ms']; + return ts is num ? ts.toInt() : 0; +} + /// Compute the frozen band window around the MEASUREMENT instant /// ([measuredAtMs]) from already-decoded rows, pure and testable without a /// database (pass the rows in). /// -/// Window: [measurement_start − preMs, measurement_start + postMs] — the -/// default design is the 5-minute rest window BEFORE the measurement -/// ([kResearchRestPreMs], [kResearchWindowPostMs] = 0), so the cuff's own -/// inflation stays out of the feature window by construction. +/// Window: [measurement_instant − preMs, measurement_instant + postMs) — +/// half-open. The default design is the 5-minute rest window BEFORE the +/// measurement ([kResearchRestPreMs], [kResearchWindowPostMs] = 0), so the +/// cuff's own inflation stays out of the feature window by construction. /// /// Reads ONLY what the caller passes — `decoded_onehz` (HR) and /// `decoded_rr` (beat intervals) rows. No raw archive, no re-decode, @@ -211,18 +279,25 @@ const (double, double) kResearchDiastolicBounds = (20, 200); /// /// Quality rules (all documented engineering parameters, none claimed as /// validated artifact thresholds): -/// · onehz rows are sorted and deduplicated by `rec_ts`; -/// · an HR row is valid when its `hr` is a finite positive integer — +/// · onehz rows are filtered to the half-open window, sorted, and +/// deduplicated by `rec_ts`; +/// · an HR row is valid when its `hr` is a finite positive number — /// absent validity is absent, not false, and an invalid row never -/// enters the mean (it must not drag an average toward zero); -/// · intervals are sorted and deduplicated by `rr_ts_ms`; non-finite, -/// zero, or negative values are rejected outright; +/// enters the mean NOR the coverage (a run of hr = 0 off-skin rows +/// must not read as a worn band); +/// · beat rows are keyed by beat identity ([_beatKey]: beat_ts_ms when +/// present, else (rr_ts_ms, beat_index)) — NEVER by rr_ts_ms alone, +/// which is identical for every beat of a record; +/// · non-finite, zero, or negative interval values are rejected; /// · an interval PAIR is valid only when the two intervals are -/// contiguous in time (gap ≤ [kResearchMaxBeatGapMs]) — RMSSD is -/// computed over those pairs and ONLY those pairs, never across a -/// sensor gap; +/// successive valid beats whose beat times are contiguous +/// (gap ≤ [kResearchMaxBeatGapMs]) — RMSSD is computed over those +/// pairs and ONLY those pairs, never across a sensor gap; /// · [nowMs] decides pending: a window whose end lies in the future is -/// 'pending' and must be finalized once it has elapsed. +/// 'pending' and must be finalized once it has elapsed. The UI never +/// produces one (Option 1: pre-measurement window only, future +/// measurement instants are refused); the state exists so data-level +/// callers cannot silently mislabel such a window as final. BpResearchWindow? researchWindowFrom({ required int measuredAtMs, required List> onehzRows, @@ -241,71 +316,73 @@ BpResearchWindow? researchWindowFrom({ final end = measuredAtMs + post; // decoded_onehz.rec_ts is epoch SECONDS; rr is rr_ts_ms (epoch ms). - // Filter, then SORT (epoch bases differ; rows may arrive unsorted), then - // DEDUPLICATE by timestamp (first row wins — a re-decoded duplicate is - // the same second, not a new one). - final onehz = onehzRows - .where((r) { + // Filter to the HALF-OPEN window [start, end), then SORT, then + // DEDUPLICATE by rec_ts (first row wins — a re-decoded duplicate is the + // same second, not a new one). + final onehz = + onehzRows.where((r) { final ts = r['rec_ts']; - return ts is num && ts * 1000 >= start && ts * 1000 <= end; - }) - .toList() - ..sort((a, b) => - ((a['rec_ts'] as num).toDouble()).compareTo((b['rec_ts'] as num).toDouble())); - final dedupedOnehz = >[]; + return ts is num && ts * 1000 >= start && ts * 1000 < end; + }).toList()..sort( + (a, b) => ((a['rec_ts'] as num).toDouble()).compareTo( + (b['rec_ts'] as num).toDouble(), + ), + ); + final onehzDedup = >[]; { int? lastTs; for (final r in onehz) { final ts = (r['rec_ts'] as num).toInt(); if (lastTs == ts) continue; lastTs = ts; - dedupedOnehz.add(r); + onehzDedup.add(r); } } - final onehzDedup = dedupedOnehz; - final rrAll = rrRows - .where((r) { - final ts = r['rr_ts_ms']; - return ts is num && ts >= start && ts <= end; - }) - .toList() - ..sort((a, b) => - ((a['rr_ts_ms'] as num).toDouble()).compareTo((b['rr_ts_ms'] as num).toDouble())); - final dedupedRr = >[]; + final rrAll = rrRows.where((r) { + final ts = r['rr_ts_ms']; + return ts is num && ts >= start && ts < end; + }).toList()..sort((a, b) => _beatTimeMs(a).compareTo(_beatTimeMs(b))); + // Dedup by BEAT IDENTITY, not by rr_ts_ms — beats of one record differ + // in beat_index and, when the decoder provides it, beat_ts_ms. + final rrDedup = >[]; { - int? lastTs; + int? lastKey; for (final r in rrAll) { - final ts = (r['rr_ts_ms'] as num).toInt(); - if (lastTs == ts) continue; - lastTs = ts; - dedupedRr.add(r); + final key = _beatKey(r); + if (lastKey == key) continue; + lastKey = key; + rrDedup.add(r); } } - final rrDedup = dedupedRr; if (onehzDedup.isEmpty && rrDedup.isEmpty) return null; // Valid HR rows only — a run of hr = 0 rows must not drag the average - // toward zero, and non-finite values are rejected outright. - final validHr = onehzDedup - .map((r) => r['hr']) - .whereType() - .map((v) => v.toDouble()) - .where((h) => h.isFinite && h > 0) + // toward zero AND must not count as observed signal (coverage). + final validHrRows = onehzDedup + .where((r) { + final h = r['hr']; + return h is num && h.isFinite && h > 0; + }) .toList(growable: false); - final hrMean = - validHr.isEmpty ? null : validHr.reduce((a, b) => a + b) / validHr.length; - final validHrSeconds = - validHr.isEmpty ? null : onehzDedup.length; // 1 Hz: one row is one second - - // Valid intervals: finite, positive, deduplicated. Rejected ones are - // counted so an analysis can see HOW MUCH of the beat series survived. - final validIntervals = <(int, double)>[]; // (rr_ts_ms, rr_ms) + final hrMean = validHrRows.isEmpty + ? null + : validHrRows + .map((r) => (r['hr'] as num).toDouble()) + .reduce((a, b) => a + b) / + validHrRows.length; + // 1 Hz: one valid row is one second of valid signal. This is the number + // coverage is computed from — valid seconds, not raw rows. + final validHrSeconds = validHrRows.isEmpty ? null : validHrRows.length; + + // Valid intervals: finite, positive, beat-keyed. Raw vs valid counts are + // kept apart so an analysis can see how much of the beat series survived. + final validIntervals = <(int, double)>[]; // (beat_time_ms, rr_ms) for (final r in rrDedup) { final v = r['rr_ms']; if (v is num && v.isFinite && v > 0) { - validIntervals.add(((r['rr_ts_ms'] as num).toInt(), v.toDouble())); + validIntervals.add((_beatTimeMs(r), v.toDouble())); } } @@ -316,9 +393,9 @@ BpResearchWindow? researchWindowFrom({ rrMean = values.reduce((a, b) => a + b) / values.length; rrMin = values.reduce((a, b) => a < b ? a : b); rrMax = values.reduce((a, b) => a > b ? a : b); - // RMSSD over CONTIGUOUS successive pairs only: the two intervals must - // be adjacent in time (gap ≤ [gap]). A difference across a sensor gap - // is a fabrication, not an HRV sample. + // RMSSD over CONTIGUOUS successive pairs only: the two beats must be + // adjacent in time (gap ≤ [gap]). A difference across a sensor gap is a + // fabrication, not an HRV sample. if (validIntervals.length >= 2) { var sumSq = 0.0; for (var i = 1; i < validIntervals.length; i++) { @@ -327,28 +404,33 @@ BpResearchWindow? researchWindowFrom({ sumSq += d * d; validPairs++; } - if (validPairs > 0) rmssd = _sqrt(sumSq / validPairs); + if (validPairs > 0) rmssd = sqrt(sumSq / validPairs); } } final pairTotal = validIntervals.length >= 2 ? validIntervals.length - 1 : 0; - final rejectedPairFraction = - pairTotal == 0 ? null : 1.0 - (validPairs / pairTotal); + final rejectedPairFraction = pairTotal == 0 + ? null + : 1.0 - (validPairs / pairTotal); final windowSeconds = (end - start) / 1000.0; - final coverage = - validHrSeconds == null || windowSeconds <= 0 ? null : validHrSeconds / windowSeconds; + final coverage = validHrSeconds == null || windowSeconds <= 0 + ? null + : validHrSeconds / windowSeconds; // Quality status — an honest verdict, not a fabricated confidence number. - // pending — the window extends into the future; finalize later. + // pending — the window extends into the future; finalize later. Not + // producible from the UI (future instants are refused); a + // data-level caller that still passes one gets an honest + // label instead of a silently "final" window. // no_data — nothing valid survived in either series. - // gappy — over half the pairs were rejected across gaps, or under - // half the window has valid HR: usable, flag it. + // gappy — over half the successive pairs were rejected across gaps, + // or under half the window has valid HR: usable, flag it. // ok — otherwise. String status; if (nowMs != null && end > nowMs) { status = 'pending'; - } else if (validHr.isEmpty && validIntervals.isEmpty) { + } else if (validHrRows.isEmpty && validIntervals.isEmpty) { status = 'no_data'; } else if ((rejectedPairFraction != null && rejectedPairFraction > 0.5) || (coverage != null && coverage < 0.5)) { @@ -357,15 +439,29 @@ BpResearchWindow? researchWindowFrom({ status = 'ok'; } + int? observedStart; + int? observedEnd; + final o1 = validHrRows.isNotEmpty + ? (validHrRows.first['rec_ts'] as num).toInt() * 1000 + : null; + final o2 = validIntervals.isNotEmpty ? validIntervals.first.$1 : null; + final e1 = validHrRows.isNotEmpty + ? (validHrRows.last['rec_ts'] as num).toInt() * 1000 + : null; + final e2 = validIntervals.isNotEmpty ? validIntervals.last.$1 : null; + if (o1 != null && o2 != null) { + observedStart = o1 < o2 ? o1 : o2; + observedEnd = (e1 ?? o1) > (e2 ?? o2) ? (e1 ?? o1) : (e2 ?? o2); + } else { + observedStart = o1 ?? o2; + observedEnd = e1 ?? e2; + } + return BpResearchWindow( windowStartMs: start, windowEndMs: end, - observedStartMs: onehzDedup.isNotEmpty - ? (onehzDedup.first['rec_ts'] as num).toInt() * 1000 - : (rrDedup.isNotEmpty ? (rrDedup.first['rr_ts_ms'] as num).toInt() : null), - observedEndMs: onehzDedup.isNotEmpty - ? (onehzDedup.last['rec_ts'] as num).toInt() * 1000 - : (rrDedup.isNotEmpty ? (rrDedup.last['rr_ts_ms'] as num).toInt() : null), + observedStartMs: observedStart, + observedEndMs: observedEnd, onehzRows: onehzDedup.isEmpty ? null : onehzDedup.length, rrBeats: rrDedup.isEmpty ? null : rrDedup.length, hrMean: hrMean, @@ -373,7 +469,7 @@ BpResearchWindow? researchWindowFrom({ rrMsMin: rrMin, rrMsMax: rrMax, rmssdMs: rmssd, - validHrSeconds: validHr.isEmpty ? null : validHr.length, + validHrSeconds: validHrSeconds, validIntervalCount: validIntervals.isEmpty ? null : validIntervals.length, validIntervalPairCount: validPairs == 0 ? null : validPairs, coverageFraction: coverage, @@ -393,14 +489,3 @@ class BpResearchSnapshotRows { final List> onehzRows; final List> rrRows; } - -double _sqrt(double v) => v <= 0 ? 0.0 : _sqrtNewton(v); -double _sqrtNewton(double v) { - var x = v; - var y = (x + 1) / 2; - while ((y - x).abs() > 1e-12) { - x = y; - y = (x + v / x) / 2; - } - return y; -} diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart index e0b6300f5..1c19a8161 100644 --- a/lib/ui2/profile/bp_research.dart +++ b/lib/ui2/profile/bp_research.dart @@ -73,18 +73,39 @@ class _BpResearchScreenState extends State { if (mounted) setState(() => _rows = rows); } - /// Parse the optional measurement-time field. Returns null when empty - /// (= now) or unparseable (the caller refuses the capture: a wrong + /// Parse the optional measurement-time field (MINUTE precision — the + /// recorded instant is stored with time_precision = 'minute' and is never + /// claimed to be second-accurate). Returns null when empty (= the entry + /// moment anchors the pairing, valid only because the reading was taken + /// just now) or unparseable (the caller refuses the capture: a wrong /// pairing instant silently pairs the reference with the wrong five /// minutes of band data — worse than refusing). + /// + /// STRICT calendar validation: Dart's DateTime rolls overflowing dates + /// over (2024-02-31 becomes March 2), so every component is re-checked + /// against the parsed result — an invalid date is REJECTED, never + /// silently reinterpreted as a different day. DateTime? _parseMeasuredAt(DateTime now) { final text = _measuredAt.text.trim(); if (text.isEmpty) return now; final twoPart = RegExp(r'^(\d{4}-\d{2}-\d{2})[ T](\d{1,2}):(\d{2})$'); final m = twoPart.firstMatch(text); if (m != null) { - final d = DateTime.tryParse('${m.group(1)} ${m.group(2)}:${m.group(3)}'); - return d; + final y = int.tryParse(m.group(1)!.substring(0, 4)); + final mo = int.tryParse(m.group(1)!.substring(5, 7)); + final d = int.tryParse(m.group(1)!.substring(8, 10)); + final h = int.tryParse(m.group(2)!); + final min = int.tryParse(m.group(3)!); + if (y == null || mo == null || d == null || h == null || min == null) { + return null; + } + if (mo < 1 || mo > 12 || d < 1 || h > 23 || min > 59) return null; + final parsed = DateTime(y, mo, d, h, min); + // Overflow check: a rolled-over date no longer matches its parts. + if (parsed.year != y || parsed.month != mo || parsed.day != d) { + return null; + } + return parsed; } final hm = RegExp(r'^(\d{1,2}):(\d{2})$').firstMatch(text); if (hm != null) { @@ -109,11 +130,15 @@ class _BpResearchScreenState extends State { dia > kResearchDiastolicBounds.$2 || dia >= sys) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(l?.bpResearchBadValue ?? - 'That pair is outside the range this app supports \u2014 ' - 'check the numbers and try again. Nothing was stored.'), - )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l?.bpResearchBadValue ?? + 'That pair is outside the range this app supports \u2014 ' + 'check the numbers and try again. Nothing was stored.', + ), + ), + ); } return; } @@ -121,12 +146,15 @@ class _BpResearchScreenState extends State { final measuredAt = _parseMeasuredAt(enteredAt); if (measuredAt == null) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text( + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( 'Could not read the measurement time \u2014 use HH:MM or ' 'YYYY-MM-DD HH:MM, or leave it empty for "now". Nothing was ' - 'stored.'), - )); + 'stored.', + ), + ), + ); } return; } @@ -134,12 +162,15 @@ class _BpResearchScreenState extends State { final enteredAtMs = enteredAt.millisecondsSinceEpoch; if (measuredAtMs > enteredAtMs + 60 * 1000) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text( + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( 'The measurement time lies in the future \u2014 the window ' 'would pair the reference with data that does not exist yet. ' - 'Nothing was stored.'), - )); + 'Nothing was stored.', + ), + ), + ); } return; } @@ -176,21 +207,25 @@ class _BpResearchScreenState extends State { await LocalDb.putBpResearchCapture( BpResearchCapture( measuredAtMs: measuredAtMs, - // The measurement START is the instant the features are anchored - // to; the nominal instant stays the idempotency key. - measurementStartedAtMs: measuredAtMs, - // A single-instant cuff reading has no invented duration. + // A single MINUTE-precision instant is all the UI records. It is + // the pairing anchor, NOT a claimed inflation start: leaving + // measurement_started_at_ms NULL keeps the "unknown start" + // honest instead of dressing the typed instant up as one. + measurementStartedAtMs: null, measurementFinishedAtMs: null, + timePrecision: 'minute', systolicMmHg: sys, diastolicMmHg: dia, capturedAtMs: enteredAtMs, device: _device.text.trim().isEmpty ? null : _device.text.trim(), posture: _posture.text.trim().isEmpty ? null : _posture.text.trim(), - conditions: - _conditions.text.trim().isEmpty ? null : _conditions.text.trim(), + conditions: _conditions.text.trim().isEmpty + ? null + : _conditions.text.trim(), bandDeviceId: LocalDb.kPrimaryDeviceId, - measurementSessionId: - _sessionId.text.trim().isEmpty ? null : _sessionId.text.trim(), + measurementSessionId: _sessionId.text.trim().isEmpty + ? null + : _sessionId.text.trim(), window: window, ), snapshotOnehzRows: onehz, @@ -203,11 +238,15 @@ class _BpResearchScreenState extends State { await _refresh(); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(stored - ? 'Capture saved, but refreshing the history failed. ($e)' - : 'Capture failed \u2014 nothing was stored. ($e)'), - )); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + stored + ? 'Capture saved, but refreshing the history failed. ($e)' + : 'Capture failed \u2014 nothing was stored. ($e)', + ), + ), + ); } } finally { if (mounted) setState(() => _busy = false); @@ -219,20 +258,18 @@ class _BpResearchScreenState extends State { final l = AppLocalizations.of(c); final p = P.of(c); return Scaffold( - appBar: AppBar( - title: Text(l?.bpResearchTitle ?? 'BP research capture'), - ), + appBar: AppBar(title: Text(l?.bpResearchTitle ?? 'BP research capture')), body: ListView( padding: const EdgeInsets.all(S.x4), children: [ Text( l?.bpResearchIntro ?? 'EXPERIMENTAL. Take a cuff reading, type the pair in, ' - 'press capture. The band data of the minutes before that ' - 'instant is frozen next to it \u2014 for you to compare outside ' - 'this app. Nothing here is a health feature, nothing here ' - 'feeds any score, and nothing here is ever blended with what ' - 'the band measured.', + 'press capture. The band data of the minutes before that ' + 'instant is frozen next to it \u2014 for you to compare outside ' + 'this app. Nothing here is a health feature, nothing here ' + 'feeds any score, and nothing here is ever blended with what ' + 'the band measured.', style: F.cap.copyWith(color: p.ink2, height: 1.5), ), const SizedBox(height: S.x4), @@ -240,7 +277,7 @@ class _BpResearchScreenState extends State { controller: _sys, keyboardType: TextInputType.number, inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9]')) + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), ], decoration: InputDecoration( labelText: l?.bpResearchSystolic ?? 'Systolic (mmHg)', @@ -251,7 +288,7 @@ class _BpResearchScreenState extends State { controller: _dia, keyboardType: TextInputType.number, inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9]')) + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), ], decoration: InputDecoration( labelText: l?.bpResearchDiastolic ?? 'Diastolic (mmHg)', @@ -265,8 +302,9 @@ class _BpResearchScreenState extends State { labelText: 'Measurement time (HH:MM or YYYY-MM-DD HH:MM; empty = now)', helperText: - 'Back-date to the actual cuff reading \u2014 the band window ' - 'is frozen around THAT instant, not around typing it in.', + 'Back-date to the actual cuff reading \u2014 the band ' + 'window is frozen around THAT instant, not around typing ' + 'it in. Minute precision; empty = taken just now.', ), ), const SizedBox(height: S.x2), @@ -315,8 +353,7 @@ class _BpResearchScreenState extends State { dense: true, title: Text( '${r['systolic_mmhg']}/${r['diastolic_mmhg']} mmHg \u2014 ' - '${formatDayTime(DateTime.fromMillisecondsSinceEpoch( - r['measured_at_ms'] as int), l)}', + '${formatDayTime(DateTime.fromMillisecondsSinceEpoch(r['measured_at_ms'] as int), l)}', ), subtitle: Text(_windowSummary(r)), trailing: IconButton( @@ -331,9 +368,9 @@ class _BpResearchScreenState extends State { Text( l?.bpResearchExportHint ?? 'Export all captures as CSV from Your data \u203a Export CSV ' - '(set \u201cBP research captures\u201d). This is the dedicated ' - 'export for BP research data; a full-database backup or an ' - 'opt-in health share also contains it.', + '(set \u201cBP research captures\u201d). This is the dedicated ' + 'export for BP research data; a full-database backup or an ' + 'opt-in health share also contains it.', style: F.cap.copyWith(color: p.ink2, height: 1.5), ), ], diff --git a/test/bp_research_capture_test.dart b/test/bp_research_capture_test.dart index 119c33b7e..56192d25f 100644 --- a/test/bp_research_capture_test.dart +++ b/test/bp_research_capture_test.dart @@ -6,18 +6,30 @@ // CONTIGUOUS interval pair for RMSSD) is absent, never zero; // · rows outside the window are ignored, whatever their table's epoch // base is (decoded_onehz.rec_ts is SECONDS, decoded_rr.rr_ts_ms is ms); -// · the v2 rest window lies BEFORE the measurement instant: the cuff's -// own inflation stays out of the feature window by construction; +// · the rest window lies BEFORE the measurement instant and is +// HALF-OPEN [start, end): the cuff's own inflation stays out of the +// feature window by construction, and coverage can never exceed 1.0 +// by counting both endpoints; // · duplicate timestamps are deduplicated, unsorted rows are sorted; -// · RMSSD never spans a sensor gap, and the gap fraction is reported; -// · a window whose end lies in the future is 'pending'; -// · requested window bounds and OBSERVED data bounds are distinct. +// · beats are keyed by BEAT IDENTITY (beat_ts_ms, else +// (rr_ts_ms, beat_index)) — never by the whole-second rr_ts_ms, which +// is identical for every beat of one record; +// · coverage counts VALID HR seconds only — a run of hr = 0 rows must +// not read as a worn band; +// · RMSSD never spans a sensor gap, and the rejected-PAIR fraction is +// reported under its honest name; +// · a window whose end lies in the future is 'pending' (an internal +// data-level state — the UI cannot produce one); +// · requested window bounds and OBSERVED data bounds are distinct, and +// observed bounds are built from VALID rows only. +import 'dart:math' as math; + import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/health/bp_research_capture.dart'; void main() { const at = 1700000000000; // ms - // The v2 default rest window: [at − 5 min, at]. + // The default rest window: [at − 5 min, at). const preStart = at - 5 * 60 * 1000; test('no band data in the window yields a NULL window, not zeroes', () { @@ -29,16 +41,16 @@ void main() { expect(w, isNull); }); - test('the rest window lies BEFORE the measurement, inflation excluded', - () { + test('the rest window lies BEFORE the measurement, inflation excluded', () { final w = researchWindowFrom( measuredAtMs: at, onehzRows: [ // Inside the rest window (seconds base). {'rec_ts': preStart ~/ 1000 + 60, 'hr': 60}, - // The measurement instant itself and AFTER it: the cuff inflating. - // Outside the v2 window — must not land in any stat. - {'rec_ts': at ~/ 1000 + 1, 'hr': 180}, + // The measurement instant itself (the half-open end) and AFTER it: + // the cuff inflating. Outside the window — must not land in any stat. + {'rec_ts': at ~/ 1000, 'hr': 180}, + {'rec_ts': at ~/ 1000 + 1, 'hr': 190}, ], rrRows: const [], ); @@ -91,6 +103,56 @@ void main() { expect(w.hrMean, 60.0); // (56 + 64) / 2 }); + test('beats of one record are NOT duplicates (beat identity)', () { + // decoded_rr keys beats by (rec_ts, beat_index); rr_ts_ms alone is + // rec_ts*1000 for EVERY beat of the record. Deduplicating by rr_ts_ms + // would drop real beats and corrupt RMSSD. + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + // One record at rec_ts, four beats — same rr_ts_ms, different + // beat_index. + {'rr_ts_ms': preStart + 1000, 'beat_index': 0, 'rr_ms': 1000}, + {'rr_ts_ms': preStart + 1000, 'beat_index': 1, 'rr_ms': 1100}, + {'rr_ts_ms': preStart + 1000, 'beat_index': 2, 'rr_ms': 900}, + {'rr_ts_ms': preStart + 1000, 'beat_index': 3, 'rr_ms': 1050}, + ], + ); + expect(w!.rrBeats, 4); // all four beats survive + expect(w.validIntervalCount, 4); + // All three successive pairs are usable (whole-second heuristic: the + // beats of one second share a time, so the pairs count as contiguous). + expect(w.validIntervalPairCount, 3); + expect(w.rmssdMs, isNotNull); + }); + + test('beat_ts_ms is the beat identity when the decoder provides it', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + // Same (rr_ts_ms, beat_index) twice but DIFFERENT measured beat + // instants — two real beats, not a duplicate. + { + 'rr_ts_ms': preStart + 1000, + 'beat_index': 0, + 'beat_ts_ms': preStart + 1000, + 'rr_ms': 1000, + }, + { + 'rr_ts_ms': preStart + 1000, + 'beat_index': 0, + 'beat_ts_ms': preStart + 2000, + 'rr_ms': 1100, + }, + ], + ); + expect(w!.rrBeats, 2); + // 1000 ms apart: contiguous, one RMSSD pair. + expect(w.validIntervalPairCount, 1); + }); + test('invalid HR rows do not drag the mean toward zero', () { final w = researchWindowFrom( measuredAtMs: at, @@ -103,11 +165,45 @@ void main() { ], rrRows: const [], ); - expect(w!.onehzRows, 4); - expect(w.validHrSeconds, 2); + expect(w!.onehzRows, 4); // raw rows + expect(w.validHrSeconds, 2); // valid seconds expect(w.hrMean, 60.0); // 0 and −5 excluded, not averaged in }); + test('coverage counts VALID seconds only; off-skin zeroes do not cover', () { + // 300 raw rows, 200 of them hr = 0 (band off skin): coverage must be + // 1/3, not 1.0 — an off-skin window must not escape the gappy verdict. + final onehz = [ + for (var i = 0; i < 300; i++) + {'rec_ts': preStart ~/ 1000 + i, 'hr': i < 200 ? 0 : 60}, + ]; + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: onehz, + rrRows: const [], + ); + expect(w!.onehzRows, 300); + expect(w.validHrSeconds, 100); + expect(w.coverageFraction, closeTo(1 / 3, 0.001)); + expect(w.qualityStatus, 'gappy'); + }); + + test('observed bounds come from VALID rows, not raw rows', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: [ + // Invalid rows at the edges must not extend the observed signal. + {'rec_ts': preStart ~/ 1000, 'hr': 0}, + {'rec_ts': preStart ~/ 1000 + 120, 'hr': 60}, + {'rec_ts': preStart ~/ 1000 + 180, 'hr': 62}, + {'rec_ts': preStart ~/ 1000 + 299, 'hr': 0}, + ], + rrRows: const [], + ); + expect(w!.observedStartMs, preStart + 120000); + expect(w.observedEndMs, preStart + 180000); + }); + test('RMSSD over successive differences, min/max preserved', () { final w = researchWindowFrom( measuredAtMs: at, @@ -128,36 +224,44 @@ void main() { expect(w.hrMean, isNull); // no 1 Hz rows: absent, not zero }); - test('RMSSD never spans a sensor gap; the gap fraction is reported', () { - final w = researchWindowFrom( - measuredAtMs: at, - onehzRows: const [], - rrRows: [ - // A contiguous pair before the gap. - {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, - {'rr_ts_ms': preStart + 2000, 'rr_ms': 1100}, - // THE GAP: two minutes of nothing. The pair across it must not - // enter RMSSD — a difference across a sensor gap is a fabricated - // HRV sample, not a real one. - {'rr_ts_ms': preStart + 140000, 'rr_ms': 800}, - // A contiguous pair after the gap. - {'rr_ts_ms': preStart + 141000, 'rr_ms': 850}, - ], - ); - expect(w!.rrBeats, 4); - expect(w.validIntervalCount, 4); - // Two of three successive pairs are contiguous; one spans the gap. - expect(w.validIntervalPairCount, 2); - expect(w.rejectedIntervalFraction, closeTo(1 / 3, 0.001)); - // RMSSD over the two REAL pairs: diffs +100, −50 → sqrt((10000+2500)/2). - expect(w.rmssdMs!, closeTo(_sqrtRef(12500 / 2), 0.01)); - // One rejected pair of three is under the >50% gap threshold, and no - // 1 Hz rows means coverage is NULL (absent) rather than low — so the - // honest verdict is 'ok', with the gap fraction carried alongside. - expect(w.qualityStatus, 'ok'); - }); + test( + 'RMSSD never spans a sensor gap; the pair-rejection fraction reports', + () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + // A contiguous pair before the gap. + {'rr_ts_ms': preStart + 1000, 'rr_ms': 1000}, + {'rr_ts_ms': preStart + 2000, 'rr_ms': 1100}, + // THE GAP: two minutes of nothing. The pair across it must not + // enter RMSSD — a difference across a sensor gap is a fabricated + // HRV sample, not a real one. + {'rr_ts_ms': preStart + 140000, 'rr_ms': 800}, + // A contiguous pair after the gap. + {'rr_ts_ms': preStart + 141000, 'rr_ms': 850}, + ], + ); + expect(w!.rrBeats, 4); + expect(w.validIntervalCount, 4); + // Two of three successive pairs are contiguous; one spans the gap. + expect(w.validIntervalPairCount, 2); + // The metric is named for what it measures: the share of successive + // PAIRS rejected — not an interval-exclusion rate. + expect(w.rejectedIntervalFraction, closeTo(1 / 3, 0.001)); + // RMSSD over the two REAL pairs: diffs +100, −50 → sqrt((10000+2500)/2). + expect(w.rmssdMs!, closeTo(math.sqrt(12500 / 2), 0.01)); + // One rejected pair of three is under the >50% threshold, and no 1 Hz + // rows means coverage is NULL (absent) rather than low — so the honest + // verdict is 'ok', with the rejected-pair fraction carried alongside. + expect(w.qualityStatus, 'ok'); + }, + ); test('a window whose end lies in the future is pending', () { + // An internal, data-level state: the UI refuses future instants, so + // it can never produce one — this pins the honest labelling for any + // caller that still passes such a window. final w = researchWindowFrom( measuredAtMs: at, onehzRows: [ @@ -170,10 +274,10 @@ void main() { }); test('a well-covered window is ok; coverage is honest', () { - // 300 valid seconds of a 300-second window = full coverage. + // 300 valid seconds of a half-open 300-second window = coverage 1.0 + // exactly — never more, because the end instant itself is excluded. final onehz = [ - for (var i = 0; i < 300; i++) - {'rec_ts': preStart ~/ 1000 + i, 'hr': 60}, + for (var i = 0; i < 300; i++) {'rec_ts': preStart ~/ 1000 + i, 'hr': 60}, ]; final w = researchWindowFrom( measuredAtMs: at, @@ -182,6 +286,7 @@ void main() { ); expect(w!.qualityStatus, 'ok'); expect(w.coverageFraction, closeTo(1.0, 0.001)); + expect(w.coverageFraction!, lessThanOrEqualTo(1.0)); expect(w.validHrSeconds, 300); expect(w.featureVersion, kResearchFeatureVersion); }); @@ -216,14 +321,3 @@ void main() { expect(w.onehzRows, 1); }); } - -double _sqrtRef(double v) => v <= 0 ? 0 : _newton(v); -double _newton(double v) { - var x = v; - var y = (x + 1) / 2; - while ((y - x).abs() > 1e-12) { - x = y; - y = (x + v / x) / 2; - } - return y; -} diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart index ac0f48496..c9b98cf5c 100644 --- a/test/bp_research_db_test.dart +++ b/test/bp_research_db_test.dart @@ -7,6 +7,8 @@ // · a retake that now finds band data replaces the old window instead // of orphaning it under the replaced reference's old id. // Runs the REAL LocalDb over sqflite_common_ffi. +import 'dart:convert' show jsonEncode; + import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/health/bp_research_capture.dart'; @@ -19,17 +21,16 @@ BpResearchCapture _capture( int measuredAtMs, { String? device, BpResearchWindow? window, -}) => - BpResearchCapture( - measuredAtMs: measuredAtMs, - device: device, - posture: 'sitting', - conditions: 'rest', - systolicMmHg: 120, - diastolicMmHg: 80, - capturedAtMs: measuredAtMs, - window: window, - ); +}) => BpResearchCapture( + measuredAtMs: measuredAtMs, + device: device, + posture: 'sitting', + conditions: 'rest', + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: measuredAtMs, + window: window, +); const _win = BpResearchWindow( windowStartMs: _at - 120000, @@ -59,15 +60,17 @@ void main() { await db.close(); }); - test('a retake with no device replaces the reference, not duplicates it', - () async { - await LocalDb.putBpResearchCapture(_capture(_at)); - await LocalDb.putBpResearchCapture(_capture(_at, window: _win)); - final rows = await LocalDb.bpResearchCaptures(); - expect(rows, hasLength(1)); - expect(rows.first['device'], ''); - expect(rows.first['hr_mean'], 62.5); - }); + test( + 'a retake with no device replaces the reference, not duplicates it', + () async { + await LocalDb.putBpResearchCapture(_capture(_at)); + await LocalDb.putBpResearchCapture(_capture(_at, window: _win)); + final rows = await LocalDb.bpResearchCaptures(); + expect(rows, hasLength(1)); + expect(rows.first['device'], ''); + expect(rows.first['hr_mean'], 62.5); + }, + ); test('a named device stays distinct from the no-device row', () async { await LocalDb.putBpResearchCapture(_capture(_at, device: 'omron')); @@ -76,82 +79,150 @@ void main() { expect(rows.where((r) => r['device'] == 'omron'), hasLength(1)); }); - test('delete removes the window row too (the SQL cascade is inert)', - () async { - final db = await LocalDb.instance; - final before = - await db.rawQuery('SELECT COUNT(*) c FROM bp_research_window'); - expect(before.first['c'], greaterThan(0)); - final refs = await db - .rawQuery('SELECT id FROM bp_research_reference WHERE device = ?', - ['omron']); - await LocalDb.deleteBpResearchCapture(refs.first['id'] as int); - final orphaned = await db.rawQuery( - 'SELECT COUNT(*) c FROM bp_research_window ' - 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', - ); - expect(orphaned.first['c'], 0); - }); + test( + 'delete removes the window row too (the SQL cascade is inert)', + () async { + final db = await LocalDb.instance; + final before = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window', + ); + expect(before.first['c'], greaterThan(0)); + final refs = await db.rawQuery( + 'SELECT id FROM bp_research_reference WHERE device = ?', + ['omron'], + ); + await LocalDb.deleteBpResearchCapture(refs.first['id'] as int); + final orphaned = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + ); + expect(orphaned.first['c'], 0); + }, + ); - test('a retake that now finds band data replaces the absent window', - () async { - final later = _at + 60000; - await LocalDb.putBpResearchCapture(_capture(later)); - await LocalDb.putBpResearchCapture(_capture(later, window: _win)); - final db = await LocalDb.instance; - final windows = await db.rawQuery( + test( + 'a retake that now finds band data replaces the absent window', + () async { + final later = _at + 60000; + await LocalDb.putBpResearchCapture(_capture(later)); + await LocalDb.putBpResearchCapture(_capture(later, window: _win)); + final db = await LocalDb.instance; + final windows = await db.rawQuery( 'SELECT COUNT(*) c FROM bp_research_window ' 'WHERE reference_id IN ' '(SELECT id FROM bp_research_reference WHERE measured_at_ms = ?)', - [later]); - expect(windows.first['c'], 1); - final orphaned = await db.rawQuery( - 'SELECT COUNT(*) c FROM bp_research_window ' - 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', - ); - expect(orphaned.first['c'], 0); - }); + [later], + ); + expect(windows.first['c'], 1); + final orphaned = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + ); + expect(orphaned.first['c'], 0); + }, + ); - test('a retro capture pairs with HISTORICAL rows and keeps entry time', - () async { - final db = await LocalDb.instance; - await db.delete('bp_research_window'); - await db.delete('bp_research_reference'); - final measured = _at; // the cuff reading, this morning - final entered = _at + 6 * 3600 * 1000; // typed in this evening - await LocalDb.putBpResearchCapture(BpResearchCapture( - measuredAtMs: measured, - measurementStartedAtMs: measured, - systolicMmHg: 120, - diastolicMmHg: 80, - capturedAtMs: entered, // ENTRY time, not measurement - device: 'omron', - bandDeviceId: LocalDb.kPrimaryDeviceId, - measurementSessionId: 'morning', - window: _win, - )); - final r = (await LocalDb.bpResearchCaptures()).first; - expect(r['measured_at_ms'], measured); - expect(r['measurement_started_at_ms'], measured); - expect(r['measurement_finished_at_ms'], isNull); // no invented duration - expect(r['captured_at_ms'], entered); // entry vs measurement - expect(r['band_device_id'], LocalDb.kPrimaryDeviceId); - expect(r['measurement_session_id'], 'morning'); - }); + test( + 'a retro capture pairs with HISTORICAL rows and keeps entry time', + () async { + final db = await LocalDb.instance; + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + final measured = _at; // the cuff reading, this morning + final entered = _at + 6 * 3600 * 1000; // typed in this evening + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: measured, + measurementStartedAtMs: measured, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: entered, // ENTRY time, not measurement + device: 'omron', + bandDeviceId: LocalDb.kPrimaryDeviceId, + measurementSessionId: 'morning', + window: _win, + ), + ); + final r = (await LocalDb.bpResearchCaptures()).first; + expect(r['measured_at_ms'], measured); + expect(r['measurement_started_at_ms'], measured); + expect(r['measurement_finished_at_ms'], isNull); // no invented duration + expect(r['captured_at_ms'], entered); // entry vs measurement + expect(r['band_device_id'], LocalDb.kPrimaryDeviceId); + expect(r['measurement_session_id'], 'morning'); + }, + ); - test('a snapshot freezes the raw rows; re-processing writes a new revision', - () async { + test( + 'a snapshot freezes the raw rows; re-processing writes a new revision', + () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + final onehz = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + {'rec_ts': (_at - 59000) ~/ 1000, 'hr': 62}, + ]; + final rr = [ + {'rr_ts_ms': _at - 60000, 'rr_ms': 1000}, + {'rr_ts_ms': _at - 59000, 'rr_ms': 1050}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'omron', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: onehz, + rrRows: rr, + ), + ), + snapshotOnehzRows: onehz, + snapshotRrRows: rr, + ); + final id = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference', + )).first['id'] + as int; + final snap1 = await db.rawQuery( + 'SELECT revision, onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + ); + expect(snap1, hasLength(1)); + expect(snap1.first['revision'], 1); + // The frozen rows are the exact input: reproducible. + expect(snap1.first['onehz_json'], contains('60')); + final win = await db.rawQuery( + 'SELECT snapshot_revision, feature_version, quality_status, ' + 'valid_interval_pair_count FROM bp_research_window ' + 'WHERE reference_id = ?', + [id], + ); + expect(win.first['snapshot_revision'], 1); + expect(win.first['feature_version'], kResearchFeatureVersion); + expect(win.first['quality_status'], isNotNull); + expect(win.first['valid_interval_pair_count'], 1); + }, + ); + + test('a retake keeps the reference id and writes revision 2, revision 1' + ' stays byte-identical', () async { final db = await LocalDb.instance; await db.delete('bp_research_snapshot'); await db.delete('bp_research_window'); await db.delete('bp_research_reference'); - final onehz = [ + final rows1 = [ {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, {'rec_ts': (_at - 59000) ~/ 1000, 'hr': 62}, ]; - final rr = [ + final rr1 = [ {'rr_ts_ms': _at - 60000, 'rr_ms': 1000}, - {'rr_ts_ms': _at - 59000, 'rr_ms': 1050}, ]; await LocalDb.putBpResearchCapture( BpResearchCapture( @@ -161,65 +232,189 @@ void main() { capturedAtMs: _at, device: 'omron', window: researchWindowFrom( - measuredAtMs: _at, onehzRows: onehz, rrRows: rr), + measuredAtMs: _at, + onehzRows: rows1, + rrRows: rr1, + ), ), - snapshotOnehzRows: onehz, - snapshotRrRows: rr, + snapshotOnehzRows: rows1, + snapshotRrRows: rr1, ); - final id = (await db.rawQuery( - 'SELECT id FROM bp_research_reference')).first['id'] as int; - final snap1 = await db.rawQuery( - 'SELECT revision, onehz_json FROM bp_research_snapshot ' - 'WHERE reference_id = ?', [id]); - expect(snap1, hasLength(1)); - expect(snap1.first['revision'], 1); - // The frozen rows are the exact input: reproducible. - expect(snap1.first['onehz_json'], contains('60')); + final idBefore = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + final json1 = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [idBefore], + )).first['onehz_json'] + as String; + // The retake: DIFFERENT sensor rows for the same natural reference. + final rows2 = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 64}, + {'rec_ts': (_at - 59000) ~/ 1000, 'hr': 66}, + ]; + final rr2 = [ + {'rr_ts_ms': _at - 60000, 'rr_ms': 900}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 118, + diastolicMmHg: 79, + capturedAtMs: _at + 1000, + device: 'omron', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rows2, + rrRows: rr2, + ), + ), + snapshotOnehzRows: rows2, + snapshotRrRows: rr2, + ); + final idAfter = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + // The reference id is STABLE — a retake is an update, not a new row. + expect(idAfter, idBefore); + final snaps = await db.rawQuery( + 'SELECT revision, onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? ORDER BY revision', + [idBefore], + ); + expect(snaps, hasLength(2)); + expect(snaps[0]['revision'], 1); + expect(snaps[1]['revision'], 2); + // Revision 1 is untouched — byte-identical history. + expect(snaps[0]['onehz_json'], json1); + expect(snaps[0]['onehz_json'], contains('60')); + expect(snaps[1]['onehz_json'], contains('64')); + // The window summary points at the CURRENT revision. final win = await db.rawQuery( - 'SELECT snapshot_revision, feature_version, quality_status, ' - 'valid_interval_pair_count FROM bp_research_window ' - 'WHERE reference_id = ?', [id]); - expect(win.first['snapshot_revision'], 1); - expect(win.first['feature_version'], kResearchFeatureVersion); - expect(win.first['quality_status'], isNotNull); - expect(win.first['valid_interval_pair_count'], 1); + 'SELECT snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [idBefore], + ); + expect(win.first['snapshot_revision'], 2); + // The reference fields were updated in place. + final ref = await db.rawQuery( + 'SELECT systolic_mmhg FROM bp_research_reference WHERE id = ?', + [idBefore], + ); + expect(ref.first['systolic_mmhg'], 118.0); + }); + + test( + 'a reference correction (no new snapshot rows) keeps the snapshots', + () async { + final db = await LocalDb.instance; + // Re-capture the SAME natural reference with corrected values but no + // snapshot rows: a field fix must not touch the snapshot history. + final id = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference', + )).first['id'] + as int; + final before = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + ); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 117, + diastolicMmHg: 78, + capturedAtMs: _at + 2000, + device: 'omron', + ), + ); + final after = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + ); + expect(after.first['c'], before.first['c']); + final ref = await db.rawQuery( + 'SELECT systolic_mmhg FROM bp_research_reference WHERE id = ?', + [id], + ); + expect(ref.first['systolic_mmhg'], 117.0); + }, + ); + + test('overwriting an existing snapshot revision is an integrity error, ' + 'not a silent rewrite', () async { + final db = await LocalDb.instance; + final id = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + final foreignJson = jsonEncode([ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 999}, + ]); + // A second writer claiming the SAME (reference, revision) key with + // DIFFERENT content must fail loudly: plain INSERT + UNIQUE. + await expectLater( + db.insert('bp_research_snapshot', { + 'reference_id': id, + 'revision': 1, + 'onehz_json': foreignJson, + 'rr_json': '[]', + 'created_at_ms': _at, + }), + throwsA(isA()), + ); + // The destination revision is untouched. + final kept = await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [id], + ); + expect(kept, hasLength(1)); + expect(kept.first['onehz_json'], isNot(foreignJson)); }); test('delete removes the snapshot rows too', () async { final db = await LocalDb.instance; - final id = (await db.rawQuery( - 'SELECT id FROM bp_research_reference')).first['id'] as int; + final id = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; await LocalDb.deleteBpResearchCapture(id); final left = await db.rawQuery( - 'SELECT COUNT(*) c FROM bp_research_snapshot ' - 'WHERE reference_id = ?', [id]); + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + ); expect(left.first['c'], 0); }); - test('the research tables ride the backup restore and salvage lists', - () async { - expect(LocalDb.restoreTablesForTest, contains('bp_research_reference')); - expect(LocalDb.restoreTablesForTest, contains('bp_research_window')); - expect(LocalDb.salvageTablesForTest, contains('bp_research_reference')); - expect(LocalDb.salvageTablesForTest, contains('bp_research_window')); - // Parent before child, in both lists. - int posOf(List l, String t) => l.indexOf(t); - expect( - posOf(LocalDb.restoreTablesForTest, 'bp_research_reference'), - lessThan(posOf(LocalDb.restoreTablesForTest, 'bp_research_window')), - ); - expect( - posOf(LocalDb.salvageTablesForTest, 'bp_research_reference'), - lessThan(posOf(LocalDb.salvageTablesForTest, 'bp_research_window')), - ); - }); + test( + 'the research tables ride the backup restore and salvage lists', + () async { + expect(LocalDb.restoreTablesForTest, contains('bp_research_reference')); + expect(LocalDb.restoreTablesForTest, contains('bp_research_window')); + expect(LocalDb.salvageTablesForTest, contains('bp_research_reference')); + expect(LocalDb.salvageTablesForTest, contains('bp_research_window')); + // Parent before child, in both lists. + int posOf(List l, String t) => l.indexOf(t); + expect( + posOf(LocalDb.restoreTablesForTest, 'bp_research_reference'), + lessThan(posOf(LocalDb.restoreTablesForTest, 'bp_research_window')), + ); + expect( + posOf(LocalDb.salvageTablesForTest, 'bp_research_reference'), + lessThan(posOf(LocalDb.salvageTablesForTest, 'bp_research_window')), + ); + }, + ); // A foreign export's AUTOINCREMENT ids are meaningless on this install: // its id=1 must never REPLACE an unrelated local capture that happens to // hold id=1. The merge keys references on (measured_at_ms, device) and // remaps each window onto the DESTINATION reference id. - test('restore merges captures by natural key, never by source id', - () async { + test('restore merges captures by natural key, never by source id', () async { // Start from a clean store: earlier tests in this file leave rows // behind, and this one asserts exact row sets. final db0 = await LocalDb.instance; @@ -228,24 +423,28 @@ void main() { // Local state: one capture (id=1 by AUTOINCREMENT) plus its window. await LocalDb.putBpResearchCapture(_capture(_at, device: 'local')); // A foreign export whose DIFFERENT capture also carries id=1. - final srcPath = - p.join(await databaseFactory.getDatabasesPath(), 'bp_foreign.db'); + final srcPath = p.join( + await databaseFactory.getDatabasesPath(), + 'bp_foreign.db', + ); await databaseFactory.deleteDatabase(srcPath); final src = await databaseFactory.openDatabase(srcPath); await src.execute( - 'CREATE TABLE bp_research_reference (' - 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' - 'measured_at_ms INTEGER NOT NULL, ' - 'device TEXT, posture TEXT, conditions TEXT, ' - 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' - 'captured_at_ms INTEGER NOT NULL, ' - 'UNIQUE (measured_at_ms, device))'); + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, ' + 'device TEXT, posture TEXT, conditions TEXT, ' + 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' + 'captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); await src.execute( - 'CREATE TABLE bp_research_window (' - 'reference_id INTEGER NOT NULL PRIMARY KEY, ' - 'window_start_ms INTEGER NOT NULL, window_end_ms INTEGER NOT NULL, ' - 'onehz_rows INTEGER, rr_beats INTEGER, hr_mean REAL, rr_ms_mean REAL, ' - 'rr_ms_min REAL, rr_ms_max REAL, rmssd_ms REAL, meta_json TEXT)'); + 'CREATE TABLE bp_research_window (' + 'reference_id INTEGER NOT NULL PRIMARY KEY, ' + 'window_start_ms INTEGER NOT NULL, window_end_ms INTEGER NOT NULL, ' + 'onehz_rows INTEGER, rr_beats INTEGER, hr_mean REAL, rr_ms_mean REAL, ' + 'rr_ms_min REAL, rr_ms_max REAL, rmssd_ms REAL, meta_json TEXT)', + ); await src.insert('bp_research_reference', { 'id': 1, // deliberately collides with the local capture's id 'measured_at_ms': _at + 60000, @@ -273,8 +472,9 @@ void main() { final db = await LocalDb.instance; // Both captures survive: the foreign id=1 did not eat the local one. final refs = await db.rawQuery( - 'SELECT measured_at_ms, systolic_mmhg FROM bp_research_reference ' - 'ORDER BY measured_at_ms'); + 'SELECT measured_at_ms, systolic_mmhg FROM bp_research_reference ' + 'ORDER BY measured_at_ms', + ); expect(refs, hasLength(2)); expect(refs[0]['measured_at_ms'], _at); expect(refs[0]['systolic_mmhg'], 120.0); @@ -288,38 +488,44 @@ void main() { ); expect(orphaned.first['c'], 0); final importedWin = await db.rawQuery( - 'SELECT hr_mean FROM bp_research_window w ' - 'JOIN bp_research_reference r ON r.id = w.reference_id ' - 'WHERE r.measured_at_ms = ?', [_at + 60000]); + 'SELECT hr_mean FROM bp_research_window w ' + 'JOIN bp_research_reference r ON r.id = w.reference_id ' + 'WHERE r.measured_at_ms = ?', + [_at + 60000], + ); expect(importedWin.first['hr_mean'], 71.0); await databaseFactory.deleteDatabase(srcPath); }); - test('a colliding restore keeps the destination id and its window', - () async { + test('a colliding restore keeps the destination id and its window', () async { final db0 = await LocalDb.instance; await db0.delete('bp_research_window'); await db0.delete('bp_research_reference'); // Local capture WITH a window. await LocalDb.putBpResearchCapture( - _capture(_at, device: 'local', window: _win)); - final localId = (await db0.rawQuery( - 'SELECT id FROM bp_research_reference')).first['id'] as int; + _capture(_at, device: 'local', window: _win), + ); + final localId = + (await db0.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; // A foreign export of the SAME instant (same natural key) with no // window row: the capture's fields update, the window survives. - final srcPath = - p.join(await databaseFactory.getDatabasesPath(), 'bp_foreign3.db'); + final srcPath = p.join( + await databaseFactory.getDatabasesPath(), + 'bp_foreign3.db', + ); await databaseFactory.deleteDatabase(srcPath); final src = await databaseFactory.openDatabase(srcPath); await src.execute( - 'CREATE TABLE bp_research_reference (' - 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' - 'measured_at_ms INTEGER NOT NULL, ' - 'device TEXT, posture TEXT, conditions TEXT, ' - 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' - 'captured_at_ms INTEGER NOT NULL, ' - 'UNIQUE (measured_at_ms, device))'); + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, ' + 'device TEXT, posture TEXT, conditions TEXT, ' + 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' + 'captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); await src.insert('bp_research_reference', { 'id': 7, 'measured_at_ms': _at, @@ -336,7 +542,8 @@ void main() { final db = await LocalDb.instance; final refs = await db.rawQuery( - 'SELECT id, posture, systolic_mmhg FROM bp_research_reference'); + 'SELECT id, posture, systolic_mmhg FROM bp_research_reference', + ); expect(refs, hasLength(1)); // The destination id is KEPT, so the window stays attached. expect(refs.first['id'], localId); @@ -348,29 +555,32 @@ void main() { ); expect(orphaned.first['c'], 0); final win = await db.rawQuery( - 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', - [localId]); + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [localId], + ); expect(win.first['hr_mean'], 62.5); await databaseFactory.deleteDatabase(srcPath); }); - test('a re-import of the same export converges (idempotent merge)', - () async { + test('a re-import of the same export converges (idempotent merge)', () async { final db0 = await LocalDb.instance; await db0.delete('bp_research_window'); await db0.delete('bp_research_reference'); - final srcPath = - p.join(await databaseFactory.getDatabasesPath(), 'bp_foreign2.db'); + final srcPath = p.join( + await databaseFactory.getDatabasesPath(), + 'bp_foreign2.db', + ); await databaseFactory.deleteDatabase(srcPath); final src = await databaseFactory.openDatabase(srcPath); await src.execute( - 'CREATE TABLE bp_research_reference (' - 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' - 'measured_at_ms INTEGER NOT NULL, ' - 'device TEXT, posture TEXT, conditions TEXT, ' - 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' - 'captured_at_ms INTEGER NOT NULL, ' - 'UNIQUE (measured_at_ms, device))'); + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, ' + 'device TEXT, posture TEXT, conditions TEXT, ' + 'systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, ' + 'captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); await src.insert('bp_research_reference', { 'id': 1, 'measured_at_ms': _at + 120000, @@ -385,8 +595,10 @@ void main() { await LocalDb.importFromDbFile(srcPath); final db = await LocalDb.instance; final n = (await db.rawQuery( - 'SELECT COUNT(*) c FROM bp_research_reference ' - 'WHERE measured_at_ms = ?', [_at + 120000])).first['c']; + 'SELECT COUNT(*) c FROM bp_research_reference ' + 'WHERE measured_at_ms = ?', + [_at + 120000], + )).first['c']; expect(n, 1); await databaseFactory.deleteDatabase(srcPath); }); diff --git a/tool/bp_research_model.py b/tool/bp_research_model.py index 31fc756f9..2a35727f8 100644 --- a/tool/bp_research_model.py +++ b/tool/bp_research_model.py @@ -25,12 +25,17 @@ Learning levels: A: only the personal offset (theta[0]) updates — scalar Kalman. - theta[1], theta[2] stay 0 unless a separately validated model says - otherwise. + theta[1], theta[2] stay 0, so level A is NOT a sensor model in any + predictive sense; it is reported as 'adaptive_cuff_offset_baseline'. B: full parameter vector — scalar-per-parameter Kalman with Joseph- - form covariance. Off by default; requires enough independent - feature variation (documented thresholds below) and remains - experimental. + form covariance. Off by default and EXPERIMENTAL. Enabled per + reference ONLY from causal, already-processed history: at least + MIN_SLOPE_SAMPLES previously updated aggregated references with + finite features and sufficient spread in BOTH H and L. Before the + gate opens (and on any gate failure) the run falls back to level A + and the report says so. The transition A->B is a documented + covariance hand-over: p_offset seeds the offset diagonal of P and + the level-A theta carries over unchanged (both tested). Evaluation discipline: · a prediction is ALWAYS recorded before its reference updates the @@ -61,6 +66,18 @@ DEFAULT_P0 = 400.0 # initial offset uncertainty (±20 mmHg SD) MIN_SLOPE_SAMPLES = 20 # level B needs at least this many aggregated refs MIN_FEATURE_SPREAD = 0.25 # and this much normalized spread in H and L +# Quality admission (documented research rule, NOT a validated criterion): +# a capture enters the model only with usable features and a quality +# status the rule accepts. 'pending' (window not final), 'no_data' and +# missing features are excluded; 'gappy' is admitted — it is usable data +# with an honest warning flag, and excluding it would bias the dataset +# toward clean, unrepresentative windows. +ADMITTED_QUALITY = frozenset({"ok", "gappy", ""}) +# Session aggregation span: members of one explicit session id are only +# aggregated when they lie within this span (engineering default, 30 min — +# a few cuff readings of one sitting). Same label, farther apart: NOT one +# session; each reference stays independent. +MAX_SESSION_SPAN_MS = 30 * 60 * 1000 # Feature normalization. Documented, arbitrary-but-fixed engineering # anchors; a change requires retraining or transforming the parameters. @@ -91,6 +108,19 @@ class Model: last_cuff: float | None = None predictions: list[dict] = field(default_factory=list) + @staticmethod + def hand_over_to_level_b(m: "Model") -> "Model": + """Documented A->B transition: theta carries over unchanged, the + offset variance p_offset seeds the offset diagonal of P, and the + slope variances start from DEFAULT_P0 (nothing about them was ever + learned in level A).""" + return Model( + theta=list(m.theta), + P=[[m.p_offset, 0, 0], [0, DEFAULT_P0, 0], [0, 0, DEFAULT_P0]], + p_offset=m.p_offset, + last_cuff=m.last_cuff, + ) + @staticmethod def initial(cuff_mean: float) -> "Model": # Calibration baseline, NOT a sensor-backed prediction: slopes @@ -104,8 +134,12 @@ def initial(cuff_mean: float) -> "Model": def features(hr: float | None, rmssd: float | None) -> list[float] | None: """z = [1, (H-H0)/sH, (L-L0)/sL]; None when H or V is missing — - missing data never becomes a zero feature.""" - if hr is None or rmssd is None or rmssd <= 0: + missing data never becomes a zero feature. NaN and infinities are + rejected like any other unusable input, never laundered into a + feature value.""" + if hr is None or rmssd is None: + return None + if not (math.isfinite(hr) and math.isfinite(rmssd) and hr > 0 and rmssd > 0): return None l = math.log(rmssd + EPSILON_MS) return [1.0, (hr - H0_BPM) / SH_BPM, (l - L0) / SL] @@ -117,7 +151,12 @@ def predict(m: Model, z: list[float]) -> float: def update_level_a(m: Model, z: list[float], ref: float, delta_days: float) -> None: - """Scalar Kalman on the offset only (slopes stay frozen).""" + """Scalar Kalman on the offset only (slopes stay frozen). + + The A->B hand-over is explicit: when a later run switches this model + to level B, [Model.hand_over_to_level_b] seeds the offset diagonal of + P from p_offset and carries theta over unchanged — no undocumented + mixing of the scalar and matrix covariances.""" p_minus = m.p_offset + DEFAULT_Q_OFFSET * max(delta_days, 0.0) k = p_minus / (p_minus + DEFAULT_R_MMHG) pred = predict(m, z) @@ -178,33 +217,66 @@ def num(key: str) -> float | None: def aggregate_sessions(rows: list[Row]) -> list[Row]: - """Multiple cuff readings of one session are NOT independent + """Multiple cuff readings of one sitting are NOT independent physiological states — average them into one reference before they - enter the model. Sessions are keyed by (session_id, calendar day).""" - by_key: dict[tuple, list[Row]] = {} + enter the model. + + Session identity is EXPLICIT: only rows sharing a measurement_session_id + can be aggregated, and only when they lie within MAX_SESSION_SPAN_MS of + each other (chained: consecutive members, not min-to-max of an + arbitrarily long chain). No implicit calendar-day aggregation — the + same label hours apart stays separate references, and rows without a + session id never merge with anything. + + Within one aggregated session the REFERENCE values are the session + mean; the FEATURES are the coverage-weighted mean of the members' + features (they all describe the same few minutes of the same sitting — + a 'best member's features' pick would silently borrow a different + member's window instead of representing the session). + """ + explicit: list[Row] = [] + solo: list[Row] = [] for r in rows: - day = r.measured_at_ms // 86400000 - key = (r.session_id, day) if r.session_id else ("_solo", day, - r.measured_at_ms) - by_key.setdefault(key, []).append(r) - out = [] - for key, group in by_key.items(): - if len(group) == 1: - out.append(group[0]) - else: - # Same instant features across the session's captures; the - # REFERENCE is the session mean. Feature fields are taken from - # the capture with the best quality/coverage. - best = max(group, key=lambda g: (g.coverage or 0.0)) + (explicit if r.session_id else solo).append(r) + out: list[Row] = list(solo) + by_label: dict[str, list[Row]] = {} + for r in explicit: + by_label.setdefault(r.session_id, []).append(r) + for label, group in by_label.items(): + group.sort(key=lambda g: g.measured_at_ms) + cluster = [group[0]] + clusters: list[list[Row]] = [] + for g in group[1:]: + if g.measured_at_ms - cluster[-1].measured_at_ms <= MAX_SESSION_SPAN_MS: + cluster.append(g) + else: + clusters.append(cluster) + cluster = [g] + clusters.append(cluster) + for members in clusters: + if len(members) == 1: + out.append(members[0]) + continue + n = len(members) + weights = [(m.coverage or 0.0) for m in members] + def wmean(vals: list[float | None]) -> float | None: + pairs = [(v, w) for v, w in zip(vals, weights) + if v is not None and w > 0] + if not pairs: + return None + tw = sum(w for _, w in pairs) + return sum(v * w for v, w in pairs) / tw out.append(Row( - measured_at_ms=max(g.measured_at_ms for g in group), - sys_mmhg=sum(g.sys_mmhg for g in group) / len(group), - dia_mmhg=sum(g.dia_mmhg for g in group) / len(group), - hr_mean=best.hr_mean, - rmssd_ms=best.rmssd_ms, - session_id=key[0] if isinstance(key[0], str) else None, - quality=best.quality, - coverage=best.coverage, + measured_at_ms=sum(m.measured_at_ms for m in members) / n, + sys_mmhg=sum(m.sys_mmhg for m in members) / n, + dia_mmhg=sum(m.dia_mmhg for m in members) / n, + hr_mean=wmean([m.hr_mean for m in members]), + rmssd_ms=wmean([m.rmssd_ms for m in members]), + session_id=label, + quality=(members[0].quality if all( + m.quality == members[0].quality for m in members) else None), + coverage=(sum(weights) / n if all( + m.coverage is not None for m in members) else None), )) out.sort(key=lambda x: x.measured_at_ms) return out @@ -219,96 +291,154 @@ def signed_mean(xs: list[float]) -> float: def run(rows: list[Row], level_b: bool = False) -> dict: + """Chronological prequential replay. + + FAIR COMPARISON: all three models are evaluated on the EXACT SAME + target set — the admitted aggregated references AFTER the calibration + row. A reference without usable features updates NO model (features + would be fabricated for the sensor model alone), so all three n's are + identical by construction. + + Level B is enabled causally, per reference, from ALREADY-PROCESSED + history only: at least MIN_SLOPE_SAMPLES previously UPDATED references + with finite features and spread in BOTH H and L among them. No future + row of the CSV is inspected. When the gate has not opened (or the run + did not ask for level B), the update falls back to level A and the + report says so. + """ aggregated = aggregate_sessions(rows) if not aggregated: return {"error": "no rows"} - # Calibration baseline from the FIRST session's cuff values. - first_sys = aggregated[0].sys_mmhg - first_dia = aggregated[0].dia_mmhg - m_sys = Model.initial(first_sys) - m_dia = Model.initial(first_dia) - - usable = [r for r in aggregated[1:]] # prequential: predict, then update - err_calib = {"sys": [], "dia": []} - err_time = {"sys": [], "dia": []} - err_model = {"sys": [], "dia": []} - excluded = 0 - last_t = aggregated[0].measured_at_ms - - # Baseline 3: cuff-only time model — the running mean of every reference - # seen so far, features never involved. - seen_sys = [first_sys] - seen_dia = [first_dia] + # Quality admission (documented research rule, see ADMITTED_QUALITY). + admitted = [r for r in aggregated + if (r.quality or "") in ADMITTED_QUALITY] + excluded_quality = len(aggregated) - len(admitted) + + # Calibration row: the first admitted reference seeds the models. + # Baselines start from it too, so all models see the same history. + first = admitted[0] + m_sys = Model.initial(first.sys_mmhg) + m_dia = Model.initial(first.dia_mmhg) + + usable = admitted[1:] # prequential: predict, then update + targets: list[Row] = [] + excluded_no_features = 0 + last_t = first.measured_at_ms + + # Baseline 2: last calibration cuff value — defined from the FIRST + # admitted row on, so its n matches everyone else's. + last_cuff_sys = first.sys_mmhg + last_cuff_dia = first.dia_mmhg + # Baseline 3: cuff-only time model — the running mean of every admitted + # reference seen so far, features never involved. + seen_sys = [first.sys_mmhg] + seen_dia = [first.dia_mmhg] + + # Causal level-B gate state: history of the FEATURE VECTORS of the + # references that were actually processed (updated on), never future + # rows. + processed_z: list[list[float]] = [] + level_b_updates = 0 + level_a_updates = 0 + + per_target: list[dict] = [] for r in usable: z = features(r.hr_mean, r.rmssd_ms) if z is None: - excluded += 1 + excluded_no_features += 1 continue delta_days = (r.measured_at_ms - last_t) / 86400000.0 last_t = r.measured_at_ms + targets.append(r) - # 1. prequential prediction — recorded BEFORE the update. + # 1. prequential predictions — recorded BEFORE any update. pred_s = predict(m_sys, z) pred_d = predict(m_dia, z) - err_model["sys"].append(pred_s - r.sys_mmhg) - err_model["dia"].append(pred_d - r.dia_mmhg) - - # 2. baseline: last calibration cuff value (no WHOOP features). - if m_sys.last_cuff is not None: - err_calib["sys"].append(m_sys.last_cuff - r.sys_mmhg) - err_calib["dia"].append(m_dia.last_cuff - r.dia_mmhg) - - # 3. baseline: cuff-only time model (running cuff mean). - err_time["sys"].append( - sum(seen_sys) / len(seen_sys) - r.sys_mmhg) - err_time["dia"].append( - sum(seen_dia) / len(seen_dia) - r.dia_mmhg) - seen_sys.append(r.sys_mmhg) - seen_dia.append(r.dia_mmhg) - # 4. update AFTER recording the prediction. - if level_b and len(usable) >= MIN_SLOPE_SAMPLES: - hs = [abs((r_.hr_mean or H0_BPM) - H0_BPM) / SH_BPM - for r_ in usable[:n_seen]] - if max(hs, default=0.0) >= MIN_FEATURE_SPREAD: - update_level_b(m_sys, z, r.sys_mmhg, delta_days) - update_level_b(m_dia, z, r.dia_mmhg, delta_days) + # 2. baseline: last cuff value, no WHOOP features. + # 3. baseline: running cuff mean, no WHOOP features. + # Both are evaluated on the same target as the model. + per_target.append({ + "measured_at_ms": r.measured_at_ms, + "model_mode": ("level_b" if (level_b and _b_gate(processed_z)) + else "level_a"), + "pred_model_sys": pred_s, + "pred_model_dia": pred_d, + "pred_last_cuff_sys": last_cuff_sys, + "pred_last_cuff_dia": last_cuff_dia, + "pred_cuff_mean_sys": sum(seen_sys) / len(seen_sys), + "pred_cuff_mean_dia": sum(seen_dia) / len(seen_dia), + "ref_sys": r.sys_mmhg, + "ref_dia": r.dia_mmhg, + }) + + # 4. update AFTER recording the predictions. The mode decision uses + # ONLY processed history (no future rows, no len(usable)). + use_b = level_b and _b_gate(processed_z) + if use_b: + update_level_b(m_sys, z, r.sys_mmhg, delta_days) + update_level_b(m_dia, z, r.dia_mmhg, delta_days) + level_b_updates += 1 else: update_level_a(m_sys, z, r.sys_mmhg, delta_days) update_level_a(m_dia, z, r.dia_mmhg, delta_days) - m_sys.last_cuff = r.sys_mmhg - m_dia.last_cuff = r.dia_mmhg + level_a_updates += 1 + processed_z.append(z) + last_cuff_sys = r.sys_mmhg + last_cuff_dia = r.dia_mmhg + seen_sys.append(r.sys_mmhg) + seen_dia.append(r.dia_mmhg) - def stats(errs: list[float]) -> dict: + def stats(pred_key: str, ref_key: str) -> dict: + errs = [t[pred_key] - t[ref_key] for t in per_target] if not errs: return {"n": 0} - var = sum((e - signed_mean(errs)) ** 2 for e in errs) / len(errs) + mean = signed_mean(errs) + var = sum((e - mean) ** 2 for e in errs) / len(errs) return { "n": len(errs), "mae_mmhg": round(mae(errs), 2), - "mean_signed_mmhg": round(signed_mean(errs), 2), + "mean_signed_mmhg": round(mean, 2), "sd_mmhg": round(math.sqrt(var), 2), } return { "rows_total": len(rows), "rows_aggregated": len(aggregated), - "rows_excluded_no_features": excluded, + "rows_excluded_quality": excluded_quality, + "rows_excluded_no_features": excluded_no_features, + "admission_rule": { + "admitted_quality": sorted(ADMITTED_QUALITY), + "max_session_span_ms": MAX_SESSION_SPAN_MS, + }, + "updates": { + "level_a": level_a_updates, + "level_b": level_b_updates, + "level_b_requested": level_b, + "level_b_gate": { + "min_processed_refs": MIN_SLOPE_SAMPLES, + "min_feature_spread_h_and_l": MIN_FEATURE_SPREAD, + }, + }, "systolic": { - "baseline_last_cuff": stats(err_calib["sys"]), - "baseline_cuff_time_model": stats(err_time["sys"]), - "hr_hrv_model": stats(err_model["sys"]), + "baseline_last_cuff": stats("pred_last_cuff_sys", "ref_sys"), + "baseline_cuff_time_model": stats("pred_cuff_mean_sys", "ref_sys"), + # Level A holds sensor slopes at zero: an offset tracker, not a + # sensor model. Named for what it is. + "adaptive_cuff_offset_baseline": stats("pred_model_sys", "ref_sys"), }, "diastolic": { - "baseline_last_cuff": stats(err_calib["dia"]), - "baseline_cuff_time_model": stats(err_time["dia"]), - "hr_hrv_model": stats(err_model["dia"]), + "baseline_last_cuff": stats("pred_last_cuff_dia", "ref_dia"), + "baseline_cuff_time_model": stats("pred_cuff_mean_dia", "ref_dia"), + "adaptive_cuff_offset_baseline": stats("pred_model_dia", "ref_dia"), }, "model_state": { "theta_sys": [round(t, 3) for t in m_sys.theta], "theta_dia": [round(t, 3) for t in m_dia.theta], + "p_offset_sys": round(m_sys.p_offset, 3), + "p_offset_dia": round(m_dia.p_offset, 3), "feature_anchors": {"H0_bpm": H0_BPM, "sH_bpm": SH_BPM, "L0": round(L0, 4), "sL": SL, "epsilon_ms": EPSILON_MS}, @@ -316,6 +446,20 @@ def stats(errs: list[float]) -> dict: } +def _b_gate(processed_z: list[list[float]]) -> bool: + """Level-B admission from CAUSAL history only: enough processed + references, finite features, and spread in BOTH H and L. Falls back to + level A on any failure (the caller reports the fallback).""" + if len(processed_z) < MIN_SLOPE_SAMPLES: + return False + hs = [z[1] for z in processed_z] + ls = [z[2] for z in processed_z] + if any(not math.isfinite(v) for v in hs + ls): + return False + return (max(hs) - min(hs) >= MIN_FEATURE_SPREAD + and max(ls) - min(ls) >= MIN_FEATURE_SPREAD) + + def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) diff --git a/tool/test_bp_research_model.py b/tool/test_bp_research_model.py index ff9dcfde6..441adddb3 100644 --- a/tool/test_bp_research_model.py +++ b/tool/test_bp_research_model.py @@ -2,8 +2,9 @@ """Math-only tests for tool/bp_research_model.py. Synthetic data here verifies MATHEMATICS (Kalman recursion, feature math, -session aggregation, prequential discipline) — it claims NO physiological -validity and is never used as evidence of medical accuracy. +session aggregation, prequential discipline, causal level-B gating, fair +baselines) — it claims NO physiological validity and is never used as +evidence of medical accuracy. Run: python3 tool/test_bp_research_model.py """ @@ -19,6 +20,10 @@ def approx(a, b, tol=1e-9): assert abs(a - b) <= tol, f"{a} != {b}" +def _row(t, sys_, dia, hr=70.0, rm=40.0, sess=None, q="ok", cov=1.0): + return m.Row(t, sys_, dia, hr, rm, sess, q, cov) + + def test_features(): # z = [1, (H-60)/20, (ln(V+eps) - ln(40+eps))] z = m.features(80.0, 40.0) @@ -29,6 +34,11 @@ def test_features(): assert m.features(None, 40.0) is None assert m.features(80.0, None) is None assert m.features(80.0, 0.0) is None # RMSSD 0 = absent, not ln(0) + # NaN and infinities are rejected, never laundered into features. + assert m.features(float("nan"), 40.0) is None + assert m.features(80.0, float("inf")) is None + assert m.features(80.0, float("nan")) is None + assert m.features(float("-inf"), 40.0) is None def test_level_a_converges(): @@ -62,19 +72,94 @@ def test_prediction_is_recorded_before_update(): # Prequential discipline: with ONE usable reference after calibration, # the recorded prediction must equal the initial calibration baseline # (no feature influence yet — slopes are zero at start). - rows = [m.Row(0, 120.0, 80.0, 70.0, 40.0, None, "ok", 1.0), - m.Row(86_400_000, 122.0, 82.0, 70.0, 40.0, None, "ok", 1.0)] + rows = [_row(0, 120.0, 80.0), _row(86_400_000, 122.0, 82.0)] rep = m.run(rows) # First usable row: prediction = theta^T z = 120 + 0 + 0 = 120. - approx(rep["systolic"]["hr_hrv_model"]["mae_mmhg"], 2.0, 0.01) + approx(rep["systolic"]["adaptive_cuff_offset_baseline"]["mae_mmhg"], 2.0, 0.01) + + +def test_level_b_runs_without_name_error(): + # 25 references with real feature variation: level B must actually + # execute past its gate (>= 20 processed, spread in H AND L) and never + # raise NameError. + rows = [_row(0, 120.0, 80.0)] + for i in range(25): + hr = 60.0 + (i % 5) * 8.0 + rm = 25.0 + (i % 4) * 15.0 + rows.append(_row((i + 1) * 86_400_000, 120.0 + i * 0.5, 80.0, + hr=hr, rm=rm)) + rep = m.run(rows, level_b=True) + assert rep["updates"]["level_b"] > 0 + assert rep["updates"]["level_a"] >= m.MIN_SLOPE_SAMPLES + # The gate opens only after enough CAUSAL history, never from the + # total row count of the CSV. + assert rep["updates"]["level_b"] == len(rows) - 1 - rep["updates"]["level_a"] + + +def test_level_b_falls_back_without_variation(): + # Plenty of references but ZERO variation in L: the gate must stay + # shut and every update falls back to level A, reported as such. + rows = [_row(0, 120.0, 80.0)] + for i in range(25): + rows.append(_row((i + 1) * 86_400_000, 120.0, 80.0, + hr=60.0 + (i % 5) * 8.0, rm=40.0)) + rep = m.run(rows, level_b=True) + assert rep["updates"]["level_b"] == 0 + assert rep["updates"]["level_a"] == 25 + + +def test_level_b_uses_no_future_information(): + # The FIRST 19 references look exactly like a different, feature-rich + # future — the gate must not inspect them. With only 10 references + # total, level B can never open even though the CSV is full of spread. + rows = [_row(0, 120.0, 80.0)] + for i in range(10): + rows.append(_row((i + 1) * 86_400_000, 120.0 + i, 80.0, + hr=60.0 + i * 5, rm=25.0 + i * 10)) + rep = m.run(rows, level_b=True) + assert rep["updates"]["level_b"] == 0 + assert rep["updates"]["level_a"] == 10 + # And the predictions of the first targets are IDENTICAL with and + # without --level-b requested: the flag must not change the past. + rep2 = m.run(rows, level_b=False) + a = rep["systolic"]["adaptive_cuff_offset_baseline"] + b = rep2["systolic"]["adaptive_cuff_offset_baseline"] + assert a["mae_mmhg"] == b["mae_mmhg"] + + +def test_quality_exclusion(): + # 'pending' and 'no_data' captures are excluded by the admission rule + # and reported; 'gappy' is admitted. + rows = [_row(0, 120.0, 80.0, q="ok"), + _row(86_400_000, 121.0, 81.0, q="pending"), + _row(2 * 86_400_000, 122.0, 82.0, q="no_data"), + _row(3 * 86_400_000, 123.0, 83.0, q="gappy")] + rep = m.run(rows) + assert rep["rows_excluded_quality"] == 2 + # Admitted: the 'ok' calibration row and the 'gappy' target. + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 1 + + +def test_level_a_to_level_b_handover(): + # The documented transition: theta carries over unchanged, p_offset + # seeds the offset diagonal of P, slopes start at DEFAULT_P0. + mdl = m.Model.initial(120.0) + z = m.features(70.0, 40.0) + for _ in range(50): + m.update_level_a(mdl, z, 128.0, delta_days=0.01) + handed = m.Model.hand_over_to_level_b(mdl) + assert handed.theta == mdl.theta + assert handed.P[0][0] == mdl.p_offset + assert handed.P[1][1] == m.DEFAULT_P0 + assert handed.P[2][2] == m.DEFAULT_P0 def test_session_aggregation(): # Three readings of one session are NOT three independent states. rows = [ - m.Row(1000, 120.0, 80.0, 70.0, 40.0, "s1", "ok", 1.0), - m.Row(60_000, 124.0, 84.0, 70.0, 40.0, "s1", "ok", 1.0), - m.Row(120_000, 122.0, 82.0, 70.0, 40.0, "s1", "ok", 1.0), + _row(1000, 120.0, 80.0, sess="s1"), + _row(60_000, 124.0, 84.0, sess="s1"), + _row(120_000, 122.0, 82.0, sess="s1"), ] agg = m.aggregate_sessions(rows) assert len(agg) == 1 @@ -82,22 +167,57 @@ def test_session_aggregation(): approx(agg[0].dia_mmhg, 82.0) +def test_session_label_conflict_keeps_references_apart(): + # The SAME session label hours apart is NOT one session: each + # reference stays independent (no implicit day/label aggregation). + rows = [ + _row(0, 120.0, 80.0, sess="morning"), + _row(60_000, 121.0, 81.0, sess="morning"), + # 6 hours later, same label: a different sitting. + _row(6 * 3_600_000, 130.0, 90.0, sess="morning"), + ] + agg = m.aggregate_sessions(rows) + assert len(agg) == 2 + approx(agg[1].sys_mmhg, 130.0) + + def test_missing_features_excluded_not_zeroed(): - rows = [m.Row(0, 120.0, 80.0, 70.0, 40.0, None, "ok", 1.0), + rows = [_row(0, 120.0, 80.0), # No band data: excluded, never treated as HR 0. - m.Row(86_400_000, 121.0, 81.0, None, None, None, "no_data", None)] + _row(86_400_000, 121.0, 81.0, hr=None, rm=None, q="no_data"), + # Quality 'ok' but the features are STILL absent: the feature + # exclusion is what must catch this one, not the quality rule. + _row(2 * 86_400_000, 122.0, 82.0, hr=None, rm=None, q="ok")] rep = m.run(rows) + assert rep["rows_excluded_quality"] == 1 assert rep["rows_excluded_no_features"] == 1 + # The only post-calibration target had no features: nothing is left to + # evaluate — an honest n = 0, not a fabricated prediction. + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + + +def test_fair_baseline_target_sets(): + # All models are evaluated on the SAME targets: identical n across + # last-cuff, cuff-mean and the model. + rows = [_row(0, 120.0, 80.0)] + for i in range(8): + rows.append(_row((i + 1) * 86_400_000, 120.0 + i, 80.0 + i * 0.5, + hr=60.0 + i * 3, rm=30.0 + i * 5)) + rep = m.run(rows) + for group in ("systolic", "diastolic"): + ns = {k: v["n"] for k, v in rep[group].items()} + assert len(set(ns.values())) == 1, ns + assert all(n == 8 for n in ns.values()), ns def test_chronological_replay(): # Back-dated rows: the CSV order must not matter, only measured_at_ms. - r1 = m.Row(86_400_000, 122.0, 82.0, 70.0, 40.0, None, "ok", 1.0) - r0 = m.Row(0, 120.0, 80.0, 70.0, 40.0, None, "ok", 1.0) + r1 = _row(86_400_000, 122.0, 82.0) + r0 = _row(0, 120.0, 80.0) a = m.run([r1, r0]) b = m.run([r0, r1]) - assert a["systolic"]["hr_hrv_model"]["mae_mmhg"] == \ - b["systolic"]["hr_hrv_model"]["mae_mmhg"] + assert a["systolic"]["adaptive_cuff_offset_baseline"]["mae_mmhg"] == \ + b["systolic"]["adaptive_cuff_offset_baseline"]["mae_mmhg"] if __name__ == "__main__": From 5e34ca43a73187cd7029ca75b066b1b721b0cc0b Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 09:22:56 +0000 Subject: [PATCH 09/22] fix(review): production beat query, collision-free beat identity, real level-A-to-B hand-over, strict session quality, snapshot-aware restore, store-side reference validation Co-authored-by: BucciMobile --- lib/data/db.dart | 694 +++++++++++++++------------- lib/health/bp_research_capture.dart | 82 +++- lib/ui2/profile/bp_research.dart | 16 +- test/bp_research_db_test.dart | 408 ++++++++++++++++ tool/bp_research_model.py | 78 +++- tool/test_bp_research_model.py | 73 +++ 6 files changed, 1001 insertions(+), 350 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index da596f509..0baf2d372 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -996,10 +996,16 @@ class LocalDb { // is what `id = ''` (kPrimaryDeviceId) means. Roles move, keys do // not. A no-op on a database with no device row yet. await _addColumnIfMissing( - db, 'device', 'role', "TEXT NOT NULL DEFAULT 'paired'", + db, + 'device', + 'role', + "TEXT NOT NULL DEFAULT 'paired'", ); await _addColumnIfMissing( - db, 'device', 'wearing', 'INTEGER NOT NULL DEFAULT 1', + db, + 'device', + 'wearing', + 'INTEGER NOT NULL DEFAULT 1', ); // A rung must no-op on a table this ladder has not created yet // (the same rule _addColumnIfMissing follows above). @@ -1033,7 +1039,9 @@ class LocalDb { await _rekeyByDeviceIdV51(db, 'band_events', keyTail: const ['hex']); await _rekeyByDeviceIdV51(db, 'events', keyTail: const ['hex']); await _rekeyByDeviceIdV51( - db, 'band_battery', keyTail: const ['ts', 'source'], + db, + 'band_battery', + keyTail: const ['ts', 'source'], ); // Step 6: coverage for the days the substrate still holds. Bounded, @@ -1051,7 +1059,9 @@ class LocalDb { // exactly its configured time, unchanged. No kAlgoVersion bump: // this is not a health metric. await _addColumnIfMissing( - db, 'alarm_schedule', 'smart_window_minutes', + db, + 'alarm_schedule', + 'smart_window_minutes', 'INTEGER NOT NULL DEFAULT 0', ); } @@ -1185,7 +1195,9 @@ class LocalDb { // just above for the same reasoning). await _createLiveWorkoutTally(db); await _addColumnIfMissing( - db, 'alarm_schedule', 'smart_window_minutes', + db, + 'alarm_schedule', + 'smart_window_minutes', 'INTEGER NOT NULL DEFAULT 0', ); await _createEcgTables(db); @@ -1447,7 +1459,6 @@ class LocalDb { static Future _ensureBeatTimeColumn(Database db) => _addColumnIfMissing(db, 'decoded_rr', 'beat_ts_ms', 'INTEGER'); - /// v46: retire what v34 banked into `on_wrist` / `hr_valid`, and any /// `skin_temp_c` that is really the sensor's unavailable sentinel. /// @@ -1608,7 +1619,6 @@ class LocalDb { ); } - /// BP research capture store (schema rung 55). /// /// EXPERIMENTAL, DEVELOPER-ONLY, and it stays that way. A cuff reading the @@ -1638,42 +1648,49 @@ class LocalDb { // doubles as both, and absent stays absent — no backfill. if (!refCols.contains('measurement_started_at_ms')) { await db.execute( - 'ALTER TABLE bp_research_reference ' - 'ADD COLUMN measurement_started_at_ms INTEGER'); + 'ALTER TABLE bp_research_reference ' + 'ADD COLUMN measurement_started_at_ms INTEGER', + ); } if (!refCols.contains('measurement_finished_at_ms')) { await db.execute( - 'ALTER TABLE bp_research_reference ' - 'ADD COLUMN measurement_finished_at_ms INTEGER'); + 'ALTER TABLE bp_research_reference ' + 'ADD COLUMN measurement_finished_at_ms INTEGER', + ); } // Band identity and session grouping, kept beside the capture so signal // provenance survives a device swap or a second band. if (!refCols.contains('band_device_id')) { await db.execute( - 'ALTER TABLE bp_research_reference ADD COLUMN band_device_id TEXT'); + 'ALTER TABLE bp_research_reference ADD COLUMN band_device_id TEXT', + ); } if (!refCols.contains('measurement_session_id')) { await db.execute( - 'ALTER TABLE bp_research_reference ' - 'ADD COLUMN measurement_session_id TEXT'); + 'ALTER TABLE bp_research_reference ' + 'ADD COLUMN measurement_session_id TEXT', + ); } // Precision of the recorded measurement instant ('minute' for the // current UI) — the analysis must know the pairing instant is not // second-accurate. if (!refCols.contains('time_precision')) { await db.execute( - 'ALTER TABLE bp_research_reference ADD COLUMN time_precision TEXT'); + 'ALTER TABLE bp_research_reference ADD COLUMN time_precision TEXT', + ); } final winCols = await _columnsOf(db, 'bp_research_window'); // Requested vs OBSERVED window bounds: what the data actually covered. if (!winCols.contains('observed_start_ms')) { await db.execute( - 'ALTER TABLE bp_research_window ADD COLUMN observed_start_ms INTEGER'); + 'ALTER TABLE bp_research_window ADD COLUMN observed_start_ms INTEGER', + ); } if (!winCols.contains('observed_end_ms')) { await db.execute( - 'ALTER TABLE bp_research_window ADD COLUMN observed_end_ms INTEGER'); + 'ALTER TABLE bp_research_window ADD COLUMN observed_end_ms INTEGER', + ); } // Quality counts (v2): honest coverage and continuity metrics, never a // fabricated confidence number. @@ -1698,14 +1715,15 @@ class LocalDb { // NEW revision row; old revisions stay. Research-only, same isolation // as the rung-55 tables. await db.execute( - 'CREATE TABLE IF NOT EXISTS bp_research_snapshot (' - 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' - 'reference_id INTEGER NOT NULL, ' - 'revision INTEGER NOT NULL, ' - 'onehz_json TEXT NOT NULL, ' - 'rr_json TEXT NOT NULL, ' - 'created_at_ms INTEGER NOT NULL, ' - 'UNIQUE (reference_id, revision))'); + 'CREATE TABLE IF NOT EXISTS bp_research_snapshot (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'reference_id INTEGER NOT NULL, ' + 'revision INTEGER NOT NULL, ' + 'onehz_json TEXT NOT NULL, ' + 'rr_json TEXT NOT NULL, ' + 'created_at_ms INTEGER NOT NULL, ' + 'UNIQUE (reference_id, revision))', + ); } static Future _createBpResearch(Database db) async { @@ -1767,6 +1785,24 @@ class LocalDb { List>? snapshotOnehzRows, List>? snapshotRrRows, }) async { + // STORE-SIDE validation, not just UI: any caller (a future import, a + // second screen) meets the same research bounds. Enforced BEFORE the + // transaction opens, so a rejected capture leaves no partial row, no + // window, no snapshot behind. Out-of-bounds is REJECTED, never + // corrected or clamped — a clamped reading is a fabricated one. + if (!c.systolicMmHg.isFinite || + !c.diastolicMmHg.isFinite || + c.systolicMmHg < kResearchSystolicBounds.$1 || + c.systolicMmHg > kResearchSystolicBounds.$2 || + c.diastolicMmHg < kResearchDiastolicBounds.$1 || + c.diastolicMmHg > kResearchDiastolicBounds.$2 || + c.diastolicMmHg >= c.systolicMmHg) { + throw ArgumentError( + 'Invalid BP research reference: systolic ' + '${c.systolicMmHg} / diastolic ${c.diastolicMmHg} mmHg is ' + 'outside the research bounds or dia >= sys.', + ); + } final db = await instance; await db.transaction((txn) async { // NULL never equals NULL in a UNIQUE constraint, so a retake with no @@ -1842,7 +1878,9 @@ class LocalDb { final w = c.window; if (w == null) { await txn.rawDelete( - 'DELETE FROM bp_research_window WHERE reference_id = ?', [id]); + 'DELETE FROM bp_research_window WHERE reference_id = ?', + [id], + ); return; } await txn.rawInsert( @@ -1885,11 +1923,13 @@ class LocalDb { // error instead of a silent history rewrite. Rows frozen as JSON // exactly as the window computation saw them. if (snapshotOnehzRows != null || snapshotRrRows != null) { - final maxRev = Sqflite.firstIntValue(await txn.rawQuery( - 'SELECT MAX(revision) FROM bp_research_snapshot ' - 'WHERE reference_id = ?', - [id], - )); + final maxRev = Sqflite.firstIntValue( + await txn.rawQuery( + 'SELECT MAX(revision) FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [id], + ), + ); final rev = (maxRev ?? 0) + 1; await txn.rawInsert( 'INSERT INTO bp_research_snapshot ' @@ -1912,7 +1952,6 @@ class LocalDb { }); } - /// All captures, newest first, for the dev screen and the CSV export. static Future>> bpResearchCaptures() async { final db = await instance; @@ -2055,8 +2094,9 @@ class LocalDb { 'VALUES(?, ?, ?)', [category, dedupeKey, candidate], ); - final n = - Sqflite.firstIntValue(await txn.rawQuery('SELECT changes()')); + final n = Sqflite.firstIntValue( + await txn.rawQuery('SELECT changes()'), + ); if (n == 1) return candidate; } throw StateError('notif_slots: no free slot within $probes probes'); @@ -2234,11 +2274,11 @@ class LocalDb { ); final batch = txn.batch(); for (var i = 0; i < packets.length; i++) { - batch.insert( - 'ecg_reading_packet', - {...packets[i], 'reading_id': reading['id'], 'ordinal': i}, - conflictAlgorithm: ConflictAlgorithm.fail, - ); + batch.insert('ecg_reading_packet', { + ...packets[i], + 'reading_id': reading['id'], + 'ordinal': i, + }, conflictAlgorithm: ConflictAlgorithm.fail); } await batch.commit(noResult: true); }); @@ -2260,9 +2300,7 @@ class LocalDb { } /// The accepted packets of [id] in ordinal order (placeholders included). - static Future>> ecgReadingPackets( - String id, - ) async { + static Future>> ecgReadingPackets(String id) async { final db = await instance; return db.query( 'ecg_reading_packet', @@ -2322,17 +2360,13 @@ class LocalDb { int smartWindowMinutes = 0, }) async { final db = await instance; - await db.insert( - 'alarm_schedule', - { - 'weekday': weekday, - 'hour': hour, - 'minute': minute, - 'enabled': enabled ? 1 : 0, - 'smart_window_minutes': smartWindowMinutes, - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.insert('alarm_schedule', { + 'weekday': weekday, + 'hour': hour, + 'minute': minute, + 'enabled': enabled ? 1 : 0, + 'smart_window_minutes': smartWindowMinutes, + }, conflictAlgorithm: ConflictAlgorithm.replace); } /// Wipe the whole weekly schedule — the "Cancel-all" half of disabling the @@ -2362,7 +2396,8 @@ class LocalDb { return db.query( 'decoded_onehz', columns: const ['rec_ts', 'hr', 'ax', 'ay', 'az'], - where: 'rec_ts >= ? AND rec_ts <= ? ' + where: + 'rec_ts >= ? AND rec_ts <= ? ' 'AND hr IS NOT NULL AND ax IS NOT NULL AND ay IS NOT NULL AND az IS NOT NULL', whereArgs: [sinceEpochSec, untilEpochSec], orderBy: 'rec_ts ASC', @@ -2636,14 +2671,7 @@ class LocalDb { '${blankAdapter ? 'adapter_id = NULL, ' : 'adapter_id = COALESCE(?, adapter_id), '}' 'remote_id = COALESCE(?, remote_id), label = COALESCE(?, label), ' 'tier = COALESCE(?, tier), last_seen = ? WHERE id = ?', - [ - if (!blankAdapter) adapterId, - remoteId, - label, - tier, - now, - id, - ], + [if (!blankAdapter) adapterId, remoteId, label, tier, now, id], ); } @@ -2872,11 +2900,7 @@ class LocalDb { int limit = 200, }) async { final db = await instance; - return db.query( - 'imported_workout', - orderBy: 'start_ts DESC', - limit: limit, - ); + return db.query('imported_workout', orderBy: 'start_ts DESC', limit: limit); } /// Drop one imported workout AND its route. `deleteSession` cannot do this — @@ -3097,13 +3121,16 @@ class LocalDb { // A DB whose ladder has not created these must no-op rather than throw. for (final t in const ['decoded_onehz', 'device_coverage']) { final present = await db.rawQuery( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [t], + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + [t], ); if (present.isEmpty) return; } // Pre-v47 shape has no device_id; every row is the primary by definition. final cols = await _columnsOf(db, 'decoded_onehz'); - final dev = cols.contains('device_id') ? 'device_id' : "'$kPrimaryDeviceId'"; + final dev = cols.contains('device_id') + ? 'device_id' + : "'$kPrimaryDeviceId'"; const kBucket = 60; // seconds — the resolver's grid // One interval may absorb a gap of up to this many buckets and stay open. @@ -3167,8 +3194,9 @@ class LocalDb { // RR beats live in their own table, one row per beat. final rrCols = await _columnsOf(db, 'decoded_rr'); if (rrCols.isNotEmpty) { - final rrDev = - rrCols.contains('device_id') ? 'device_id' : "'$kPrimaryDeviceId'"; + final rrDev = rrCols.contains('device_id') + ? 'device_id' + : "'$kPrimaryDeviceId'"; final rows = await db.rawQuery( 'SELECT $rrDev AS d, rec_ts / $kBucket AS b FROM decoded_rr ' 'WHERE rec_ts > 0 GROUP BY d, b ORDER BY d ASC, b ASC', @@ -3208,11 +3236,12 @@ class LocalDb { List? neutrals, Map? toleranceSec, }) async { - assert(samples.length == sampleSecs.length, - 'sampleSecs must be parallel to samples'); + assert( + samples.length == sampleSecs.length, + 'sampleSecs must be parallel to samples', + ); final seen = >{}; - void observe(String signal, int sec) => - (seen[signal] ??= []).add(sec); + void observe(String signal, int sec) => (seen[signal] ??= []).add(sec); for (var i = 0; i < samples.length; i++) { final s = samples[i]; if (s == null) continue; @@ -3272,10 +3301,12 @@ class LocalDb { orderBy: 'start_ts DESC', limit: 1, ); - final startTs = - open.isEmpty ? null : (open.single['start_ts'] as num).toInt(); - final endTs = - open.isEmpty ? null : (open.single['end_ts'] as num).toInt(); + final startTs = open.isEmpty + ? null + : (open.single['start_ts'] as num).toInt(); + final endTs = open.isEmpty + ? null + : (open.single['end_ts'] as num).toInt(); for (var i = 0; i < spans.length; i++) { final (firstSec, lastSec) = spans[i]; // EXTEND ONLY A SPAN THAT STARTS AT OR AFTER THE OPEN INTERVAL and is @@ -3303,16 +3334,12 @@ class LocalDb { ); } } else { - await txn.insert( - 'device_coverage', - { - 'device_id': deviceId, - 'signal': signal, - 'start_ts': firstSec, - 'end_ts': lastSec, - }, - conflictAlgorithm: ConflictAlgorithm.ignore, - ); + await txn.insert('device_coverage', { + 'device_id': deviceId, + 'signal': signal, + 'start_ts': firstSec, + 'end_ts': lastSec, + }, conflictAlgorithm: ConflictAlgorithm.ignore); } } } @@ -3566,14 +3593,13 @@ class LocalDb { int loSec, int hiSec, { required String deviceId, - }) => - _toggleSpans( - loSec, - hiSec, - onId: proto.EventId.wristOn, - offId: proto.EventId.wristOff, - deviceId: deviceId, - ); + }) => _toggleSpans( + loSec, + hiSec, + onId: proto.EventId.wristOn, + offId: proto.EventId.wristOff, + deviceId: deviceId, + ); /// Spans ([startSec, endSec]) in [loSec, hiSec) during which the band was on /// the charger — off-wrist by definition, and motionless. @@ -3581,14 +3607,13 @@ class LocalDb { int loSec, int hiSec, { required String deviceId, - }) => - _toggleSpans( - loSec, - hiSec, - onId: proto.EventId.chargingOff, - offId: proto.EventId.chargingOn, - deviceId: deviceId, - ); + }) => _toggleSpans( + loSec, + hiSec, + onId: proto.EventId.chargingOff, + offId: proto.EventId.chargingOn, + deviceId: deviceId, + ); /// Build "state active" spans from a pair of toggle events, clipped to /// [loSec, hiSec). [offId] opens a span; [onId] closes it. @@ -3789,6 +3814,7 @@ class LocalDb { String? trimToken, Map? extraCursors, List? archives, + /// Rows from a band with no framed record to decode — a notify sensor's /// beats, a ring's stamped temperature. Queued into the SAME transaction /// as [raws], so a source with no flash still gets the one durable write @@ -3807,20 +3833,21 @@ class LocalDb { // the host supplies it; a signal absent from the map defaults to // 2*kBucket (120s) inside [_extendCoverageVia]. Map? coverageToleranceSec, - }) => - _withCommitGate(() => _commitSyncBatchLocked( - raws, - samples, - trimToken: trimToken, - extraCursors: extraCursors, - archives: archives, - neutrals: neutrals, - ecgRawPackets: ecgRawPackets, - onCheckpoint: onCheckpoint, - deviceFamily: deviceFamily, - deviceId: deviceId, - coverageToleranceSec: coverageToleranceSec, - )); + }) => _withCommitGate( + () => _commitSyncBatchLocked( + raws, + samples, + trimToken: trimToken, + extraCursors: extraCursors, + archives: archives, + neutrals: neutrals, + ecgRawPackets: ecgRawPackets, + onCheckpoint: onCheckpoint, + deviceFamily: deviceFamily, + deviceId: deviceId, + coverageToleranceSec: coverageToleranceSec, + ), + ); static Future _commitSyncBatchLocked( List raws, @@ -4058,8 +4085,11 @@ class LocalDb { await setCursor(kCounter, '$maxCounter', txn: txn); await setCursor(kRecTs, '$maxRecTs', txn: txn); if (trimToken != null) { - await setCursor(cursorKeyFor('strap_trim', deviceId), trimToken, - txn: txn); + await setCursor( + cursorKeyFor('strap_trim', deviceId), + trimToken, + txn: txn, + ); } if (extraCursors != null) { for (final e in extraCursors.entries) { @@ -5631,12 +5661,14 @@ class LocalDb { List prepend = const [], List? primaryKey, }) { - final own = [ - for (final c in info) - if ((((c['pk'] as num?)?.toInt()) ?? 0) > 0) c, - ]..sort( - (a, b) => ((a['pk'] as num).toInt()).compareTo((b['pk'] as num).toInt()), - ); + final own = + [ + for (final c in info) + if ((((c['pk'] as num?)?.toInt()) ?? 0) > 0) c, + ]..sort( + (a, b) => + ((a['pk'] as num).toInt()).compareTo((b['pk'] as num).toInt()), + ); final key = primaryKey ?? [for (final c in own) c['name'] as String]; final inline = key.length == 1 ? key.first : null; final defs = [...prepend]; @@ -5709,14 +5741,7 @@ class LocalDb { final tmp = '_${table}_v47'; await db.execute('DROP TABLE IF EXISTS $tmp'); await db.execute( - 'CREATE TABLE $tmp (${_rebuildDdlBody( - info, - prepend: const [ - "device_id TEXT NOT NULL DEFAULT ''", - 'ts_ms INTEGER NOT NULL DEFAULT 0', - ], - primaryKey: ['device_id', 'ts_ms', ...keyTail], - )})', + 'CREATE TABLE $tmp (${_rebuildDdlBody(info, prepend: const ["device_id TEXT NOT NULL DEFAULT ''", 'ts_ms INTEGER NOT NULL DEFAULT 0'], primaryKey: ['device_id', 'ts_ms', ...keyTail])})', ); final cols = names.join(', '); // COALESCE because a declared PRIMARY KEY on a legacy rowid table does NOT @@ -5768,11 +5793,7 @@ class LocalDb { final tmp = '_${table}_v51'; await db.execute('DROP TABLE IF EXISTS $tmp'); await db.execute( - 'CREATE TABLE $tmp (${_rebuildDdlBody( - info, - prepend: ["device_id TEXT NOT NULL DEFAULT '$kPrimaryDeviceId'"], - primaryKey: ['device_id', ...keyTail], - )})', + 'CREATE TABLE $tmp (${_rebuildDdlBody(info, prepend: ["device_id TEXT NOT NULL DEFAULT '$kPrimaryDeviceId'"], primaryKey: ['device_id', ...keyTail])})', ); final cols = names.join(', '); await db.execute( @@ -6304,7 +6325,6 @@ class LocalDb { return (rawRecTs != null && rawRecTs > 0) ? rawRecTs : decoded.tsEpoch; } - /// Replaces this second's RR beats. Returns the ops queued. /// /// Clear the second before reinserting so a SHRINKING beat count can't strand @@ -6394,22 +6414,18 @@ class LocalDb { required String deviceId, }) { final recTs = n.tsEpoch; - batch.insert( - 'decoded_onehz', - { - 'device_id': deviceId, - 'ts_ms': recTs * 1000, - 'rec_ts': recTs, - 'counter': 0, - // Absent is NULL, never zeroed — same rule _queueDecodedOneHz - // follows for hr/accel/optical. - 'hr': n.hr, - 'skin_temp_c': n.skinTempC, - 'device_family': deviceFamily, - 'source': deviceFamily, - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + batch.insert('decoded_onehz', { + 'device_id': deviceId, + 'ts_ms': recTs * 1000, + 'rec_ts': recTs, + 'counter': 0, + // Absent is NULL, never zeroed — same rule _queueDecodedOneHz + // follows for hr/accel/optical. + 'hr': n.hr, + 'skin_temp_c': n.skinTempC, + 'device_family': deviceFamily, + 'source': deviceFamily, + }, conflictAlgorithm: ConflictAlgorithm.replace); var ops = 1; // SCOPED TO THE WRITING DEVICE. Clear the second before reinserting so a // shrinking beat count can't strand stale high-index beats — same @@ -6423,20 +6439,16 @@ class LocalDb { for (var i = 0; i < n.rrMs.length; i++) { final rr = n.rrMs[i]; if (rr <= 0) continue; - batch.insert( - 'decoded_rr', - { - 'device_id': deviceId, - 'ts_ms': recTs * 1000, - 'rec_ts': recTs, - 'beat_index': i, - 'rr_ts_ms': recTs * 1000, - 'rr_ms': rr, - 'device_family': deviceFamily, - 'source': deviceFamily, - }, - conflictAlgorithm: ConflictAlgorithm.replace, - ); + batch.insert('decoded_rr', { + 'device_id': deviceId, + 'ts_ms': recTs * 1000, + 'rec_ts': recTs, + 'beat_index': i, + 'rr_ts_ms': recTs * 1000, + 'rr_ms': rr, + 'device_family': deviceFamily, + 'source': deviceFamily, + }, conflictAlgorithm: ConflictAlgorithm.replace); ops++; } return ops; @@ -6667,7 +6679,9 @@ class LocalDb { /// retention edge anyway, so the cap is a backstop and not the normal case. /// INSERT OR IGNORE, so it can never overwrite a row the live writer already /// wrote. - static Future _backfillBandBatteryFromEvents(DatabaseExecutor db) async { + static Future _backfillBandBatteryFromEvents( + DatabaseExecutor db, + ) async { // A DB whose ladder has not created these yet (or is mid-ladder) must // NO-OP rather than throw. `redriveArchivedRecords` guards the same way and // for the same reason: a throw in here rolls the WHOLE upgrade back and @@ -6790,11 +6804,10 @@ class LocalDb { 'captured_at': capturedAt, }, conflictAlgorithm: ConflictAlgorithm.ignore); if (battery != null) { - await db.insert( - 'band_battery', - {'device_id': deviceId, ...battery}, - conflictAlgorithm: ConflictAlgorithm.ignore, - ); + await db.insert('band_battery', { + 'device_id': deviceId, + ...battery, + }, conflictAlgorithm: ConflictAlgorithm.ignore); } }, bestEffort: true); } @@ -7103,15 +7116,19 @@ class LocalDb { // the oldV<44 ladder step, where `decoded_onehz` is still keyed by rec_ts // alone and naming `device_id` would throw inside onUpgrade (quarantining // the database), and from the app/tests on a re-keyed table. One PRAGMA. - final preDeviceKey = - !(await _columnsOf(db, 'decoded_onehz')).contains('device_id'); + final preDeviceKey = !(await _columnsOf( + db, + 'decoded_onehz', + )).contains('device_id'); // Same self-detection as `preDeviceKey` above, for `raw_archive`'s own // rekey (v51): this function runs from the oldV<44 rung too, i.e. BEFORE // the v51 rekey, so at that point `raw_archive` is still hex-keyed and the // row-value comparison below must fall back to comparing `hex` alone. - final preArchiveDeviceKey = - !(await _columnsOf(db, 'raw_archive')).contains('device_id'); + final preArchiveDeviceKey = !(await _columnsOf( + db, + 'raw_archive', + )).contains('device_id'); final marks = List.filled(redrivableArchiveReasons.length, '?').join(','); // Paged on (hex, device_id) — a stable, total order that needs no extra @@ -7147,8 +7164,9 @@ class LocalDb { // one another. final byDeviceRecTs = <(String, int), (RawRecord, Sample)>{}; for (final r in rows) { - final deviceId = - preArchiveDeviceKey ? kPrimaryDeviceId : r['device_id'] as String; + final deviceId = preArchiveDeviceKey + ? kPrimaryDeviceId + : r['device_id'] as String; final raw = RawRecord( counter: (r['counter'] as num?)?.toInt() ?? 0, packetType: (r['packet_type'] as num?)?.toInt() ?? 0, @@ -7819,7 +7837,11 @@ class LocalDb { final db = await instance; final name = signal.name; await db.transaction((txn) async { - await txn.delete('signal_priority', where: 'signal = ?', whereArgs: [name]); + await txn.delete( + 'signal_priority', + where: 'signal = ?', + whereArgs: [name], + ); for (var i = 0; i < order.length; i++) { await txn.insert('signal_priority', { 'signal': name, @@ -7863,7 +7885,10 @@ class LocalDb { /// Sparse: an absent signal falls through to `rankSources()` (§4.5's ladder). static Future>> signalPriorities() async { final db = await instance; - final rows = await db.query('signal_priority', orderBy: 'signal ASC, rank ASC, device_id ASC'); + final rows = await db.query( + 'signal_priority', + orderBy: 'signal ASC, rank ASC, device_id ASC', + ); final out = >{}; for (final r in rows) { (out[r['signal'] as String] ??= []).add(r['device_id'] as String); @@ -8965,85 +8990,85 @@ class LocalDb { /// merges, in order: independent tables first; all use INSERT OR /// REPLACE so re-import is safe. static const List _restoreTables = [ - // Hand-entered rows first. Nothing regenerates these, so if a merge is - // ever cut short (an OOM, a damaged source) they are the ones already - // banked. They were also simply MISSING here until now — nutrition, - // medication, strength sets, symptoms and routes did not survive a - // backup/restore round trip at all, the same omission `wipeAll` documents. - 'bp_research_reference', - 'bp_research_window', - 'bp_research_snapshot', - 'journal', - 'journal_metric', - 'journal_field_def', - 'lab_result', - 'lab_marker_def', - 'strength_set', - 'exercise_def', - 'food_entry', - 'food_def', - 'med_def', - 'med_dose', - 'cycle_log', - 'cycle_symptom', - 'breathing_session', - // Vendor-computed, typed-in and imported scalars. In the hand-entered - // block because a third of it IS hand-entered and nothing regenerates - // any of it — a `reports` band trims its own history, and the app whose - // export the imported rows came from may be uninstalled by now. - 'observation', - 'workout_route', - 'workout_split', - // The user's sleep corrections. These are the ONLY copy of them — the - // detector's output is deliberately not baked in, so a restore that - // skipped these would silently reinstate every nap the user had deleted - // and lose every one they logged. - 'sleep_override', - 'sleep_nap', - 'samples', - 'events', - 'decoded_onehz', - 'decoded_rr', - // The only copy of what a paired sensor measured during a session — the - // band cannot re-deliver it, so a restore that skipped it loses it. - 'external_hr', - // Re-readable from the health store, but only for as long as that app is - // installed and that permission is granted — cheaper to carry. - 'imported_measurement', - // Same reasoning, and more so: a route is thousands of points that the - // source app may have deleted since. `workout_route` is already in this - // list above and carries the imported routes too. - 'imported_workout', - // The never-pruned archive of frames we could not decode. exportCopy() - // is a whole-database VACUUM INTO, so these rows DO leave the device — - // leaving the table out here meant a backup/restore round trip silently - // dropped them, in the one table whose entire purpose is that a frame is - // never lost. Keyed by `hex`, so two same-counter frames from different - // boots both survive the merge. - 'raw_archive', - 'band_events', - 'band_battery', - 'day_result', - 'metric_series', - 'metric_series_version', - 'sessions', - 'notifications', - 'baselines', - // The devices this phone knows about — so a SECONDARY device's identity - // survives a backup/restore round trip rather than leaving its rows in - // `decoded_onehz` pointing at a `device_id` nothing can name. The PRIMARY - // row is deliberately skipped on the way in; see the guard below. - 'device', - 'device_coverage', - 'signal_priority', - // WHOOP MG ECG: a user-initiated reading, its exact accepted packets - // and the raw R16 records history recovered for it. None regenerates — - // the band trimmed its copy on ACK. Parent before child so a restore - // cut short never leaves packets without their reading. - 'ecg_reading', - 'ecg_reading_packet', - 'ecg_raw_packet', - 'sync_cursor', + // Hand-entered rows first. Nothing regenerates these, so if a merge is + // ever cut short (an OOM, a damaged source) they are the ones already + // banked. They were also simply MISSING here until now — nutrition, + // medication, strength sets, symptoms and routes did not survive a + // backup/restore round trip at all, the same omission `wipeAll` documents. + 'bp_research_reference', + 'bp_research_window', + 'bp_research_snapshot', + 'journal', + 'journal_metric', + 'journal_field_def', + 'lab_result', + 'lab_marker_def', + 'strength_set', + 'exercise_def', + 'food_entry', + 'food_def', + 'med_def', + 'med_dose', + 'cycle_log', + 'cycle_symptom', + 'breathing_session', + // Vendor-computed, typed-in and imported scalars. In the hand-entered + // block because a third of it IS hand-entered and nothing regenerates + // any of it — a `reports` band trims its own history, and the app whose + // export the imported rows came from may be uninstalled by now. + 'observation', + 'workout_route', + 'workout_split', + // The user's sleep corrections. These are the ONLY copy of them — the + // detector's output is deliberately not baked in, so a restore that + // skipped these would silently reinstate every nap the user had deleted + // and lose every one they logged. + 'sleep_override', + 'sleep_nap', + 'samples', + 'events', + 'decoded_onehz', + 'decoded_rr', + // The only copy of what a paired sensor measured during a session — the + // band cannot re-deliver it, so a restore that skipped it loses it. + 'external_hr', + // Re-readable from the health store, but only for as long as that app is + // installed and that permission is granted — cheaper to carry. + 'imported_measurement', + // Same reasoning, and more so: a route is thousands of points that the + // source app may have deleted since. `workout_route` is already in this + // list above and carries the imported routes too. + 'imported_workout', + // The never-pruned archive of frames we could not decode. exportCopy() + // is a whole-database VACUUM INTO, so these rows DO leave the device — + // leaving the table out here meant a backup/restore round trip silently + // dropped them, in the one table whose entire purpose is that a frame is + // never lost. Keyed by `hex`, so two same-counter frames from different + // boots both survive the merge. + 'raw_archive', + 'band_events', + 'band_battery', + 'day_result', + 'metric_series', + 'metric_series_version', + 'sessions', + 'notifications', + 'baselines', + // The devices this phone knows about — so a SECONDARY device's identity + // survives a backup/restore round trip rather than leaving its rows in + // `decoded_onehz` pointing at a `device_id` nothing can name. The PRIMARY + // row is deliberately skipped on the way in; see the guard below. + 'device', + 'device_coverage', + 'signal_priority', + // WHOOP MG ECG: a user-initiated reading, its exact accepted packets + // and the raw R16 records history recovered for it. None regenerates — + // the band trimmed its copy on ACK. Parent before child so a restore + // cut short never leaves packets without their reading. + 'ecg_reading', + 'ecg_reading_packet', + 'ecg_raw_packet', + 'sync_cursor', ]; @visibleForTesting @@ -9069,6 +9094,7 @@ class LocalDb { final info = await db.rawQuery('PRAGMA table_info($t)'); return {for (final c in info) (c['name'] as String)}; } + Future srcHasTable(String t, Database s) async { // A salvage source may predate the window table; `SELECT *` on a // missing table throws, so probe for its existence first. @@ -9088,6 +9114,10 @@ class LocalDb { // DIFFERENT snapshot under the same (reference, revision) key — // immutable history is never overwritten, the source file keeps them. var skippedSnapshots = 0; + // Window rows skipped on import because the snapshot revision their + // features were computed from conflicts with the destination's local + // revision — a window may never point at a foreign snapshot. + var skippedWindows = 0; // DISTINCT DAYS ACTUALLY WRITTEN — the number the caller reports as // "N days imported". // @@ -9166,15 +9196,18 @@ class LocalDb { final refCols = await destCols('bp_research_reference'); final winCols = await destCols('bp_research_window'); final snapCols = await destCols('bp_research_snapshot'); - final srcRefs = t == 'bp_research_reference' && + final srcRefs = + t == 'bp_research_reference' && await srcHasTable('bp_research_reference', src) ? await src.rawQuery('SELECT * FROM bp_research_reference') : const >[]; - final srcWins = t == 'bp_research_window' && + final srcWins = + t == 'bp_research_window' && await srcHasTable('bp_research_window', src) ? await src.rawQuery('SELECT * FROM bp_research_window') : const >[]; - final srcSnaps = t == 'bp_research_snapshot' && + final srcSnaps = + t == 'bp_research_snapshot' && await srcHasTable('bp_research_snapshot', src) ? await src.rawQuery('SELECT * FROM bp_research_snapshot') : const >[]; @@ -9248,14 +9281,82 @@ class LocalDb { bpIdMap[srcId.toInt()] = destId; } } + // SNAPSHOT PASS FIRST. A window row names the snapshot + // revision its features were computed from, so the window + // may only be imported when that revision exists HERE with + // the SAME content. Doing snapshots first builds the + // (dest_reference_id, dest_revision) -> status map the + // window pass then checks against — the invariant being: + // window features must belong to exactly the snapshot they + // were computed from, never to a local revision that + // happens to share the number but holds different rows. + final snapStatus = <(int, int), String>{}; + for (final sn in srcSnaps) { + final row = { + for (final e in sn.entries) + if (snapCols.contains(e.key)) e.key: e.value, + }; + final mapped = + bpIdMap[(row.remove('reference_id') as num?)?.toInt()]; + final rev = (row['revision'] as num?)?.toInt(); + if (mapped == null || rev == null) continue; + final clash = await txn.rawQuery( + 'SELECT onehz_json, rr_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = ?', + [mapped, rev], + ); + if (clash.isNotEmpty) { + // IMMUTABLE HISTORY: an existing revision is never + // overwritten. Identical content = idempotent re-import + // (status 'identical'); different content = a conflict + // the window pass must respect (status 'conflict') — + // the source revision stays available in the source + // backup, nothing is lost, nothing is rewritten. + final same = + clash.first['onehz_json'] == row['onehz_json'] && + clash.first['rr_json'] == row['rr_json']; + snapStatus[(mapped, rev)] = same ? 'identical' : 'conflict'; + if (!same) skippedSnapshots++; + } else { + await txn.rawInsert( + 'INSERT INTO bp_research_snapshot ' + '(reference_id, revision, onehz_json, rr_json, ' + 'created_at_ms) VALUES (?, ?, ?, ?, ?)', + [ + mapped, + rev, + row['onehz_json'], + row['rr_json'], + row['created_at_ms'], + ], + ); + snapStatus[(mapped, rev)] = 'inserted'; + } + } for (final w in srcWins) { final row = { for (final e in w.entries) if (winCols.contains(e.key)) e.key: e.value, }; - final mapped = bpIdMap[ - (row.remove('reference_id') as num?)?.toInt()]; + final mapped = + bpIdMap[(row.remove('reference_id') as num?)?.toInt()]; if (mapped == null) continue; + // WINDOW/SNAPSHOT CONSISTENCY: a window that names a + // snapshot revision may only be imported when that + // revision is HERE with the same content ('inserted' or + // 'identical'). A 'conflict' means the local revision n + // holds DIFFERENT rows than the source window's features + // were computed from — importing it would point features + // at a foreign snapshot. The window is skipped and + // counted; the destination keeps its own consistent pair. + final rev = (row['snapshot_revision'] as num?)?.toInt(); + if (rev != null) { + final status = snapStatus[(mapped, rev)]; + if (status == null || status == 'conflict') { + skippedWindows++; + continue; + } + } await txn.rawInsert( 'INSERT OR REPLACE INTO bp_research_window ' '(reference_id, window_start_ms, window_end_ms, ' @@ -9291,55 +9392,17 @@ class LocalDb { ], ); } - for (final sn in srcSnaps) { - final row = { - for (final e in sn.entries) - if (snapCols.contains(e.key)) e.key: e.value, - }; - final mapped = bpIdMap[ - (row.remove('reference_id') as num?)?.toInt()]; - if (mapped == null) continue; - // Snapshots are IMMUTABLE: a colliding (reference, revision) - // key with DIFFERENT content is skipped, not overwritten — - // the destination history cannot be rewritten by an import, - // and the source revision stays available in the source - // file. A byte-identical collision is a no-op (idempotent - // re-import). - final rev = (row['revision'] as num?)?.toInt(); - if (rev == null) continue; - final clash = await txn.rawQuery( - 'SELECT onehz_json, rr_json FROM bp_research_snapshot ' - 'WHERE reference_id = ? AND revision = ?', - [mapped, rev], - ); - if (clash.isNotEmpty) { - final same = clash.first['onehz_json'] == row['onehz_json'] && - clash.first['rr_json'] == row['rr_json']; - if (!same) skippedSnapshots++; - continue; - } - await txn.rawInsert( - 'INSERT INTO bp_research_snapshot ' - '(reference_id, revision, onehz_json, rr_json, ' - 'created_at_ms) VALUES (?, ?, ?, ?, ?)', - [ - mapped, - rev, - row['onehz_json'], - row['rr_json'], - row['created_at_ms'], - ], - ); - } }); counts[t] = t == 'bp_research_reference' ? srcRefs.length : t == 'bp_research_window' - ? srcWins.length - // Snapshots whose (reference, revision) key collided - // with DIFFERENT content were skipped, not imported — - // the count must not claim them. - : srcSnaps.length - skippedSnapshots; + // Windows skipped over a snapshot conflict were not + // imported — the count must not claim them. + ? srcWins.length - skippedWindows + // Snapshots whose (reference, revision) key collided + // with DIFFERENT content were skipped, not imported — + // the count must not claim them. + : srcSnaps.length - skippedSnapshots; } catch (_) { if (!tolerant) rethrow; counts[t] = 0; @@ -9997,7 +10060,8 @@ class LocalDb { final db = await instance; return db.query( 'metric_series', - where: 'key = ? AND value IS NOT NULL' + where: + 'key = ? AND value IS NOT NULL' '${measuredOnly ? ' AND date NOT IN ($_importedDatesSql)' : ''}', whereArgs: [key], orderBy: 'date ASC', diff --git a/lib/health/bp_research_capture.dart b/lib/health/bp_research_capture.dart index 40f0e944c..3cf04d3fd 100644 --- a/lib/health/bp_research_capture.dart +++ b/lib/health/bp_research_capture.dart @@ -198,9 +198,15 @@ class BpResearchCapture { /// invented duration. final int? measurementFinishedAtMs; - /// 'minute' — the precision of [measuredAtMs] as recorded by the current - /// UI. Documented so an analysis knows the pairing instant is not - /// second-accurate; a future finer-grained UI would record 'second'. + /// The precision of the USER-REPORTED measurement time ('minute' for + /// the current UI) — NOT a property of the stored millisecond timestamp + /// itself. measuredAtMs always carries full millisecond precision; when + /// the user typed "14:30" or left the field empty, only the MINUTE part + /// of that timestamp is meaningful, and this field says so. An analysis + /// must not treat the seconds/millis of a user-typed instant as known. + /// (Back-dated instants are truncated to the minute; an empty field + /// stores the entry moment as-is — its precision documents the reported + /// time, not the anchor's technical resolution.) final String? timePrecision; /// When the pair was TYPED IN. A retro capture entered hours later has @@ -238,29 +244,54 @@ class BpResearchCapture { const (double, double) kResearchSystolicBounds = (50, 300); const (double, double) kResearchDiastolicBounds = (20, 200); -/// The row key that identifies ONE beat: the measured sub-second instant -/// when the decoder provides it (`beat_ts_ms`), otherwise the whole-second -/// record time plus the beat's index within that record. rr_ts_ms alone is -/// rec_ts*1000 for EVERY beat of a record, so keying by it would collapse -/// all beats of a second into one and corrupt every RMSSD. -int _beatKey(Map r) { +/// The measured beat instant when the decoder provides it (`beat_ts_ms`), +/// otherwise the whole-second record time. rr_ts_ms alone is rec_ts*1000 +/// for EVERY beat of a record, so the beat POSITION falls back to the +/// record second on legacy rows — a documented heuristic: beats of one +/// second then share a position, and continuity pairs across them can +/// only under-reject, never fabricate differences. +int _beatTimeMs(Map r) { final beatTs = r['beat_ts_ms']; if (beatTs is num && beatTs > 0) return beatTs.toInt(); final ts = r['rr_ts_ms']; - final idx = r['beat_index']; - return ((ts is num ? ts.toInt() : 0) << 8) | (idx is num ? idx.toInt() : 0); + return ts is num ? ts.toInt() : 0; } -/// The beat's position on the time axis for continuity checks: the measured -/// instant when the decoder provides it, otherwise the whole-second record -/// time (a documented heuristic — beats of one second then share a time, -/// and pairs of those are still treated as contiguous, which can only -/// under-reject, never fabricate differences). -int _beatTimeMs(Map r) { +/// BEAT IDENTITY — collision-free by construction, never bit-packed: +/// the measured beat instant when present, otherwise the whole-second +/// record time AND the beat's index within the record. Neither component +/// is truncated or masked, so any beat_index range (and negative or junk +/// values) can only produce distinct keys, never a silent collision. +// ignore: avoid_redundant_argument_values +/// BEAT IDENTITY — collision-free by construction, never bit-packed: +/// the measured beat instant when present ('b', beat_ts_ms, 0), +/// otherwise the whole-second record time AND the beat's index within +/// the record ('r', rr_ts_ms, beat_index). Neither component is +/// truncated or masked, so any beat_index range — and negative or junk +/// values — can only produce distinct keys, never a silent collision. +(String, int, int) _beatKey(Map r) { final beatTs = r['beat_ts_ms']; - if (beatTs is num && beatTs > 0) return beatTs.toInt(); + if (beatTs is num && beatTs > 0) return ('b', beatTs.toInt(), 0); final ts = r['rr_ts_ms']; - return ts is num ? ts.toInt() : 0; + final idx = r['beat_index']; + return ('r', ts is num ? ts.toInt() : 0, idx is num ? idx.toInt() : 0); +} + +/// Deterministic beat order: measured beat time first (when present), +/// then the whole-second record time, then the beat index. Ties beyond +/// that are true duplicates and fall to the dedup pass — List.sort is +/// NOT stable in Dart, so no rule may silently depend on compare == 0. +int _beatOrder(Map a, Map b) { + final at = _beatTimeMs(a); + final bt = _beatTimeMs(b); + if (at != bt) return at < bt ? -1 : 1; + final ar = (a['rr_ts_ms'] as num?)?.toInt() ?? 0; + final br = (b['rr_ts_ms'] as num?)?.toInt() ?? 0; + if (ar != br) return ar < br ? -1 : 1; + final ai = (a['beat_index'] as num?)?.toInt() ?? 0; + final bi = (b['beat_index'] as num?)?.toInt() ?? 0; + if (ai != bi) return ai < bi ? -1 : 1; + return 0; } /// Compute the frozen band window around the MEASUREMENT instant @@ -339,15 +370,20 @@ BpResearchWindow? researchWindowFrom({ } } + // Window membership follows the beat's real position: the measured + // sub-second instant when the row carries one, otherwise the record + // second — the SAME rule the production query filters by, so no beat + // can be admitted by the query and then re-rejected here (or vice + // versa) because its record second and its measured instant disagree. final rrAll = rrRows.where((r) { - final ts = r['rr_ts_ms']; - return ts is num && ts >= start && ts < end; - }).toList()..sort((a, b) => _beatTimeMs(a).compareTo(_beatTimeMs(b))); + final t = _beatTimeMs(r); + return t >= start && t < end; + }).toList()..sort(_beatOrder); // Dedup by BEAT IDENTITY, not by rr_ts_ms — beats of one record differ // in beat_index and, when the decoder provides it, beat_ts_ms. final rrDedup = >[]; { - int? lastKey; + (String, int, int)? lastKey; for (final r in rrAll) { final key = _beatKey(r); if (lastKey == key) continue; diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart index 1c19a8161..8ba8f4082 100644 --- a/lib/ui2/profile/bp_research.dart +++ b/lib/ui2/profile/bp_research.dart @@ -192,10 +192,20 @@ class _BpResearchScreenState extends State { 'ORDER BY rec_ts ASC', [LocalDb.kPrimaryDeviceId, start ~/ 1000, end ~/ 1000], ); + // The full beat identity rides along: beat_index (always present in + // decoded_rr) and beat_ts_ms (the measured sub-second instant, NULL + // on rows banked before that column existed — _ensureBeatTimeColumn + // guarantees the COLUMN on every open, old data keeps NULL values). + // Window membership uses the beat's real position when known: + // COALESCE(beat_ts_ms, rr_ts_ms) — a beat whose record second lies + // in the window but whose measured instant does not (or vice versa) + // is filtered by where the beat actually was, not by its record. final rr = await db.rawQuery( - 'SELECT rr_ts_ms, rr_ms FROM decoded_rr ' - 'WHERE device_id = ? AND rr_ts_ms >= ? AND rr_ts_ms <= ? ' - 'ORDER BY rr_ts_ms ASC', + 'SELECT rr_ts_ms, rr_ms, beat_index, beat_ts_ms FROM decoded_rr ' + 'WHERE device_id = ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) >= ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) < ? ' + 'ORDER BY rr_ts_ms ASC, beat_index ASC', [LocalDb.kPrimaryDeviceId, start, end], ); final window = researchWindowFrom( diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart index c9b98cf5c..a5bc02b5e 100644 --- a/test/bp_research_db_test.dart +++ b/test/bp_research_db_test.dart @@ -602,4 +602,412 @@ void main() { expect(n, 1); await databaseFactory.deleteDatabase(srcPath); }); + + test('a restore whose snapshot conflicts skips the window too ' + '(window/snapshot consistency)', () async { + final db0 = await LocalDb.instance; + await db0.delete('bp_research_snapshot'); + await db0.delete('bp_research_window'); + await db0.delete('bp_research_reference'); + // LOCAL: a capture with snapshot revision 1 (rows A) and a window + // pointing at it. + final rowsA = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'cuff', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rowsA, + rrRows: const [], + ), + ), + snapshotOnehzRows: rowsA, + snapshotRrRows: const [], + ); + final localId = + (await db0.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + final localJson = + (await db0.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [localId], + )).first['onehz_json'] + as String; + final localHr = (await db0.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [localId], + )).first['hr_mean']; + + // FOREIGN: the SAME natural reference, a snapshot revision 1 with + // DIFFERENT rows (B), and a window whose features came from B — + // importing that window would point features at local revision 1, + // which holds A. Both must be skipped; the local pair stays. + final srcPath = p.join( + await databaseFactory.getDatabasesPath(), + 'bp_foreign_conflict.db', + ); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, device TEXT, posture TEXT, ' + 'conditions TEXT, systolic_mmhg REAL NOT NULL, ' + 'diastolic_mmhg REAL NOT NULL, captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); + await src.insert('bp_research_reference', { + 'id': 42, + 'measured_at_ms': _at, + 'device': 'cuff', + 'systolic_mmhg': 121, + 'diastolic_mmhg': 81, + 'captured_at_ms': _at, + }); + await src.execute( + 'CREATE TABLE bp_research_window (' + 'reference_id INTEGER PRIMARY KEY, window_start_ms INTEGER NOT NULL, ' + 'window_end_ms INTEGER NOT NULL, onehz_rows INTEGER, rr_beats INTEGER, ' + 'hr_mean REAL, rr_ms_mean REAL, rr_ms_min REAL, rr_ms_max REAL, ' + 'rmssd_ms REAL, meta_json TEXT, observed_start_ms INTEGER, ' + 'observed_end_ms INTEGER, valid_hr_seconds INTEGER, ' + 'valid_interval_count INTEGER, valid_interval_pair_count INTEGER, ' + 'coverage_fraction REAL, rejected_interval_fraction REAL, ' + 'quality_status TEXT, feature_version INTEGER, ' + 'snapshot_revision INTEGER)', + ); + await src.insert('bp_research_window', { + 'reference_id': 42, + 'window_start_ms': _at - 300000, + 'window_end_ms': _at, + 'onehz_rows': 300, + 'hr_mean': 77.7, + 'feature_version': 3, + 'snapshot_revision': 1, + }); + await src.execute( + 'CREATE TABLE bp_research_snapshot (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, reference_id INTEGER NOT NULL, ' + 'revision INTEGER NOT NULL, onehz_json TEXT NOT NULL, ' + 'rr_json TEXT NOT NULL, created_at_ms INTEGER NOT NULL, ' + 'UNIQUE (reference_id, revision))', + ); + await src.insert('bp_research_snapshot', { + 'reference_id': 42, + 'revision': 1, + 'onehz_json': '[{"rec_ts":${(_at - 60000) ~/ 1000},"hr":99}]', + 'rr_json': '[]', + 'created_at_ms': _at, + }); + await src.close(); + await LocalDb.importFromDbFile(srcPath); + + final db = await LocalDb.instance; + // The local snapshot revision 1 is untouched — foreign content lost. + final snap = await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [localId], + ); + expect(snap, hasLength(1)); + expect(snap.first['onehz_json'], localJson); + // The foreign window was SKIPPED: the local window survives. + final win = await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [localId], + ); + expect(win, hasLength(1)); + expect(win.first['hr_mean'], localHr); + await databaseFactory.deleteDatabase(srcPath); + }); + + test( + 'a restore with an IDENTICAL snapshot is idempotent (window too)', + () async { + final db = await LocalDb.instance; + final localId = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference', + )).first['id'] + as int; + final localJson = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [localId], + )).first['onehz_json'] + as String; + // The SAME snapshot content under the same key: idempotent + // re-import, no duplicate revision rows, the window converges. + final srcPath = p.join( + await databaseFactory.getDatabasesPath(), + 'bp_foreign_ident.db', + ); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, device TEXT, posture TEXT, ' + 'conditions TEXT, systolic_mmhg REAL NOT NULL, ' + 'diastolic_mmhg REAL NOT NULL, captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); + await src.insert('bp_research_reference', { + 'id': 43, + 'measured_at_ms': _at, + 'device': 'cuff', + 'systolic_mmhg': 120, + 'diastolic_mmhg': 80, + 'captured_at_ms': _at, + }); + await src.execute( + 'CREATE TABLE bp_research_window (' + 'reference_id INTEGER PRIMARY KEY, window_start_ms INTEGER NOT NULL, ' + 'window_end_ms INTEGER NOT NULL, onehz_rows INTEGER, rr_beats INTEGER, ' + 'hr_mean REAL, rr_ms_mean REAL, rr_ms_min REAL, rr_ms_max REAL, ' + 'rmssd_ms REAL, meta_json TEXT, observed_start_ms INTEGER, ' + 'observed_end_ms INTEGER, valid_hr_seconds INTEGER, ' + 'valid_interval_count INTEGER, valid_interval_pair_count INTEGER, ' + 'coverage_fraction REAL, rejected_interval_fraction REAL, ' + 'quality_status TEXT, feature_version INTEGER, ' + 'snapshot_revision INTEGER)', + ); + await src.insert('bp_research_window', { + 'reference_id': 43, + 'window_start_ms': _at - 300000, + 'window_end_ms': _at, + 'onehz_rows': 1, + 'hr_mean': 60, + 'feature_version': 3, + 'snapshot_revision': 1, + }); + await src.execute( + 'CREATE TABLE bp_research_snapshot (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, reference_id INTEGER NOT NULL, ' + 'revision INTEGER NOT NULL, onehz_json TEXT NOT NULL, ' + 'rr_json TEXT NOT NULL, created_at_ms INTEGER NOT NULL, ' + 'UNIQUE (reference_id, revision))', + ); + await src.insert('bp_research_snapshot', { + 'reference_id': 43, + 'revision': 1, + 'onehz_json': localJson, + 'rr_json': '[]', + 'created_at_ms': _at, + }); + await src.close(); + await LocalDb.importFromDbFile(srcPath); + await LocalDb.importFromDbFile(srcPath); + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [localId], + ); + expect(snaps.first['c'], 1); + final win = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id = ?', + [localId], + ); + expect(win.first['c'], 1); + await databaseFactory.deleteDatabase(srcPath); + }, + ); + + test('the store rejects invalid references before writing anything', () async { + final db = await LocalDb.instance; + final before = + (await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_reference', + )).first['c'] + as int; + BpResearchCapture ref(int m, double sys, double dia) => BpResearchCapture( + measuredAtMs: m, + systolicMmHg: sys, + diastolicMmHg: dia, + capturedAtMs: m, + device: 'validate', + window: _win, + ); + // NaN / infinity: rejected, never laundered through the bounds check. + for (final bad in [ + ref(_at + 1000000, double.nan, 80), + ref(_at + 1000000, double.infinity, 80), + ref(_at + 1000000, 120, double.nan), + ref(_at + 1000000, 120, double.negativeInfinity), + // Out of research bounds. + ref(_at + 1000000, 301, 80), + ref(_at + 1000000, 49, 80), + ref(_at + 1000000, 120, 201), + ref(_at + 1000000, 120, 19), + // dia >= sys. + ref(_at + 1000000, 120, 120), + ref(_at + 1000000, 110, 120), + ]) { + await expectLater(LocalDb.putBpResearchCapture(bad), throwsArgumentError); + } + // Boundary values are VALID: 300/200 passes the bounds, dia < sys. + await LocalDb.putBpResearchCapture(ref(_at + 1000000, 300, 200)); + // Nothing partial was left behind by the rejected writes. + final after = + (await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_reference', + )).first['c'] + as int; + expect(after, before + 1); + // None of the REJECTED writes left a window or snapshot row behind: + // the only window/snapshot rows are the ones that BELONG to the one + // valid reference (rows with no owning reference must not exist). + final orphanW = + (await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + )).first['c'] + as int; + final orphanS = + (await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id NOT IN (SELECT id FROM bp_research_reference)', + )).first['c'] + as int; + expect(orphanW, 0); + expect(orphanS, 0); + await LocalDb.deleteBpResearchCapture(_at + 1000000); + }); + + test('the production beat query and window computation keep every beat ' + 'of one record (integration)', () async { + // The FULL production path, not synthetic maps: real decoded_rr rows + // (several beats of ONE record share rr_ts_ms = rec_ts*1000), the + // same COALESCE query the capture screen runs, the snapshot freeze, + // and researchWindowFrom on the queried rows. + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_rr'); + final recTs = (_at - 60000) ~/ 1000; // inside the rest window + // FOUR beats of that one record: identical rr_ts_ms, distinct + // beat_index; one carries a measured beat_ts_ms. + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 0, + 'rec_ts': recTs, + 'beat_index': 0, + 'rr_ts_ms': recTs * 1000, + 'rr_ms': 1000, + }); + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 0, + 'rec_ts': recTs, + 'beat_index': 1, + 'rr_ts_ms': recTs * 1000, + 'rr_ms': 1100, + }); + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 0, + 'rec_ts': recTs, + 'beat_index': 2, + 'rr_ts_ms': recTs * 1000, + 'rr_ms': 900, + }); + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 0, + 'rec_ts': recTs, + 'beat_index': 3, + 'rr_ts_ms': recTs * 1000, + 'beat_ts_ms': recTs * 1000 + 3000, + 'rr_ms': 1050, + }); + // THE PRODUCTION QUERY (same shape as the capture screen). + final start = _at - kResearchRestPreMs; + final end = _at + kResearchWindowPostMs; + final rr = await db.rawQuery( + 'SELECT rr_ts_ms, rr_ms, beat_index, beat_ts_ms FROM decoded_rr ' + 'WHERE device_id = ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) >= ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) < ? ' + 'ORDER BY rr_ts_ms ASC, beat_index ASC', + [LocalDb.kPrimaryDeviceId, start, end], + ); + expect(rr, hasLength(4)); // no beat was dropped as a "duplicate" + final w = researchWindowFrom( + measuredAtMs: _at, + onehzRows: const [], + rrRows: rr, + ); + expect(w, isNotNull); + expect(w!.rrBeats, 4); // all four beats survive the window computation + expect(w.validIntervalCount, 4); + // Beat 3 was MEASURED 3000 ms after beat 2 — beyond the 2500 ms beat-gap + // engineering default — so the pair across that gap is correctly NOT + // used for RMSSD: 3 successive beats = 2 RMSSD pairs, not 3. + expect(w.validIntervalPairCount, 2); + expect(w.rmssdMs, isNotNull); + // The snapshot freezes exactly these queried rows (beat fields ride + // along), so re-processing reproduces the same features. + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'integration', + window: w, + ), + snapshotOnehzRows: const [], + snapshotRrRows: rr, + ); + final snap = await db.rawQuery('SELECT rr_json FROM bp_research_snapshot'); + expect(snap, hasLength(1)); + expect(snap.first['rr_json'] as String, contains('beat_index')); + await LocalDb.deleteBpResearchCapture( + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int, + ); + }); + + test( + 'beat_ts_ms window membership follows the measured beat instant', + () async { + // A beat whose record second lies in the window but whose MEASURED + // instant does not must stay outside; the mirrored case (record + // outside, measured inside) must be kept. + final recIn = (_at - 10000) ~/ 1000; // record inside the window + final w = researchWindowFrom( + measuredAtMs: _at, + onehzRows: const [], + rrRows: [ + // Record second inside, measured instant BEFORE the window. + { + 'rr_ts_ms': recIn * 1000, + 'beat_index': 0, + 'beat_ts_ms': _at - kResearchRestPreMs - 5000, + 'rr_ms': 1000, + }, + // Record second before the window, measured instant inside. + { + 'rr_ts_ms': (_at - kResearchRestPreMs - 60000) ~/ 1000 * 1000, + 'beat_index': 0, + 'beat_ts_ms': _at - 60000, + 'rr_ms': 1100, + }, + ], + ); + expect(w, isNotNull); + expect(w!.rrBeats, 1); // only the measured-inside beat survives + expect(w.rrMsMean, 1100.0); + }, + ); } diff --git a/tool/bp_research_model.py b/tool/bp_research_model.py index 2a35727f8..4248112dd 100644 --- a/tool/bp_research_model.py +++ b/tool/bp_research_model.py @@ -72,7 +72,18 @@ # missing features are excluded; 'gappy' is admitted — it is usable data # with an honest warning flag, and excluding it would bias the dataset # toward clean, unrepresentative windows. -ADMITTED_QUALITY = frozenset({"ok", "gappy", ""}) +ADMITTED_QUALITY = frozenset({"ok", "gappy"}) + + +def is_admitted_quality(status: str | None) -> bool: + """Strict, documented research admission rule: only 'ok' and 'gappy' + enter the model. UNKNOWN quality (None, empty, anything else) is NOT + admitted by default — a silent None -> "" -> admitted path would let + mixed or legacy sessions in through the back door. Historical rows + without a quality status can be admitted explicitly via + --admit-missing-quality (a documented compatibility mode, off by + default, reported in the report).""" + return status in ADMITTED_QUALITY # Session aggregation span: members of one explicit session id are only # aggregated when they lie within this span (engineering default, 30 min — # a few cuff readings of one sitting). Same label, farther apart: NOT one @@ -216,7 +227,8 @@ def num(key: str) -> float | None: return rows -def aggregate_sessions(rows: list[Row]) -> list[Row]: +def aggregate_sessions(rows: list[Row], + admit_missing_quality: bool = False) -> list[Row]: """Multiple cuff readings of one sitting are NOT independent physiological states — average them into one reference before they enter the model. @@ -273,8 +285,20 @@ def wmean(vals: list[float | None]) -> float | None: hr_mean=wmean([m.hr_mean for m in members]), rmssd_ms=wmean([m.rmssd_ms for m in members]), session_id=label, - quality=(members[0].quality if all( - m.quality == members[0].quality for m in members) else None), + # STRICT member fold: if ANY member of the session is not + # admitted quality, the whole aggregated session carries + # None and is excluded downstream — an 'ok + pending' + # sitting is not admitted because half of it is not final + # data. All-admitted sessions carry their worst (most + # flagged) admitted status, 'gappy' over 'ok'. + quality=(max( + (m.quality for m in members), + key=lambda q: {"ok": 0, "gappy": 1}.get(q, -1)) + if all( + is_admitted_quality(m.quality) + or (m.quality is None and admit_missing_quality) + for m in members) + else None), coverage=(sum(weights) / n if all( m.coverage is not None for m in members) else None), )) @@ -290,7 +314,8 @@ def signed_mean(xs: list[float]) -> float: return sum(xs) / len(xs) if xs else float("nan") -def run(rows: list[Row], level_b: bool = False) -> dict: +def run(rows: list[Row], level_b: bool = False, + admit_missing_quality: bool = False) -> dict: """Chronological prequential replay. FAIR COMPARISON: all three models are evaluated on the EXACT SAME @@ -306,13 +331,18 @@ def run(rows: list[Row], level_b: bool = False) -> dict: did not ask for level B), the update falls back to level A and the report says so. """ - aggregated = aggregate_sessions(rows) + aggregated = aggregate_sessions(rows, + admit_missing_quality=admit_missing_quality) if not aggregated: return {"error": "no rows"} - # Quality admission (documented research rule, see ADMITTED_QUALITY). + # Quality admission (documented research rule). aggregate_sessions + # already folded every member's quality into the aggregate: a session + # with ANY non-admitted member ('ok + pending') carries quality None + # and lands here, never silently inside the model. admitted = [r for r in aggregated - if (r.quality or "") in ADMITTED_QUALITY] + if is_admitted_quality(r.quality) + or (r.quality is None and admit_missing_quality)] excluded_quality = len(aggregated) - len(admitted) # Calibration row: the first admitted reference seeds the models. @@ -341,6 +371,14 @@ def run(rows: list[Row], level_b: bool = False) -> dict: processed_z: list[list[float]] = [] level_b_updates = 0 level_a_updates = 0 + # The A->B hand-over happens EXACTLY ONCE, at the first causally + # admissible level-B update: the live models are re-seeded from their + # level-A state (theta carried over, p_offset into P[0][0], slope + # variances at DEFAULT_P0) and every later level-B update continues the + # matrix covariance. Without this the level-B updates would run on the + # INITIAL P, discarding everything level A learned about the offset. + level_b_started = False + level_b_started_at: int | None = None per_target: list[dict] = [] @@ -377,6 +415,11 @@ def run(rows: list[Row], level_b: bool = False) -> dict: # 4. update AFTER recording the predictions. The mode decision uses # ONLY processed history (no future rows, no len(usable)). use_b = level_b and _b_gate(processed_z) + if use_b and not level_b_started: + m_sys = Model.hand_over_to_level_b(m_sys) + m_dia = Model.hand_over_to_level_b(m_dia) + level_b_started = True + level_b_started_at = len(processed_z) if use_b: update_level_b(m_sys, z, r.sys_mmhg, delta_days) update_level_b(m_dia, z, r.dia_mmhg, delta_days) @@ -411,16 +454,27 @@ def stats(pred_key: str, ref_key: str) -> dict: "rows_excluded_no_features": excluded_no_features, "admission_rule": { "admitted_quality": sorted(ADMITTED_QUALITY), + "unknown_quality_admitted": admit_missing_quality, "max_session_span_ms": MAX_SESSION_SPAN_MS, }, "updates": { "level_a": level_a_updates, "level_b": level_b_updates, "level_b_requested": level_b, + "level_b_started": level_b_started, + "level_b_started_after_refs": level_b_started_at, "level_b_gate": { "min_processed_refs": MIN_SLOPE_SAMPLES, "min_feature_spread_h_and_l": MIN_FEATURE_SPREAD, }, + # Why level B never opened, when it was requested but never + # started: too few processed references, or too little spread + # in H or L — the run fell back to level A throughout. + "level_b_fallback_reason": ( + None if (not level_b or level_b_started) + else "gate never opened: fewer than " + f"{MIN_SLOPE_SAMPLES} processed references with " + f">={MIN_FEATURE_SPREAD} spread in BOTH H and L"), }, "systolic": { "baseline_last_cuff": stats("pred_last_cuff_sys", "ref_sys"), @@ -468,10 +522,16 @@ def main() -> int: help="enable experimental full-parameter learning " "(level B; requires independent feature variation)") ap.add_argument("--out", help="write the report as JSON instead of stdout") + ap.add_argument("--admit-missing-quality", action="store_true", + help="COMPATIBILITY MODE (off by default): admit rows " + "with an UNKNOWN quality status — e.g. exports " + "from before quality_status existed. Strict, " + "reproducible default stays: unknown is excluded.") args = ap.parse_args() rows = load_rows(args.csv) - report = run(rows, level_b=args.level_b) + report = run(rows, level_b=args.level_b, + admit_missing_quality=args.admit_missing_quality) text = json.dumps(report, indent=2) if args.out: with open(args.out, "w", encoding="utf-8") as f: diff --git a/tool/test_bp_research_model.py b/tool/test_bp_research_model.py index 441adddb3..27b5c93ad 100644 --- a/tool/test_bp_research_model.py +++ b/tool/test_bp_research_model.py @@ -140,6 +140,79 @@ def test_quality_exclusion(): assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 1 +def test_level_b_handover_happens_once_in_run(): + # END-TO-END: level-A updates run first (gate shut), the gate opens + # causally, the hand-over happens EXACTLY ONCE, and the FIRST + # level-B update runs on P[0][0] = the p_offset level A actually + # learned — not the initial DEFAULT_P0. + rows = [_row(0, 120.0, 80.0)] + for i in range(25): + hr = 60.0 + (i % 5) * 8.0 + rm = 25.0 + (i % 4) * 15.0 + rows.append(_row((i + 1) * 86_400_000, 120.0 + i * 0.5, 80.0, + hr=hr, rm=rm)) + rep = m.run(rows, level_b=True) + u = rep["updates"] + assert u["level_b_requested"] is True + assert u["level_b_started"] is True + assert u["level_b"] == 25 - m.MIN_SLOPE_SAMPLES + assert u["level_a"] == m.MIN_SLOPE_SAMPLES + # The hand-over fired after exactly MIN_SLOPE_SAMPLES processed refs. + assert u["level_b_started_after_refs"] == m.MIN_SLOPE_SAMPLES + assert u["level_b_fallback_reason"] is None + + +def test_level_b_p_offset_carries_into_level_b(): + # The hand-over must carry the LEARNED p_offset: after many level-A + # updates the offset covariance is far below DEFAULT_P0, so P[0][0] + # at hand-over must be that learned value. + mdl = m.Model.initial(120.0) + z = m.features(70.0, 40.0) + for _ in range(100): + m.update_level_a(mdl, z, 128.0, delta_days=0.01) + handed = m.Model.hand_over_to_level_b(mdl) + assert handed.P[0][0] == mdl.p_offset + assert handed.P[0][0] < m.DEFAULT_P0 + + +def test_mixed_session_quality_excludes_the_session(): + # 'ok + pending' in ONE session: the whole session is excluded — + # never silently admitted through a None -> "" back door. + rows = [_row(0, 120.0, 80.0), + _row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q="pending")] + rep = m.run(rows) + assert rep["rows_excluded_quality"] == 1 + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + + +def test_all_admitted_session_quality_stays_admitted(): + rows = [_row(0, 120.0, 80.0), + _row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q="gappy")] + rep = m.run(rows) + assert rep["rows_excluded_quality"] == 0 + # The aggregate carries the worst ADMITTED status ('gappy'). + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 1 + + +def test_unknown_quality_is_excluded_by_default(): + rows = [_row(0, 120.0, 80.0, q="ok"), + _row(86_400_000, 121.0, 81.0, q=None)] + rep = m.run(rows) + assert rep["rows_excluded_quality"] == 1 + assert rep["admission_rule"]["unknown_quality_admitted"] is False + + +def test_unknown_quality_admitted_only_in_compatibility_mode(): + rows = [_row(0, 120.0, 80.0, q="ok"), + _row(86_400_000, 121.0, 81.0, q=None)] + rep = m.run(rows, admit_missing_quality=True) + assert rep["rows_excluded_quality"] == 0 + assert rep["admission_rule"]["unknown_quality_admitted"] is True + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 1 + + def test_level_a_to_level_b_handover(): # The documented transition: theta carries over unchanged, p_offset # seeds the offset diagonal of P, slopes start at DEFAULT_P0. From beda050c3e9d33fc382fc473da726ede1dc88ae9 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 09:59:28 +0000 Subject: [PATCH 10/22] =?UTF-8?q?fix(review):=20atomic=20BP=20research=20r?= =?UTF-8?q?estore=20=E2=80=94=20reference,=20snapshots=20and=20window=20im?= =?UTF-8?q?port=20as=20one=20transactional=20unit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- lib/data/db.dart | 117 +++++++-- test/bp_research_db_test.dart | 467 ++++++++++++++++++++++++++++++++++ 2 files changed, 556 insertions(+), 28 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 0baf2d372..a6d886929 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9118,6 +9118,19 @@ class LocalDb { // features were computed from conflicts with the destination's local // revision — a window may never point at a foreign snapshot. var skippedWindows = 0; + // Of those, the split by REASON — surfaced as extra import-count keys + // so a restore can be audited: a skipped window is either a snapshot + // CONTENT conflict or a snapshot the source backup simply does not + // carry. + var skippedWindowsSnapshotConflict = 0; + var skippedWindowsMissingSnapshot = 0; + // Rows skipped because their SOURCE reference did not map (dangling + // source rows) — reported separately from content conflicts. + var skippedSnapshotsDanglingReference = 0; + var skippedWindowsDanglingReference = 0; + // Source snapshots already present HERE byte-identically — idempotent + // re-imports, not new imports; the count must not claim them. + var skippedSnapshotsIdentical = 0; // DISTINCT DAYS ACTUALLY WRITTEN — the number the caller reports as // "N days imported". // @@ -9190,27 +9203,33 @@ class LocalDb { if (t == 'bp_research_reference' || t == 'bp_research_window' || t == 'bp_research_snapshot') { - // The reference pass builds the id map; the window and snapshot - // passes that follow (references merge first) only consume it. + // ONE TRANSACTIONAL UNIT. Reference, snapshots and window are + // restored together, driven by the reference entry: the window + // names the snapshot revision its features were computed from, + // so it may only be written when that revision exists HERE with + // the same content — decided inside the SAME transaction, not in + // three separate table passes. (A previous split-pass version + // loaded `srcSnaps` only in the snapshot pass, leaving the + // snapshot status map EMPTY in the window pass, so every + // conflict-free window with a snapshot revision was silently + // skipped.) The window and snapshot list entries that follow are + // already handled here and only skip. + if (t != 'bp_research_reference') { + continue; + } try { final refCols = await destCols('bp_research_reference'); final winCols = await destCols('bp_research_window'); final snapCols = await destCols('bp_research_snapshot'); - final srcRefs = - t == 'bp_research_reference' && - await srcHasTable('bp_research_reference', src) + final srcRefs = await srcHasTable('bp_research_reference', src) ? await src.rawQuery('SELECT * FROM bp_research_reference') : const >[]; - final srcWins = - t == 'bp_research_window' && - await srcHasTable('bp_research_window', src) - ? await src.rawQuery('SELECT * FROM bp_research_window') - : const >[]; - final srcSnaps = - t == 'bp_research_snapshot' && - await srcHasTable('bp_research_snapshot', src) + final srcSnaps = await srcHasTable('bp_research_snapshot', src) ? await src.rawQuery('SELECT * FROM bp_research_snapshot') : const >[]; + final srcWins = await srcHasTable('bp_research_window', src) + ? await src.rawQuery('SELECT * FROM bp_research_window') + : const >[]; await db.transaction((txn) async { for (final r in srcRefs) { final row = { @@ -9299,7 +9318,14 @@ class LocalDb { final mapped = bpIdMap[(row.remove('reference_id') as num?)?.toInt()]; final rev = (row['revision'] as num?)?.toInt(); - if (mapped == null || rev == null) continue; + if (mapped == null || rev == null) { + // A snapshot whose source reference did not map is a + // dangling source row — skipped and counted, never + // silently claimed as imported. + skippedSnapshots++; + skippedSnapshotsDanglingReference++; + continue; + } final clash = await txn.rawQuery( 'SELECT onehz_json, rr_json FROM bp_research_snapshot ' 'WHERE reference_id = ? AND revision = ?', @@ -9316,7 +9342,14 @@ class LocalDb { clash.first['onehz_json'] == row['onehz_json'] && clash.first['rr_json'] == row['rr_json']; snapStatus[(mapped, rev)] = same ? 'identical' : 'conflict'; - if (!same) skippedSnapshots++; + if (same) { + // An identical re-import is NOT a new import — the + // count must not claim it. + skippedSnapshots++; + skippedSnapshotsIdentical++; + } else { + skippedSnapshots++; + } } else { await txn.rawInsert( 'INSERT INTO bp_research_snapshot ' @@ -9340,8 +9373,13 @@ class LocalDb { }; final mapped = bpIdMap[(row.remove('reference_id') as num?)?.toInt()]; - if (mapped == null) continue; - // WINDOW/SNAPSHOT CONSISTENCY: a window that names a + if (mapped == null) { + // Dangling source window: no reference to attach to. + // Skipped and counted, never claimed as imported. + skippedWindows++; + skippedWindowsDanglingReference++; + continue; + } // snapshot revision may only be imported when that // revision is HERE with the same content ('inserted' or // 'identical'). A 'conflict' means the local revision n @@ -9352,8 +9390,18 @@ class LocalDb { final rev = (row['snapshot_revision'] as num?)?.toInt(); if (rev != null) { final status = snapStatus[(mapped, rev)]; - if (status == null || status == 'conflict') { + if (status == null) { + // The source window names a snapshot revision the + // source backup does not carry — its features cannot + // be linked to raw data that does not exist. Skipped + // and counted as missing, never imported snapshotless. + skippedWindows++; + skippedWindowsMissingSnapshot++; + continue; + } + if (status == 'conflict') { skippedWindows++; + skippedWindowsSnapshotConflict++; continue; } } @@ -9393,16 +9441,29 @@ class LocalDb { ); } }); - counts[t] = t == 'bp_research_reference' - ? srcRefs.length - : t == 'bp_research_window' - // Windows skipped over a snapshot conflict were not - // imported — the count must not claim them. - ? srcWins.length - skippedWindows - // Snapshots whose (reference, revision) key collided - // with DIFFERENT content were skipped, not imported — - // the count must not claim them. - : srcSnaps.length - skippedSnapshots; + counts['bp_research_reference'] = srcRefs.length; + // Snapshots whose (reference, revision) key collided with + // DIFFERENT content were skipped, not imported — the count + // must not claim them. + counts['bp_research_snapshot'] = + srcSnaps.length - skippedSnapshots; + // Windows skipped over a snapshot conflict or a missing source + // snapshot were not imported — the count must not claim them. + counts['bp_research_window'] = srcWins.length - skippedWindows; + // Audit keys: WHY windows or snapshots were skipped, so a + // restore result can be read without opening the source file. + counts['bp_research_snapshot_conflicts'] = + skippedSnapshots - + skippedSnapshotsDanglingReference - + skippedSnapshotsIdentical; + counts['bp_research_window_snapshot_conflicts'] = + skippedWindowsSnapshotConflict; + counts['bp_research_window_missing_snapshot'] = + skippedWindowsMissingSnapshot; + counts['bp_research_snapshot_dangling_reference'] = + skippedSnapshotsDanglingReference; + counts['bp_research_window_dangling_reference'] = + skippedWindowsDanglingReference; } catch (_) { if (!tolerant) rethrow; counts[t] = 0; diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart index a5bc02b5e..34a4cc83a 100644 --- a/test/bp_research_db_test.dart +++ b/test/bp_research_db_test.dart @@ -1010,4 +1010,471 @@ void main() { expect(w.rrMsMean, 1100.0); }, ); + // ======================================================================== + // ATOMIC BP RESEARCH RESTORE (reference + snapshot + window, ONE unit). + // The regression behind these tests: the restore loop used to visit the + // three BP tables in SEPARATE passes and loaded the source snapshots only + // in the snapshot pass — so in the window pass the snapshot status map + // was EMPTY and every conflict-free window with a snapshot revision was + // silently skipped. Fresh-target restores lost their windows entirely. + // ======================================================================== + + Future makeForeignBpDb( + String name, { + required int refId, + required int measuredAtMs, + String device = 'restore', + double sys = 130, + double dia = 85, + List>? snapshots, + List>? windows, + }) async { + final srcPath = p.join(await databaseFactory.getDatabasesPath(), name); + await databaseFactory.deleteDatabase(srcPath); + final src = await databaseFactory.openDatabase(srcPath); + await src.execute( + 'CREATE TABLE bp_research_reference (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'measured_at_ms INTEGER NOT NULL, device TEXT, posture TEXT, ' + 'conditions TEXT, systolic_mmhg REAL NOT NULL, ' + 'diastolic_mmhg REAL NOT NULL, captured_at_ms INTEGER NOT NULL, ' + 'UNIQUE (measured_at_ms, device))', + ); + await src.insert('bp_research_reference', { + 'id': refId, + 'measured_at_ms': measuredAtMs, + 'device': device, + 'systolic_mmhg': sys, + 'diastolic_mmhg': dia, + 'captured_at_ms': measuredAtMs, + }); + await src.execute( + 'CREATE TABLE bp_research_window (' + 'reference_id INTEGER PRIMARY KEY, window_start_ms INTEGER NOT NULL, ' + 'window_end_ms INTEGER NOT NULL, onehz_rows INTEGER, rr_beats INTEGER, ' + 'hr_mean REAL, rr_ms_mean REAL, rr_ms_min REAL, rr_ms_max REAL, ' + 'rmssd_ms REAL, meta_json TEXT, observed_start_ms INTEGER, ' + 'observed_end_ms INTEGER, valid_hr_seconds INTEGER, ' + 'valid_interval_count INTEGER, valid_interval_pair_count INTEGER, ' + 'coverage_fraction REAL, rejected_interval_fraction REAL, ' + 'quality_status TEXT, feature_version INTEGER, ' + 'snapshot_revision INTEGER)', + ); + for (final w in windows ?? const >[]) { + await src.insert('bp_research_window', w); + } + await src.execute( + 'CREATE TABLE bp_research_snapshot (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, reference_id INTEGER NOT NULL, ' + 'revision INTEGER NOT NULL, onehz_json TEXT NOT NULL, ' + 'rr_json TEXT NOT NULL, created_at_ms INTEGER NOT NULL, ' + 'UNIQUE (reference_id, revision))', + ); + for (final s in snapshots ?? const >[]) { + await src.insert('bp_research_snapshot', s); + } + await src.close(); + return srcPath; + } + + Map foreignWindow( + int refId, { + int? snapshotRevision, + double hrMean = 77.7, + int onehzRows = 300, + }) => { + 'reference_id': refId, + 'window_start_ms': _at - 300000, + 'window_end_ms': _at, + 'onehz_rows': onehzRows, + 'rr_beats': 210, + 'hr_mean': hrMean, + 'rmssd_ms': 38.5, + 'feature_version': kResearchFeatureVersion, + 'snapshot_revision': snapshotRevision, + }; + + Map foreignSnapshot( + int refId, + int revision, { + required int hr, + }) => { + 'reference_id': refId, + 'revision': revision, + 'onehz_json': '[{"rec_ts":${(_at - 60000) ~/ 1000},"hr":$hr}]', + 'rr_json': '[]', + 'created_at_ms': _at, + }; + + Future clearBpTables() async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + } + + test('restore TEST 1: a FRESH target restores reference + snapshot + window ' + 'as one unit (the regression)', () async { + await clearBpTables(); + // FRESH target: no local BP rows at all. The source carries a + // reference, snapshot revision 1, and a window naming revision 1. + final srcPath = await makeForeignBpDb( + 'bp_fresh_success.db', + refId: 7, + measuredAtMs: _at, + snapshots: [foreignSnapshot(7, 1, hr: 60)], + windows: [foreignWindow(7, snapshotRevision: 1, hrMean: 71.5)], + ); + final counts = await LocalDb.importFromDbFile(srcPath); + final db = await LocalDb.instance; + final refs = await db.rawQuery('SELECT * FROM bp_research_reference'); + expect(refs, hasLength(1)); + final destId = refs.first['id'] as int; + final snaps = await db.rawQuery( + 'SELECT * FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [destId], + ); + expect(snaps, hasLength(1)); + expect( + snaps.first['onehz_json'], + '[{"rec_ts":${(_at - 60000) ~/ 1000},"hr":60}]', + ); + final wins = await db.rawQuery( + 'SELECT * FROM bp_research_window WHERE reference_id = ?', + [destId], + ); + expect(wins, hasLength(1)); + expect(wins.first['snapshot_revision'], 1); + expect(wins.first['hr_mean'], 71.5); + expect(wins.first['rmssd_ms'], 38.5); + // Counters: exactly 1 / 1 / 1, no skips. + expect(counts['bp_research_reference'], 1); + expect(counts['bp_research_snapshot'], 1); + expect(counts['bp_research_window'], 1); + expect(counts['bp_research_window_snapshot_conflicts'], 0); + expect(counts['bp_research_window_missing_snapshot'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore TEST 2: an IDENTICAL snapshot re-import is idempotent and the ' + 'window converges', () async { + await clearBpTables(); + // LOCAL: reference + snapshot rev 1 (rows A) + a LOCAL window with + // DISTINCT features, so the test proves the import UPDATED the + // window rather than merely preserving a pre-existing one. + final rowsA = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 130, + diastolicMmHg: 85, + capturedAtMs: _at, + device: 'restore', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rowsA, + rrRows: const [], + ), + ), + snapshotOnehzRows: rowsA, + snapshotRrRows: const [], + ); + final db = await LocalDb.instance; + final destId = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + // SOURCE: the SAME natural reference, the SAME snapshot rev 1 (rows + // A), but a window with DIFFERENT features (88.8) — the identical + // snapshot status must admit that window and converge it. + final srcPath = await makeForeignBpDb( + 'bp_ident_converge.db', + refId: 9, + measuredAtMs: _at, + device: 'restore', + snapshots: [foreignSnapshot(9, 1, hr: 60)], + windows: [foreignWindow(9, snapshotRevision: 1, hrMean: 88.8)], + ); + final counts = await LocalDb.importFromDbFile(srcPath); + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [destId], + ); + expect(snaps.first['c'], 1); // no duplicate revision rows + final wins = await db.rawQuery( + 'SELECT hr_mean, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [destId], + ); + expect(wins, hasLength(1)); + expect(wins.first['hr_mean'], 88.8); // the IMPORTED window won + expect(wins.first['snapshot_revision'], 1); + // Re-import AGAIN: full idempotency, still one of each, same values. + final counts2 = await LocalDb.importFromDbFile(srcPath); + final snaps2 = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [destId], + ); + expect(snaps2.first['c'], 1); + final wins2 = await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [destId], + ); + expect(wins2, hasLength(1)); + expect(wins2.first['hr_mean'], 88.8); + // An identical snapshot is NOT a new import; nothing was skipped + // as a conflict or missing. + expect(counts['bp_research_snapshot'], 0); + expect(counts['bp_research_snapshot_conflicts'], 0); + expect(counts2['bp_research_snapshot'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore TEST 3: a CONFLICTING snapshot skips the snapshot AND the ' + 'window, counters rise', () async { + await clearBpTables(); + // LOCAL: reference + snapshot rev 1 (rows A) + window A. + final rowsA = [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 130, + diastolicMmHg: 85, + capturedAtMs: _at, + device: 'restore', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rowsA, + rrRows: const [], + ), + ), + snapshotOnehzRows: rowsA, + snapshotRrRows: const [], + ); + final db = await LocalDb.instance; + final destId = + (await db.rawQuery('SELECT id FROM bp_research_reference')).first['id'] + as int; + final localJson = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [destId], + )).first['onehz_json'] + as String; + final localHr = (await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [destId], + )).first['hr_mean']; + // SOURCE: same natural reference, snapshot rev 1 with DIFFERENT + // rows (hr 99), and a window computed from those rows. + final srcPath = await makeForeignBpDb( + 'bp_conflict_counters.db', + refId: 11, + measuredAtMs: _at, + device: 'restore', + snapshots: [foreignSnapshot(11, 1, hr: 99)], + windows: [foreignWindow(11, snapshotRevision: 1, hrMean: 95.5)], + ); + final counts = await LocalDb.importFromDbFile(srcPath); + // Local snapshot rev 1 stays byte-identical (A), foreign B lost. + final snap = await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [destId], + ); + expect(snap, hasLength(1)); + expect(snap.first['onehz_json'], localJson); + // The foreign window was SKIPPED; local window A survives untouched. + final win = await db.rawQuery( + 'SELECT hr_mean FROM bp_research_window WHERE reference_id = ?', + [destId], + ); + expect(win, hasLength(1)); + expect(win.first['hr_mean'], localHr); + // Counters tell the truth: nothing imported, conflicts recorded. + expect(counts['bp_research_snapshot'], 0); + expect(counts['bp_research_window'], 0); + expect(counts['bp_research_snapshot_conflicts'], 1); + expect(counts['bp_research_window_snapshot_conflicts'], 1); + expect(counts['bp_research_window_missing_snapshot'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore TEST 4: a window whose snapshot is MISSING in the source is ' + 'never imported', () async { + await clearBpTables(); + // SOURCE: a reference and a window naming snapshot revision 1 — + // but NO snapshot row at all. Its features have no raw-data basis + // here; importing the window would point at nothing. + final srcPath = await makeForeignBpDb( + 'bp_missing_snapshot.db', + refId: 13, + measuredAtMs: _at, + snapshots: const [], + windows: [foreignWindow(13, snapshotRevision: 1)], + ); + final counts = await LocalDb.importFromDbFile(srcPath); + final db = await LocalDb.instance; + final refs = await db.rawQuery('SELECT * FROM bp_research_reference'); + expect(refs, hasLength(1)); // the reference itself is imported + final destId = refs.first['id'] as int; + final wins = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_window ' + 'WHERE reference_id = ?', + [destId], + ); + expect(wins.first['c'], 0); // the window was NOT imported + expect(counts['bp_research_window'], 0); + expect(counts['bp_research_window_missing_snapshot'], 1); + expect(counts['bp_research_window_snapshot_conflicts'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore TEST 5: a LEGACY snapshotless window (snapshot_revision NULL) ' + 'imports snapshotless, no fabricated revision', () async { + await clearBpTables(); + // SOURCE: v1-style capture — a window with snapshot_revision NULL + // and no snapshot rows. The documented legacy rule: import the + // window as-is, keep it snapshotless, never fabricate a revision. + final srcPath = await makeForeignBpDb( + 'bp_legacy_window.db', + refId: 15, + measuredAtMs: _at, + snapshots: const [], + windows: [foreignWindow(15, snapshotRevision: null, hrMean: 66.6)], + ); + final counts = await LocalDb.importFromDbFile(srcPath); + final db = await LocalDb.instance; + final refs = await db.rawQuery('SELECT * FROM bp_research_reference'); + expect(refs, hasLength(1)); + final destId = refs.first['id'] as int; + final wins = await db.rawQuery( + 'SELECT hr_mean, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [destId], + ); + expect(wins, hasLength(1)); + expect(wins.first['hr_mean'], 66.6); + expect(wins.first['snapshot_revision'], null); // stays snapshotless + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [destId], + ); + expect(snaps.first['c'], 0); // no revision was fabricated + expect(counts['bp_research_window'], 1); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore TEST 6: a reference ID collision maps snapshot and window to ' + 'the CORRECT destination reference', () async { + await clearBpTables(); + // LOCAL: one capture whose AUTOINCREMENT id is 1 (deliberately the + // same NUMBER the source uses for a DIFFERENT natural reference). + await LocalDb.putBpResearchCapture(_capture(_at, device: 'local')); + final db = await LocalDb.instance; + final localId = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference WHERE device = ?', + ['local'], + )).first['id'] + as int; + // SOURCE: id 1 — a DIFFERENT natural reference (different time) — + // with its own snapshot and window. They must land on the SOURCE + // row's DESTINATION id, never on the local id that shares the + // number. + final srcPath = await makeForeignBpDb( + 'bp_id_collision.db', + refId: 1, + measuredAtMs: _at + 60000, + device: 'foreign', + snapshots: [foreignSnapshot(1, 1, hr: 70)], + windows: [foreignWindow(1, snapshotRevision: 1, hrMean: 72.0)], + ); + await LocalDb.importFromDbFile(srcPath); + final refs = await db.rawQuery( + 'SELECT id, device, measured_at_ms FROM bp_research_reference ' + 'ORDER BY measured_at_ms', + ); + expect(refs, hasLength(2)); // both captures survive + final foreignDestId = + refs.firstWhere((r) => r['device'] == 'foreign')['id'] as int; + // Snapshot and window point at the FOREIGN reference's destination + // id — never at the local row that merely shares the number 1. + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [foreignDestId], + ); + expect(snaps.first['c'], 1); + final wins = await db.rawQuery( + 'SELECT hr_mean, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [foreignDestId], + ); + expect(wins, hasLength(1)); + expect(wins.first['hr_mean'], 72.0); + expect(wins.first['snapshot_revision'], 1); + // The LOCAL reference keeps its window untouched (from _capture: + // the first put had none, so the count is 0 here) and no foreign + // row landed on it. + final localSnaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [localId], + ); + expect(localSnaps.first['c'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); + + test('restore TEST 7: a repeated re-import of the SAME source creates no ' + 'duplicates and stable counts', () async { + await clearBpTables(); + final srcPath = await makeForeignBpDb( + 'bp_reimport.db', + refId: 17, + measuredAtMs: _at, + snapshots: [foreignSnapshot(17, 1, hr: 65)], + windows: [foreignWindow(17, snapshotRevision: 1, hrMean: 73.0)], + ); + final c1 = await LocalDb.importFromDbFile(srcPath); + final c2 = await LocalDb.importFromDbFile(srcPath); + final c3 = await LocalDb.importFromDbFile(srcPath); + final db = await LocalDb.instance; + final refs = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_reference', + ); + expect(refs.first['c'], 1); // no duplicate references + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot', + ); + expect(snaps.first['c'], 1); // no duplicate snapshots + final wins = await db.rawQuery('SELECT COUNT(*) c FROM bp_research_window'); + expect(wins.first['c'], 1); // no duplicate windows + final json = await db.rawQuery( + 'SELECT onehz_json, rr_json FROM bp_research_snapshot', + ); + expect( + json.first['onehz_json'], + '[{"rec_ts":${(_at - 60000) ~/ 1000},"hr":65}]', + ); + expect(json.first['rr_json'], '[]'); // content unchanged + // First import reports 1/1/1; re-imports converge — the identical + // snapshot and the natural-key reference are not NEW imports. + expect(c1['bp_research_reference'], 1); + expect(c1['bp_research_snapshot'], 1); + expect(c1['bp_research_window'], 1); + expect(c2['bp_research_reference'], 1); // matched, updated in place + expect(c2['bp_research_snapshot'], 0); // identical, not new + expect(c2['bp_research_window'], 1); // re-written, still one row + expect(c3['bp_research_window_missing_snapshot'], 0); + await databaseFactory.deleteDatabase(srcPath); + }); } From 8fac68dfbe82e44e31f849b9b4b78da19006227e Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 10:43:49 +0000 Subject: [PATCH 11/22] fix(review): sync-finality pending windows, explicit reprocessing, snapshot-invariant windows, strict compatibility-mode exclusion, structured no-admitted-rows report Co-authored-by: BucciMobile --- lib/data/db.dart | 173 +++++++++++++++++++++++- lib/health/bp_research_capture.dart | 54 +++++--- lib/ui2/profile/bp_research.dart | 46 ++++++- test/bp_research_capture_test.dart | 43 ++++++ test/bp_research_db_test.dart | 203 ++++++++++++++++++++++++++++ tool/bp_research_model.py | 90 ++++++++---- tool/test_bp_research_model.py | 124 +++++++++++++++++ 7 files changed, 683 insertions(+), 50 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index a6d886929..8916a370e 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1780,6 +1780,161 @@ class LocalDb { /// `(measured_at_ms, device)`: the colliding row is deleted explicitly /// first (window, snapshots, then the reference — no PRAGMA foreign_keys /// here, so nothing cascades on its own), then re-inserted. + /// The SYNC WATERMARK of exactly one band: up to which instant do we + /// provably hold DECODED local data for this device? HR and RR are + /// answered SEPARATELY — the two series decode from different packets + /// and a shared watermark would be a fabrication. NULL means no decoded + /// row exists for the device. The BP research window classification + /// uses this as its pending criterion: a window whose end lies beyond + /// the watermark is 'pending', never a final 'no_data'/'gappy' — the + /// missing tail may still arrive with the next sync. + static Future<({int? onehzThroughMs, int? rrThroughMs})> + bpResearchDataThroughMs(String deviceId) async { + final db = await instance; + final onehz = Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT MAX(rec_ts) FROM decoded_onehz WHERE device_id = ?', + [deviceId], + ), + ); + final rr = Sqflite.firstIntValue( + await db.rawQuery( + 'SELECT MAX(COALESCE(beat_ts_ms, rr_ts_ms)) / 1 FROM decoded_rr ' + 'WHERE device_id = ?', + [deviceId], + ), + ); + return ( + onehzThroughMs: onehz == null ? null : onehz * 1000, + rrThroughMs: rr, + ); + } + + /// EXPLICIT RE-PROCESSING of ONE stored capture (developer-mode action). + /// Re-reads the CURRENT local WHOOP data for the capture's ORIGINAL + /// window bounds, re-classifies the window, and writes a NEW snapshot + /// revision — the old revisions stay byte-identical. The reference + /// itself (cuff values, measurement time) is NEVER touched. A window + /// whose data basis still does not reach the window end stays + /// 'pending' — no fabricated finality. + static Future reprocessBpResearchCapture(int referenceId) async { + final db = await instance; + final refs = await db.rawQuery( + 'SELECT r.measured_at_ms, r.band_device_id, ' + 'w.window_start_ms, w.window_end_ms ' + 'FROM bp_research_reference r ' + 'LEFT JOIN bp_research_window w ON w.reference_id = r.id ' + 'WHERE r.id = ?', + [referenceId], + ); + if (refs.isEmpty) return; + final r = refs.first; + final bandDeviceId = (r['band_device_id'] as String?) ?? kPrimaryDeviceId; + // The ORIGINAL window bounds: re-processing must not silently move + // the feature window, only refresh the data inside it. + final start = + (r['window_start_ms'] as num?)?.toInt() ?? + (r['measured_at_ms'] as num).toInt() - kResearchRestPreMs; + final end = + (r['window_end_ms'] as num?)?.toInt() ?? + (r['measured_at_ms'] as num).toInt() + kResearchWindowPostMs; + final onehz = await db.rawQuery( + 'SELECT rec_ts, hr FROM decoded_onehz ' + 'WHERE device_id = ? AND rec_ts >= ? AND rec_ts <= ? ' + 'ORDER BY rec_ts ASC', + [bandDeviceId, start ~/ 1000, (end - 1) ~/ 1000], + ); + final rr = await db.rawQuery( + 'SELECT rr_ts_ms, rr_ms, beat_index, beat_ts_ms FROM decoded_rr ' + 'WHERE device_id = ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) >= ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) < ? ' + 'ORDER BY rr_ts_ms ASC, beat_index ASC', + [bandDeviceId, start, end], + ); + final through = await bpResearchDataThroughMs(bandDeviceId); + // CONSERVATIVE WATERMARK: a series with NO decoded rows at all has + // watermark 0 (nothing provably decoded); the EARLIER of the two + // series decides — the window cannot be final until BOTH could have + // delivered their tail. + final onehzThrough = through.onehzThroughMs ?? 0; + final rrThrough = through.rrThroughMs ?? 0; + final dataThroughMs = onehzThrough < rrThrough + ? onehzThrough + : rrThrough; + final window = researchWindowFrom( + measuredAtMs: (r['measured_at_ms'] as num).toInt(), + onehzRows: onehz, + rrRows: rr, + preMs: (r['measured_at_ms'] as num).toInt() - start, + postMs: end - (r['measured_at_ms'] as num).toInt(), + nowMs: DateTime.now().millisecondsSinceEpoch, + dataThroughMs: dataThroughMs, + ); + await db.transaction((txn) async { + if (window == null) { + // Still nothing usable: drop the window row, keep the reference. + await txn.rawDelete( + 'DELETE FROM bp_research_window WHERE reference_id = ?', + [referenceId], + ); + return; + } + final maxRev = Sqflite.firstIntValue( + await txn.rawQuery( + 'SELECT MAX(revision) FROM bp_research_snapshot ' + 'WHERE reference_id = ?', + [referenceId], + ), + ); + final rev = (maxRev ?? 0) + 1; + await txn.rawInsert( + 'INSERT INTO bp_research_snapshot ' + '(reference_id, revision, onehz_json, rr_json, created_at_ms) ' + 'VALUES (?, ?, ?, ?, ?)', + [ + referenceId, + rev, + jsonEncode(onehz), + jsonEncode(rr), + DateTime.now().millisecondsSinceEpoch, + ], + ); + await txn.rawUpdate( + 'UPDATE bp_research_window SET ' + 'window_start_ms = ?, window_end_ms = ?, observed_start_ms = ?, ' + 'observed_end_ms = ?, onehz_rows = ?, rr_beats = ?, hr_mean = ?, ' + 'rr_ms_mean = ?, rr_ms_min = ?, rr_ms_max = ?, rmssd_ms = ?, ' + 'valid_hr_seconds = ?, valid_interval_count = ?, ' + 'valid_interval_pair_count = ?, coverage_fraction = ?, ' + 'rejected_interval_fraction = ?, quality_status = ?, ' + 'feature_version = ?, snapshot_revision = ? WHERE reference_id = ?', + [ + window.windowStartMs, + window.windowEndMs, + window.observedStartMs, + window.observedEndMs, + window.onehzRows, + window.rrBeats, + window.hrMean, + window.rrMsMean, + window.rrMsMin, + window.rrMsMax, + window.rmssdMs, + window.validHrSeconds, + window.validIntervalCount, + window.validIntervalPairCount, + window.coverageFraction, + window.rejectedIntervalFraction, + window.qualityStatus, + window.featureVersion, + rev, + referenceId, + ], + ); + }); + } + static Future putBpResearchCapture( BpResearchCapture c, { List>? snapshotOnehzRows, @@ -1883,6 +2038,19 @@ class LocalDb { ); return; } + // SNAPSHOT/WINDOW INVARIANT: a window may only name a snapshot + // revision that ACTUALLY exists for THIS reference. The revision is + // decided HERE, from the snapshot lists of THIS operation — never + // from a caller-set w.snapshotRevision, which could point at a + // foreign or non-existent revision (the window would reference raw + // data that is not what its features were computed from). + // · snapshot lists passed → new revision (max + 1) is created + // below and the window is pointed at it in the SAME transaction; + // · no snapshot lists → snapshot_revision = NULL: the window is + // stored SNAPSHOTLESS (a legacy-style summary), never claiming + // an old or foreign revision it cannot prove. + final hasSnapshotRows = + snapshotOnehzRows != null || snapshotRrRows != null; await txn.rawInsert( 'INSERT OR REPLACE INTO bp_research_window ' '(reference_id, window_start_ms, window_end_ms, observed_start_ms, ' @@ -1912,7 +2080,10 @@ class LocalDb { w.rejectedIntervalFraction, w.qualityStatus, w.featureVersion, - w.snapshotRevision, + // NULL without snapshot lists — no revision is claimed that + // does not exist; with lists the UPDATE below sets the real + // new revision before the transaction commits. + hasSnapshotRows ? w.snapshotRevision : null, w.metaJson, ], ); diff --git a/lib/health/bp_research_capture.dart b/lib/health/bp_research_capture.dart index 3cf04d3fd..b850865f5 100644 --- a/lib/health/bp_research_capture.dart +++ b/lib/health/bp_research_capture.dart @@ -257,12 +257,6 @@ int _beatTimeMs(Map r) { return ts is num ? ts.toInt() : 0; } -/// BEAT IDENTITY — collision-free by construction, never bit-packed: -/// the measured beat instant when present, otherwise the whole-second -/// record time AND the beat's index within the record. Neither component -/// is truncated or masked, so any beat_index range (and negative or junk -/// values) can only produce distinct keys, never a silent collision. -// ignore: avoid_redundant_argument_values /// BEAT IDENTITY — collision-free by construction, never bit-packed: /// the measured beat instant when present ('b', beat_ts_ms, 0), /// otherwise the whole-second record time AND the beat's index within @@ -324,11 +318,16 @@ int _beatOrder(Map a, Map b) { /// successive valid beats whose beat times are contiguous /// (gap ≤ [kResearchMaxBeatGapMs]) — RMSSD is computed over those /// pairs and ONLY those pairs, never across a sensor gap; -/// · [nowMs] decides pending: a window whose end lies in the future is -/// 'pending' and must be finalized once it has elapsed. The UI never -/// produces one (Option 1: pre-measurement window only, future -/// measurement instants are refused); the state exists so data-level -/// callers cannot silently mislabel such a window as final. +/// · [nowMs] and [dataThroughMs] decide pending: a window whose end +/// lies in the future, or whose locally decoded data provably does +/// not reach the window end yet (dataThroughMs — the sync watermark +/// of exactly this band), is 'pending' and must be re-processed once +/// the data has arrived. 'pending' is NOT a quality verdict — it +/// only says the window cannot be finally judged yet. The UI never +/// produces a future window (Option 1: pre-measurement window only, +/// future measurement instants are refused), but a 'just now' +/// capture CAN still be pending when the band has not synced the +/// last minutes yet. BpResearchWindow? researchWindowFrom({ required int measuredAtMs, required List> onehzRows, @@ -337,6 +336,7 @@ BpResearchWindow? researchWindowFrom({ int? postMs, int? maxGapMs, int? nowMs, + int? dataThroughMs, String? deviceId, String? metaJson, }) { @@ -455,16 +455,30 @@ BpResearchWindow? researchWindowFrom({ : validHrSeconds / windowSeconds; // Quality status — an honest verdict, not a fabricated confidence number. - // pending — the window extends into the future; finalize later. Not - // producible from the UI (future instants are refused); a - // data-level caller that still passes one gets an honest - // label instead of a silently "final" window. - // no_data — nothing valid survived in either series. - // gappy — over half the successive pairs were rejected across gaps, - // or under half the window has valid HR: usable, flag it. - // ok — otherwise. + // pending — the window is NOT final yet: its end lies in the future + // (nowMs), or the locally decoded data provably does not + // reach up to the window end yet (dataThroughMs, the + // sync watermark of exactly this band). 'Not final' means + // the missing tail may still arrive — it must never be + // mislabeled 'no_data' or 'gappy', which would claim the + // window was finalizable and just holds bad data. + // ONE-SECOND TOLERANCE: decoded_onehz rows are whole + // seconds, so the last second that can still lie INSIDE + // the half-open [start, end) window ends at end - 1000. + // A watermark there proves every row the window could + // contain has arrived; demanding watermark = end would + // require a row OUTSIDE the window and keep honest + // pre-measurement windows pending forever. + // no_data — finalizable, but nothing valid survived in either series. + // gappy — finalizable, but over half the successive pairs were + // rejected across gaps, or under half the window has valid + // HR: usable, flag it. + // ok — finalizable and passes the current research rule. + // PRECEDENCE: pending wins over everything — a window whose data basis + // is not provably complete cannot carry a final verdict. String status; - if (nowMs != null && end > nowMs) { + if ((nowMs != null && end > nowMs) || + (dataThroughMs != null && dataThroughMs < end - 1000)) { status = 'pending'; } else if (validHrRows.isEmpty && validIntervals.isEmpty) { status = 'no_data'; diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart index 8ba8f4082..28ffac453 100644 --- a/lib/ui2/profile/bp_research.dart +++ b/lib/ui2/profile/bp_research.dart @@ -208,11 +208,25 @@ class _BpResearchScreenState extends State { 'ORDER BY rr_ts_ms ASC, beat_index ASC', [LocalDb.kPrimaryDeviceId, start, end], ); + // SYNC FINALITY: a window is only final when the locally decoded + // data provably reaches its end. Both series are checked separately + // (they decode from different packets); the EARLIER watermark decides + // — the window cannot be judged final until BOTH series could have + // delivered their tail. A series with NO decoded rows at all counts + // as watermark 0: not provably through, conservative pending. + final through = await LocalDb.bpResearchDataThroughMs( + LocalDb.kPrimaryDeviceId, + ); + final onehzThrough = through.onehzThroughMs ?? 0; + final rrThrough = through.rrThroughMs ?? 0; + final dataThroughMs = + onehzThrough < rrThrough ? onehzThrough : rrThrough; final window = researchWindowFrom( measuredAtMs: measuredAtMs, onehzRows: onehz, rrRows: rr, nowMs: enteredAtMs, + dataThroughMs: dataThroughMs, ); await LocalDb.putBpResearchCapture( BpResearchCapture( @@ -366,12 +380,32 @@ class _BpResearchScreenState extends State { '${formatDayTime(DateTime.fromMillisecondsSinceEpoch(r['measured_at_ms'] as int), l)}', ), subtitle: Text(_windowSummary(r)), - trailing: IconButton( - icon: const Icon(LucideIcons.trash2, size: 18), - onPressed: () async { - await LocalDb.deleteBpResearchCapture(r['id'] as int); - await _refresh(); - }, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // EXPLICIT REPROCESSING (developer mode): re-read the + // CURRENT local data for this capture's ORIGINAL window + // bounds, write a NEW snapshot revision, re-classify + // (pending → final once the sync provably reaches the + // window end). Reference values are never touched. + IconButton( + icon: const Icon(LucideIcons.refreshCw, size: 18), + tooltip: 'Refresh band window', + onPressed: () async { + await LocalDb.reprocessBpResearchCapture( + r['id'] as int, + ); + await _refresh(); + }, + ), + IconButton( + icon: const Icon(LucideIcons.trash2, size: 18), + onPressed: () async { + await LocalDb.deleteBpResearchCapture(r['id'] as int); + await _refresh(); + }, + ), + ], ), ), const SizedBox(height: S.x4), diff --git a/test/bp_research_capture_test.dart b/test/bp_research_capture_test.dart index 56192d25f..7daf43d9e 100644 --- a/test/bp_research_capture_test.dart +++ b/test/bp_research_capture_test.dart @@ -320,4 +320,47 @@ void main() { expect(w!.windowEndMs, at + 60000); expect(w.onehzRows, 1); }); + test('a window whose local data does not provably reach its end is ' + 'pending (sync watermark), never a final verdict', () { + // Full, perfectly valid data — but the watermark proves the band + // has not synced up to the window END: the missing tail may still + // arrive, so 'pending', NOT 'ok' (and never 'no_data'/'gappy'). + final rows = >[ + for (int s = 0; s < 300; s++) {'rec_ts': at ~/ 1000 - 300 + s, 'hr': 60}, + ]; + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: rows, + rrRows: const [], + dataThroughMs: at - 60000, // synced only to T-60s + ); + expect(w, isNotNull); + expect(w!.qualityStatus, 'pending'); + // The data itself is still frozen — pending is a VERDICT about + // finality, not a rejection of the rows. + expect(w.onehzRows, 300); + // Once the watermark reaches the end, the SAME rows are final: + final w2 = researchWindowFrom( + measuredAtMs: at, + onehzRows: rows, + rrRows: const [], + dataThroughMs: at, + ); + expect(w2!.qualityStatus, 'ok'); + }); + + test('pending outranks a would-be gappy classification (precedence)', () { + // Half the coverage missing AND the watermark short: the missing + // part may still arrive, so 'pending', not 'gappy'. + final rows = >[ + for (int s = 0; s < 150; s++) {'rec_ts': at ~/ 1000 - 300 + s, 'hr': 60}, + ]; + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: rows, + rrRows: const [], + dataThroughMs: at - 150000, + ); + expect(w!.qualityStatus, 'pending'); + }); } diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart index 34a4cc83a..1edc10db7 100644 --- a/test/bp_research_db_test.dart +++ b/test/bp_research_db_test.dart @@ -1477,4 +1477,207 @@ void main() { expect(c3['bp_research_window_missing_snapshot'], 0); await databaseFactory.deleteDatabase(srcPath); }); + test('restore-invariant: a window without snapshot lists is stored ' + 'SNAPSHOTLESS, never a fabricated revision (B2/B4)', () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + // A window object carrying a BOGUS snapshotRevision=99 — without + // snapshot lists the store must NOT persist that claim. + final bogus = BpResearchWindow( + windowStartMs: _at - 300000, + windowEndMs: _at, + onehzRows: 10, + hrMean: 60.0, + featureVersion: kResearchFeatureVersion, + snapshotRevision: 99, + qualityStatus: 'ok', + ); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'invariant', + window: bogus, + ), + // NO snapshot lists. + ); + final win = await db.rawQuery( + 'SELECT snapshot_revision FROM bp_research_window w ' + 'JOIN bp_research_reference r ON r.id = w.reference_id ' + 'WHERE r.device = ?', + ['invariant'], + ); + expect(win, hasLength(1)); + // No revision is claimed that does not exist. + expect(win.first['snapshot_revision'], isNull); + final snaps = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot s ' + 'JOIN bp_research_reference r ON r.id = s.reference_id ' + 'WHERE r.device = ?', + ['invariant'], + ); + expect(snaps.first['c'], 0); + }); + + test('reprocess: a pending capture with a later watermark becomes final, ' + 'writes revision 2, keeps revision 1 byte-identical (A3)', () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_onehz'); + // Band synced only up to T-60s at capture time: pending. + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 1, + 'rec_ts': (_at - 60000) ~/ 1000, + 'counter': 0, + 'hr': 60, + }); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'reprocess', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + ], + rrRows: const [], + dataThroughMs: _at - 60000, + ), + ), + snapshotOnehzRows: [ + {'rec_ts': (_at - 60000) ~/ 1000, 'hr': 60}, + ], + snapshotRrRows: const [], + ); + final refId = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference WHERE device = ?', + ['reprocess'], + )).first['id'] + as int; + var win = await db.rawQuery( + 'SELECT quality_status, snapshot_revision ' + 'FROM bp_research_window WHERE reference_id = ?', + [refId], + ); + expect(win.first['quality_status'], 'pending'); + expect(win.first['snapshot_revision'], 1); + final rev1Json = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [refId], + )).first['onehz_json'] + as String; + // The band syncs the rest of the window — up to the LAST whole + // second that can still lie inside the half-open window. + for (int s = 0; s < 300; s++) { + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 100 + s, + 'rec_ts': (_at - 300000) ~/ 1000 + s, + 'counter': s, + 'hr': 60, + }); + } + // The RR series syncs its tail too — the watermark is the EARLIER + // of both series, so HR alone would keep the window pending. + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 900, + 'rec_ts': (_at - 1000) ~/ 1000, + 'beat_index': 0, + 'rr_ts_ms': _at - 1000, + 'rr_ms': 900, + }); + // ...and the developer explicitly re-processes the capture. + await LocalDb.reprocessBpResearchCapture(refId); + win = await db.rawQuery( + 'SELECT quality_status, snapshot_revision, hr_mean ' + 'FROM bp_research_window WHERE reference_id = ?', + [refId], + ); + expect(win.first['quality_status'], 'ok'); + expect(win.first['snapshot_revision'], 2); // NEW revision + // Revision 1 stays byte-identical. + final rev1After = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [refId], + )).first['onehz_json'] + as String; + expect(rev1After, rev1Json); + // The reference itself was never touched. + final ref = await db.rawQuery( + 'SELECT measured_at_ms, systolic_mmhg, diastolic_mmhg, captured_at_ms ' + 'FROM bp_research_reference WHERE id = ?', + [refId], + ); + expect(ref.first['measured_at_ms'], _at); + expect(ref.first['systolic_mmhg'], 120.0); + expect(ref.first['diastolic_mmhg'], 80.0); + }); + + test('reprocess stays pending when the sync still does not reach the ' + 'window end (A3, smoke 3)', () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_onehz'); + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 500, + 'rec_ts': (_at - 240000) ~/ 1000, + 'counter': 0, + 'hr': 62, + }); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 118, + diastolicMmHg: 78, + capturedAtMs: _at, + device: 'stillpending', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: [ + {'rec_ts': (_at - 240000) ~/ 1000, 'hr': 62}, + ], + rrRows: const [], + dataThroughMs: _at - 240000, + ), + ), + snapshotOnehzRows: [ + {'rec_ts': (_at - 240000) ~/ 1000, 'hr': 62}, + ], + snapshotRrRows: const [], + ); + final refId = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference WHERE device = ?', + ['stillpending'], + )).first['id'] + as int; + // Re-process WITHOUT new data: must stay pending — no fabricated + // final verdict. + await LocalDb.reprocessBpResearchCapture(refId); + final win = await db.rawQuery( + 'SELECT quality_status FROM bp_research_window ' + 'WHERE reference_id = ?', + [refId], + ); + expect(win.first['quality_status'], 'pending'); + }); } diff --git a/tool/bp_research_model.py b/tool/bp_research_model.py index 4248112dd..e3ba2bb28 100644 --- a/tool/bp_research_model.py +++ b/tool/bp_research_model.py @@ -75,15 +75,51 @@ ADMITTED_QUALITY = frozenset({"ok", "gappy"}) -def is_admitted_quality(status: str | None) -> bool: +def is_admitted_quality(status: str | None, + *, + admit_missing_quality: bool = False) -> bool: """Strict, documented research admission rule: only 'ok' and 'gappy' enter the model. UNKNOWN quality (None, empty, anything else) is NOT - admitted by default — a silent None -> "" -> admitted path would let - mixed or legacy sessions in through the back door. Historical rows - without a quality status can be admitted explicitly via - --admit-missing-quality (a documented compatibility mode, off by - default, reported in the report).""" - return status in ADMITTED_QUALITY + admitted — and NEVER re-admitted via --admit-missing-quality, which + exists ONLY for historical rows whose quality metadata is genuinely + absent (None). Default stays strict and reproducible.""" + if status in ADMITTED_QUALITY: + return True + if status is None: + return admit_missing_quality + return False +# Explicit exclusion status — an aggregated session carries this when any +# member's quality is KNOWN to be non-admitted ('pending', 'no_data', …). +# --admit-missing-quality can NEVER re-admit it: that flag exists ONLY for +# historical rows whose quality metadata is genuinely absent (None). +EXCLUDED_MIXED = "excluded_mixed_quality" + + +def _fold_member_quality(members) -> str | None: + """Fold member qualities into one honest session verdict. + + · ok/gappy everywhere → worst admitted status ('gappy' over 'ok'). + · any KNOWN non-admitted member → EXCLUDED_MIXED: excluded in every + mode, never re-admitted by the compatibility flag. + · all members None (historical, no metadata) → None: admission then + follows --admit-missing-quality. + · admitted mixed with genuinely-missing → conservative None (the + flag decides downstream; without it the session is excluded). + """ + rank = {"ok": 0, "gappy": 1} + if any(m.quality is not None and not is_admitted_quality(m.quality) + for m in members): + return EXCLUDED_MIXED + if all(m.quality is None for m in members): + return None + if any(m.quality is None for m in members): + # Admitted mixed with genuinely-missing: conservative — the + # admission flag decides via None (excluded without it). + return None + return max((m.quality for m in members), + key=lambda q: rank.get(q, -1)) + + # Session aggregation span: members of one explicit session id are only # aggregated when they lie within this span (engineering default, 30 min — # a few cuff readings of one sitting). Same label, farther apart: NOT one @@ -285,20 +321,12 @@ def wmean(vals: list[float | None]) -> float | None: hr_mean=wmean([m.hr_mean for m in members]), rmssd_ms=wmean([m.rmssd_ms for m in members]), session_id=label, - # STRICT member fold: if ANY member of the session is not - # admitted quality, the whole aggregated session carries - # None and is excluded downstream — an 'ok + pending' - # sitting is not admitted because half of it is not final - # data. All-admitted sessions carry their worst (most - # flagged) admitted status, 'gappy' over 'ok'. - quality=(max( - (m.quality for m in members), - key=lambda q: {"ok": 0, "gappy": 1}.get(q, -1)) - if all( - is_admitted_quality(m.quality) - or (m.quality is None and admit_missing_quality) - for m in members) - else None), + # STRICT member fold with EXPLICIT exclusion statuses — + # 'known non-admitted quality' (→ EXCLUDED_MIXED, never + # compatibility-admitted) must stay distinct from + # 'genuinely missing historical quality' (→ None, which + # follows the compatibility flag at admission time). + quality=_fold_member_quality(members), coverage=(sum(weights) / n if all( m.coverage is not None for m in members) else None), )) @@ -341,9 +369,25 @@ def run(rows: list[Row], level_b: bool = False, # with ANY non-admitted member ('ok + pending') carries quality None # and lands here, never silently inside the model. admitted = [r for r in aggregated - if is_admitted_quality(r.quality) - or (r.quality is None and admit_missing_quality)] + if is_admitted_quality(r.quality, + admit_missing_quality= + admit_missing_quality)] excluded_quality = len(aggregated) - len(admitted) + if not admitted: + # Structured, parseable research report instead of an + # IndexError on admitted[0]: rows exist, but none passes the + # quality admission rule. + return { + "error": "no admitted rows", + "rows_total": len(rows), + "rows_aggregated": len(aggregated), + "rows_excluded_quality": excluded_quality, + "admission_rule": { + "admitted_quality": sorted(ADMITTED_QUALITY), + "unknown_quality_admitted": admit_missing_quality, + "max_session_span_ms": MAX_SESSION_SPAN_MS, + }, + } # Calibration row: the first admitted reference seeds the models. # Baselines start from it too, so all models see the same history. diff --git a/tool/test_bp_research_model.py b/tool/test_bp_research_model.py index 27b5c93ad..4aaf87862 100644 --- a/tool/test_bp_research_model.py +++ b/tool/test_bp_research_model.py @@ -293,6 +293,130 @@ def test_chronological_replay(): b["systolic"]["adaptive_cuff_offset_baseline"]["mae_mmhg"] + + +# ====================================================================== +# C: the compatibility mode re-admits ONLY genuinely missing quality. +# A session excluded for a KNOWN bad member (pending, no_data) carries +# the EXPLICIT EXCLUDED_MIXED status and stays excluded in every mode. +# ====================================================================== + +def test_ok_pending_session_stays_excluded_in_compatibility_mode(): + rows = [_row(0, 120.0, 80.0), + _row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q="pending")] + rep = m.run(rows, admit_missing_quality=True) + assert rep["rows_excluded_quality"] == 1 + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + + +def test_ok_no_data_session_stays_excluded_in_compatibility_mode(): + rows = [_row(0, 120.0, 80.0), + _row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q="no_data")] + rep = m.run(rows, admit_missing_quality=True) + assert rep["rows_excluded_quality"] == 1 + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + + +def test_ok_gappy_session_is_admitted_as_gappy_in_compatibility_mode(): + rows = [_row(0, 120.0, 80.0), + _row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q="gappy")] + rep = m.run(rows, admit_missing_quality=True) + assert rep["rows_excluded_quality"] == 0 + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 1 + + +def test_all_missing_quality_session_follows_the_flag(): + # Every member genuinely lacks quality metadata (historical data): + # excluded by default, admitted ONLY with the explicit flag. + rows = [_row(0, 120.0, 80.0, sess="s1", q=None), + _row(60_000, 122.0, 82.0, sess="s1", q=None)] + rep_default = m.run(rows) + # ONE aggregated session row (all members None) → excluded by default. + assert rep_default["rows_excluded_quality"] == 1 + rep_compat = m.run(rows, admit_missing_quality=True) + assert rep_compat["rows_excluded_quality"] == 0 + + +def test_admitted_mixed_with_missing_is_conservative(): + # 'ok + genuinely missing' (no KNOWN bad member): excluded without + # the flag; the flag admits it because nothing known is wrong. + rows = [_row(60_000, 122.0, 82.0, sess="s1", q="ok"), + _row(120_000, 124.0, 84.0, sess="s1", q=None)] + rep_default = m.run(rows) + # The two members aggregate into ONE session row, whose folded + # quality is None (conservative) → excluded without the flag. + assert rep_default["rows_excluded_quality"] == 1 + rep_compat = m.run(rows, admit_missing_quality=True) + assert rep_compat["rows_excluded_quality"] == 0 + + +def test_excluded_mixed_status_is_never_admitted(): + # The fold's explicit exclusion status itself never passes admission. + assert m.is_admitted_quality(m.EXCLUDED_MIXED) is False + assert m.is_admitted_quality(m.EXCLUDED_MIXED, + admit_missing_quality=True) is False + assert m.is_admitted_quality("pending") is False + assert m.is_admitted_quality("no_data") is False + + +# ====================================================================== +# D: no crash when nothing is admitted — a structured, parseable +# research report instead of an IndexError. +# ====================================================================== + +def test_empty_csv_returns_no_rows_error(): + rep = m.run([]) + assert rep["error"] == "no rows" + + +def test_only_pending_rows_return_no_admitted_rows(): + rows = [_row(0, 120.0, 80.0, q="pending"), + _row(86_400_000, 121.0, 81.0, q="pending")] + rep = m.run(rows) + assert rep["error"] == "no admitted rows" + assert rep["rows_total"] == 2 + assert rep["rows_excluded_quality"] == 2 + assert rep["admission_rule"]["unknown_quality_admitted"] is False + + +def test_only_no_data_rows_return_no_admitted_rows(): + rows = [_row(0, 120.0, 80.0, q="no_data")] + rep = m.run(rows) + assert rep["error"] == "no admitted rows" + assert rep["rows_excluded_quality"] == 1 + + +def test_only_unknown_quality_returns_no_admitted_rows(): + rows = [_row(0, 120.0, 80.0, q="weird_status")] + rep = m.run(rows) + assert rep["error"] == "no admitted rows" + assert rep["rows_excluded_quality"] == 1 + + +def test_calibration_row_then_only_excluded_still_reports(): + # One valid calibration point, then only excluded rows: no target + # rows, but a structured report — never a crash. + rows = [_row(0, 120.0, 80.0, q="ok"), + _row(86_400_000, 121.0, 81.0, q="pending")] + rep = m.run(rows) + assert "error" not in rep + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + + +def test_admitted_rows_without_features_report_zero_targets(): + # Admitted rows exist, but none carries usable HR/RMSSD features: + # the report is structured, targets are zero, no exception. + rows = [_row(0, 120.0, 80.0, q="ok"), + _row(86_400_000, 121.0, 81.0, hr=None, rm=None, q="ok")] + rep = m.run(rows) + assert "error" not in rep + assert rep["systolic"]["adaptive_cuff_offset_baseline"]["n"] == 0 + assert rep["rows_excluded_no_features"] == 1 + + if __name__ == "__main__": for name, fn in sorted(globals().items()): if name.startswith("test_"): From ed7295f60ba9e6f42804eecdb10f87f3884d7128 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 11:14:51 +0000 Subject: [PATCH 12/22] =?UTF-8?q?fix(review):=20pending=20windows=20surviv?= =?UTF-8?q?e=20empty=20sync=20=E2=80=94=20no-data=20null=20only=20when=20f?= =?UTF-8?q?inal,=20no=20fabricated=20snapshots=20over=20empty=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- lib/data/db.dart | 71 +++++++++++++-- lib/health/bp_research_capture.dart | 29 +++++- test/bp_research_db_test.dart | 133 ++++++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 7 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 8916a370e..76cd69f79 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1859,9 +1859,7 @@ class LocalDb { // delivered their tail. final onehzThrough = through.onehzThroughMs ?? 0; final rrThrough = through.rrThroughMs ?? 0; - final dataThroughMs = onehzThrough < rrThrough - ? onehzThrough - : rrThrough; + final dataThroughMs = onehzThrough < rrThrough ? onehzThrough : rrThrough; final window = researchWindowFrom( measuredAtMs: (r['measured_at_ms'] as num).toInt(), onehzRows: onehz, @@ -1873,13 +1871,64 @@ class LocalDb { ); await db.transaction((txn) async { if (window == null) { - // Still nothing usable: drop the window row, keep the reference. + // FINAL and provably empty: the honest no-data case — the window + // row goes, the reference stays. await txn.rawDelete( 'DELETE FROM bp_research_window WHERE reference_id = ?', [referenceId], ); return; } + final onehzEmpty = onehz.isEmpty; + final rrEmpty = rr.isEmpty; + if (onehzEmpty && rrEmpty) { + // NOT final and still nothing locally: keep the window as + // 'pending' — it must survive so a later re-process can attach + // a new snapshot revision. A new revision over EMPTY rows would + // be fabricated evidence, so the window KEEPS whatever revision + // it already points at (or stays snapshotless). + await txn.rawInsert( + 'INSERT OR REPLACE INTO bp_research_window ' + '(reference_id, window_start_ms, window_end_ms, observed_start_ms, ' + 'observed_end_ms, onehz_rows, rr_beats, hr_mean, rr_ms_mean, ' + 'rr_ms_min, rr_ms_max, rmssd_ms, valid_hr_seconds, ' + 'valid_interval_count, valid_interval_pair_count, ' + 'coverage_fraction, rejected_interval_fraction, quality_status, ' + 'feature_version, snapshot_revision, meta_json) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + referenceId, + window.windowStartMs, + window.windowEndMs, + window.observedStartMs, + window.observedEndMs, + window.onehzRows, + window.rrBeats, + window.hrMean, + window.rrMsMean, + window.rrMsMin, + window.rrMsMax, + window.rmssdMs, + window.validHrSeconds, + window.validIntervalCount, + window.validIntervalPairCount, + window.coverageFraction, + window.rejectedIntervalFraction, + window.qualityStatus, + window.featureVersion, + // Keep the EXISTING revision: no new snapshot was computed, so + // no new revision may be claimed (snapshot-invariant). + (await txn.rawQuery( + 'SELECT snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [referenceId], + )).firstOrNull?['snapshot_revision'] + as int?, + window.metaJson, + ], + ); + return; + } final maxRev = Sqflite.firstIntValue( await txn.rawQuery( 'SELECT MAX(revision) FROM bp_research_snapshot ' @@ -2049,8 +2098,11 @@ class LocalDb { // · no snapshot lists → snapshot_revision = NULL: the window is // stored SNAPSHOTLESS (a legacy-style summary), never claiming // an old or foreign revision it cannot prove. + // Same content rule as the snapshot below: lists that are empty + // carry no evidence, so the window stays snapshotless. final hasSnapshotRows = - snapshotOnehzRows != null || snapshotRrRows != null; + (snapshotOnehzRows != null && snapshotOnehzRows.isNotEmpty) || + (snapshotRrRows != null && snapshotRrRows.isNotEmpty); await txn.rawInsert( 'INSERT OR REPLACE INTO bp_research_window ' '(reference_id, window_start_ms, window_end_ms, observed_start_ms, ' @@ -2093,7 +2145,14 @@ class LocalDb { // makes an overwrite of an existing revision a database-integrity // error instead of a silent history rewrite. Rows frozen as JSON // exactly as the window computation saw them. - if (snapshotOnehzRows != null || snapshotRrRows != null) { + // NO SNAPSHOT OVER EMPTY ROWS: a revision frozen over zero onehz + // AND zero rr rows is fabricated evidence — a pending window keeps + // its row without claiming any revision; the FIRST real data + // creates revision 1. + final snapshotHasContent = + (snapshotOnehzRows != null && snapshotOnehzRows.isNotEmpty) || + (snapshotRrRows != null && snapshotRrRows.isNotEmpty); + if (snapshotHasContent) { final maxRev = Sqflite.firstIntValue( await txn.rawQuery( 'SELECT MAX(revision) FROM bp_research_snapshot ' diff --git a/lib/health/bp_research_capture.dart b/lib/health/bp_research_capture.dart index b850865f5..82bcb68f6 100644 --- a/lib/health/bp_research_capture.dart +++ b/lib/health/bp_research_capture.dart @@ -392,7 +392,34 @@ BpResearchWindow? researchWindowFrom({ } } - if (onehzDedup.isEmpty && rrDedup.isEmpty) return null; + // EMPTY ≠ FINAL-EMPTY. With the sync-finality semantics a window can + // only be judged when its data basis is provably complete: + // · no rows at all AND the window is not provably final (future end + // or a watermark short of it) → a PENDING window, not null: the + // band may simply not have synced the last minutes yet, and the + // capture must keep its window row so a later re-process can + // attach a new snapshot revision to it. All stats stay NULL — + // missing is not zero. + // · no rows at all AND final (watermark provably reaches the end) + // → null is CORRECT: a final window that provably holds nothing + // is the honest no-data case. + final notFinal = + (nowMs != null && end > nowMs) || + (dataThroughMs != null && dataThroughMs < end - 1000); + if (onehzDedup.isEmpty && rrDedup.isEmpty) { + if (notFinal) { + return BpResearchWindow( + windowStartMs: start, + windowEndMs: end, + onehzRows: null, + rrBeats: null, + qualityStatus: 'pending', + featureVersion: kResearchFeatureVersion, + metaJson: metaJson, + ); + } + return null; + } // Valid HR rows only — a run of hr = 0 rows must not drag the average // toward zero AND must not count as observed signal (coverage). diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart index 1edc10db7..f57b7ffe1 100644 --- a/test/bp_research_db_test.dart +++ b/test/bp_research_db_test.dart @@ -1680,4 +1680,137 @@ void main() { ); expect(win.first['quality_status'], 'pending'); }); + test('A: an empty, not-final window is PENDING, keeps its row, and ' + 're-processing attaches a new revision once data arrives', () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_onehz'); + await db.delete('decoded_rr'); + // Szenario: cuff reading "just now", band has synced NOTHING yet — + // watermark 0 < window end. The capture must KEEP a pending window + // row (not lose it to null), so re-processing can find it. + final w = researchWindowFrom( + measuredAtMs: _at, + onehzRows: const [], + rrRows: const [], + nowMs: _at, + dataThroughMs: 0, + ); + expect(w, isNotNull); // the regression: no more silent null + expect(w!.qualityStatus, 'pending'); + expect(w.onehzRows, isNull); // missing ≠ 0 + expect(w.rrBeats, isNull); + expect(w.hrMean, isNull); + expect(w.rmssdMs, isNull); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'syncfix', + window: w, + ), + snapshotOnehzRows: const [], + snapshotRrRows: const [], + ); + final refId = + (await db.rawQuery( + 'SELECT id FROM bp_research_reference WHERE device = ?', + ['syncfix'], + )).first['id'] + as int; + var win = await db.rawQuery( + 'SELECT quality_status, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [refId], + ); + expect(win, hasLength(1)); // the window row SURVIVED the store + expect(win.first['quality_status'], 'pending'); + // Re-process BEFORE any sync: must stay pending, must NOT lose the + // row, must NOT invent a revision over empty rows. + await LocalDb.reprocessBpResearchCapture(refId); + win = await db.rawQuery( + 'SELECT quality_status, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [refId], + ); + expect(win, hasLength(1)); + expect(win.first['quality_status'], 'pending'); + // The band syncs the full window tail now... + for (int s = 0; s < 300; s++) { + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 1000 + s, + 'rec_ts': (_at - 300000) ~/ 1000 + s, + 'counter': s, + 'hr': 60, + }); + } + await db.insert('decoded_rr', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 2000, + 'rec_ts': (_at - 1000) ~/ 1000, + 'beat_index': 0, + 'rr_ts_ms': _at - 1000, + 'rr_ms': 900, + }); + // ...and re-processing attaches REAL data plus a new revision. + await LocalDb.reprocessBpResearchCapture(refId); + win = await db.rawQuery( + 'SELECT quality_status, snapshot_revision, onehz_rows, hr_mean ' + 'FROM bp_research_window WHERE reference_id = ?', + [refId], + ); + expect(win.first['quality_status'], 'ok'); // final now + expect(win.first['snapshot_revision'], 1); // FIRST real revision + expect(win.first['onehz_rows'], 300); + expect(win.first['hr_mean'], 60.0); + final snap = await db.rawQuery( + 'SELECT COUNT(*) c FROM bp_research_snapshot WHERE reference_id = ?', + [refId], + ); + expect(snap.first['c'], 1); + // A SECOND re-process with unchanged data writes revision 2 and + // keeps revision 1 byte-identical. + final rev1 = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [refId], + )).first['onehz_json'] + as String; + await LocalDb.reprocessBpResearchCapture(refId); + final win2 = await db.rawQuery( + 'SELECT snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [refId], + ); + expect(win2.first['snapshot_revision'], 2); + final rev1After = + (await db.rawQuery( + 'SELECT onehz_json FROM bp_research_snapshot ' + 'WHERE reference_id = ? AND revision = 1', + [refId], + )).first['onehz_json'] + as String; + expect(rev1After, rev1); + }); + + test('A: a final, provably empty window is still an honest NULL window ' + '(no pending-forever regression)', () async { + // Watermark provably covers the window end, the window is in the + // past, and there is STILL nothing: null is CORRECT (no_data + // honesty), not a fabricated pending row. + final w = researchWindowFrom( + measuredAtMs: _at, + onehzRows: const [], + rrRows: const [], + nowMs: _at + 600000, + dataThroughMs: _at + 600000, + ); + expect(w, isNull); + }); } From 97bd0417ee9ecea67f5f84cd24025b76a7c46bb9 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 11:37:02 +0000 Subject: [PATCH 13/22] fix(ui): pending windows show sync hint, not a false no-data verdict Co-authored-by: BucciMobile --- lib/ui2/profile/bp_research.dart | 34 +++++++++++-- test/bp_research_ui_test.dart | 85 ++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 test/bp_research_ui_test.dart diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart index 28ffac453..af791c350 100644 --- a/lib/ui2/profile/bp_research.dart +++ b/lib/ui2/profile/bp_research.dart @@ -21,6 +21,7 @@ // · Nothing here is exported to HealthKit / Health Connect. // A window with no band data is stored as a capture with an EMPTY window — // missing is missing, never zero. +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -33,6 +34,10 @@ import 'devices.dart' show formatDayTime; class BpResearchScreen extends StatefulWidget { const BpResearchScreen({super.key}); + @visibleForTesting + static String windowSummary(Map r) => + _BpResearchScreenState._windowSummary(r); + @override State createState() => _BpResearchScreenState(); } @@ -219,8 +224,7 @@ class _BpResearchScreenState extends State { ); final onehzThrough = through.onehzThroughMs ?? 0; final rrThrough = through.rrThroughMs ?? 0; - final dataThroughMs = - onehzThrough < rrThrough ? onehzThrough : rrThrough; + final dataThroughMs = onehzThrough < rrThrough ? onehzThrough : rrThrough; final window = researchWindowFrom( measuredAtMs: measuredAtMs, onehzRows: onehz, @@ -426,14 +430,36 @@ class _BpResearchScreenState extends State { /// A window is summarised as what it actually holds. A NULL stat is shown /// as absent — a dash, never a zero, and never a value that would read as /// a measurement. + /// + /// INVARIANT: pending ≠ no_data. 'pending' means the window is NOT + /// FINALIZABLE yet — the local sync provably does not reach the window + /// end, so the missing tail may still arrive after a sync + refresh. It + /// is never "no band data": that verdict is reserved for a FINAL window + /// that provably holds nothing. Pending is checked FIRST so a NULL stat + /// can never be misread as a final verdict; whatever HAS arrived is + /// still shown honestly alongside the hint. A non-medical, non-claiming + /// message. static String _windowSummary(Map r) { final onehz = r['onehz_rows']; final beats = r['rr_beats']; final hr = r['hr_mean']; final rmssd = r['rmssd_ms']; final status = r['quality_status']; + if (status == 'pending') { + // Not final YET — never "no band data". Show whatever has arrived + // (partial data is honest data), plus the sync hint. + final parts = [ + 'Band data is still syncing — refresh this window after sync.', + ]; + if (hr is num) parts.add('HR ${hr.toStringAsFixed(0)} bpm'); + if (rmssd is num) parts.add('RMSSD ${rmssd.toStringAsFixed(0)} ms'); + if (onehz is num || beats is num) { + parts.add('${onehz ?? 0} 1 Hz rows, ${beats ?? 0} beats so far'); + } + return parts.join(' · '); + } if (onehz == null && beats == null) { - return 'No band data in the window \u2014 stored as-is.'; + return 'No band data in the window — stored as-is.'; } final parts = []; if (hr is num) parts.add('HR ${hr.toStringAsFixed(0)} bpm'); @@ -442,6 +468,6 @@ class _BpResearchScreenState extends State { if (status is String && status.isNotEmpty && status != 'ok') { parts.add(status); } - return parts.join(' \u00b7 '); + return parts.join(' · '); } } diff --git a/test/bp_research_ui_test.dart b/test/bp_research_ui_test.dart new file mode 100644 index 000000000..2e8a6b156 --- /dev/null +++ b/test/bp_research_ui_test.dart @@ -0,0 +1,85 @@ +// Tests for the BP research window summary: pending must never read as a +// final "no band data" verdict, while a final empty window keeps its honest +// no-data text. Missing stays missing — never a fabricated zero. +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/profile/bp_research.dart'; + +void main() { + group('BpResearchScreen.windowSummary', () { + test('pending with no rows shows the sync hint, not "No band data"', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': 'pending', + 'onehz_rows': null, + 'rr_beats': null, + 'hr_mean': null, + 'rmssd_ms': null, + }); + expect(s, contains('Band data is still syncing')); + expect(s, isNot(contains('No band data'))); + }); + + test('pending with partial data shows the hint plus available metrics', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': 'pending', + 'onehz_rows': 120, + 'rr_beats': 80, + 'hr_mean': 62.0, + 'rmssd_ms': 41.0, + }); + expect(s, contains('Band data is still syncing')); + expect(s, contains('HR 62 bpm')); + expect(s, contains('RMSSD 41 ms')); + expect(s, contains('120 1 Hz rows, 80 beats so far')); + expect(s, isNot(contains('No band data'))); + }); + + test('final empty window keeps the honest no-data text', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': 'no_data', + 'onehz_rows': null, + 'rr_beats': null, + 'hr_mean': null, + 'rmssd_ms': null, + }); + expect(s, 'No band data in the window — stored as-is.'); + }); + + test('final empty window without status keeps the no-data text', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': null, + 'onehz_rows': null, + 'rr_beats': null, + 'hr_mean': null, + 'rmssd_ms': null, + }); + expect(s, 'No band data in the window — stored as-is.'); + }); + + test('ok window shows the existing summary without a status suffix', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': 'ok', + 'onehz_rows': 300, + 'rr_beats': 295, + 'hr_mean': 58.4, + 'rmssd_ms': 47.2, + }); + expect(s, isNot(contains('syncing'))); + expect(s, contains('HR 58 bpm')); + expect(s, contains('RMSSD 47 ms')); + expect(s, contains('300 1 Hz rows, 295 beats')); + expect(s, isNot(contains(' ok'))); + }); + + test('gappy window appends its status to the existing summary', () { + final s = BpResearchScreen.windowSummary(const { + 'quality_status': 'gappy', + 'onehz_rows': 210, + 'rr_beats': 180, + 'hr_mean': 61.0, + 'rmssd_ms': 38.0, + }); + expect(s, contains('210 1 Hz rows, 180 beats')); + expect(s.endsWith('gappy'), isTrue); + }); + }); +} From 5148c325bc8a740c4db8dc5f980afc00a831f35c Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 12:13:04 +0000 Subject: [PATCH 14/22] fix(l10n): honest 5-minute pre-measurement window texts, BP research strings localized Co-authored-by: BucciMobile --- lib/l10n/app_en.arb | 26 +++++++++++++-- lib/ui2/profile/bp_research.dart | 54 ++++++++++++++++++-------------- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bbf7d1ab0..46879539f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12446,7 +12446,7 @@ "@settingsBpResearchRowTitle": { "description": "Developer settings row title for the experimental blood-pressure research capture screen" }, - "settingsBpResearchRowSub": "EXPERIMENTAL. Pair a cuff reading with the band data of the same instant, for comparison outside this app. Never a health feature", + "settingsBpResearchRowSub": "EXPERIMENTAL. Pair a cuff reading with the 5 minutes of band rest data right before it, for comparison outside this app. Never a health feature", "@settingsBpResearchRowSub": { "description": "Developer settings row subtitle for the BP research capture screen" }, @@ -12454,7 +12454,7 @@ "@bpResearchTitle": { "description": "Title of the experimental BP research capture screen" }, - "bpResearchIntro": "EXPERIMENTAL. Take a cuff reading, type the pair in, press capture. The band data of the ±2 minutes around that instant is frozen next to it — for you to compare outside this app. Nothing here is a health feature, nothing here feeds any score, and nothing here is ever blended with what the band measured.", + "bpResearchIntro": "EXPERIMENTAL. Take a cuff reading, type the pair in, press capture. The 5 minutes of band data right before that reading are frozen next to it — for you to compare outside this app. Nothing here is a health feature, nothing here feeds any score, and nothing here is ever blended with what the band measured.", "@bpResearchIntro": { "description": "Intro text of the BP research capture screen" }, @@ -12486,7 +12486,7 @@ "@bpResearchMeasuredAt": { "description": "Optional back-dating field: when the cuff reading was actually taken" }, - "bpResearchMeasuredAtHint": "Back-date to the actual cuff reading \u2014 the band window is frozen around THAT instant, not around typing it in.", + "bpResearchMeasuredAtHint": "Back-date to the actual cuff reading \u2014 the 5-minute band window covers the rest time BEFORE that reading, not the typing-in. Your entry is minute-precise; the stored timestamp is the technical pairing anchor.", "@bpResearchMeasuredAtHint": { "description": "Helper text under the measurement-time field" }, @@ -12498,6 +12498,26 @@ "@bpResearchSessionIdHint": { "description": "Helper text under the session-id field" }, + "bpResearchBadTime": "Could not read the measurement time — use HH:MM or YYYY-MM-DD HH:MM, or leave it empty for \"now\". Nothing was stored.", + "@bpResearchBadTime": { + "description": "Snackbar text for an unparseable measurement-time entry, which is rejected, never guessed" + }, + "bpResearchFutureTime": "The measurement time lies in the future — the window would pair the reference with data that does not exist yet. Nothing was stored.", + "@bpResearchFutureTime": { + "description": "Snackbar text rejecting a future measurement time" + }, + "bpResearchRefreshTooltip": "Refresh band window", + "@bpResearchRefreshTooltip": { + "description": "Tooltip of the developer-mode button that re-reads the current local data for this capture's original window bounds" + }, + "bpResearchPendingSync": "Band data is still syncing — refresh this window after sync.", + "@bpResearchPendingSync": { + "description": "Window summary hint for a pending window: the local sync provably does not reach the window end yet, so the missing tail may still arrive; not a data-quality verdict" + }, + "bpResearchNoData": "No band data in the window — stored as-is.", + "@bpResearchNoData": { + "description": "Window summary text for a final window that provably holds no band data" + }, "bpResearchHistory": "Captures", "@bpResearchHistory": { "description": "Section header for the stored capture list" diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart index af791c350..1f9ef295c 100644 --- a/lib/ui2/profile/bp_research.dart +++ b/lib/ui2/profile/bp_research.dart @@ -21,7 +21,6 @@ // · Nothing here is exported to HealthKit / Health Connect. // A window with no band data is stored as a capture with an EMPTY window — // missing is missing, never zero. -import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -35,8 +34,8 @@ class BpResearchScreen extends StatefulWidget { const BpResearchScreen({super.key}); @visibleForTesting - static String windowSummary(Map r) => - _BpResearchScreenState._windowSummary(r); + static String windowSummary(Map r, [AppLocalizations? l]) => + _BpResearchScreenState._windowSummary(r, l); @override State createState() => _BpResearchScreenState(); @@ -152,11 +151,12 @@ class _BpResearchScreenState extends State { if (measuredAt == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Text( - 'Could not read the measurement time \u2014 use HH:MM or ' - 'YYYY-MM-DD HH:MM, or leave it empty for "now". Nothing was ' - 'stored.', + l?.bpResearchBadTime ?? + 'Could not read the measurement time \u2014 use HH:MM or ' + 'YYYY-MM-DD HH:MM, or leave it empty for "now". Nothing was ' + 'stored.', ), ), ); @@ -168,11 +168,12 @@ class _BpResearchScreenState extends State { if (measuredAtMs > enteredAtMs + 60 * 1000) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( + SnackBar( content: Text( - 'The measurement time lies in the future \u2014 the window ' - 'would pair the reference with data that does not exist yet. ' - 'Nothing was stored.', + l?.bpResearchFutureTime ?? + 'The measurement time lies in the future \u2014 the window ' + 'would pair the reference with data that does not exist yet. ' + 'Nothing was stored.', ), ), ); @@ -326,13 +327,15 @@ class _BpResearchScreenState extends State { TextField( controller: _measuredAt, keyboardType: TextInputType.datetime, - decoration: const InputDecoration( + decoration: InputDecoration( labelText: + l?.bpResearchMeasuredAt ?? 'Measurement time (HH:MM or YYYY-MM-DD HH:MM; empty = now)', helperText: - 'Back-date to the actual cuff reading \u2014 the band ' - 'window is frozen around THAT instant, not around typing ' - 'it in. Minute precision; empty = taken just now.', + l?.bpResearchMeasuredAtHint ?? + 'Back-date to the actual cuff reading \u2014 the 5-minute ' + 'band window covers the rest time BEFORE that reading, not ' + 'the typing-in. Minute precision; empty = taken just now.', ), ), const SizedBox(height: S.x2), @@ -345,11 +348,12 @@ class _BpResearchScreenState extends State { const SizedBox(height: S.x2), TextField( controller: _sessionId, - decoration: const InputDecoration( - labelText: 'Session id (optional)', + decoration: InputDecoration( + labelText: l?.bpResearchSessionId ?? 'Session id (optional)', helperText: + l?.bpResearchSessionIdHint ?? 'Group readings of one sitting \u2014 they are not ' - 'independent states, and an analysis must be able to tell.', + 'independent states, and an analysis must be able to tell.', ), ), const SizedBox(height: S.x2), @@ -383,7 +387,7 @@ class _BpResearchScreenState extends State { '${r['systolic_mmhg']}/${r['diastolic_mmhg']} mmHg \u2014 ' '${formatDayTime(DateTime.fromMillisecondsSinceEpoch(r['measured_at_ms'] as int), l)}', ), - subtitle: Text(_windowSummary(r)), + subtitle: Text(_windowSummary(r, l)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -394,7 +398,8 @@ class _BpResearchScreenState extends State { // window end). Reference values are never touched. IconButton( icon: const Icon(LucideIcons.refreshCw, size: 18), - tooltip: 'Refresh band window', + tooltip: + l?.bpResearchRefreshTooltip ?? 'Refresh band window', onPressed: () async { await LocalDb.reprocessBpResearchCapture( r['id'] as int, @@ -439,7 +444,7 @@ class _BpResearchScreenState extends State { /// can never be misread as a final verdict; whatever HAS arrived is /// still shown honestly alongside the hint. A non-medical, non-claiming /// message. - static String _windowSummary(Map r) { + static String _windowSummary(Map r, AppLocalizations? l) { final onehz = r['onehz_rows']; final beats = r['rr_beats']; final hr = r['hr_mean']; @@ -449,7 +454,9 @@ class _BpResearchScreenState extends State { // Not final YET — never "no band data". Show whatever has arrived // (partial data is honest data), plus the sync hint. final parts = [ - 'Band data is still syncing — refresh this window after sync.', + l?.bpResearchPendingSync ?? + 'Band data is still syncing — refresh this window after ' + 'sync.', ]; if (hr is num) parts.add('HR ${hr.toStringAsFixed(0)} bpm'); if (rmssd is num) parts.add('RMSSD ${rmssd.toStringAsFixed(0)} ms'); @@ -459,7 +466,8 @@ class _BpResearchScreenState extends State { return parts.join(' · '); } if (onehz == null && beats == null) { - return 'No band data in the window — stored as-is.'; + return l?.bpResearchNoData ?? + 'No band data in the window — stored as-is.'; } final parts = []; if (hr is num) parts.add('HR ${hr.toStringAsFixed(0)} bpm'); From 0024c45f29818343e32bc7d61198c81d19a72ab7 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 12:37:15 +0000 Subject: [PATCH 15/22] fix(migration): self-sufficient rung-56 BP upgrade, BP repair on open, real-file 54->56/55->56 migration tests Co-authored-by: BucciMobile --- lib/data/db.dart | 13 ++ test/bp_research_migration_test.dart | 336 +++++++++++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 test/bp_research_migration_test.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 76cd69f79..37e62952b 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1190,6 +1190,12 @@ class LocalDb { await _createNotifFired(db); await _createNotifSlots(db); await _createAlarmSchedule(db); + // BP research (rung 55 + v2 rung 56): both helpers are fully idempotent + // (CREATE TABLE IF NOT EXISTS plus per-column guarded ALTERs), so a + // same-version merged build whose schema lineage skipped a rung gets + // repaired here instead of bricking on the first BP read or write. + await _createBpResearch(db); + await _upgradeBpResearchV2(db); // CREATE TABLE IF NOT EXISTS on the every-open repair path, no schema // version bump needed — additive, no backfill (see _createImportedWorkout // just above for the same reasoning). @@ -1643,6 +1649,13 @@ class LocalDb { /// (every ADD COLUMN guarded by _columnsOf) so it can serve both the /// onUpgrade ladder and a fresh install that ran rung 55's CREATE first. static Future _upgradeBpResearchV2(Database db) async { + // SELF-SUFFICIENT rung: the ALTERs below assume the rung-55 tables exist. + // A v55 database whose bp_research tables are missing (an interrupted + // foreign build, an unusual merge lineage) would throw "no such table" + // inside the ONE exclusive onUpgrade transaction and brick every launch + // with no rollback target. CREATE TABLE IF NOT EXISTS is a no-op in every + // normal path (the ladder created the tables one rung earlier). + await _createBpResearch(db); final refCols = await _columnsOf(db, 'bp_research_reference'); // Measurement vs entry time. NULL on v1 rows: their measured_at_ms // doubles as both, and absent stays absent — no backfill. diff --git a/test/bp_research_migration_test.dart b/test/bp_research_migration_test.dart new file mode 100644 index 000000000..35a1247d1 --- /dev/null +++ b/test/bp_research_migration_test.dart @@ -0,0 +1,336 @@ +// END-TO-END BP research migration regressions over REAL SQLite files, +// in the style of db_migration_ladder_test.dart. Each test hand-builds a +// database file at an OLD schema version (54, 55, or a partially-migrated +// shape), then opens it through LocalDb so sqflite runs the whole onUpgrade +// ladder — and asserts the ladder completed without the +// quarantine-and-rebuild fallback (a bricked rung would still end at the +// current user_version, so version alone proves nothing). +// +// Covered paths: +// · fresh install (onCreate) — v1 tables + v2 columns in one pass +// · 54 → 55 → 56 in one app update (the rung-55 tables do not exist yet) +// · 55 → 56 (tables exist, v2 columns/table do not) +// · interrupted/unusual upgrade shapes: +// - v55 file WITHOUT the bp tables (foreign or partial build) +// - v56 file with rung-55 tables but a missing v2 column +// - v56 file already fully migrated (idempotent re-open) +// · backup/restore compatibility: a v1-shaped source DB restores into a +// v2 target with its v1 rows left honestly NULL in the new columns. +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/data/db.dart'; + +const _v1BpDdl = [ + ''' + CREATE TABLE bp_research_reference ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + measured_at_ms INTEGER NOT NULL, + device TEXT, + posture TEXT, + conditions TEXT, + systolic_mmhg REAL NOT NULL, + diastolic_mmhg REAL NOT NULL, + captured_at_ms INTEGER NOT NULL, + UNIQUE (measured_at_ms, device) + )''', + ''' + CREATE TABLE bp_research_window ( + reference_id INTEGER NOT NULL PRIMARY KEY, + window_start_ms INTEGER NOT NULL, + window_end_ms INTEGER NOT NULL, + onehz_rows INTEGER, + rr_beats INTEGER, + hr_mean REAL, + rr_ms_mean REAL, + rr_ms_min REAL, + rr_ms_max REAL, + rmssd_ms REAL, + meta_json TEXT + )''', + 'CREATE INDEX idx_bp_research_reference_at ' + 'ON bp_research_reference(measured_at_ms)', +]; + +Future _dbPath(String name) async => + p.join(await databaseFactory.getDatabasesPath(), name); + +/// Build a database FILE at [version] with [ddl] applied, optionally with +/// [seedRows], then close it — exactly like a user's old install on disk. +Future _seedOldDb( + String name, + int version, + List ddl, { + Future Function(Database db)? seedRows, +}) async { + final path = await _dbPath(name); + await databaseFactory.deleteDatabase(path); + final db = await databaseFactory.openDatabase( + path, + options: OpenDatabaseOptions( + version: version, + onCreate: (db, _) async { + for (final s in ddl) { + await db.execute(s); + } + }, + ), + ); + if (seedRows != null) await seedRows(db); + await db.close(); +} + +/// Open [name] through LocalDb (running the REAL ladder + repair pass) and +/// assert it completed without the quarantine fallback. +Future _openThroughLocalDb(String name) async { + await LocalDb.close(); + LocalDb.lastRebuild = null; + LocalDb.dbName = name; + final db = await LocalDb.instance; + expect( + LocalDb.lastRebuild, + isNull, + reason: + 'the upgrade bricked and fell back to quarantine-and-rebuild: ' + '${LocalDb.lastRebuild?.cause}', + ); + final rows = await db.rawQuery('PRAGMA user_version'); + expect((rows.first.values.first as num?)?.toInt(), LocalDb.schemaVersion); + return db; +} + +const _expectedRefV2Cols = [ + 'measurement_started_at_ms', + 'measurement_finished_at_ms', + 'band_device_id', + 'measurement_session_id', + 'time_precision', +]; + +const _expectedWinV2Cols = [ + 'observed_start_ms', + 'observed_end_ms', + 'valid_hr_seconds', + 'valid_interval_count', + 'valid_interval_pair_count', + 'coverage_fraction', + 'rejected_interval_fraction', + 'quality_status', + 'feature_version', + 'snapshot_revision', +]; + +Future> _columns(Database db, String table) async { + final info = await db.rawQuery('PRAGMA table_info($table)'); + return {for (final c in info) c['name'] as String}; +} + +void main() { + final created = []; + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + tearDownAll(() async { + await LocalDb.close(); + for (final n in created) { + await databaseFactory.deleteDatabase(await _dbPath(n)); + } + }); + + test( + 'fresh install creates v1 tables + all v2 columns in one pass', + () async { + const name = 'bp_migrate_fresh_test.db'; + created.add(name); + await LocalDb.close(); + LocalDb.lastRebuild = null; + LocalDb.dbName = name; + await databaseFactory.deleteDatabase(await _dbPath(name)); + final db = await LocalDb.instance; + expect(LocalDb.lastRebuild, isNull); + final refCols = await _columns(db, 'bp_research_reference'); + final winCols = await _columns(db, 'bp_research_window'); + for (final c in _expectedRefV2Cols) { + expect(refCols, contains(c)); + } + for (final c in _expectedWinV2Cols) { + expect(winCols, contains(c)); + } + final snapCols = await _columns(db, 'bp_research_snapshot'); + expect(snapCols, containsAll(['reference_id', 'revision', 'onehz_json'])); + }, + ); + + test( + 'upgrade 54 → 56 in one update creates everything, brick-free', + () async { + const name = 'bp_migrate_v54_test.db'; + created.add(name); + // A v54 database has NO bp tables at all; the ladder must create them at + // rung 55 and add the v2 shape at rung 56 — inside ONE transaction. + await _seedOldDb(name, 54, const []); + final db = await _openThroughLocalDb(name); + expect( + await db.rawQuery('SELECT name FROM sqlite_master WHERE name = ?', [ + 'bp_research_reference', + ]), + isNotEmpty, + ); + expect( + await db.rawQuery('SELECT name FROM sqlite_master WHERE name = ?', [ + 'bp_research_snapshot', + ]), + isNotEmpty, + ); + final refCols = await _columns(db, 'bp_research_reference'); + for (final c in _expectedRefV2Cols) { + expect(refCols, contains(c)); + } + }, + ); + + test( + 'upgrade 55 → 56 adds v2 columns/table, v1 data stays untouched', + () async { + const name = 'bp_migrate_v55_test.db'; + created.add(name); + await _seedOldDb( + name, + 55, + _v1BpDdl, + seedRows: (db) async { + await db.insert('bp_research_reference', { + 'measured_at_ms': 1700000000000, + 'device': 'omron', + 'systolic_mmhg': 120.0, + 'diastolic_mmhg': 80.0, + 'captured_at_ms': 1700000060000, + }); + await db.insert('bp_research_window', { + 'reference_id': 1, + 'window_start_ms': 1699999700000, + 'window_end_ms': 1700000000000, + 'onehz_rows': 300, + 'hr_mean': 61.0, + 'rmssd_ms': 42.0, + }); + }, + ); + final db = await _openThroughLocalDb(name); + final winCols = await _columns(db, 'bp_research_window'); + for (final c in _expectedWinV2Cols) { + expect(winCols, contains(c)); + } + // The v1 row keeps its data and reads NULL in every v2 column — absent + // stays absent, nothing is fabricated or rewritten. + final ref = await db.query('bp_research_reference'); + expect(ref, hasLength(1)); + expect(ref.first['systolic_mmhg'], 120.0); + expect(ref.first['measurement_started_at_ms'], isNull); + expect(ref.first['band_device_id'], isNull); + final win = await db.query('bp_research_window'); + expect(win, hasLength(1)); + expect(win.first['onehz_rows'], 300); + expect(win.first['quality_status'], isNull); + expect(win.first['snapshot_revision'], isNull); + }, + ); + + test('a v55 file WITHOUT the bp tables upgrades brick-free', () async { + // The hardened rung-56 helper must self-create the rung-55 tables + // instead of throwing "no such table" inside the exclusive transaction. + const name = 'bp_migrate_v55_missing_tables_test.db'; + created.add(name); + await _seedOldDb(name, 55, const []); + final db = await _openThroughLocalDb(name); + expect( + await db.rawQuery('SELECT name FROM sqlite_master WHERE name = ?', [ + 'bp_research_reference', + ]), + isNotEmpty, + ); + final refCols = await _columns(db, 'bp_research_reference'); + for (final c in _expectedRefV2Cols) { + expect(refCols, contains(c)); + } + }); + + test('a v56 file missing one v2 column is repaired on open', () async { + // Same-version merged-build case: the tables exist, one ALTER was + // skipped by an unusual lineage. _repairOpenSchema must add it back. + const name = 'bp_migrate_v56_partial_test.db'; + created.add(name); + await _seedOldDb(name, 56, _v1BpDdl); + // v1 window shape only (missing ALL v2 window columns) so the repair + // pass has real work to do on the window table. + final db = await _openThroughLocalDb(name); + final winCols = await _columns(db, 'bp_research_window'); + for (final c in _expectedWinV2Cols) { + expect(winCols, contains(c)); + } + }); + + test('an already-current v56 database re-opens idempotently', () async { + const name = 'bp_migrate_v56_idempotent_test.db'; + created.add(name); + await _seedOldDb(name, 56, const []); + final db = await _openThroughLocalDb(name); + await db.rawQuery('SELECT 1'); + // Reopen: the repair pass runs the same helpers again on a full schema. + await LocalDb.close(); + final db2 = await _openThroughLocalDb(name); + await db2.rawQuery('SELECT 1'); + }); + + test( + 'a v1-shaped backup restores into a v2 target with honest NULLs', + () async { + const target = 'bp_restore_v1_target_test.db'; + const source = 'bp_restore_v1_source_test.db'; + created.add(target); + created.add(source); + await _seedOldDb( + source, + 55, + _v1BpDdl, + seedRows: (db) async { + await db.insert('bp_research_reference', { + 'measured_at_ms': 1700000000000, + 'device': '', + 'systolic_mmhg': 125.0, + 'diastolic_mmhg': 82.0, + 'captured_at_ms': 1700000060000, + }); + await db.insert('bp_research_window', { + 'reference_id': 1, + 'window_start_ms': 1699999700000, + 'window_end_ms': 1700000000000, + 'onehz_rows': 200, + 'hr_mean': 58.0, + }); + }, + ); + await _seedOldDb(target, 56, const []); + final db = await _openThroughLocalDb(target); + final counts = await LocalDb.importFromDbFile(await _dbPath(source)); + expect(counts['bp_research_reference'], 1); + expect(counts['bp_research_window'], 1); + final ref = await db.query('bp_research_reference'); + expect(ref, hasLength(1)); + expect(ref.first['systolic_mmhg'], 125.0); + // v1 provenance stays NULL — the import never invents it. + expect(ref.first['measurement_session_id'], isNull); + final win = await db.query('bp_research_window'); + expect(win, hasLength(1)); + expect(win.first['onehz_rows'], 200); + expect( + win.first['snapshot_revision'], + isNull, + reason: + 'a v1 window is snapshotless; the import must not invent a ' + 'revision it has no snapshot for', + ); + }, + ); +} From 89af1dc5d43c2b1934ccda695bdd1f7ba0e8bbf1 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 1 Oct 2026 12:40:02 +0000 Subject: [PATCH 16/22] fix(tool): structured CSV validation errors instead of tracebacks, corrupt values never laundered to None Co-authored-by: BucciMobile --- tool/bp_research_model.py | 74 +++++++++++++++++++---- tool/test_bp_research_model.py | 105 +++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 11 deletions(-) diff --git a/tool/bp_research_model.py b/tool/bp_research_model.py index e3ba2bb28..8c3afcae9 100644 --- a/tool/bp_research_model.py +++ b/tool/bp_research_model.py @@ -237,27 +237,68 @@ def update_level_b(m: Model, z: list[float], ref: float, m.P[i][j] = apa[i][j] + DEFAULT_R_MMHG * k[i] * k[j] +class CsvDataError(ValueError): + """A row of the CSV export is corrupt (missing/invalid mandatory field, + unparseable or non-finite number, invalid timestamp). Raised INSTEAD of + a raw traceback so the CLI can report a structured, understandable + research error. Corrupt is corrupt — it is never silently laundered + into None/missing.""" + + def load_rows(path: str) -> list[Row]: rows: list[Row] = [] with open(path, newline="", encoding="utf-8") as f: - for r in csv.DictReader(f): - def num(key: str) -> float | None: + for i, r in enumerate(csv.DictReader(f), start=2): + def required(key: str) -> float: v = (r.get(key) or "").strip() if not v: - return None + raise CsvDataError( + f"CSV row {i}: required field '{key}' is empty") try: - return float(v) + x = float(v) except ValueError: + raise CsvDataError( + f"CSV row {i}: required field '{key}' is not a " + f"number: {v!r}") from None + if not math.isfinite(x): + raise CsvDataError( + f"CSV row {i}: required field '{key}' is not " + f"finite: {v!r}") + return x + + def optional(key: str) -> float | None: + # Empty = honestly missing (stays None downstream). A + # NON-EMPTY value that does not parse, or parses to + # NaN/inf, is CORRUPT — an error, never a quiet None + # that would read as "no data". + v = (r.get(key) or "").strip() + if not v: return None + try: + x = float(v) + except ValueError: + raise CsvDataError( + f"CSV row {i}: field '{key}' is not a number: " + f"{v!r}") from None + if not math.isfinite(x): + raise CsvDataError( + f"CSV row {i}: field '{key}' is not finite: {v!r}") + return x + + measured = required("measured_at_ms") + if not measured.is_integer(): + raise CsvDataError( + f"CSV row {i}: 'measured_at_ms' must be a whole " + f"number of milliseconds, got {measured!r}") rows.append(Row( - measured_at_ms=int(float(r["measured_at_ms"])), - sys_mmhg=float(r["systolic_mmhg"]), - dia_mmhg=float(r["diastolic_mmhg"]), - hr_mean=num("hr_mean"), - rmssd_ms=num("rmssd_ms"), + measured_at_ms=int(measured), + sys_mmhg=required("systolic_mmhg"), + dia_mmhg=required("diastolic_mmhg"), + hr_mean=optional("hr_mean"), + rmssd_ms=optional("rmssd_ms"), session_id=(r.get("measurement_session_id") or "").strip() or None, quality=(r.get("quality_status") or "").strip() or None, - coverage=num("coverage_fraction"), + coverage=optional("coverage_fraction"), )) rows.sort(key=lambda x: x.measured_at_ms) return rows @@ -573,7 +614,18 @@ def main() -> int: "reproducible default stays: unknown is excluded.") args = ap.parse_args() - rows = load_rows(args.csv) + try: + rows = load_rows(args.csv) + except CsvDataError as e: + report = {"error": "corrupt csv", "detail": str(e)} + text = json.dumps(report, indent=2) + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + f.write(text + "\n") + else: + print(text) + print(f"error: {e}", file=sys.stderr) + return 2 report = run(rows, level_b=args.level_b, admit_missing_quality=args.admit_missing_quality) text = json.dumps(report, indent=2) diff --git a/tool/test_bp_research_model.py b/tool/test_bp_research_model.py index 4aaf87862..9098f5a65 100644 --- a/tool/test_bp_research_model.py +++ b/tool/test_bp_research_model.py @@ -417,6 +417,111 @@ def test_admitted_rows_without_features_report_zero_targets(): assert rep["rows_excluded_no_features"] == 1 +def test_load_rows_rejects_corrupt_mandatory_numbers(): + import csv as _csv, tempfile + bad = [ + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\n,120,80\n", "empty required"), + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\nabc,120,80\n", "non-numeric"), + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\n1700000000,abc,80\n", "bad sys"), + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\n1700000000,120,\n", "empty dia"), + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\n1700000000,nan,80\n", "nan sys"), + ("measured_at_ms,systolic_mmhg,diastolic_mmhg\n1700000000,inf,80\n", "inf sys"), + ] + for content, why in bad: + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f: + f.write(content) + path = f.name + try: + try: + m.load_rows(path) + except m.CsvDataError: + pass + else: + raise AssertionError(f"corrupt CSV accepted: {why}") + finally: + os.unlink(path) + + +def test_load_rows_rejects_invalid_timestamp(): + import tempfile + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f: + f.write("measured_at_ms,systolic_mmhg,diastolic_mmhg\n" + "1700000000.5,120,80\n") + path = f.name + try: + try: + m.load_rows(path) + except m.CsvDataError: + pass + else: + raise AssertionError("fractional measured_at_ms accepted") + finally: + os.unlink(path) + + +def test_load_rows_rejects_corrupt_optional_numbers(): + # A non-empty optional field that does not parse (or is nan/inf) is a + # corrupt row, never a quiet None that would read as "no data". + import tempfile + bad = [ + ("1700000000,120,80,abc,40\n", "non-numeric hr"), + ("1700000000,120,80,70,nan\n", "nan rmssd"), + ("1700000000,120,80,70,inf\n", "inf rmssd"), + ] + for tail, why in bad: + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f: + f.write("measured_at_ms,systolic_mmhg,diastolic_mmhg," + "hr_mean,rmssd_ms\n" + tail) + path = f.name + try: + try: + m.load_rows(path) + except m.CsvDataError: + pass + else: + raise AssertionError(f"corrupt optional field accepted: {why}") + finally: + os.unlink(path) + + +def test_load_rows_keeps_empty_optionals_as_none(): + # Empty optional fields are honestly missing, not corrupt. + import tempfile + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f: + f.write("measured_at_ms,systolic_mmhg,diastolic_mmhg," + "hr_mean,rmssd_ms\n1700000000,120,80,,\n") + path = f.name + try: + rows = m.load_rows(path) + assert len(rows) == 1 + assert rows[0].hr_mean is None + assert rows[0].rmssd_ms is None + finally: + os.unlink(path) + + +def test_main_reports_corrupt_csv_structured(): + # The CLI exits 2 with a parseable JSON error report, no traceback. + import json as _json, subprocess, tempfile + with tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False) as f: + f.write("measured_at_ms,systolic_mmhg,diastolic_mmhg\n" + "1700000000,abc,80\n") + path = f.name + try: + proc = subprocess.run( + [sys.executable, os.path.join(os.path.dirname(__file__), + "bp_research_model.py"), + "--csv", path], + capture_output=True, text=True) + assert proc.returncode == 2, proc.stderr + report = _json.loads(proc.stdout) + assert report["error"] == "corrupt csv" + assert "systolic_mmhg" in report["detail"] + assert "Traceback" not in proc.stderr + finally: + os.unlink(path) + + if __name__ == "__main__": for name, fn in sorted(globals().items()): if name.startswith("test_"): From 8eed0020da84f7a4bb33e9e016c3985b77ddf248 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:15:40 +0530 Subject: [PATCH 17/22] revert unrelated formatter churn in db.dart and csv_export.dart --- lib/data/csv_export.dart | 6 +- lib/data/db.dart | 476 ++++++++++++++++++++------------------- 2 files changed, 241 insertions(+), 241 deletions(-) diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart index 316a09847..70c562a39 100644 --- a/lib/data/csv_export.dart +++ b/lib/data/csv_export.dart @@ -114,8 +114,7 @@ const kCsvExportSets = [ name: 'sleep', title: 'Sleep stages', columns: ['date', 'start_ts', 'end_ts', 'stage'], - sql: - 'SELECT date, start_ts, end_ts, stage FROM v_hypnogram ' + sql: 'SELECT date, start_ts, end_ts, stage FROM v_hypnogram ' 'ORDER BY date ASC, start_ts ASC', ), CsvExportSet( @@ -147,8 +146,7 @@ const kCsvExportSets = [ name: 'labs', title: 'Lab results', columns: ['taken_on', 'marker', 'value', 'unit', 'note'], - sql: - 'SELECT taken_on, marker, value, unit, note FROM lab_result ' + sql: 'SELECT taken_on, marker, value, unit, note FROM lab_result ' 'ORDER BY taken_on ASC, marker ASC', ), // ── everything below is data the user TYPED IN ────────────────────────────── diff --git a/lib/data/db.dart b/lib/data/db.dart index 37e62952b..7e43b4f9e 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -996,16 +996,10 @@ class LocalDb { // is what `id = ''` (kPrimaryDeviceId) means. Roles move, keys do // not. A no-op on a database with no device row yet. await _addColumnIfMissing( - db, - 'device', - 'role', - "TEXT NOT NULL DEFAULT 'paired'", + db, 'device', 'role', "TEXT NOT NULL DEFAULT 'paired'", ); await _addColumnIfMissing( - db, - 'device', - 'wearing', - 'INTEGER NOT NULL DEFAULT 1', + db, 'device', 'wearing', 'INTEGER NOT NULL DEFAULT 1', ); // A rung must no-op on a table this ladder has not created yet // (the same rule _addColumnIfMissing follows above). @@ -1039,9 +1033,7 @@ class LocalDb { await _rekeyByDeviceIdV51(db, 'band_events', keyTail: const ['hex']); await _rekeyByDeviceIdV51(db, 'events', keyTail: const ['hex']); await _rekeyByDeviceIdV51( - db, - 'band_battery', - keyTail: const ['ts', 'source'], + db, 'band_battery', keyTail: const ['ts', 'source'], ); // Step 6: coverage for the days the substrate still holds. Bounded, @@ -1059,9 +1051,7 @@ class LocalDb { // exactly its configured time, unchanged. No kAlgoVersion bump: // this is not a health metric. await _addColumnIfMissing( - db, - 'alarm_schedule', - 'smart_window_minutes', + db, 'alarm_schedule', 'smart_window_minutes', 'INTEGER NOT NULL DEFAULT 0', ); } @@ -1201,9 +1191,7 @@ class LocalDb { // just above for the same reasoning). await _createLiveWorkoutTally(db); await _addColumnIfMissing( - db, - 'alarm_schedule', - 'smart_window_minutes', + db, 'alarm_schedule', 'smart_window_minutes', 'INTEGER NOT NULL DEFAULT 0', ); await _createEcgTables(db); @@ -1465,6 +1453,7 @@ class LocalDb { static Future _ensureBeatTimeColumn(Database db) => _addColumnIfMissing(db, 'decoded_rr', 'beat_ts_ms', 'INTEGER'); + /// v46: retire what v34 banked into `on_wrist` / `hr_valid`, and any /// `skin_temp_c` that is really the sensor's unavailable sentinel. /// @@ -2337,9 +2326,8 @@ class LocalDb { 'VALUES(?, ?, ?)', [category, dedupeKey, candidate], ); - final n = Sqflite.firstIntValue( - await txn.rawQuery('SELECT changes()'), - ); + final n = + Sqflite.firstIntValue(await txn.rawQuery('SELECT changes()')); if (n == 1) return candidate; } throw StateError('notif_slots: no free slot within $probes probes'); @@ -2517,11 +2505,11 @@ class LocalDb { ); final batch = txn.batch(); for (var i = 0; i < packets.length; i++) { - batch.insert('ecg_reading_packet', { - ...packets[i], - 'reading_id': reading['id'], - 'ordinal': i, - }, conflictAlgorithm: ConflictAlgorithm.fail); + batch.insert( + 'ecg_reading_packet', + {...packets[i], 'reading_id': reading['id'], 'ordinal': i}, + conflictAlgorithm: ConflictAlgorithm.fail, + ); } await batch.commit(noResult: true); }); @@ -2543,7 +2531,9 @@ class LocalDb { } /// The accepted packets of [id] in ordinal order (placeholders included). - static Future>> ecgReadingPackets(String id) async { + static Future>> ecgReadingPackets( + String id, + ) async { final db = await instance; return db.query( 'ecg_reading_packet', @@ -2603,13 +2593,17 @@ class LocalDb { int smartWindowMinutes = 0, }) async { final db = await instance; - await db.insert('alarm_schedule', { - 'weekday': weekday, - 'hour': hour, - 'minute': minute, - 'enabled': enabled ? 1 : 0, - 'smart_window_minutes': smartWindowMinutes, - }, conflictAlgorithm: ConflictAlgorithm.replace); + await db.insert( + 'alarm_schedule', + { + 'weekday': weekday, + 'hour': hour, + 'minute': minute, + 'enabled': enabled ? 1 : 0, + 'smart_window_minutes': smartWindowMinutes, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); } /// Wipe the whole weekly schedule — the "Cancel-all" half of disabling the @@ -2639,8 +2633,7 @@ class LocalDb { return db.query( 'decoded_onehz', columns: const ['rec_ts', 'hr', 'ax', 'ay', 'az'], - where: - 'rec_ts >= ? AND rec_ts <= ? ' + where: 'rec_ts >= ? AND rec_ts <= ? ' 'AND hr IS NOT NULL AND ax IS NOT NULL AND ay IS NOT NULL AND az IS NOT NULL', whereArgs: [sinceEpochSec, untilEpochSec], orderBy: 'rec_ts ASC', @@ -2914,7 +2907,14 @@ class LocalDb { '${blankAdapter ? 'adapter_id = NULL, ' : 'adapter_id = COALESCE(?, adapter_id), '}' 'remote_id = COALESCE(?, remote_id), label = COALESCE(?, label), ' 'tier = COALESCE(?, tier), last_seen = ? WHERE id = ?', - [if (!blankAdapter) adapterId, remoteId, label, tier, now, id], + [ + if (!blankAdapter) adapterId, + remoteId, + label, + tier, + now, + id, + ], ); } @@ -3143,7 +3143,11 @@ class LocalDb { int limit = 200, }) async { final db = await instance; - return db.query('imported_workout', orderBy: 'start_ts DESC', limit: limit); + return db.query( + 'imported_workout', + orderBy: 'start_ts DESC', + limit: limit, + ); } /// Drop one imported workout AND its route. `deleteSession` cannot do this — @@ -3364,16 +3368,13 @@ class LocalDb { // A DB whose ladder has not created these must no-op rather than throw. for (final t in const ['decoded_onehz', 'device_coverage']) { final present = await db.rawQuery( - "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", - [t], + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", [t], ); if (present.isEmpty) return; } // Pre-v47 shape has no device_id; every row is the primary by definition. final cols = await _columnsOf(db, 'decoded_onehz'); - final dev = cols.contains('device_id') - ? 'device_id' - : "'$kPrimaryDeviceId'"; + final dev = cols.contains('device_id') ? 'device_id' : "'$kPrimaryDeviceId'"; const kBucket = 60; // seconds — the resolver's grid // One interval may absorb a gap of up to this many buckets and stay open. @@ -3437,9 +3438,8 @@ class LocalDb { // RR beats live in their own table, one row per beat. final rrCols = await _columnsOf(db, 'decoded_rr'); if (rrCols.isNotEmpty) { - final rrDev = rrCols.contains('device_id') - ? 'device_id' - : "'$kPrimaryDeviceId'"; + final rrDev = + rrCols.contains('device_id') ? 'device_id' : "'$kPrimaryDeviceId'"; final rows = await db.rawQuery( 'SELECT $rrDev AS d, rec_ts / $kBucket AS b FROM decoded_rr ' 'WHERE rec_ts > 0 GROUP BY d, b ORDER BY d ASC, b ASC', @@ -3479,12 +3479,11 @@ class LocalDb { List? neutrals, Map? toleranceSec, }) async { - assert( - samples.length == sampleSecs.length, - 'sampleSecs must be parallel to samples', - ); + assert(samples.length == sampleSecs.length, + 'sampleSecs must be parallel to samples'); final seen = >{}; - void observe(String signal, int sec) => (seen[signal] ??= []).add(sec); + void observe(String signal, int sec) => + (seen[signal] ??= []).add(sec); for (var i = 0; i < samples.length; i++) { final s = samples[i]; if (s == null) continue; @@ -3544,12 +3543,10 @@ class LocalDb { orderBy: 'start_ts DESC', limit: 1, ); - final startTs = open.isEmpty - ? null - : (open.single['start_ts'] as num).toInt(); - final endTs = open.isEmpty - ? null - : (open.single['end_ts'] as num).toInt(); + final startTs = + open.isEmpty ? null : (open.single['start_ts'] as num).toInt(); + final endTs = + open.isEmpty ? null : (open.single['end_ts'] as num).toInt(); for (var i = 0; i < spans.length; i++) { final (firstSec, lastSec) = spans[i]; // EXTEND ONLY A SPAN THAT STARTS AT OR AFTER THE OPEN INTERVAL and is @@ -3577,12 +3574,16 @@ class LocalDb { ); } } else { - await txn.insert('device_coverage', { - 'device_id': deviceId, - 'signal': signal, - 'start_ts': firstSec, - 'end_ts': lastSec, - }, conflictAlgorithm: ConflictAlgorithm.ignore); + await txn.insert( + 'device_coverage', + { + 'device_id': deviceId, + 'signal': signal, + 'start_ts': firstSec, + 'end_ts': lastSec, + }, + conflictAlgorithm: ConflictAlgorithm.ignore, + ); } } } @@ -3836,13 +3837,14 @@ class LocalDb { int loSec, int hiSec, { required String deviceId, - }) => _toggleSpans( - loSec, - hiSec, - onId: proto.EventId.wristOn, - offId: proto.EventId.wristOff, - deviceId: deviceId, - ); + }) => + _toggleSpans( + loSec, + hiSec, + onId: proto.EventId.wristOn, + offId: proto.EventId.wristOff, + deviceId: deviceId, + ); /// Spans ([startSec, endSec]) in [loSec, hiSec) during which the band was on /// the charger — off-wrist by definition, and motionless. @@ -3850,13 +3852,14 @@ class LocalDb { int loSec, int hiSec, { required String deviceId, - }) => _toggleSpans( - loSec, - hiSec, - onId: proto.EventId.chargingOff, - offId: proto.EventId.chargingOn, - deviceId: deviceId, - ); + }) => + _toggleSpans( + loSec, + hiSec, + onId: proto.EventId.chargingOff, + offId: proto.EventId.chargingOn, + deviceId: deviceId, + ); /// Build "state active" spans from a pair of toggle events, clipped to /// [loSec, hiSec). [offId] opens a span; [onId] closes it. @@ -4057,7 +4060,6 @@ class LocalDb { String? trimToken, Map? extraCursors, List? archives, - /// Rows from a band with no framed record to decode — a notify sensor's /// beats, a ring's stamped temperature. Queued into the SAME transaction /// as [raws], so a source with no flash still gets the one durable write @@ -4076,21 +4078,20 @@ class LocalDb { // the host supplies it; a signal absent from the map defaults to // 2*kBucket (120s) inside [_extendCoverageVia]. Map? coverageToleranceSec, - }) => _withCommitGate( - () => _commitSyncBatchLocked( - raws, - samples, - trimToken: trimToken, - extraCursors: extraCursors, - archives: archives, - neutrals: neutrals, - ecgRawPackets: ecgRawPackets, - onCheckpoint: onCheckpoint, - deviceFamily: deviceFamily, - deviceId: deviceId, - coverageToleranceSec: coverageToleranceSec, - ), - ); + }) => + _withCommitGate(() => _commitSyncBatchLocked( + raws, + samples, + trimToken: trimToken, + extraCursors: extraCursors, + archives: archives, + neutrals: neutrals, + ecgRawPackets: ecgRawPackets, + onCheckpoint: onCheckpoint, + deviceFamily: deviceFamily, + deviceId: deviceId, + coverageToleranceSec: coverageToleranceSec, + )); static Future _commitSyncBatchLocked( List raws, @@ -4328,11 +4329,8 @@ class LocalDb { await setCursor(kCounter, '$maxCounter', txn: txn); await setCursor(kRecTs, '$maxRecTs', txn: txn); if (trimToken != null) { - await setCursor( - cursorKeyFor('strap_trim', deviceId), - trimToken, - txn: txn, - ); + await setCursor(cursorKeyFor('strap_trim', deviceId), trimToken, + txn: txn); } if (extraCursors != null) { for (final e in extraCursors.entries) { @@ -5904,14 +5902,12 @@ class LocalDb { List prepend = const [], List? primaryKey, }) { - final own = - [ - for (final c in info) - if ((((c['pk'] as num?)?.toInt()) ?? 0) > 0) c, - ]..sort( - (a, b) => - ((a['pk'] as num).toInt()).compareTo((b['pk'] as num).toInt()), - ); + final own = [ + for (final c in info) + if ((((c['pk'] as num?)?.toInt()) ?? 0) > 0) c, + ]..sort( + (a, b) => ((a['pk'] as num).toInt()).compareTo((b['pk'] as num).toInt()), + ); final key = primaryKey ?? [for (final c in own) c['name'] as String]; final inline = key.length == 1 ? key.first : null; final defs = [...prepend]; @@ -5984,7 +5980,14 @@ class LocalDb { final tmp = '_${table}_v47'; await db.execute('DROP TABLE IF EXISTS $tmp'); await db.execute( - 'CREATE TABLE $tmp (${_rebuildDdlBody(info, prepend: const ["device_id TEXT NOT NULL DEFAULT ''", 'ts_ms INTEGER NOT NULL DEFAULT 0'], primaryKey: ['device_id', 'ts_ms', ...keyTail])})', + 'CREATE TABLE $tmp (${_rebuildDdlBody( + info, + prepend: const [ + "device_id TEXT NOT NULL DEFAULT ''", + 'ts_ms INTEGER NOT NULL DEFAULT 0', + ], + primaryKey: ['device_id', 'ts_ms', ...keyTail], + )})', ); final cols = names.join(', '); // COALESCE because a declared PRIMARY KEY on a legacy rowid table does NOT @@ -6036,7 +6039,11 @@ class LocalDb { final tmp = '_${table}_v51'; await db.execute('DROP TABLE IF EXISTS $tmp'); await db.execute( - 'CREATE TABLE $tmp (${_rebuildDdlBody(info, prepend: ["device_id TEXT NOT NULL DEFAULT '$kPrimaryDeviceId'"], primaryKey: ['device_id', ...keyTail])})', + 'CREATE TABLE $tmp (${_rebuildDdlBody( + info, + prepend: ["device_id TEXT NOT NULL DEFAULT '$kPrimaryDeviceId'"], + primaryKey: ['device_id', ...keyTail], + )})', ); final cols = names.join(', '); await db.execute( @@ -6568,6 +6575,7 @@ class LocalDb { return (rawRecTs != null && rawRecTs > 0) ? rawRecTs : decoded.tsEpoch; } + /// Replaces this second's RR beats. Returns the ops queued. /// /// Clear the second before reinserting so a SHRINKING beat count can't strand @@ -6657,18 +6665,22 @@ class LocalDb { required String deviceId, }) { final recTs = n.tsEpoch; - batch.insert('decoded_onehz', { - 'device_id': deviceId, - 'ts_ms': recTs * 1000, - 'rec_ts': recTs, - 'counter': 0, - // Absent is NULL, never zeroed — same rule _queueDecodedOneHz - // follows for hr/accel/optical. - 'hr': n.hr, - 'skin_temp_c': n.skinTempC, - 'device_family': deviceFamily, - 'source': deviceFamily, - }, conflictAlgorithm: ConflictAlgorithm.replace); + batch.insert( + 'decoded_onehz', + { + 'device_id': deviceId, + 'ts_ms': recTs * 1000, + 'rec_ts': recTs, + 'counter': 0, + // Absent is NULL, never zeroed — same rule _queueDecodedOneHz + // follows for hr/accel/optical. + 'hr': n.hr, + 'skin_temp_c': n.skinTempC, + 'device_family': deviceFamily, + 'source': deviceFamily, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); var ops = 1; // SCOPED TO THE WRITING DEVICE. Clear the second before reinserting so a // shrinking beat count can't strand stale high-index beats — same @@ -6682,16 +6694,20 @@ class LocalDb { for (var i = 0; i < n.rrMs.length; i++) { final rr = n.rrMs[i]; if (rr <= 0) continue; - batch.insert('decoded_rr', { - 'device_id': deviceId, - 'ts_ms': recTs * 1000, - 'rec_ts': recTs, - 'beat_index': i, - 'rr_ts_ms': recTs * 1000, - 'rr_ms': rr, - 'device_family': deviceFamily, - 'source': deviceFamily, - }, conflictAlgorithm: ConflictAlgorithm.replace); + batch.insert( + 'decoded_rr', + { + 'device_id': deviceId, + 'ts_ms': recTs * 1000, + 'rec_ts': recTs, + 'beat_index': i, + 'rr_ts_ms': recTs * 1000, + 'rr_ms': rr, + 'device_family': deviceFamily, + 'source': deviceFamily, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); ops++; } return ops; @@ -6922,9 +6938,7 @@ class LocalDb { /// retention edge anyway, so the cap is a backstop and not the normal case. /// INSERT OR IGNORE, so it can never overwrite a row the live writer already /// wrote. - static Future _backfillBandBatteryFromEvents( - DatabaseExecutor db, - ) async { + static Future _backfillBandBatteryFromEvents(DatabaseExecutor db) async { // A DB whose ladder has not created these yet (or is mid-ladder) must // NO-OP rather than throw. `redriveArchivedRecords` guards the same way and // for the same reason: a throw in here rolls the WHOLE upgrade back and @@ -7047,10 +7061,11 @@ class LocalDb { 'captured_at': capturedAt, }, conflictAlgorithm: ConflictAlgorithm.ignore); if (battery != null) { - await db.insert('band_battery', { - 'device_id': deviceId, - ...battery, - }, conflictAlgorithm: ConflictAlgorithm.ignore); + await db.insert( + 'band_battery', + {'device_id': deviceId, ...battery}, + conflictAlgorithm: ConflictAlgorithm.ignore, + ); } }, bestEffort: true); } @@ -7359,19 +7374,15 @@ class LocalDb { // the oldV<44 ladder step, where `decoded_onehz` is still keyed by rec_ts // alone and naming `device_id` would throw inside onUpgrade (quarantining // the database), and from the app/tests on a re-keyed table. One PRAGMA. - final preDeviceKey = !(await _columnsOf( - db, - 'decoded_onehz', - )).contains('device_id'); + final preDeviceKey = + !(await _columnsOf(db, 'decoded_onehz')).contains('device_id'); // Same self-detection as `preDeviceKey` above, for `raw_archive`'s own // rekey (v51): this function runs from the oldV<44 rung too, i.e. BEFORE // the v51 rekey, so at that point `raw_archive` is still hex-keyed and the // row-value comparison below must fall back to comparing `hex` alone. - final preArchiveDeviceKey = !(await _columnsOf( - db, - 'raw_archive', - )).contains('device_id'); + final preArchiveDeviceKey = + !(await _columnsOf(db, 'raw_archive')).contains('device_id'); final marks = List.filled(redrivableArchiveReasons.length, '?').join(','); // Paged on (hex, device_id) — a stable, total order that needs no extra @@ -7407,9 +7418,8 @@ class LocalDb { // one another. final byDeviceRecTs = <(String, int), (RawRecord, Sample)>{}; for (final r in rows) { - final deviceId = preArchiveDeviceKey - ? kPrimaryDeviceId - : r['device_id'] as String; + final deviceId = + preArchiveDeviceKey ? kPrimaryDeviceId : r['device_id'] as String; final raw = RawRecord( counter: (r['counter'] as num?)?.toInt() ?? 0, packetType: (r['packet_type'] as num?)?.toInt() ?? 0, @@ -8080,11 +8090,7 @@ class LocalDb { final db = await instance; final name = signal.name; await db.transaction((txn) async { - await txn.delete( - 'signal_priority', - where: 'signal = ?', - whereArgs: [name], - ); + await txn.delete('signal_priority', where: 'signal = ?', whereArgs: [name]); for (var i = 0; i < order.length; i++) { await txn.insert('signal_priority', { 'signal': name, @@ -8128,10 +8134,7 @@ class LocalDb { /// Sparse: an absent signal falls through to `rankSources()` (§4.5's ladder). static Future>> signalPriorities() async { final db = await instance; - final rows = await db.query( - 'signal_priority', - orderBy: 'signal ASC, rank ASC, device_id ASC', - ); + final rows = await db.query('signal_priority', orderBy: 'signal ASC, rank ASC, device_id ASC'); final out = >{}; for (final r in rows) { (out[r['signal'] as String] ??= []).add(r['device_id'] as String); @@ -9233,85 +9236,85 @@ class LocalDb { /// merges, in order: independent tables first; all use INSERT OR /// REPLACE so re-import is safe. static const List _restoreTables = [ - // Hand-entered rows first. Nothing regenerates these, so if a merge is - // ever cut short (an OOM, a damaged source) they are the ones already - // banked. They were also simply MISSING here until now — nutrition, - // medication, strength sets, symptoms and routes did not survive a - // backup/restore round trip at all, the same omission `wipeAll` documents. - 'bp_research_reference', - 'bp_research_window', - 'bp_research_snapshot', - 'journal', - 'journal_metric', - 'journal_field_def', - 'lab_result', - 'lab_marker_def', - 'strength_set', - 'exercise_def', - 'food_entry', - 'food_def', - 'med_def', - 'med_dose', - 'cycle_log', - 'cycle_symptom', - 'breathing_session', - // Vendor-computed, typed-in and imported scalars. In the hand-entered - // block because a third of it IS hand-entered and nothing regenerates - // any of it — a `reports` band trims its own history, and the app whose - // export the imported rows came from may be uninstalled by now. - 'observation', - 'workout_route', - 'workout_split', - // The user's sleep corrections. These are the ONLY copy of them — the - // detector's output is deliberately not baked in, so a restore that - // skipped these would silently reinstate every nap the user had deleted - // and lose every one they logged. - 'sleep_override', - 'sleep_nap', - 'samples', - 'events', - 'decoded_onehz', - 'decoded_rr', - // The only copy of what a paired sensor measured during a session — the - // band cannot re-deliver it, so a restore that skipped it loses it. - 'external_hr', - // Re-readable from the health store, but only for as long as that app is - // installed and that permission is granted — cheaper to carry. - 'imported_measurement', - // Same reasoning, and more so: a route is thousands of points that the - // source app may have deleted since. `workout_route` is already in this - // list above and carries the imported routes too. - 'imported_workout', - // The never-pruned archive of frames we could not decode. exportCopy() - // is a whole-database VACUUM INTO, so these rows DO leave the device — - // leaving the table out here meant a backup/restore round trip silently - // dropped them, in the one table whose entire purpose is that a frame is - // never lost. Keyed by `hex`, so two same-counter frames from different - // boots both survive the merge. - 'raw_archive', - 'band_events', - 'band_battery', - 'day_result', - 'metric_series', - 'metric_series_version', - 'sessions', - 'notifications', - 'baselines', - // The devices this phone knows about — so a SECONDARY device's identity - // survives a backup/restore round trip rather than leaving its rows in - // `decoded_onehz` pointing at a `device_id` nothing can name. The PRIMARY - // row is deliberately skipped on the way in; see the guard below. - 'device', - 'device_coverage', - 'signal_priority', - // WHOOP MG ECG: a user-initiated reading, its exact accepted packets - // and the raw R16 records history recovered for it. None regenerates — - // the band trimmed its copy on ACK. Parent before child so a restore - // cut short never leaves packets without their reading. - 'ecg_reading', - 'ecg_reading_packet', - 'ecg_raw_packet', - 'sync_cursor', + // Hand-entered rows first. Nothing regenerates these, so if a merge is + // ever cut short (an OOM, a damaged source) they are the ones already + // banked. They were also simply MISSING here until now — nutrition, + // medication, strength sets, symptoms and routes did not survive a + // backup/restore round trip at all, the same omission `wipeAll` documents. + 'bp_research_reference', + 'bp_research_window', + 'bp_research_snapshot', + 'journal', + 'journal_metric', + 'journal_field_def', + 'lab_result', + 'lab_marker_def', + 'strength_set', + 'exercise_def', + 'food_entry', + 'food_def', + 'med_def', + 'med_dose', + 'cycle_log', + 'cycle_symptom', + 'breathing_session', + // Vendor-computed, typed-in and imported scalars. In the hand-entered + // block because a third of it IS hand-entered and nothing regenerates + // any of it — a `reports` band trims its own history, and the app whose + // export the imported rows came from may be uninstalled by now. + 'observation', + 'workout_route', + 'workout_split', + // The user's sleep corrections. These are the ONLY copy of them — the + // detector's output is deliberately not baked in, so a restore that + // skipped these would silently reinstate every nap the user had deleted + // and lose every one they logged. + 'sleep_override', + 'sleep_nap', + 'samples', + 'events', + 'decoded_onehz', + 'decoded_rr', + // The only copy of what a paired sensor measured during a session — the + // band cannot re-deliver it, so a restore that skipped it loses it. + 'external_hr', + // Re-readable from the health store, but only for as long as that app is + // installed and that permission is granted — cheaper to carry. + 'imported_measurement', + // Same reasoning, and more so: a route is thousands of points that the + // source app may have deleted since. `workout_route` is already in this + // list above and carries the imported routes too. + 'imported_workout', + // The never-pruned archive of frames we could not decode. exportCopy() + // is a whole-database VACUUM INTO, so these rows DO leave the device — + // leaving the table out here meant a backup/restore round trip silently + // dropped them, in the one table whose entire purpose is that a frame is + // never lost. Keyed by `hex`, so two same-counter frames from different + // boots both survive the merge. + 'raw_archive', + 'band_events', + 'band_battery', + 'day_result', + 'metric_series', + 'metric_series_version', + 'sessions', + 'notifications', + 'baselines', + // The devices this phone knows about — so a SECONDARY device's identity + // survives a backup/restore round trip rather than leaving its rows in + // `decoded_onehz` pointing at a `device_id` nothing can name. The PRIMARY + // row is deliberately skipped on the way in; see the guard below. + 'device', + 'device_coverage', + 'signal_priority', + // WHOOP MG ECG: a user-initiated reading, its exact accepted packets + // and the raw R16 records history recovered for it. None regenerates — + // the band trimmed its copy on ACK. Parent before child so a restore + // cut short never leaves packets without their reading. + 'ecg_reading', + 'ecg_reading_packet', + 'ecg_raw_packet', + 'sync_cursor', ]; @visibleForTesting @@ -10364,8 +10367,7 @@ class LocalDb { final db = await instance; return db.query( 'metric_series', - where: - 'key = ? AND value IS NOT NULL' + where: 'key = ? AND value IS NOT NULL' '${measuredOnly ? ' AND date NOT IN ($_importedDatesSql)' : ''}', whereArgs: [key], orderBy: 'date ASC', From 11bd94d3b0c5bec77e7bd09c94fe773c19a36f32 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:22:43 +0530 Subject: [PATCH 18/22] bp research: create tables on the repair path instead of rungs 55/56, one window query, refresh keeps pruned windows and creates a missing one --- lib/data/db.dart | 648 ++++++++------------------- lib/ui2/profile/bp_research.dart | 54 +-- test/bp_research_db_test.dart | 85 ++++ test/bp_research_migration_test.dart | 213 +-------- 4 files changed, 292 insertions(+), 708 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 7e43b4f9e..5a93851ff 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -353,7 +353,7 @@ class LocalDb { /// pass it: sqflite throws `ArgumentError('onCreate must be null if no /// version is specified')` BEFORE opening anything when `onCreate` is given /// without `version` (sqflite_common database_mixin.dart). - static const int schemaVersion = 56; + static const int schemaVersion = 54; /// SQLite caps host parameters per statement (`SQLITE_MAX_VARIABLE_NUMBER` — /// only 999 on the builds shipped with older Android/iOS). Any `IN (?, ?, …)` @@ -466,7 +466,6 @@ class LocalDb { await _createNotifSlots(db); await _createAlarmSchedule(db); await _createBpResearch(db); - await _upgradeBpResearchV2(db); await _ensureCoachViews(db); }, onUpgrade: (db, oldV, newV) async { @@ -1079,26 +1078,6 @@ class LocalDb { // next free rung rather than collide with any of them. await _createEcgTables(db); } - if (oldV < 55) { - // BP research capture: paired cuff reference readings plus the - // band's own decoded 1 Hz / R-R window frozen around the - // measurement instant, for out-of-app comparison only. Two new - // tables, CREATE TABLE IF NOT EXISTS and NOTHING else — no - // backfill, no rewrite, no ADD COLUMN — so a throw here has - // nothing to roll back onto (invariant 11). Ships without a - // kAlgoVersion bump: nothing derived moves, and nothing derived - // may ever read these (see the guard comment in - // [_createBpResearch]). - await _createBpResearch(db); - } - if (oldV < 56) { - // BP research v2: measurement vs entry time, the rest window - // with quality counts, and immutable raw-row snapshots. All - // ADDITIVE: new nullable columns on the existing tables plus one - // new table — no rewrite, no backfill (v1 rows keep NULL in the - // new columns; absent stays absent). Same isolation as rung 55. - await _upgradeBpResearchV2(db); - } }, onOpen: (db) async { await _repairOpenSchema(db); @@ -1180,12 +1159,8 @@ class LocalDb { await _createNotifFired(db); await _createNotifSlots(db); await _createAlarmSchedule(db); - // BP research (rung 55 + v2 rung 56): both helpers are fully idempotent - // (CREATE TABLE IF NOT EXISTS plus per-column guarded ALTERs), so a - // same-version merged build whose schema lineage skipped a rung gets - // repaired here instead of bricking on the first BP read or write. + // Dev-mode BP research capture tables. Additive, no rung. await _createBpResearch(db); - await _upgradeBpResearchV2(db); // CREATE TABLE IF NOT EXISTS on the every-open repair path, no schema // version bump needed — additive, no backfill (see _createImportedWorkout // just above for the same reasoning). @@ -1614,131 +1589,26 @@ class LocalDb { ); } - /// BP research capture store (schema rung 55). - /// - /// EXPERIMENTAL, DEVELOPER-ONLY, and it stays that way. A cuff reading the - /// user types in next to the band data of the same instant is exactly the - /// pairing the `imported_measurement` guard exists to prevent becoming an - /// input: the moment a wrist series and a cuff series are regressed against - /// each other ON DEVICE, this app is making a cuffless-blood-pressure - /// claim from an uncleared device. So this is a SEPARATE store, read by - /// exactly one dev screen and one CSV export, and — like - /// `imported_measurement` and `observation` — the isolation is structural: - /// nothing in `compute/`, nothing that feeds `day_result` or - /// `metric_series`, and nothing that writes to HealthKit / Health Connect - /// may name either table. `bp_research_isolation_test.dart` fails the moment - /// anyone does. - /// - /// Captures are idempotent on `(measured_at_ms, device)`: retaking the same - /// cuff reading at the same instant re-states the window rather than - /// duplicating it. Legitimate repeat measurements minutes apart are - /// different instants and both stay. - /// Rung 56: the v2 research columns and the snapshot table. Additive only — - /// nullable columns and a new table, no rewrite, no backfill. Idempotent - /// (every ADD COLUMN guarded by _columnsOf) so it can serve both the - /// onUpgrade ladder and a fresh install that ran rung 55's CREATE first. - static Future _upgradeBpResearchV2(Database db) async { - // SELF-SUFFICIENT rung: the ALTERs below assume the rung-55 tables exist. - // A v55 database whose bp_research tables are missing (an interrupted - // foreign build, an unusual merge lineage) would throw "no such table" - // inside the ONE exclusive onUpgrade transaction and brick every launch - // with no rollback target. CREATE TABLE IF NOT EXISTS is a no-op in every - // normal path (the ladder created the tables one rung earlier). - await _createBpResearch(db); - final refCols = await _columnsOf(db, 'bp_research_reference'); - // Measurement vs entry time. NULL on v1 rows: their measured_at_ms - // doubles as both, and absent stays absent — no backfill. - if (!refCols.contains('measurement_started_at_ms')) { - await db.execute( - 'ALTER TABLE bp_research_reference ' - 'ADD COLUMN measurement_started_at_ms INTEGER', - ); - } - if (!refCols.contains('measurement_finished_at_ms')) { - await db.execute( - 'ALTER TABLE bp_research_reference ' - 'ADD COLUMN measurement_finished_at_ms INTEGER', - ); - } - // Band identity and session grouping, kept beside the capture so signal - // provenance survives a device swap or a second band. - if (!refCols.contains('band_device_id')) { - await db.execute( - 'ALTER TABLE bp_research_reference ADD COLUMN band_device_id TEXT', - ); - } - if (!refCols.contains('measurement_session_id')) { - await db.execute( - 'ALTER TABLE bp_research_reference ' - 'ADD COLUMN measurement_session_id TEXT', - ); - } - // Precision of the recorded measurement instant ('minute' for the - // current UI) — the analysis must know the pairing instant is not - // second-accurate. - if (!refCols.contains('time_precision')) { - await db.execute( - 'ALTER TABLE bp_research_reference ADD COLUMN time_precision TEXT', - ); - } - - final winCols = await _columnsOf(db, 'bp_research_window'); - // Requested vs OBSERVED window bounds: what the data actually covered. - if (!winCols.contains('observed_start_ms')) { - await db.execute( - 'ALTER TABLE bp_research_window ADD COLUMN observed_start_ms INTEGER', - ); - } - if (!winCols.contains('observed_end_ms')) { - await db.execute( - 'ALTER TABLE bp_research_window ADD COLUMN observed_end_ms INTEGER', - ); - } - // Quality counts (v2): honest coverage and continuity metrics, never a - // fabricated confidence number. - for (final c in [ - 'valid_hr_seconds INTEGER', - 'valid_interval_count INTEGER', - 'valid_interval_pair_count INTEGER', - 'coverage_fraction REAL', - 'rejected_interval_fraction REAL', - 'quality_status TEXT', - 'feature_version INTEGER', - 'snapshot_revision INTEGER', - ]) { - final name = c.split(' ').first; - if (!winCols.contains(name)) { - await db.execute('ALTER TABLE bp_research_window ADD COLUMN $c'); - } - } - - // Immutable raw-row snapshots: the exact onehz/rr rows a window - // revision was computed from, frozen as JSON. Re-processing writes a - // NEW revision row; old revisions stay. Research-only, same isolation - // as the rung-55 tables. - await db.execute( - 'CREATE TABLE IF NOT EXISTS bp_research_snapshot (' - 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' - 'reference_id INTEGER NOT NULL, ' - 'revision INTEGER NOT NULL, ' - 'onehz_json TEXT NOT NULL, ' - 'rr_json TEXT NOT NULL, ' - 'created_at_ms INTEGER NOT NULL, ' - 'UNIQUE (reference_id, revision))', - ); - } - + /// Dev-mode BP research store: cuff readings typed in next to the band's + /// own decoded window before them, for CSV export only. Nothing derived, + /// nothing in `compute/` and no health-store writer may read these; + /// `bp_research_isolation_test.dart` enforces it. static Future _createBpResearch(Database db) async { await db.execute(''' CREATE TABLE IF NOT EXISTS bp_research_reference ( id INTEGER PRIMARY KEY AUTOINCREMENT, measured_at_ms INTEGER NOT NULL, + measurement_started_at_ms INTEGER, + measurement_finished_at_ms INTEGER, device TEXT, posture TEXT, conditions TEXT, systolic_mmhg REAL NOT NULL, diastolic_mmhg REAL NOT NULL, captured_at_ms INTEGER NOT NULL, + band_device_id TEXT, + measurement_session_id TEXT, + time_precision TEXT, UNIQUE (measured_at_ms, device) ) '''); @@ -1748,9 +1618,8 @@ class LocalDb { REFERENCES bp_research_reference(id) ON DELETE CASCADE, window_start_ms INTEGER NOT NULL, window_end_ms INTEGER NOT NULL, - -- NULL-safe by design: the band may have had nothing to say at that - -- instant (not worn, not synced yet), and a missing window is recorded - -- as missing — never as zeroes. + observed_start_ms INTEGER, + observed_end_ms INTEGER, onehz_rows INTEGER, rr_beats INTEGER, hr_mean REAL, @@ -1758,72 +1627,93 @@ class LocalDb { rr_ms_min REAL, rr_ms_max REAL, rmssd_ms REAL, + valid_hr_seconds INTEGER, + valid_interval_count INTEGER, + valid_interval_pair_count INTEGER, + coverage_fraction REAL, + rejected_interval_fraction REAL, + quality_status TEXT, + feature_version INTEGER, + snapshot_revision INTEGER, meta_json TEXT ) '''); + // The exact rows a window revision was computed from. Insert-only: a + // refresh writes revision n+1 and never rewrites an older one. + await db.execute(''' + CREATE TABLE IF NOT EXISTS bp_research_snapshot ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reference_id INTEGER NOT NULL, + revision INTEGER NOT NULL, + onehz_json TEXT NOT NULL, + rr_json TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + UNIQUE (reference_id, revision) + ) + '''); await db.execute( 'CREATE INDEX IF NOT EXISTS idx_bp_research_reference_at ' 'ON bp_research_reference(measured_at_ms)', ); } - /// Insert one cuff reference reading plus its frozen band window. - /// - /// Pure write; the caller computes the window stats (see - /// `lib/health/bp_research_capture.dart`). Idempotent on - /// `(measured_at_ms, device)`: `INSERT OR REPLACE` on the reference, then - /// the window row is restated in the same transaction so a retake can never - /// leave an old window under a new reference. - /// Insert one cuff reference reading plus its frozen band window and the - /// immutable snapshot of the rows the window was computed from. - /// - /// Pure write; the caller computes the window stats (see - /// `lib/health/bp_research_capture.dart`). Idempotent on - /// `(measured_at_ms, device)`: the colliding row is deleted explicitly - /// first (window, snapshots, then the reference — no PRAGMA foreign_keys - /// here, so nothing cascades on its own), then re-inserted. - /// The SYNC WATERMARK of exactly one band: up to which instant do we - /// provably hold DECODED local data for this device? HR and RR are - /// answered SEPARATELY — the two series decode from different packets - /// and a shared watermark would be a fabrication. NULL means no decoded - /// row exists for the device. The BP research window classification - /// uses this as its pending criterion: a window whose end lies beyond - /// the watermark is 'pending', never a final 'no_data'/'gappy' — the - /// missing tail may still arrive with the next sync. - static Future<({int? onehzThroughMs, int? rrThroughMs})> - bpResearchDataThroughMs(String deviceId) async { - final db = await instance; - final onehz = Sqflite.firstIntValue( + /// The decoded rows of one band inside the half-open window + /// `[startMs, endMs)`, plus how far that band's decoded data reaches: the + /// earlier of the HR and RR watermarks, 0 for a series with no rows yet. + static Future< + ({ + List> onehz, + List> rr, + int dataThroughMs, + }) + > + bpResearchRows(String deviceId, int startMs, int endMs) async { + final db = await instance; + final onehz = await db.rawQuery( + 'SELECT rec_ts, hr FROM decoded_onehz ' + 'WHERE device_id = ? AND rec_ts >= ? AND rec_ts < ? ' + 'ORDER BY rec_ts ASC', + [deviceId, (startMs + 999) ~/ 1000, (endMs + 999) ~/ 1000], + ); + // A beat sits a few seconds before its record at most; the rr_ts_ms + // bound is only there so the query can use its index. + final rr = await db.rawQuery( + 'SELECT rr_ts_ms, rr_ms, beat_index, beat_ts_ms FROM decoded_rr ' + 'WHERE device_id = ? AND rr_ts_ms >= ? AND rr_ts_ms < ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) >= ? ' + 'AND COALESCE(beat_ts_ms, rr_ts_ms) < ? ' + 'ORDER BY rr_ts_ms ASC, beat_index ASC', + [deviceId, startMs - 60000, endMs + 60000, startMs, endMs], + ); + final onehzThrough = Sqflite.firstIntValue( await db.rawQuery( - 'SELECT MAX(rec_ts) FROM decoded_onehz WHERE device_id = ?', + 'SELECT MAX(rec_ts) * 1000 FROM decoded_onehz WHERE device_id = ?', [deviceId], ), - ); - final rr = Sqflite.firstIntValue( + ) ?? 0; + final rrThrough = Sqflite.firstIntValue( await db.rawQuery( - 'SELECT MAX(COALESCE(beat_ts_ms, rr_ts_ms)) / 1 FROM decoded_rr ' - 'WHERE device_id = ?', + 'SELECT MAX(rr_ts_ms) FROM decoded_rr WHERE device_id = ?', [deviceId], ), - ); + ) ?? 0; return ( - onehzThroughMs: onehz == null ? null : onehz * 1000, - rrThroughMs: rr, + onehz: onehz, + rr: rr, + dataThroughMs: onehzThrough < rrThrough ? onehzThrough : rrThrough, ); } - /// EXPLICIT RE-PROCESSING of ONE stored capture (developer-mode action). - /// Re-reads the CURRENT local WHOOP data for the capture's ORIGINAL - /// window bounds, re-classifies the window, and writes a NEW snapshot - /// revision — the old revisions stay byte-identical. The reference - /// itself (cuff values, measurement time) is NEVER touched. A window - /// whose data basis still does not reach the window end stays - /// 'pending' — no fabricated finality. + /// Re-read the current local data for a stored capture's original window + /// and write it as a new snapshot revision. The cuff reference is never + /// touched. static Future reprocessBpResearchCapture(int referenceId) async { final db = await instance; final refs = await db.rawQuery( 'SELECT r.measured_at_ms, r.band_device_id, ' - 'w.window_start_ms, w.window_end_ms ' + 'w.window_start_ms, w.window_end_ms, ' + '(SELECT MAX(revision) FROM bp_research_snapshot s ' + 'WHERE s.reference_id = r.id) AS max_rev ' 'FROM bp_research_reference r ' 'LEFT JOIN bp_research_window w ON w.reference_id = r.id ' 'WHERE r.id = ?', @@ -1831,171 +1721,51 @@ class LocalDb { ); if (refs.isEmpty) return; final r = refs.first; - final bandDeviceId = (r['band_device_id'] as String?) ?? kPrimaryDeviceId; - // The ORIGINAL window bounds: re-processing must not silently move - // the feature window, only refresh the data inside it. - final start = - (r['window_start_ms'] as num?)?.toInt() ?? - (r['measured_at_ms'] as num).toInt() - kResearchRestPreMs; - final end = - (r['window_end_ms'] as num?)?.toInt() ?? - (r['measured_at_ms'] as num).toInt() + kResearchWindowPostMs; - final onehz = await db.rawQuery( - 'SELECT rec_ts, hr FROM decoded_onehz ' - 'WHERE device_id = ? AND rec_ts >= ? AND rec_ts <= ? ' - 'ORDER BY rec_ts ASC', - [bandDeviceId, start ~/ 1000, (end - 1) ~/ 1000], - ); - final rr = await db.rawQuery( - 'SELECT rr_ts_ms, rr_ms, beat_index, beat_ts_ms FROM decoded_rr ' - 'WHERE device_id = ? ' - 'AND COALESCE(beat_ts_ms, rr_ts_ms) >= ? ' - 'AND COALESCE(beat_ts_ms, rr_ts_ms) < ? ' - 'ORDER BY rr_ts_ms ASC, beat_index ASC', - [bandDeviceId, start, end], - ); - final through = await bpResearchDataThroughMs(bandDeviceId); - // CONSERVATIVE WATERMARK: a series with NO decoded rows at all has - // watermark 0 (nothing provably decoded); the EARLIER of the two - // series decides — the window cannot be final until BOTH could have - // delivered their tail. - final onehzThrough = through.onehzThroughMs ?? 0; - final rrThrough = through.rrThroughMs ?? 0; - final dataThroughMs = onehzThrough < rrThrough ? onehzThrough : rrThrough; + final at = (r['measured_at_ms'] as num).toInt(); + final start = (r['window_start_ms'] as num?)?.toInt() ?? + at - kResearchRestPreMs; + final end = (r['window_end_ms'] as num?)?.toInt() ?? + at + kResearchWindowPostMs; + final rows = await bpResearchRows( + (r['band_device_id'] as String?) ?? kPrimaryDeviceId, + start, + end, + ); + // Nothing local any more but a snapshot exists: the decoded rows were + // pruned, and the frozen window is the only copy. Keep it. + // ponytail: a partially pruned window still overwrites; compare row + // counts against the last snapshot if that ever matters. + if (rows.onehz.isEmpty && rows.rr.isEmpty && r['max_rev'] != null) return; final window = researchWindowFrom( - measuredAtMs: (r['measured_at_ms'] as num).toInt(), - onehzRows: onehz, - rrRows: rr, - preMs: (r['measured_at_ms'] as num).toInt() - start, - postMs: end - (r['measured_at_ms'] as num).toInt(), + measuredAtMs: at, + onehzRows: rows.onehz, + rrRows: rows.rr, + preMs: at - start, + postMs: end - at, nowMs: DateTime.now().millisecondsSinceEpoch, - dataThroughMs: dataThroughMs, + dataThroughMs: rows.dataThroughMs, + ); + await db.transaction( + (txn) => _putBpResearchWindow( + txn, + referenceId, + window, + rows.onehz, + rows.rr, + DateTime.now().millisecondsSinceEpoch, + ), ); - await db.transaction((txn) async { - if (window == null) { - // FINAL and provably empty: the honest no-data case — the window - // row goes, the reference stays. - await txn.rawDelete( - 'DELETE FROM bp_research_window WHERE reference_id = ?', - [referenceId], - ); - return; - } - final onehzEmpty = onehz.isEmpty; - final rrEmpty = rr.isEmpty; - if (onehzEmpty && rrEmpty) { - // NOT final and still nothing locally: keep the window as - // 'pending' — it must survive so a later re-process can attach - // a new snapshot revision. A new revision over EMPTY rows would - // be fabricated evidence, so the window KEEPS whatever revision - // it already points at (or stays snapshotless). - await txn.rawInsert( - 'INSERT OR REPLACE INTO bp_research_window ' - '(reference_id, window_start_ms, window_end_ms, observed_start_ms, ' - 'observed_end_ms, onehz_rows, rr_beats, hr_mean, rr_ms_mean, ' - 'rr_ms_min, rr_ms_max, rmssd_ms, valid_hr_seconds, ' - 'valid_interval_count, valid_interval_pair_count, ' - 'coverage_fraction, rejected_interval_fraction, quality_status, ' - 'feature_version, snapshot_revision, meta_json) ' - 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - referenceId, - window.windowStartMs, - window.windowEndMs, - window.observedStartMs, - window.observedEndMs, - window.onehzRows, - window.rrBeats, - window.hrMean, - window.rrMsMean, - window.rrMsMin, - window.rrMsMax, - window.rmssdMs, - window.validHrSeconds, - window.validIntervalCount, - window.validIntervalPairCount, - window.coverageFraction, - window.rejectedIntervalFraction, - window.qualityStatus, - window.featureVersion, - // Keep the EXISTING revision: no new snapshot was computed, so - // no new revision may be claimed (snapshot-invariant). - (await txn.rawQuery( - 'SELECT snapshot_revision FROM bp_research_window ' - 'WHERE reference_id = ?', - [referenceId], - )).firstOrNull?['snapshot_revision'] - as int?, - window.metaJson, - ], - ); - return; - } - final maxRev = Sqflite.firstIntValue( - await txn.rawQuery( - 'SELECT MAX(revision) FROM bp_research_snapshot ' - 'WHERE reference_id = ?', - [referenceId], - ), - ); - final rev = (maxRev ?? 0) + 1; - await txn.rawInsert( - 'INSERT INTO bp_research_snapshot ' - '(reference_id, revision, onehz_json, rr_json, created_at_ms) ' - 'VALUES (?, ?, ?, ?, ?)', - [ - referenceId, - rev, - jsonEncode(onehz), - jsonEncode(rr), - DateTime.now().millisecondsSinceEpoch, - ], - ); - await txn.rawUpdate( - 'UPDATE bp_research_window SET ' - 'window_start_ms = ?, window_end_ms = ?, observed_start_ms = ?, ' - 'observed_end_ms = ?, onehz_rows = ?, rr_beats = ?, hr_mean = ?, ' - 'rr_ms_mean = ?, rr_ms_min = ?, rr_ms_max = ?, rmssd_ms = ?, ' - 'valid_hr_seconds = ?, valid_interval_count = ?, ' - 'valid_interval_pair_count = ?, coverage_fraction = ?, ' - 'rejected_interval_fraction = ?, quality_status = ?, ' - 'feature_version = ?, snapshot_revision = ? WHERE reference_id = ?', - [ - window.windowStartMs, - window.windowEndMs, - window.observedStartMs, - window.observedEndMs, - window.onehzRows, - window.rrBeats, - window.hrMean, - window.rrMsMean, - window.rrMsMin, - window.rrMsMax, - window.rmssdMs, - window.validHrSeconds, - window.validIntervalCount, - window.validIntervalPairCount, - window.coverageFraction, - window.rejectedIntervalFraction, - window.qualityStatus, - window.featureVersion, - rev, - referenceId, - ], - ); - }); } + /// Insert or restate one cuff reading plus its band window and the + /// snapshot of the rows the window was computed from. Idempotent on + /// `(measured_at_ms, device)`: a retake keeps the reference id and every + /// older snapshot revision. Invalid pairs throw before anything is written. static Future putBpResearchCapture( BpResearchCapture c, { List>? snapshotOnehzRows, List>? snapshotRrRows, }) async { - // STORE-SIDE validation, not just UI: any caller (a future import, a - // second screen) meets the same research bounds. Enforced BEFORE the - // transaction opens, so a rejected capture leaves no partial row, no - // window, no snapshot behind. Out-of-bounds is REJECTED, never - // corrected or clamped — a clamped reading is a fabricated one. if (!c.systolicMmHg.isFinite || !c.diastolicMmHg.isFinite || c.systolicMmHg < kResearchSystolicBounds.$1 || @@ -2011,19 +1781,7 @@ class LocalDb { } final db = await instance; await db.transaction((txn) async { - // NULL never equals NULL in a UNIQUE constraint, so a retake with no - // device text would duplicate the reference instead of replacing it. - // Normalizing to '' keeps (measured_at_ms, device) unique either way, - // and the window of the row being replaced is deleted explicitly — - // without PRAGMA foreign_keys the ON DELETE CASCADE never runs, and - // a fresh id would orphan the old window. - // A retake KEEPS the destination reference id (UPDATE in place, not - // delete + reinsert — a fresh id would strand the window row, and - // deleting the reference would destroy the snapshot history) and - // KEEPS every historical snapshot revision — the immutable-snapshot - // contract. Only the window summary row is restated, because it - // describes the CURRENT revision. NULL never equals NULL in a UNIQUE - // constraint, so the device text is normalized to '' either way. + // NULL never equals NULL in a UNIQUE constraint, so no device is ''. final device = c.device ?? ''; final existing = await txn.rawQuery( 'SELECT id FROM bp_research_reference ' @@ -2077,111 +1835,87 @@ class LocalDb { ], ); } - // A capture with no band data stores NO window row — the LEFT JOIN in - // [bpResearchCaptures] renders it as an empty window, and `NOT NULL` - // on the window bounds is what keeps a half-written window out of the - // store. A retake that now finds band data replaces the absent row. - final w = c.window; - if (w == null) { - await txn.rawDelete( - 'DELETE FROM bp_research_window WHERE reference_id = ?', + await _putBpResearchWindow( + txn, + id, + c.window, + snapshotOnehzRows ?? const [], + snapshotRrRows ?? const [], + c.capturedAtMs, + ); + }); + } + + /// Restate the window row of [id]. No window deletes the row. Rows freeze + /// into the next snapshot revision and the window names it; no rows means + /// no revision is claimed (snapshot_revision NULL). + static Future _putBpResearchWindow( + Transaction txn, + int id, + BpResearchWindow? w, + List> onehz, + List> rr, + int createdAtMs, + ) async { + if (w == null) { + await txn.rawDelete( + 'DELETE FROM bp_research_window WHERE reference_id = ?', + [id], + ); + return; + } + int? rev; + if (onehz.isNotEmpty || rr.isNotEmpty) { + final maxRev = Sqflite.firstIntValue( + await txn.rawQuery( + 'SELECT MAX(revision) FROM bp_research_snapshot ' + 'WHERE reference_id = ?', [id], - ); - return; - } - // SNAPSHOT/WINDOW INVARIANT: a window may only name a snapshot - // revision that ACTUALLY exists for THIS reference. The revision is - // decided HERE, from the snapshot lists of THIS operation — never - // from a caller-set w.snapshotRevision, which could point at a - // foreign or non-existent revision (the window would reference raw - // data that is not what its features were computed from). - // · snapshot lists passed → new revision (max + 1) is created - // below and the window is pointed at it in the SAME transaction; - // · no snapshot lists → snapshot_revision = NULL: the window is - // stored SNAPSHOTLESS (a legacy-style summary), never claiming - // an old or foreign revision it cannot prove. - // Same content rule as the snapshot below: lists that are empty - // carry no evidence, so the window stays snapshotless. - final hasSnapshotRows = - (snapshotOnehzRows != null && snapshotOnehzRows.isNotEmpty) || - (snapshotRrRows != null && snapshotRrRows.isNotEmpty); + ), + ); + rev = (maxRev ?? 0) + 1; + // Plain INSERT: UNIQUE (reference_id, revision) makes rewriting an + // existing revision an error, never a silent overwrite. await txn.rawInsert( - 'INSERT OR REPLACE INTO bp_research_window ' - '(reference_id, window_start_ms, window_end_ms, observed_start_ms, ' - 'observed_end_ms, onehz_rows, rr_beats, hr_mean, rr_ms_mean, ' - 'rr_ms_min, rr_ms_max, rmssd_ms, valid_hr_seconds, ' - 'valid_interval_count, valid_interval_pair_count, ' - 'coverage_fraction, rejected_interval_fraction, quality_status, ' - 'feature_version, snapshot_revision, meta_json) ' - 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [ - id, - w.windowStartMs, - w.windowEndMs, - w.observedStartMs, - w.observedEndMs, - w.onehzRows, - w.rrBeats, - w.hrMean, - w.rrMsMean, - w.rrMsMin, - w.rrMsMax, - w.rmssdMs, - w.validHrSeconds, - w.validIntervalCount, - w.validIntervalPairCount, - w.coverageFraction, - w.rejectedIntervalFraction, - w.qualityStatus, - w.featureVersion, - // NULL without snapshot lists — no revision is claimed that - // does not exist; with lists the UPDATE below sets the real - // new revision before the transaction commits. - hasSnapshotRows ? w.snapshotRevision : null, - w.metaJson, - ], + 'INSERT INTO bp_research_snapshot ' + '(reference_id, revision, onehz_json, rr_json, created_at_ms) ' + 'VALUES (?, ?, ?, ?, ?)', + [id, rev, jsonEncode(onehz), jsonEncode(rr), createdAtMs], ); - // The immutable snapshot: the next free revision (max + 1), so a - // re-processed capture writes a NEW revision and every older revision - // survives. Plain INSERT — the UNIQUE (reference_id, revision) key - // makes an overwrite of an existing revision a database-integrity - // error instead of a silent history rewrite. Rows frozen as JSON - // exactly as the window computation saw them. - // NO SNAPSHOT OVER EMPTY ROWS: a revision frozen over zero onehz - // AND zero rr rows is fabricated evidence — a pending window keeps - // its row without claiming any revision; the FIRST real data - // creates revision 1. - final snapshotHasContent = - (snapshotOnehzRows != null && snapshotOnehzRows.isNotEmpty) || - (snapshotRrRows != null && snapshotRrRows.isNotEmpty); - if (snapshotHasContent) { - final maxRev = Sqflite.firstIntValue( - await txn.rawQuery( - 'SELECT MAX(revision) FROM bp_research_snapshot ' - 'WHERE reference_id = ?', - [id], - ), - ); - final rev = (maxRev ?? 0) + 1; - await txn.rawInsert( - 'INSERT INTO bp_research_snapshot ' - '(reference_id, revision, onehz_json, rr_json, created_at_ms) ' - 'VALUES (?, ?, ?, ?, ?)', - [ - id, - rev, - jsonEncode(snapshotOnehzRows ?? const []), - jsonEncode(snapshotRrRows ?? const []), - c.capturedAtMs, - ], - ); - await txn.rawUpdate( - 'UPDATE bp_research_window SET snapshot_revision = ? ' - 'WHERE reference_id = ?', - [rev, id], - ); - } - }); + } + await txn.rawInsert( + 'INSERT OR REPLACE INTO bp_research_window ' + '(reference_id, window_start_ms, window_end_ms, observed_start_ms, ' + 'observed_end_ms, onehz_rows, rr_beats, hr_mean, rr_ms_mean, ' + 'rr_ms_min, rr_ms_max, rmssd_ms, valid_hr_seconds, ' + 'valid_interval_count, valid_interval_pair_count, ' + 'coverage_fraction, rejected_interval_fraction, quality_status, ' + 'feature_version, snapshot_revision, meta_json) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + id, + w.windowStartMs, + w.windowEndMs, + w.observedStartMs, + w.observedEndMs, + w.onehzRows, + w.rrBeats, + w.hrMean, + w.rrMsMean, + w.rrMsMin, + w.rrMsMax, + w.rmssdMs, + w.validHrSeconds, + w.validIntervalCount, + w.validIntervalPairCount, + w.coverageFraction, + w.rejectedIntervalFraction, + w.qualityStatus, + w.featureVersion, + rev, + w.metaJson, + ], + ); } /// All captures, newest first, for the dev screen and the CSV export. diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart index 1f9ef295c..b5c442dc9 100644 --- a/lib/ui2/profile/bp_research.dart +++ b/lib/ui2/profile/bp_research.dart @@ -183,55 +183,17 @@ class _BpResearchScreenState extends State { setState(() => _busy = true); var stored = false; try { - // The rest window BEFORE the measurement: the feature window ends at - // the measurement start, so the cuff's own inflation stays out of it - // by construction. See lib/health/bp_research_capture.dart. - final start = measuredAtMs - kResearchRestPreMs; - final end = measuredAtMs + kResearchWindowPostMs; - final db = await LocalDb.instance; - // Read exactly what the app already holds around the MEASUREMENT - // instant — historical rows for a back-dated capture. Two narrow - // range reads, never a day dump, never the raw archive. - final onehz = await db.rawQuery( - 'SELECT rec_ts, hr FROM decoded_onehz ' - 'WHERE device_id = ? AND rec_ts >= ? AND rec_ts <= ? ' - 'ORDER BY rec_ts ASC', - [LocalDb.kPrimaryDeviceId, start ~/ 1000, end ~/ 1000], - ); - // The full beat identity rides along: beat_index (always present in - // decoded_rr) and beat_ts_ms (the measured sub-second instant, NULL - // on rows banked before that column existed — _ensureBeatTimeColumn - // guarantees the COLUMN on every open, old data keeps NULL values). - // Window membership uses the beat's real position when known: - // COALESCE(beat_ts_ms, rr_ts_ms) — a beat whose record second lies - // in the window but whose measured instant does not (or vice versa) - // is filtered by where the beat actually was, not by its record. - final rr = await db.rawQuery( - 'SELECT rr_ts_ms, rr_ms, beat_index, beat_ts_ms FROM decoded_rr ' - 'WHERE device_id = ? ' - 'AND COALESCE(beat_ts_ms, rr_ts_ms) >= ? ' - 'AND COALESCE(beat_ts_ms, rr_ts_ms) < ? ' - 'ORDER BY rr_ts_ms ASC, beat_index ASC', - [LocalDb.kPrimaryDeviceId, start, end], - ); - // SYNC FINALITY: a window is only final when the locally decoded - // data provably reaches its end. Both series are checked separately - // (they decode from different packets); the EARLIER watermark decides - // — the window cannot be judged final until BOTH series could have - // delivered their tail. A series with NO decoded rows at all counts - // as watermark 0: not provably through, conservative pending. - final through = await LocalDb.bpResearchDataThroughMs( + final rows = await LocalDb.bpResearchRows( LocalDb.kPrimaryDeviceId, + measuredAtMs - kResearchRestPreMs, + measuredAtMs + kResearchWindowPostMs, ); - final onehzThrough = through.onehzThroughMs ?? 0; - final rrThrough = through.rrThroughMs ?? 0; - final dataThroughMs = onehzThrough < rrThrough ? onehzThrough : rrThrough; final window = researchWindowFrom( measuredAtMs: measuredAtMs, - onehzRows: onehz, - rrRows: rr, + onehzRows: rows.onehz, + rrRows: rows.rr, nowMs: enteredAtMs, - dataThroughMs: dataThroughMs, + dataThroughMs: rows.dataThroughMs, ); await LocalDb.putBpResearchCapture( BpResearchCapture( @@ -257,8 +219,8 @@ class _BpResearchScreenState extends State { : _sessionId.text.trim(), window: window, ), - snapshotOnehzRows: onehz, - snapshotRrRows: rr, + snapshotOnehzRows: rows.onehz, + snapshotRrRows: rows.rr, ); stored = true; _sys.clear(); diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart index f57b7ffe1..bd5fa0bf6 100644 --- a/test/bp_research_db_test.dart +++ b/test/bp_research_db_test.dart @@ -1813,4 +1813,89 @@ void main() { ); expect(w, isNull); }); + + test('reprocess after the decoded rows were pruned keeps the frozen window', + () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_onehz'); + await db.delete('decoded_rr'); + final rows = [ + for (var s = 0; s < 300; s++) {'rec_ts': (_at - 300000) ~/ 1000 + s, 'hr': 60}, + ]; + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'pruned', + window: researchWindowFrom( + measuredAtMs: _at, + onehzRows: rows, + rrRows: const [], + ), + ), + snapshotOnehzRows: rows, + snapshotRrRows: const [], + ); + // Newer data exists, so the window is final, but its own rows are gone. + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': 1, + 'rec_ts': _at ~/ 1000 + 3600, + 'counter': 0, + 'hr': 60, + }); + final refId = (await db.rawQuery('SELECT id FROM bp_research_reference')) + .first['id'] as int; + await LocalDb.reprocessBpResearchCapture(refId); + final win = await db.rawQuery( + 'SELECT hr_mean, snapshot_revision FROM bp_research_window ' + 'WHERE reference_id = ?', + [refId], + ); + expect(win, hasLength(1)); + expect(win.first['hr_mean'], 60.0); + expect(win.first['snapshot_revision'], 1); + }); + + test('reprocess of a capture stored without a window creates the window', + () async { + final db = await LocalDb.instance; + await db.delete('bp_research_snapshot'); + await db.delete('bp_research_window'); + await db.delete('bp_research_reference'); + await db.delete('decoded_onehz'); + await db.delete('decoded_rr'); + await LocalDb.putBpResearchCapture( + BpResearchCapture( + measuredAtMs: _at, + systolicMmHg: 120, + diastolicMmHg: 80, + capturedAtMs: _at, + device: 'late', + ), + ); + for (var s = 0; s < 300; s++) { + await db.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': s, + 'rec_ts': (_at - 300000) ~/ 1000 + s, + 'counter': s, + 'hr': 60, + }); + } + final refId = (await db.rawQuery('SELECT id FROM bp_research_reference')) + .first['id'] as int; + await LocalDb.reprocessBpResearchCapture(refId); + final win = await db.rawQuery( + 'SELECT snapshot_revision FROM bp_research_window WHERE reference_id = ?', + [refId], + ); + expect(win, hasLength(1)); + expect(win.first['snapshot_revision'], 1); + }); } diff --git a/test/bp_research_migration_test.dart b/test/bp_research_migration_test.dart index 35a1247d1..ff02f4250 100644 --- a/test/bp_research_migration_test.dart +++ b/test/bp_research_migration_test.dart @@ -1,68 +1,19 @@ -// END-TO-END BP research migration regressions over REAL SQLite files, -// in the style of db_migration_ladder_test.dart. Each test hand-builds a -// database file at an OLD schema version (54, 55, or a partially-migrated -// shape), then opens it through LocalDb so sqflite runs the whole onUpgrade -// ladder — and asserts the ladder completed without the -// quarantine-and-rebuild fallback (a bricked rung would still end at the -// current user_version, so version alone proves nothing). -// -// Covered paths: -// · fresh install (onCreate) — v1 tables + v2 columns in one pass -// · 54 → 55 → 56 in one app update (the rung-55 tables do not exist yet) -// · 55 → 56 (tables exist, v2 columns/table do not) -// · interrupted/unusual upgrade shapes: -// - v55 file WITHOUT the bp tables (foreign or partial build) -// - v56 file with rung-55 tables but a missing v2 column -// - v56 file already fully migrated (idempotent re-open) -// · backup/restore compatibility: a v1-shaped source DB restores into a -// v2 target with its v1 rows left honestly NULL in the new columns. +// The BP research tables are additive with no ladder rung: an existing +// install gets them from the open-time repair pass, a fresh one from onCreate. import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:openstrap_edge/data/db.dart'; -const _v1BpDdl = [ - ''' - CREATE TABLE bp_research_reference ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - measured_at_ms INTEGER NOT NULL, - device TEXT, - posture TEXT, - conditions TEXT, - systolic_mmhg REAL NOT NULL, - diastolic_mmhg REAL NOT NULL, - captured_at_ms INTEGER NOT NULL, - UNIQUE (measured_at_ms, device) - )''', - ''' - CREATE TABLE bp_research_window ( - reference_id INTEGER NOT NULL PRIMARY KEY, - window_start_ms INTEGER NOT NULL, - window_end_ms INTEGER NOT NULL, - onehz_rows INTEGER, - rr_beats INTEGER, - hr_mean REAL, - rr_ms_mean REAL, - rr_ms_min REAL, - rr_ms_max REAL, - rmssd_ms REAL, - meta_json TEXT - )''', - 'CREATE INDEX idx_bp_research_reference_at ' - 'ON bp_research_reference(measured_at_ms)', -]; - Future _dbPath(String name) async => p.join(await databaseFactory.getDatabasesPath(), name); -/// Build a database FILE at [version] with [ddl] applied, optionally with -/// [seedRows], then close it — exactly like a user's old install on disk. +/// Build a database file at [version] with [ddl] applied, then close it. Future _seedOldDb( String name, int version, - List ddl, { - Future Function(Database db)? seedRows, -}) async { + List ddl, +) async { final path = await _dbPath(name); await databaseFactory.deleteDatabase(path); final db = await databaseFactory.openDatabase( @@ -76,12 +27,10 @@ Future _seedOldDb( }, ), ); - if (seedRows != null) await seedRows(db); await db.close(); } -/// Open [name] through LocalDb (running the REAL ladder + repair pass) and -/// assert it completed without the quarantine fallback. +/// Open [name] through LocalDb and assert it did not quarantine-and-rebuild. Future _openThroughLocalDb(String name) async { await LocalDb.close(); LocalDb.lastRebuild = null; @@ -139,7 +88,7 @@ void main() { }); test( - 'fresh install creates v1 tables + all v2 columns in one pass', + 'fresh install creates the bp research tables', () async { const name = 'bp_migrate_fresh_test.db'; created.add(name); @@ -163,12 +112,10 @@ void main() { ); test( - 'upgrade 54 → 56 in one update creates everything, brick-free', + 'an existing v54 database gets the bp research tables on open', () async { const name = 'bp_migrate_v54_test.db'; created.add(name); - // A v54 database has NO bp tables at all; the ladder must create them at - // rung 55 and add the v2 shape at rung 56 — inside ONE transaction. await _seedOldDb(name, 54, const []); final db = await _openThroughLocalDb(name); expect( @@ -189,148 +136,4 @@ void main() { } }, ); - - test( - 'upgrade 55 → 56 adds v2 columns/table, v1 data stays untouched', - () async { - const name = 'bp_migrate_v55_test.db'; - created.add(name); - await _seedOldDb( - name, - 55, - _v1BpDdl, - seedRows: (db) async { - await db.insert('bp_research_reference', { - 'measured_at_ms': 1700000000000, - 'device': 'omron', - 'systolic_mmhg': 120.0, - 'diastolic_mmhg': 80.0, - 'captured_at_ms': 1700000060000, - }); - await db.insert('bp_research_window', { - 'reference_id': 1, - 'window_start_ms': 1699999700000, - 'window_end_ms': 1700000000000, - 'onehz_rows': 300, - 'hr_mean': 61.0, - 'rmssd_ms': 42.0, - }); - }, - ); - final db = await _openThroughLocalDb(name); - final winCols = await _columns(db, 'bp_research_window'); - for (final c in _expectedWinV2Cols) { - expect(winCols, contains(c)); - } - // The v1 row keeps its data and reads NULL in every v2 column — absent - // stays absent, nothing is fabricated or rewritten. - final ref = await db.query('bp_research_reference'); - expect(ref, hasLength(1)); - expect(ref.first['systolic_mmhg'], 120.0); - expect(ref.first['measurement_started_at_ms'], isNull); - expect(ref.first['band_device_id'], isNull); - final win = await db.query('bp_research_window'); - expect(win, hasLength(1)); - expect(win.first['onehz_rows'], 300); - expect(win.first['quality_status'], isNull); - expect(win.first['snapshot_revision'], isNull); - }, - ); - - test('a v55 file WITHOUT the bp tables upgrades brick-free', () async { - // The hardened rung-56 helper must self-create the rung-55 tables - // instead of throwing "no such table" inside the exclusive transaction. - const name = 'bp_migrate_v55_missing_tables_test.db'; - created.add(name); - await _seedOldDb(name, 55, const []); - final db = await _openThroughLocalDb(name); - expect( - await db.rawQuery('SELECT name FROM sqlite_master WHERE name = ?', [ - 'bp_research_reference', - ]), - isNotEmpty, - ); - final refCols = await _columns(db, 'bp_research_reference'); - for (final c in _expectedRefV2Cols) { - expect(refCols, contains(c)); - } - }); - - test('a v56 file missing one v2 column is repaired on open', () async { - // Same-version merged-build case: the tables exist, one ALTER was - // skipped by an unusual lineage. _repairOpenSchema must add it back. - const name = 'bp_migrate_v56_partial_test.db'; - created.add(name); - await _seedOldDb(name, 56, _v1BpDdl); - // v1 window shape only (missing ALL v2 window columns) so the repair - // pass has real work to do on the window table. - final db = await _openThroughLocalDb(name); - final winCols = await _columns(db, 'bp_research_window'); - for (final c in _expectedWinV2Cols) { - expect(winCols, contains(c)); - } - }); - - test('an already-current v56 database re-opens idempotently', () async { - const name = 'bp_migrate_v56_idempotent_test.db'; - created.add(name); - await _seedOldDb(name, 56, const []); - final db = await _openThroughLocalDb(name); - await db.rawQuery('SELECT 1'); - // Reopen: the repair pass runs the same helpers again on a full schema. - await LocalDb.close(); - final db2 = await _openThroughLocalDb(name); - await db2.rawQuery('SELECT 1'); - }); - - test( - 'a v1-shaped backup restores into a v2 target with honest NULLs', - () async { - const target = 'bp_restore_v1_target_test.db'; - const source = 'bp_restore_v1_source_test.db'; - created.add(target); - created.add(source); - await _seedOldDb( - source, - 55, - _v1BpDdl, - seedRows: (db) async { - await db.insert('bp_research_reference', { - 'measured_at_ms': 1700000000000, - 'device': '', - 'systolic_mmhg': 125.0, - 'diastolic_mmhg': 82.0, - 'captured_at_ms': 1700000060000, - }); - await db.insert('bp_research_window', { - 'reference_id': 1, - 'window_start_ms': 1699999700000, - 'window_end_ms': 1700000000000, - 'onehz_rows': 200, - 'hr_mean': 58.0, - }); - }, - ); - await _seedOldDb(target, 56, const []); - final db = await _openThroughLocalDb(target); - final counts = await LocalDb.importFromDbFile(await _dbPath(source)); - expect(counts['bp_research_reference'], 1); - expect(counts['bp_research_window'], 1); - final ref = await db.query('bp_research_reference'); - expect(ref, hasLength(1)); - expect(ref.first['systolic_mmhg'], 125.0); - // v1 provenance stays NULL — the import never invents it. - expect(ref.first['measurement_session_id'], isNull); - final win = await db.query('bp_research_window'); - expect(win, hasLength(1)); - expect(win.first['onehz_rows'], 200); - expect( - win.first['snapshot_revision'], - isNull, - reason: - 'a v1 window is snapshotless; the import must not invent a ' - 'revision it has no snapshot for', - ); - }, - ); } From 6c0bf6eece98964322255022a7d206aa509c483e Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:25:16 +0530 Subject: [PATCH 19/22] bp research: an invalid beat breaks the rmssd chain, trim comments, drop unused BpResearchSnapshotRows --- lib/health/bp_research_capture.dart | 343 ++++++---------------------- test/bp_research_capture_test.dart | 40 ++-- 2 files changed, 90 insertions(+), 293 deletions(-) diff --git a/lib/health/bp_research_capture.dart b/lib/health/bp_research_capture.dart index 82bcb68f6..bdb12c5c6 100644 --- a/lib/health/bp_research_capture.dart +++ b/lib/health/bp_research_capture.dart @@ -1,74 +1,27 @@ -// BP research capture — pair a cuff reading with the band's own decoded data -// from the minutes around the MEASUREMENT instant (not the entry instant). -// -// EXPERIMENTAL / DEVELOPER-ONLY. This exists to build a paired dataset a -// human can analyse OUTSIDE this app (CSV export); it is not a blood -// pressure feature and never becomes one. The same guard that fences -// `imported_measurement` applies twice over here: nothing in `compute/`, -// nothing that writes `day_result` / `metric_series`, and nothing that -// writes to HealthKit / Health Connect may read these tables — a wrist -// series regressed against cuff readings inside this app is a -// cuffless-blood-pressure claim from an uncleared device, which is exactly -// the category that earned WHOOP an FDA Warning Letter in Jul 2025. -// -// A capture with no band data at that instant is stored as a capture with a -// NULL window. Missing is missing — never 0, never a fabricated average. -// -// v2 (this file's current shape) separates three instants the v1 capture -// conflated: the MEASUREMENT instant (when the cuff actually squeezed), the -// ENTRY instant (when the user typed the pair in — a retro capture can be -// entered hours later), and the WINDOW instants. A retro capture is paired -// with the historical sensor data of its measurement instant, never with -// whatever the band happens to hold at entry time. +// BP research capture: a cuff reading typed in next to the band's own +// decoded data from the minutes before it, kept for CSV export. Dev mode +// only. Nothing in compute/, nothing that feeds day_result / metric_series +// and no health-store writer may read these tables +// (bp_research_isolation_test.dart). Missing data stays NULL. import 'dart:math' show sqrt; -// -// TIME SEMANTICS (honest by construction): -// · The UI records only a MINUTE-precision instant — either the time the -// reading was taken (back-dated) or the entry moment (field empty). The -// entry moment is a usable pairing anchor ONLY when the user records -// the reading right away; it is stored as the measured instant with -// time_precision = 'minute' and is never claimed to be the exact -// inflation start. No invented start/finish instants are fabricated. -// · The window is the rest window BEFORE the measurement instant and the -// product exposes no post-measurement window at all (Option 1 of the -// pending-window review): a window that would reach into the future -// cannot be produced by the UI, so 'pending' remains an internal, -// data-level state only. - -/// Feature-schema version of the window computation. Bumped whenever a -/// window field's MEANING changes (not its mere presence): exports carry it -/// so an analysis can tell which formula produced which column. -/// v3: coverage counts VALID HR seconds only (v2 counted raw deduplicated -/// rows); RR beats are keyed by their true beat identity (beat_ts_ms when -/// present, else (rr_ts_ms, beat_index)) instead of being deduplicated by -/// the whole-second rr_ts_ms; the window is half-open [start, end). +/// Version of the window formulas, exported with every row. Bump when a +/// field's meaning changes. const int kResearchFeatureVersion = 3; -/// Rest window BEFORE the cuff measurement instant (engineering default, -/// 5 minutes): the feature window is -/// [measurement_instant − pre, measurement_instant). The cuff's own -/// inflation must not enter the feature window unchecked — ending the -/// window at the measurement instant keeps it out by construction. -/// A documented research parameter, not a validated physiological constant. +/// Rest window before the measurement: [at - 5 min, at). Ending at the +/// measurement keeps the cuff's own inflation out. const int kResearchRestPreMs = 5 * 60 * 1000; -/// The product exposes ONLY the pre-measurement rest window; there is no -/// post-measurement window and no UI path that could produce one. Kept as a -/// named constant so the design decision stays visible at the call sites. +/// No post-measurement window. const int kResearchWindowPostMs = 0; -/// Maximum gap between two successive beat intervals for them to count as -/// a CONTIGUOUS pair (engineering default, 2.5 s). RMSSD is only ever -/// computed over pairs that are genuinely adjacent in time — a difference -/// across a sensor gap is a fabrication, not a heart-rate-variability -/// sample. A documented research parameter, not a validated artifact rule. +/// Two successive beats further apart than this are not paired for RMSSD. const int kResearchMaxBeatGapMs = 2500; -/// The frozen band window around one measurement instant. Every field is -/// nullable for the same reason the storage layer's columns are: a stat the -/// window could not honestly compute is absent, not zero. +/// The band window before one measurement. A stat that could not be +/// computed is null, never zero. class BpResearchWindow { const BpResearchWindow({ required this.windowStartMs, @@ -93,28 +46,18 @@ class BpResearchWindow { this.metaJson, }); - /// The REQUESTED window bounds — half-open [start, end) around the - /// measurement instant. Half-open so a 5-minute window at 1 Hz holds at - /// most exactly 300 seconds and coverage can never exceed 1.0 by - /// counting both endpoints of a closed interval. + /// Requested bounds, half-open [start, end). final int windowStartMs; final int windowEndMs; - /// What the data actually OBSERVED inside the requested window — the - /// first and last VALID row time (a row with no usable value does not - /// extend the observed signal; raw coverage is reported separately via - /// [onehzRows]/[rrBeats], which count all in-window rows). Distinct from - /// the requested bounds so an analysis can tell "the band was worn for - /// the last minute of a five-minute window" from "the band was worn all - /// five minutes". + /// First and last valid sample inside the window. final int? observedStartMs; final int? observedEndMs; - /// All in-window 1 Hz rows (raw, deduplicated by rec_ts) — raw coverage. + /// In-window 1 Hz rows, deduplicated by rec_ts. final int? onehzRows; - /// All in-window beat rows (raw, deduplicated by beat identity) — raw - /// beat coverage. + /// In-window beats, deduplicated by beat identity. final int? rrBeats; final double? hrMean; @@ -123,46 +66,33 @@ class BpResearchWindow { final double? rrMsMax; final double? rmssdMs; - // ── quality (v2) ────────────────────────────────────────────────────── - /// 1 Hz rows with a valid HR — at 1 Hz that is seconds of valid signal. + /// 1 Hz rows with a valid HR. final int? validHrSeconds; - /// Beat intervals that survived validation (finite, positive, sorted, - /// deduplicated by beat identity). Intervals the analysis may use. + /// Beats with a finite positive interval. final int? validIntervalCount; - /// SUCCESSIVE interval pairs that are also CONTIGUOUS in time. The only - /// pairs RMSSD is computed over. + /// Successive valid beats within [kResearchMaxBeatGapMs]; RMSSD uses these. final int? validIntervalPairCount; - /// valid_hr_seconds ÷ requested window seconds (half-open window). - /// NULL when the window has no duration or no valid HR row at all — - /// coverage of nothing is not 0%. + /// validHrSeconds / window seconds; null without any valid HR. final double? coverageFraction; - /// Share of successive VALID interval pairs REJECTED as non-contiguous - /// (gap in the beat series). Named for what it measures: a PAIR-rejection - /// rate, not an interval-exclusion rate (the latter is visible via - /// [rrBeats] vs [validIntervalCount]). NULL when there are no successive - /// pairs to reject. + /// Share of successive valid pairs dropped for a gap; null without pairs. final double? rejectedIntervalFraction; - /// 'pending' | 'ok' | 'gappy' | 'no_data' — see [researchWindowFrom]. + /// 'pending' | 'ok' | 'gappy' | 'no_data', see [researchWindowFrom]. final String? qualityStatus; - /// Which feature schema computed these stats (see [kResearchFeatureVersion]). final int featureVersion; - /// Which immutable snapshot revision the raw rows are frozen in (1, 2, …). - /// Re-processing a capture writes a NEW revision and keeps the old one. + /// Snapshot revision holding the rows these stats came from. final int? snapshotRevision; - /// Provenance the analysis needs and nothing else: device_id, firmware - /// string if known, sample counts by table. JSON, written verbatim. final String? metaJson; } -/// One cuff reference reading plus its window, ready to store. +/// One cuff reading plus its window, ready to store. class BpResearchCapture { const BpResearchCapture({ required this.measuredAtMs, @@ -180,55 +110,31 @@ class BpResearchCapture { this.window, }); - /// Nominal measurement instant — the v1 identity of the capture and still - /// the idempotency key together with [device]. This is the instant the - /// user supplied (minute precision) — the time the cuff reading was TAKEN - /// for a back-dated capture, or the ENTRY moment when the field was left - /// empty (the reading was taken "just now"; the pairing anchor is the - /// entry moment, never claimed to be the exact inflation start). + /// When the reading was taken (the entry moment when not back-dated). + /// Idempotency key together with [device]. final int measuredAtMs; - /// When the cuff actually STARTED squeezing. Kept for data that has a - /// real start instant; the current UI records a single minute-precision - /// instant and leaves this NULL — absent stays absent. + /// Cuff inflation start/finish when known; the UI leaves both null. final int? measurementStartedAtMs; - /// When the cuff finished. Optional: many cuffs report one instant only. - /// If only a single measurement instant is known, this stays NULL — no - /// invented duration. final int? measurementFinishedAtMs; - /// The precision of the USER-REPORTED measurement time ('minute' for - /// the current UI) — NOT a property of the stored millisecond timestamp - /// itself. measuredAtMs always carries full millisecond precision; when - /// the user typed "14:30" or left the field empty, only the MINUTE part - /// of that timestamp is meaningful, and this field says so. An analysis - /// must not treat the seconds/millis of a user-typed instant as known. - /// (Back-dated instants are truncated to the minute; an empty field - /// stores the entry moment as-is — its precision documents the reported - /// time, not the anchor's technical resolution.) + /// Precision of the user-reported time ('minute' from the UI). final String? timePrecision; - /// When the pair was TYPED IN. A retro capture entered hours later has - /// this far after its measurement instant. + /// When the pair was typed in. final int capturedAtMs; final double systolicMmHg; final double diastolicMmHg; - /// The cuff's own name ('OMRON', 'Withings BPM', …). NULL when the user - /// typed a bare pair of numbers with no device named. The CUFF device — - /// never conflated with [bandDeviceId]. + /// The cuff, as the user named it. final String? device; - /// The band whose decoded data the window froze (the app's primary device - /// id at capture time). Kept beside the capture so a future second band - /// or a device swap can never silently mix signal origins. + /// The band the window was read from. final String? bandDeviceId; - /// Free-form session label for grouping multiple cuff readings of one - /// sitting — they are NOT independent physiological states, and an - /// analysis must be able to tell them apart from readings hours apart. + /// Free-form label grouping readings of one sitting. final String? measurementSessionId; final String? posture; @@ -238,18 +144,12 @@ class BpResearchCapture { final BpResearchWindow? window; } -/// Same plausibility bounds as `health_measurement_import.dart`, for the -/// same reason: 400 mmHg is a cuff error, and a clamped reading is a -/// fabricated one. Out-of-bounds input is rejected, never corrected. +/// Same bounds as `health_measurement_import.dart`; out of range is +/// rejected, never clamped. const (double, double) kResearchSystolicBounds = (50, 300); const (double, double) kResearchDiastolicBounds = (20, 200); -/// The measured beat instant when the decoder provides it (`beat_ts_ms`), -/// otherwise the whole-second record time. rr_ts_ms alone is rec_ts*1000 -/// for EVERY beat of a record, so the beat POSITION falls back to the -/// record second on legacy rows — a documented heuristic: beats of one -/// second then share a position, and continuity pairs across them can -/// only under-reject, never fabricate differences. +/// `beat_ts_ms` when present, else the record second (`rr_ts_ms`). int _beatTimeMs(Map r) { final beatTs = r['beat_ts_ms']; if (beatTs is num && beatTs > 0) return beatTs.toInt(); @@ -257,12 +157,8 @@ int _beatTimeMs(Map r) { return ts is num ? ts.toInt() : 0; } -/// BEAT IDENTITY — collision-free by construction, never bit-packed: -/// the measured beat instant when present ('b', beat_ts_ms, 0), -/// otherwise the whole-second record time AND the beat's index within -/// the record ('r', rr_ts_ms, beat_index). Neither component is -/// truncated or masked, so any beat_index range — and negative or junk -/// values — can only produce distinct keys, never a silent collision. +/// Beat identity: `beat_ts_ms` when present, else (rr_ts_ms, beat_index). +/// rr_ts_ms alone is shared by every beat of a record. (String, int, int) _beatKey(Map r) { final beatTs = r['beat_ts_ms']; if (beatTs is num && beatTs > 0) return ('b', beatTs.toInt(), 0); @@ -271,10 +167,7 @@ int _beatTimeMs(Map r) { return ('r', ts is num ? ts.toInt() : 0, idx is num ? idx.toInt() : 0); } -/// Deterministic beat order: measured beat time first (when present), -/// then the whole-second record time, then the beat index. Ties beyond -/// that are true duplicates and fall to the dedup pass — List.sort is -/// NOT stable in Dart, so no rule may silently depend on compare == 0. +/// Beat time, then record time, then beat index. int _beatOrder(Map a, Map b) { final at = _beatTimeMs(a); final bt = _beatTimeMs(b); @@ -288,46 +181,13 @@ int _beatOrder(Map a, Map b) { return 0; } -/// Compute the frozen band window around the MEASUREMENT instant -/// ([measuredAtMs]) from already-decoded rows, pure and testable without a -/// database (pass the rows in). +/// The window [measuredAtMs - preMs, measuredAtMs + postMs) from decoded +/// rows. Null when no row falls inside and the data is final. /// -/// Window: [measurement_instant − preMs, measurement_instant + postMs) — -/// half-open. The default design is the 5-minute rest window BEFORE the -/// measurement ([kResearchRestPreMs], [kResearchWindowPostMs] = 0), so the -/// cuff's own inflation stays out of the feature window by construction. -/// -/// Reads ONLY what the caller passes — `decoded_onehz` (HR) and -/// `decoded_rr` (beat intervals) rows. No raw archive, no re-decode, -/// nothing derived: the point is to freeze exactly what the app already -/// holds for the measurement instant, whenever in the past that was. -/// -/// Quality rules (all documented engineering parameters, none claimed as -/// validated artifact thresholds): -/// · onehz rows are filtered to the half-open window, sorted, and -/// deduplicated by `rec_ts`; -/// · an HR row is valid when its `hr` is a finite positive number — -/// absent validity is absent, not false, and an invalid row never -/// enters the mean NOR the coverage (a run of hr = 0 off-skin rows -/// must not read as a worn band); -/// · beat rows are keyed by beat identity ([_beatKey]: beat_ts_ms when -/// present, else (rr_ts_ms, beat_index)) — NEVER by rr_ts_ms alone, -/// which is identical for every beat of a record; -/// · non-finite, zero, or negative interval values are rejected; -/// · an interval PAIR is valid only when the two intervals are -/// successive valid beats whose beat times are contiguous -/// (gap ≤ [kResearchMaxBeatGapMs]) — RMSSD is computed over those -/// pairs and ONLY those pairs, never across a sensor gap; -/// · [nowMs] and [dataThroughMs] decide pending: a window whose end -/// lies in the future, or whose locally decoded data provably does -/// not reach the window end yet (dataThroughMs — the sync watermark -/// of exactly this band), is 'pending' and must be re-processed once -/// the data has arrived. 'pending' is NOT a quality verdict — it -/// only says the window cannot be finally judged yet. The UI never -/// produces a future window (Option 1: pre-measurement window only, -/// future measurement instants are refused), but a 'just now' -/// capture CAN still be pending when the band has not synced the -/// last minutes yet. +/// Status: 'pending' while the window end is in the future ([nowMs]) or the +/// band's decoded data does not reach it yet ([dataThroughMs]); otherwise +/// 'no_data', 'gappy' (under half covered, or over half the pairs dropped +/// for gaps) or 'ok'. The thresholds are research defaults, not validated. BpResearchWindow? researchWindowFrom({ required int measuredAtMs, required List> onehzRows, @@ -346,10 +206,7 @@ BpResearchWindow? researchWindowFrom({ final start = measuredAtMs - pre; final end = measuredAtMs + post; - // decoded_onehz.rec_ts is epoch SECONDS; rr is rr_ts_ms (epoch ms). - // Filter to the HALF-OPEN window [start, end), then SORT, then - // DEDUPLICATE by rec_ts (first row wins — a re-decoded duplicate is the - // same second, not a new one). + // decoded_onehz.rec_ts is epoch seconds. final onehz = onehzRows.where((r) { final ts = r['rec_ts']; @@ -370,17 +227,10 @@ BpResearchWindow? researchWindowFrom({ } } - // Window membership follows the beat's real position: the measured - // sub-second instant when the row carries one, otherwise the record - // second — the SAME rule the production query filters by, so no beat - // can be admitted by the query and then re-rejected here (or vice - // versa) because its record second and its measured instant disagree. final rrAll = rrRows.where((r) { final t = _beatTimeMs(r); return t >= start && t < end; }).toList()..sort(_beatOrder); - // Dedup by BEAT IDENTITY, not by rr_ts_ms — beats of one record differ - // in beat_index and, when the decoder provides it, beat_ts_ms. final rrDedup = >[]; { (String, int, int)? lastKey; @@ -392,27 +242,18 @@ BpResearchWindow? researchWindowFrom({ } } - // EMPTY ≠ FINAL-EMPTY. With the sync-finality semantics a window can - // only be judged when its data basis is provably complete: - // · no rows at all AND the window is not provably final (future end - // or a watermark short of it) → a PENDING window, not null: the - // band may simply not have synced the last minutes yet, and the - // capture must keep its window row so a later re-process can - // attach a new snapshot revision to it. All stats stay NULL — - // missing is not zero. - // · no rows at all AND final (watermark provably reaches the end) - // → null is CORRECT: a final window that provably holds nothing - // is the honest no-data case. + // Data not final yet: the window end is in the future, or this band's + // decoded data stops short of it. rec_ts is whole seconds, so the last + // second inside [start, end) is end - 1000. final notFinal = (nowMs != null && end > nowMs) || (dataThroughMs != null && dataThroughMs < end - 1000); if (onehzDedup.isEmpty && rrDedup.isEmpty) { + // Keep a pending window row so a refresh can fill it later. if (notFinal) { return BpResearchWindow( windowStartMs: start, windowEndMs: end, - onehzRows: null, - rrBeats: null, qualityStatus: 'pending', featureVersion: kResearchFeatureVersion, metaJson: metaJson, @@ -421,8 +262,6 @@ BpResearchWindow? researchWindowFrom({ return null; } - // Valid HR rows only — a run of hr = 0 rows must not drag the average - // toward zero AND must not count as observed signal (coverage). final validHrRows = onehzDedup .where((r) { final h = r['hr']; @@ -435,43 +274,42 @@ BpResearchWindow? researchWindowFrom({ .map((r) => (r['hr'] as num).toDouble()) .reduce((a, b) => a + b) / validHrRows.length; - // 1 Hz: one valid row is one second of valid signal. This is the number - // coverage is computed from — valid seconds, not raw rows. final validHrSeconds = validHrRows.isEmpty ? null : validHrRows.length; - // Valid intervals: finite, positive, beat-keyed. Raw vs valid counts are - // kept apart so an analysis can see how much of the beat series survived. + // RMSSD pairs only beats that are adjacent in the series AND within + // [gap] of each other; an invalid beat breaks the chain. final validIntervals = <(int, double)>[]; // (beat_time_ms, rr_ms) + var pairTotal = 0; + var validPairs = 0; + var sumSq = 0.0; + (int, double)? prev; for (final r in rrDedup) { final v = r['rr_ms']; - if (v is num && v.isFinite && v > 0) { - validIntervals.add((_beatTimeMs(r), v.toDouble())); + if (v is! num || !v.isFinite || v <= 0) { + prev = null; + continue; + } + final cur = (_beatTimeMs(r), v.toDouble()); + validIntervals.add(cur); + if (prev != null) { + pairTotal++; + if (cur.$1 - prev.$1 <= gap) { + final d = cur.$2 - prev.$2; + sumSq += d * d; + validPairs++; + } } + prev = cur; } - double? rrMean, rrMin, rrMax, rmssd; - var validPairs = 0; + double? rrMean, rrMin, rrMax; if (validIntervals.isNotEmpty) { final values = validIntervals.map((p) => p.$2).toList(growable: false); rrMean = values.reduce((a, b) => a + b) / values.length; rrMin = values.reduce((a, b) => a < b ? a : b); rrMax = values.reduce((a, b) => a > b ? a : b); - // RMSSD over CONTIGUOUS successive pairs only: the two beats must be - // adjacent in time (gap ≤ [gap]). A difference across a sensor gap is a - // fabrication, not an HRV sample. - if (validIntervals.length >= 2) { - var sumSq = 0.0; - for (var i = 1; i < validIntervals.length; i++) { - if (validIntervals[i].$1 - validIntervals[i - 1].$1 > gap) continue; - final d = validIntervals[i].$2 - validIntervals[i - 1].$2; - sumSq += d * d; - validPairs++; - } - if (validPairs > 0) rmssd = sqrt(sumSq / validPairs); - } } - - final pairTotal = validIntervals.length >= 2 ? validIntervals.length - 1 : 0; + final rmssd = validPairs > 0 ? sqrt(sumSq / validPairs) : null; final rejectedPairFraction = pairTotal == 0 ? null : 1.0 - (validPairs / pairTotal); @@ -481,31 +319,8 @@ BpResearchWindow? researchWindowFrom({ ? null : validHrSeconds / windowSeconds; - // Quality status — an honest verdict, not a fabricated confidence number. - // pending — the window is NOT final yet: its end lies in the future - // (nowMs), or the locally decoded data provably does not - // reach up to the window end yet (dataThroughMs, the - // sync watermark of exactly this band). 'Not final' means - // the missing tail may still arrive — it must never be - // mislabeled 'no_data' or 'gappy', which would claim the - // window was finalizable and just holds bad data. - // ONE-SECOND TOLERANCE: decoded_onehz rows are whole - // seconds, so the last second that can still lie INSIDE - // the half-open [start, end) window ends at end - 1000. - // A watermark there proves every row the window could - // contain has arrived; demanding watermark = end would - // require a row OUTSIDE the window and keep honest - // pre-measurement windows pending forever. - // no_data — finalizable, but nothing valid survived in either series. - // gappy — finalizable, but over half the successive pairs were - // rejected across gaps, or under half the window has valid - // HR: usable, flag it. - // ok — finalizable and passes the current research rule. - // PRECEDENCE: pending wins over everything — a window whose data basis - // is not provably complete cannot carry a final verdict. - String status; - if ((nowMs != null && end > nowMs) || - (dataThroughMs != null && dataThroughMs < end - 1000)) { + final String status; + if (notFinal) { status = 'pending'; } else if (validHrRows.isEmpty && validIntervals.isEmpty) { status = 'no_data'; @@ -556,13 +371,3 @@ BpResearchWindow? researchWindowFrom({ metaJson: metaJson, ); } - -/// The rows a snapshot freezes, so re-processing is reproducible: the exact -/// onehz and rr rows that produced a window revision, as plain JSON. -class BpResearchSnapshotRows { - const BpResearchSnapshotRows({required this.onehzRows, required this.rrRows}); - - /// The filtered, sorted, deduplicated rows the window computation saw. - final List> onehzRows; - final List> rrRows; -} diff --git a/test/bp_research_capture_test.dart b/test/bp_research_capture_test.dart index 7daf43d9e..1be6b4b5c 100644 --- a/test/bp_research_capture_test.dart +++ b/test/bp_research_capture_test.dart @@ -1,27 +1,4 @@ -// Unit tests for the pure window computation behind the BP research capture. -// -// The rules under test are the ones the storage and UI lean on: -// · a window with no band data is NULL, not zeroes; -// · a stat the window cannot honestly compute (no valid HR, no -// CONTIGUOUS interval pair for RMSSD) is absent, never zero; -// · rows outside the window are ignored, whatever their table's epoch -// base is (decoded_onehz.rec_ts is SECONDS, decoded_rr.rr_ts_ms is ms); -// · the rest window lies BEFORE the measurement instant and is -// HALF-OPEN [start, end): the cuff's own inflation stays out of the -// feature window by construction, and coverage can never exceed 1.0 -// by counting both endpoints; -// · duplicate timestamps are deduplicated, unsorted rows are sorted; -// · beats are keyed by BEAT IDENTITY (beat_ts_ms, else -// (rr_ts_ms, beat_index)) — never by the whole-second rr_ts_ms, which -// is identical for every beat of one record; -// · coverage counts VALID HR seconds only — a run of hr = 0 rows must -// not read as a worn band; -// · RMSSD never spans a sensor gap, and the rejected-PAIR fraction is -// reported under its honest name; -// · a window whose end lies in the future is 'pending' (an internal -// data-level state — the UI cannot produce one); -// · requested window bounds and OBSERVED data bounds are distinct, and -// observed bounds are built from VALID rows only. +// Pure window computation behind the BP research capture. import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; @@ -363,4 +340,19 @@ void main() { ); expect(w!.qualityStatus, 'pending'); }); + + test('an invalid beat breaks the RMSSD chain instead of being skipped', () { + final w = researchWindowFrom( + measuredAtMs: at, + onehzRows: const [], + rrRows: [ + {'rr_ts_ms': at - 3000, 'beat_index': 0, 'rr_ms': 800}, + {'rr_ts_ms': at - 2000, 'beat_index': 0, 'rr_ms': 0}, + {'rr_ts_ms': at - 1000, 'beat_index': 0, 'rr_ms': 1000}, + ], + )!; + expect(w.validIntervalCount, 2); + expect(w.validIntervalPairCount, isNull); + expect(w.rmssdMs, isNull); + }); } From ca3a86f10acfe216921dc3454f6fda2dd6d42d99 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:27:55 +0530 Subject: [PATCH 20/22] bp research: trim the restore merge comments --- lib/data/db.dart | 97 ++++-------------------------------------------- 1 file changed, 8 insertions(+), 89 deletions(-) diff --git a/lib/data/db.dart b/lib/data/db.dart index 5a93851ff..160d2106b 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -1733,8 +1733,7 @@ class LocalDb { ); // Nothing local any more but a snapshot exists: the decoded rows were // pruned, and the frozen window is the only copy. Keep it. - // ponytail: a partially pruned window still overwrites; compare row - // counts against the last snapshot if that ever matters. + // A partially pruned window still writes a smaller revision. if (rows.onehz.isEmpty && rows.rr.isEmpty && r['max_rev'] != null) return; final window = researchWindowFrom( measuredAtMs: at, @@ -1943,11 +1942,10 @@ class LocalDb { '''); } - /// Delete one capture (dev screen). The window cascades. + /// Delete one capture with its window and snapshots. foreign_keys is off, + /// so ON DELETE CASCADE does nothing here. static Future deleteBpResearchCapture(int id) async { final db = await instance; - // No PRAGMA foreign_keys here, so the window's ON DELETE CASCADE is - // inert — the delete has to take the window row explicitly. await db.transaction((txn) async { await txn.delete( 'bp_research_window', @@ -9076,8 +9074,6 @@ class LocalDb { } Future srcHasTable(String t, Database s) async { - // A salvage source may predate the window table; `SELECT *` on a - // missing table throws, so probe for its existence first. final rows = await s.rawQuery( "SELECT name FROM sqlite_master WHERE type='table' AND name = ?", [t], @@ -9086,30 +9082,14 @@ class LocalDb { } final counts = {}; - // SOURCE→DEST id map for the BP research reference merge below: the - // window rows of a foreign export name their reference by the SOURCE - // database's AUTOINCREMENT id, which is meaningless here. + // BP research rows name their reference by the source's AUTOINCREMENT id. final bpIdMap = {}; - // Snapshot revisions skipped on import because the destination holds a - // DIFFERENT snapshot under the same (reference, revision) key — - // immutable history is never overwritten, the source file keeps them. var skippedSnapshots = 0; - // Window rows skipped on import because the snapshot revision their - // features were computed from conflicts with the destination's local - // revision — a window may never point at a foreign snapshot. var skippedWindows = 0; - // Of those, the split by REASON — surfaced as extra import-count keys - // so a restore can be audited: a skipped window is either a snapshot - // CONTENT conflict or a snapshot the source backup simply does not - // carry. var skippedWindowsSnapshotConflict = 0; var skippedWindowsMissingSnapshot = 0; - // Rows skipped because their SOURCE reference did not map (dangling - // source rows) — reported separately from content conflicts. var skippedSnapshotsDanglingReference = 0; var skippedWindowsDanglingReference = 0; - // Source snapshots already present HERE byte-identically — idempotent - // re-imports, not new imports; the count must not claim them. var skippedSnapshotsIdentical = 0; // DISTINCT DAYS ACTUALLY WRITTEN — the number the caller reports as // "N days imported". @@ -9169,31 +9149,13 @@ class LocalDb { if (e.isNoSuchTableError()) continue; rethrow; } - // BP RESEARCH CAPTURES MERGE BY NATURAL KEY, NOT BY SOURCE ID. The - // reference's `id` is a device-local AUTOINCREMENT and the window - // and snapshot rows' `reference_id` name it, so the generic - // REPLACE-by-PK path would let a foreign export's id=1 eat this - // install's id=1 capture. All three tables are hand-typed and tiny - // (nothing writes them but the dev screen), so a dedicated merge - // beats threading a special case through the paged loop: the - // reference is keyed on its natural UNIQUE (measured_at_ms, device) - // identity, the window and snapshots follow onto the DESTINATION - // id, and a capture whose incoming window is absent keeps the - // window it already had. Re-import converges. + // BP research captures merge by their natural key + // (measured_at_ms, device) in one transaction, never by source id. + // A snapshot revision is never overwritten; a window whose + // snapshot is missing or differs here is skipped and counted. if (t == 'bp_research_reference' || t == 'bp_research_window' || t == 'bp_research_snapshot') { - // ONE TRANSACTIONAL UNIT. Reference, snapshots and window are - // restored together, driven by the reference entry: the window - // names the snapshot revision its features were computed from, - // so it may only be written when that revision exists HERE with - // the same content — decided inside the SAME transaction, not in - // three separate table passes. (A previous split-pass version - // loaded `srcSnaps` only in the snapshot pass, leaving the - // snapshot status map EMPTY in the window pass, so every - // conflict-free window with a snapshot revision was silently - // skipped.) The window and snapshot list entries that follow are - // already handled here and only skip. if (t != 'bp_research_reference') { continue; } @@ -9217,9 +9179,6 @@ class LocalDb { if (refCols.contains(e.key)) e.key: e.value, }; final srcId = row.remove('id'); - // KEEP the destination id on collision (see the v1 fix): - // UPDATE in place preserves the id the window and - // snapshot rows are about to be re-attached to. final device = (row['device'] as String?) ?? ''; final existing = await txn.rawQuery( 'SELECT id FROM bp_research_reference ' @@ -9280,15 +9239,6 @@ class LocalDb { bpIdMap[srcId.toInt()] = destId; } } - // SNAPSHOT PASS FIRST. A window row names the snapshot - // revision its features were computed from, so the window - // may only be imported when that revision exists HERE with - // the SAME content. Doing snapshots first builds the - // (dest_reference_id, dest_revision) -> status map the - // window pass then checks against — the invariant being: - // window features must belong to exactly the snapshot they - // were computed from, never to a local revision that - // happens to share the number but holds different rows. final snapStatus = <(int, int), String>{}; for (final sn in srcSnaps) { final row = { @@ -9299,9 +9249,6 @@ class LocalDb { bpIdMap[(row.remove('reference_id') as num?)?.toInt()]; final rev = (row['revision'] as num?)?.toInt(); if (mapped == null || rev == null) { - // A snapshot whose source reference did not map is a - // dangling source row — skipped and counted, never - // silently claimed as imported. skippedSnapshots++; skippedSnapshotsDanglingReference++; continue; @@ -9312,19 +9259,11 @@ class LocalDb { [mapped, rev], ); if (clash.isNotEmpty) { - // IMMUTABLE HISTORY: an existing revision is never - // overwritten. Identical content = idempotent re-import - // (status 'identical'); different content = a conflict - // the window pass must respect (status 'conflict') — - // the source revision stays available in the source - // backup, nothing is lost, nothing is rewritten. final same = clash.first['onehz_json'] == row['onehz_json'] && clash.first['rr_json'] == row['rr_json']; snapStatus[(mapped, rev)] = same ? 'identical' : 'conflict'; if (same) { - // An identical re-import is NOT a new import — the - // count must not claim it. skippedSnapshots++; skippedSnapshotsIdentical++; } else { @@ -9354,27 +9293,14 @@ class LocalDb { final mapped = bpIdMap[(row.remove('reference_id') as num?)?.toInt()]; if (mapped == null) { - // Dangling source window: no reference to attach to. - // Skipped and counted, never claimed as imported. skippedWindows++; skippedWindowsDanglingReference++; continue; } - // snapshot revision may only be imported when that - // revision is HERE with the same content ('inserted' or - // 'identical'). A 'conflict' means the local revision n - // holds DIFFERENT rows than the source window's features - // were computed from — importing it would point features - // at a foreign snapshot. The window is skipped and - // counted; the destination keeps its own consistent pair. final rev = (row['snapshot_revision'] as num?)?.toInt(); if (rev != null) { final status = snapStatus[(mapped, rev)]; if (status == null) { - // The source window names a snapshot revision the - // source backup does not carry — its features cannot - // be linked to raw data that does not exist. Skipped - // and counted as missing, never imported snapshotless. skippedWindows++; skippedWindowsMissingSnapshot++; continue; @@ -9422,16 +9348,9 @@ class LocalDb { } }); counts['bp_research_reference'] = srcRefs.length; - // Snapshots whose (reference, revision) key collided with - // DIFFERENT content were skipped, not imported — the count - // must not claim them. counts['bp_research_snapshot'] = srcSnaps.length - skippedSnapshots; - // Windows skipped over a snapshot conflict or a missing source - // snapshot were not imported — the count must not claim them. counts['bp_research_window'] = srcWins.length - skippedWindows; - // Audit keys: WHY windows or snapshots were skipped, so a - // restore result can be read without opening the source file. counts['bp_research_snapshot_conflicts'] = skippedSnapshots - skippedSnapshotsDanglingReference - From c4f4e79b9874086bfe48e0226c903b5c6297d235 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:32:29 +0530 Subject: [PATCH 21/22] bp research screen: plain copy, localize the error snackbars, catch refresh/delete failures, trim comments --- lib/data/csv_export.dart | 7 +- lib/l10n/app_en.arb | 32 +++++-- lib/ui2/profile/bp_research.dart | 121 ++++++++------------------- lib/ui2/profile/settings.dart | 6 +- test/bp_research_db_test.dart | 32 +++---- test/bp_research_isolation_test.dart | 26 ++---- test/bp_research_ui_test.dart | 4 +- test/ui2_tokens_test.dart | 5 +- 8 files changed, 88 insertions(+), 145 deletions(-) diff --git a/lib/data/csv_export.dart b/lib/data/csv_export.dart index 70c562a39..1597eb7f3 100644 --- a/lib/data/csv_export.dart +++ b/lib/data/csv_export.dart @@ -286,11 +286,8 @@ const kCsvExportSets = [ CsvExportSet( name: 'bp_research', title: 'BP research captures (EXPERIMENTAL)', - // Paired cuff reference readings plus the band window frozen around - // each instant. Research data, not health data: never blended, never a - // training input, and absent stats stay EMPTY here exactly as they are - // NULL in the store — a spreadsheet cannot tell a zero from a reading - // afterwards, and a column of zeroes is a fabrication. + // Cuff readings plus the band window before each. Absent stats stay + // empty, never 0. columns: [ 'measured_at_ms', 'measurement_started_at_ms', diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 46879539f..9a414b8fb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -12446,7 +12446,7 @@ "@settingsBpResearchRowTitle": { "description": "Developer settings row title for the experimental blood-pressure research capture screen" }, - "settingsBpResearchRowSub": "EXPERIMENTAL. Pair a cuff reading with the 5 minutes of band rest data right before it, for comparison outside this app. Never a health feature", + "settingsBpResearchRowSub": "Experimental. Pair a cuff reading with the band data from the 5 minutes before it, for CSV export", "@settingsBpResearchRowSub": { "description": "Developer settings row subtitle for the BP research capture screen" }, @@ -12454,7 +12454,7 @@ "@bpResearchTitle": { "description": "Title of the experimental BP research capture screen" }, - "bpResearchIntro": "EXPERIMENTAL. Take a cuff reading, type the pair in, press capture. The 5 minutes of band data right before that reading are frozen next to it — for you to compare outside this app. Nothing here is a health feature, nothing here feeds any score, and nothing here is ever blended with what the band measured.", + "bpResearchIntro": "Experimental. Take a cuff reading, type it in and press capture. The 5 minutes of band data before the reading are saved next to it for CSV export.", "@bpResearchIntro": { "description": "Intro text of the BP research capture screen" }, @@ -12494,7 +12494,7 @@ "@bpResearchSessionId": { "description": "Optional label grouping readings of one sitting" }, - "bpResearchSessionIdHint": "Group readings of one sitting \u2014 they are not independent states, and an analysis must be able to tell.", + "bpResearchSessionIdHint": "Groups readings taken in one sitting.", "@bpResearchSessionIdHint": { "description": "Helper text under the session-id field" }, @@ -12502,7 +12502,7 @@ "@bpResearchBadTime": { "description": "Snackbar text for an unparseable measurement-time entry, which is rejected, never guessed" }, - "bpResearchFutureTime": "The measurement time lies in the future — the window would pair the reference with data that does not exist yet. Nothing was stored.", + "bpResearchFutureTime": "The measurement time is in the future. Nothing was stored.", "@bpResearchFutureTime": { "description": "Snackbar text rejecting a future measurement time" }, @@ -12526,8 +12526,30 @@ "@bpResearchBadValue": { "description": "Snackbar text for an out-of-bounds reference pair, which is rejected, never clamped" }, - "bpResearchExportHint": "Export all captures as CSV from Your data › Export CSV (set \"BP research captures\"). This is the dedicated export for BP research data; a full-database backup or an opt-in health share also contains it.", + "bpResearchExportHint": "Export the captures from Your data › Export CSV, set \"BP research captures\".", "@bpResearchExportHint": { "description": "Hint under the capture list explaining the CSV export path" + }, + "bpResearchSaveFailed": "Capture failed, nothing was stored. ({error})", + "@bpResearchSaveFailed": { + "description": "Snackbar when storing a BP research capture throws", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "bpResearchListFailed": "Capture saved, but the list did not refresh. ({error})", + "@bpResearchListFailed": { + "description": "Snackbar when a capture was stored but re-reading the list failed", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "bpResearchDeleteTooltip": "Delete capture", + "@bpResearchDeleteTooltip": { + "description": "Tooltip of the button that deletes one BP research capture" } } diff --git a/lib/ui2/profile/bp_research.dart b/lib/ui2/profile/bp_research.dart index b5c442dc9..1ccb14cf5 100644 --- a/lib/ui2/profile/bp_research.dart +++ b/lib/ui2/profile/bp_research.dart @@ -1,26 +1,5 @@ -// BP research capture — DEVELOPER MODE ONLY. -// -// One flow: take a cuff blood pressure reading, type the pair in, press -// capture. The app freezes the band's own decoded data around the -// MEASUREMENT instant — by default the 5 minutes of rest BEFORE the -// measurement, so the cuff's own inflation stays out of the feature window -// — next to the reference pair, and keeps every capture so a human can -// compare them over weeks: here in a list, or out of the app through the -// `bp_research` CSV export set. -// -// The measurement instant can be back-dated: a cuff reading taken this -// morning and typed in this evening is paired with the historical sensor -// data of the MEASUREMENT time, never with whatever the band holds at -// entry time. -// -// This is data COLLECTION, not a blood pressure feature: -// · Nothing derived reads these tables. No score, no baseline, no chart -// of ours takes a capture as an input. -// · Nothing here is ever blended with, averaged against, or corrected -// against anything the band measured. -// · Nothing here is exported to HealthKit / Health Connect. -// A window with no band data is stored as a capture with an EMPTY window — -// missing is missing, never zero. +// BP research capture (developer mode): type in a cuff reading and the band's +// decoded window before it is saved next to it. See bp_research_capture.dart. import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -77,18 +56,20 @@ class _BpResearchScreenState extends State { if (mounted) setState(() => _rows = rows); } - /// Parse the optional measurement-time field (MINUTE precision — the - /// recorded instant is stored with time_precision = 'minute' and is never - /// claimed to be second-accurate). Returns null when empty (= the entry - /// moment anchors the pairing, valid only because the reading was taken - /// just now) or unparseable (the caller refuses the capture: a wrong - /// pairing instant silently pairs the reference with the wrong five - /// minutes of band data — worse than refusing). - /// - /// STRICT calendar validation: Dart's DateTime rolls overflowing dates - /// over (2024-02-31 becomes March 2), so every component is re-checked - /// against the parsed result — an invalid date is REJECTED, never - /// silently reinterpreted as a different day. + Future _rowAction(Future Function() action) async { + try { + await action(); + await _refresh(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$e'))); + } + } + + /// The measurement time field, minute precision. Empty is [now]; anything + /// unparseable (including a rolled-over date like 2024-02-31) is null. DateTime? _parseMeasuredAt(DateTime now) { final text = _measuredAt.text.trim(); if (text.isEmpty) return now; @@ -171,9 +152,7 @@ class _BpResearchScreenState extends State { SnackBar( content: Text( l?.bpResearchFutureTime ?? - 'The measurement time lies in the future \u2014 the window ' - 'would pair the reference with data that does not exist yet. ' - 'Nothing was stored.', + 'The measurement time is in the future. Nothing was stored.', ), ), ); @@ -198,10 +177,6 @@ class _BpResearchScreenState extends State { await LocalDb.putBpResearchCapture( BpResearchCapture( measuredAtMs: measuredAtMs, - // A single MINUTE-precision instant is all the UI records. It is - // the pairing anchor, NOT a claimed inflation start: leaving - // measurement_started_at_ms NULL keeps the "unknown start" - // honest instead of dressing the typed instant up as one. measurementStartedAtMs: null, measurementFinishedAtMs: null, timePrecision: 'minute', @@ -233,8 +208,10 @@ class _BpResearchScreenState extends State { SnackBar( content: Text( stored - ? 'Capture saved, but refreshing the history failed. ($e)' - : 'Capture failed \u2014 nothing was stored. ($e)', + ? (l?.bpResearchListFailed('$e') ?? + 'Capture saved, but the list did not refresh. ($e)') + : (l?.bpResearchSaveFailed('$e') ?? + 'Capture failed, nothing was stored. ($e)'), ), ), ); @@ -255,12 +232,9 @@ class _BpResearchScreenState extends State { children: [ Text( l?.bpResearchIntro ?? - 'EXPERIMENTAL. Take a cuff reading, type the pair in, ' - 'press capture. The band data of the minutes before that ' - 'instant is frozen next to it \u2014 for you to compare outside ' - 'this app. Nothing here is a health feature, nothing here ' - 'feeds any score, and nothing here is ever blended with what ' - 'the band measured.', + 'Experimental. Take a cuff reading, type it in and press ' + 'capture. The 5 minutes of band data before the reading are ' + 'saved next to it for CSV export.', style: F.cap.copyWith(color: p.ink2, height: 1.5), ), const SizedBox(height: S.x4), @@ -314,8 +288,7 @@ class _BpResearchScreenState extends State { labelText: l?.bpResearchSessionId ?? 'Session id (optional)', helperText: l?.bpResearchSessionIdHint ?? - 'Group readings of one sitting \u2014 they are not ' - 'independent states, and an analysis must be able to tell.', + 'Groups readings taken in one sitting.', ), ), const SizedBox(height: S.x2), @@ -353,28 +326,22 @@ class _BpResearchScreenState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - // EXPLICIT REPROCESSING (developer mode): re-read the - // CURRENT local data for this capture's ORIGINAL window - // bounds, write a NEW snapshot revision, re-classify - // (pending → final once the sync provably reaches the - // window end). Reference values are never touched. IconButton( icon: const Icon(LucideIcons.refreshCw, size: 18), tooltip: l?.bpResearchRefreshTooltip ?? 'Refresh band window', - onPressed: () async { - await LocalDb.reprocessBpResearchCapture( + onPressed: () => _rowAction( + () => LocalDb.reprocessBpResearchCapture( r['id'] as int, - ); - await _refresh(); - }, + ), + ), ), IconButton( icon: const Icon(LucideIcons.trash2, size: 18), - onPressed: () async { - await LocalDb.deleteBpResearchCapture(r['id'] as int); - await _refresh(); - }, + tooltip: l?.bpResearchDeleteTooltip ?? 'Delete capture', + onPressed: () => _rowAction( + () => LocalDb.deleteBpResearchCapture(r['id'] as int), + ), ), ], ), @@ -382,10 +349,8 @@ class _BpResearchScreenState extends State { const SizedBox(height: S.x4), Text( l?.bpResearchExportHint ?? - 'Export all captures as CSV from Your data \u203a Export CSV ' - '(set \u201cBP research captures\u201d). This is the dedicated ' - 'export for BP research data; a full-database backup or an ' - 'opt-in health share also contains it.', + 'Export the captures from Your data \u203a Export CSV, set ' + '\u201cBP research captures\u201d.', style: F.cap.copyWith(color: p.ink2, height: 1.5), ), ], @@ -394,18 +359,8 @@ class _BpResearchScreenState extends State { ); } - /// A window is summarised as what it actually holds. A NULL stat is shown - /// as absent — a dash, never a zero, and never a value that would read as - /// a measurement. - /// - /// INVARIANT: pending ≠ no_data. 'pending' means the window is NOT - /// FINALIZABLE yet — the local sync provably does not reach the window - /// end, so the missing tail may still arrive after a sync + refresh. It - /// is never "no band data": that verdict is reserved for a FINAL window - /// that provably holds nothing. Pending is checked FIRST so a NULL stat - /// can never be misread as a final verdict; whatever HAS arrived is - /// still shown honestly alongside the hint. A non-medical, non-claiming - /// message. + /// One line per capture. Null stats are left out; a pending window says + /// it is still syncing rather than "no band data". static String _windowSummary(Map r, AppLocalizations? l) { final onehz = r['onehz_rows']; final beats = r['rr_beats']; @@ -413,8 +368,6 @@ class _BpResearchScreenState extends State { final rmssd = r['rmssd_ms']; final status = r['quality_status']; if (status == 'pending') { - // Not final YET — never "no band data". Show whatever has arrived - // (partial data is honest data), plus the sync hint. final parts = [ l?.bpResearchPendingSync ?? 'Band data is still syncing — refresh this window after ' diff --git a/lib/ui2/profile/settings.dart b/lib/ui2/profile/settings.dart index 5946eff02..5b1167dde 100644 --- a/lib/ui2/profile/settings.dart +++ b/lib/ui2/profile/settings.dart @@ -797,9 +797,9 @@ class MoreSettingsView extends StatelessWidget { SetRow(LucideIcons.heartPulse, C.purple, l?.settingsBpResearchRowTitle ?? 'BP research capture', sub: l?.settingsBpResearchRowSub ?? - 'EXPERIMENTAL. Pair a cuff reading with the band ' - 'data of the same instant, for comparison ' - 'outside this app. Never a health feature', + 'Experimental. Pair a cuff reading with the band ' + 'data from the 5 minutes before it, for CSV ' + 'export', onTap: () => goto(c, const BpResearchScreen())), SetRow(LucideIcons.code, C.n500, l?.settingsDeveloperModeRowTitle ?? 'Developer mode', diff --git a/test/bp_research_db_test.dart b/test/bp_research_db_test.dart index bd5fa0bf6..4928f922a 100644 --- a/test/bp_research_db_test.dart +++ b/test/bp_research_db_test.dart @@ -1,12 +1,4 @@ -// The BP research store guarantees the review fixes made explicit: -// · a retake with NO device text replaces the reference instead of -// duplicating it (NULL never equals NULL in a UNIQUE constraint, so -// the store normalizes to '' — the rows must stay one, not two); -// · deleting a capture removes its window row in the same transaction -// (no PRAGMA foreign_keys here, so the ON DELETE CASCADE is inert); -// · a retake that now finds band data replaces the old window instead -// of orphaning it under the replaced reference's old id. -// Runs the REAL LocalDb over sqflite_common_ffi. +// BP research store over the real LocalDb (sqflite_common_ffi). import 'dart:convert' show jsonEncode; import 'package:flutter_test/flutter_test.dart'; @@ -1113,7 +1105,7 @@ void main() { await db.delete('bp_research_reference'); } - test('restore TEST 1: a FRESH target restores reference + snapshot + window ' + test('restore: a FRESH target restores reference + snapshot + window ' 'as one unit (the regression)', () async { await clearBpTables(); // FRESH target: no local BP rows at all. The source carries a @@ -1157,7 +1149,7 @@ void main() { await databaseFactory.deleteDatabase(srcPath); }); - test('restore TEST 2: an IDENTICAL snapshot re-import is idempotent and the ' + test('restore: an IDENTICAL snapshot re-import is idempotent and the ' 'window converges', () async { await clearBpTables(); // LOCAL: reference + snapshot rev 1 (rows A) + a LOCAL window with @@ -1234,7 +1226,7 @@ void main() { await databaseFactory.deleteDatabase(srcPath); }); - test('restore TEST 3: a CONFLICTING snapshot skips the snapshot AND the ' + test('restore: a CONFLICTING snapshot skips the snapshot AND the ' 'window, counters rise', () async { await clearBpTables(); // LOCAL: reference + snapshot rev 1 (rows A) + window A. @@ -1307,7 +1299,7 @@ void main() { await databaseFactory.deleteDatabase(srcPath); }); - test('restore TEST 4: a window whose snapshot is MISSING in the source is ' + test('restore: a window whose snapshot is MISSING in the source is ' 'never imported', () async { await clearBpTables(); // SOURCE: a reference and a window naming snapshot revision 1 — @@ -1337,7 +1329,7 @@ void main() { await databaseFactory.deleteDatabase(srcPath); }); - test('restore TEST 5: a LEGACY snapshotless window (snapshot_revision NULL) ' + test('restore: a LEGACY snapshotless window (snapshot_revision NULL) ' 'imports snapshotless, no fabricated revision', () async { await clearBpTables(); // SOURCE: v1-style capture — a window with snapshot_revision NULL @@ -1373,7 +1365,7 @@ void main() { await databaseFactory.deleteDatabase(srcPath); }); - test('restore TEST 6: a reference ID collision maps snapshot and window to ' + test('restore: a reference ID collision maps snapshot and window to ' 'the CORRECT destination reference', () async { await clearBpTables(); // LOCAL: one capture whose AUTOINCREMENT id is 1 (deliberately the @@ -1434,7 +1426,7 @@ void main() { await databaseFactory.deleteDatabase(srcPath); }); - test('restore TEST 7: a repeated re-import of the SAME source creates no ' + test('restore: a repeated re-import of the SAME source creates no ' 'duplicates and stable counts', () async { await clearBpTables(); final srcPath = await makeForeignBpDb( @@ -1524,7 +1516,7 @@ void main() { }); test('reprocess: a pending capture with a later watermark becomes final, ' - 'writes revision 2, keeps revision 1 byte-identical (A3)', () async { + 'writes revision 2, keeps revision 1 byte-identical', () async { final db = await LocalDb.instance; await db.delete('bp_research_snapshot'); await db.delete('bp_research_window'); @@ -1630,7 +1622,7 @@ void main() { }); test('reprocess stays pending when the sync still does not reach the ' - 'window end (A3, smoke 3)', () async { + 'window end', () async { final db = await LocalDb.instance; await db.delete('bp_research_snapshot'); await db.delete('bp_research_window'); @@ -1680,7 +1672,7 @@ void main() { ); expect(win.first['quality_status'], 'pending'); }); - test('A: an empty, not-final window is PENDING, keeps its row, and ' + test('an empty, not-final window is PENDING, keeps its row, and ' 're-processing attaches a new revision once data arrives', () async { final db = await LocalDb.instance; await db.delete('bp_research_snapshot'); @@ -1799,7 +1791,7 @@ void main() { expect(rev1After, rev1); }); - test('A: a final, provably empty window is still an honest NULL window ' + test('a final, provably empty window is still an honest NULL window ' '(no pending-forever regression)', () async { // Watermark provably covers the window end, the window is in the // past, and there is STILL nothing: null is CORRECT (no_data diff --git a/test/bp_research_isolation_test.dart b/test/bp_research_isolation_test.dart index bcb25bf3c..bf9d0ec1b 100644 --- a/test/bp_research_isolation_test.dart +++ b/test/bp_research_isolation_test.dart @@ -1,30 +1,13 @@ -// THE INVARIANT the BP research store exists to have. -// -// `bp_research_reference` / `bp_research_window` hold a cuff reading next to -// the band's own decoded data from the same instant. That pairing is exactly -// the input a cuffless-blood-pressure claim would be built from, which is -// the category that earned WHOOP an FDA Warning Letter in Jul 2025 — so the -// tables are fenced the same way `observation` is: nothing outside the -// allow-list may name either table, and the allow-list names the readers -// whose whole job is showing/exporting research data to the user. -// -// Layer 1 (structural source scan) reuses the mechanism -// observation_isolation_test.dart is built on. A violation is SILENT — no -// exception, just a wrist-vs-cuff regression one PR later — so the control -// is a test that goes red, not a comment that says don't. +// Nothing outside the allow-list may name the BP research store: a wrist +// series regressed against cuff readings inside the app would be a cuffless +// blood pressure feature. Same mechanism as observation_isolation_test.dart. import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; -/// The ONLY files allowed to name either BP research table. -/// -/// Adding to this list is the deliberate act the invariant asks for. Before -/// you do: a READER for the dev screen or the CSV export is fine. Anything -/// in `lib/compute/`, anything that feeds `day_result`, `metric_series` or -/// a baseline, and anything that writes to HealthKit / Health Connect is -/// the thing this whole file exists to stop. +/// Readers for the dev screen and the CSV export only. const _allowed = { 'lib/data/db.dart', 'lib/data/csv_export.dart', @@ -45,6 +28,7 @@ void main() { s.contains('bp_research_snapshot') || s.contains('bpResearchCaptures') || s.contains('putBpResearchCapture') || + s.contains('reprocessBpResearchCapture') || s.contains('deleteBpResearchCapture'); if (hit && !_allowed.contains(rel)) offenders.add(rel); } diff --git a/test/bp_research_ui_test.dart b/test/bp_research_ui_test.dart index 2e8a6b156..f3f531a23 100644 --- a/test/bp_research_ui_test.dart +++ b/test/bp_research_ui_test.dart @@ -1,6 +1,4 @@ -// Tests for the BP research window summary: pending must never read as a -// final "no band data" verdict, while a final empty window keeps its honest -// no-data text. Missing stays missing — never a fabricated zero. +// BP research window summary: pending never reads as "no band data". import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ui2/profile/bp_research.dart'; diff --git a/test/ui2_tokens_test.dart b/test/ui2_tokens_test.dart index effee3e28..adfa52b38 100644 --- a/test/ui2_tokens_test.dart +++ b/test/ui2_tokens_test.dart @@ -271,10 +271,7 @@ const _notComponents = { // Where the hydration notification lands: a Scaffold route that reads and // writes the day's journal metrics. The one control on it — FieldStepper — // IS in the gallery. - // The BP research capture route: a Scaffold that reads and writes its own - // research table (never the health stores) and only exists behind the dev - // toggle. A gallery case would have to mock the database; the capture flow - // is what bp_research_capture_test.dart covers on the data side. + // Dev-mode BP research route: a Scaffold over its own tables. 'BpResearchScreen', // The coach chat and its BYOK setup: Scaffold routes that own an engine, a // 120 s network call and the keychain. `CoachFigure` — the part a gallery can From 18888881133daf30c23c73cf42ce424f7539ca60 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:32:47 +0530 Subject: [PATCH 22/22] bp research model: drop pr reference from docstring --- tool/bp_research_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/bp_research_model.py b/tool/bp_research_model.py index 8c3afcae9..b82626e9e 100644 --- a/tool/bp_research_model.py +++ b/tool/bp_research_model.py @@ -16,7 +16,7 @@ Usage: python3 bp_research_model.py --csv bp_research.csv [--out report.txt] -Model (research draft, per the PR description): +Model (research draft): H = mean of valid HR in the window (hr_mean) V = RMSSD over valid contiguous interval pairs (rmssd_ms) L = ln((V + eps) / 1 ms), eps = 1e-3 ms, numerical stability only