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:
+6
-3
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user