package router import ( "context" "encoding/json" "fmt" "math" "os" "path/filepath" "sync" ort "github.com/yalue/onnxruntime_go" ) // The routing heads (V-546, V-661, V-664). Four linear heads over one masked // mean pool of a fine-tuned copy of multilingual-e5-small: intent, // destination, BIO slot tags and clarify. Trained on workpc, exported to ONNX, // and read here. // // Why this is not the classifier. The classifier compares one utterance to // frozen seed phrases by cosine. A head is a softmax over the label set, so it // cannot name a value that does not exist, and its max is a calibratable // confidence where Confidence: 1.0 was a hardcode. // // Why it is not the resident model either. It answers in single-digit // milliseconds against the model's p50 of 1.19s, and it names a destination // the classifier arm never names at all. // // The body is a COPY of the embedder weights, fine-tuned. It must never // replace models/embedder/multilingual-e5-small — memory recall depends on // that file scoring what it scored. // // The slot head is exported and deliberately not read. Slots already come from // the stage-2 extractor, and mapping BIO tags back to text needs character // offsets the unigram tokenizer does not keep. Reading it is separate work. const ( // headsSeq — the sequence length the heads were trained at. Padding is // masked out of both attention and the pool, so this changes nothing but // truncation, and truncation is what training did at 64. headsSeq = 64 // headsThreshold — max softmax over the intent head, below which the heads // decline and the cascade carries on to the resident model. // // 0.6 is the knee measured on the 88-case intent fixture // (docs/evals/2026-08-08-routing-heads-in-go.md). It keeps 81 of 88 cases // at 97.5% accuracy. Every higher value up to 0.9 drops right answers and // keeps the same two wrong ones, so it buys nothing. headsThreshold = 0.6 ) // RouterHeads runs the exported graph. Nil is a working value everywhere: a // deployment with no weights file routes exactly as it did before this // existed. type RouterHeads struct { tokenizer *unigramTokenizer session *ort.DynamicSession[int64, float32] runtime *ONNXRuntimeLease intents []Intent sources []Source threshold float64 closeOnce sync.Once closeErr error } // headsMeta — router_heads.json, written beside the weights by the exporter. // The label order is the head's output order and cannot be inferred from Go. type headsMeta struct { Intents []string `json:"intents"` Sources []string `json:"sources"` Prefix string `json:"prefix"` } // NewRouterHeads loads the graph and its label order. modelPath points at the // .onnx; the external weights and router_heads.json sit beside it. // // It shares the process-global ONNX environment with the embedder. The runtime // lease is independent so shutdown order cannot unload the library while this // graph's session is still alive. func NewRouterHeads(modelPath, tokenizerPath string) (*RouterHeads, error) { metaPath := filepath.Join(filepath.Dir(modelPath), "router_heads.json") raw, err := os.ReadFile(metaPath) if err != nil { return nil, fmt.Errorf("heads: read %s: %w", metaPath, err) } var meta headsMeta if err := json.Unmarshal(raw, &meta); err != nil { return nil, fmt.Errorf("heads: parse %s: %w", metaPath, err) } if meta.Prefix != queryPrefix { return nil, fmt.Errorf("heads: trained with prefix %q, this build uses %q", meta.Prefix, queryPrefix) } intents := make([]Intent, len(meta.Intents)) for i, s := range meta.Intents { intents[i] = Intent(s) } sources := make([]Source, len(meta.Sources)) for i, s := range meta.Sources { // SourceUnknown is not in Sources, because it is the absence of a // choice. It is a class the head can emit, and the one it should emit // often, so it is allowed here and nowhere else. if s != string(SourceUnknown) && !ValidSource(Source(s)) { return nil, fmt.Errorf("heads: unknown destination %q in %s", s, metaPath) } sources[i] = Source(s) } tok, err := newUnigramTokenizer(tokenizerPath) if err != nil { return nil, fmt.Errorf("heads: tokenizer: %w", err) } runtime, err := AcquireONNXRuntime("") if err != nil { return nil, fmt.Errorf("heads: %w", err) } session, err := ort.NewDynamicSession[int64, float32]( modelPath, []string{"input_ids", "attention_mask"}, []string{"intent", "source", "slots", "clarify"}, ) if err != nil { _ = runtime.Close() return nil, fmt.Errorf("heads: create session: %w", err) } return &RouterHeads{ tokenizer: tok, session: session, runtime: runtime, intents: intents, sources: sources, threshold: headsThreshold, }, nil } func (h *RouterHeads) Close() error { if h == nil { return nil } h.closeOnce.Do(func() { if h.session != nil { h.session.Destroy() h.session = nil } h.closeErr = h.runtime.Close() }) return h.closeErr } // headsResult — one forward pass, read back. type headsResult struct { Intent Intent Source Source Confidence float64 Clarify bool } // Route runs the heads and reports whether they are confident enough to answer. // A false second return is a decline, not an error: the cascade goes on to the // resident model, which is what happens today. func (h *RouterHeads) Route(ctx context.Context, utterance string) (headsResult, bool, error) { if h == nil { return headsResult{}, false, nil } ids, mask, _ := h.tokenizer.Encode(queryPrefix + utterance) ids, mask = ids[:headsSeq], mask[:headsSeq] // The tokenizer pads and truncates to its own length, which is longer than // this one. Cutting the tail can cut the separator with it, so put it back. if mask[headsSeq-1] == 1 { ids[headsSeq-1] = sepTokenID } shape := ort.NewShape(1, headsSeq) idsT, err := ort.NewTensor(shape, ids) if err != nil { return headsResult{}, false, fmt.Errorf("heads: ids tensor: %w", err) } defer idsT.Destroy() maskT, err := ort.NewTensor(shape, mask) if err != nil { return headsResult{}, false, fmt.Errorf("heads: mask tensor: %w", err) } defer maskT.Destroy() intentT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, int64(len(h.intents)))) if err != nil { return headsResult{}, false, fmt.Errorf("heads: intent tensor: %w", err) } defer intentT.Destroy() sourceT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, int64(len(h.sources)))) if err != nil { return headsResult{}, false, fmt.Errorf("heads: source tensor: %w", err) } defer sourceT.Destroy() slotsT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, headsSeq, int64(numBIOTags))) if err != nil { return headsResult{}, false, fmt.Errorf("heads: slots tensor: %w", err) } defer slotsT.Destroy() clarifyT, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 2)) if err != nil { return headsResult{}, false, fmt.Errorf("heads: clarify tensor: %w", err) } defer clarifyT.Destroy() if err := h.session.Run( []*ort.Tensor[int64]{idsT, maskT}, []*ort.Tensor[float32]{intentT, sourceT, slotsT, clarifyT}, ); err != nil { return headsResult{}, false, fmt.Errorf("heads: run: %w", err) } // The graph applies its own softmax, so these are probabilities and the max // is the same number the eval calibrated the threshold against. i, conf := argmax(intentT.GetData()) res := headsResult{ Intent: h.intents[i], Confidence: conf, } cl := clarifyT.GetData() res.Clarify = len(cl) == 2 && cl[1] > cl[0] // The destination head is trained on query rows and is meaningless on any // other intent, the same way queryWalk is never reached by one. if res.Intent == IntentQuery { s, _ := argmax(sourceT.GetData()) res.Source = h.sources[s] } // The clarify head decides on its own, and it decides first. It answers a // different question from the intent head — not which intent, but whether // there is enough here to act on at all — so a low intent confidence is no // reason to discard it. It is usually the same turns: "вода" reads as // intent act at 0.23 and clarify at 0.98, and letting the intent threshold // bury that hands the turn to the classifier, which routes it confidently // and never asks. if res.Clarify { return res, true, nil } if conf < h.threshold { return res, false, nil } return res, true, nil } // numBIOTags — O plus B- and I- for each of Maven's five slots. The head is not // read, but the graph writes it and the output tensor has to be the right size. const numBIOTags = 11 func argmax(v []float32) (int, float64) { best, bestV := 0, math.Inf(-1) for i, x := range v { if float64(x) > bestV { best, bestV = i, float64(x) } } return best, bestV }