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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 169 additions & 13 deletions lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -597,7 +597,18 @@ import 'substrate.dart';
// persisted bundle does, so days derived at v58 must be re-derived to pick it
// up. `ABSENT` was deliberately NOT invented as a fifth tier — `Tier.all` in
// analytics is a closed set of four published grades.
const int kAlgoVersion = 59;
//
// v60 - the all-day HRV and respiratory curves advance their cadence cursor on
// every ATTEMPT rather than only on a successful estimate. `_dayRespCurve`
// left `lastEmit` unset whenever rsaRespRate came back absent — and absent is
// the EXPECTED daytime case, because daytime RSA is movement-confounded — so a
// confounded stretch re-ran the triple Lomb-Scargle once per beat instead of
// once per five minutes. That is what exhausted the 90 s day-blocks budget and
// left days persisted headline-only. `_dayHrvCurve` had the same shape plus an
// O(window) sum that ran before its cadence gate was checked. Both curves keep
// their sampling intent; points that were previously emitted a beat or two
// after a failed attempt now land on the next cadence tick instead.
const int kAlgoVersion = 60;

// Fold idempotency, the minimum-nights warm-up, and legacy-payload handling
// all live in SleepProfilePolicy (pure, unit-tested) — see
Expand Down Expand Up @@ -2508,6 +2519,38 @@ class DerivationEngine {
final finalized =
!producedNothing && (forceFinalize || (ageFinalized && secondHalfOk));

// A failed/timed-out second half yields a headline-only bundle, and
// putDayResult replaces the row wholesale — so re-deriving an already
// complete day (rescanRecent deliberately revisits finalized days) destroyed
// its naps, sleep periods, workouts, HRR, wear and curves. Carry the
// previous result's detail forward instead of blanking it. Same principle as
// the producedNothing guard above and the skip-marker guard in
// _markDaySkipped: never let a thinner result overwrite a richer one.
var effectiveFinalized = finalized;
var effectivePartial = !secondHalfOk;
if (!secondHalfOk) {
final existing = await LocalDb.dayResult(day.date);
if (_isRealDayResult(existing)) {
final prev = _decodeBundle(existing!['payload_json']);
if (prev != null) {
final recovered = carryForwardDetail(prev, bundle);
final prevVersion = (existing['algo_version'] as num?)?.toInt();
final outcome = recoveryOutcome(
recovered: recovered,
prevPartial: (existing['partial'] as num?)?.toInt() == 1,
prevVersion: prevVersion,
prevFinalized: (existing['finalized'] as num?)?.toInt() == 1,
finalizedByAge: finalized,
);
effectivePartial = outcome.partial;
effectiveFinalized = outcome.finalized;
_log('derive ${day.date}: second half failed — carried the previous '
"result's detail blocks forward (v$prevVersion -> v$kAlgoVersion, "
'partial=$effectivePartial)');
}
}
}

final scalars =
(bundle['scalars'] as Map?)?.cast<String, dynamic>() ?? const {};
double? sc(String k) => (scalars[k] as num?)?.toDouble();
Expand All @@ -2518,8 +2561,8 @@ class DerivationEngine {
windowJson: jsonEncode(
((day.sleepJson['window'] as Map?) ?? const {}).cast<String, dynamic>(),
),
finalized: finalized,
partial: !secondHalfOk,
finalized: effectiveFinalized,
partial: effectivePartial,
rhr: sc('rhr'),
rmssd: sc('rmssd'),
readiness: sc('readiness'),
Expand Down Expand Up @@ -2722,6 +2765,76 @@ class DerivationEngine {
return row['rhr'] != null || row['rmssd'] != null || row['readiness'] != null;
}

/// Fill [next]'s missing detail from [prev] when the second-half compute
/// failed, so a headline-only pass never blanks a day that already had naps,
/// workouts, HRR, wear and curves. Returns true if anything was carried over.
///
/// Keyed on ABSENCE, not on null: isolate 1 writes its headline scalars
/// explicitly and a null there is a real "we could not measure this today"
/// that must survive. Only keys the failed second half never got to add are
/// restored — a freshly computed value always wins.
@visibleForTesting
static bool carryForwardDetail(
Map<String, dynamic> prev,
Map<String, dynamic> next,
) {
var carried = false;
for (final e in prev.entries) {
if (e.key == 'scalars' || e.key == 'series') continue;
if (next.containsKey(e.key) || e.value == null) continue;
next[e.key] = e.value;
carried = true;
}
// `scalars` and `series` are flat maps the second half patches INTO rather
// than owning, so they merge per key instead of wholesale.
for (final sub in const ['scalars', 'series']) {
final p = prev[sub];
if (p is! Map) continue;
final n = next[sub];
if (n is! Map) {
next[sub] = Map<String, dynamic>.from(p.cast<String, dynamic>());
carried = true;
continue;
}
for (final e in p.entries) {
if (n.containsKey(e.key) || e.value == null) continue;
n[e.key] = e.value;
carried = true;
}
}
return carried;
}

/// How a day should be filed after its second half failed and the previous
/// result's detail was carried forward.
///
/// The version check is the subtle part. `LocalDb.dayResult` returns the
/// HIGHEST algo_version stored for the day, so immediately after a bump the
/// row it hands back belongs to the previous version. Carrying that detail
/// forward still beats blanking the day — but it must not be filed as a
/// finished CURRENT-version result, because the reason a bump exists is that
/// those blocks are computed differently now. A cross-version carry therefore
/// stays partial and unfinalized, so a later pass recomputes it for real
/// instead of locking last version's curves in under this version's number.
@visibleForTesting
static ({bool partial, bool finalized}) recoveryOutcome({
required bool recovered,
required bool prevPartial,
required int? prevVersion,
required bool prevFinalized,
required bool finalizedByAge,
}) {
final sameVersion = prevVersion == kAlgoVersion;
if (!recovered || prevPartial || !sameVersion) {
// Stays partial, but keep whatever the caller had already decided about
// finalizing: an IMPORT force-finalizes even a partial day, because there
// is no stored raw to ever recompute it from.
return (partial: true, finalized: finalizedByAge);
}
// As complete as it was before this pass, so it keeps what it had earned.
return (partial: false, finalized: finalizedByAge || prevFinalized);
}

/// Test seam for [_markDaySkipped] — the "a skip marker must never destroy a
/// real result" guarantee is the whole point of the method, so it is pinned
/// directly rather than through a full derive pass.
Expand Down Expand Up @@ -3879,7 +3992,13 @@ class DerivationEngine {
/// Timeline graph. 5-min sliding window, emitted ~each minute. Inline artifact
/// gate (plausible RR 300–2000 ms) — daytime RR is noisier/motion-confounded,
/// so this is a context line, not the nocturnal recovery RMSSD.
static List<Map<String, num>> _dayHrvCurve(Substrate s) {
/// Test seam: counts window evaluations, for the same reason as
/// [debugRespAttempts] — the fix is about how often the O(window) sum runs.
@visibleForTesting
static int debugHrvAttempts = 0;

@visibleForTesting
static List<Map<String, num>> dayHrvCurve(Substrate s) {
final ts = <double>[], rr = <double>[];
for (var i = 0; i < s.rrMs.length; i++) {
final v = s.rrMs[i];
Expand All @@ -3897,7 +4016,11 @@ class DerivationEngine {
while (ts[i] - ts[lo] > winMs) {
lo++;
}
if (i - lo >= 10) {
// Cadence gate FIRST: the sum-of-squared-differences below is O(window),
// and running it for every beat only to discard the result on the 60 s
// check was the whole window's work wasted per sample.
if (i - lo >= 10 && ts[i] - lastEmit > 60000) {
debugHrvAttempts++;
var ssd = 0.0;
var nd = 0;
for (var k = lo + 1; k <= i; k++) {
Expand All @@ -3909,14 +4032,18 @@ class DerivationEngine {
ssd += d * d;
nd++;
}
if (nd >= 8 && ts[i] - lastEmit > 60000) {
// Advance on the ATTEMPT, before either quality check. The window holds
// ~300-600 beats, and leaving the cursor behind when a stretch is too
// artifact-heavy to yield 8 usable pairs re-runs that whole sum on every
// subsequent beat until one finally does.
lastEmit = ts[i];
if (nd >= 8) {
final rmssd = math.sqrt(ssd / nd);
if (rmssd <= 220) {
out.add({
't': (ts[i] / 1000).round(),
'v': double.parse(rmssd.toStringAsFixed(1)),
});
lastEmit = ts[i];
}
}
}
Expand All @@ -3928,7 +4055,24 @@ class DerivationEngine {
/// 24/7 RR. 3-min window emitted ~every 5 min; absent windows (too few/too
/// noisy beats) are skipped — never fabricated. Daytime RSA is movement-
/// confounded, so it's a context line.
static List<Map<String, num>> _dayRespCurve(Substrate s) {
///
/// Test seam: replaces the RSA estimator, so the ABSENT branch — the one that
/// used to strand the cadence cursor and re-run a triple Lomb-Scargle per beat
/// — can be exercised deterministically. It needs a seam because no synthetic
/// RR reliably makes the real estimator abstain: the behaviour comes from real
/// movement-confounded daytime data, which is exactly what is hard to fake.
@visibleForTesting
static double? Function(List<double> nn, List<double> nnt)?
debugRespEstimator;

/// Test seam: counts estimator ATTEMPTS. The cost fix is about how often the
/// estimator runs, not about what it returns, so the attempt count is the only
/// thing that actually distinguishes the fixed code from the broken code.
@visibleForTesting
static int debugRespAttempts = 0;

@visibleForTesting
static List<Map<String, num>> dayRespCurve(Substrate s) {
final ts = <double>[], rr = <double>[];
for (var i = 0; i < s.rrMs.length; i++) {
final v = s.rrMs[i];
Expand All @@ -3951,14 +4095,26 @@ class DerivationEngine {
final nn = rr.sublist(lo, i + 1);
final t0 = ts[lo];
final nnt = [for (var k = lo; k <= i; k++) ts[k] - t0];
final est = ana.rsaRespRate(nn, nnt, artifactFraction: 0.15);
final brpm = est.present ? est.value!.brpm : null;
debugRespAttempts++;
final seam = debugRespEstimator;
final double? brpm;
if (seam != null) {
brpm = seam(nn, nnt);
} else {
final est = ana.rsaRespRate(nn, nnt, artifactFraction: 0.15);
brpm = est.present ? est.value!.brpm : null;
}
// Advance the cadence cursor on every ATTEMPT, not just on a successful
// estimate. Daytime RSA is movement-confounded (see above), so absent is
// the common case — and while lastEmit sat inside the success branch a
// confounded stretch re-ran the triple Lomb-Scargle once per BEAT
// instead of once per 5 min. That is what blew the day-blocks budget.
lastEmit = ts[i];
if (brpm != null) {
out.add({
't': (ts[i] / 1000).round(),
'v': double.parse(brpm.toStringAsFixed(1)),
});
lastEmit = ts[i];
}
}
}
Expand Down Expand Up @@ -4534,8 +4690,8 @@ class DerivationEngine {
}

bundlePatch['daytime_hrv'] = _daytimeHrv(daySub, onset, offset);
seriesPatch['hrv_day'] = _dayHrvCurve(daySub);
seriesPatch['resp_day'] = _dayRespCurve(daySub);
seriesPatch['hrv_day'] = dayHrvCurve(daySub);
seriesPatch['resp_day'] = dayRespCurve(daySub);
seriesPatch['skin_temp_day'] = _daySkinTempCurve(daySub);
bundlePatch['restlessness'] = _restlessness(sleepSub);
// napSub extends a few hours past this day's calendar end so a nap/
Expand Down
11 changes: 10 additions & 1 deletion lib/state/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,12 @@ class AppState extends ChangeNotifier {
/// The on-device compute orchestrator. Kicked (light) after every drain/flush
/// completion, and (heavy) on foreground finalize. Background heavy passes run
/// via WorkManager (Android) — see lib/compute/background_derivation.dart.
late final DerivationEngine _derive = DerivationEngine(log: _log);
// `background` is final on the engine and picks the concurrency + per-day
// timeout, so seeding the scheduler alone left a headless first sweep running
// the foreground budget. Late-initialized, so this reads the value both
// constructors have already set by the time anything touches `_derive`.
late final DerivationEngine _derive =
DerivationEngine(log: _log, background: _background);
late final DeriveScheduler _deriveScheduler = DeriveScheduler(
run: ({required DeriveJobKind kind}) =>
_afterDrain(heavy: kind == DeriveJobKind.heavy),
Expand Down Expand Up @@ -927,6 +932,10 @@ class AppState extends ChangeNotifier {
// connection interval would run at the fast one until the user next
// foregrounded the app.
engine.setBackground(_background);
// Same reasoning for the derive pacing budget: it defaults to foreground and
// otherwise only flips on a transition, so a headless start paced its very
// first sweep as if the app were on screen.
_deriveScheduler.setBackground(_background);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
repo = LocalRepositoryImpl(getProfileMap: () => user);
// iOS BGProcessing/BGAppRefresh wakes while the FOREGROUND app owns the band
// skip the headless BLE path (it would fight FBP for the peripheral) — route
Expand Down
41 changes: 41 additions & 0 deletions lib/telemetry/error_classification.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Which uncaught errors actually represent a crash.
//
// `PlatformDispatcher.onError` was filing everything it saw as fatal. That is
// wrong for the transient network failures the app is expected to survive: a
// basemap tile fetched with no connectivity, a TLS handshake dropped mid-flight,
// a request abandoned when its widget went away. None of those stop the app, but
// each one counted against the crash-free-user rate and buried real crashes in
// the issue list.
//
// Pure and dependency-free so it can be unit tested without a Flutter binding.

/// True when [error] is a transient I/O failure rather than a defect.
///
/// Deliberately matched on type NAME rather than on an imported type: this file
/// stays dependency-free, and the relevant types come from `dart:io` and
/// `package:http`, which classify identically here.
bool isTransientError(Object error) {
final type = error.runtimeType.toString();
const transientTypes = {
'SocketException',
'HttpException',
'HandshakeException',
'TlsException',
'ClientException',
'ConnectionClosedException',
};
// TimeoutException is deliberately NOT here. It is not network-specific — a
// derivation, database or lifecycle timeout raises the same type, and those
// are exactly the failures worth keeping visible as crashes.
if (transientTypes.contains(type)) return true;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// http's ClientException wraps the underlying cause in its message rather
// than nesting the exception, so the wrapped form has to be matched on text.
final text = error.toString();
return text.contains('SocketException') ||
text.contains('HandshakeException') ||
text.contains('Connection closed') ||
text.contains('Connection reset') ||
text.contains('Failed host lookup') ||
text.contains('Network is unreachable');
}
11 changes: 10 additions & 1 deletion lib/telemetry/telemetry_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import 'package:firebase_performance/firebase_performance.dart';
import 'package:firebase_analytics/firebase_analytics.dart';

import '../cloud/companion_client.dart';
import 'error_classification.dart';
import 'jank_policy.dart';

/// A band-side snapshot AppState supplies (it owns the live DeviceState).
Expand Down Expand Up @@ -167,7 +168,15 @@ class TelemetryService {
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
try {
if (Firebase.apps.isNotEmpty && _enabled) {
FirebaseCrashlytics.instance.recordError(error, stack, fatal: true);
// Same reasoning as the `silent` check above, for the errors the
// framework never sees: a dropped tile fetch or a TLS handshake that
// died with no network is not a crash, and filing it as one both
// understates crash-free users and buries the real crashes.
FirebaseCrashlytics.instance.recordError(
error,
stack,
fatal: !isTransientError(error),
);
}
} catch (_) {}
record(kind: 'crash', level: 'error', message: '$error', stack: '$stack');
Expand Down
9 changes: 5 additions & 4 deletions lib/ui/activity/workout_share_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import '../../state/units_controller.dart';
import '../../theme/theme.dart';
import '../../theme/tokens.dart';
import '../kit/kit.dart' show AppIcon, OsIcon;
import '../kit/share_origin.dart';
import '../kit/route_map.dart';

/// Where the image is going. Strava-style: a feed post and a story, because
Expand Down Expand Up @@ -337,6 +338,10 @@ class _WorkoutSharePreviewScreenState extends State<WorkoutSharePreviewScreen> {

Future<void> _share() async {
if (_sharing) return;
// Measured before the capture/encode/write awaits below, per shareOriginFor:
// after an async gap this widget may have been relaid out and the rect would
// describe a box that has moved.
final origin = shareOriginFor(context);
setState(() => _sharing = true);
try {
final boundary = _captureKey.currentContext?.findRenderObject()
Expand Down Expand Up @@ -382,10 +387,6 @@ class _WorkoutSharePreviewScreenState extends State<WorkoutSharePreviewScreen> {
await file.writeAsBytes(bytes.buffer.asUint8List());

if (!mounted) return;
final box = context.findRenderObject() as RenderBox?;
final origin = (box != null && box.hasSize)
? (box.localToGlobal(Offset.zero) & box.size)
: null;
// No caption text: the image carries everything, and a canned
// "My OpenStrap workout" string is exactly the kind of filler that makes
// a share feel automated.
Expand Down
Loading
Loading