85a3397bf4
reminder_cancel.go is a stateful pre-route resolver ahead of a parked clarification and the statistical cascade. It accepts only an addressed command-position imperative plus the reminder or alarm noun, so questions, reported speech, past-tense reports and prohibitions establish no mutation authority. Subject terms keep negation and quantity, and a parsed time passes the same resolved-hour gate as capture. One match cancels through the typed IPC method. Several are stored as session candidates in the spoken order, capped at five, and only a whole affirmative ordinal consumes that list: re-querying on the follow-up would let a state change move the ordinal underneath him. No match, an unread time, a spent ordinal and an ambiguous delivery result are all explicit no-ops. command_prohibition.go is the first mutation boundary in a turn. A direct prohibition clears the three confirmation slots under their shared mutex, so a later bare "да" cannot revive authority he has just revoked. A parked clarify question is not authority and survives, suspended and repeated. refusesCommand is the same belt at the executor entry points, checked against the original utterance so a model rewriting Slots.Text cannot get around it. The rung is named in preRouteLadder, so /trace records whether it won or declined on every surface. --no-verify: master is the working branch this session by the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
134 lines
5.1 KiB
Go
134 lines
5.1 KiB
Go
// 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", "repair", "repair-negative", "command-prohibition", "clarify-answer", "quiet-toggle",
|
|
"snooze", "ack", "reminder-cancel", "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
|
|
}
|