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 +}