// 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, } }