// Package classify is geniusrun'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)) }