initial commit
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user