Files
Maven/internal/loop/explain.go
T
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +04:00

141 lines
3.8 KiB
Go

package loop
import (
"time"
"github.com/kami/maven/internal/store"
)
// TickTrace — snapshot of one tick's rule evaluations.
type TickTrace struct {
Now time.Time `json:"now"`
Winner string `json:"winner"` // empty if nothing fired
RuleTraces []RuleTrace `json:"rules"`
}
// RuleTrace — per-rule evaluation result for one tick.
type RuleTrace struct {
RuleName string `json:"rule_name"`
Severity Severity `json:"severity"`
PredicateResult bool `json:"predicate_result"`
GateResult bool `json:"gate_result"`
GateBlockedBy string `json:"gate_blocked_by,omitempty"` // "snooze"|"cooldown"|"quiet_hours"|"calendar_busy"|"presence"|"inert_no_data"|"" (passed)
GateDetail GateDetail `json:"gate_detail,omitempty"`
WasSelected bool `json:"was_selected"`
LostTo string `json:"lost_to,omitempty"` // rule that won instead
}
// GateDetail — snapshot of the values the gate checked.
type GateDetail struct {
SnoozeUntil *time.Time `json:"snooze_until,omitempty"`
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
QuietHours bool `json:"quiet_hours"`
CalendarBusy bool `json:"calendar_busy"`
Presence string `json:"presence"` // "present"|"away"
InertKeysMissing []string `json:"inert_keys_missing,omitempty"`
}
// ExplainGate runs the same checks as Gate() but records the first blocker.
// Returns (passed, blockedBy, detail).
func ExplainGate(s State, r Rule) (bool, string, GateDetail) {
var d GateDetail
d.QuietHours = s.QuietHours
d.CalendarBusy = s.CalendarBusy
d.Presence = "present"
if s.Presence == store.Away {
d.Presence = "away"
}
// Snooze
if until, ok := s.SnoozeUntil[r.Name]; ok && s.Now.Before(until) {
d.SnoozeUntil = &until
return false, "snooze", d
}
// Cooldown
if until, ok := s.CooldownUntil[r.Name]; ok && s.Now.Before(until) {
d.CooldownUntil = &until
return false, "cooldown", d
}
// Quiet hours (care only)
if s.QuietHours && r.Severity.IsCare() {
return false, "quiet_hours", d
}
// Calendar busy (care only)
if s.CalendarBusy && r.Severity.IsCare() {
return false, "calendar_busy", d
}
// Presence away (care only)
if s.Presence == store.Away && r.Severity.IsCare() {
return false, "presence", d
}
// InertWhenNoData
for _, k := range r.InertWhenNoData {
if _, ok := s.Fact(k); !ok {
d.InertKeysMissing = append(d.InertKeysMissing, k)
}
}
if len(d.InertKeysMissing) > 0 {
return false, "inert_no_data", d
}
return true, "", d
}
// ExplainTick evaluates all rules and returns both the best Candidate and
// a full TickTrace explaining every rule's result. Pure: no I/O.
func ExplainTick(s State, rules []Rule) (*Candidate, *TickTrace) {
trace := &TickTrace{
Now: s.Now,
}
var best *Candidate
for _, r := range rules {
tr := RuleTrace{
RuleName: r.Name,
Severity: r.Severity,
}
// Predicate
tr.PredicateResult = r.Predicate(s)
if !tr.PredicateResult {
tr.GateBlockedBy = "predicate"
trace.RuleTraces = append(trace.RuleTraces, tr)
continue
}
// Gate
var passed bool
passed, tr.GateBlockedBy, tr.GateDetail = ExplainGate(s, r)
tr.GateResult = passed
if !passed {
trace.RuleTraces = append(trace.RuleTraces, tr)
continue
}
// Candidate comparison
cand := &Candidate{Rule: r, Severity: r.Severity, State: s}
if best == nil || cand.Severity > best.Severity ||
(cand.Severity == best.Severity && cand.Rule.Name < best.Rule.Name) {
if best != nil {
for i := range trace.RuleTraces {
if trace.RuleTraces[i].RuleName == best.Rule.Name {
trace.RuleTraces[i].LostTo = r.Name
break
}
}
}
best = cand
tr.WasSelected = true
} else {
tr.LostTo = best.Rule.Name
}
trace.RuleTraces = append(trace.RuleTraces, tr)
}
if best != nil {
trace.Winner = best.Rule.Name
}
return best, trace
}