From 0558dfed0fa7e351d424d4bb18018ad9cd99ab5b Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:52:19 +0400 Subject: [PATCH 1/8] a turn record holds every claim, not only the winner (V-564) Arbitration between the claimants on the utterance stream is order, hardcoded in three places, and a log that names the winner cannot explain a loss. The new package holds one record per turn: who claimed, what it would have made the turn, the score it reported, and why the rest did not get it. Being explicit that a claimant was never asked is the point: that silence is what the hardcoded ordering hides. The record rides the context, the seam querysource.go already uses, so no claim site can change a route and a context with no record costs nothing. The ring is memory and bounded: a turn record is read minutes later or never, and his words do not belong in a table that outlives the diagnosis. --- internal/decision/decision.go | 185 ++++++++++++++++++++++++++++++++++ internal/decision/ring.go | 49 +++++++++ 2 files changed, 234 insertions(+) create mode 100644 internal/decision/decision.go create mode 100644 internal/decision/ring.go 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/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 +} From b56e0e6248f53fe3e2932ca16341e0dc134920f6 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:52:28 +0400 Subject: [PATCH 2/8] the record's own tests: never-asked, bounds, fan-out (V-564) --- internal/decision/decision_test.go | 98 ++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 internal/decision/decision_test.go 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)) + } +} From 5417692566df53048ef4a97d311818e80f6df4dd Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:52:28 +0400 Subject: [PATCH 3/8] the cascade says which grammar declined and which never ran (V-564) Stage 0 records every grammar it reached, keeping a pattern that never matched apart from a Build that refused the content, and names the ones after the winner as never asked. The routing arm records the classifier's runners-up and which arm of gateLLMDecision cut the confidence, because thinned alone is not enough to act on. --- internal/router/decisiontrace.go | 83 ++++++++++++++++++++++++++++++++ internal/router/router.go | 57 +++++++++++++++++++++- 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 internal/router/decisiontrace.go 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 } From 5ac7347c38a2c344d05ff5ffa391b74d1f5c96d4 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:52:39 +0400 Subject: [PATCH 4/8] the resolver ladder and the query chain report their claims (V-564) The two claimant sets that live in the daemon are where the arbitration is least visible: both are a hardcoded order of functions that each answer 'is this mine?' alone. The ladder declares its roster up front, so a rung that never ran is named rather than omitted, and the query chain does the same for the sources below the one that claimed. Recording is installed in runTurn and not in the IPC entry point, so the mic, telegram and the web leave the same trail. A record only the web produced would be missing exactly the turns that are hardest to reproduce. --- cmd/mavend/actions_query.go | 18 +++++ cmd/mavend/decisiontrace.go | 132 ++++++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 43 +++++++++--- cmd/mavend/voicewire.go | 7 +- 4 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 cmd/mavend/decisiontrace.go 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/voice.go b/cmd/mavend/voice.go index fe12f19..3a20399 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) @@ -374,6 +396,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) } } @@ -390,6 +414,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 520d300..f3ef494 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{}}, From 3e6a427e85df1088dd16e932b3e602b635caadd9 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:52:48 +0400 Subject: [PATCH 5/8] a turn names its winner, its losers, and who never looked (V-564) --- cmd/mavend/decisiontrace_test.go | 163 +++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 cmd/mavend/decisiontrace_test.go 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) + } +} From a5b245dbf51a9adbc82a33019a58cfefb295ab5e Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:52:48 +0400 Subject: [PATCH 6/8] the ring reads out over ipc as turn decisions (V-564) Same shape as TickTrace and RecentEvents: a bounded daemon ring, so the store adapter refuses rather than pretending a table exists. No voice wiring means an empty list and not an error, because a box with no voice path has had no turns to arbitrate. --- cmd/mavend/main.go | 2 ++ cmd/mavend/tick_api.go | 12 ++++++++++++ internal/ipc/api.go | 24 ++++++++++++++++++++++++ internal/ipc/client.go | 8 ++++++++ internal/ipc/coreapi.go | 7 +++++++ internal/ipc/server.go | 7 +++++++ internal/ipc/storeapi.go | 6 ++++++ internal/ipc/unimplemented.go | 3 +++ internal/ipc/wire.go | 1 + 9 files changed, 70 insertions(+) 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/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" From eec3d9bed25cee8ca6d8ad80933e81d0a9b78720 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:52:58 +0400 Subject: [PATCH 7/8] /trace grows a turn-decisions table under the rule trace (V-564) Both tables answer the same question, who won and who lost, one about nudges and the other about utterances, so they share a page rather than splitting the nav. A turn is one collapsible row; never_asked is coloured like a block, because it usually is one. A read failure is logged and the rule trace above it still renders: a daemon too old to know the method is the ordinary case during a rolling deploy. --- cmd/mavweb/handlers_test.go | 40 +++++++++++++++++++++++++++++++++++++ cmd/mavweb/main.go | 19 +++++++++++++++++- cmd/mavweb/trace.html | 24 ++++++++++++++++++++-- 3 files changed, 80 insertions(+), 3 deletions(-) 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"}} From cf28f6fdf0401b51c332b6d0f34c47ead6ffcb53 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:52:59 +0400 Subject: [PATCH 8/8] qa reads /trace for the query chain again (V-564) --- CLAUDE.md | 16 ++++++++++++++++ docs/qa.md | 14 +++++++++----- 2 files changed, 25 insertions(+), 5 deletions(-) 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/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