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 }