Files
Maven/internal/router/classifier.go
2026-07-03 00:32:48 +02:00

161 lines
5.0 KiB
Go

package router
import (
"context"
"errors"
"math"
"sort"
)
// ErrNoIntents — the classifier has no seeded examples; cannot classify.
// The daemon seeds ~10 examples/intent at bootstrap (per spec). Until then
// every free-form utterance routes to clarify-or-ask, never to a guess.
var ErrNoIntents = errors.New("router: no intents seeded")
// Example — one labeled utterance + its embedding. The classifier is
// append-only: misroute correction = AddExample for the corrected intent
// (per spec — "append-only, grows the classifier as used. more reliable over
// time, no retrain"). The note stays as provenance; the router never silently
// rewires a label.
type Example struct {
Text string
Vec []float32
}
// Result — one scored intent from Classify. Score is cosine similarity in
// [-1,1]; higher = closer to that intent's centroid.
type Result struct {
Intent Intent
Score float64
}
// Classifier — nearest-centroid over labeled intents. One forward pass (the
// embed) yields a vector; cosine similarity to each intent's centroid (mean
// of its examples) gives a score; max wins. ~10 examples/intent is the spec's
// bootstrap target.
//
// Pure given the Embedder: the only I/O is the embed call itself. Centroid
// math is deterministic and unit-testable with a fake embedder. The cascade
// (router.go) applies the confidence threshold; the classifier just scores.
type Classifier struct {
embedder Embedder
examples map[Intent][]Example
centroids map[Intent][]float32
dim int
}
func NewClassifier(e Embedder) *Classifier {
return &Classifier{
embedder: e,
examples: make(map[Intent][]Example),
centroids: make(map[Intent][]float32),
dim: e.Dim(),
}
}
// AddExample — appends a labeled example and recomputes that intent's centroid.
// Misroute correction calls this with the corrected intent. Production path.
func (c *Classifier) AddExample(ctx context.Context, intent Intent, text string) error {
vec, err := c.embedder.Embed(ctx, text)
if err != nil {
return err
}
c.addVec(intent, text, vec)
return nil
}
// AddExampleVec — for tests that want to skip the embedder (inject vectors
// directly). Keeps the classifier pure under a fake embedder without re-running
// the hash. Not used by the production cascade.
func (c *Classifier) AddExampleVec(intent Intent, text string, vec []float32) {
c.addVec(intent, text, vec)
}
func (c *Classifier) addVec(intent Intent, text string, vec []float32) {
c.examples[intent] = append(c.examples[intent], Example{Text: text, Vec: vec})
c.centroids[intent] = meanVec(c.examples[intent])
}
// Examples — read-only view of seeded examples per intent. Introspectable: the
// daemon surfaces "what maven has been taught" through an authed surface.
func (c *Classifier) Examples(intent Intent) []Example {
return c.examples[intent]
}
// Intents — the set of intents with at least one seeded example.
func (c *Classifier) Intents() []Intent {
out := make([]Intent, 0, len(c.centroids))
for k := range c.centroids {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
// Classify — embeds the utterance and returns all intents scored by cosine
// similarity to their centroid, sorted best-first (ties broken by intent asc
// for determinism). The caller applies the confidence threshold (stage 3).
// Returns ErrNoIntents if no examples have been seeded.
func (c *Classifier) Classify(ctx context.Context, utterance string) ([]Result, error) {
if len(c.centroids) == 0 {
return nil, ErrNoIntents
}
vec, err := c.embedder.Embed(ctx, utterance)
if err != nil {
return nil, err
}
results := make([]Result, 0, len(c.centroids))
for intent, centroid := range c.centroids {
results = append(results, Result{Intent: intent, Score: cosine(vec, centroid)})
}
sort.Slice(results, func(i, j int) bool {
if results[i].Score != results[j].Score {
return results[i].Score > results[j].Score
}
return results[i].Intent < results[j].Intent
})
return results, nil
}
// meanVec — L2-normalized mean of a set of example vectors. Normalizing the
// centroid keeps cosine = dot product against normalized query vectors, and
// stops high-example-count intents from dominating purely by magnitude.
func meanVec(exs []Example) []float32 {
if len(exs) == 0 {
return nil
}
m := make([]float32, len(exs[0].Vec))
for _, e := range exs {
for i, x := range e.Vec {
m[i] += x
}
}
var sum float64
for i := range m {
m[i] /= float32(len(exs))
sum += float64(m[i]) * float64(m[i])
}
if sum == 0 {
return m
}
inv := float32(1.0 / math.Sqrt(sum))
for i := range m {
m[i] *= inv
}
return m
}
// cosine — both inputs are L2-normalized ⇒ dot product == cosine similarity.
// Mismatched/empty dimensions return 0 (no signal), which the threshold gate
// turns into clarify — never into a confident wrong write.
func cosine(a, b []float32) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var dot float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
}
return dot
}