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:
kami
2026-07-05 13:14:58 +04:00
parent 354990fed0
commit 2689db1248
9 changed files with 579 additions and 6 deletions
+140
View File
@@ -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
}
+310
View File
@@ -0,0 +1,310 @@
package loop
import (
"testing"
"time"
"github.com/kami/maven/internal/store"
)
// ----------------------------- ExplainGate --------------------------------
func TestExplainGate_AllPass(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Present}
r := Rule{Name: "test", Severity: Sev3, Predicate: func(State) bool { return true }}
passed, blocked, d := ExplainGate(s, r)
if !passed {
t.Fatalf("all-pass gate: want passed, blocked by %q; detail=%+v", blocked, d)
}
}
func TestExplainGate_Snooze(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
SnoozeUntil: map[string]time.Time{"water": now.Add(time.Hour)},
Facts: map[string]store.Fact{"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour))},
}
r := WaterRule()
passed, blocked, d := ExplainGate(s, r)
if passed || blocked != "snooze" {
t.Fatalf("snooze: want blocked=snooze, got passed=%v blocked=%q", passed, blocked)
}
if d.SnoozeUntil == nil || !d.SnoozeUntil.Equal(now.Add(time.Hour)) {
t.Fatalf("snooze-until missing or wrong: %v", d.SnoozeUntil)
}
}
func TestExplainGate_Cooldown(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)},
Facts: map[string]store.Fact{"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour))},
}
r := WaterRule()
passed, blocked, d := ExplainGate(s, r)
if passed || blocked != "cooldown" {
t.Fatalf("cooldown: want blocked=cooldown, got passed=%v blocked=%q", passed, blocked)
}
if d.CooldownUntil == nil || !d.CooldownUntil.Equal(now.Add(10*time.Minute)) {
t.Fatalf("cooldown-until missing or wrong: %v", d.CooldownUntil)
}
}
func TestExplainGate_QuietHours(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Present, QuietHours: true}
r := WaterRule() // Sev1 — care
passed, blocked, _ := ExplainGate(s, r)
if passed || blocked != "quiet_hours" {
t.Fatalf("quiet hours: want blocked=quiet_hours, got passed=%v blocked=%q", passed, blocked)
}
}
func TestExplainGate_QuietHoursOpsBypass(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Present, QuietHours: true,
Facts: map[string]store.Fact{"netdata_alarm": factAt("netdata_alarm", "poll:netdata", `"critical"`, now.Add(-1*time.Minute))},
}
r := NetdataCriticalRule() // Sev3 — ops
passed, blocked, d := ExplainGate(s, r)
if !passed {
t.Fatalf("ops sev3 should bypass quiet hours, blocked=%q detail=%+v", blocked, d)
}
}
func TestExplainGate_CalendarBusy(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Present, CalendarBusy: true}
r := WaterRule() // Sev1
passed, blocked, _ := ExplainGate(s, r)
if passed || blocked != "calendar_busy" {
t.Fatalf("calendar busy: want blocked=calendar_busy, got passed=%v blocked=%q", passed, blocked)
}
}
func TestExplainGate_PresenceAway(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Away}
r := WaterRule() // Sev1 care
passed, blocked, d := ExplainGate(s, r)
if passed || blocked != "presence" {
t.Fatalf("presence away: want blocked=presence, got passed=%v blocked=%q detail=%+v", passed, blocked, d)
}
if d.Presence != "away" {
t.Fatalf("detail presence: want away, got %q", d.Presence)
}
}
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Away,
Facts: map[string]store.Fact{"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
}
r := ServiceDownRule() // Sev4 ops
passed, blocked, d := ExplainGate(s, r)
if !passed {
t.Fatalf("ops sev4 should bypass away, blocked=%q detail=%+v", blocked, d)
}
}
func TestExplainGate_InertNoData(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Present} // no facts at all
r := WaterRule() // InertWhenNoData: ["water"]
passed, blocked, d := ExplainGate(s, r)
if passed || blocked != "inert_no_data" {
t.Fatalf("inert: want blocked=inert_no_data, got passed=%v blocked=%q", passed, blocked)
}
if len(d.InertKeysMissing) != 1 || d.InertKeysMissing[0] != "water" {
t.Fatalf("inert keys: want [water], got %v", d.InertKeysMissing)
}
}
func TestExplainGate_AllBlockersOrdered(t *testing.T) {
// Snooze is checked before cooldown, quiet hours, etc.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
SnoozeUntil: map[string]time.Time{"water": now.Add(time.Hour)},
CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)},
QuietHours: true,
}
r := WaterRule()
_, blocked, d := ExplainGate(s, r)
if blocked != "snooze" {
t.Fatalf("snooze should be checked first: got blocked=%q", blocked)
}
if d.SnoozeUntil == nil {
t.Fatal("snooze-until should be set")
}
}
// ----------------------------- ExplainTick --------------------------------
func TestExplainTick_NothingFires(t *testing.T) {
now := refTime()
s := State{Now: now, Presence: store.Away}
cand, trace := ExplainTick(s, DefaultRules())
if cand != nil {
t.Fatalf("nil candidate expected, got %+v", cand)
}
if trace.Winner != "" {
t.Fatalf("empty winner expected, got %q", trace.Winner)
}
if !trace.Now.Equal(now) {
t.Fatalf("trace now mismatch")
}
if len(trace.RuleTraces) != len(DefaultRules()) {
t.Fatalf("expected %d rule traces, got %d", len(DefaultRules()), len(trace.RuleTraces))
}
}
func TestExplainTick_OneFires(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
}
cand, trace := ExplainTick(s, DefaultRules())
if cand == nil || cand.Rule.Name != "water" {
t.Fatalf("want water candidate, got %+v", cand)
}
if trace.Winner != "water" {
t.Fatalf("winner water, got %q", trace.Winner)
}
var found bool
for _, r := range trace.RuleTraces {
if r.RuleName == "water" {
found = true
if !r.PredicateResult {
t.Error("water predicate should be true")
}
if !r.GateResult {
t.Error("water gate should be true")
}
if !r.WasSelected {
t.Error("water should be selected")
}
}
}
if !found {
t.Fatal("water rule not in trace")
}
}
func TestExplainTick_Tiebreak(t *testing.T) {
// Two rules with same severity — alphabetically first should win.
now := refTime()
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"meal": factAt("meal", "tap:meal", `"pizza"`, now.Add(-7*time.Hour)),
},
}
cand, trace := ExplainTick(s, []Rule{MealRule(), WaterRule()})
if cand == nil {
t.Fatal("expected a candidate")
}
// Both Sev1, "meal" < "water" alphabetically → meal wins
if cand.Rule.Name != "meal" {
t.Fatalf("tiebreak: want meal, got %q", cand.Rule.Name)
}
if trace.Winner != "meal" {
t.Fatalf("winner should be meal, got %q", trace.Winner)
}
// meal was selected, water lost to meal
var waterFound, mealFound bool
for _, r := range trace.RuleTraces {
switch r.RuleName {
case "water":
waterFound = true
if r.LostTo != "meal" {
t.Errorf("water.LostTo = %q, want meal", r.LostTo)
}
if r.WasSelected {
t.Error("water should not be selected in tiebreak")
}
case "meal":
mealFound = true
if !r.WasSelected {
t.Error("meal should be selected")
}
if r.LostTo != "" {
t.Errorf("meal.LostTo = %q, want empty", r.LostTo)
}
}
}
if !waterFound || !mealFound {
t.Fatal("both rules should be in trace")
}
}
func TestExplainTick_WinnerRecorded(t *testing.T) {
now := refTime()
// water (Sev1) and service_down (Sev4) both want to fire.
s := State{
Now: now,
Presence: store.Present,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
},
}
cand, trace := ExplainTick(s, DefaultRules())
if cand == nil || cand.Rule.Name != "service_down" {
t.Fatalf("max severity wins: want service_down, got %+v", cand)
}
if trace.Winner != "service_down" {
t.Fatalf("winner = %q, want service_down", trace.Winner)
}
// water lost to service_down
var waterLost bool
for _, r := range trace.RuleTraces {
if r.RuleName == "water" && r.LostTo == "service_down" {
waterLost = true
}
}
if !waterLost {
t.Error("water should show lost_to=service_down")
}
}
func TestExplainTick_GateBlockedRecorded(t *testing.T) {
now := refTime()
s := State{
Now: now,
Presence: store.Present,
QuietHours: true,
Facts: map[string]store.Fact{
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
},
}
_, trace := ExplainTick(s, DefaultRules())
for _, tr := range trace.RuleTraces {
if tr.RuleName == "water" {
if tr.GateBlockedBy != "quiet_hours" {
t.Errorf("water blocked by %q, want quiet_hours", tr.GateBlockedBy)
}
if tr.PredicateResult != true {
t.Error("water predicate should be true")
}
if tr.GateResult != false {
t.Error("water gate should be false")
}
}
}
}