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.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user