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 }