Files
vikingowl 58beb7ce3c feat(router): classifier-source telemetry + router stats command
Phase 4 routing decisions depend on knowing whether the SLM classifier
is actually firing or whether the heuristic is silently doing all the
work. Adds the instrumentation to make that observable.

router.ClassifierSource enum (heuristic / slm / slm_fallback) is set
on Task by every classifier:
- HeuristicClassifier → ClassifierHeuristic
- slm.Classifier → ClassifierSLM on success, ClassifierSLMFallback when
  the SLM call fails or returns unparseable output

The source is plumbed through router.Outcome to QualityTracker, which
now maintains per-source counters alongside the existing per-arm × task
EMA scores. QualitySnapshot serializes both (classifier_counts is
omitempty for back-compat with pre-feature quality.json files).

lazyClassifier logs at INFO the first time it falls back to heuristic
because the SLM hasn't booted yet — distinguishes operational fallback
from an unconfigured-SLM run.

slm.Manager.Start() now records elapsed-to-healthy and the main.go
goroutine logs it as part of the "SLM ready" event. Confirms whether
short-lived runs are racing the boot cycle.

New `gnoma router stats` subcommand prints both tables (arm × task
quality, classifier source breakdown) from quality.json with a Phase 4
trust hint when the data is too sparse or the SLM share is low.

6 new tests cover ClassifierSource string/enum, heuristic + SLM source
propagation, QualityTracker counter round-trip, and back-compat
restore from a legacy quality.json without classifier_counts.
2026-05-19 18:18:22 +02:00

101 lines
2.8 KiB
Go

package router
// QualitySnapshot is the serializable form of QualityTracker EMA data plus
// classifier-source counts. The ClassifierCounts field is omitted when empty
// for backward compatibility with older quality.json files.
type QualitySnapshot struct {
Scores map[string]map[string]*EMAScore `json:"scores"` // ArmID -> TaskType.String() -> score
ClassifierCounts map[string]int `json:"classifier_counts,omitempty"`
}
// Snapshot returns a serializable copy of current quality data.
func (qt *QualityTracker) Snapshot() QualitySnapshot {
qt.mu.RLock()
defer qt.mu.RUnlock()
snap := QualitySnapshot{
Scores: make(map[string]map[string]*EMAScore),
}
for armID, tasks := range qt.scores {
snap.Scores[string(armID)] = make(map[string]*EMAScore)
for taskType, score := range tasks {
snap.Scores[string(armID)][taskType.String()] = &EMAScore{
Value: score.Value,
Count: score.Count,
}
}
}
if len(qt.classifierCount) > 0 {
snap.ClassifierCounts = make(map[string]int, len(qt.classifierCount))
for src, n := range qt.classifierCount {
snap.ClassifierCounts[src.String()] = n
}
}
return snap
}
// Restore loads previously persisted quality data. Replaces all current
// scores and classifier counts. Missing classifier_counts field (old files)
// initialises to empty.
func (qt *QualityTracker) Restore(snap QualitySnapshot) {
qt.mu.Lock()
defer qt.mu.Unlock()
qt.scores = make(map[ArmID]map[TaskType]*EMAScore)
for armID, tasks := range snap.Scores {
qt.scores[ArmID(armID)] = make(map[TaskType]*EMAScore)
for taskTypeStr, score := range tasks {
taskType, ok := parseTaskType(taskTypeStr)
if !ok {
continue // skip unrecognized task types
}
qt.scores[ArmID(armID)][taskType] = &EMAScore{
Value: score.Value,
Count: score.Count,
}
}
}
qt.classifierCount = make(map[ClassifierSource]int)
for srcStr, n := range snap.ClassifierCounts {
if src, ok := parseClassifierSource(srcStr); ok {
qt.classifierCount[src] = n
}
}
}
// parseClassifierSource is the inverse of ClassifierSource.String().
func parseClassifierSource(s string) (ClassifierSource, bool) {
switch s {
case "heuristic":
return ClassifierHeuristic, true
case "slm":
return ClassifierSLM, true
case "slm_fallback":
return ClassifierSLMFallback, true
default:
return ClassifierUnknown, false
}
}
// parseTaskType maps a TaskType string representation back to its constant.
func parseTaskType(s string) (TaskType, bool) {
for _, tt := range allTaskTypes {
if tt.String() == s {
return tt, true
}
}
return 0, false
}
// allTaskTypes lists every known TaskType constant for reverse lookup.
var allTaskTypes = []TaskType{
TaskBoilerplate,
TaskGeneration,
TaskRefactor,
TaskReview,
TaskUnitTest,
TaskPlanning,
TaskOrchestration,
TaskSecurityReview,
TaskDebug,
TaskExplain,
}