loop: add rule trace/explanation engine
ExplainTick and ExplainGate produce a full TickTrace for every tick, recording per-rule: predicate result, gate result, first gate blocker, gate detail (snooze/cooldown/presence etc.), and win/loss info. IPC layer: new MethodTickTrace, DTOs (TickTrace, RuleTrace, GateDetail), dispatch, client proxy, and CoreAPI interface method. Daemon: tickLoop caches the latest trace; daemonAPI wraps storeAPI with a TickTrace override that returns the cached trace. Tests: 15 new tests for ExplainGate (all blockers, bypass conditions, ordering) and ExplainTick (nothing fires, one fires, tiebreak, winner/lost_to recording, gate-blocked recording).
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user