Skip to content

delete the things that were only ever measuring themselves - #263

Open
abdulsaheel wants to merge 7 commits into
feat/ux-round-2from
feat/perf
Open

delete the things that were only ever measuring themselves#263
abdulsaheel wants to merge 7 commits into
feat/ux-round-2from
feat/perf

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

User description

Stacked on #261. Nothing here changes a derived number — there is no kAlgoVersion bump, and if one becomes necessary the change is wrong. This is "faster and shorter", nothing else.

deletions

~90 lines of SpO2 diagnostics ran on the calling isolate, per derived day, in release. SpO2 is refused permanently, not parked — the pipeline says so in its own words and odi is a const Metric.absent, so most of what the logger printed was a compile-time constant. It did ~14 full passes over ~28,800-sample arrays plus two HashSets and a growable List<double>, for every day including backfill. Deleting it orphaned DayBundleInput.sleepSpo2Red/sleepSpo2Ir, which deriveDayBundle never read — they were serialized and copied across the isolate boundary purely to be ignored. 8 call sites fixed.
The refusal metric, its tier, inputs_used and note are untouched. This deletes a logger, never an abstention path.

dbCounts cost 13 full-table COUNT(*) pairs and fed one log line. With the writes gone the field would have been written never and read once, printing a permanent raw=0 — a field that exists only to log a lie — so it went too. LocalDb.counts() stays; two tests use it. Every enclosing block survived: five of those sites sit inside if (…) { notifyListeners(); }-shaped blocks, and deleting the block rather than the statement would have re-introduced the staleness bug #261 just fixed.

importEdgeBackup hand-inflated gzip that importFromDbFile already inflates. The duplicate was also the unsafe one: Dart's gzip.decoder returns partial output on a truncated .db.gz without raising, so a half-synced backup restored short and reported success — on the one path where the original is already gone. The downstream inflate reads the CRC32/ISIZE trailer off the file.

the one that is a bug wearing a perf costume

_reanalyzeForOverride never bumped insightsRevision. Every sleep override, nap edit and phone-steps toggle rewrites day_result and no RevisionReload screen noticed. One line.

hot paths

onHistoricalData re-parsed its own hex back into bytes to read one byte the caller already had. It takes revision now; the unused Sample? went with it. Semantics are exact — the caller already computes recType = inner.length > 1 ? inner[1] : -1, so the new revision < 0 guard is the old inner.length < 2 guard. ~6 MB of garbage per gen4 offload (not the 18 MB originally claimed; v20/v21/v26 never reach this path).

The rest countdown rebuilt the entire workout shell once a second, in the file that introduced LiveTick to stop exactly this — ~1,200 full-subtree rebuilds in a strength session. restLeft is a ValueNotifier; only the body branch is wrapped, the footer is untouched, and setState stays at the zero crossing where the shape genuinely changes.
The test asserts the LiveShell instance is identical across a tick, not just that the text moved — a text-only assertion passes on the broken version too. Verified it fails pre-change.

App icons decoded at source resolution. cacheWidth only; the source is a third-party launcher icon and need not be square.

DayBundleInput.fromJson unboxed every array the substrate deliberately packed. dbls returns a Float64List — copied, never aliased, so the synchronous test path cannot hand two repos a shared mutable array. The ints/strs fast paths were skipped on purpose: Smis and Strings box nothing per element, and a strs fast path would hand out a const [] where a growable list goes out today.
Worth a reviewer's eye: Float64List is fixed-length where .toList() was growable. Nothing mutates these and all six deriveDayBundle test callers exercise the typed lists through toJson/fromJson, so if something downstream ever starts growing a caller-owned list it throws rather than corrupts — loud, not silent.

housekeeping

One commit (drop the spo2 diagnostic logger…) also contains the Float64List change; the message only describes the first. The no-amend rule caught it after the fact. Diff is correct, message is incomplete.

DerivationEngine.runDays now has zero production callers — deleting reanalyzeDays took the last one, and only derive_result_protection_test.dart calls it. Left alone deliberately; that is a decision, not a reflex delete.

2795 tests pass, 422 golden skips, analyze clean.


PR Type

Bug fix, Enhancement


Description

  • Remove sleepSpo2Red/sleepSpo2Ir fields and ~90-line SpO2 diagnostic logger that ran per-day on the calling isolate, burning CPU on permanently-refused data

  • Eliminate dbCounts field and all 13 LocalDb.counts() calls; fix sleep-override/nap re-derive not refreshing screens by calling bumpInsights() instead

  • Stop double-inflating gzip backups in importEdgeBackup; importFromDbFile already handles it with trailer validation

  • Convert strength-workout rest countdown from setState field to ValueNotifier, preventing full LiveShell rebuilds on every tick; fix yoga hold timer similarly


Diagram Walkthrough

flowchart LR
  A["BLE ingest\n(ble_engine.dart)"] -- "passes revision int\n(not raw hex)" --> B["BurstStats.onHistoricalData"]
  C["importEdgeBackup\n(app_state.dart)"] -- "removed hand-rolled\ngzip inflate" --> D["LocalDb.importFromDbFile\n(handles gzip internally)"]
  E["_reanalyzeForOverride\n_reanalyzeDays\nstopWorkout"] -- "replaced _bumpInsightsRevision\nalias" --> F["bumpInsights()"]
  G["DayBundleInput"] -- "removed sleepSpo2Red\nsleepSpo2Ir fields" --> H["deriveDayBundle\n(isolate boundary)"]
  I["_LiveStrengthState\nrestLeft field + setState"] -- "converted to" --> J["ValueNotifier<int>\n+ ValueListenableBuilder"]
Loading

File Walkthrough

Relevant files
Enhancement
3 files
ble_engine.dart
Pass revision int to BurstStats, drop hex re-parse             
+11/-12 
onehz_pipeline.dart
Drop sleepSpo2Red/Ir fields; optimize dbls() with Float64List
+16/-13 
band_notifications.dart
Decode notification app icons at display size only             
+9/-1     
Bug fix
3 files
derivation_engine.dart
Remove SpO2 diagnostic logger and spo2Red/spo2Ir inputs   
+0/-83   
app_state.dart
Remove dbCounts, fix override re-derive screen refresh, drop
double-gzip
+14/-88 
live.dart
Rest countdown to ValueNotifier; fix yoga hold timer rebuild
+39/-15 
Tests
7 files
ble_safe_trim_test.dart
Update onHistoricalRecord calls to pass revision int         
+16/-15 
daily_energy_consistency_test.dart
Remove sleepSpo2Red/Ir from DayBundleInput test fixture   
+0/-2     
derivation_pipeline_test.dart
Remove spo2 arrays from pipeline test fixtures                     
+1/-9     
hr_ceiling_zones_test.dart
Remove sleepSpo2Red/Ir from HR ceiling zones test fixture
+0/-2     
resting_hr_nocturnal_only_test.dart
Remove sleepSpo2Red/Ir from resting HR test fixture           
+0/-2     
strain_resting_hr_source_test.dart
Remove sleepSpo2Red/Ir from strain/RHR test fixture           
+0/-2     
ui2_activity_test.dart
Add test: rest countdown does not rebuild LiveShell           
+30/-0   
Miscellaneous
1 files
derive_probe.dart
Remove sleepSpo2Red/Ir from derive probe tool                       
+0/-2     

overrides rewrote day_result and nothing told the screens, so an edit only
showed up after a restart. also dropped reanalyzeDays and the
_bumpInsightsRevision alias — no callers.
importFromDbFile already sniffs and inflates, and unlike gzip.decoder it
checks the trailer — the hand-rolled block would restore a truncated
backup short and call it a success.
the notification-relay list decodes every app icon at whatever the launcher
shipped — up to 512 px — to paint a 32 pt row. cacheWidth only: these are
third-party icons and not all of them are square, so pinning both dimensions
would squash them.

same reasoning as _IconChoice in settings.dart.
BurstStats.onHistoricalData took the record hex and ran the whole thing back
through hexToBytes just to reach inner[1] — the revision — which the ingest
path had already read as recType two hundred lines earlier. one throwaway
buffer per record, on every record of every offload: roughly 6 MB of garbage
for a full gen4 backfill.

pass the revision, drop the Sample the function never looked at.
restLeft was a field behind setState, so each of the ninety ticks between sets
rebuilt _LiveStrengthState and with it a fresh LiveShell — header, transport,
footer, body — in the file that added LiveTick to stop exactly this. a session
with twenty sets does it well over a thousand times.

it's a ValueNotifier now, with only the rest ring listening. setState stays at
the zero crossing, where the footer and the body branch genuinely change.

the yoga hold timer had the same wrapper for nothing — that body already
rebuilds at 1 Hz through the shell clock, so the setState just bought a second
rebuild of the same subtree. deleted.

test pumps a set, ticks a second, and checks the LiveShell instance is the
same one while the countdown moved. fails on the old code.
13 sites each ran a full-table COUNT(*) over every table just to feed one
'raw=' in the session-start log. counts() stays, two tests use it.
spo2 is refused permanently, so the logger was printing compile-time
constants — 14 passes over the sleep arrays on the calling isolate,
unconditionally, for every derived day. sleepSpo2Red/Ir went with it:
deriveDayBundle never read them, they were only serialized and copied
across the isolate boundary to be ignored. substrate keeps its raw
channels, and the refusal metric is untouched.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

stacked on #261 so it targets that branch rather than main — review it anyway please. everything here is meant to be output-identical, so anything that looks like it changes a number is a real finding.

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@abdulsaheel, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 51 minutes

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d6fcfc09-cfd2-4352-9894-3e016f7829dd

📥 Commits

Reviewing files that changed from the base of the PR and between f36c0b6 and 8ae112e.

⛔ Files ignored due to path filters (7)
  • test/ble_safe_trim_test.dart is excluded by !test/**
  • test/daily_energy_consistency_test.dart is excluded by !test/**
  • test/derivation_pipeline_test.dart is excluded by !test/**
  • test/hr_ceiling_zones_test.dart is excluded by !test/**
  • test/resting_hr_nocturnal_only_test.dart is excluded by !test/**
  • test/strain_resting_hr_source_test.dart is excluded by !test/**
  • test/ui2_activity_test.dart is excluded by !test/**
📒 Files selected for processing (7)
  • lib/ble/ble_engine.dart
  • lib/compute/derivation_engine.dart
  • lib/compute/onehz_pipeline.dart
  • lib/state/app_state.dart
  • lib/ui2/activity/live.dart
  • lib/ui2/profile/band_notifications.dart
  • tool/derive_probe.dart

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

@abdulsaheel I will review #263 against its stacked base, #261. I will treat any change to derived output as a finding. I will also check the relevant analytics-version and recomputation invariants from the repository guidance.


🧠 Learnings used
Learnt from: CR
Repo: OpenStrap/edge PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-07-26T08:43:35.363Z
Learning: Applies to lib/compute/derivation_engine.dart : Bump kAlgoVersion and add a changelog entry whenever analytics output changes, including changes caused by a sibling analytics re-pin.

Learnt from: CR
Repo: OpenStrap/edge PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-07-26T08:43:35.363Z
Learning: Applies to lib/**/*.{dart} : Recomputation must be idempotent: repeated derivation with additional data must not duplicate baseline entries, drift persisted scalars, or append where replacement is required; use trailingSeriesValues for trailing windows.
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@github-actions

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Import deletion

The gzip inflation block that handled .db.gz auto-backups was deleted and replaced with a comment claiming importFromDbFile now handles it internally. If LocalDb.importFromDbFile does NOT actually inflate gzip internally (i.e., the comment is aspirational rather than descriptive of the current implementation), then restoring from the app's own automatic backup — the most critical import path — silently fails or errors. The PR description says the inflate was moved inside importFromDbFile, but this cannot be verified from the diff alone. If that move did not happen, this is a data-loss regression on the primary restore path.

Future<int> importEdgeBackup(String path) async {
  importRollupError = null;
  // Gzipped auto-backups (`.db.gz`) are inflated INSIDE importFromDbFile —
  // do not add it back here. Its inflate checks the gzip trailer, so a
  // truncated backup fails loudly; `gzip.decoder` returns partial output
  // without raising and would restore short while reporting success.
  final counts = await LocalDb.importFromDbFile(path);
  // Imported rows include derived day_result/metric_series → refresh rollups.
  try {
    await _derive.finalizeImport(_profile);
  } catch (e) {
    importRollupError = '$e';
  }
Stale restLeft read

In _rest_() (the ring painter and countdown text), restLeft.value is read directly rather than inside a ValueListenableBuilder. The ValueListenableBuilder wrapping _rest_() is placed in the body column, but the footer lambda reads restLeft.value at build time without subscribing. When the footer is rebuilt by the shell's own clock (once per second), restLeft.value will be current, so the footer toggle works. However, the Ring painter and the countdown Text inside _rest_() are only rebuilt when the ValueListenableBuilder fires — but _rest_() itself is called from inside that builder, so this is fine. The real concern is the footer lambda: restLeft.value > 0 is evaluated at the shell's build time, not reactively. If the shell does not rebuild at the zero crossing, the footer stays showing "+30s / Skip rest" after rest ends. The setState(() {}) at zero crossing is the only mechanism that triggers a shell rebuild — confirm it reaches the footer's parent.

footer: (ctx) => restLeft.value > 0
    ? Row(children: [
        Expanded(
          child: BigButton('+30s',
              color: C.teal,
              soft: true,
              // No `setState`: nothing on the shell changes shape while
              // the countdown stays above zero, and the ring listens.
              onTap: () => restLeft.value += 30),
        ),
        const SizedBox(width: S.x3),
        Expanded(
          child: BigButton('Skip rest',
              icon: LucideIcons.skipForward,
              color: C.teal,
              onTap: () {
                _rest?.cancel();
                restLeft.value = 0;
                setState(() {});
              }),
        ),
      ])
    : BigButton('Log set',
dbls returns Float64List

dbls() now returns Float64List (a typed list) but the field declarations for dayRrTsMs, dayRrMs, sleepRrTsMs, sleepRrMs are typed as List<double>. Float64List implements List<double> so this compiles, but callers that do is List<double> checks or pass these to analytics code expecting a growable list may get unexpected behavior. More concretely, the comment says "always a COPY, never an alias" but if (v is List<double>) return Float64List.fromList(v) — when v IS already a List<double> (e.g. a plain List<double> from the in-test synchronous path), this returns a copy, which is correct. However when v is a Float64List (which IS a List<double>), it also copies — that's fine. The logic is correct but the is List<double> branch fires for both plain lists and typed lists, making the fast path (skip element-by-element conversion) unreachable for the common isolate case where the boundary hands back a List<dynamic>. This is a minor performance issue, not a correctness bug.

List<double> dbls(String k) {
  final v = (m[k] as List?) ?? const [];
  if (v is List<double>) return Float64List.fromList(v);
  final out = Float64List(v.length);
  for (var i = 0; i < v.length; i++) {
    out[i] = (v[i] as num).toDouble();
  }
  return out;
}

@github-actions

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Plain field mutation without setState freezes UI

Writing hold directly without setState means the widget tree never rebuilds to
reflect the updated value. Unlike restLeft, which is now a ValueNotifier consumed by
a ValueListenableBuilder, hold is a plain int field with no listener mechanism, so
the UI will freeze at its initial value. Either convert hold to a ValueNotifier and
wrap its consumer in a ValueListenableBuilder, or keep the setState call here.

lib/ui2/activity/live.dart [1811-1814]

 _hold = Timer.periodic(Motion.tick, (_) {
   if (!mounted) return;
-  hold = hold > 0 ? hold - 1 : 30;
+  setState(() => hold = hold > 0 ? hold - 1 : 30);
 });
Suggestion importance[1-10]: 7

__

Why: The comment in the PR explicitly says "No setState" for hold because the shell's clock already rebuilds the subtree once a second. However, if hold is a plain int field and the shell's clock rebuild is what drives the UI update, this is an intentional design choice. The suggestion raises a valid concern if hold is not consumed within a widget that rebuilds via the shell clock, but the PR's comment suggests this is deliberate. The score reflects that this could be a real bug depending on how hold is consumed.

Medium
Stale context used after async gap without mounted guard

say(context, 'Rest over') is called after setState(() {}) inside a Timer callback,
but context is used without a mounted guard at that point. The mounted check at the
top of the callback only guards restLeft.value--; if the widget is disposed between
the mounted check and the say() call, this will use a stale context. Move the
mounted check to wrap the entire body, or add a second mounted check before say().

lib/ui2/activity/live.dart [1182-1193]

 _rest = Timer.periodic(Motion.tick, (t) {
   if (!mounted) return;
   restLeft.value--;
   if (restLeft.value <= 0) {
     t.cancel();
-    // The one tick the shell has to see — see [restLeft].
+    if (!mounted) return;
     setState(() {});
     HapticFeedback.mediumImpact();
-    // A buzz is not a message. The rest-over moment was reachable only by
-    // feeling the watch, or by watching a number nobody was told to watch.
     say(context, 'Rest over');
   }
 });
Suggestion importance[1-10]: 6

__

Why: The suggestion correctly identifies that context is used after a potential async gap (the mounted check is at the top but the widget could be disposed between the check and the say(context, ...) call). Adding a second mounted guard before say() is a valid defensive practice, though the window is very small in a synchronous timer callback.

Low

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant