Skip to content

fix: sleep profile folds every derive pass instead of once per night - #174

Merged
abdulsaheel merged 7 commits into
mainfrom
fix/sleep-profile-fold
Aug 2, 2026
Merged

fix: sleep profile folds every derive pass instead of once per night#174
abdulsaheel merged 7 commits into
mainfrom
fix/sleep-profile-fold

Conversation

@abdulsaheel

@abdulsaheel abdulsaheel commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

User description

A real user export had "nights": 1348 in sleep_user_profile. They have 12 days of data.

The EWMA fold runs on every staging pass for a day — algo bumps, BLE drain re-derives, backfill sweeps — so the same handful of nights folded hundreds of times. Two things break:

  • personalWeight saturates at 28 nights, so it's pinned at the 0.5 cap from the first sweep
  • the EWMA (alpha ~2/15) collapses onto whichever day was re-derived last, not a representative fortnight

Replayed that profile over the same 11 nights. Worst night: wake 4.3% -> 36.4%, deep 1.9% -> 0.0%. So the personalization layer was re-creating the exact wake over-call cardioStager exists to avoid. Same bug class as #108, different table.

fix

  • fold at most once per day_id, tracked in the profile payload (no migration — baselines is free-form JSON)
  • don't apply the profile at all until 3 nights. van der Aar 2025: gains need >=3 nights and ~17.5% of subjects get worse from personalization, so a 1-2 night profile is pure downside
  • discard pre-tracking profiles. Can't repair them — there's no record of which days went in — so they rebuild from cold start, which is the per-night-local path the stager was validated on anyway

gotcha

Gating the profile to null below the floor would make every fold restart from empty and pin nights at 1 forever. The fold uses the loaded profile regardless; only staging is withheld. Nearly shipped that.

Logic went into SleepProfilePolicy (pure, 15 tests) instead of inline in the engine. One test simulates 12 days x 100 re-derives and asserts exactly 12 folds.

kAlgoVersion 51 -> 52 so days re-stage without the corrupt blend.

1073 tests green, analyze clean.

Separate from OpenStrap/analytics#34 which fixes the staging rules themselves — that one'll need another bump when we repin.

🤖 Generated with Claude Code


PR Type

Bug fix, Tests


Description

  • Fix sleep profile EWMA folding hundreds of times per night instead of once

    • Real user had nights: 1348 against only 12 days of data
    • Worst-case effect: wake 4.3%→36.4%, deep 1.9%→0.0% on a single night
  • Add per-day_id fold tracking in profile payload; discard legacy profiles lacking it

  • Gate profile influence on staging until ≥3 nights (van der Aar 2025 warm-up floor)

  • kAlgoVersion bumped 51→52 so all days re-stage without the corrupt blend


Diagram Walkthrough

flowchart LR
  A["BLE drain / algo bump\n/ backfill re-derive"] -- "every pass" --> B["_loadSleepUserProfileJson()"]
  B -- "usableProfileJson()\ndiscards legacy" --> C["SleepProfilePolicy"]
  C -- "foldedDays()" --> D["Set of already-folded day_ids"]
  D -- "shouldFold()" --> E{"day_id\nalready folded?"}
  E -- "yes → skip" --> F["No EWMA step\n(idempotent)"]
  E -- "no → fold once" --> G["base.fold(obs)\n+ withFoldedDays()"]
  G --> H["Persist updated profile\nwith folded_days stamp"]
  C -- "shouldBlend(nights)" --> I{"nights >= 3?"}
  I -- "no" --> J["cardioUserProfile = null\n(cold-start staging)"]
  I -- "yes" --> K["cardioUserProfile = loaded\n(personalized staging)"]
Loading

File Walkthrough

Relevant files
Bug fix
sleep_profile_policy.dart
New pure SleepProfilePolicy enforcing fold idempotency and warm-up
gate

lib/compute/sleep_profile_policy.dart

  • New pure-policy class SleepProfilePolicy with no Flutter/DB
    dependencies
  • shouldFold() gates folding to once per day_id using a folded_days set
    in the payload
  • shouldBlend() withholds the profile from staging until ≥3 nights are
    accumulated
  • usableProfileJson() / isLegacy() discard pre-tracking profiles that
    lack folded_days
  • appendFoldedDay() / withFoldedDays() maintain the sorted, capped
    (400-entry) fold ledger
+130/-0 
derivation_engine.dart
Wire SleepProfilePolicy into derivation engine; bump kAlgoVersion to
52

lib/compute/derivation_engine.dart

  • kAlgoVersion bumped from 51 to 52 with detailed changelog entry
  • _loadSleepUserProfileJson() now delegates to
    SleepProfilePolicy.usableProfileJson(), discarding legacy profiles
  • Staging worker now separates loaded profile (used for folding) from
    cardioUserProfile (gated by shouldBlend)
  • Fold is now guarded by mayFold (SleepProfilePolicy.shouldFold());
    folded result stamped with withFoldedDays()
+63/-11 
Tests
sleep_profile_policy_test.dart
15 unit tests for SleepProfilePolicy covering all policy branches

test/sleep_profile_policy_test.dart

  • 15 new unit tests covering fold idempotency, minimum-nights gate,
    legacy-payload discard, and bookkeeping
  • Key regression test simulates 12 days × 100 re-derives and asserts
    exactly 12 folds occur
  • Tests cover cap eviction, JSON round-trip, override suppression, and
    null/corrupt payload handling
+164/-0 

Summary by CodeRabbit

  • Bug Fixes
    • Prevented duplicate daily sleep-profile processing.
    • Sleep profiles now build data during the first three nights before personalization begins.
    • Added safeguards for legacy, malformed, overridden, and previously processed sleep data.
    • Improved reliability when simultaneous updates occur, preserving profile changes and recovering safely from errors.
  • Tests
    • Added comprehensive coverage for profile folding, validation, concurrent updates, data preservation, and recovery scenarios.

Real user export had "nights": 1348 against 12 days of data. The EWMA fold
runs on every staging pass for a day — algo bumps, BLE drain re-derives,
backfill sweeps — so the same handful of nights folded hundreds of times.

Two things break. personalWeight saturates at 28 nights so it's pinned at the
0.5 cap from the first sweep, and the EWMA (alpha ~2/15) collapses onto
whichever day happened to be re-derived last instead of a representative
fortnight.

Replayed that profile over the same 11 nights: wake 4.3% -> 36.4%, deep
1.9% -> 0.0% on the worst night. So the personalization layer was
re-manufacturing the exact wake over-call cardioStager exists to avoid.

Fixes:
- fold at most once per day_id, tracked in the profile payload
- don't apply the profile at all until 3 nights (van der Aar 2025 — gains need
  >=3 nights and ~17.5% of subjects get WORSE from personalization, so a
  1-2 night profile is downside with no edge)
- discard pre-tracking profiles. Can't repair them, there's no record of what
  went in, so they rebuild from cold start

Watch out: gating the profile to null below the floor would make every fold
restart from empty and pin nights at 1 forever. The fold uses the loaded
profile regardless, only staging is withheld.

Logic is in SleepProfilePolicy (pure, 15 tests) rather than inline in the
engine. kAlgoVersion 51 -> 52.

1073 tests green.
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d680f342-5f6f-4b2a-9adb-8b221d3e37fe

📥 Commits

Reviewing files that changed from the base of the PR and between 365c6f8 and 7d9c8e4.

📒 Files selected for processing (4)
  • lib/compute/derivation_engine.dart
  • lib/compute/sleep_profile_policy.dart
  • test/db_update_baseline_test.dart
  • test/sleep_profile_policy_test.dart
📝 Walkthrough

Walkthrough

The sleep-profile lifecycle now validates persisted payloads through SleepProfilePolicy, accumulates during a three-night warm-up, folds each day at most once, tracks bounded folded-day history, serializes profile updates, and discards legacy or invalid profiles.

Changes

Sleep profile lifecycle

Layer / File(s) Summary
Sleep profile policy contract
lib/compute/sleep_profile_policy.dart
Adds payload validation, legacy-profile handling, three-night blending rules, fold eligibility checks, bounded folded-day history, and defensive JSON decoding.
Serialized baseline updates
lib/data/db.dart, test/db_update_baseline_test.dart
Adds transactional read-modify-write updates and tests concurrent accumulation, set union, legacy rebuilding, and rollback behavior.
Staging and folding integration
lib/compute/derivation_engine.dart
Applies policy checks, returns raw observations from the worker, serializes folding, rechecks eligibility, records folded days, and bumps kAlgoVersion to 52.
Lifecycle regression coverage
test/sleep_profile_policy_test.dart
Tests idempotent folding, warm-up thresholds, invalid and legacy payloads, serialized updates, history management, field preservation, and JSON persistence.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DerivationEngine
  participant SleepProfilePolicy
  participant LocalDb
  DerivationEngine->>SleepProfilePolicy: Validate profile and check fold eligibility
  SleepProfilePolicy-->>DerivationEngine: Return usable profile and fold decision
  DerivationEngine->>LocalDb: updateBaseline with fold transformation
  LocalDb->>DerivationEngine: Read latest profile inside exclusive transaction
  DerivationEngine->>SleepProfilePolicy: Recheck day eligibility
  SleepProfilePolicy-->>DerivationEngine: Return updated fold metadata
  DerivationEngine->>LocalDb: Persist folded observation and folded day
Loading

Possibly related PRs

  • OpenStrap/edge#81: Both changes modify sleep-profile staging and folding in derivation_engine.dart.
  • OpenStrap/edge#101: Both changes modify sleep-profile staging and baseline persistence in derivation_engine.dart and lib/data/db.dart.
  • OpenStrap/edge#108: Both changes modify derivation_engine.dart and lib/data/db.dart, but address different baseline behaviors.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary fix: preventing sleep-profile folding on every derivation pass.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 7d9c8e4)

Here are some key observations to aid the review process:

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

Fold skipped permanently on SQLITE_BUSY

When _foldObservationIntoProfile catches an exception (including SQLITE_BUSY from a concurrent exclusive transaction), the failure is swallowed and the day_id is never added to folded_days. The comment acknowledges this as a "known limitation" and says the fold never retries because the cached-candidate short-circuit fires on subsequent passes. This means a day that loses its fold due to lock contention — the exact scenario this PR is designed to handle — permanently contributes zero to the profile and is never retried. Under a backfill sweep with many concurrent days, contention is not rare; it is the expected case. The result is a profile that silently under-counts nights, which is a milder version of the original bug but in the opposite direction. The PR accepts this deliberately, but it is worth flagging because the "best-effort" framing understates the permanence: it is not "might miss once and retry later" but "misses forever for that day_id."

if (observationJson != null) {
  // BEST-EFFORT, and deliberately isolated from the day's success path.
  // The fold is bookkeeping; the day's real result is already persisted
  // above. `updateBaseline` takes an exclusive SQLite write lock, and the
  // whole point of this change is that two derivation isolates contend
  // for it — so SQLITE_BUSY here is an EXPECTED outcome, not an
  // exceptional one. Letting it escape would hit processDay's broad
  // catch, which calls `_markDaySkipped` and increments `failures`,
  // throwing away a fully computed day (and holding the timezone) over a
  // bookkeeping write.
  //
  // KNOWN LIMITATION — a swallowed failure here is PERMANENT for this
  // day, not retried. Once the day finalizes, the cached-candidate
  // short-circuit at the top of this method returns before staging runs,
  // so `observationJson` is never regenerated and the fold never happens.
  // Same for a day whose override is later removed if it already has a
  // cached candidate from before the override.
  //
  // Accepted deliberately rather than fixed: the profile is an EWMA with
  // a ~14-night horizon and a hard 0.5 blend cap, so one missing night is
  // a small perturbation, whereas a retry path needs durable pending
  // state and a way to distinguish "failed, retry" from "declined
  // permanently" (a <120-epoch nap never folds, and would otherwise
  // bypass the candidate cache and re-stage on every sweep forever).
  // If the fold ever stops being best-effort, that state machine is the
  // thing to build — do not simply bypass the cache.
  try {
    await _foldObservationIntoProfile(dayId, observationJson);
  } catch (e) {
    _log('sleep profile fold skipped for $dayId (day result kept, '
        'this night will not contribute to the profile): $e');
  }
Stale mayFold pre-check

mayFold is computed before the staging isolate runs, using the profile snapshot read at that moment. Inside the isolate, observationJson is only produced when mayFold is true. However, mayFold is based on the stale pre-staging foldedDays set. If a concurrent day folds the same dayId between the foldedDays read and the isolate completing, mayFold is still true, so observationJson is produced and _foldObservationIntoProfile is called. The re-check inside updateBaseline's transaction body correctly declines the second fold, so the double-fold is prevented. This is correct. However, the inverse is also possible: mayFold is false (the stale snapshot already contains this dayId), so observationJson is never produced, but the actual committed row might not contain this dayId yet (e.g., the earlier fold failed with SQLITE_BUSY and was swallowed). In that case the fold is skipped based on a stale read and the permanent-miss scenario above is triggered even without a true concurrent fold. This is a narrow race but real under backfill.

final foldedDays = SleepProfilePolicy.foldedDays(profileJson);
final mayFold = SleepProfilePolicy.shouldFold(
  alreadyFolded: foldedDays,
  dayId: dayId,
  hasOverride: override != null,
);
shouldBlend uses nights from corrupt profile

shouldBlend receives p?.nights where p is reconstructed from usableProfileJson-filtered JSON. However, SleepUserProfile.fromJson (analytics package) controls what nights means, and the nights field in the stored JSON is the analytics package's own count — incremented by fold() — not the folded_days list length. After the legacy-discard transition, a profile rebuilt from cold start will have nights from fold() calls (one per _foldObservationIntoProfile success), which should stay in sync with folded_days.length. But if folds are permanently missed (see issue 1), nights (from fold()) and folded_days.length diverge: nights could be below minNightsForBlend while folded_days has 3+ entries, or vice versa. The warm-up gate uses nights (the analytics count), so a user who had 3 successful folds but one was missed could be held below the gate longer than intended. Low severity but worth noting given the acknowledged permanent-miss scenario.

/// Whether a profile with [nights] folded nights may influence staging.
static bool shouldBlend(int? nights) =>
    nights != null && nights >= minNightsForBlend;

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 7d9c8e4

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
General
Warm-up gate driven by tracked distinct nights, not EWMA counter

shouldBlend uses p?.nights from the profile object, but nights in the profile is the
count of EWMA folds, not the count of entries in folded_days. After the fix, a
legacy profile is discarded and rebuilt from zero, so nights restarts at 0 and
folded_days.length is the authoritative count of distinct nights actually folded.
Using nights for the warm-up gate means the gate is still driven by the EWMA
counter, which can diverge from folded_days.length if any fold is skipped (e.g.
best-effort failure, nap filtered out). The gate should be driven by
alreadyFolded.length (the set size) rather than the EWMA counter so it reflects
exactly how many distinct nights have contributed.

lib/compute/sleep_profile_policy.dart [93-94]

 static bool shouldBlend(int? nights) =>
     nights != null && nights >= minNightsForBlend;
 
+/// Whether a profile with [foldedCount] tracked distinct nights may influence staging.
+static bool shouldBlendByCount(int foldedCount) =>
+    foldedCount >= minNightsForBlend;
+
Suggestion importance[1-10]: 3

__

Why: The suggestion raises a valid conceptual point about nights vs folded_days.length diverging, but the improved_code doesn't actually change shouldBlend — it just adds a new method alongside it. The existing code is not modified, and the caller in derivation_engine.dart uses p?.nights which comes from the EWMA profile object, making this a design-level concern rather than a clear bug fix. The impact is marginal given the EWMA counter and folded set should stay in sync under normal operation.

Low
Ensure exclusive write lock is released on transform exception

If transform throws, the exception propagates out of the transaction callback. The
sqflite transaction wrapper will roll back the DB write (correct), but the exception
then surfaces to the caller. In _foldObservationIntoProfile this is caught and
logged, so the day result is preserved. However, the ub_throw test asserts the
connection remains usable after a throwing transform — this relies on sqflite's
internal error handling. More critically, if transform throws AFTER txn.insert has
been called (e.g. a second insert in a future extension), the rollback is correct
but the exception still escapes. The current code is safe for the single-insert
case, but the exclusive: true lock is held for the duration of the async callback
including any throw — confirm sqflite releases the write lock on exception, as a
stuck lock would wedge all subsequent baseline writes across isolates.

lib/data/db.dart [3919-3936]

 await db.transaction((txn) async {
   final rows = await txn.query(
     'baselines',
     columns: ['payload_json'],
     where: 'key = ?',
     whereArgs: [key],
     limit: 1,
   );
   final current =
       rows.isEmpty ? null : rows.first['payload_json'] as String?;
-  final next = transform(current);
+  final String? next;
+  try {
+    next = transform(current);
+  } catch (_) {
+    rethrow; // sqflite rolls back; lock is released by the framework
+  }
   if (next == null) return;
   await txn.insert('baselines', {
     'key': key,
     'payload_json': next,
     'updated_at': DateTime.now().millisecondsSinceEpoch,
   }, conflictAlgorithm: ConflictAlgorithm.replace);
 }, exclusive: true);
Suggestion importance[1-10]: 2

__

Why: The concern about the write lock being released on exception is valid to raise, but the improved_code adds a try/catch that immediately rethrows — this is semantically identical to not having the try/catch at all. The sqflite framework handles transaction rollback and lock release on exception regardless, so the change provides no actual improvement and the suggestion is essentially asking the author to verify framework behavior.

Low
Clarify pre-staging fold check is an optimisation, not the authority

mayFold is computed from foldedDays read BEFORE the staging isolate runs, then
passed into the isolate closure. However, the actual fold decision inside
_foldObservationIntoProfile correctly re-reads and re-checks inside the DB
transaction. The problem is that when mayFold is false here (because foldedDays
already contains dayId), observationJson is never produced inside the isolate, so
_foldObservationIntoProfile is never called at all — the re-check inside the
transaction is never reached. This is correct for the idempotency goal, but it means
the pre-staging foldedDays read is the sole gate, and a stale read (e.g. the row was
just written by a concurrent lane that hasn't been observed yet) could suppress a
legitimate first fold. The pre-check should only be used to skip producing
observationJson when the day is already known-folded; the authoritative decision
remains inside the transaction. This is already the design intent per the comments,
so no change is needed — but the mayFold variable name and its use as the sole guard
inside the isolate should be verified to match the transaction re-check path.

lib/compute/derivation_engine.dart [1180-1184]

+// Pre-check: skip producing the observation if we already know this day
+// is folded. The authoritative idempotency check is inside
+// _foldObservationIntoProfile's exclusive transaction; this is only an
+// optimisation to avoid unnecessary staging work.
 final mayFold = SleepProfilePolicy.shouldFold(
   alreadyFolded: foldedDays,
   dayId: dayId,
   hasOverride: override != null,
 );
Suggestion importance[1-10]: 1

__

Why: The suggestion only asks to verify/clarify intent via a comment change, and the improved_code is functionally identical to the existing_code — it just adds a comment. This is a documentation-only change with no correctness impact.

Low

Previous suggestions

Suggestions up to commit e029dfd
CategorySuggestion                                                                                                                                    Impact
Possible issue
Invalid null-aware map entry syntax breaks test compilation

The ?foldedDays syntax inside a map literal is not valid Dart — a null-aware spread
(...?) works on iterables but ?value is not a map-entry expression. When foldedDays
is null (the legacy-payload tests), this will either fail to compile or produce an
unexpected entry, breaking the tests that rely on the key being absent to detect a
legacy payload. Replace with an explicit conditional spread or an if entry.

test/sleep_profile_policy_test.dart [15-19]

 String _payload({List<String>? foldedDays, int nights = 0}) => jsonEncode({
       'nights': nights,
       'hr_sleep_median': 52.5,
-      SleepProfilePolicy.foldedDaysKey: ?foldedDays,
+      if (foldedDays != null) SleepProfilePolicy.foldedDaysKey: foldedDays,
     });
Suggestion importance[1-10]: 9

__

Why: The ?foldedDays syntax used as a map value (SleepProfilePolicy.foldedDaysKey: ?foldedDays) is not valid Dart syntax. The correct approach is to use a conditional map entry (if (foldedDays != null) key: value). This would cause a compilation error, breaking all tests in the file, including the critical legacy-payload tests that depend on the key being absent when foldedDays is null.

High
Pre-staging fold gate causes permanent missed folds

mayFold is computed from a stale pre-staging read of the profile and then passed
into the worker isolate, where it gates whether observationJson is produced at all.
If the profile is re-read inside _foldObservationIntoProfile and shouldFold returns
false there, no harm is done — but if mayFold is false here (because another lane
folded this day between the profile load and now), the observation is never
generated and the fold is permanently skipped for this day even though the DB check
inside the transaction would have caught it. The pre-staging mayFold check is
redundant with the re-check inside _foldObservationIntoProfile and introduces a
false-negative: remove it and always produce observationJson when override == null,
letting the authoritative DB-level re-check in _foldObservationIntoProfile decide.

lib/compute/derivation_engine.dart [1180-1184]

-final mayFold = SleepProfilePolicy.shouldFold(
-  alreadyFolded: foldedDays,
-  dayId: dayId,
-  hasOverride: override != null,
-);
+// The authoritative idempotency check happens inside _foldObservationIntoProfile,
+// which re-reads the profile under the exclusive SQLite write lock. Computing
+// mayFold here from a stale pre-staging snapshot can suppress observationJson
+// permanently for a day that was folded by a concurrent lane between the
+// profile load and now. Always produce the observation when override == null
+// and let the DB-level re-check decide.
Suggestion importance[1-10]: 5

__

Why: The suggestion raises a valid concern about mayFold being computed from a stale pre-staging snapshot, potentially causing permanent missed folds. However, the code already has a re-check inside _foldObservationIntoProfile under the exclusive SQLite lock, and the pre-staging check is an optimization to avoid unnecessary work. The race window is narrow and the consequence (one missed fold) is explicitly acknowledged as acceptable in the code comments. The improved_code doesn't actually show replacement code, just a comment block, making it incomplete.

Low
Suggestions up to commit 365c6f8
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix invalid conditional map-entry syntax

The ?foldedDays syntax in a map literal is not valid Dart — it will fail to compile.
To conditionally include the key only when foldedDays is non-null (which is what the
test needs to simulate legacy payloads that lack the key), use a spread with an if
expression or build the map imperatively.

lib/compute/sleep_profile_policy.dart [15-19]

-String _payload({List<String>? foldedDays, int nights = 0}) => jsonEncode({
-      'nights': nights,
-      'hr_sleep_median': 52.5,
-      SleepProfilePolicy.foldedDaysKey: ?foldedDays,
-    });
+String _payload({List<String>? foldedDays, int nights = 0}) {
+  final map = <String, dynamic>{
+    'nights': nights,
+    'hr_sleep_median': 52.5,
+  };
+  if (foldedDays != null) {
+    map[SleepProfilePolicy.foldedDaysKey] = foldedDays;
+  }
+  return jsonEncode(map);
+}
Suggestion importance[1-10]: 9

__

Why: The ?foldedDays syntax inside a map literal is not valid Dart and will cause a compile error. The fix correctly uses an imperative approach to conditionally include the foldedDaysKey only when foldedDays is non-null, which is essential for the test to simulate legacy payloads lacking that key.

High
General
Ensure transform exceptions cleanly exit the transaction

If transform throws, the exception propagates out of the transaction callback and
sqflite rolls back the transaction — but the exception then surfaces to the caller
of updateBaseline. The call site in _foldObservationIntoProfile wraps the whole call
in a try/catch and swallows it, which is correct for the fold path. However, the
test 'a throwing transform rolls back and leaves the row intact' asserts that
updateBaseline itself rethrows the error (throwsStateError), and then immediately
calls updateBaseline again to confirm the connection is still usable. This is
consistent, but the transform invocation is outside any inner guard — if sqflite's
transaction machinery does not cleanly rethrow after rollback on all platforms
(particularly iOS WAL mode), the connection can be left in a bad state. Wrapping the
transform call so the exception is captured before the transaction body exits
ensures the rollback completes before the error propagates.

lib/data/db.dart [3919-3936]

 await db.transaction((txn) async {
   final rows = await txn.query(
     'baselines',
     columns: ['payload_json'],
     where: 'key = ?',
     whereArgs: [key],
     limit: 1,
   );
   final current =
       rows.isEmpty ? null : rows.first['payload_json'] as String?;
-  final next = transform(current);
+  final String? next;
+  try {
+    next = transform(current);
+  } catch (_) {
+    rethrow; // sqflite rolls back the transaction, then we propagate
+  }
   if (next == null) return;
   await txn.insert('baselines', {
     'key': key,
     'payload_json': next,
     'updated_at': DateTime.now().millisecondsSinceEpoch,
   }, conflictAlgorithm: ConflictAlgorithm.replace);
 }, exclusive: true);
Suggestion importance[1-10]: 2

__

Why: The improved_code adds a try/catch that immediately rethrows, which is functionally identical to not having the try/catch at all — sqflite already rolls back on exception propagation. This adds no meaningful behavior change and the suggestion's concern about platform-specific WAL issues is speculative.

Low
Suggestions up to commit 45f0939
CategorySuggestion                                                                                                                                    Impact
Possible issue
Invalid null-conditional map entry syntax in test helper

The spread-null syntax ?foldedDays inside a map literal is not valid Dart — Dart map
literals do not support null-conditional value spreading. When foldedDays is null
(the default), this will either fail to compile or include a null value under
foldedDaysKey, which would make isLegacy return false incorrectly (since
m[foldedDaysKey] is! List would be true for null). The intent is to omit the key
entirely when foldedDays is null, to simulate a legacy payload. Use a conditional
spread or build the map separately.

test/sleep_profile_policy_test.dart [15-19]

-String _payload({List<String>? foldedDays, int nights = 0}) => jsonEncode({
-      'nights': nights,
-      'hr_sleep_median': 52.5,
-      SleepProfilePolicy.foldedDaysKey: ?foldedDays,
-    });
+String _payload({List<String>? foldedDays, int nights = 0}) {
+  final m = <String, dynamic>{
+    'nights': nights,
+    'hr_sleep_median': 52.5,
+  };
+  if (foldedDays != null) m[SleepProfilePolicy.foldedDaysKey] = foldedDays;
+  return jsonEncode(m);
+}
Suggestion importance[1-10]: 8

__

Why: The ?foldedDays syntax inside a Dart map literal is not valid Dart syntax for conditionally omitting a key. When foldedDays is null, this would either fail to compile or insert a null value under foldedDaysKey, breaking the legacy-payload tests that rely on the key being absent. The improved_code correctly fixes this by conditionally adding the key only when non-null.

Medium
General
Use authoritative folded-day count for warm-up gate

shouldBlend gates staging on the profile's nights field, but nights is the count
from SleepUserProfile.fold() — which is the count of EWMA steps, not the count of
distinct days tracked in folded_days. After the fix, a legacy profile is discarded
and rebuilt, so nights restarts at 0 and correctly reflects the new tracking.
However, shouldBlend is called with p?.nights where p is the decoded
SleepUserProfile — if the profile was withheld by usableProfileJson (legacy), p is
null and nights is null, so shouldBlend correctly returns false. But if a tracked
profile has nights from the analytics object that diverges from folded_days.length
(e.g. due to a partial write), the warm-up gate could be bypassed. The guard should
use foldedDays.length (the authoritative count) rather than the EWMA object's nights
field for the blend decision.

lib/compute/sleep_profile_policy.dart [93-94]

 static bool shouldBlend(int? nights) =>
     nights != null && nights >= minNightsForBlend;
 
+/// Whether a profile whose [foldedDays] set has [foldedCount] entries
+/// may influence staging. Prefer this over [shouldBlend] when the
+/// folded-day set is already available — it uses the authoritative count.
+static bool shouldBlendFromFolded(int foldedCount) =>
+    foldedCount >= minNightsForBlend;
+
Suggestion importance[1-10]: 2

__

Why: The suggestion proposes adding a new method shouldBlendFromFolded but the improved_code is essentially the same as existing_code plus an addendum. The current design using p?.nights (the EWMA object's field) is intentional — nights in SleepUserProfile is the EWMA fold count, and after the fix legacy profiles are discarded so it restarts correctly. The divergence scenario described is theoretical and not demonstrated as a real bug in this PR's context.

Low
Suggestions up to commit d1ccf16
CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix invalid null-aware map value syntax in test helper

The ?foldedDays syntax is not valid Dart — a null-aware spread (...?foldedDays) is
the closest idiom but that is for iterables, not map values. The intent is to omit
the key entirely when foldedDays is null (to produce a legacy payload), but this
will cause a compile error. Use a conditional spread or an explicit if entry to
conditionally include the key.

test/sleep_profile_policy_test.dart [15-19]

 String _payload({List<String>? foldedDays, int nights = 0}) => jsonEncode({
       'nights': nights,
       'hr_sleep_median': 52.5,
-      SleepProfilePolicy.foldedDaysKey: ?foldedDays,
+      if (foldedDays != null) SleepProfilePolicy.foldedDaysKey: foldedDays,
     });
Suggestion importance[1-10]: 9

__

Why: The ?foldedDays syntax used as a map value is not valid Dart and will cause a compile error. The correct approach is to use a conditional map entry if (foldedDays != null) SleepProfilePolicy.foldedDaysKey: foldedDays to omit the key when foldedDays is null, which is exactly what the test needs to simulate legacy payloads.

High
General
Stale pre-staging fold check can silently drop a valid observation

mayFold is computed from the profile read before the staging isolate runs, but by
the time _foldObservationIntoProfile executes, a concurrent lane may have already
folded this dayId. The pre-staging mayFold check is therefore used to skip passing
observationJson back at all — meaning if a concurrent lane folds first and then this
lane's isolate finishes, the observation is silently dropped and never retried. The
_foldObservationIntoProfile method already re-checks shouldFold inside the
transaction, so the outer mayFold guard should only gate the expensive
takeCardioObservations work, not permanently suppress the fold attempt. Pass
observationJson back unconditionally and let the transaction body decide.

lib/compute/derivation_engine.dart [1180-1184]

-final mayFold = SleepProfilePolicy.shouldFold(
-  alreadyFolded: foldedDays,
-  dayId: dayId,
-  hasOverride: override != null,
-);
+// Always attempt to collect the observation; the transaction in
+// _foldObservationIntoProfile re-checks shouldFold against the committed
+// row and declines if this day was already folded by a concurrent lane.
+final mayFold = !hasOverride; // only skip for overrides, not stale pre-check
Suggestion importance[1-10]: 5

__

Why: The concern is valid — mayFold computed before staging could suppress an observation that should be folded. However, the improved_code references hasOverride which isn't directly available in that form, and the suggestion's fix is incomplete/inaccurate relative to the existing code structure. The transaction body already re-checks shouldFold, so the main risk is a missed fold in a concurrent scenario, which is a real but edge-case concern.

Low

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/compute/derivation_engine.dart (1)

1175-1263: 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Serialize the roll-in of sleep-profile folds.

runWithConcurrency launches up to maxForegroundConcurrency lanes concurrently and allows any lane to read/await/write next, while processDay reads sleep_user_profile, waits for an isolate, then replaces that shared baseline key. Under a multi-day sweep, two days can both read the same pre-write profile, decide to fold, and the later write clobbers the earlier fold. The only coverage added here tests SleepProfilePolicy idempotence in isolation, not this shared-key race.

Accumulate fold decisions during the concurrent run and merge/persist one sleep_user_profile update after the run completes, or otherwise make the folded-key update atomic and read-modify-write one fold at a time.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 1175 - 1263, Serialize
updates to the shared sleep_user_profile baseline in the processDay fold path so
concurrent days cannot overwrite each other’s folds. Preserve the existing
per-day idempotence checks in SleepProfilePolicy, but accumulate fold results
across the concurrent run and merge/persist them after the run, or use an atomic
read-modify-write mechanism that applies one fold at a time instead of
independently replacing the profile after _runIsolateCancellable.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 1175-1263: Serialize updates to the shared sleep_user_profile
baseline in the processDay fold path so concurrent days cannot overwrite each
other’s folds. Preserve the existing per-day idempotence checks in
SleepProfilePolicy, but accumulate fold results across the concurrent run and
merge/persist them after the run, or use an atomic read-modify-write mechanism
that applies one fold at a time instead of independently replacing the profile
after _runIsolateCancellable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c3ff2521-456f-44ca-b482-3e2e0cc0123e

📥 Commits

Reviewing files that changed from the base of the PR and between 3dd7623 and 7707bba.

📒 Files selected for processing (3)
  • lib/compute/derivation_engine.dart
  • lib/compute/sleep_profile_policy.dart
  • test/sleep_profile_policy_test.dart

CodeRabbit caught a real one. processDay runs up to _deriveConcurrency days at
once in the foreground, and each day read the profile, awaited a staging
isolate, then wrote it back. Two days read the same payload, both fold, later
write clobbers the earlier — losing the fold AND its day_id, so that day
re-folds next sweep and nights drifts up again. Same corruption the PR is
fixing, just slower.

Worker now returns the raw observation instead of a pre-folded profile, and the
fold happens on the main isolate under SleepProfilePolicy.withProfileLock,
which re-reads the profile and re-checks shouldFold inside the critical
section. Lock is only held across the DB read-modify-write, not the isolate, so
day concurrency is unaffected.

3 tests for it including the concurrent-clobber case. 18 policy tests, 1073
total.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Checked the one finding. It's real — fixed.

the race

Verified the path is genuinely concurrent: processDay is run through runWithConcurrency(orderedDays, _deriveConcurrency, ...) and DerivePacing.concurrency returns min(cores, maxForegroundConcurrency) in the foreground, so >1 on any real device.

So: two days read the same sleep_user_profile, both await their staging isolate, both decide to fold, later write clobbers the earlier one. Loses the fold and its day_id from folded_days — so that day re-folds on the next sweep and nights drifts up again. Which is the exact accounting corruption this PR is fixing, just at a slower rate. The idempotence tests didn't catch it because they test the policy in isolation, as you said.

fix

The worker now returns the raw observation instead of a pre-folded profile — folding in the isolate would bake in whatever profile it read before staging started, which is stale by definition. The fold moved to the main isolate inside SleepProfilePolicy.withProfileLock, which re-reads the profile and re-checks shouldFold inside the critical section.

Went with the atomic read-modify-write rather than accumulate-and-merge-after-the-run: the lock is only held across the DB read/write, never across the isolate, so day-level concurrency is untouched, and it doesn't require restructuring the sweep.

3 tests, including one that models N days folding concurrently and asserts every day_id survives, plus one that a throwing action releases the lock rather than deadlocking the rest of the sweep.

18 policy tests, 1073 total, analyze clean.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit a535b2d

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/compute/derivation_engine.dart (1)

1219-1219: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Rename the observation payload so the identifier matches its contents.

foldedJson and updatedProfileJson now carry a raw SleepNightObservation, not a folded profile. The names describe the previous contract. In a path whose whole purpose is to stop a stale profile from being written back, a name that still says "profile" invites exactly that mistake in a later edit. The parameter at line 1282 is already named observationJson, so the producer side is the only inconsistent part.

♻️ Proposed rename
-      String? foldedJson;
+      String? observationJson;
-            foldedJson = jsonEncode({
+            observationJson = jsonEncode({
-      return (jsonEncode(candidate.toJson()), foldedJson);
+      return (jsonEncode(candidate.toJson()), observationJson);
-    final (candidateJson, updatedProfileJson) =
+    final (candidateJson, observationJson) =
         await _runIsolateCancellable(() {
-      if (updatedProfileJson != null) {
-        await _foldObservationIntoProfile(dayId, updatedProfileJson);
+      if (observationJson != null) {
+        await _foldObservationIntoProfile(dayId, observationJson);
       }

Also applies to: 1240-1240, 1266-1268

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` at line 1219, Rename the observation
payload variables foldedJson and updatedProfileJson to observationJson
consistently throughout the relevant derivation flow, including their
declarations and uses near the producer logic. Preserve the raw
SleepNightObservation content and align the producer-side naming with the
existing observationJson parameter.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/compute/sleep_profile_policy.dart`:
- Around line 136-143: Replace the static withProfileLock/_lock serialization in
the derivation flow with database-backed coordination that works across
isolates. Ensure the read, fold, and write of the sleep_user_profile row occur
atomically inside a transaction or use a compare-and-set retry path, so
concurrent DerivationEngine passes preserve all folded_days entries.

In `@test/sleep_profile_policy_test.dart`:
- Around line 149-169: Update the test “a day already folded by another lane is
skipped, not double-counted” to start with an empty folded_days profile,
allowing both concurrent foldDay calls to initially observe the day as eligible.
Keep the concurrent Future.wait execution and assert that folds equals 1 after
both complete, so the test verifies withProfileLock prevents double-folding.

---

Outside diff comments:
In `@lib/compute/derivation_engine.dart`:
- Line 1219: Rename the observation payload variables foldedJson and
updatedProfileJson to observationJson consistently throughout the relevant
derivation flow, including their declarations and uses near the producer logic.
Preserve the raw SleepNightObservation content and align the producer-side
naming with the existing observationJson parameter.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 88af8b86-b2b7-4f26-8d35-41d0d00ce278

📥 Commits

Reviewing files that changed from the base of the PR and between 7707bba and a535b2d.

📒 Files selected for processing (3)
  • lib/compute/derivation_engine.dart
  • lib/compute/sleep_profile_policy.dart
  • test/sleep_profile_policy_test.dart

Comment thread lib/compute/sleep_profile_policy.dart Outdated
Comment thread test/sleep_profile_policy_test.dart Outdated
Two more from CodeRabbit, both right.

1. The Dart static lock I added doesn't serialize across ISOLATES. Verified:
   derivationDispatcher is a vm:entry-point WorkManager entry that builds its
   own DerivationEngine in a background isolate, so a static has one copy per
   isolate. A background heavy pass and a foreground sweep would each hold
   "the" lock and still clobber each other. Worse than nothing because it
   looked sufficient.

   Replaced with LocalDb.updateBaseline: read-modify-write inside ONE exclusive
   SQLite transaction. exclusive:true issues BEGIN IMMEDIATE so the write lock
   is taken up front rather than failing to upgrade under WAL. SQLite's lock is
   cross-connection, so it holds across isolates and processes.

2. My "already folded by another lane" test didn't test anything. It seeded the
   day into folded_days first, so both lanes skipped on their first read and
   folds==0 with or without the lock. Deleting the lock left it green.

   Replaced with tests that model the transaction body at commit time: two
   lanes folding the SAME day from an empty profile must commit exactly once,
   distinct days must all survive, a stale pre-staging read must not resurrect
   an already-folded day, and a legacy row rebuilds from 0 rather than 1348.

Also renamed foldedJson/updatedProfileJson to observationJson — they carry a
raw observation now, and a name saying "profile" in the one path built to avoid
writing a stale profile is asking for it.

1077 tests, analyze clean.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Both right. Second one especially — thanks for catching it.

the lock doesn't cross isolates

Verified before changing anything: derivationDispatcher is a @pragma('vm:entry-point') WorkManager entry that constructs its own DerivationEngine(background: true) and calls engine.run(...). That's a separate background isolate, and a Dart static has one copy per isolate. So a background heavy pass and a foreground sweep would each hold "the" lock and clobber each other anyway.

Worse than nothing, really — it looked sufficient. Removed it and left a comment saying why, so nobody re-adds one.

Replaced with LocalDb.updateBaseline: the whole read-modify-write happens inside one exclusive SQLite transaction. exclusive: true issues BEGIN IMMEDIATE so the write lock is taken up front rather than failing to upgrade under WAL when another connection holds it. SQLite's lock is cross-connection, so it holds across isolates and processes — which is the guarantee that was actually needed.

the vacuous test

You're right and it's a bit embarrassing. Seeding '2026-07-30' into folded_days meant both lanes skipped on their first read, so folds == 0 regardless. Deleting the lock left it green. It asserted nothing.

Rewrote that group to model the transaction body at commit time, which is what the exclusive write lock actually guarantees:

  • two lanes folding the same day from an empty profile commit exactly once
  • distinct days all survive, none lost
  • a stale pre-staging read cannot resurrect an already-folded day (asserts the stale view would wrongly permit it, and that deciding against the committed row declines) — that's the specific thing the re-check inside the transaction buys
  • a legacy row rebuilds from 0, not from 1348

rename

Done — foldedJson / updatedProfileJsonobservationJson. Fair point that a name saying "profile" in the one path built to avoid writing a stale profile is asking for trouble.

PR Agent's "lock not reset on throw"

Moot now, the lock is gone. For the record it did release correctly (.whenComplete), and there was a test for it — but the whole mechanism was the wrong one.

1077 tests, analyze clean.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d1ccf16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 1266-1268: Wrap the await of _foldObservationIntoProfile in the
observationJson branch within local error handling so any database or isolate
failure is contained and does not escape _sleepCandidateForDay or mark the day
as failed. Keep the persisted sleep-session candidate and normal day-success
path unchanged; the day should remain eligible for a later fold attempt.

In `@test/sleep_profile_policy_test.dart`:
- Around line 117-126: Add direct regression tests that invoke
LocalDb.updateBaseline using the existing sqflite_common_ffi harness. Verify the
transform receives null for an absent key, null results preserve an existing row
byte-for-byte, and non-null results replace payload_json while advancing
updated_at.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 05300a5b-56b6-4495-a87b-d2fb0004894b

📥 Commits

Reviewing files that changed from the base of the PR and between a535b2d and d1ccf16.

📒 Files selected for processing (4)
  • lib/compute/derivation_engine.dart
  • lib/compute/sleep_profile_policy.dart
  • lib/data/db.dart
  • test/sleep_profile_policy_test.dart

Comment thread lib/compute/derivation_engine.dart
Comment on lines +117 to +126
group('concurrent-fold semantics (DB transaction contract)', () {
// The real serialization is an exclusive SQLite transaction in
// LocalDb.updateBaseline — a Dart lock cannot span isolates. What is
// testable here without a DB is the PURE contract the transaction body
// relies on: given the payload as it exists at commit time, decide once.
//
// These model the transaction body running serially (which is what the
// exclusive write lock guarantees) and assert the outcome is correct for
// any interleaving.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find an existing sqflite test harness and any updateBaseline coverage.
set -euo pipefail

echo '--- existing sqflite/ffi test setup ---'
rg -n -C3 --type=dart 'sqfliteFfiInit|databaseFactoryFfi|inMemoryDatabasePath|sqflite_common_ffi' test lib || true

echo '--- ffi dev dependency ---'
rg -n -C2 'sqflite' pubspec.yaml || true

echo '--- existing references to updateBaseline ---'
rg -n -C3 --type=dart '\bupdateBaseline\s*\(' . || true

Repository: OpenStrap/edge

Length of output: 35925


🏁 Script executed:

#!/bin/bash
# Inspect the implementation and call points for updateBaseline without executing repository code.
set -euo pipefail

echo '--- updateBaseline implementation ---'
sed -n '3900,3935p' lib/data/db.dart

echo '--- updateBaseline call site context ---'
sed -n '1300,1330p' lib/compute/derivation_engine.dart

echo '--- tests mentioning updateBaseline ---'
rg -n --type=dart '\bupdateBaseline\s*\(' test || true

echo '--- tests mentioning foldObservationIntoProfile ---'
rg -n --type=dart '_foldObservationIntoProfile|foldObservationIntoProfile|folded_days|fold' test/sleep_profile_policy_test.dart || true

Repository: OpenStrap/edge

Length of output: 6840


Add direct regression tests for LocalDb.updateBaseline.

test/sleep_profile_policy_test.dart covers the policy contract, but LocalDb.updateBaseline is the new cross-isolate synchronization primitive and has no tests calling it. Add tests for the deterministic database contract:

  • transform receives null when key is absent.
  • A null return leaves the existing row byte-identical.
  • A non-null return replaces payload_json and advances updated_at.

The suite can use the existing sqflite_common_ffi harness that other LocalDb tests already use.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/sleep_profile_policy_test.dart` around lines 117 - 126, Add direct
regression tests that invoke LocalDb.updateBaseline using the existing
sqflite_common_ffi harness. Verify the transform receives null for an absent
key, null results preserve an existing row byte-for-byte, and non-null results
replace payload_json while advancing updated_at.

Source: Coding guidelines

Self-inflicted, from round 2. updateBaseline takes an exclusive SQLite write
lock and the entire point is that two derivation isolates contend for it — so
SQLITE_BUSY is an EXPECTED outcome. But it escaped _sleepCandidateForDay into
processDay's broad catch, which calls _markDaySkipped and bumps failures. So a
fully computed day (already persisted) gets thrown away, and the tz hold stays,
because a bookkeeping write lost a lock race. _markDaySkipped is the same path
that was a P0 for destroying good day_result rows.

Wrapped in try/catch + log. Nothing is lost: the day_id never reaches
folded_days, so the next pass folds it again.

Also added test/db_update_baseline_test.dart — updateBaseline is the new
cross-isolate primitive and had no direct coverage. transform sees null for an
absent key, a null return leaves the row byte-identical (updated_at included),
a non-null return replaces and advances updated_at, sequential accumulate,
concurrent accumulate, concurrent set-union, and a throwing transform rolls
back without wedging the connection.

Checked these actually bite, given last round: swapped updateBaseline for a
naive read-then-write and the concurrent tests lose 19 of 20 increments and
19 of 20 day_ids. Restored, all green.

1084 tests, analyze clean.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Both valid. The first one is a regression I introduced in the previous round, so thanks for staying on it.

fold failure was failing the whole day

Verified the path: processDay has a broad catch (e) at both call sites that calls _markDaySkipped and increments failures, which holds the timezone. And _markDaySkipped is the same path that was a P0 for destroying good day_result rows.

What makes it worse is that I caused it. Round 2 introduced an exclusive SQLite write lock specifically so two derivation isolates contend for it — so SQLITE_BUSY is an expected outcome here, not an exceptional one. I raised the probability of an exception while it was wired to a destructive handler. A fully computed, already-persisted day would get discarded because a bookkeeping write lost a lock race.

Wrapped in try/catch with a log line. Nothing is lost: the day_id never reaches folded_days, so the next pass folds it again.

direct tests for updateBaseline

Fair — it's the new cross-isolate primitive and had no coverage of its own. Added test/db_update_baseline_test.dart on the existing sqflite_common_ffi harness, covering the three you listed plus a few:

  • transform sees null for an absent key, and can create the row
  • a null return leaves the row byte-identical, updated_at included
  • a non-null return replaces the payload and advances updated_at
  • sequential accumulate — every update observes the previous commit
  • concurrent accumulate, and concurrent set-union (the real folded_days shape)
  • a throwing transform rolls back and doesn't wedge the connection

And I checked they actually bite, since last round I shipped a test that asserted nothing. Temporarily swapped updateBaseline for a naive read-then-write:

concurrent accumulate    Expected: <20>   Actual: <1>
concurrent set-union     Expected: 20 days   Actual: ['2026-07-29']

19 of 20 increments and 19 of 20 day_ids lost. Restored, all green.

1084 tests, analyze clean.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 45f0939

Neither turned out to be a bug, but both pointed at genuinely untested corners,
so they get tests rather than a reply saying "works on my machine".

- concurrent LEGACY discard. The worry was that several lanes all observe the
  pre-tracking row at once, each treat it as a cold start, and each write a
  profile containing only its own day. Can't happen through updateBaseline —
  BEGIN IMMEDIATE serialises them, so only the first lane sees the legacy row
  and the rest see folded_days already present. 15 concurrent lanes over a
  nights:1348 row now assert nights==15 and no day_id lost.

- override skip must not blacklist the day. Declining to fold an override
  records nothing, so removing the override later and re-deriving folds it
  normally. Pinning it because the tempting alternative — marking it folded to
  "remember we skipped" — would exclude that night forever.

The third claim (syntax error in the test helper, "none of the 15 tests run")
is wrong: `key: ?value` is null-aware element syntax, valid on our SDK 3.11.4,
and the analyzer's own use_null_aware_elements lint is what asked for it. 19
tests in that file run and pass, analyze is clean, and test.yml triggers on
pull_request so CI ran them too.

1086 tests, analyze clean.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

PR Agent's persistent review flagged three things. Checked all three — none is a bug, but two pointed at untested corners so they get tests rather than a "works on my machine".

"Syntax error in test helper" — incorrect

The claim is that SleepProfilePolicy.foldedDaysKey: ?foldedDays is invalid Dart, so the file won't compile and none of the policy tests run.

key: ?value is null-aware element syntax. Valid on our SDK (3.11.4), and the analyzer's own use_null_aware_elements lint is literally what asked me to write it that way — I had it as a conditional if first and the analyzer flagged that.

Evidence:

$ flutter test test/sleep_profile_policy_test.dart
00:00 +19: All tests passed!

$ flutter analyze test/sleep_profile_policy_test.dart
No issues found!

Also "no CI gate runs on PRs" is wrong — test.yml triggers on pull_request, and gh pr checks 174 shows test pass 3m33s on this PR.

"Legacy discard race" — not reachable, now covered

The scenario: several lanes all observe the pre-tracking row simultaneously, each treats it as a cold start, each writes a profile containing only its own day, so the rebuild drops folds.

It can't happen through updateBaseline. BEGIN IMMEDIATE serialises the transactions, so only the first lane ever sees the legacy row; by the time the second runs, the row already carries folded_days and is no longer legacy. The read is inside the same transaction as the write.

Rather than argue it, added concurrent LEGACY discard: the rebuild loses nothing either — 15 concurrent lanes over a nights: 1348 row, asserting nights == 15, every day_id present, and the 1348 gone.

"Observation silently dropped on override path" — correct by design, now pinned

The write-up says "this is correct behavior" three times and is partly self-contradictory (says override days are "permanently excluded", then correctly notes a later non-override re-derive would fold them). The second reading is right: skipping an override records nothing in folded_days, so the day stays eligible and the behaviour is self-healing.

But "not tested" was fair. Added skipping an override does NOT blacklist the day forever — skip under override records nothing, removing the override makes it foldable, and it still folds only once after that. Worth pinning because the tempting alternative (marking it folded to "remember we skipped it") would exclude that night from the profile permanently.

1086 tests, analyze clean.

Note: CodeRabbit shows Review rate limited on the last two commits, so it hasn't re-reviewed 45f0939 or 365c6f8 yet.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 365c6f8

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/compute/derivation_engine.dart (1)

1325-1333: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Re-check override state during the serialized fold.

_foldObservationIntoProfile passes hasOverride: false unconditionally. If a user adds an override after _sleepCandidateForDay reads override == null but before updateBaseline commits, the observation is folded into the profile. Read the override row in the same transaction as the profile update, or make both row updates atomic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 1325 - 1333, The serialized
fold in _foldObservationIntoProfile must re-check the day’s override state
inside the LocalDb.updateBaseline transaction instead of passing hasOverride:
false from the earlier read. Read the override row using the transaction’s
current state and pass whether an override exists to
SleepProfilePolicy.shouldFold, ensuring a concurrently added override prevents
folding; alternatively make the override and profile updates atomic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 1266-1281: The bookkeeping catch around
_foldObservationIntoProfile must preserve retryability when folding fails,
including SQLITE_BUSY and after an override is removed. Persist the pending
observation for dayId, or update _sleepCandidateForDay to bypass its
finalized-candidate cache until dayId is present in folded_days, so the next
pass reloads observationJson and retries the fold without changing the
successful day result path.

In `@test/db_update_baseline_test.dart`:
- Around line 1-10: The current contention tests only use concurrent futures
within one isolate and do not exercise cross-isolate database synchronization.
Extend the LocalDb.updateBaseline test suite with a regression case that spawns
a real second Isolate, opens the same sqflite_ffi database, and performs the
competing update alongside the test isolate; alternatively, document an existing
isolate-based test if one already covers this behavior.

---

Outside diff comments:
In `@lib/compute/derivation_engine.dart`:
- Around line 1325-1333: The serialized fold in _foldObservationIntoProfile must
re-check the day’s override state inside the LocalDb.updateBaseline transaction
instead of passing hasOverride: false from the earlier read. Read the override
row using the transaction’s current state and pass whether an override exists to
SleepProfilePolicy.shouldFold, ensuring a concurrently added override prevents
folding; alternatively make the override and profile updates atomic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 113bb88b-b54f-4f0d-bf6d-822b236c7408

📥 Commits

Reviewing files that changed from the base of the PR and between d1ccf16 and 365c6f8.

📒 Files selected for processing (3)
  • lib/compute/derivation_engine.dart
  • test/db_update_baseline_test.dart
  • test/sleep_profile_policy_test.dart

Comment on lines +1266 to +1281
if (observationJson != null) {
// BEST-EFFORT, and deliberately isolated from the day's success path.
// The fold is bookkeeping; the day's real result is already persisted
// above. `updateBaseline` takes an exclusive SQLite write lock, and the
// whole point of this change is that two derivation isolates contend
// for it — so SQLITE_BUSY here is an EXPECTED outcome, not an
// exceptional one. Letting it escape would hit processDay's broad
// catch, which calls `_markDaySkipped` and increments `failures`,
// throwing away a fully computed day (and holding the timezone) over a
// bookkeeping write. Nothing is lost by swallowing it: the day_id never
// reaches `folded_days`, so the next pass simply folds it again.
try {
await _foldObservationIntoProfile(dayId, observationJson);
} catch (e) {
_log('sleep profile fold skipped for $dayId (day result kept): $e');
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not lose a fold when bookkeeping contention occurs.

After this catch, a day can be finalized without its day_id in folded_days. On the next pass, _sleepCandidateForDay returns the cached finalized candidate at Lines 1133-1150 before loading the profile or producing observationJson. _foldObservationIntoProfile is then never retried. The same path prevents folding after an override is removed. Persist a pending observation, or bypass the finalized-candidate cache until the day is recorded in folded_days.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/compute/derivation_engine.dart` around lines 1266 - 1281, The bookkeeping
catch around _foldObservationIntoProfile must preserve retryability when folding
fails, including SQLITE_BUSY and after an override is removed. Persist the
pending observation for dayId, or update _sleepCandidateForDay to bypass its
finalized-candidate cache until dayId is present in folded_days, so the next
pass reloads observationJson and retries the fold without changing the
successful day result path.

Comment on lines +1 to +10
// LocalDb.updateBaseline — the cross-isolate synchronization primitive behind
// the rolling sleep-profile fold, exercised against the REAL LocalDb over
// sqflite_ffi.
//
// Why this has its own suite: a Dart `static` mutex cannot serialize the fold,
// because derivation also runs in a background isolate (`derivationDispatcher`
// is a vm:entry-point WorkManager entry that builds its own DerivationEngine),
// and a static has one copy per isolate. The read-modify-write therefore has to
// be atomic in the DATABASE. `sleep_profile_policy_test.dart` covers the pure
// decision contract; these cover the storage contract it depends on.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect how LocalDb opens the database and how the background isolate entry point accesses it.
ast-grep outline lib/data/db.dart --items all
rg -n "openDatabase|singleInstance|databaseFactory" lib/data/db.dart
rg -n -C6 "derivationDispatcher" lib/compute

Repository: OpenStrap/edge

Length of output: 5566


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files 'test/db_update_baseline_test.dart' 'lib/data/db.dart' 'lib/compute/background_derivation.dart' 'pubspec.yaml'

echo
echo "== db_update_baseline_test.dart outline and relevant lines =="
ast-grep outline test/db_update_baseline_test.dart --items all || true
wc -l test/testify db_update_baseline_test.dart lib/data/db.dart
sed -n '1,240p' test/db_update_baseline_test.dart

echo
echo "== lib/data/db.dart updateBaseline-related sections =="
rg -n -C8 "updateBaseline|exclusive|LocalDb|openDatabase|Database|singleInstance|sqflite|databaseFactory|sleep_profile" lib/data/db.dart test/db_update_baseline_test.dart

Repository: OpenStrap/edge

Length of output: 519


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files 'test/db_update_baseline_test.dart' 'lib/data/db.dart' 'lib/compute/background_derivation.dart' 'pubspec.yaml'

echo
echo "== db_update_baseline_test.dart outline and relevant lines =="
ast-grep outline test/db_update_baseline_test.dart --items all || true
wc -l test/test db_update_baseline_test.dart lib/data/db.dart
sed -n '1,240p' test/db_update_baseline_test.dart

echo
echo "== lib/data/db.dart updateBaseline-related sections =="
rg -n -C8 "updateBaseline|exclusive|LocalDb|openDatabase|Database|singleInstance|sqflite|databaseFactory|sleep_profile" lib/data/db.dart test/db_update_baseline_test.dart

Repository: OpenStrap/edge

Length of output: 516


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repository root =="
pwd
echo "== top-level files =="
find . -maxdepth 3 -type f \( -name "pubspec.yaml" -o -path "./test/db_update_baseline_test.dart" -o -path "./lib/data/db.dart" -o -path "./lib/compute/background_derivation.dart" \) | sort

echo
echo "== db_update_baseline_test.dart lines 1-240 =="
sed -n '1,240p' test/db_update_baseline_test.dart

echo
echo "== updateBaseline and database opening references =="
rg -n -C6 "updateBaseline|exclusive|openDatabase|singleInstance|Database|sqflite|databaseFactory|background_derivation|derivationDispatcher|sleep_profile" lib/data/db.dart test/db_update_baseline_test.dart lib/compute/background_derivation.dart lib/compute/sleep_profile_policy.dart lib/compute/derivation_engine.dart pubspec.yaml

Repository: OpenStrap/edge

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== public API and updateBaseline implementation =="
rg -n -C24 "static.*updateBaseline|Future<(.*)?>\s*updateBaseline|baseline\(|putBaseline\(|BEGIN IMMEDIATE|transaction\(|exclusive" lib/data/db.dart

echo
echo "== LocalDb public methods near top =="
sed -n '24,240p' lib/data/db.dart

echo
echo "== updateBaseline call sites in lib =="
rg -n -C8 "updateBaseline\(" lib/compute lib/data

Repository: OpenStrap/edge

Length of output: 50370


Exercise cross-isolate contention in db_update_baseline_test.dart.

The concurrent tests use Future.wait in one test isolate with a single FFI-derived sqflite_ffi Database, while the fix is a cross-isolate problem from derivationDispatcher. Add a regression test that spawns a real second Isolate opening the same DB and contends with the test isolate, or document an already-present isolate test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/db_update_baseline_test.dart` around lines 1 - 10, The current
contention tests only use concurrent futures within one isolate and do not
exercise cross-isolate database synchronization. Extend the
LocalDb.updateBaseline test suite with a regression case that spawns a real
second Isolate, opens the same sqflite_ffi database, and performs the competing
update alongside the test isolate; alternatively, document an existing
isolate-based test if one already covers this behavior.

Neither of these is a code fix. Both are cases where a comment or a test name
promised something the code doesn't do.

1. My round-3 comment said a swallowed fold failure is harmless because "the
   next pass simply folds it again". That's false. _sleepCandidateForDay
   short-circuits on a cached finalized candidate before staging runs, so
   observationJson is never regenerated and the fold never retries. Same for a
   day whose override is removed if it already had a cached candidate.

   Documented as a KNOWN LIMITATION with the reasoning for accepting it: the
   profile is an EWMA over ~14 nights capped at 0.5 blend weight, so a missing
   night is a small perturbation, while a retry path needs durable pending
   state plus a way to tell "failed, retry" from "declined permanently" — a
   <120-epoch nap never folds and would otherwise bypass the candidate cache
   and re-stage every sweep forever. Naively bypassing the cache is the wrong
   fix and the comment says so.

2. My test "skipping an override does NOT blacklist the day forever" reads like
   an end-to-end guarantee. It isn't — it only asserts the policy keeps saying
   yes. The engine often won't re-fold, for the reason above. Renamed and
   scoped so nobody trusts it for more than it checks.

Also scoped the db_update_baseline header: those concurrent tests run in one
isolate on one connection, so they prove atomicity under interleaved async
access (they do fail 19/20 against a naive read-then-write) but NOT
cross-connection contention. The cross-isolate guarantee rests on SQLite's
documented BEGIN IMMEDIATE locking, which they assume rather than verify.

1086 tests, analyze clean.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Both correct. Neither gets a code fix from me, and I want to be explicit about why rather than quietly closing them.

the fold-retry gap — you're right, and my comment was wrong

Verified: _sleepCandidateForDay returns the cached finalized candidate at Lines 1133-1150 before staging runs, so observationJson is never regenerated. My round-3 comment claimed a swallowed failure was harmless because "the next pass simply folds it again". That is false. A swallowed fold failure is permanent for that day, and the same path blocks re-folding after an override is removed if the day already had a candidate cached.

I have not built the retry. Reasoning, so it can be argued with:

  • the profile is an EWMA over a ~14-night horizon with a hard 0.5 blend cap, so one missing night is a small perturbation — this is genuinely best-effort bookkeeping
  • the naive version of your second suggestion (bypass the cache until the day is in folded_days) is actively wrong: a <120-epoch nap never folds, so it would never enter the set, so it would bypass the cache and re-stage every sweep forever. Staging is the expensive part.
  • doing it properly needs durable pending-observation state plus a distinction between "failed, retry" and "declined permanently". That's a state machine, and this PR has already had two rounds where my fix for one finding created the next one. I'd rather not add one more under momentum.

So: documented as a KNOWN LIMITATION at the call site, with the reasoning and an explicit "do not simply bypass the cache" note for whoever picks it up. Happy to build the retry if you'd rather — say the word.

the override test was overclaiming

Related, and my fault. skipping an override does NOT blacklist the day forever reads as an end-to-end guarantee. It only asserts the policy keeps returning true — the engine often won't re-fold, for exactly the reason above. Renamed to skipping an override records nothing, so the POLICY stays eligible with a scope note pointing at the limitation.

That's twice now in this PR that I've shipped a test whose name promised more than it checked.

cross-isolate coverage

Fair. The concurrent tests run in one isolate on one sqflite_ffi connection, so they prove atomicity under interleaved async access — they do fail 19/20 against a naive read-then-write — but not two OS-level connections contending, which is the actual scenario.

I didn't add the spawned-isolate test: LocalDb's singleton/static setup has no entry point for opening the same file from a second isolate, so it'd need a test-only seam, and I'm wary of adding production surface purely for a test at this point in the PR. Instead the file header now states plainly what the tests do and don't establish, and that the cross-isolate guarantee rests on SQLite's documented BEGIN IMMEDIATE locking rather than on these tests. Flagged as worth building if this primitive picks up more callers.

1086 tests, analyze clean.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit e029dfd

PR Agent point, narrow but fair. appendFoldedDay evicts by LEXICOGRAPHIC order,
which only equals chronological order for zero-padded ISO dates. Feed it an
epoch string or a UUID and a RECENT day could be evicted while an older one is
kept — and an evicted day passes shouldFold again, which is the double-fold
this class exists to prevent. Invisible in the payload if it ever happened.

Only day_label.dart values reach this today, so it's a precondition rather than
a bug. Asserted it and documented why the sort is load-bearing.

The assert immediately failed my own cap test, which was building fake labels
like '2020-01-00000'. Rewrote it to walk real dates, and added coverage for
eviction across a month and year boundary plus the non-date rejection.

Two other PR Agent focus areas need no change:
- "Fold skipped permanently on SQLITE_BUSY" is the same gap CodeRabbit raised;
  already documented as a KNOWN LIMITATION at the call site with the reasoning.
- "Stale mayFold pre-check" — the substantive half was whether a drained
  observation accumulator could starve a later day in the same isolate.
  It can't: _runIsolateCancellable does Isolate.spawn per call with onExit
  wired, so it's one fresh isolate per day and the globals die with it. The
  rest is a few serialized doubles occasionally declined by the transaction
  re-check, which is the correct design — mayFold can go stale during 90s of
  staging no matter how fresh the pre-check is.

1088 tests, analyze clean.
@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Three focus areas in the updated persistent review. One acted on, two need no change.

"Cap evicts by sort order, not insertion age" — fair, asserted

Narrow but correct. appendFoldedDay evicts by lexicographic order, which only equals chronological order for zero-padded ISO dates. A UUID or epoch string would break the sort, so a recent day could be evicted while an older one is kept — and an evicted day passes shouldFold again, which is precisely the double-fold this class exists to prevent. And it'd be invisible in the payload.

Only day_label.dart values reach this today, so it's a precondition, not a bug. Added the assert and documented why the sort is load-bearing.

Nice side effect: the assert immediately failed my own cap test, which was generating fake labels like 2020-01-00000. Rewrote it to walk real dates and added coverage for eviction across a month and year boundary, plus the non-date rejection.

"Fold skipped permanently on SQLITE_BUSY" — already documented

Same gap CodeRabbit raised in the previous round. Confirmed real, deliberately not fixed, documented as a KNOWN LIMITATION at the call site with the reasoning (EWMA over ~14 nights at a 0.5 cap, and the naive fix — bypassing the candidate cache — would make a <120-epoch nap re-stage every sweep forever). Details in my earlier reply.

"Stale mayFold pre-check" — not reachable

The substantive half of this was: "if that drain has side effects (clearing the accumulator), a subsequent legitimate fold attempt for a different day in the same isolate lifetime could see an empty observation list. Whether the isolate is reused across days in the same sweep determines whether this matters."

Checked — it isn't reused. _runIsolateCancellable does Isolate.spawn per call with onError/onExit wired, and the isolate exits after sending its result. One fresh isolate per day, so _cardioObservations dies with it and can't starve another day.

The remainder is a handful of serialized doubles that the transaction re-check occasionally declines. That's the intended design rather than waste: mayFold can go stale during ~90s of staging no matter how fresh the pre-check is, which is exactly why the authoritative check lives inside the transaction.

1088 tests, analyze clean.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 7d9c8e4

@abdulsaheel

Copy link
Copy Markdown
Collaborator Author

Persistent review is down to two focus areas on 7d9c8e4, both already answered above (the SQLITE_BUSY gap is documented as a known limitation; the stale mayFold drain concern isn't reachable because _runIsolateCancellable spawns a fresh isolate per day). The cap-eviction one dropped off after the assert landed.

On the current code suggestions — nothing actionable, but for the record:

"Warm-up gate driven by tracked distinct nights, not EWMA counter" (importance 3). The stated divergence doesn't occur: a skipped fold increments neither nights nor folded_days, so they stay in step. They only part company past the 400-entry cap, where folded_days.length plateaus while nights keeps climbing — and both are far above the 3-night gate by then, so the gate behaves identically. The proposed diff also just adds an unused method beside shouldBlend without changing it, which PR Agent's own rationale notes.

"Ensure exclusive write lock is released on transform exception". The suggested diff is a semantic no-op (try { next = transform(current); } catch (_) { rethrow; } is exactly what already happens implicitly). The real question inside it — "confirm sqflite releases the write lock on exception, as a stuck lock would wedge all subsequent baseline writes" — is worth having pinned, and it already is: a throwing transform rolls back and leaves the row intact throws inside the transaction, asserts the row is unchanged, then performs another successful updateBaseline. A held lock would hang that second call.

"Fix invalid null-aware map value syntax" (importance 7). Repeat of the earlier claim. Still incorrect — key: ?value is null-aware element syntax, valid on SDK 3.11.4, and use_null_aware_elements is the lint that asked for it. Evidence unchanged: flutter test on that file reports +19: All tests passed, flutter analyze reports No issues found!, and the test job passes on this PR. Not changing working code on a claim that three independent checks contradict.

"Remove redundant expect that fires before skip takes effect" — this one targets test/derivation_pipeline_test.dart in PR #154, not this PR. That file isn't in this diff (derivation_engine.dart, sleep_profile_policy.dart, db.dart, and two test files are). Stale entry accumulated in the suggestions comment.

1088 tests, analyze clean. Nothing further from me unless a human reviewer wants the fold-retry state machine built.

🤖 Generated with Claude Code

@abdulsaheel
abdulsaheel merged commit 5a788da into main Aug 2, 2026
3 checks passed
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