221 lines
5.3 KiB
Go
221 lines
5.3 KiB
Go
|
|
// 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))
|
||
|
|
}
|