Initial commit: smartrun MVP

Garmin run classification and progression tracker. Go backend (MCP
client to mcp-garmin, SQLite store, deterministic rule engine, REST
API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:33:06 +02:00
commit f689f74ae0
69 changed files with 10666 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
package classify
import "sort"
const (
StatusAssigned = "assigned"
StatusNeedsReview = "needs_review"
// DefaultMinConfidence is the score below which even a single matching
// kind is sent to manual review rather than auto-assigned.
DefaultMinConfidence = 0.6
)
// RuleKind is one active workout kind's rule, as loaded from the store.
type RuleKind struct {
WorkoutKindID int64
Name string
Rule Node
}
// ScoredKind is one kind that matched an activity's metrics, with its
// confidence score.
type ScoredKind struct {
WorkoutKindID int64 `json:"workout_kind_id"`
Name string `json:"name"`
Score float64 `json:"score"`
}
// Result is the outcome of classifying one activity.
type Result struct {
Status string
WorkoutKindID *int64
Confidence *float64
Candidates []ScoredKind // every kind that matched, sorted by score descending
}
// Classify evaluates every active kind's rule against ctx and decides
// whether the activity is cleanly assignable, or needs manual review
// because zero kinds matched, multiple kinds matched, or the single match's
// confidence fell below minConfidence.
func Classify(ctx MetricContext, kinds []RuleKind, minConfidence float64) Result {
candidates := []ScoredKind{}
for _, k := range kinds {
matched, score := k.Rule.Evaluate(ctx)
if matched {
candidates = append(candidates, ScoredKind{WorkoutKindID: k.WorkoutKindID, Name: k.Name, Score: score})
}
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Score > candidates[j].Score })
if len(candidates) != 1 {
return Result{Status: StatusNeedsReview, Candidates: candidates}
}
only := candidates[0]
if only.Score < minConfidence {
return Result{Status: StatusNeedsReview, Candidates: candidates}
}
id := only.WorkoutKindID
score := only.Score
return Result{Status: StatusAssigned, WorkoutKindID: &id, Confidence: &score, Candidates: candidates}
}

View File

@@ -0,0 +1,190 @@
package classify
import "testing"
func easyRule() Node {
return Node{Match: MatchAll, Conditions: []Node{
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{330.0, 420.0}},
{Metric: "avg_hr_pct_max", Op: OpLte, Value: 0.75},
}}
}
func tempoRule() Node {
return Node{Match: MatchAll, Conditions: []Node{
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{270.0, 330.0}},
{Metric: "avg_hr_pct_max", Op: OpGte, Value: 0.80},
}}
}
func testKinds() []RuleKind {
return []RuleKind{
{WorkoutKindID: 1, Name: "Easy", Rule: easyRule()},
{WorkoutKindID: 2, Name: "Tempo", Rule: tempoRule()},
}
}
func TestClassify_CleanSingleMatch(t *testing.T) {
ctx := MetricContext{"avg_pace_sec_per_km": 375, "avg_hr_pct_max": 0.65}
result := Classify(ctx, testKinds(), DefaultMinConfidence)
if result.Status != StatusAssigned {
t.Fatalf("Status = %q, want %q", result.Status, StatusAssigned)
}
if result.WorkoutKindID == nil || *result.WorkoutKindID != 1 {
t.Fatalf("WorkoutKindID = %v, want 1 (Easy)", result.WorkoutKindID)
}
if result.Confidence == nil || *result.Confidence < DefaultMinConfidence {
t.Fatalf("Confidence = %v, want >= %v", result.Confidence, DefaultMinConfidence)
}
}
func TestClassify_AmbiguousMultiMatch(t *testing.T) {
// Overlapping rule zone: a kind covering the same pace range as both
// Easy and Tempo, so a run in the overlap matches two kinds at once.
overlap := RuleKind{WorkoutKindID: 3, Name: "Overlap", Rule: Node{
Match: MatchAll, Conditions: []Node{
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{300.0, 400.0}},
},
}}
kinds := append(testKinds(), overlap)
ctx := MetricContext{"avg_pace_sec_per_km": 375, "avg_hr_pct_max": 0.65}
result := Classify(ctx, kinds, DefaultMinConfidence)
if result.Status != StatusNeedsReview {
t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview)
}
if len(result.Candidates) < 2 {
t.Fatalf("expected >= 2 candidates for ambiguous match, got %d: %+v", len(result.Candidates), result.Candidates)
}
if result.WorkoutKindID != nil {
t.Fatalf("WorkoutKindID should be nil when needs_review, got %v", *result.WorkoutKindID)
}
}
func TestClassify_NoMatch(t *testing.T) {
// A very slow, low-HR run that fits neither Easy nor Tempo's pace range.
ctx := MetricContext{"avg_pace_sec_per_km": 600, "avg_hr_pct_max": 0.55}
result := Classify(ctx, testKinds(), DefaultMinConfidence)
if result.Status != StatusNeedsReview {
t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview)
}
if len(result.Candidates) != 0 {
t.Fatalf("expected 0 candidates for no match, got %d: %+v", len(result.Candidates), result.Candidates)
}
}
func TestClassify_LowConfidenceSingleMatchNeedsReview(t *testing.T) {
// Right at the edge of Easy's pace window and right at the HR ceiling --
// technically matches but only barely, so confidence should be low.
ctx := MetricContext{"avg_pace_sec_per_km": 419, "avg_hr_pct_max": 0.75}
result := Classify(ctx, testKinds(), 0.9) // deliberately strict threshold
if result.Status != StatusNeedsReview {
t.Fatalf("Status = %q, want %q (low confidence should force review)", result.Status, StatusNeedsReview)
}
if len(result.Candidates) != 1 {
t.Fatalf("expected exactly 1 (low-confidence) candidate, got %d", len(result.Candidates))
}
}
func TestClassify_MissingMetricDoesNotMatch(t *testing.T) {
ctx := MetricContext{"avg_pace_sec_per_km": 375} // avg_hr_pct_max absent
result := Classify(ctx, testKinds(), DefaultMinConfidence)
if result.Status != StatusNeedsReview {
t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview)
}
}
func TestDetectIntervalPattern(t *testing.T) {
cases := []struct {
name string
laps []LapInfo
want bool
}{
{
name: "clear interval workout: alternating active/rest",
laps: []LapInfo{
{IntensityType: "ACTIVE"}, {IntensityType: "REST"},
{IntensityType: "ACTIVE"}, {IntensityType: "REST"},
},
want: true,
},
{
name: "long run with a single hill lap should not look like intervals",
laps: []LapInfo{
{IntensityType: "ACTIVE"}, {IntensityType: "ACTIVE"}, {IntensityType: "ACTIVE"},
},
want: false,
},
{
name: "warmup + single effort + cooldown is not repeated intervals",
laps: []LapInfo{
{IntensityType: "WARMUP"}, {IntensityType: "ACTIVE"}, {IntensityType: "COOLDOWN"},
},
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := DetectIntervalPattern(c.laps)
if got != c.want {
t.Errorf("DetectIntervalPattern() = %v, want %v", got, c.want)
}
})
}
}
func hr(v float64) *float64 { return &v }
func TestHRDrift_RisingHeartRateDetected(t *testing.T) {
var samples []SampleInfo
for i := 0; i < 20; i++ {
samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 10), HeartRate: hr(140 + float64(i))})
}
drift, ok := HRDrift(samples)
if !ok {
t.Fatal("expected ok=true with enough samples")
}
if drift <= 0 {
t.Errorf("drift = %v, want positive (rising HR)", drift)
}
}
func TestHRDrift_TooFewSamplesIsNotOk(t *testing.T) {
samples := []SampleInfo{{ElapsedSeconds: 0, HeartRate: hr(140)}, {ElapsedSeconds: 10, HeartRate: hr(142)}}
_, ok := HRDrift(samples)
if ok {
t.Error("expected ok=false with too few samples")
}
}
func TestHRRecovery_FallingHeartRateIsPositiveRecoveryRate(t *testing.T) {
var samples []SampleInfo
for i := 0; i < 20; i++ {
samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 5), HeartRate: hr(170 - float64(i)*2)})
}
recovery, ok := HRRecovery(samples)
if !ok {
t.Fatal("expected ok=true with enough samples")
}
if recovery <= 0 {
t.Errorf("recovery = %v, want positive (HR dropping)", recovery)
}
}
func TestHRRecovery_StillRisingIsNegative(t *testing.T) {
var samples []SampleInfo
for i := 0; i < 20; i++ {
samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 5), HeartRate: hr(120 + float64(i))})
}
recovery, ok := HRRecovery(samples)
if !ok {
t.Fatal("expected ok=true with enough samples")
}
if recovery >= 0 {
t.Errorf("recovery = %v, want negative (HR still rising during recovery lap)", recovery)
}
}

View File

@@ -0,0 +1,125 @@
package classify
import (
"math"
"strings"
)
// LapInfo is the subset of a lap's fields needed for interval-pattern
// detection, independent of internal/store's row representation.
type LapInfo struct {
IntensityType string
}
// DetectIntervalPattern reports whether an activity's laps look like a
// structured interval workout, using Garmin's own per-lap IntensityType
// tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it
// from pace variance -- the device/app already knows which laps were work
// vs rest segments when the activity was recorded as a structured workout.
func DetectIntervalPattern(laps []LapInfo) bool {
active, rest := 0, 0
for _, l := range laps {
switch strings.ToUpper(l.IntensityType) {
case "ACTIVE":
active++
case "REST", "RECOVERY", "COOLDOWN", "WARMUP":
rest++
}
}
return active >= 2 && rest >= 2
}
// LapPaceStdDev returns the standard deviation of per-lap pace (any
// consistent unit, e.g. sec/km), a fallback signal for "uneven pacing" on
// activities without formal interval tagging.
func LapPaceStdDev(paces []float64) float64 {
n := float64(len(paces))
if n == 0 {
return 0
}
var sum float64
for _, p := range paces {
sum += p
}
mean := sum / n
var variance float64
for _, p := range paces {
variance += (p - mean) * (p - mean)
}
return math.Sqrt(variance / n)
}
// SampleInfo is one HR-bearing telemetry sample within a lap's time window.
type SampleInfo struct {
ElapsedSeconds float64
HeartRate *float64
}
// minSamplesForTrend is the fewest HR readings needed before a drift/recovery
// slope is considered meaningful rather than noise.
const minSamplesForTrend = 10
// HRTrend fits a line to heart rate vs elapsed time across samples and
// returns its slope in bpm/minute. Returns ok=false if there aren't enough
// HR readings to trust the result.
func HRTrend(samples []SampleInfo) (bpmPerMin float64, ok bool) {
var xs, ys []float64
for _, s := range samples {
if s.HeartRate == nil {
continue
}
xs = append(xs, s.ElapsedSeconds)
ys = append(ys, *s.HeartRate)
}
if len(xs) < minSamplesForTrend {
return 0, false
}
slope, ok := linregSlope(xs, ys)
if !ok {
return 0, false
}
return slope * 60, true
}
// HRDrift is HRTrend applied to an active/effort lap's samples: a positive
// result means heart rate is climbing over the interval (cardiac drift) for
// a comparable effort level.
func HRDrift(samples []SampleInfo) (bpmPerMin float64, ok bool) {
return HRTrend(samples)
}
// HRRecovery is HRTrend applied to a recovery/rest lap's samples, sign-
// flipped so a positive result means heart rate is dropping (higher =
// better recovery) and a negative result flags heart rate still rising
// during what was supposed to be a rest interval.
func HRRecovery(samples []SampleInfo) (bpmDropPerMin float64, ok bool) {
slope, ok := HRTrend(samples)
if !ok {
return 0, false
}
return -slope, true
}
func linregSlope(xs, ys []float64) (float64, bool) {
n := float64(len(xs))
if n < 2 {
return 0, false
}
var sumX, sumY float64
for i := range xs {
sumX += xs[i]
sumY += ys[i]
}
xbar, ybar := sumX/n, sumY/n
var num, den float64
for i := range xs {
dx := xs[i] - xbar
num += dx * (ys[i] - ybar)
den += dx * dx
}
if den == 0 {
return 0, false
}
return num / den, true
}

View File

@@ -0,0 +1,220 @@
// Package classify is smartrun's classification rule engine: it evaluates a
// user-editable AND/OR condition tree against a run's metrics to decide
// which "workout kind" (Easy, Tempo, Threshold, ...) it belongs to. Pure
// logic only -- no I/O, no database, no Garmin client -- so it's fully
// unit-testable against fixture data.
package classify
import (
"fmt"
"math"
)
// Node is one node of a workout kind's rule condition tree. A branch node
// sets Match ("all" or "any") and Conditions (children); a leaf node sets
// Metric, Op, and Value instead.
type Node struct {
Match string `json:"match,omitempty"`
Conditions []Node `json:"conditions,omitempty"`
Metric string `json:"metric,omitempty"`
Op string `json:"op,omitempty"`
Value any `json:"value,omitempty"`
}
const (
MatchAll = "all"
MatchAny = "any"
OpEq = "=="
OpNeq = "!="
OpGt = ">"
OpGte = ">="
OpLt = "<"
OpLte = "<="
OpBetween = "between"
)
// Validate reports whether a rule tree is well-formed, without needing a
// MetricContext to evaluate against. Intended for the API layer to give
// immediate feedback when a user edits a workout kind's rule.
func (n Node) Validate() error {
if n.Match != "" {
if n.Match != MatchAll && n.Match != MatchAny {
return fmt.Errorf("invalid match %q, want %q or %q", n.Match, MatchAll, MatchAny)
}
if len(n.Conditions) == 0 {
return fmt.Errorf("branch node %q has no conditions", n.Match)
}
for i, c := range n.Conditions {
if err := c.Validate(); err != nil {
return fmt.Errorf("condition %d: %w", i, err)
}
}
return nil
}
if n.Metric == "" {
return fmt.Errorf("leaf node missing metric")
}
switch n.Op {
case OpEq, OpNeq, OpGt, OpGte, OpLt, OpLte:
if n.Value == nil {
return fmt.Errorf("metric %q: op %q requires a value", n.Metric, n.Op)
}
case OpBetween:
arr, ok := n.Value.([]any)
if !ok || len(arr) != 2 {
return fmt.Errorf("metric %q: op %q requires a 2-element array value", n.Metric, n.Op)
}
default:
return fmt.Errorf("metric %q: unsupported op %q", n.Metric, n.Op)
}
return nil
}
// MetricContext is the set of computed metrics for one activity that a rule
// tree is evaluated against. Boolean metrics (e.g. has_interval_pattern) are
// represented as 1.0/0.0.
type MetricContext map[string]float64
// Evaluate recursively evaluates the tree against ctx, returning whether it
// matched and a confidence score. For branch nodes, "all" aggregates scores
// via min and requires every child matched; "any" aggregates via max and
// requires at least one child matched.
func (n Node) Evaluate(ctx MetricContext) (matched bool, score float64) {
if n.Match != "" {
switch n.Match {
case MatchAll:
matched = true
score = math.Inf(1)
for _, c := range n.Conditions {
m, s := c.Evaluate(ctx)
if !m {
matched = false
}
if s < score {
score = s
}
}
case MatchAny:
matched = false
score = math.Inf(-1)
for _, c := range n.Conditions {
m, s := c.Evaluate(ctx)
if m {
matched = true
}
if s > score {
score = s
}
}
default:
return false, 0
}
return matched, score
}
return evaluateLeaf(n, ctx)
}
func evaluateLeaf(n Node, ctx MetricContext) (matched bool, score float64) {
v, ok := ctx[n.Metric]
if !ok {
return false, 0
}
switch n.Op {
case OpEq, OpNeq:
want, ok := toFloat(n.Value)
if !ok {
return false, 0
}
eq := v == want
if n.Op == OpNeq {
eq = !eq
}
if eq {
return true, 1
}
return false, 0
case OpGt, OpGte, OpLt, OpLte:
threshold, ok := toFloat(n.Value)
if !ok {
return false, 0
}
var margin float64
switch n.Op {
case OpGt:
matched = v > threshold
margin = v - threshold
case OpGte:
matched = v >= threshold
margin = v - threshold
case OpLt:
matched = v < threshold
margin = threshold - v
case OpLte:
matched = v <= threshold
margin = threshold - v
}
return matched, squash(margin, scaleFor(threshold))
case OpBetween:
arr, ok := n.Value.([]any)
if !ok || len(arr) != 2 {
return false, 0
}
lo, ok1 := toFloat(arr[0])
hi, ok2 := toFloat(arr[1])
if !ok1 || !ok2 || lo > hi {
return false, 0
}
matched = v >= lo && v <= hi
mid := (lo + hi) / 2
halfRange := (hi - lo) / 2
if halfRange == 0 {
halfRange = 1
}
distance := math.Abs(v - mid)
score := 1 - distance/halfRange // 1.0 centered, 0 at boundary, negative outside
return matched, score
default:
return false, 0
}
}
func toFloat(v any) (float64, bool) {
switch t := v.(type) {
case float64:
return t, true
case bool:
if t {
return 1, true
}
return 0, true
case int:
return float64(t), true
default:
return 0, false
}
}
// scaleFor picks a margin-to-score scaling factor proportional to the
// threshold's magnitude, so e.g. a 30-second margin on a ~300s threshold
// scores similarly to a 3-minute margin on a ~1800s threshold.
func scaleFor(threshold float64) float64 {
abs := math.Abs(threshold)
if abs < 1e-9 {
return 1
}
return 5 / abs
}
// squash maps a signed margin to a 0..1 score via a logistic curve: 0 margin
// (exactly at the threshold) scores 0.5, comfortably-matched margins
// approach 1, comfortably-unmatched margins approach 0.
func squash(margin, scale float64) float64 {
return 1 / (1 + math.Exp(-margin*scale))
}

View File

@@ -0,0 +1,61 @@
package classify
import (
"encoding/json"
"testing"
)
func TestNode_ValidateAcceptsWellFormedTree(t *testing.T) {
n := Node{Match: MatchAll, Conditions: []Node{
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{270.0, 300.0}},
{Match: MatchAny, Conditions: []Node{
{Metric: "aerobic_training_effect", Op: OpGte, Value: 3.5},
{Metric: "lap_interval_pattern", Op: OpEq, Value: true},
}},
}}
if err := n.Validate(); err != nil {
t.Errorf("Validate() = %v, want nil", err)
}
}
func TestNode_ValidateRejectsMalformed(t *testing.T) {
cases := []struct {
name string
n Node
}{
{"unknown match", Node{Match: "xor", Conditions: []Node{{Metric: "x", Op: OpGt, Value: 1.0}}}},
{"branch with no conditions", Node{Match: MatchAll}},
{"leaf with no metric", Node{Op: OpGt, Value: 1.0}},
{"between with non-array value", Node{Metric: "x", Op: OpBetween, Value: 5.0}},
{"between with wrong-length array", Node{Metric: "x", Op: OpBetween, Value: []any{1.0}}},
{"unsupported op", Node{Metric: "x", Op: "~=", Value: 1.0}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := c.n.Validate(); err == nil {
t.Error("Validate() = nil, want an error")
}
})
}
}
func TestNode_RoundTripsThroughJSON(t *testing.T) {
raw := `{
"match": "all",
"conditions": [
{"metric": "avg_pace_sec_per_km", "op": "between", "value": [270, 300]},
{"metric": "lap_interval_pattern", "op": "==", "value": true}
]
}`
var n Node
if err := json.Unmarshal([]byte(raw), &n); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if err := n.Validate(); err != nil {
t.Fatalf("Validate() after round-trip = %v", err)
}
matched, _ := n.Evaluate(MetricContext{"avg_pace_sec_per_km": 285, "lap_interval_pattern": 1})
if !matched {
t.Error("expected match after JSON round-trip")
}
}