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
+6 -3
View File
@@ -175,10 +175,13 @@ func run(args []string) error {
tickInterval := time.Duration(cfg.TickInterval)
repeatInterval := time.Duration(cfg.RepeatInterval)
autotuneInterval := time.Duration(cfg.AutotuneInterval)
loop := newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest)
tl := newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest)
// ----- IPC boundary (core ↔ modules) -----
coreAPI := ipc.NewStoreAPI(st)
coreAPI := &daemonAPI{
CoreAPI: ipc.NewStoreAPI(st),
getTrace: tl.trace,
}
srv, err := ipc.Listen(cfg.SocketPath, coreAPI)
if err != nil {
return fmt.Errorf("ipc listen: %w", err)
@@ -216,7 +219,7 @@ func run(args []string) error {
wg.Add(1)
go func() {
defer wg.Done()
loop.run(ctx)
tl.run(ctx)
}()
<-ctx.Done()
+66 -2
View File
@@ -20,6 +20,7 @@ import (
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
@@ -62,8 +63,9 @@ type tickLoop struct {
// IS the insistence signal). keyed by rule name. nil phrase for a rule
// = no successful initial dispatch yet (cold-start edge — fall back to
// a generic body).
mu sync.Mutex
mu sync.Mutex
lastPhrase map[string]delivery.PhrasedNudge
lastTrace *loop.TickTrace // cached from the most recent tick
}
func newTickLoop(
@@ -133,7 +135,11 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
}
// proactive: at most one candidate, max severity.
if cand := loop.Tick(state, t.rules); cand != nil {
cand, trace := loop.ExplainTick(state, t.rules)
t.mu.Lock()
t.lastTrace = trace
t.mu.Unlock()
if cand != nil {
if t.shouldQueue(cand) {
t.queueNudge(ctx, cand, state, now)
} else {
@@ -363,4 +369,62 @@ func defaultConfigPath() string {
return "mavend.json"
}
return filepath.Join(home, ".config", "maven", "mavend.json")
}
// trace returns the most recent TickTrace, or nil if no tick has run yet.
func (t *tickLoop) trace() *loop.TickTrace {
t.mu.Lock()
defer t.mu.Unlock()
return t.lastTrace
}
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
// daemon's in-memory tick trace cache.
type daemonAPI struct {
ipc.CoreAPI
getTrace func() *loop.TickTrace
}
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
trace := d.getTrace()
if trace == nil {
return ipc.TickTrace{}, nil
}
return toIPCTickTrace(*trace), nil
}
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
for i, r := range t.RuleTraces {
rules[i] = toIPCRuleTrace(r)
}
return ipc.TickTrace{
Now: t.Now,
Winner: t.Winner,
Rules: rules,
}
}
func toIPCRuleTrace(r loop.RuleTrace) ipc.RuleTrace {
return ipc.RuleTrace{
RuleName: r.RuleName,
Severity: int(r.Severity),
PredicateResult: r.PredicateResult,
GateResult: r.GateResult,
GateBlockedBy: r.GateBlockedBy,
GateDetail: toIPCGateDetail(r.GateDetail),
WasSelected: r.WasSelected,
LostTo: r.LostTo,
}
}
func toIPCGateDetail(d loop.GateDetail) ipc.GateDetail {
return ipc.GateDetail{
SnoozeUntil: d.SnoozeUntil,
CooldownUntil: d.CooldownUntil,
QuietHours: d.QuietHours,
CalendarBusy: d.CalendarBusy,
Presence: d.Presence,
InertKeysMissing: d.InertKeysMissing,
}
}