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
+36
View File
@@ -240,6 +240,42 @@ type CoreAPI interface {
LookupTool(ctx context.Context, name string) (Tool, error)
ListTools(ctx context.Context, status string) ([]Tool, error)
RevertFact(ctx context.Context, key string) (int64, error)
// TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not
// persisted — it's a daemon-level cache).
TickTrace(ctx context.Context) (TickTrace, error)
}
// --- Rule trace / explanation DTOs ---
// RuleTrace — per-rule evaluation result for one tick.
type RuleTrace struct {
RuleName string `json:"rule_name"`
Severity int `json:"severity"`
PredicateResult bool `json:"predicate_result"`
GateResult bool `json:"gate_result"`
GateBlockedBy string `json:"gate_blocked_by,omitempty"`
GateDetail GateDetail `json:"gate_detail,omitempty"`
WasSelected bool `json:"was_selected"`
LostTo string `json:"lost_to,omitempty"`
}
// 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"`
InertKeysMissing []string `json:"inert_keys_missing,omitempty"`
}
// TickTrace — snapshot of one tick's rule evaluations.
type TickTrace struct {
Now time.Time `json:"now"`
Winner string `json:"winner"`
Rules []RuleTrace `json:"rules"`
}
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
+8
View File
@@ -345,6 +345,14 @@ func (c *Client) ListTools(ctx context.Context, status string) ([]Tool, error) {
return r.Tools, nil
}
func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
var t TickTrace
if err := c.call(ctx, MethodTickTrace, nil, &t); err != nil {
return TickTrace{}, err
}
return t, nil
}
func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
var result struct {
NewID int64 `json:"new_id"`
+11
View File
@@ -174,6 +174,10 @@ func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
return newID, mapErr(err)
}
func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
}
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
ts, err := a.s.ListTools(ctx, status)
if err != nil {
@@ -629,6 +633,13 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
}
return marshalResult(map[string]int64{"new_id": newID}), nil
case MethodTickTrace:
t, err := s.api.TickTrace(ctx)
if err != nil {
return nil, err
}
return marshalResult(t), nil
case MethodAssertStepUp:
if s.StepUp != nil {
return marshalResult(nil), s.StepUp(ctx)
+1
View File
@@ -35,6 +35,7 @@ const (
MethodLookupTool Method = "lookup_tool"
MethodListTools Method = "list_tools"
MethodRevertFact Method = "revert_fact"
MethodTickTrace Method = "tick_trace"
)
// Request — one frame from module to core. Params is the JSON-encoded argument