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:
63
backend/internal/classify/engine.go
Normal file
63
backend/internal/classify/engine.go
Normal 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}
|
||||
}
|
||||
190
backend/internal/classify/engine_test.go
Normal file
190
backend/internal/classify/engine_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
125
backend/internal/classify/laps.go
Normal file
125
backend/internal/classify/laps.go
Normal 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
|
||||
}
|
||||
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))
|
||||
}
|
||||
61
backend/internal/classify/rule_test.go
Normal file
61
backend/internal/classify/rule_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user