diff --git a/CLAUDE.md b/CLAUDE.md index 72cc9b2..6c60d1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,6 +261,22 @@ of the house. A demonstrative ("отметь это как сделанное") `h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to the cascade rather than transitioning the wrong item. +**Who claimed a turn is now recorded, and so is who did not** (V-564, umbrella +V-558). Arbitration between the claimants on the utterance stream is order, +hardcoded in the pre-route resolver ladder, in `buildRouter` and in +`querySources`. `internal/decision` records one `Record` per turn: every +claimant, what it would have made the turn, the score it reported, and whether +it won, declined, lost on score, was thinned by a gate or was **never asked**. +The record rides the context, the same seam `querysource.go` uses, so a claim +site cannot change a route and a context with no record costs nothing. It is +installed in `runTurn`, so the mic, telegram and the web all leave the same +trail. Storage is a 25-turn in-memory ring on the handler (`decision.Ring`), +read over `ipc.TurnDecisions` and rendered as the second table on `/trace`. +Nothing persists: a turn record is read minutes later or never, and his words do +not belong in a table that outlives the diagnosis. Adding a rung to the ladder +in `runTurn` means adding its name to `preRouteLadder` in +`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record. + ## LLM output contract All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index e24651a..80dec77 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -11,6 +11,7 @@ import ( "unicode" "github.com/kami/maven/internal/crawl" + "github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/morning" @@ -158,8 +159,17 @@ var querySources = []querySource{ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string { t := &queryTurn{dec: dec} + // The roster, so the record can say which sources were never reached rather + // than leaving them out and letting a reader assume they looked and passed + // (V-564). Finish names everyone below the winner. + decision.Expect(ctx, decision.StageQuery, querySourceNames()) + rec := decision.From(ctx) for _, src := range querySources { if dec.Continued && !src.dateAware { + rec.Note(decision.Claim{ + Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked, + Reason: "a continuation turn only asks the date-aware sources", + }) continue } if reply, ok := src.answer(h, ctx, t); ok { @@ -172,8 +182,16 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) // caller asked for one, so /chat can show it (V-539). log.Printf("voice: query claimed by source %q", src.name) noteQuerySource(ctx, src.name) + rec.Note(decision.Claim{ + Stage: decision.StageQuery, Claimant: src.name, + Intent: string(dec.Intent), Outcome: decision.Won, + }) return reply } + rec.Note(decision.Claim{ + Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.Declined, + Reason: "it had no answer for this turn", + }) } if dec.Continued { // The previous question cannot be re-asked for another day. Saying so diff --git a/cmd/mavend/decisiontrace.go b/cmd/mavend/decisiontrace.go new file mode 100644 index 0000000..247992c --- /dev/null +++ b/cmd/mavend/decisiontrace.go @@ -0,0 +1,132 @@ +// 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", "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 +} diff --git a/cmd/mavend/decisiontrace_test.go b/cmd/mavend/decisiontrace_test.go new file mode 100644 index 0000000..813d34f --- /dev/null +++ b/cmd/mavend/decisiontrace_test.go @@ -0,0 +1,163 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/decision" + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/tool" + "github.com/kami/maven/internal/voice" +) + +// traceHandler — a handler with the decision ring wired, the same shape the +// daemon builds in wireVoice. +func traceHandler(t *testing.T, ring *decision.Ring) *reactiveHandler { + t.Helper() + st := newTestStore(t) + api := ipc.NewStoreAPI(st) + now := time.Now() + emb := router.NewHashEmbedder(1024) + return &reactiveHandler{ + api: api, + recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()}, + router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil), + replier: voice.NewStubReplier(), + now: func() time.Time { return now }, + dataStore: st, + decisions: ring, + } +} + +// findClaim — the first claim for a claimant, or nil. +func findClaim(rec *decision.Record, claimant string) *decision.Claim { + for i := range rec.Claims { + if rec.Claims[i].Claimant == claimant { + return &rec.Claims[i] + } + } + return nil +} + +// TestTurnRecordNamesWinnerAndLosers — the point of V-564. A turn a stage-0 +// grammar claims must leave a record naming that grammar as the winner, naming +// a pre-route resolver that declined, and naming the routing engines that were +// never reached at all. The last of those is the fact the hardcoded ordering +// hides: "classifier" absent from the record and "classifier" never asked read +// the same to a human, and only one of them is the truth. +func TestTurnRecordNamesWinnerAndLosers(t *testing.T) { + ring := decision.NewRing() + h := traceHandler(t, ring) + + reply := h.handleText(context.Background(), "web", "сколько сейчас времени") + if reply == "" { + t.Fatal("turn produced no reply") + } + + recs := ring.Recent(5) + if len(recs) != 1 { + t.Fatalf("want 1 record, got %d", len(recs)) + } + rec := recs[0] + if rec.Utterance != "сколько сейчас времени" { + t.Errorf("utterance = %q", rec.Utterance) + } + if !strings.HasPrefix(rec.Winner, "stage0:") { + t.Errorf("want a stage-0 grammar as the winner, got %q", rec.Winner) + } + + // A loser that examined the turn: the confirm resolver ran first and had + // nothing pending. + confirm := findClaim(rec, "confirm") + if confirm == nil || confirm.Outcome != decision.Declined { + t.Errorf("confirm claim = %+v, want a decline", confirm) + } + + // A loser that never looked: stage 0 answered, so neither routing engine + // was reached. + for _, name := range []string{"llm-router", "classifier"} { + c := findClaim(rec, name) + if c != nil && c.Outcome == decision.Won { + t.Errorf("%s cannot have won a stage-0 turn: %+v", name, c) + } + } + + // And every rung of the ladder below the winner is named, not omitted. + for _, name := range preRouteLadder { + if findClaim(rec, name) == nil { + t.Errorf("ladder rung %q is missing from the record", name) + } + } +} + +// TestRecordingDoesNotChangeTheReply — instrumentation, so a turn with the ring +// wired and the same turn without it must answer identically. If this ever +// fails, a claim site is doing more than noting. +func TestRecordingDoesNotChangeTheReply(t *testing.T) { + for _, utt := range []string{ + "сколько сейчас времени", + "запиши что я пил воду", + "что у меня сегодня", + } { + withRing := traceHandler(t, decision.NewRing()).handleText(context.Background(), "web", utt) + without := traceHandler(t, nil).handleText(context.Background(), "web", utt) + if withRing != without { + t.Errorf("%q: recorded reply %q != unrecorded %q", utt, withRing, without) + } + } +} + +// TestQueryChainRecordsWhoWasNeverAsked — a query source below the claimant is +// never consulted, and the record must say so rather than leave it out. This is +// the arm that would have explained the Rome misroute in one read. +func TestQueryChainRecordsWhoWasNeverAsked(t *testing.T) { + ring := decision.NewRing() + h := traceHandler(t, ring) + h.handleText(context.Background(), "web", "что у меня сегодня") + + rec := ring.Recent(1)[0] + var asked, never int + for _, c := range rec.Claims { + if c.Stage != decision.StageQuery { + continue + } + if c.Outcome == decision.NeverAsked { + never++ + } else { + asked++ + } + } + if asked == 0 { + t.Fatal("no query source reported at all") + } + if never == 0 { + t.Fatal("no query source was recorded as never asked; the chain cannot have run to the end") + } + if got := len(querySourceNames()); asked+never != got { + t.Errorf("record covers %d of %d query sources", asked+never, got) + } +} + +// TestTurnDecisionsFnConvertsTheRing — the IPC read path. Nil when voice was +// never wired, because a box with no turns is an empty page and not an error. +func TestTurnDecisionsFnConvertsTheRing(t *testing.T) { + if fn := turnDecisionsFn(nil); fn != nil { + t.Error("no wiring should mean no reader") + } + ring := decision.NewRing() + h := traceHandler(t, ring) + h.handleText(context.Background(), "web", "сколько сейчас времени") + + fn := turnDecisionsFn(&voiceWiring{handler: h}) + if fn == nil { + t.Fatal("wired handler produced no reader") + } + out := fn(10) + if len(out) != 1 || out[0].Winner == "" || len(out[0].Claims) == 0 { + t.Fatalf("conversion lost the record: %+v", out) + } +} diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index a498b8f..7c5d805 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -350,6 +350,7 @@ func run(args []string) error { getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) }, getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) }, getEvents: intakeEventsFn(evBus), + getDecisions: turnDecisionsFn(voiceW), seedStore: seedStoreIfAllowed(st), nexus: nexusOf(voiceW), } @@ -619,6 +620,7 @@ func run(args []string) error { getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) }, getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) }, getEvents: intakeEventsFn(evBus), + getDecisions: turnDecisionsFn(voiceW), seedStore: seedStoreIfAllowed(st), } if voiceW != nil && voiceW.handler != nil { diff --git a/cmd/mavend/tick_api.go b/cmd/mavend/tick_api.go index 24ef255..236b521 100644 --- a/cmd/mavend/tick_api.go +++ b/cmd/mavend/tick_api.go @@ -23,6 +23,7 @@ type daemonAPI struct { chatFn func(ctx context.Context, conversation, text string) string getMCPServers func() []ipc.MCPServerStatus getEvents func(n int) []ipc.IntakeEvent + getDecisions func(n int) []ipc.TurnDecision // nexus — the identity client, nil when no nexus block is configured. It // is what makes ResolveEntity answerable at all; without it the store // adapter's refusal stands, and a surface that wanted an entity id says so @@ -118,6 +119,17 @@ func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) { return toIPCTickTrace(*trace), nil } +// TurnDecisions — the arbitration records of the last few turns (V-564). Nil +// getter means voice was never wired, and that is an empty list rather than an +// error: a box with no voice path has had no turns to arbitrate, which is not a +// fault and renders as an empty table. +func (d *daemonAPI) TurnDecisions(ctx context.Context, n int) ([]ipc.TurnDecision, error) { + if d.getDecisions == nil { + return nil, nil + } + return d.getDecisions(n), nil +} + func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) { if d.getMorningStatus == nil { return nil, errors.New("mavend: morning status not available") diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 2a949bc..0e052c6 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -53,6 +53,7 @@ import ( "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/crawl" + "github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/lexicon" @@ -137,6 +138,13 @@ type reactiveHandler struct { // slot per reach, not one for the box. nil ⇒ no carry-over. dialogueSessions *dialogue.SessionStore + // decisions holds the last few turns' arbitration records (V-564): who + // claimed the turn, who lost it and who was never asked. In memory and + // bounded, because a turn record is read minutes later or never, and none + // of his words belong in a table that outlives the diagnosis. nil ⇒ nothing + // is recorded, which is what a test that did not ask for one gets. + decisions *decision.Ring + // clarifyStore parks the request behind an open question she asked (see // clarify.go). nil ⇒ she falls back to the canned "не поняла" reply. clarifyStore *dialogue.ClarifyStore @@ -248,6 +256,18 @@ const ( // // The ordering is load-bearing — see the step comments. func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string { + // 0. the decision record (V-564). Installed here rather than in the IPC + // entry point, so the mic, telegram and the web all leave the same trail — + // a record only the web produced would be missing exactly the turns that + // are hardest to reproduce. It rides the context, costs a few dozen structs + // on a human-rate path, and no claim site can change a route with it. + if h.decisions != nil { + var rec *decision.Record + ctx, rec = decision.With(ctx, text) + decision.Expect(ctx, decision.StagePreRoute, preRouteLadder) + defer func() { h.decisions.Push(rec.Finish(h.now())) }() + } + // 1. expired clarify — a question was parked but its TTL ran out, so the // request behind it is gone. Say that out loud (see clarify.go) and carry // on: these words are still routed as a fresh utterance below, with the @@ -263,7 +283,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // 2. confirm turn — if a destructive act is parked, this utterance is its // y/n answer, not a fresh command. Handled before routing so "да" doesn't // get classified as some other intent. - if reply, handled := h.resolveConfirm(ctx, text); handled { + if reply, handled := h.resolveConfirm(ctx, text); notePreRoute(ctx, "confirm", handled) { return withNotice(expiredNotice, reply) } @@ -275,7 +295,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // so the notice is empty here in practice. withNotice anyway: every exit // from runTurn carries it, and that is what stops the next one from // forgetting. - if reply, handled := h.resolveClarifyAnswer(ctx, text); handled { + if reply, handled := h.resolveClarifyAnswer(ctx, text); notePreRoute(ctx, "clarify-answer", handled) { return withNotice(expiredNotice, reply) } @@ -283,7 +303,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // "тихий режим" / "quiet on" would route through the classifier // unreliably (it's a command, not a free-form query), so we match it // before routing. Same pattern as the confirm turn above. - if reply, handled := h.resolveQuietToggle(ctx, text, src); handled { + if reply, handled := h.resolveQuietToggle(ctx, text, src); notePreRoute(ctx, "quiet-toggle", handled) { return withNotice(expiredNotice, reply) } @@ -291,14 +311,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // sent. Only handled when a pending nudge is actually inside the window // (snooze.go); otherwise the words route normally, because "потом" is an // ordinary word and eating every one of them would break real sentences. - if reply, handled := h.resolveSnooze(ctx, text, src); handled { + if reply, handled := h.resolveSnooze(ctx, text, src); notePreRoute(ctx, "snooze", handled) { return withNotice(expiredNotice, reply) } // 4c. spoken ack — "готово" closes that same nudge as `acted`. Only the // contentless form is intercepted here; "выпил воды" keeps routing and // closes the nudge after its fact lands (ackFromFact, step 8b). - if reply, handled := h.resolveAck(ctx, text, src); handled { + if reply, handled := h.resolveAck(ctx, text, src); notePreRoute(ctx, "ack", handled) { return withNotice(expiredNotice, reply) } @@ -306,7 +326,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // turn and names what it should have been (repair.go). Before routing, // like the confirm and clarify turns: routing the correction as a fresh // utterance files the correction itself instead of fixing anything. - if reply, handled := h.resolveRepair(ctx, text); handled { + if reply, handled := h.resolveRepair(ctx, text); notePreRoute(ctx, "repair", handled) { return withNotice(expiredNotice, reply) } @@ -314,7 +334,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // just read (ordinal.go). Before routing, and only when a list is actually // bound to the session: with nothing offered, "второй" is an ordinary word // and keeps routing. - if reply, handled := h.resolveCandidate(ctx, text, src); handled { + if reply, handled := h.resolveCandidate(ctx, text, src); notePreRoute(ctx, "ordinal", handled) { return withNotice(expiredNotice, reply) } @@ -359,7 +379,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // ("а завтра?" … "а послезавтра?") keeps working. if h.dialogueSessions != nil { if !cont { - dec = followUpMerge(prev, dec, now) + merged := followUpMerge(prev, dec, now) + noteMerge(ctx, dec, merged) + dec = merged } if !dec.Clarify { h.rememberTurn(ctx, prev, dec, now) @@ -380,6 +402,8 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } if question, asked := h.askClarify(ctx, dec); asked { + noteTerminal(ctx, "clarify-ask", dec.Intent, + "the route was below the threshold, so she asked instead of acting") return withNotice(expiredNotice, question) } } @@ -396,6 +420,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // the round-trip stays alive. replyText := h.applyAction(ctx, dec) log.Printf("voice: applyAction returned: %q", replyText) + // A query turn was already claimed by a source inside the chain; every other + // intent has no chain and no scoreboard, so the handler is the winner. + noteTerminal(ctx, "action-handler", dec.Intent, "") // 8b. a fact that answers a live nudge closes it as `acted` (ack.go). // Silent: the fact reply stands, she does not congratulate him for it. diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index dddef03..e005bed 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -11,6 +11,7 @@ import ( "time" "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/decision" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/delivery/voicesink" "github.com/kami/maven/internal/dialogue" @@ -293,7 +294,11 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem }, dataStore: dataStore, dialogueSessions: dialogueSessions, - clarifyStore: clarifyStore, + // Always on (V-564). The record is the instrument the rest of V-558 is + // measured with, and one that only runs when a flag is set is not there + // on the night the misroute happens. + decisions: decision.NewRing(), + clarifyStore: clarifyStore, // 0 here (unset config) ⇒ the dialogue default. clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts, extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}}, diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 53e6b96..437ef23 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -65,6 +65,7 @@ type fakeCore struct { // for handleTrace tests tickTrace ipc.TickTrace traceErr error + turns []ipc.TurnDecision // for handleChatAPI tests chatText string @@ -173,6 +174,10 @@ func (f *fakeCore) RevertFact(_ context.Context, key string) (int64, error) { return f.revertNewID, nil } +func (f *fakeCore) TurnDecisions(_ context.Context, _ int) ([]ipc.TurnDecision, error) { + return f.turns, nil +} + func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) { if f.traceErr != nil { return ipc.TickTrace{}, f.traceErr @@ -825,6 +830,41 @@ func TestHandleTrace(t *testing.T) { t.Error("rendered 'nothing fired' but a winner was set") } }) + + // The turn arbitration shares this page (V-564). A reader must see the + // winner, a loser and the claimants that were never asked, because the last + // of those is what the hardcoded ordering hides. + t.Run("renders the turn decision record", func(t *testing.T) { + core := &fakeCore{turns: []ipc.TurnDecision{{ + Ts: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC), + Utterance: "какая погода в риме", + Winner: "query:weather", + Claims: []ipc.TurnClaim{ + {Stage: "query", Claimant: "weather", Intent: "query", Outcome: "won"}, + {Stage: "query", Claimant: "calendar", Outcome: "declined", Reason: "no answer"}, + {Stage: "query", Claimant: "kiwix", Outcome: "never_asked"}, + }, + }}} + rr := httptest.NewRecorder() + handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core) + body := rr.Body.String() + for _, want := range []string{"какая погода в риме", "query:weather", "calendar", "kiwix", "never_asked"} { + if !strings.Contains(body, want) { + t.Errorf("rendered page is missing %q", want) + } + } + }) + + t.Run("no turns renders the empty note, not an error", func(t *testing.T) { + rr := httptest.NewRecorder() + handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), &fakeCore{}) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + if !strings.Contains(rr.Body.String(), "no turn has run") { + t.Error("empty ring did not render its note") + } + }) } // --- handleRevert --- diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 88b4414..0d89d76 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1415,12 +1415,29 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { http.Error(w, "core read failed", http.StatusBadGateway) return } + // The turn records share this page rather than getting one of their own + // (V-564): both answer the same question — who won, who lost and why — and + // one is about nudges while the other is about utterances. A read failure + // here is not fatal to the page: the rule trace above it still renders, and + // a daemon too old to know the method is the ordinary case during a rolling + // deploy. + turns, err := core.TurnDecisions(ctx, 25) + if err != nil { + log.Printf("trace: turn decisions: %v", err) + } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := traceTmpl.Execute(w, trace); err != nil { + if err := traceTmpl.Execute(w, traceData{Tick: trace, Turns: turns}); err != nil { log.Printf("trace render: %v", err) } } +// traceData — what trace.html renders: the last tick's rule arbitration and the +// last turns' claim arbitration. +type traceData struct { + Tick ipc.TickTrace + Turns []ipc.TurnDecision +} + func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "morning disabled (no -core)", http.StatusServiceUnavailable) diff --git a/cmd/mavweb/trace.html b/cmd/mavweb/trace.html index 3d95037..4c997b2 100644 --- a/cmd/mavweb/trace.html +++ b/cmd/mavweb/trace.html @@ -1,9 +1,9 @@ {{template "shellTop" "trace"}}

Rule Trace

-
{{.Now | ago}} — winner: {{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}
+
{{.Tick.Now | ago}} — winner: {{if .Tick.Winner}}{{.Tick.Winner}}{{else}}nothing fired{{end}}
-{{range .Rules}} +{{range .Tick.Rules}} @@ -21,5 +21,25 @@ {{end}}
rulesevpredicategateblocked bydetailselectedlost to
{{.RuleName}} {{.Severity}} {{.PredicateResult}}{{.LostTo}}
+ +

Turn Decisions

+
Who claimed each utterance, who lost it, and who was never asked. In memory, newest first, cleared on restart.
+{{if not .Turns}}
no turn has run since the daemon started
{{end}} +{{range .Turns}} +
+{{.Utterance}}{{if .Winner}}{{.Winner}}{{else}}nobody{{end}} {{.Ts | ago}} +
+ +{{range .Claims}} + + + + + + +{{end}} +
stageclaimantwould have beenscoreoutcomewhy
{{.Stage}}{{.Claimant}}{{if .Intent}}{{.Intent}}{{else}}—{{end}}{{if .HasScore}}{{printf "%.3f" .Score}}{{else}}—{{end}}{{.Outcome}}{{.Reason}}
+
+{{end}} {{template "shellBottom"}} diff --git a/docs/qa.md b/docs/qa.md index cd1c808..ebad83d 100644 --- a/docs/qa.md +++ b/docs/qa.md @@ -423,11 +423,15 @@ something only a ZIM answers, with the search block on. Live search leads and th ZIMs are the fallback since 02-08-2026. **286**'s remaining half is doc and git ingestion, which is build work, not a check. -**Do not read `/trace` for this.** `/trace` is the nudge-rule trace: rule, -severity, predicate, gate, selected. No query-source field exists anywhere in the -codebase. The only evidence of which query source claimed a turn is the -`voice: search:` and `voice: kiwix:` lines in `docker compose logs mavend` -(`actions_query.go:589` and `:660`). +**Read `/trace` for this.** It carries two tables since 06-08-2026 (V-564). The +nudge-rule trace it always had, and below it the **turn decisions**: one +collapsible record per utterance. Each names every claimant, what it would have +made the turn, the score it reported, and whether it won, declined, lost or was +**never asked**. That last one answers "did Kiwix pass, or was it never +reached". The log lines cannot tell you that. The ring holds the last 25 turns +in daemon memory and is empty after a restart, so read it in the same sitting. +`/chat` still shows the claiming source as a badge, and the `voice: query +claimed by source` line is still in `docker compose logs mavend`. Run 02-08-2026, 20 turns. **Search leads and the personal boundary holds.** Every world question that reached the boundary was claimed by search. All three diff --git a/internal/decision/decision.go b/internal/decision/decision.go new file mode 100644 index 0000000..25afb63 --- /dev/null +++ b/internal/decision/decision.go @@ -0,0 +1,185 @@ +// Package decision records who claimed one turn and who lost it (V-564). +// +// Arbitration between the claimants on the utterance stream is order, hardcoded +// in the resolver ladder, in buildRouter and in querySources (V-558). Order is +// invisible in a log: the daemon says which intent won and which query source +// answered, never who else wanted the turn, with what score, or why it did not +// get it. The Rome misroute took a probe, a log read and a code read to explain, +// which is one diagnosis too many for a defect family already three deep. +// +// The record rides the context, the same seam querysource.go uses and for the +// same reason: a turn answers through one string that the mic, telegram and the +// web all share, so a second return value is not threadable. A context with no +// recorder notes nothing, so every Note here is free in a test or a tool that +// did not ask for one. +// +// The most important thing it holds is not a loss but a silence. A claimant +// that was NEVER ASKED — because something earlier in the ladder returned +// first — looks identical to one that examined the turn and declined, and it is +// that confusion the hardcoded ordering hides. So a stage declares its roster +// up front and Finish names everyone who never reported. +package decision + +import ( + "context" + "sync" + "time" +) + +// Stages, in the order a turn passes through them. +const ( + StagePreRoute = "pre-route" // clarify, confirm, repair and their siblings + StageZero = "stage0" // grammar rules + StageRoute = "route" // LLM router, classifier + StageMerge = "merge" // the follow-up merge, which edits rather than claims + StageQuery = "query" // the query source chain + StageAction = "action" // whoever actually produced the reply +) + +// Outcomes. Coarse on purpose: the record answers "who wanted this turn and +// what happened to their claim", not "re-derive the branch". +const ( + Won = "won" // this claimant produced the turn + Declined = "declined" // it looked at the turn and said not mine + LostOnOrder = "lost_on_order" // it wanted the turn, something earlier had it + LostOnScore = "lost_on_score" // it was scored against a rival and scored lower + Thinned = "thinned" // it claimed, and a gate cut its confidence + Merged = "merged" // it changed the winning claim without owning it + NeverAsked = "never_asked" // it never got to look at all +) + +// Claim is one claimant's say on one turn. +type Claim struct { + Stage string `json:"stage"` + Claimant string `json:"claimant"` + Intent string `json:"intent,omitempty"` // what it would have made the turn + Score float64 `json:"score,omitempty"` // only meaningful with HasScore + HasScore bool `json:"has_score,omitempty"` + Outcome string `json:"outcome"` + Reason string `json:"reason,omitempty"` // why it lost, in its own terms +} + +// Record is one turn's arbitration. Utterance is held because a record with no +// utterance is unreadable, and this store is diagnostics with a short life — +// unlike the facts table, which is the audit trail. +type Record struct { + Ts time.Time `json:"ts"` + Utterance string `json:"utterance"` + Winner string `json:"winner"` + Claims []Claim `json:"claims"` + + mu sync.Mutex + rosters []roster +} + +type roster struct { + stage string + names []string +} + +// Expect declares the claimants a stage could have asked, so Finish can tell a +// decline from a silence. The slice is held, not copied: every caller passes a +// package-level table. +func (r *Record) Expect(stage string, names []string) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.rosters = append(r.rosters, roster{stage: stage, names: names}) +} + +// Note appends one claim. The winner is whoever noted Won last, which is the +// claimant that actually returned the reply. +func (r *Record) Note(c Claim) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.Claims = append(r.Claims, c) + if c.Outcome == Won { + r.Winner = c.Stage + ":" + c.Claimant + } +} + +// NoteIfUnclaimed records a win only when nobody has claimed the turn yet. It +// is what closes a record whose route was decided but whose reply came from +// somewhere with no scoreboard — a clarify question, or an action handler with +// no chain in front of it. Without it a thinned route leaves the record with no +// winner at all, which reads as a lost turn rather than an asked question. +func (r *Record) NoteIfUnclaimed(c Claim) { + if r == nil { + return + } + r.mu.Lock() + claimed := r.Winner != "" + r.mu.Unlock() + if claimed { + return + } + c.Outcome = Won + r.Note(c) +} + +// Finish fills in the never-asked claimants and returns the record. Called once +// by whoever installed the recorder, after the turn has answered. +func (r *Record) Finish(now time.Time) *Record { + if r == nil { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + r.Ts = now + reported := map[string]bool{} + for _, c := range r.Claims { + reported[c.Stage+":"+c.Claimant] = true + } + for _, ros := range r.rosters { + for _, name := range ros.names { + if !reported[ros.stage+":"+name] { + r.Claims = append(r.Claims, Claim{ + Stage: ros.stage, Claimant: name, Outcome: NeverAsked, + }) + } + } + } + return r +} + +// --- the context seam --- + +type recorderKey struct{} + +// With returns a context carrying a fresh record, and the record to read after +// the turn has answered. +func With(ctx context.Context, utterance string) (context.Context, *Record) { + rec := &Record{Utterance: utterance} + return context.WithValue(ctx, recorderKey{}, rec), rec +} + +// From returns the record on the context, or nil. Every method on *Record is +// nil-safe, so a caller does not have to check. +func From(ctx context.Context) *Record { + rec, _ := ctx.Value(recorderKey{}).(*Record) + return rec +} + +// Note is the shorthand every claim site uses: a no-op when nobody is recording. +func Note(ctx context.Context, c Claim) { + From(ctx).Note(c) +} + +// Expect is the roster shorthand, likewise a no-op with no recorder. +func Expect(ctx context.Context, stage string, names []string) { + From(ctx).Expect(stage, names) +} + +// Scored is a claim carrying a confidence, kept as a constructor so a caller +// cannot forget HasScore and have a real 0.0 read as "no score". +func Scored(stage, claimant, intent string, score float64, outcome, reason string) Claim { + return Claim{ + Stage: stage, Claimant: claimant, Intent: intent, + Score: score, HasScore: true, Outcome: outcome, Reason: reason, + } +} diff --git a/internal/decision/decision_test.go b/internal/decision/decision_test.go new file mode 100644 index 0000000..2aff6a9 --- /dev/null +++ b/internal/decision/decision_test.go @@ -0,0 +1,98 @@ +package decision + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestFinishNamesTheNeverAsked(t *testing.T) { + ctx, rec := With(context.Background(), "какая погода в риме?") + Expect(ctx, StageQuery, []string{"calendar", "weather", "search", "kiwix"}) + Note(ctx, Claim{Stage: StageQuery, Claimant: "calendar", Outcome: Declined}) + Note(ctx, Claim{Stage: StageQuery, Claimant: "weather", Outcome: Won}) + + rec.Finish(time.Now()) + + outcomes := map[string]string{} + for _, c := range rec.Claims { + outcomes[c.Claimant] = c.Outcome + } + if outcomes["weather"] != Won || rec.Winner != StageQuery+":weather" { + t.Errorf("winner = %q, weather = %q", rec.Winner, outcomes["weather"]) + } + if outcomes["calendar"] != Declined { + t.Errorf("calendar = %q, want a decline", outcomes["calendar"]) + } + // The two below the winner never looked, and saying so is the whole point. + for _, name := range []string{"search", "kiwix"} { + if outcomes[name] != NeverAsked { + t.Errorf("%s = %q, want %q", name, outcomes[name], NeverAsked) + } + } +} + +// A real 0.0 confidence must not read as "this claimant has no score". +func TestScoredKeepsAZeroScore(t *testing.T) { + c := Scored(StageRoute, "classifier", "chat", 0, LostOnScore, "") + if !c.HasScore || c.Score != 0 { + t.Errorf("claim = %+v", c) + } +} + +func TestNoteIfUnclaimedYieldsToARealWinner(t *testing.T) { + ctx, rec := With(context.Background(), "x") + Note(ctx, Claim{Stage: StageQuery, Claimant: "weather", Outcome: Won}) + rec.NoteIfUnclaimed(Claim{Stage: StageAction, Claimant: "action-handler"}) + if rec.Winner != StageQuery+":weather" { + t.Errorf("winner = %q, want the query source", rec.Winner) + } +} + +// A context with no record must cost nothing and crash nothing: that is what +// makes the claim sites safe to leave in every test and every fixture run. +func TestNoRecorderIsANoOp(t *testing.T) { + ctx := context.Background() + Note(ctx, Claim{Claimant: "x", Outcome: Won}) + Expect(ctx, StageQuery, []string{"y"}) + if From(ctx) != nil { + t.Error("bare context reported a record") + } + From(ctx).NoteIfUnclaimed(Claim{Claimant: "z"}) + if rec := From(ctx).Finish(time.Now()); rec != nil { + t.Error("finishing a nil record produced one") + } +} + +func TestRingIsBoundedAndNewestFirst(t *testing.T) { + r := NewRing() + for i := 0; i < ringSize+5; i++ { + r.Push(&Record{Utterance: string(rune('a' + i))}) + } + got := r.Recent(ringSize + 10) + if len(got) != ringSize { + t.Fatalf("kept %d records, want %d", len(got), ringSize) + } + if got[0].Utterance != string(rune('a'+ringSize+4)) { + t.Errorf("newest = %q", got[0].Utterance) + } +} + +// A query source may fan out to goroutines of its own, so two of them noting at +// once must not race. Run under -race, which is where this earns its keep. +func TestConcurrentNotes(t *testing.T) { + ctx, rec := With(context.Background(), "x") + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + Note(ctx, Claim{Stage: StageQuery, Claimant: "fanout", Outcome: Declined}) + }() + } + wg.Wait() + if len(rec.Claims) != 8 { + t.Errorf("recorded %d claims, want 8", len(rec.Claims)) + } +} diff --git a/internal/decision/ring.go b/internal/decision/ring.go new file mode 100644 index 0000000..d1a3b64 --- /dev/null +++ b/internal/decision/ring.go @@ -0,0 +1,49 @@ +package decision + +import "sync" + +// ringSize is how many turns are kept. Turns arrive at human rate, not machine +// rate, so the whole store is memory: no migration, no insert on the answer +// path, and nothing of his words survives a restart. That is what makes this +// cheap enough to leave on always (V-564). Ecosystem traces went to SQLite +// because one act writes several hops and they must outlive the turn; an +// arbitration record is read minutes later or never. +const ringSize = 25 + +// Ring holds the newest records, newest first on read. +type Ring struct { + mu sync.Mutex + recs []*Record +} + +func NewRing() *Ring { return &Ring{} } + +// Push adds one finished record and drops the oldest past the bound. +func (r *Ring) Push(rec *Record) { + if r == nil || rec == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.recs = append(r.recs, rec) + if len(r.recs) > ringSize { + r.recs = r.recs[len(r.recs)-ringSize:] + } +} + +// Recent returns up to n records, newest first. +func (r *Ring) Recent(n int) []*Record { + if r == nil || n <= 0 { + return nil + } + r.mu.Lock() + defer r.mu.Unlock() + if n > len(r.recs) { + n = len(r.recs) + } + out := make([]*Record, 0, n) + for i := 0; i < n; i++ { + out = append(out, r.recs[len(r.recs)-1-i]) + } + return out +} diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 7025895..8128700 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -836,6 +836,30 @@ type TickTrace struct { Rules []RuleTrace `json:"rules"` } +// --- Turn decision trace DTOs (V-564) --- + +// TurnClaim — one claimant's say on one turn: who, at which stage, what it +// would have made the turn, the score it reported if it has one, and what +// happened to the claim. Same shape as RuleTrace above and for the same reason: +// a winner alone does not explain an arbitration, the losers do. +type TurnClaim struct { + Stage string `json:"stage"` + Claimant string `json:"claimant"` + Intent string `json:"intent,omitempty"` + Score float64 `json:"score,omitempty"` + HasScore bool `json:"has_score,omitempty"` + Outcome string `json:"outcome"` + Reason string `json:"reason,omitempty"` +} + +// TurnDecision — one turn's arbitration, newest first when read as a list. +type TurnDecision struct { + Ts time.Time `json:"ts"` + Utterance string `json:"utterance"` + Winner string `json:"winner"` + Claims []TurnClaim `json:"claims"` +} + // MorningRoutineItem — one checklist entry's current state. type MorningRoutineItem struct { Key string `json:"key"` diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 063ca68..ccba692 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -672,6 +672,14 @@ func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) { return t, nil } +func (c *Client) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) { + var d []TurnDecision + if err := c.call(ctx, MethodTurnDecisions, nReq{N: n}, &d); err != nil { + return nil, err + } + return d, nil +} + func (c *Client) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) { var e []IntakeEvent if err := c.call(ctx, MethodRecentEvents, nReq{N: n}, &e); err != nil { diff --git a/internal/ipc/coreapi.go b/internal/ipc/coreapi.go index 89464f3..d3d952d 100644 --- a/internal/ipc/coreapi.go +++ b/internal/ipc/coreapi.go @@ -169,6 +169,13 @@ type SystemAPI interface { // persisted — it's a daemon-level cache). TickTrace(ctx context.Context) (TickTrace, error) + // TurnDecisions returns the newest turn arbitration records, newest first + // (V-564). Same shape as TickTrace and RecentEvents: a bounded in-memory + // ring on the daemon, so the store adapter returns an error rather than + // pretending a table exists. Empty is a normal answer — it means no turn + // has run since the daemon started. + TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) + // RecentEcosystemTraces reads the ecosystem call log, which lives in its // own table so machine-rate traces never crowd out human-rate facts. RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error) diff --git a/internal/ipc/server.go b/internal/ipc/server.go index e11cbb0..b421aed 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -583,6 +583,13 @@ var methodTable = map[Method]handlerFunc{ MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) { return api.TickTrace(ctx) }), + MethodTurnDecisions: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]TurnDecision, error) { + d, err := api.TurnDecisions(ctx, p.N) + if d == nil { + d = []TurnDecision{} + } + return d, err + }), // MorningStatus intentionally has no nil→[]T{} normalization here — the // pre-table arm marshaled api.MorningStatus's result as-is (a nil slice // serializes as JSON null), and this preserves that exact wire shape. diff --git a/internal/ipc/storeapi.go b/internal/ipc/storeapi.go index 67e891b..6158d36 100644 --- a/internal/ipc/storeapi.go +++ b/internal/ipc/storeapi.go @@ -265,6 +265,12 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) { return TickTrace{}, errors.New("store: tick trace not available via direct store API") } +// TurnDecisions — same story as TickTrace: the arbitration record is a daemon +// ring, not a table, so there is nothing here to read it from (V-564). +func (a *storeAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) { + return nil, errors.New("store: turn decisions not available via direct store API") +} + // SeedEvent — same shape as MorningStatus: writing the fact is a store call, // but extraction and detect-and-propose live in mavend, and a seed that wrote // the fact without running them would be the one thing this seam must not be, diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go index 25d8a6a..69681cb 100644 --- a/internal/ipc/unimplemented.go +++ b/internal/ipc/unimplemented.go @@ -144,6 +144,9 @@ func (UnimplementedCoreAPI) RevertFact(ctx context.Context, key string) (int64, func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) { return TickTrace{}, ErrNotImplemented } +func (UnimplementedCoreAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) { + return nil, ErrNotImplemented +} func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) { return nil, ErrNotImplemented } diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index 137828d..f671316 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -48,6 +48,7 @@ const ( MethodAcceptProposedRoutine Method = "accept_proposed_routine" MethodRevertFact Method = "revert_fact" MethodTickTrace Method = "tick_trace" + MethodTurnDecisions Method = "turn_decisions" MethodMorningStatus Method = "morning_status" MethodMCPServers Method = "mcp_servers" MethodDayPlan Method = "day_plan" diff --git a/internal/router/decisiontrace.go b/internal/router/decisiontrace.go new file mode 100644 index 0000000..f66ac2a --- /dev/null +++ b/internal/router/decisiontrace.go @@ -0,0 +1,83 @@ +// router/decisiontrace.go — what the cascade tells the per-turn decision record. +// +// The cascade's arbitration is order (V-558): the first grammar whose Build +// agrees wins, and the model and the classifier are only reached because nobody +// upstream did. None of that is visible afterwards, so V-564 has each stage say +// its piece into the record riding the context. Nothing here reads the record +// back and nothing here can change a route — a nil recorder is the normal case +// in the fixture runner and every router test. +package router + +import ( + "context" + + "github.com/kami/maven/internal/decision" +) + +// The two routing engines, named as claimants. They are one stage and not two, +// because only one of them ever runs: the classifier is reached when the model +// is absent or errored, never alongside it. +const ( + claimantLLM = "llm-router" + claimantClassifier = "classifier" +) + +// thinReason names which arm of gateLLMDecision cut the confidence. The gate +// has three structural holes and they are three different defects, so "thinned" +// alone is not enough to act on. +func thinReason(d *Decision) string { + switch { + case d.Intent == IntentFact && !d.Slots.HasKey: + return "a fact with no key even after the parser tried" + case d.Intent == IntentAct && !d.Slots.HasFn: + return "an act that never resolved to an allowlisted fn" + case d.Intent == IntentReminder && !reminderHasSubject(d.Slots.Text): + return "a reminder with no subject to say at the hour" + default: + return "below the clarify threshold" + } +} + +// Reasons a stage-0 grammar did not take a turn. Kept apart because they are +// different defects: a pattern that never matched is a rule that does not know +// the shape, a Build that declined is a rule that knew the shape and refused +// the content (narrative-query and the wakeword acts do this by design), and a +// grammar after the winner was never consulted at all. +const ( + reasonNoMatch = "pattern did not match" + reasonBuildDeclmn = "matched the shape, Build declined the content" + reasonEarlierClaim = "an earlier grammar claimed the turn" +) + +// noteGrammarOutcomes records the stage-0 pass. examined is how many grammars +// were reached; declined holds the names whose Build said no; won is the winner +// or empty. Everything past the winner is named as never asked, because that +// silence is the thing the hardcoded order hides. +func (r *Router) noteGrammarOutcomes(ctx context.Context, examined int, declined map[int]bool, won string, intent Intent) { + rec := decision.From(ctx) + if rec == nil { + return + } + for i, g := range r.grammars { + switch { + case i >= examined: + rec.Note(decision.Claim{ + Stage: decision.StageZero, Claimant: g.Name, + Outcome: decision.NeverAsked, Reason: reasonEarlierClaim, + }) + case g.Name == won: + rec.Note(decision.Scored(decision.StageZero, g.Name, string(intent), 1.0, + decision.Won, "")) + case declined[i]: + rec.Note(decision.Claim{ + Stage: decision.StageZero, Claimant: g.Name, + Outcome: decision.Declined, Reason: reasonBuildDeclmn, + }) + default: + rec.Note(decision.Claim{ + Stage: decision.StageZero, Claimant: g.Name, + Outcome: decision.Declined, Reason: reasonNoMatch, + }) + } + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 68457e3..d8c8088 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -5,6 +5,8 @@ import ( "errors" "log" "time" + + "github.com/kami/maven/internal/decision" ) // Config — wires the cascade. Build via New; a zero-value Router is unusable. @@ -67,7 +69,11 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De // the STT often includes one (transcribed phonetically, any script) — try // the wake-stripped utterance too so those grammars still fire. stripped, hadWake := StripWakeToken(utterance) - for _, g := range r.grammars { + // declinedBuild — the grammars that matched the shape and refused the + // content, kept for the decision record (V-564) so a reader can tell that + // rule from one whose pattern never fired. + var declinedBuild map[int]bool + for i, g := range r.grammars { m := g.Pattern.FindStringSubmatch(utterance) if m == nil && hadWake { m = g.Pattern.FindStringSubmatch(stripped) @@ -77,11 +83,17 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De } d, ok := g.Build(m) if !ok { + if declinedBuild == nil { + declinedBuild = map[int]bool{} + } + declinedBuild[i] = true continue // grammar matched shape but not content → fall through } d.Utterance = utterance + r.noteGrammarOutcomes(ctx, i+1, declinedBuild, g.Name, d.Intent) return d, nil } + r.noteGrammarOutcomes(ctx, len(r.grammars), declinedBuild, "", "") // stage 1a — LLM router (when wired). It reasons over the utterance instead // of nearest-centroid guessing. On any error/parse-fail, fall through to the @@ -90,11 +102,38 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De if d, ok, err := r.llm.Route(ctx, utterance, now); err == nil && ok { d.Utterance = utterance r.fillSlots(ctx, &d, now) + before := d.Confidence r.gateLLMDecision(&d) + // The classifier is the floor and it never ran, which is the whole + // reason a wrong LLM route reads as unexplainable (V-564). + decision.Note(ctx, decision.Claim{ + Stage: decision.StageRoute, Claimant: claimantClassifier, + Outcome: decision.NeverAsked, Reason: "the LLM router answered", + }) + outcome, reason := decision.Won, "" + if d.Confidence < before { + outcome, reason = decision.Thinned, thinReason(&d) + } + decision.Note(ctx, decision.Scored(decision.StageRoute, claimantLLM, + string(d.Intent), d.Confidence, outcome, reason)) return d, nil } else if err != nil { log.Printf("router: llm route fell back to classifier: %v", err) + decision.Note(ctx, decision.Claim{ + Stage: decision.StageRoute, Claimant: claimantLLM, + Outcome: decision.Declined, Reason: "error: " + err.Error(), + }) + } else { + decision.Note(ctx, decision.Claim{ + Stage: decision.StageRoute, Claimant: claimantLLM, + Outcome: decision.Declined, Reason: "no parsable route in the reply", + }) } + } else { + decision.Note(ctx, decision.Claim{ + Stage: decision.StageRoute, Claimant: claimantLLM, + Outcome: decision.NeverAsked, Reason: "no LLM router is wired", + }) } // stage 1 — intent classifier. @@ -103,6 +142,16 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De return Decision{}, err } best := results[0] + // The runners-up are the interesting part: two intents a hundredth apart is + // a different defect from one that won outright (V-564). Two are enough to + // see that, and the rest of a seven-intent scoreboard is noise on the page. + if rec := decision.From(ctx); rec != nil { + for _, res := range results[1:min(len(results), 3)] { + rec.Note(decision.Scored(decision.StageRoute, claimantClassifier, + string(res.Intent), res.Score, decision.LostOnScore, + "lower similarity than "+string(best.Intent))) + } + } // stage 2 — slot extraction for the winning intent. d := Decision{ @@ -118,6 +167,12 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De d.Stage = 3 d.Clarify = true } + outcome, reason := decision.Won, "" + if d.Clarify { + outcome, reason = decision.Thinned, "below the clarify threshold, so she asks instead" + } + decision.Note(ctx, decision.Scored(decision.StageRoute, claimantClassifier, + string(d.Intent), d.Confidence, outcome, reason)) return d, nil }