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:
220
backend/internal/classify/rule.go
Normal file
220
backend/internal/classify/rule.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user