// mavend/decisiontrace.go — the daemon's half of the per-turn decision record. // // V-564. The router says what the cascade did (internal/router/decisiontrace.go); // this file covers the two claimant sets that live in the daemon: the stateful // resolvers that run BEFORE routing and pre-empt it unconditionally, and the // query source chain that runs after. Those two are where the arbitration is // least visible, because both are a hardcoded order of functions that each // answer "is this mine?" alone and none of which answers "is this more mine // than yours?" (V-558). // // Recording changes no route. Every helper here is a no-op on a context with no // record, which is what every test that does not ask for one gets. package main import ( "context" "github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/router" ) // preRouteLadder — the resolvers runTurn offers the utterance to before the // router sees it, in the order they get their say. Kept here as a roster rather // than derived from the code, so a resolver that returns early and skips the // rest still leaves the rest NAMED in the record: a claimant that never looked // and one that looked and passed are the distinction the ordering hides, and // they are the difference between a bug in the ladder and a bug in a resolver. // // Adding a step to runTurn means adding its name here. Nothing enforces that, // and nothing should: a missing name costs one line of the record, while a // check that walks the ladder would have to run the ladder. var preRouteLadder = []string{ "confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair", "repair-negative", "ordinal", } // notePreRoute records one rung of that ladder and passes its verdict through // unchanged, so the call site stays the single `if handled` it already was. func notePreRoute(ctx context.Context, name string, handled bool) bool { rec := decision.From(ctx) if rec == nil { return handled } if handled { rec.Note(decision.Claim{ Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Won, Reason: "it pre-empted routing, so the router never saw this turn", }) return handled } rec.Note(decision.Claim{ Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Declined, Reason: "nothing of its own was pending", }) return handled } // noteTerminal records whoever actually produced the reply, but only if the // turn is still unclaimed. A route decides the intent; it does not answer, and // on a thinned route or a plain act nothing downstream keeps a scoreboard. So // the record would otherwise close with an empty winner, which reads as a lost // turn instead of an asked question. func noteTerminal(ctx context.Context, claimant string, intent router.Intent, reason string) { decision.From(ctx).NoteIfUnclaimed(decision.Claim{ Stage: decision.StageAction, Claimant: claimant, Intent: string(intent), Reason: reason, }) } // noteMerge records the follow-up merge, which is the one claimant that edits // the winning decision instead of taking the turn from it. It is compared on // the four slots the merge can fill, because a Decision holds a slice and is // not comparable. func noteMerge(ctx context.Context, before, after router.Decision) { rec := decision.From(ctx) if rec == nil { return } changed := before.Slots.HasTime != after.Slots.HasTime || before.Slots.HasKey != after.Slots.HasKey || before.Slots.HasFn != after.Slots.HasFn || before.Slots.Text != after.Slots.Text || before.Intent != after.Intent if !changed { rec.Note(decision.Claim{ Stage: decision.StageMerge, Claimant: "follow-up-merge", Outcome: decision.Declined, Reason: "no slot of this turn was left for a previous one to fill", }) return } rec.Note(decision.Claim{ Stage: decision.StageMerge, Claimant: "follow-up-merge", Intent: string(after.Intent), Outcome: decision.Merged, Reason: "filled this turn's gaps from the previous turn", }) } // turnDecisionsFn — the reader mavweb gets, or nil when voice was never wired. // Same shape as intakeEventsFn: the daemon holds the ring, the IPC layer only // converts it. func turnDecisionsFn(w *voiceWiring) func(int) []ipc.TurnDecision { if w == nil || w.handler == nil || w.handler.decisions == nil { return nil } ring := w.handler.decisions return func(n int) []ipc.TurnDecision { recs := ring.Recent(n) out := make([]ipc.TurnDecision, 0, len(recs)) for _, rec := range recs { claims := make([]ipc.TurnClaim, 0, len(rec.Claims)) for _, c := range rec.Claims { claims = append(claims, ipc.TurnClaim{ Stage: c.Stage, Claimant: c.Claimant, Intent: c.Intent, Score: c.Score, HasScore: c.HasScore, Outcome: c.Outcome, Reason: c.Reason, }) } out = append(out, ipc.TurnDecision{ Ts: rec.Ts, Utterance: rec.Utterance, Winner: rec.Winner, Claims: claims, }) } return out } } // querySourceNames — the query chain's roster, in chain order. func querySourceNames() []string { names := make([]string, len(querySources)) for i, src := range querySources { names[i] = src.name } return names }