Files
geniusrun/backend/internal/classify/rule.go
Christophe Vila f9d85e16ef Rename smartrun to geniusrun throughout the codebase
Updates the Go module path, cmd/smartrund -> cmd/geniusrund, the
smartrun-dev skill, .gitignore, and every reference in docs/CLAUDE.md
to match.
2026-07-24 21:08:07 +02:00

221 lines
5.3 KiB
Go

// 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))
}