Skip to content

rescale strain to the load actually earned, and backfill what is stored - #240

Open
svssathvik7 wants to merge 1 commit into
OpenStrap:mainfrom
svssathvik7:fix/strain-scale-recalibration
Open

rescale strain to the load actually earned, and backfill what is stored#240
svssathvik7 wants to merge 1 commit into
OpenStrap:mainfrom
svssathvik7:fix/strain-scale-recalibration

Conversation

@svssathvik7

@svssathvik7 svssathvik7 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Depends on OpenStrap/analytics#45. pubspec.yaml pins openstrap_analytics to a commit SHA, so this cannot build against upstream until that merges and the SHA is bumped here. Verified locally through pubspec_overrides.yaml against the sibling checkout — the pin bump is the only thing outstanding.

What moved

Rides the analytics change that redefines the 0–21 headline as the TRIMP earned above a quiet-waking baseline, rather than whole-waking-day TRIMP through a log base 1.5. An inactive full-wear day scored 12.8/21 on a real bundle, because ~16 h of simply being awake accrues ~180 TRIMP and that curve is steepest near zero. 21 sat at TRIMP ~4987 (~35 h at 80 % HRR), so a marathon read ~15.8 and the top of the scale was decorative.

Real days, recomputed by the new code:

Day Before After
Inactive full-wear day 12.8 2.25
2026-07-09 — 2,641 steps, 611 wake min 12.79 9.03
2026-07-10 — 23 steps, 135 wake min 7.88 0.00

Both call sites pass the wake window the TRIMP was accumulated over, since that is what sets the baseline: perMin.length in the engine and the pure pipeline, the window's own length for a manually logged session. Passing the observed length rather than an assumed full day is what stops a partial-wear day being charged a whole day's overhead. Sex is threaded with it so the baseline is priced by the same Banister constant that scored the TRIMP.

The intraday strain curve picks up Banister's 0.64/0.86 scale coefficient. It had been inlining exp(b*hrr) and dropping the coefficient entirely, accumulating a TRIMP 1.5625× the day's own — tolerable while it was only a shape, not once the headline nets against a baseline priced with banisterY.

Backfill

Raw 1 Hz substrate is pruned rawRetentionDays (3) behind the data edge, so history cannot be re-derived from raw — the engine keeps the old row and logs "no substrate (raw pruned)". It does not need raw: strain is a pure function of (TRIMP, wake minutes, sex), and metric_series already stores trimp, worn_min and tst_min for every derived day.

The reconstruction is exact, not approximate: on a real bundle series.strain_curve has 611 points against worn_min − tst_min = 827 − 216 = 611. The curve carries one point per wake minute, so the window reconstructed here is the one the pipeline fed the scorer.

strain_backfill.dart rewrites metric_series (trends, sparklines, v_daily, the coach's SQL surface, baselines) and writes a corrected day_result row at the new algo version — a version bump writes a new row, so the immutable-per-version invariant holds. It runs once before the sweep, guarded by compute_freshness, and never fatally.

Three deliberate choices, each pinned by a test:

  • Days inside the raw window are left alone for a real re-derive. Patching them would write a row at kAlgoVersion, and the derive gate matches algo_version exactly — a partial patch would stand in for a full re-derivation.
  • A day that cannot be rescaled is left exactly as it was and counted. No stored TRIMP or no wake window → skipped, because 0 is a number, not an absence.
  • The stale series.strain_curve is dropped on rescaled days. It was built from per-sample HR that no longer exists and its last point is the old headline, so a curve ending at 12.79 under a headline of 9.03 contradicts itself. I looked at inverting the old map to recover per-point TRIMP, but that depends on which code version wrote the curve (the missing 0.64 coefficient) and I could not make it reliably correct.

Separate bug, found while tracing the target the user actually sees

There were two strain targets with different key names and only one producer. crossDayPipeline emits strain_coach ({target_min, target_max, band}) into the insights map, which the Insights card reads. CoachData reads coach.strain_target ({value, low, high}) — and nothing wrote a coach key anywhere: sub('coach') in payloads.dart was the only occurrence of it in the tree.

So Today's plan chip, the Coach screen's target tile and the home-screen widget all rendered nothing, silently, while a test fixture "covered" the shape production never emitted. coachToday() bridges the two, getToday() emits it, and the fixture's shape is now something the app actually produces.

Tests

1901 tests pass, dart analyze lib test clean. New coverage: strain_rescale_backfill_test.dart (exact recompute against both real bundles, abstention on missing inputs, the retention guard, idempotency, skip-don't-zero, curve removal) and coach_strain_target_wiring_test.dart (the emitted shape actually reaches CoachData).

The one thing not verified here is the migration against a real device DB — tests run on in-memory sqlite with seeded rows, so the first real run is worth watching for the [derive] strain rescale: N day(s) rebuilt log line.

Summary by CodeRabbit

  • New Features

    • Added updated strain scoring that accounts for waking duration and individual baseline factors.
    • Added personalized strain targets to the daily coaching information.
    • Improved intraday strain tracking with consistent scoring throughout the day.
  • Improvements

    • Historical strain results are recalculated to align with the updated scale.
    • Recovery, fatigue, freshness, and strain comparisons now use the recalibrated scoring.
    • Preserved historical data when insufficient information is available for recalculation.

Rides the analytics change that redefines the 0-21 headline as the TRIMP earned
ABOVE a quiet-waking baseline rather than whole-waking-day TRIMP through a
log base 1.5. On a real bundle an INACTIVE full-wear day scored 12.8 out of 21,
because ~16 h of simply being awake accrues ~180 TRIMP and that curve is
steepest near zero. 21 sat at TRIMP ~4987 — about 35 h at 80 % HRR — so the top
of the scale was unreachable and a marathon read ~15.8.

Both call sites now pass the wake window the TRIMP was accumulated over, since
that is what sets the baseline: `perMin.length` in the engine and the pure
pipeline, the window's own length for a manually logged session. Passing the
OBSERVED length rather than an assumed full day is what stops a partial-wear day
from being charged a whole day's overhead — a real bundle (2026-07-10) was worn
135 waking minutes. Sex is threaded through with it so the baseline is priced by
the same Banister constant that scored the TRIMP.

The intraday strain curve picks up Banister's 0.64/0.86 scale coefficient. It
had been inlining `exp(b*hrr)` and dropping the coefficient entirely, so it
accumulated a TRIMP 1.5625x the day's own — tolerable while it was only a shape,
but not once the headline nets against a baseline priced with `banisterY`. Its
baseline grows with the wake window already elapsed, so the curve stays flat
through quiet waking and climbs only on real effort.

BACKFILL. Raw 1 Hz substrate is pruned `rawRetentionDays` (3) behind the data
edge, so history cannot be re-derived from raw — the engine keeps the old row
and logs "no substrate (raw pruned)". It does not need raw: strain is a pure
function of (TRIMP, wake minutes, sex), and metric_series already stores trimp,
worn_min and tst_min for every derived day. On a real bundle `strain_curve` has
611 points against `worn_min - tst_min` = 611, so the reconstructed wake window
is the one the pipeline fed the scorer.

`strain_backfill.dart` rebuilds the headline from those, rewriting metric_series
(trends, sparklines, v_daily, the coach's SQL surface, baselines) and writing a
corrected day_result row at the new algo version — a version bump writes a NEW
row, so the immutable-per-version invariant holds. It runs once, before the
sweep, guarded by compute_freshness, and never fatally: a failed rescale must
not take the derive cycle down.

Days INSIDE the raw window are deliberately left alone for a real re-derive.
Patching them would write a row at kAlgoVersion, and the derive gate matches
algo_version exactly, so a partial patch would stand in for a full
re-derivation. A day that cannot be rescaled — no stored TRIMP, or no wake
window — is left exactly as it was and counted, because 0 is a number, not an
absence. The stale `series.strain_curve` is DROPPED on rescaled days: it was
built from per-sample HR that no longer exists, and its last point IS the old
headline, so a curve ending at 12.79 under a headline of 9.03 contradicts
itself.

SEPARATE BUG, found while tracing the target the user actually sees. There were
two strain targets with different key names and only one producer.
crossDayPipeline emits `strain_coach` ({target_min,target_max,band}) into the
insights map, which the Insights card reads. CoachData reads
`coach.strain_target` ({value,low,high}) — and NOTHING wrote a `coach` key
anywhere: `sub('coach')` in payloads.dart was the only occurrence of it in the
tree. Today's plan chip, the Coach screen's target tile and the home-screen
widget all rendered nothing, silently, while a test fixture "covered" the shape
production never emitted. `coachToday()` bridges the two and getToday() emits
it; the fixture's shape is now something the app actually produces.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The strain scale changes to use quiet-waking baseline subtraction, observed wake duration, and sex-specific weighting. Historical records can be rescaled from stored metrics. Today data now includes cross-day strain coach targets.

Strain scale recalibration

Layer / File(s) Summary
Duration-aware strain scoring
lib/compute/derivation_engine.dart, lib/compute/manual_session.dart, lib/compute/onehz_pipeline.dart
Headline, manual-session, and intraday strain calculations now use duration-aware, sex-specific scoring. Algorithm version 63 rebases related recovery and fatigue comparisons.
Historical strain backfill
lib/compute/strain_backfill.dart, lib/compute/derivation_engine.dart, lib/data/db.dart
The one-shot backfill reconstructs eligible historical strain from stored metrics, updates series and bundles, removes stale strain curves, and records completion.
Backfill persistence validation
test/strain_rescale_backfill_test.dart
Tests cover recalculation, database updates, retention boundaries, missing inputs, stale curves, preserved TRIMP, idempotence, and sex-specific results.

Coach target wiring

Layer / File(s) Summary
Today coach target mapping
lib/data/local_repository_impl.dart, test/coach_strain_target_wiring_test.dart
coachToday converts cross-day strain target bounds and rationale into CoachData data. getToday() exposes the result. Tests cover valid, absent, and malformed targets.

Estimated code review effort: 5 (Critical) | ~90+ minutes

Sequence Diagram(s)

sequenceDiagram
  participant DerivationEngine
  participant backfillStrainScale
  participant LocalDb
  participant strainScoreMetric
  DerivationEngine->>backfillStrainScale: run historical strain backfill
  backfillStrainScale->>LocalDb: load stored TRIMP and wake metrics
  backfillStrainScale->>strainScoreMetric: calculate duration-aware strain
  strainScoreMetric-->>backfillStrainScale: return recalculated strain
  backfillStrainScale->>LocalDb: update series and day bundles
  DerivationEngine->>strainScoreMetric: calculate current headline strain
Loading

Possibly related PRs

  • OpenStrap/edge#189: Both changes modify strainFromPerMinuteHr in manual_session.dart.
  • OpenStrap/edge#227: Both changes modify strain calculations across the derivation, manual-session, and one-hertz pipeline code.

Suggested labels: Review effort 5/5

Suggested reviewers: abdulsaheel

🚥 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 summarizes the main changes: recalibrating strain to earned load and backfilling stored results.
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.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

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 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: 8

🤖 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 1125-1141: The one-shot strain backfill currently runs only in the
full derive flow, so selected re-analysis through runDays() skips it. Extract
the existing guarded backfill and logging logic into a shared method, invoke
that method from both the current derive path and runDays(), and preserve its
one-shot, non-fatal behavior. Add a regression test covering selected
re-analysis of a raw-pruned legacy day.

In `@lib/compute/strain_backfill.dart`:
- Around line 95-105: Update the retention cutoff calculation in the strain
backfill flow around _markDone() to use LocalDb.lastDecodedRecTs() as the
decoded-data edge instead of days.last from strainRows. When no decoded record
exists, leave the cutoff unset so all eligible stored days are processed;
preserve existing retention-day shifting when a decoded timestamp is available,
and add coverage for decoded data newer than the newest strain row.
- Around line 111-175: Make the historical rewrite in the backfill flow atomic
by replacing the separate read/write sequence around LocalDb.dayResult and
LocalDb.putDayResult with one database transaction that rechecks absence of a
current-version row before writing the bundle and series; skip the stale rewrite
when another derivation has already produced version kAlgoVersion. Update the
compute_freshness claim within that same transaction, preserving idempotent
replacement semantics and preventing current payload details or strain_curve
data from being overwritten. Add an interleaving regression test covering a
concurrent current-version write.
- Around line 209-215: Update _shiftDays to use local calendar arithmetic by
constructing the target with DateTime(year, month, day + days) instead of adding
Duration(days: days), then return the formatted result through dayLabelOf(). Add
a test covering a spring-forward DST transition and verifying the expected date
label.
- Around line 65-69: Update the strain calculation near the existing
ana.strainScore call to use an API supported by the pinned analytics revision:
either upgrade to a revision defining the wakeMinutes and female overload, or
call strainScoreMetric(trimp) and return its value only when non-null. Preserve
the existing null and wake validation behavior.

In `@lib/data/local_repository_impl.dart`:
- Around line 3266-3275: Reject inverted strain-target bounds in the
strain-target mapping logic by returning null when lo is greater than hi, while
preserving valid ranges. Add a regression expectation for reversed numeric
bounds in test/coach_strain_target_wiring_test.dart at lines 84-87.
- Around line 362-364: Update getToday() to call _crossDay() before its
empty-response early return, and include coachToday(cd) under the 'coach' key in
that return shape. Preserve the existing coach field in the normal response and
add a regression test covering strain_coach without a daily bundle or wake
features.

In `@test/strain_rescale_backfill_test.dart`:
- Around line 136-205: Make the tests in the backfillStrainScale group
independent by resetting and seeding all required database rows in setUp(),
including both historical dates and their expected payload state. Ensure each
test can run alone or in any order, while preserving the existing assertions for
rescaling, raw-retention skipping, curve removal, idempotence, and missing-TRIMP
handling.
🪄 Autofix

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: 8c4bf436-e6c8-4732-8d54-0fa6ad2b4167

📥 Commits

Reviewing files that changed from the base of the PR and between 6bddec4 and 96e2d23.

📒 Files selected for processing (8)
  • lib/compute/derivation_engine.dart
  • lib/compute/manual_session.dart
  • lib/compute/onehz_pipeline.dart
  • lib/compute/strain_backfill.dart
  • lib/data/db.dart
  • lib/data/local_repository_impl.dart
  • test/coach_strain_target_wiring_test.dart
  • test/strain_rescale_backfill_test.dart

Comment on lines +1125 to +1141
// ONE-SHOT: rescale stored strain onto the v63 scale. Days inside the raw
// window re-derive below from substrate; everything older has none, so its
// headline is rebuilt from the stored TRIMP + wake window instead. Runs
// before the sweep so the two never disagree mid-pass, and no-ops after the
// first successful pass (`compute_freshness`). Never fatal — a failed
// rescale must not take the derive cycle down with it.
try {
final rescaled = await backfillStrainScale(
female: workoutSex(profile.sex) == 'female',
);
if (rescaled.didWork) {
_log('[derive] strain rescale: ${rescaled.bundleDays} day(s) rebuilt, '
'${rescaled.skipped} skipped (no TRIMP or no wake window)');
}
} catch (e) {
_log('[derive] strain rescale failed (kept old values): $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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline lib/compute/derivation_engine.dart --items all --type method
rg -n -C 4 'runDays\s*\(|\.runDays\s*\(|backfillStrainScale\s*\(' lib test

Repository: OpenStrap/edge

Length of output: 11127


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- DerivationEngine.run and shared helpers ---'
sed -n '1000,1170p' lib/compute/derivation_engine.dart
printf '%s\n' '--- DerivationEngine.runDays and adjacent entry paths ---'
sed -n '1300,1535p' lib/compute/derivation_engine.dart
printf '%s\n' '--- strain backfill implementation ---'
sed -n '1,180p' lib/compute/strain_backfill.dart
printf '%s\n' '--- related tests and freshness references ---'
rg -n -C 8 'kStrainRescaleKey|strain rescale|runDays|rescanRecent|backfillStrainScale' lib/compute lib/state test

Repository: OpenStrap/edge

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- rescanRecent body and completion path ---'
sed -n '2100,2295p' lib/compute/derivation_engine.dart
printf '%s\n' '--- runDays backfill-related calls in DerivationEngine ---'
python3 - <<'PY'
from pathlib import Path
p = Path('lib/compute/derivation_engine.dart')
s = p.read_text()
for name in ('run(', 'runDays(', 'rescanRecent('):
    start = s.find('Future<int> ' + name)
    if start < 0:
        print(name, 'NOT FOUND')
        continue
    next_methods = [s.find('\n  Future<int> ', start + 1), s.find('\n  Future<void> ', start + 1)]
    end = min(x for x in next_methods if x >= 0)
    body = s[start:end]
    print(f'{name}: backfillStrainScale={body.count("backfillStrainScale(")}, '
          f'runDays={body.count(".runDays(")}, '
          f'returns={body.count("return ")}')
PY
printf '%s\n' '--- existing selected-day strain/backfill regression coverage ---'
rg -n -i -C 5 'selected.*day|historical|raw.?prun|strain.*runDays|runDays.*strain|strain.*rescal|rescal.*runDays' test lib

Repository: OpenStrap/edge

Length of output: 50371


Run the strain backfill from runDays().

runDays() never calls backfillStrainScale(). A selected re-analysis of a raw-pruned legacy day therefore leaves its old strain values unchanged. Extract the one-shot gate into a shared method and add a runDays() regression 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 `@lib/compute/derivation_engine.dart` around lines 1125 - 1141, The one-shot
strain backfill currently runs only in the full derive flow, so selected
re-analysis through runDays() skips it. Extract the existing guarded backfill
and logging logic into a shared method, invoke that method from both the current
derive path and runDays(), and preserve its one-shot, non-fatal behavior. Add a
regression test covering selected re-analysis of a raw-pruned legacy day.

Source: Coding guidelines

Comment on lines +65 to +69
if (trimp == null || wornMin == null) return null;
final wake = wornMin - (tstMin ?? 0);
if (wake <= 0) return null;
return ana.strainScore(trimp, wakeMinutes: wake, female: female);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 3 'openstrap_analytics' pubspec.yaml pubspec.lock
rg -n -C 3 'strainScoreMetric\s*\(|strainScore\s*\(' lib test

Repository: OpenStrap/edge

Length of output: 4447


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- strain_backfill.dart ---'
sed -n '1,90p' lib/compute/strain_backfill.dart

printf '%s\n' '--- dependency declarations ---'
sed -n '45,65p' pubspec.yaml
sed -n '955,980p' pubspec.lock

printf '%s\n' '--- local API usage ---'
rg -n -C 4 'strainScoreMetric|strainScore\s*\(' lib/compute test

printf '%s\n' '--- repository metadata ---'
git status --short
git ls-files | rg '(^|/)(pubspec\.lock|strain_backfill\.dart)$'

Repository: OpenStrap/edge

Length of output: 10554


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo='OpenStrap/analytics'
sha='e047c5920ea2e0def28f028d0fc990c1fe64cd6b'
base="https://raw.githubusercontent.com/${repo}/${sha}"

printf '%s\n' '--- commit resolution ---'
curl -fsSL "https://api.github.com/repos/${repo}/commits/${sha}" | jq -r '.sha'

printf '%s\n' '--- exported onehz.dart ---'
curl -fsSL "${base}/lib/onehz.dart"

printf '%s\n' '--- files defining or declaring scorer APIs ---'
curl -fsSL "https://api.github.com/repos/${repo}/git/trees/${sha}?recursive=1" |
  jq -r '.tree[] | select(.type == "blob" and (.path | endswith(".dart"))) | .path' |
  while read -r path; do
    content="$(curl -fsSL "${base}/${path}")"
    if printf '%s\n' "$content" | rg -q 'strainScoreMetric|double\??\s+strainScore|strainScore\s*\('; then
      printf '%s\n' "--- ${path} ---"
      printf '%s\n' "$content" |
        rg -n -C 8 'strainScoreMetric|double\??\s+strainScore|strainScore\s*\('
    fi
  done

Repository: OpenStrap/edge

Length of output: 229


🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo_url='https://github.com/OpenStrap/analytics.git'
sha='e047c5920ea2e0def28f028d0fc990c1fe64cd6b'
base="https://raw.githubusercontent.com/OpenStrap/analytics/${sha}"

printf '%s\n' '--- pinned commit exists ---'
git ls-remote "$repo_url" "$sha"

printf '%s\n' '--- exported onehz.dart ---'
curl -fsSL "$base/lib/onehz.dart"

printf '%s\n' '--- scorer definitions in likely source files ---'
for path in lib/onehz.dart lib/src/onehz.dart lib/src/strain.dart lib/src/metrics.dart; do
  if content="$(curl -fsSL "$base/$path" 2>/dev/null)"; then
    printf '%s\n' "--- $path ---"
    printf '%s\n' "$content" |
      rg -n -C 10 'strainScoreMetric|double\??\s+strainScore|strainScore\s*\(' || true
  fi
done

Repository: OpenStrap/edge

Length of output: 2058


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sha='e047c5920ea2e0def28f028d0fc990c1fe64cd6b'
archive="https://codeload.github.com/OpenStrap/analytics/tar.gz/${sha}"

printf '%s\n' '--- workout-related files ---'
curl -fsSL "$archive" |
  tar -tzf - |
  rg 'src/onehz/workout|onehz/workout|strain'

printf '%s\n' '--- scorer declarations ---'
curl -fsSL "$archive" |
  tar -xOzf - --wildcards '*/lib/src/onehz/workout/*' 2>/dev/null |
  rg -n -C 12 'strainScoreMetric|double\??\s+strainScore|strainScore\s*\('

Repository: OpenStrap/edge

Length of output: 981


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sha='e047c5920ea2e0def28f028d0fc990c1fe64cd6b'
archive="https://codeload.github.com/OpenStrap/analytics/tar.gz/${sha}"

python3 - "$archive" <<'PY'
import io
import re
import sys
import tarfile
import urllib.request

url = sys.argv[1]
data = urllib.request.urlopen(url).read()
pattern = re.compile(r'(strainScoreMetric|strainScore)', re.I)

with tarfile.open(fileobj=io.BytesIO(data), mode='r:gz') as archive:
    for member in archive.getmembers():
        if not member.isfile() or not member.name.endswith('.dart'):
            continue
        text = archive.extractfile(member).read().decode('utf-8', errors='replace')
        if not pattern.search(text):
            continue

        print(f'--- {member.name} ---')
        lines = text.splitlines()
        for index, line in enumerate(lines):
            if pattern.search(line):
                start = max(0, index - 8)
                end = min(len(lines), index + 9)
                for number in range(start, end):
                    print(f'{number + 1}: {lines[number]}')
                print()
PY

Repository: OpenStrap/edge

Length of output: 2438


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sha='e047c5920ea2e0def28f028d0fc990c1fe64cd6b'
archive="https://codeload.github.com/OpenStrap/analytics/tar.gz/${sha}"
tmp_archive="$(mktemp)"

trap 'rm -f "$tmp_archive"' EXIT
curl -fsSL "$archive" -o "$tmp_archive"

python3 - "$tmp_archive" <<'PY'
import re
import sys
import tarfile

pattern = re.compile(r'(strainScoreMetric|strainScore)', re.I)

with tarfile.open(sys.argv[1], mode='r:gz') as archive:
    for member in archive.getmembers():
        if not member.isfile() or not member.name.endswith('.dart'):
            continue
        text = archive.extractfile(member).read().decode('utf-8', errors='replace')
        if not pattern.search(text):
            continue

        print(f'--- {member.name} ---')
        lines = text.splitlines()
        for index, line in enumerate(lines):
            if pattern.search(line):
                start = max(0, index - 8)
                end = min(len(lines), index + 9)
                for number in range(start, end):
                    print(f'{number + 1}: {lines[number]}')
                print()
PY

Repository: OpenStrap/edge

Length of output: 50370


Use an API available in the pinned analytics revision.

At e047c5920ea2e0def28f028d0fc990c1fe64cd6b, neither ana.strainScore nor ana.strainScoreMetric accepts wakeMinutes or female. Use a revision that defines this overload, or call strainScoreMetric(trimp) and return its value only when present.

🧰 Tools
🪛 GitHub Actions: test / 0_test.txt

[error] 68-68: Flutter analyze: The named parameter 'wakeMinutes' isn't defined (undefined_named_parameter).


[error] 68-68: Flutter analyze: The named parameter 'female' isn't defined (undefined_named_parameter).

🪛 GitHub Actions: test / test

[error] 68-68: flutter analyze failed: named parameter 'wakeMinutes' is not defined (undefined_named_parameter).


[error] 68-68: flutter analyze failed: named parameter 'female' is not defined (undefined_named_parameter).

🤖 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/strain_backfill.dart` around lines 65 - 69, Update the strain
calculation near the existing ana.strainScore call to use an API supported by
the pinned analytics revision: either upgrade to a revision defining the
wakeMinutes and female overload, or call strainScoreMetric(trimp) and return its
value only when non-null. Preserve the existing null and wake validation
behavior.

Source: Pipeline failures

Comment on lines +95 to +105
// The DATA EDGE is the newest day on disk, matching how the pruner measures
// retention (never the wall clock — a multi-day flash backfill received in
// one sync must not be treated as old). Days at or after the cutoff still
// have raw and are LEFT for a real re-derive: writing a patched row at
// kAlgoVersion here would satisfy the derive gate, which matches
// algo_version EXACTLY, and a partial patch would stand in for a full
// re-derivation of the day.
final days = <String>[
for (final r in strainRows) ?(r['date'] as String?),
]..sort();
final cutoff = _shiftDays(days.last, -rawRetentionDays);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Derive retention from the decoded-data edge.

days.last is the latest non-null strain series row, not the latest decoded record. metricSeries('strain') excludes null values. If recent raw days have no strain, this cutoff moves backward and the backfill skips older raw-pruned days permanently when _markDone() runs.

Use LocalDb.lastDecodedRecTs() as the retention edge. If no decoded data exists, process all eligible stored days. Add a test where decoded data is newer than the newest strain row.

🤖 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/strain_backfill.dart` around lines 95 - 105, Update the retention
cutoff calculation in the strain backfill flow around _markDone() to use
LocalDb.lastDecodedRecTs() as the decoded-data edge instead of days.last from
strainRows. When no decoded record exists, leave the cutoff unset so all
eligible stored days are processed; preserve existing retention-day shifting
when a decoded timestamp is available, and add coverage for decoded data newer
than the newest strain row.

Comment on lines +111 to +175
for (final day in days) {
if (day.compareTo(cutoff) >= 0) continue;

final row = await LocalDb.dayResult(day);
// Already carries a row at the current version — rescaled on a prior pass.
if (row != null &&
((row['algo_version'] as num?)?.toInt() ?? 0) >= kAlgoVersion) {
continue;
}

final next = rescaledStrain(
trimp: trimpBy[day],
wornMin: wornBy[day],
tstMin: tstBy[day],
female: female,
);
if (next == null) {
skipped++;
continue;
}

if (row == null) {
// A series row with no bundle behind it: still worth fixing the trend.
await LocalDb.putMetricSeriesValue(day, 'strain', next);
seriesDays++;
continue;
}

final payload = _decode(row['payload_json']);
if (payload == null) {
skipped++;
continue;
}
final scalars = payload['scalars'];
if (scalars is! Map) {
skipped++;
continue;
}
scalars['strain'] = next;

// The intraday curve is cumulative strain, one point per wake minute, built
// from per-sample HR that no longer exists — it cannot be rescaled, and its
// last point IS the old headline. A curve ending at 12.79 under a headline
// of 9.03 contradicts itself, so it is DROPPED rather than left to disagree.
final series = payload['series'];
if (series is Map) series.remove('strain_curve');

final partial = (row['partial'] as num?)?.toInt() == 1;
await LocalDb.putDayResult(
dayId: day,
algoVersion: kAlgoVersion,
payloadJson: jsonEncode(payload),
windowJson: (row['window_json'] as String?) ?? '{}',
finalized: (row['finalized'] as num?)?.toInt() == 1,
skipped: (row['skipped'] as num?)?.toInt() == 1,
partial: partial,
rhr: (row['rhr'] as num?)?.toDouble(),
rmssd: (row['rmssd'] as num?)?.toDouble(),
readiness: (row['readiness'] as num?)?.toDouble(),
// `putDayResult` skips the series write for a partial row, so only count
// the trend as rewritten when it actually was.
series: {'strain': next},
);
bundleDays++;
if (!partial) seriesDays++;

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

Make the historical rewrite conditional and atomic.

Another derivation isolate can write a complete v63 result after Line 114 reads the v62 row and before Line 159 calls putDayResult(). ConflictAlgorithm.replace then replaces that complete v63 payload with this stale v62 payload, removes its newly derived strain_curve, and can discard other current-version detail.

Add one database transaction that verifies no current-version row exists and writes the bundle and series only when that condition still holds. Coordinate the compute_freshness claim in the same transaction. Add an interleaving regression test.

Based on learnings, “Recomputation must be idempotent: repeated derivation with additional data must not duplicate baseline entries, drift persisted scalars, or append where replacement is required.”

🤖 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/strain_backfill.dart` around lines 111 - 175, Make the historical
rewrite in the backfill flow atomic by replacing the separate read/write
sequence around LocalDb.dayResult and LocalDb.putDayResult with one database
transaction that rechecks absence of a current-version row before writing the
bundle and series; skip the stale rewrite when another derivation has already
produced version kAlgoVersion. Update the compute_freshness claim within that
same transaction, preserving idempotent replacement semantics and preventing
current payload details or strain_curve data from being overwritten. Add an
interleaving regression test covering a concurrent current-version write.

Source: Learnings

Comment on lines +209 to +215
/// Shift a 'YYYY-MM-DD' label by [days] calendar days.
String _shiftDays(String day, int days) {
final t = DateTime.parse(day).add(Duration(days: days));
final mm = t.month.toString().padLeft(2, '0');
final dd = t.day.toString().padLeft(2, '0');
return '${t.year}-$mm-$dd';
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -a '^day_label\.dart$' lib
rg -n -C 3 'dayLabelOf|todayLabel|localDayStartSec|localDayEndSec' lib/data
rg -n -C 3 '_shiftDays|backfillStrainScale' lib test

Repository: OpenStrap/edge

Length of output: 15772


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- strain_backfill.dart ---'
sed -n '1,235p' lib/compute/strain_backfill.dart

printf '%s\n' '--- day_label.dart ---'
cat -n lib/data/day_label.dart

printf '%s\n' '--- relevant tests and package API references ---'
rg -n -C 5 'shiftDays|dayLabelOf|localDay(Start|End)Sec|DST|spring|fall|strainScore|wakeMinutes|female' test lib pubspec.yaml

Repository: OpenStrap/edge

Length of output: 50373


🌐 Web query:

Dart DateTime.parse date-only string local UTC semantics and DateTime.add Duration days daylight saving time documentation

💡 Result:

In Dart, the DateTime.parse() method and DateTime.add() behavior are governed by specific rules regarding time zones and the definition of a "day" [1][2][3]. DateTime.parse Semantics When using DateTime.parse() on a string that does not explicitly include a time zone offset (e.g., "2026-08-12"), Dart interprets the result in local time [4][5][6]. If the string includes an explicit UTC offset (e.g., "2026-08-12T14:00:00Z" or "2026-08-12T14:00:00-0500"), Dart parses it as a specific point in time and, if an offset other than UTC is provided, converts it to the equivalent UTC time [1][7]. To force a string to be parsed as UTC when it lacks an explicit offset, a common convention is to append 'Z' to the string (e.g., DateTime.parse(dateString + 'Z')), which informs the parser to treat it as UTC [4][5]. DateTime.add and Daylight Saving Time (DST) The DateTime.add() method adds a Duration to a DateTime instance [2]. A Duration of "days" is strictly defined as 24 hours (86,400 seconds) [8][9][3]. It does not represent a calendar day [8]. Because DateTime.add() operates on absolute time, it is affected by daylight saving time transitions when using local time [2][9]. If a transition (e.g., clocks moving forward or backward) occurs during the interval added, the resulting local time of day may shift, or the calendar date may not match expectations (e.g., adding 1 day might result in the same calendar day or skip one) [2][8][9]. Best Practices To perform calendar-based calculations without DST interference, it is recommended to work exclusively with UTC DateTime objects [8][9][10]. Because UTC does not observe daylight saving time, adding 86,400 seconds consistently results in a shift of exactly one calendar day [8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- strain_backfill.dart ---'
cat -n lib/compute/strain_backfill.dart | sed -n '1,225p'

printf '%s\n' '--- strain backfill tests ---'
cat -n test/strain_rescale_backfill_test.dart | sed -n '120,225p'

printf '%s\n' '--- strainScore references ---'
rg -n -C 8 'ana\.strainScore|strainScore|wakeMinutes|female' lib/compute lib test -g '*.dart'

Repository: OpenStrap/edge

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

if ! command -v dart >/dev/null 2>&1; then
  echo "dart runtime unavailable"
  exit 0
fi

TZ=America/New_York dart - <<'DART'
String legacy(String day, int days) {
  final t = DateTime.parse(day).add(Duration(days: days));
  final mm = t.month.toString().padLeft(2, '0');
  final dd = t.day.toString().padLeft(2, '0');
  return '${t.year}-$mm-$dd';
}

String calendar(String day, int days) {
  final p = day.split('-').map(int.parse).toList();
  final t = DateTime(p[0], p[1], p[2] + days);
  final mm = t.month.toString().padLeft(2, '0');
  final dd = t.day.toString().padLeft(2, '0');
  return '${t.year}-${mm}-${dd}';
}

for (final day in ['2026-03-09', '2026-11-02']) {
  print('$day - 3 days: legacy=${legacy(day, -3)}, '
      'calendar=${calendar(day, -3)}');
}
DART

Repository: OpenStrap/edge

Length of output: 177


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from datetime import date, datetime, timedelta, timezone
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")
day = date(2026, 3, 9)

local_midnight = datetime.combine(day, datetime.min.time(), tzinfo=tz)
elapsed_result = (local_midnight.astimezone(timezone.utc) - timedelta(days=3)).astimezone(tz)
calendar_result = datetime.combine(day + timedelta(days=-3), datetime.min.time(), tzinfo=tz)

print("fixed-duration result:", elapsed_result.isoformat(), elapsed_result.date())
print("calendar-date result:", calendar_result.isoformat(), calendar_result.date())
PY

Repository: OpenStrap/edge

Length of output: 2023


Use local calendar arithmetic in _shiftDays.

Duration(days: days) adds fixed 24-hour periods. Across a DST transition, this can produce the wrong local date label and cutoff. Construct the target with DateTime(year, month, day + days) and format it with dayLabelOf(). Add a spring-forward 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 `@lib/compute/strain_backfill.dart` around lines 209 - 215, Update _shiftDays
to use local calendar arithmetic by constructing the target with DateTime(year,
month, day + days) instead of adding Duration(days: days), then return the
formatted result through dayLabelOf(). Add a test covering a spring-forward DST
transition and verifying the expected date label.

Source: Coding guidelines

Comment on lines +362 to +364
// Today's strain target, in the shape CoachData reads. Absent until
// `strainTarget` has a recovery value, which the surfaces already handle.
'coach': coachToday(cd),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include coach in the empty Today response.

Line 220 returns before _crossDay() runs. If no daily bundle or wake features exist while strain_coach exists, getToday() omits coach and the Coach surfaces lose the target.

Load _crossDay() before the early return. Include 'coach': coachToday(cd) in that return shape. Add a regression test for this path.

As per coding guidelines, “When adding or changing a capability, cover every call path, including all raw decode paths and all relevant export/session triggers.”

🤖 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/data/local_repository_impl.dart` around lines 362 - 364, Update
getToday() to call _crossDay() before its empty-response early return, and
include coachToday(cd) under the 'coach' key in that return shape. Preserve the
existing coach field in the normal response and add a regression test covering
strain_coach without a daily bundle or wake features.

Source: Coding guidelines

Comment on lines +3266 to +3275
final lo = (v['target_min'] as num?)?.toDouble();
final hi = (v['target_max'] as num?)?.toDouble();
if (lo == null || hi == null) return null;
return {
'strain_target': {
'value': (lo + hi) / 2,
'low': lo,
'high': hi,
'rationale': (v['rationale'] ?? '').toString(),
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject inverted strain-target bounds.

coachToday() accepts target_min: 18 and target_max: 13. It then emits low: 18 and high: 13, which is an invalid CoachData range.

  • lib/data/local_repository_impl.dart#L3266-L3275: Return null when lo > hi.
  • test/coach_strain_target_wiring_test.dart#L84-L87: Add a regression expectation for reversed numeric bounds.

As per coding guidelines, “Behavior changes, especially regressions involving readiness, abstention, idempotence, synchronization, migrations, and lifecycle safety, must include regression tests.”

Proposed validation
-  if (lo == null || hi == null) return null;
+  if (lo == null || hi == null || lo > hi) return null;
📍 Affects 2 files
  • lib/data/local_repository_impl.dart#L3266-L3275 (this comment)
  • test/coach_strain_target_wiring_test.dart#L84-L87
🤖 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/data/local_repository_impl.dart` around lines 3266 - 3275, Reject
inverted strain-target bounds in the strain-target mapping logic by returning
null when lo is greater than hi, while preserving valid ranges. Add a regression
expectation for reversed numeric bounds in
test/coach_strain_target_wiring_test.dart at lines 84-87.

Source: Coding guidelines

Comment on lines +136 to +205
group('backfillStrainScale — the stored history', () {
test('rescales a raw-pruned historical day in series AND bundle', () async {
await seedDay('2026-07-09',
trimp: 177.80394321843846, strain: 12.790964777435558,
wornMin: 827, tstMin: 216);
// Data edge, well inside the retention window — must be left for the
// engine to re-derive from raw rather than patched here.
await seedDay('2026-07-20',
trimp: 200, strain: 13.0, wornMin: 900, tstMin: 400,
finalized: false);

final r = await backfillStrainScale(female: false);
expect(r.seriesDays, 1);
expect(r.bundleDays, 1);

// The trend series now carries the rescaled value.
expect(await seriesValue('strain', '2026-07-09'), closeTo(9.03, 0.05));
// …and so does the bundle the day-detail screen reads.
final row = await LocalDb.dayResult('2026-07-09');
expect((row!['algo_version'] as num).toInt(), kAlgoVersion);
final scalars = (jsonDecode(row['payload_json'] as String)
as Map)['scalars'] as Map;
expect((scalars['strain'] as num).toDouble(), closeTo(9.03, 0.05));
// TRIMP is the input, not the output — it must survive untouched.
expect((scalars['trimp'] as num).toDouble(),
closeTo(177.80394321843846, 1e-9));
});

test('leaves days inside the raw-retention window for a real re-derive',
() async {
// Patching these would write a row AT kAlgoVersion, and the derive gate
// matches algo_version EXACTLY — the engine would then skip the day and
// a partial patch would stand in for a full re-derivation.
expect(await seriesValue('strain', '2026-07-20'), closeTo(13.0, 1e-9));
final row = await LocalDb.dayResult('2026-07-20');
expect((row!['algo_version'] as num).toInt(), 62);
});

test('drops the stale intraday curve rather than contradicting the headline',
() async {
// `series.strain_curve` is cumulative strain, one point per wake minute,
// and its last point IS the old headline (12.79 for 2026-07-09). It was
// built from per-sample HR that no longer exists, so it cannot be
// rescaled — and a curve ending at 12.79 under a headline of 9.03 is
// worse than no curve. The UI already renders a missing curve honestly.
final row = await LocalDb.dayResult('2026-07-09');
final payload = jsonDecode(row!['payload_json'] as String) as Map;
final series = payload['series'] as Map?;
expect(series?['strain_curve'], isNull);
// Everything else in the block survives.
expect(series?['hr_curve'], isNotNull);
});

test('is idempotent — a second run rewrites nothing', () async {
final again = await backfillStrainScale(female: false);
expect(again.seriesDays, 0);
expect(again.bundleDays, 0);
expect(await seriesValue('strain', '2026-07-09'), closeTo(9.03, 0.05));
});

test('a day with no stored TRIMP is skipped, not zeroed', () async {
await LocalDb.putComputeFreshness(kStrainRescaleKey, '{}');
await seedDay('2026-06-01',
trimp: null, strain: 11.5, wornMin: 800, tstMin: 200);

final r = await backfillStrainScale(female: false, force: true);
expect(r.skipped, greaterThanOrEqualTo(1));
// Left exactly as it was — an un-rescalable day must not become 0.
expect(await seriesValue('strain', '2026-06-01'), closeTo(11.5, 1e-9));
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make each backfill test independent.

Several tests depend on rows created by earlier tests. For example, the test at Line 164 expects 2026-07-20 to exist, and the tests at Lines 174 and 189 expect the first test to have already run.

Reset and seed the database in setUp(), or combine the ordered assertions into one test. This makes filtered and reordered test execution reliable.

As per coding guidelines, “Behavior changes, especially regressions involving readiness, abstention, idempotence, synchronization, migrations, and lifecycle safety, must include regression tests.”

🤖 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/strain_rescale_backfill_test.dart` around lines 136 - 205, Make the
tests in the backfillStrainScale group independent by resetting and seeding all
required database rows in setUp(), including both historical dates and their
expected payload state. Ensure each test can run alone or in any order, while
preserving the existing assertions for rescaling, raw-retention skipping, curve
removal, idempotence, and missing-TRIMP handling.

Source: Coding guidelines

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant