router: introduce typed ingress boundary and route producer observability

First behavior-preserving slice of the Maven redesign. Establishes
explicit ingress/routing boundaries and enough observability to refactor
later without changing current routing, action, clarification, or
execution semantics.

Types introduced:
- NormalizedInput (internal/router/source.go): Text + InputSource,
  the typed ingress boundary replacing raw string at the turn entry.
- InputSource (internal/router/source.go): channel provenance enum
  (tap:voice, tap:text). Reuses the existing turnSource distinction.
- RouteProducer (internal/router/intent.go): which cascade stage
  produced the decision (grammar, heads, llm, classifier).

Changes:
- Decision carries a Producer RouteProducer field, set at each cascade
  stage (grammar, heads, LLM, classifier).
- turnRoute carries NormalizedInput instead of bare text string.
- runTurn takes NormalizedInput instead of (text, src).
- decision.Record carries InputSource and RouteProducer for
  observability; RoutingTrace persists route_producer (migration #27).
- turnSource is now a type alias for router.InputSource.

Behavior preserved:
- Stage-0 grammars unchanged: same order, same matching, same confidence.
- Cascade fallthrough order unchanged (grammar → heads → llm → classifier).
- Clarification behavior unchanged.
- Action dispatch unchanged.
- No new linguistic normalization.
This commit is contained in:
2026-09-05 20:16:40 +04:00
parent 2f338a1ab6
commit 87a3b163e7
15 changed files with 109 additions and 42 deletions
+14 -3
View File
@@ -68,6 +68,16 @@ type Record struct {
Winner string `json:"winner"`
Claims []Claim `json:"claims"`
// InputSource — which channel the utterance arrived on (tap:voice or
// tap:text). Carried for observability so a trace can distinguish a voice
// turn from a text turn without re-deriving it from surrounding claims.
InputSource string `json:"input_source,omitempty"`
// RouteProducer — which cascade stage produced the routing decision.
// Carried for observability so a trace names the winning component directly
// rather than requiring a scan of the claims list.
RouteProducer string `json:"route_producer,omitempty"`
mu sync.Mutex
rosters []roster
}
@@ -152,9 +162,10 @@ func (r *Record) Finish(now time.Time) *Record {
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}
// the turn has answered. inputSource identifies the channel the utterance
// arrived on; pass empty if unknown.
func With(ctx context.Context, utterance string, inputSource string) (context.Context, *Record) {
rec := &Record{Utterance: utterance, InputSource: inputSource}
return context.WithValue(ctx, recorderKey{}, rec), rec
}
+16
View File
@@ -35,6 +35,18 @@ package router
import "time"
// RouteProducer — which stage of the cascade produced the routing decision.
// Recorded for observability so a trace can name the winning component without
// re-deriving it from the stage number and surrounding claims.
type RouteProducer string
const (
RouteProducerGrammar RouteProducer = "grammar"
RouteProducerHeads RouteProducer = "heads"
RouteProducerLLM RouteProducer = "llm"
RouteProducerClassifier RouteProducer = "classifier"
)
// Intent — the seven save-where labels from docs/design.md's routing table. The
// discriminator is "does the loop evaluate a predicate against it?":
//
@@ -105,6 +117,10 @@ type Decision struct {
Slots Slots
Clarify bool // stage 3: below threshold — ask, don't guess
// Producer — which cascade stage produced this decision. Recorded for
// observability so a trace can name the winning component directly.
Producer RouteProducer
// Source — where the answer lives, for a query. The second half of the
// route, and empty on every other intent. SourceUnknown means no decider
// named one and the daemon walks its whole chain, which is what shipped
+4
View File
@@ -97,6 +97,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
continue // grammar matched shape but not content → fall through
}
d.Utterance = utterance
d.Producer = RouteProducerGrammar
// A literal pattern named that destination, which is the one provenance
// allowed to take the personal boundary off a turn (V-666). Set here and
// nowhere else, so no other arm of the cascade can claim it.
@@ -138,6 +139,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
Confidence: res.Confidence,
Source: res.Source,
Clarify: res.Clarify,
Producer: RouteProducerHeads,
}
r.fillSlots(ctx, &d, now)
// The clarify head relearned the English assumption that one word
@@ -180,6 +182,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
if r.llm != nil {
if d, ok, err := r.llm.Route(ctx, utterance, now); err == nil && ok {
d.Utterance = utterance
d.Producer = RouteProducerLLM
r.fillSlots(ctx, &d, now)
before := d.Confidence
r.gateLLMDecision(&d)
@@ -239,6 +242,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
Intent: best.Intent,
Confidence: best.Score,
Slots: r.extractor.Extract(ctx, best.Intent, utterance, now),
Producer: RouteProducerClassifier,
}
// stage 3 — confidence gate. Below threshold ⇒ clarify, don't guess.
+23
View File
@@ -76,3 +76,26 @@ func ValidSource(s Source) bool {
}
return false
}
// InputSource — which channel this utterance arrived on. The same provenance
// vocabulary facts use (internal/event). Threaded through the turn because a
// turn can write a fact, and a fact that lies about where it came from is
// worse than no fact: provenance is the first column read when asking why a
// daemon-wide setting is the way it is.
type InputSource string
const (
// InputSourceVoice — a real microphone (PushToTalk).
InputSourceVoice InputSource = "tap:voice"
// InputSourceText — mavweb /api/chat, telegram, or any text entry point.
InputSourceText InputSource = "tap:text"
)
// NormalizedInput — the typed ingress boundary for a turn. Text is the raw
// utterance after STT (voice) or as typed (text). Source identifies the
// channel. This slice performs no new linguistic normalization: text and voice
// paths continue to converge onto the same turn path as they did before.
type NormalizedInput struct {
Text string
Source InputSource
}
+5
View File
@@ -385,6 +385,11 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
CREATE UNIQUE INDEX IF NOT EXISTS idx_digest_entries_live_candidate
ON digest_entries (rule, candidate_fingerprint)
WHERE status = 'pending' AND candidate_fingerprint <> '';`,
// #27 — route_producer on routing_traces. Records which cascade stage
// (grammar, heads, llm, classifier) produced the routing decision, so a
// trace can name the winning component directly.
`ALTER TABLE routing_traces ADD COLUMN route_producer TEXT NOT NULL DEFAULT '';`,
}
// migrate applies every migration with a number greater than the DB's current
+10 -7
View File
@@ -30,6 +30,9 @@ type RoutingTrace struct {
Source string `json:"source"`
Winner string `json:"winner"`
Intent string `json:"intent"`
// RouteProducer — which cascade stage produced the routing decision.
// grammar, heads, llm, or classifier. Empty on pre-route turns.
RouteProducer string `json:"route_producer,omitempty"`
// ClaimedBeforeHead — stage 0 or a pre-route resolver answered, so the turn
// teaches nothing about the classifier. It is a large share of real traffic,
// and counting those turns as training signal would fit the head to the
@@ -56,10 +59,10 @@ func (s *Store) WriteRoutingTrace(ctx context.Context, tr RoutingTrace) (int64,
}
res, err := s.db.ExecContext(ctx, `
INSERT INTO routing_traces
(ts, utterance, source, winner, intent, claimed_before_head, encoder_id, outcome, correction, claims)
VALUES (?,?,?,?,?,?,?,?,?,?)`,
(ts, utterance, source, winner, intent, route_producer, claimed_before_head, encoder_id, outcome, correction, claims)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
tr.Ts.UnixMilli(), tr.Utterance, tr.Source, tr.Winner, tr.Intent,
tr.ClaimedBeforeHead, tr.EncoderID, tr.Outcome, tr.Correction, claims)
tr.RouteProducer, tr.ClaimedBeforeHead, tr.EncoderID, tr.Outcome, tr.Correction, claims)
if err != nil {
return 0, fmt.Errorf("write routing trace: %w", err)
}
@@ -91,8 +94,8 @@ func (s *Store) PruneRoutingTraces(ctx context.Context, before time.Time) error
// RecentRoutingTraces returns the newest n turns, newest first.
func (s *Store) RecentRoutingTraces(ctx context.Context, n int) ([]RoutingTrace, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, ts, utterance, source, winner, intent, claimed_before_head,
encoder_id, outcome, correction, claims
SELECT id, ts, utterance, source, winner, intent, route_producer,
claimed_before_head, encoder_id, outcome, correction, claims
FROM routing_traces
ORDER BY id DESC
LIMIT ?`, n)
@@ -106,8 +109,8 @@ func (s *Store) RecentRoutingTraces(ctx context.Context, n int) ([]RoutingTrace,
var tsMilli int64
var claims string
if err := rows.Scan(&tr.ID, &tsMilli, &tr.Utterance, &tr.Source, &tr.Winner,
&tr.Intent, &tr.ClaimedBeforeHead, &tr.EncoderID, &tr.Outcome,
&tr.Correction, &claims); err != nil {
&tr.Intent, &tr.RouteProducer, &tr.ClaimedBeforeHead, &tr.EncoderID,
&tr.Outcome, &tr.Correction, &claims); err != nil {
return nil, err
}
tr.Ts = time.UnixMilli(tsMilli).UTC()