238 lines
12 KiB
Markdown
238 lines
12 KiB
Markdown
|
|
# Profile, workout taxonomy & analysis engine foundations
|
|||
|
|
|
|||
|
|
**Status:** Draft — approved by user, pending implementation planning
|
|||
|
|
**Date:** 2026-07-17
|
|||
|
|
|
|||
|
|
## Context
|
|||
|
|
|
|||
|
|
smartrun currently classifies runs into arbitrary, user-created "workout
|
|||
|
|
kinds" using absolute pace/HR thresholds, and has no concept of a user
|
|||
|
|
profile — Garmin credentials are passed as environment variables at process
|
|||
|
|
start, and there is exactly one hardcoded set of engine parameters.
|
|||
|
|
|
|||
|
|
This spec refines that into a fixed running-specific taxonomy, replaces
|
|||
|
|
absolute-pace classification with structural and relative-to-self signals,
|
|||
|
|
adds warm-up/work/cool-down phase detection with per-phase heart-rate
|
|||
|
|
analysis, and introduces a single-profile settings model so all of this is
|
|||
|
|
tunable without redeploying.
|
|||
|
|
|
|||
|
|
Two related features are explicitly **out of scope** for this spec, per the
|
|||
|
|
user's direction to discuss them later once this foundation exists:
|
|||
|
|
|
|||
|
|
- **Deep per-phase workout analysis** beyond the HR metrics described in
|
|||
|
|
section 5 (the user has more detailed expectations to describe once phase
|
|||
|
|
segmentation exists to hang them on).
|
|||
|
|
- **Adaptive pace recommendation** ("delta" between the user's declared pace
|
|||
|
|
ranges and workout-derived ones) — still undecided whether this needs an
|
|||
|
|
AI component or can be fully deterministic.
|
|||
|
|
|
|||
|
|
The pace ranges and expected HR zones introduced here exist as **data
|
|||
|
|
capture** for that future delta feature; nothing in this spec computes or
|
|||
|
|
displays a delta.
|
|||
|
|
|
|||
|
|
## Goals
|
|||
|
|
|
|||
|
|
1. A single-profile settings model: Garmin credentials + all tunable engine
|
|||
|
|
parameters, stored in SQLite instead of environment variables, edited
|
|||
|
|
through one settings screen.
|
|||
|
|
2. A fixed, 7-type running workout taxonomy replacing the current
|
|||
|
|
user-created "workout kinds," each with a user-editable target pace
|
|||
|
|
range (no history — the synced activity log *is* the history).
|
|||
|
|
3. Classification driven by structure and by an activity's *relative*
|
|||
|
|
standing among its own recent history — never by matching the user's
|
|||
|
|
declared target pace ranges.
|
|||
|
|
4. Deterministic warm-up/work/cool-down phase detection per activity, with
|
|||
|
|
per-phase heart-rate analysis (zone, drift, basic recovery signal),
|
|||
|
|
configurable per workout type.
|
|||
|
|
5. A reusable "classification preview" — every activity scored against
|
|||
|
|
every workout type at once — as the shared tool for iteratively tuning
|
|||
|
|
natural-language rule definitions against real history.
|
|||
|
|
6. A "recompute" action that re-runs phase detection and reclassification
|
|||
|
|
across all activities, since relative metrics and phase parameters can
|
|||
|
|
change what a past activity should have been classified as.
|
|||
|
|
|
|||
|
|
## Non-goals
|
|||
|
|
|
|||
|
|
- Multi-profile switching / multi-tenant data (explicitly declined — one
|
|||
|
|
active profile at a time; the schema does not need per-row profile
|
|||
|
|
scoping, a singleton row is sufficient).
|
|||
|
|
- The delta/recommendation calculation itself (section above).
|
|||
|
|
- Deep per-phase analysis beyond HR zone/drift/basic recovery signal.
|
|||
|
|
- Any AI/LLM involvement at runtime — classification and phase detection
|
|||
|
|
are fully deterministic, tuned offline against real data before being
|
|||
|
|
encoded as rules/parameters.
|
|||
|
|
|
|||
|
|
## 1. Profile
|
|||
|
|
|
|||
|
|
A new **`profile`** table, a singleton row (`id = 1`, following the existing
|
|||
|
|
`sync_state` pattern), replacing environment-variable Garmin credentials and
|
|||
|
|
consolidating all tunable engine parameters:
|
|||
|
|
|
|||
|
|
- `garmin_email`, `garmin_password` — moved out of env vars; entered once
|
|||
|
|
via the profile screen.
|
|||
|
|
- `rolling_window_days` (default `90`) — the population window for
|
|||
|
|
relative-classification metrics (section 3).
|
|||
|
|
- `max_heart_rate`, `resting_heart_rate` — inputs to the Heart Rate Reserve
|
|||
|
|
(Karvonen) calculation used for zone detection (section 5).
|
|||
|
|
- Five HR zone ranges, each a `(min_pct, max_pct)` pair of "% of heart rate
|
|||
|
|
reserve," seeded with standard defaults (Z1 50–60, Z2 60–70, Z3 70–80,
|
|||
|
|
Z4 80–90, Z5 90–100) and user-editable.
|
|||
|
|
- Per-workout-type phase-detection parameters (section 4) — e.g.
|
|||
|
|
`easy_warmup_minutes`, `easy_cooldown_minutes`, `interval_warmup_minutes`,
|
|||
|
|
one pair per non-lap-based type.
|
|||
|
|
|
|||
|
|
`internal/config` (env-var loading) shrinks to just `SMARTRUN_ADDR`,
|
|||
|
|
`SMARTRUN_DB_PATH`, and the mcp-garmin subprocess paths — everything
|
|||
|
|
runtime-tunable moves into `profile`.
|
|||
|
|
|
|||
|
|
**Validation** (rejected at save time, not silently accepted):
|
|||
|
|
resting HR < max HR; zone ranges ascending and non-overlapping,
|
|||
|
|
collectively covering 0–100%.
|
|||
|
|
|
|||
|
|
**Credential updates & re-authentication.** `garmin.Client` currently takes
|
|||
|
|
a fixed `Config` (including email/password) at construction and lazily
|
|||
|
|
spawns the mcp-garmin subprocess on first use — credentials are only ever
|
|||
|
|
whatever the process was launched with. Saving new Garmin credentials via
|
|||
|
|
the profile screen must:
|
|||
|
|
|
|||
|
|
1. Update the running `garmin.Client`'s credentials in memory.
|
|||
|
|
2. Reset its "started" state and terminate any already-spawned subprocess
|
|||
|
|
(which would otherwise still be running under the *old* credentials).
|
|||
|
|
|
|||
|
|
The next "Connect to Garmin" click — the connect button and MFA-code entry
|
|||
|
|
UI already built for the previous single-env-var-credential model — then
|
|||
|
|
spawns a fresh subprocess with the newly-saved credentials and proceeds
|
|||
|
|
through the existing authenticate/MFA flow unchanged. No new UI is needed;
|
|||
|
|
saving the profile just makes that existing flow reachable at any time,
|
|||
|
|
not only right after process launch.
|
|||
|
|
|
|||
|
|
## 2. Workout taxonomy & pace ranges
|
|||
|
|
|
|||
|
|
The existing `workout_kinds` table is reseeded with exactly seven fixed
|
|||
|
|
rows and loses its "create new" / "delete" affordances in the UI — only
|
|||
|
|
`rule_json` (and the new phase/HR fields below) remain editable per type:
|
|||
|
|
|
|||
|
|
`Easy Run`, `Long Run`, `Threshold 30'`, `Threshold 60'`, `Tempo`,
|
|||
|
|
`Interval`, `MAS Test`.
|
|||
|
|
|
|||
|
|
A new **`workout_type_paces`** table, one row per workout kind:
|
|||
|
|
|
|||
|
|
- `pace_min_sec_per_km`, `pace_max_sec_per_km` — entered/displayed as
|
|||
|
|
`m:ss–m:ss`.
|
|||
|
|
- `expected_hr_zone` — which of the 5 zones this type should predominantly
|
|||
|
|
sit in (e.g. Easy Run → Zone 2), used later by the per-phase HR check
|
|||
|
|
(section 5), not by classification.
|
|||
|
|
|
|||
|
|
Neither field has history — overwritten in place when the user updates
|
|||
|
|
them, per the user's explicit direction (the activity log is the history).
|
|||
|
|
|
|||
|
|
## 3. Classification: relative & structural metrics
|
|||
|
|
|
|||
|
|
New metrics available to `internal/classify`'s condition-tree engine,
|
|||
|
|
computed fresh at classification time (no cached/materialized column):
|
|||
|
|
|
|||
|
|
- **`distance_percentile`, `duration_percentile`** — this activity's rank
|
|||
|
|
(0–1) among all *running* activities (any type except MAS Test) whose
|
|||
|
|
start date falls within `rolling_window_days` of *this activity's own
|
|||
|
|
date* (not "today" — so reclassifying an old activity is stable and
|
|||
|
|
doesn't depend on when reclassification happens to run). Computed via a
|
|||
|
|
straightforward SQL ranking query over `activities`.
|
|||
|
|
- **`lap_pace_consistency`** — coefficient of variation of pace across the
|
|||
|
|
work phase specifically (depends on section 4's phase boundaries), a low
|
|||
|
|
value indicating "constant pace" — the structural signal the user
|
|||
|
|
described for Long Run, replacing any pace-target comparison.
|
|||
|
|
- Existing metrics (`lap_interval_pattern`, `lap_hr_drift_bpm_per_min`,
|
|||
|
|
`lap_hr_recovery_bpm_per_min`, `aerobic_training_effect`, etc.) remain
|
|||
|
|
available unchanged.
|
|||
|
|
|
|||
|
|
**`ReclassifyAll`**: a new bulk operation alongside the existing per-activity
|
|||
|
|
`ClassifyActivity`, iterating every stored activity and re-evaluating it.
|
|||
|
|
Necessary because relative metrics depend on the whole population — a
|
|||
|
|
rolling-window setting change (or a new activity altering others'
|
|||
|
|
percentile rank) can change what an old activity should be classified as,
|
|||
|
|
not just new ones. Per-activity failures are logged and counted, not fatal
|
|||
|
|
to the batch.
|
|||
|
|
|
|||
|
|
**Classification preview**: a new read path (`GET
|
|||
|
|
/api/classification-preview` or similar) returning, for every activity,
|
|||
|
|
*every* workout type's evaluation — matched or not, with score — not just
|
|||
|
|
the ones that crossed the confidence threshold (unlike `kind_assignments`,
|
|||
|
|
which only records matched candidates). This is the shared tool for the
|
|||
|
|
natural-language rule-tuning sessions: change a rule, hit preview, see the
|
|||
|
|
whole table shift.
|
|||
|
|
|
|||
|
|
## 4. Phase segmentation
|
|||
|
|
|
|||
|
|
A new **`activity_phases`** table: one row per detected phase per activity
|
|||
|
|
— `activity_id`, `phase_label` (`warmup` / `work` / `cooldown` for
|
|||
|
|
continuous types; `warmup` / `work_1` / `rest_1` / `work_2` / ... /
|
|||
|
|
`cooldown` for Interval), `start_elapsed_seconds`, `end_elapsed_seconds`.
|
|||
|
|
|
|||
|
|
Two phase-detection strategies, selected per workout type (not one
|
|||
|
|
one-size-fits-all algorithm):
|
|||
|
|
|
|||
|
|
- **`fixed_duration`** (Easy, Long, Tempo, Threshold-30/60, first pass at
|
|||
|
|
MAS Test): warm-up = first *N* configured minutes (`profile`'s
|
|||
|
|
per-type setting), cool-down = last *M* configured minutes, work =
|
|||
|
|
everything between.
|
|||
|
|
- **`lap_intensity`** (Interval): reuses the existing Garmin lap
|
|||
|
|
`IntensityType` tagging (ACTIVE/REST) built earlier — warm-up = before
|
|||
|
|
the first ACTIVE lap, work = the ACTIVE/REST lap sequence itself (each
|
|||
|
|
rep an individually labeled phase), cool-down = after the last one.
|
|||
|
|
|
|||
|
|
Phases are computed as part of the existing post-sync detail-fill step
|
|||
|
|
(`internal/sync`), stored once, and only recomputed via the explicit
|
|||
|
|
recompute action (section 6) — the same lifecycle as classification.
|
|||
|
|
|
|||
|
|
**Per-phase heart-rate analysis**, computed from `activity_samples` within
|
|||
|
|
each phase's elapsed-time window:
|
|||
|
|
|
|||
|
|
- `avg_hr`, `max_hr` over the phase.
|
|||
|
|
- `hr_zone` — the phase's average HR mapped to one of the 5 Karvonen zones
|
|||
|
|
from `profile`, compared against that workout type's `expected_hr_zone`
|
|||
|
|
(section 2) so a mismatch (e.g. an Easy Run run in Zone 4) is visible.
|
|||
|
|
- `hr_drift_bpm_per_min` — reuses the existing lap-level drift regression
|
|||
|
|
(already generic over any sample window), applied to the phase's window
|
|||
|
|
instead of a lap's.
|
|||
|
|
- A basic recovery signal for cool-down/rest phases (reusing the existing
|
|||
|
|
HR-recovery regression). The more nuanced version the user described —
|
|||
|
|
correlating HR fall against recovery *pace*, not HR alone — captures its
|
|||
|
|
raw ingredients here (per-phase HR trend + per-phase pace) but the
|
|||
|
|
composite "recovery quality" metric itself is deferred to the future deep
|
|||
|
|
per-phase analysis discussion.
|
|||
|
|
|
|||
|
|
## 5. Settings panel & frontend
|
|||
|
|
|
|||
|
|
- **Profile screen**: Garmin credentials, rolling window, max/resting HR,
|
|||
|
|
the 5 zone ranges, per-type phase parameters, and (from section 2)
|
|||
|
|
per-type pace ranges + expected HR zone. One screen, one save, validated
|
|||
|
|
per section 1.
|
|||
|
|
- **Recompute action**: re-runs phase segmentation then `ReclassifyAll`
|
|||
|
|
across all activities. Reuses the existing sync-progress-banner pattern
|
|||
|
|
(live done/total) since this walks the whole history.
|
|||
|
|
- **Classification preview page**: the table from section 3.
|
|||
|
|
- **Activity detail page** (new — no per-activity view exists today):
|
|||
|
|
pace/HR-vs-time chart (Recharts) with phase bands overlaid as
|
|||
|
|
`ReferenceArea`s, plus a per-phase readout (HR zone, drift, avg pace).
|
|||
|
|
|
|||
|
|
## 6. Testing & error handling
|
|||
|
|
|
|||
|
|
- Phase detection and the new classification metrics are pure functions
|
|||
|
|
tested against fixture sample/lap data, independent of the database or
|
|||
|
|
Garmin — same pattern as the existing `internal/classify` tests.
|
|||
|
|
- `ReclassifyAll` and the phase-recompute pass are per-activity fault
|
|||
|
|
isolated: one activity's failure is logged and counted, not fatal to the
|
|||
|
|
batch; the recompute status reports a failure count alongside progress.
|
|||
|
|
- Profile save validates HR zone ranges (ascending, non-overlapping, full
|
|||
|
|
0–100% coverage) and resting-HR-less-than-max-HR before persisting,
|
|||
|
|
returning a specific error message rather than accepting invalid state.
|
|||
|
|
|
|||
|
|
## Open questions carried forward (not blocking this spec)
|
|||
|
|
|
|||
|
|
- Exact per-type natural-language rule definitions and phase parameters —
|
|||
|
|
to be tuned interactively against the user's real Garmin history using
|
|||
|
|
the classification preview (section 3) once this foundation is built.
|
|||
|
|
- The deep per-phase analysis feature beyond HR zone/drift/recovery.
|
|||
|
|
- Whether the pace/HR-zone delta recommendation is AI-assisted or fully
|
|||
|
|
deterministic.
|